فهرست منبع

独立 Bot 编辑资料并增加按机器人审批策略

Samien 1 ماه پیش
والد
کامیت
52f0743357

+ 15 - 1
design-qa.md

@@ -4,7 +4,7 @@
 
 - 当前无未解决的 P0、P1 或 P2 问题。
 - P3:项目成员使用产品现有的真实头像组件,示例图中的纯色圆点与 +1 占位没有照搬。所有成员姓名可以查看。
-- P3:保留原有管理与基础机器人功能,超出设计图显示范围的分类可在左栏滚动访问。
+- P3:普通聊天保留管理与基础机器人功能;Bot 模式的基础机器人配置已按后续批注移入“编辑资料”。
 
 ## Source visual truth
 
@@ -71,3 +71,17 @@
 - [x] 34 项相关组件测试通过,类型检查通过。
 
 final result: passed
+
+## 2026-09-15 后续批注:Bot 编辑资料与权限审批
+
+- Bot 列表右键菜单使用带图标的“编辑资料”,打开独立双栏弹窗。资料始终绑定菜单选中的 Bot,保存不切换当前单聊。
+- Bot 模式“我的设置”移除基础机器人选择器、身份与头像、模型与 Provider;普通聊天保留原入口。
+- 编辑资料包含概览、身份与头像、角色与规则、模型与 Provider、记忆和权限。模型管理复用管理员界面,绑定 Bot 已保存的本地 Profile;更换来源需要先保存。远端模型仍由远端管理。
+- 权限审批按 Bot 保存:询问我(默认)、自动允许、自动拒绝。只答复新出现的工具审批请求;不自动回答澄清,不追溯处理已等待的请求,不建立永久或整个会话的自动授权。
+- 同一 Profile 的 Bot 策略互不影响,旧记录按询问处理,自动创建的成员不能自行获得自动允许。远端引用可以设置本地审批策略,远端资料继续只读。
+- 自动审批复用现有账号、任务、会话身份校验和重复答复保护;失败保留待处理请求,重复事件不触发自动重试。
+- 桌面审批页:`docs/design/bot-approval-desktop.jpg`,1280 × 720。手机概览:`docs/design/bot-profile-mobile.jpg`,390 × 844。截图是实际组件的 CUA 原始捕获。
+- 手机初查发现导航最小宽度撑开内容区,已改为可收缩网格;修正后内容区 clientWidth=scrollWidth=380,侧栏宽 390,无内容横向裁切。
+- 资料、模型、记忆的未保存更改均受到关闭与切换保护;手机导航支持横向滚动,内容和底部保存操作分别定位。
+- 测试覆盖目标 Bot 保存、普通聊天设置保留、模型 Profile 隔离、远端审批、旧记录默认值、并发独立策略、重复请求、澄清和自动审批失败。
+- 最终验证:11 个测试文件共 167 项通过;补充的基础机器人显示名回归测试通过。类型检查与桌面服务构建通过。

BIN
docs/design/bot-approval-desktop.jpg


BIN
docs/design/bot-profile-mobile.jpg


+ 6 - 6
src/client/components/app/SettingsCenterDialog.vue

@@ -98,7 +98,7 @@ const accountCanSave = ref(false)
 const dirtyPages = reactive<Partial<Record<SettingsPage, boolean>>>({})
 const settingsQuery = ref('')
 
-const agentItems = computed<NavigationItem[]>(() => !props.isAdmin ? [] : [
+const agentItems = computed<NavigationItem[]>(() => !props.isAdmin || props.botMode ? [] : [
   { key: 'agent-identity', label: '身份与头像', icon: 'users' },
   ...(props.isAdmin ? [{ key: 'agent-models', label: '模型与 Provider', icon: 'model' } satisfies NavigationItem] : []),
 ])
@@ -147,7 +147,7 @@ const activeTitle = computed(() => ({
   'system-update': '更新与回滚',
 })[activePage.value])
 const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
-const showAgentSelector = computed(() => activePage.value.startsWith('agent-'))
+const showAgentSelector = computed(() => !props.botMode && activePage.value.startsWith('agent-'))
 const activeScope = computed(() => {
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value === 'account-profile') return '管理你的账号头像与服务器名称。'
@@ -255,7 +255,7 @@ function trapFocus(event: KeyboardEvent) {
   else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
 }
 
-watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
+watch(() => [props.open, props.initialPage, props.botMode, props.isAdmin] as const, ([open, initialPage]) => {
   if (!open) {
     profileMenuOpen.value = false
     updateLocked.value = false
@@ -263,7 +263,7 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
   }
   activePage.value = allAllowedPages.value.has(initialPage)
     ? initialPage
-    : props.isAdmin && props.activeProfile ? 'agent-identity' : 'account-profile'
+    : !props.botMode && props.isAdmin && props.activeProfile ? 'agent-identity' : 'account-profile'
   settingsQuery.value = ''
   for (const key of Object.keys(dirtyPages) as SettingsPage[]) dirtyPages[key] = false
   profileMenuOpen.value = false
@@ -341,7 +341,7 @@ function requestModeSwitch() {
               </header>
               <div class="settings-content__scroll">
                 <AgentIdentityPanel
-                  v-if="activePage === 'agent-identity' && activeProfile"
+                  v-if="activePage === 'agent-identity' && activeProfile && !botMode"
                   :key="activeProfile.name"
                   :profile="activeProfile"
                   :busy="identityBusy"
@@ -353,7 +353,7 @@ function requestModeSwitch() {
                   @save="emit('save-identity', $event)"
                 />
                 <p v-else-if="activePage === 'agent-identity'" class="settings-empty">尚未选择机器人。</p>
-                <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
+                <ModelServicesPanel v-else-if="activePage === 'agent-models' && activeProfile && isAdmin && !botMode" :key="activeProfile.name" :profile="activeProfile.name" @dirty-change="setDirty('agent-models', $event)" />
                 <AccountSecurityPanel
                   v-else-if="activePage === 'account-security' || activePage === 'account-profile'"
                   :key="activePage"

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 104 - 0
src/client/components/workspace/BotProfileDialog.vue


+ 3 - 1
src/client/components/workspace/ConversationList.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
+import AppIcon from '@/components/common/AppIcon.vue'
 import { workspaceAgentActivity, workspaceConversationItem } from './viewModels'
 import type { WorkspaceAgent, WorkspaceConversation } from '@shared/workspace'
 const props = withDefaults(defineProps<{ conversations: WorkspaceConversation[]; agents?: WorkspaceAgent[]; selected?: string }>(), { agents: () => [] })
@@ -33,7 +34,7 @@ function openSettings(){const id=menuId.value;menuId.value='';emit('settings',id
     <Teleport to="body">
       <div v-if="menuConversation" class="conversation-menu-dismiss" @pointerdown.self="menuId = ''" @keydown.esc="menuId = ''">
         <section class="conversation-actions" :style="menuPosition" role="menu" aria-label="聊天操作">
-          <button role="menuitem" @click="openSettings">{{ menuConversation.kind === 'direct' ? '机器人设置' : '群聊设置' }}</button>
+          <button type="button" role="menuitem" @click="openSettings"><AppIcon :name="menuConversation.kind === 'direct' ? 'edit' : 'settings'" :size="16"/>{{ menuConversation.kind === 'direct' ? '编辑资料' : '群聊设置' }}</button>
           <button role="menuitem" @click="action('pin')">{{ menuConversation.pinned ? '取消置顶' : '置顶聊天' }}</button>
           <button role="menuitem" @click="action('archive')">{{ menuConversation.archived ? '恢复聊天' : '归档聊天' }}</button>
           <button class="danger" role="menuitem" @click="action('delete')">删除聊天</button>
@@ -55,4 +56,5 @@ function openSettings(){const id=menuId.value;menuId.value='';emit('settings',id
 .conversation-list :deep(.sidebar-item__row small){font-size:10px;color:var(--text-muted)}
 .conversation-menu-dismiss{position:fixed;inset:0;z-index:200}.conversation-actions{position:absolute;display:grid;min-width:155px;padding:5px;border:1px solid var(--line);border-radius:10px;background:var(--surface-raised);box-shadow:var(--shadow-float)}.conversation-actions button{padding:9px 12px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer;font-size:12px}.conversation-actions button:hover{background:var(--surface-hover)}
 .conversation-actions button.danger{color:var(--danger)}
+.conversation-actions button{display:flex;align-items:center;gap:8px;min-height:36px}.conversation-actions button:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 </style>

+ 6 - 5
src/client/components/workspace/WorkspaceKnowledgePanel.vue

@@ -8,8 +8,8 @@ import { ChevronForwardOutline } from '@vicons/ionicons5'
 import type { WorkspaceAgent, WorkspaceConversation } from '@shared/workspace'
 import type { MemoryScope, WorkspaceMemory, WorkspaceMemoryJob, WorkspaceMemoryRevision, WorkspacePeerMessage, WorkspaceProject } from '@shared/workspaceKnowledge'
 
-const props = defineProps<{ agents: WorkspaceAgent[]; conversations: WorkspaceConversation[]; conversationId?: string; revision?: number; embedded?: boolean; initialTab?: 'projects' | 'user' }>()
-const emit = defineEmits<{ changed: [] }>()
+const props = defineProps<{ agents: WorkspaceAgent[]; conversations: WorkspaceConversation[]; conversationId?: string; revision?: number; embedded?: boolean; initialTab?: 'projects' | 'user' | 'agent'; selectedAgentId?: string; hideAgentControls?: boolean }>()
+const emit = defineEmits<{ changed: []; 'dirty-change': [dirty: boolean] }>()
 const dialog = ref<HTMLDialogElement>(), opened = ref(false), busy = ref(false), error = ref(''), tab = ref('agent'), agentId = ref(''), projectId = ref(''), search = ref('')
 const projects = ref<WorkspaceProject[]>([]), memories = ref<WorkspaceMemory[]>([]), peers = ref<WorkspacePeerMessage[]>([]), jobs = ref<WorkspaceMemoryJob[]>([]), revisions = ref<WorkspaceMemoryRevision[]>([])
 const draft = ref<{ id?: string; expectedRevision?: number; agentId: string; content: string; tier: WorkspaceMemory['tier'] }>()
@@ -90,9 +90,10 @@ async function setAgent(field: 'canCollaborate' | 'memoryEnabled', enabled: bool
   await perform(async () => { await apiRequest(`/api/app/agents/${agentId.value}`, { method: 'PATCH', body: { [field]: enabled } }) })
 }
 onBeforeUnmount(() => { generation++; clearInterval(timer) })
-onMounted(() => { if (props.embedded) void open(props.initialTab ?? 'projects') })
+onMounted(() => { if (props.embedded) void open(props.initialTab ?? 'projects', props.selectedAgentId) })
 watch(() => props.initialTab, value => { if (props.embedded && value) switchTab(value) })
 watch(() => props.revision, () => { if (opened.value && !busy.value && !draft.value && !projectDraft.value) void load(true) })
+watch(() => !!draft.value || !!projectDraft.value, value => emit('dirty-change', value))
 defineExpose({ open, close })
 </script>
 
@@ -136,12 +137,12 @@ defineExpose({ open, close })
       </template>
       <template v-else>
         <div class="filters">
-          <label v-if="tab === 'agent'">Bot<select v-model="agentId" @change="draft = undefined; load()"><option v-for="agent in available" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label>
+          <label v-if="tab === 'agent' && !selectedAgentId">Bot<select v-model="agentId" @change="draft = undefined; load()"><option v-for="agent in available" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label>
           <label v-if="tab === 'project'">项目<select v-model="projectId" @change="draft = undefined; load()"><option v-for="project in projects" :key="project.id" :value="project.id">{{ project.name }}</option></select></label>
           <form @submit.prevent="load()"><label>搜索记忆<input v-model="search" type="search" placeholder="搜索事实、偏好或决策" /></label><button type="submit">搜索</button></form>
         </div>
         <p class="scope-note">{{ tab === 'agent' ? '仅此 Bot 使用,可跨单聊和群聊延续。' : tab === 'user' ? '同一账号的 Bot 共享,按贡献者保存。' : `仅「${currentProject?.name ?? '当前项目'}」成员读取。` }}</p>
-        <template v-if="tab === 'agent' && currentAgent"><label class="check"><input type="checkbox" :checked="currentAgent.memoryEnabled !== false" :disabled="busy" @change="setAgent('memoryEnabled', ($event.target as HTMLInputElement).checked)" />自动记录长期事实</label><p v-if="currentAgent.memoryStatus === 'upgrade_required'" role="status">执行节点需要升级后才能启用隔离记忆;已保存的文件仍可管理。</p></template>
+        <template v-if="tab === 'agent' && currentAgent"><label v-if="!hideAgentControls" class="check"><input type="checkbox" :checked="currentAgent.memoryEnabled !== false" :disabled="busy" @change="setAgent('memoryEnabled', ($event.target as HTMLInputElement).checked)" />自动记录长期事实</label><p v-if="currentAgent.memoryStatus === 'upgrade_required'" role="status">执行节点需要升级后才能启用隔离记忆;已保存的文件仍可管理。</p></template>
         <div v-if="tab !== 'project' || projectId" class="actions"><button @click="newMemory()" :disabled="!available.length">添加记忆</button><a :href="`/api/app/workspace/memory-export?${query()}`" download="bot-memory.md">导出 Markdown</a></div>
         <form v-if="draft" class="knowledge-editor" @submit.prevent="saveMemory"><h3>{{ draft.id ? '编辑记忆' : '添加记忆' }}</h3><label v-if="!draft.id && tab !== 'agent'">贡献 Bot<select v-model="draft.agentId"><option v-for="agent in available.filter(a => tab !== 'project' || currentProject?.memberIds.includes(a.id))" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label><label>事实<textarea v-model="draft.content" required rows="4" maxlength="2000" /></label><label>类别<select v-model="draft.tier"><option value="profile">长期事实</option><option value="log">历史记录</option><option value="note">补充笔记</option></select></label><div class="actions"><button type="submit" :disabled="busy">保存</button><button type="button" @click="draft = undefined">取消</button></div></form>
         <article v-for="memory in memories" :key="`${memory.scope}:${memory.agentId}:${memory.id}`"><p class="pre-wrap">{{ memory.content }}</p><p v-if="memory.conflict" role="status">同主题有不同贡献,请核对来源。</p><small>{{ name(memory.agentId) }} · {{ memory.origin === 'manual' ? '用户维护' : memory.origin === 'explicit' ? '明确保存' : '自动提炼' }} · {{ new Date(memory.updatedAt).toLocaleString() }} · 版本 {{ memory.revision }}</small><details v-if="memory.sources.length"><summary>查看来源</summary><p v-for="source in memory.sources" :key="source.messageId"><a :href="`/conversations/${source.conversationId}${source.taskId ? `?taskId=${source.taskId}` : ''}`">{{ source.quote || '查看原话题' }}</a></p></details><div class="actions"><button @click="newMemory(memory)">编辑</button><button @click="history(memory)" :disabled="busy">修订记录</button><button @click="forget(memory)" :disabled="busy">遗忘</button></div></article>

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

@@ -8,6 +8,7 @@ import { useRoute, useRouter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vu
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
+import BotProfileDialog from '@/components/workspace/BotProfileDialog.vue'
 import WorkspaceBotPanel from '@/components/workspace/WorkspaceBotPanel.vue'
 import WorkspaceKnowledgePanel from '@/components/workspace/WorkspaceKnowledgePanel.vue'
 import type { WorkspaceProject } from '@shared/workspaceKnowledge'
@@ -167,6 +168,7 @@ const dialog = ref<'agent' | 'group' | 'editAgent' | 'editGroup' | null>(null),
   fileInput = ref<HTMLInputElement>(),
   dialogElement = ref<HTMLDialogElement>()
 const editingConversation = computed(() => conversations.value.find(c => c.id === editingConversationId.value) ?? (active.value?.id === editingConversationId.value ? active.value : undefined))
+const editingBot = computed(() => agents.value.find(agent => agent.id === editingId.value))
 const remotePickerOpen=ref(false)
 const isRemoteAgent=computed(()=>dialog.value==='editAgent' && !!agents.value.find(a=>a.id===editingId.value)?.remoteAgentId)
 async function remoteAdded(agent:Agent){
@@ -186,6 +188,7 @@ const form = reactive({
   canManageTeam: false,
   canCollaborate: true,
   memoryEnabled: true,
+  approvalPolicy: 'ask' as 'ask'|'allow'|'deny',
   source: '',
   memberIds: [] as string[],
   memberRoles: {} as Record<string, WorkspaceMemberRole>,
@@ -573,6 +576,7 @@ async function openDialog(kind: NonNullable<typeof dialog.value>, conversation =
   canManageTeam: false,
     canCollaborate: true,
     memoryEnabled: true,
+    approvalPolicy: 'ask',
     source: '',
     memberIds: [],
     memberRoles: {},
@@ -590,13 +594,14 @@ async function openDialog(kind: NonNullable<typeof dialog.value>, conversation =
     if (kind === 'editAgent') {
       const a = agents.value.find(a => a.id === conversation?.memberIds[0])
       if (!a) throw new Error('机器人不存在,请刷新后重试')
-      editingId.value = a.id
       Object.assign(form, a)
       form.source=JSON.stringify([a.nodeId,a.profile])
       if(a.remoteAgentId){
         const remote=(await apiRequest<{agents:Agent[]}>(`/api/app/nodes/${a.nodeId}/agents`)).agents.find(candidate=>candidate.id===a.remoteAgentId)
         if(remote)Object.assign(form,remote)
       }
+      form.approvalPolicy = a.approvalPolicy ?? 'ask'
+      editingId.value = a.id
     }
     if (kind === 'editGroup' && conversation) {
       editingId.value = conversation.id
@@ -619,6 +624,10 @@ function closeDialog() {
   dialog.value = null
   editingConversationId.value = ''
 }
+function saveBotProfile(draft: Pick<typeof form, 'name'|'avatar'|'instructions'|'source'|'canManageTeam'|'canCollaborate'|'memoryEnabled'|'approvalPolicy'>) {
+  Object.assign(form, draft)
+  void save()
+}
 watch(
   () => form.memberIds.slice(),
   (ids) => {
@@ -628,12 +637,13 @@ watch(
   },
 )
 async function save() {
-  if(isRemoteAgent.value)return
   busy.value = true
   error.value = ''
   try {
-    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, execution:form.execution, ...(knowledgeEnabled.value ? { canCollaborate: form.canCollaborate, memoryEnabled: form.memoryEnabled } : {}) } : {}), instructions: form.instructions }
-    if (dialog.value === 'agent') {
+    const fields = { name: form.name, ...(isAgentDialog.value ? { avatar: form.avatar, canManageTeam: form.canManageTeam, approvalPolicy: form.approvalPolicy, execution:form.execution, ...(knowledgeEnabled.value ? { canCollaborate: form.canCollaborate, memoryEnabled: form.memoryEnabled } : {}) } : {}), instructions: form.instructions }
+    if (isRemoteAgent.value) {
+      await apiRequest(`/api/app/agents/${editingId.value}`, { method: 'PATCH', body: { approvalPolicy: form.approvalPolicy } })
+    } else if (dialog.value === 'agent') {
       const [nodeId, profile] = JSON.parse(form.source)
       const result = await apiRequest<{ agent: Agent }>('/api/app/agents', {
         method: 'POST',
@@ -967,7 +977,7 @@ onBeforeUnmount(() => {
               </select>
               <AppIcon name="chevron-down" :size="14"/>
             </span>
-            <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
+            <button class="icon-button" :aria-label="active.kind === 'direct' ? '编辑资料' : '群聊设置'" :title="active.kind === 'direct' ? '编辑资料' : '群聊设置'" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon :name="active.kind === 'direct' ? 'edit' : 'settings'" /></button>
             <button type="button" class="icon-button" aria-label="Inspector" title="Inspector" :aria-pressed="dockView==='inspector'" @click="toggleDock('inspector')"><AppIcon name="bug"/></button>
           </div>
         </template>
@@ -1013,11 +1023,12 @@ onBeforeUnmount(() => {
     <PreviewModal v-if="preview" :item="preview" :items="media" @close="preview = null" />
     <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" />
     <RemoteAgentPicker v-if="remotePickerOpen && !auth.isBotOnly" @close="remotePickerOpen = false" @added="remoteAdded" />
-    <Teleport to="body"><dialog v-if="dialog" ref="dialogElement" class="editor" aria-labelledby="conversation-editor-title" @cancel.prevent="closeDialog">
+    <BotProfileDialog v-if="dialog === 'editAgent' && editingBot" :key="editingBot.id" :agent="editingBot" :draft="form" :sources="sources" :profiles="auth.profiles" :agents="agents" :conversations="conversations" :conversation-id="editingConversationId" :is-admin="auth.user?.role === 'admin'" :knowledge-enabled="knowledgeEnabled" :busy="busy" :error="error" @close="closeDialog" @save="saveBotProfile" @changed="knowledgeChanged" />
+    <Teleport to="body"><dialog v-if="dialog && dialog !== 'editAgent'" ref="dialogElement" class="editor" aria-labelledby="conversation-editor-title" @cancel.prevent="closeDialog">
       <form @submit.prevent="save">
         <header>
           <h2 id="conversation-editor-title">
-            {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : dialog === 'editAgent' ? '机器人设置' : '群聊设置' }}
+            {{ dialog === 'agent' ? '创建机器人' : dialog === 'group' ? '创建群聊' : '群聊设置' }}
           </h2>
           <button type="button" class="icon-button" aria-label="关闭" @click="closeDialog"><AppIcon name="close" :size="18" /></button>
         </header>

+ 9 - 1
src/server/workspaceRuntime.ts

@@ -450,7 +450,15 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         current.hadInteraction = true
         current.status = 'waiting'
         this.saveWork(owner, current)
-        this.notify(owner, c, this.store.require<Run>(owner, 'run', run.runId), undefined, interaction)
+        const policy = this.store.require<Agent>(owner, 'agent', agent.id).approvalPolicy ?? 'ask'
+        if (interaction.kind === 'approval' && policy !== 'ask' && !previous) {
+          // Use the same ownership, authorization and response deduplication as
+          // a manual answer. Never grant session-wide or permanent approval.
+          void this.respond(owner, interaction.id, policy === 'allow' ? 'once' : 'deny').catch(() => {
+            const latest = this.store.get<WorkspaceInteraction>(owner, 'interaction', interaction.id)
+            if (latest && !latest.resolved) this.notify(owner, c, this.store.require<Run>(owner, 'run', run.runId), undefined, latest)
+          })
+        } else if (!previous) this.notify(owner, c, this.store.require<Run>(owner, 'run', run.runId), undefined, interaction)
       } else if(type==='interaction.cancelled'){
         const upstreamId=String(p.request_id??'')
         for(const interaction of this.store.list<WorkspaceInteraction>(owner,'interaction')){

+ 5 - 1
src/server/workspaceStore.ts

@@ -48,6 +48,7 @@ export const agentInput = z
     canManageTeam: z.boolean().default(false),
     canCollaborate: z.boolean().default(true),
     memoryEnabled: z.boolean().default(true),
+    approvalPolicy: z.enum(['ask', 'allow', 'deny']).default('ask'),
     nodeId: z.string().default('local'),
     profile: z.string().min(1).max(256),
   })
@@ -67,6 +68,7 @@ export const agentPatch = z
     canManageTeam: z.boolean().optional(),
     canCollaborate: z.boolean().optional(),
     memoryEnabled: z.boolean().optional(),
+    approvalPolicy: z.enum(['ask', 'allow', 'deny']).optional(),
     archived: z.boolean().optional(),
   })
   .strict()
@@ -380,6 +382,8 @@ export class WorkspaceStore {
   }
   createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
     const body = parse(agentInput, input)
+    // A Bot cannot grant automatic approvals to members it creates.
+    if (origin) body.approvalPolicy = 'ask'
     if(!supportsHostEnvironment(body)||origin)body.allowHostEnvironment=false
     if(body.computer!=='vm'||origin)body.vmExecution='worker'
     if(body.vmExecution==='profile')body.allowHostEnvironment=true
@@ -458,7 +462,7 @@ export class WorkspaceStore {
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
       if(agent.computerEnvironmentId&&patch.execution==='profile')throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
       if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
-      if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
+      if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived' && key !== 'approvalPolicy'))
         throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')
       if (
         patch.name &&

+ 1 - 1
src/server/workspaceTeamTools.ts

@@ -11,7 +11,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
-const createAgent = agentInput.omit({ canManageTeam: true, allowHostEnvironment:true, vmExecution:true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
+const createAgent = agentInput.omit({ canManageTeam: true, allowHostEnvironment:true, vmExecution:true, approvalPolicy:true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
 const createHelper=agentInput.pick({name:true,instructions:true}).extend({requestId:uuid,goalId:uuid,profile:z.string().min(1).max(256).optional(),title:z.string().trim().min(1).max(100),brief:z.string().trim().min(1).max(16000),acceptanceCriteria:z.array(z.string().trim().min(1).max(1000)).max(12).default([]),dependsOn:z.array(uuid).max(12).default([])}).strict()
 const createTeam = z.object({
   requestId: uuid,

+ 3 - 0
src/shared/workspace.ts

@@ -1,5 +1,8 @@
 /** Application-owned identities. Hermes profile/session IDs never identify a chat. */
+export type WorkspaceApprovalPolicy = 'ask' | 'allow' | 'deny'
 export interface WorkspaceAgent {
+  /** Applied to new upstream approval requests; absent on older Bots means ask. */
+  approvalPolicy?: WorkspaceApprovalPolicy
   canCollaborate?: boolean
   memoryEnabled?: boolean
   memoryStatus?: 'ready' | 'upgrade_required'

+ 96 - 0
tests/client/botProfileDialog.test.ts

@@ -0,0 +1,96 @@
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, expect, it, vi } from 'vitest'
+import BotProfileDialog from '@/components/workspace/BotProfileDialog.vue'
+import type { WorkspaceAgent } from '@shared/workspace'
+
+const agent: WorkspaceAgent = { id: 'target-bot', name: '目标机器人', avatar: '', instructions: '检查边界条件', nodeId: 'local', profile: 'ops/team', execution: 'computer', canManageTeam: false, canCollaborate: true, memoryEnabled: true, archived: false, revision: 1, createdAt: 1, updatedAt: 1 }
+let wrapper: VueWrapper | undefined
+function open(overrides: Record<string, unknown> = {}) {
+  wrapper = mount(BotProfileDialog, {
+    props: {
+      agent,
+      draft: { ...agent, source: JSON.stringify(['local', 'ops/team']) },
+      sources: [{ nodeId: 'local', profile: 'ops/team', name: '运维机器人' }, { nodeId: 'local', profile: 'default', name: '丫头' }],
+      profiles: [{ name: 'default', agentName: '丫头', isDefault: true }],
+      agents: [agent], conversations: [], conversationId: 'target-chat', isAdmin: true, knowledgeEnabled: true,
+      ...overrides,
+    } as never,
+    global: { stubs: {
+      StandaloneDialog: { props: ['title', 'beforeClose'], template: '<div role="dialog" :aria-label="title"><slot /></div>' },
+      AppIcon: true, AgentAvatar: true, AgentIdentityPanel: true,
+      ModelServicesPanel: true, WorkspaceKnowledgePanel: true,
+    } },
+  })
+  return wrapper
+}
+function page(label: string) { return wrapper!.findAll('nav button').find(button => button.text() === label)! }
+afterEach(() => { wrapper?.unmount(); vi.restoreAllMocks() })
+
+it('shows the associated Profile identity instead of repeating its technical name', () => {
+  const view = open({ sources: [{ nodeId: 'local', profile: 'ops/team', name: 'ops/team' }], profiles: [{ name: 'ops/team', agentName: '运维助手', isDefault: false }] })
+  expect(view.get('.profile-source__identity').text()).toBe('运维助手ops/team')
+})
+
+it('keeps identity drafts on the right-clicked Bot while the account has another Profile', async () => {
+  const view = open()
+  expect(view.get('[role="dialog"]').attributes('aria-label')).toBe('编辑资料 · 目标机器人')
+  expect(view.get<HTMLButtonElement>('.profile-save').element.disabled).toBe(true)
+  await page('身份与头像').trigger('click')
+  const identity = view.findComponent({ name: 'AgentIdentityPanel' })
+  expect(identity.props('profile')).toMatchObject({ name: 'target-bot', agentName: '目标机器人' })
+  expect(identity.props('showDefaultModel')).toBe(false)
+  await view.get('input[maxlength="100"]').setValue('新的名称')
+  await page('角色与规则').trigger('click')
+  await view.get('textarea').setValue('新的规则')
+  await view.get('.profile-save').trigger('click')
+  expect(view.emitted('save')).toEqual([[{ name: '新的名称', avatar: '', instructions: '新的规则', source: '["local","ops/team"]', canManageTeam: false, canCollaborate: true, memoryEnabled: true, approvalPolicy: 'ask' }]])
+  expect(agent.name).toBe('目标机器人')
+})
+
+it('uses the saved target Profile for model services and blocks an unsaved source switch', async () => {
+  const view = open()
+  await page('模型与 Provider').trigger('click')
+  expect(view.findComponent({ name: 'ModelServicesPanel' }).props('profile')).toBe('ops/team')
+  await view.get('select[aria-label="基础机器人"]').setValue('["local","default"]')
+  expect(view.findComponent({ name: 'ModelServicesPanel' }).exists()).toBe(false)
+  expect(view.text()).toContain('请先保存基础机器人的变更')
+})
+
+it.each([false, true])('saves a per-Bot approval policy, including remote references (remote=%s)', async remote => {
+  const view = open({ agent: { ...agent, ...(remote ? { remoteAgentId: 'remote-bot' } : {}) } })
+  await page('权限').trigger('click')
+  expect(view.get<HTMLInputElement>('input[value="ask"]').element.checked).toBe(true)
+  await view.get('input[value="deny"]').setValue()
+  await view.get('.profile-save').trigger('click')
+  expect(view.emitted('save')?.[0]?.[0]).toMatchObject({ approvalPolicy: 'deny' })
+})
+
+it.each([{ isAdmin: false }, { agent: { ...agent, nodeId: 'remote' } }, { agent: { ...agent, remoteAgentId: 'remote-bot' } }])('does not expose local model management outside the eligible Bot scope: %j', async overrides => {
+  const view = open(overrides)
+  if (page('模型与 Provider')) await page('模型与 Provider').trigger('click')
+  expect(view.findComponent({ name: 'ModelServicesPanel' }).exists()).toBe(false)
+})
+
+it('pins memory to this Bot and protects unsaved memory and Provider edits', async () => {
+  const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
+  const view = open()
+  await page('身份与头像').trigger('click')
+  await view.get('input[maxlength="100"]').setValue('改过的名称')
+  await page('记忆').trigger('click')
+  const memory = view.findComponent({ name: 'WorkspaceKnowledgePanel' })
+  expect(memory.props()).toMatchObject({ selectedAgentId: 'target-bot', conversationId: 'target-chat', initialTab: 'agent', hideAgentControls: true })
+  memory.vm.$emit('dirty-change', true)
+  await view.vm.$nextTick()
+  await page('模型与 Provider').trigger('click')
+  expect(confirm).toHaveBeenCalledWith('放弃当前记忆未保存的更改?')
+  expect(view.findComponent({ name: 'WorkspaceKnowledgePanel' }).exists()).toBe(true)
+  memory.vm.$emit('dirty-change', false)
+  await view.vm.$nextTick()
+  await page('模型与 Provider').trigger('click')
+  view.findComponent({ name: 'ModelServicesPanel' }).vm.$emit('dirty-change', true)
+  await view.vm.$nextTick()
+  expect(view.get<HTMLButtonElement>('.profile-save').element.disabled).toBe(true)
+  await page('身份与头像').trigger('click')
+  expect(confirm).toHaveBeenCalledWith('放弃当前模型服务未保存的更改?')
+  expect(view.findComponent({ name: 'ModelServicesPanel' }).exists()).toBe(true)
+})

+ 14 - 6
tests/client/conversationSettings.test.ts

@@ -43,18 +43,26 @@ it.each(['direct', 'group'] as const)('opens and saves the menu target %s while
   await flushPromises()
   wrapper.findComponent({ name: 'ConversationList' }).vm.$emit('settings', 'target')
   await flushPromises()
-  const dialog = document.querySelector<HTMLDialogElement>('dialog.editor')!
-  expect(dialog.open).toBe(true)
-  expect(dialog.querySelector('h2')?.textContent?.trim()).toBe(kind === 'direct' ? '机器人设置' : '群聊设置')
-  expect(dialog.querySelector<HTMLInputElement>('input[maxlength="100"]')?.value).toBe(kind === 'direct' ? '目标机器人' : '目标聊天')
   expect(router.currentRoute.value.path).toBe('/conversations/current')
-  if (kind === 'group') {
+  if (kind === 'direct') {
+    expect(document.querySelector('dialog.editor')).toBeNull()
+    const editor = wrapper.findComponent({ name: 'BotProfileDialog' })
+    expect(editor.props('agent')).toMatchObject({ id: 'target-bot', name: '目标机器人' })
+    expect(editor.props('conversationId')).toBe('target')
+    expect(editor.props('draft')).toMatchObject({ name: '目标机器人', source: '["local","default"]' })
+    editor.vm.$emit('save', { ...editor.props('draft'), name: '目标机器人' })
+  } else {
+    const dialog = document.querySelector<HTMLDialogElement>('dialog.editor')!
+    expect(dialog.open).toBe(true)
+    expect(dialog.querySelector('h2')?.textContent?.trim()).toBe('群聊设置')
+    expect(dialog.querySelector<HTMLInputElement>('input[maxlength="100"]')?.value).toBe('目标聊天')
     expect(dialog.querySelector<HTMLInputElement>('input[value="target-bot"]')?.disabled).toBe(true)
     const stop = [...dialog.querySelectorAll('button')].find(button => button.textContent === '停止 目标机器人')!
     stop.click(); await flushPromises()
     expect(apiRequest).toHaveBeenCalledWith('/api/app/conversations/target/agents/target-bot/stop', expect.objectContaining({ method: 'POST' }))
+    dialog.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
   }
-  dialog.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
   await flushPromises()
   expect(apiRequest).toHaveBeenCalledWith(kind === 'direct' ? '/api/app/agents/target-bot' : '/api/app/conversations/target', expect.objectContaining({ method: 'PATCH', body: expect.objectContaining({ name: kind === 'direct' ? '目标机器人' : '目标聊天' }) }))
+  if (kind === 'direct') expect(router.currentRoute.value.path).toBe('/conversations/current')
 })

+ 9 - 0
tests/client/settingsCenterDialog.test.ts

@@ -81,6 +81,15 @@ afterEach(() => {
 })
 
 describe('Settings center dialog', () => {
+  it.each(['agent-identity', 'agent-models'])('keeps %s and the Profile selector out of Bot-mode personal settings', initialPage => {
+    const wrapper = mountSettings({ botMode: true, initialPage })
+    expect(wrapper.findAll('.settings-sidebar nav h3').map(heading => heading.text())).toEqual(['个人', '管理'])
+    expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
+    expect(wrapper.find('[data-testid="agent-identity"]').exists()).toBe(false)
+    expect(wrapper.find('[data-testid="model-services"]').exists()).toBe(false)
+    expect(wrapper.get('.settings-content__header').text()).toContain('账号资料')
+    wrapper.unmount()
+  })
   it('shows theme previews and filters settings without changing account scope', async () => {
     const wrapper = mountSettings({initialPage:'appearance',themePreference:'light'})
     expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('我的设置')

+ 1 - 1
tests/client/workspacePresentation.test.ts

@@ -69,7 +69,7 @@ it('composes real member avatars and refreshes them without changing group membe
 })
 
 it.each([
-  ['direct', '机器人设置'],
+  ['direct', '编辑资料'],
   ['group', '群聊设置'],
 ] as const)('opens settings for a %s conversation from its actions menu', async (kind, label) => {
   const conversation: WorkspaceConversation = {

+ 8 - 3
tests/fixtures/settings-design/main.ts

@@ -2,6 +2,7 @@
 import {createApp, defineComponent, h, ref} from 'vue'
 import {createPinia} from 'pinia'
 import BotSettingsDialog from '@/components/workspace/BotSettingsDialog.vue'
+import BotProfileDialog from '@/components/workspace/BotProfileDialog.vue'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import {useAuthStore} from '@/stores/auth'
 import '@/styles/tokens.css'
@@ -9,7 +10,7 @@ import '@/styles/global.css'
 
 const query=new URLSearchParams(location.search)
 const names=['测试2','竹儿','测试','瑶儿']
-const agents=names.map((name,i)=>({id:`00000000-0000-4000-8000-00000000000${i+1}`,name,profile:'default',nodeId:'local',archived:false}))
+const agents=names.map((name,i)=>({id:`00000000-0000-4000-8000-00000000000${i+1}`,name,profile:'default',nodeId:'local',archived:false,avatar:'',instructions:'协助整理项目资料,检查问题并给出清晰、简洁的建议。',execution:'computer',canManageTeam:false,canCollaborate:true,memoryEnabled:true,createdAt:1,updatedAt:1}))
 const conversations=[{id:'group-a',name:'方案分析团队',kind:'group',memberIds:agents.map(a=>a.id),archived:false},{id:'group-b',name:'内容运营组',kind:'group',memberIds:agents.slice(0,3).map(a=>a.id),archived:false}]
 const projects=query.has('empty')?[]:[
   {id:'project-a',name:'夭夭产品开发',description:'',memberIds:agents.map(a=>a.id),groupIds:['group-a','group-b'],revision:1,archived:false,createdAt:1,updatedAt:1},
@@ -26,6 +27,9 @@ window.fetch=async(input,init)=>{
   else if(url.pathname==='/api/app/bootstrap')data={csrfToken:'fixture-only',userId:'fixture-user',authRequired:true}
   else if(url.pathname==='/api/app/agents')data={agents}
   else if(url.pathname==='/api/app/conversations')data={conversations}
+  else if(url.pathname==='/api/app/models')data={provider:'custom',model:'GPT-5.6-terra',providers:[{slug:'custom',name:'Custom endpoint',models:['GPT-5.6-terra'],is_current:true}]}
+  else if(url.pathname==='/api/app/admin/legacy-model-services')data={items:[]}
+  else if(url.pathname.startsWith('/api/app/admin/model-services'))data={endpoints:[],services:[],current:{provider:'custom',model:'GPT-5.6-terra',base_url:''}}
   else if(url.pathname==='/api/app/workspace/projects'){
     if(init?.method==='POST'){
       const previous=projects.find(p=>p.id===body.id)
@@ -48,12 +52,13 @@ const App=defineComponent({setup(){
   const auth=useAuthStore()
   auth.user={id:'fixture-user',username:'samien',role:query.has('member')?'member':'admin'} as never
   auth.status='authenticated';auth.serverIdentity=identity
-  const opened=ref<'bot'|'personal'|null>(query.get('view')==='appearance'||query.get('view')==='profile'?'personal':'bot')
+  const opened=ref<'bot'|'personal'|'editor'|null>(query.get('view')==='editor'?'editor':query.get('view')==='appearance'||query.get('view')==='profile'?'personal':'bot')
   return()=>h('main',{style:'min-height:100dvh;background:var(--settings-panel);padding:18px;box-sizing:border-box'},[
     h('div',{style:'display:flex;gap:12px;align-items:center;font:13px var(--font-ui)'},[
-      h('span','界面验证 · 示例数据'),h('button',{onClick:()=>opened.value='bot'},'Bot 设置'),h('button',{onClick:()=>opened.value='personal'},'我的设置'),
+      h('span','界面验证 · 示例数据'),h('button',{onClick:()=>opened.value='bot'},'Bot 设置'),h('button',{onClick:()=>opened.value='personal'},'我的设置'),h('button',{onClick:()=>opened.value='editor'},'编辑资料'),
     ]),
     opened.value==='bot'?h(BotSettingsDialog,{isAdmin:!query.has('member'),onClose:()=>opened.value=null}):null,
+    opened.value==='editor'?h(BotProfileDialog,{agent:agents[0],draft:{...agents[0],source:'["local","default"]'},sources:[{nodeId:'local',profile:'default',name:'丫头'}],profiles:[{name:'default',agentName:'丫头',isDefault:true}],agents,conversations,conversationId:'fixture-chat',isAdmin:!query.has('member'),knowledgeEnabled:true,onClose:()=>opened.value=null,onSave:()=>opened.value=null} as never):null,
     h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
   ])
 }})

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

@@ -1282,6 +1282,96 @@ it('supports unlimited rounds beyond the finite limit and still permits stopping
   expect(()=>store.updateConversation(owner,g.id,{maxReplyRounds:0})).toThrow()
 })
 
+it('applies independent Bot approval choices on the same Profile and does not replay automatic answers', async () => {
+  const ask = agent('手动审批')
+  const { approvalPolicy: _policy, ...legacyAsk } = ask
+  store.put(owner, 'agent', ask.id, legacyAsk)
+  const allow = store.updateAgent(owner, agent('允许审批').id, { approvalPolicy: 'allow' })
+  const deny = store.updateAgent(owner, agent('拒绝审批').id, { approvalPolicy: 'deny' })
+  const bots = [ask, allow, deny]
+  expect(bots.every(bot => bot.profile === ask.profile)).toBe(true)
+  const notified: string[] = []
+  runtime.onNotify = (_owner, _conversation, _run, _message, interaction) => { if (interaction) notified.push(interaction.agentId) }
+  server.on('connection', socket => socket.on('message', raw => {
+    const frame = JSON.parse(String(raw))
+    if (frame.method === 'approval.respond') completeReply(socket, frame.params, '审批已处理')
+  }))
+  reply = (socket, params) => {
+    const frame = JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'same-id', message: '是否执行?' } } })
+    socket.send(frame); socket.send(frame)
+  }
+  const runs = bots.map(bot => runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '执行任务' }))
+  await finished(runs[1]!.id); await finished(runs[2]!.id)
+  const interactions = store.list<WorkspaceInteraction & { answer?: string }>(owner, 'interaction')
+  expect(interactions).toHaveLength(3)
+  expect(interactions.find(item => item.agentId === ask.id)).toMatchObject({ resolved: false })
+  expect(interactions.find(item => item.agentId === allow.id)).toMatchObject({ resolved: true, answer: 'once' })
+  expect(interactions.find(item => item.agentId === deny.id)).toMatchObject({ resolved: true, answer: 'deny' })
+  expect(requests.filter(frame => frame.method === 'approval.respond')).toHaveLength(2)
+  expect(notified).toEqual([ask.id])
+  await runtime.respond(owner, interactions.find(item => item.agentId === ask.id)!.id, 'deny')
+  await finished(runs[0]!.id)
+})
+
+it('leaves clarifications and already pending approvals for the user after a policy change', async () => {
+  const bot = agent('更改审批')
+  let kind = 'approval'
+  server.on('connection', socket => socket.on('message', raw => {
+    const frame = JSON.parse(String(raw))
+    if (['approval.respond', 'clarify.respond'].includes(frame.method)) completeReply(socket, frame.params, '已答复')
+  }))
+  reply = (socket, params) => socket.send(JSON.stringify({ method: 'event', params: { type: `${kind}.request`, session_id: params.session_id, payload: { request_id: 'pending', message: '请确认' } } }))
+  for (kind of ['approval', 'clarify']) {
+    const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续任务' })
+    await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting'))
+    store.updateAgent(owner, bot.id, { approvalPolicy: 'allow' })
+    const interaction = store.list<WorkspaceInteraction>(owner, 'interaction').find(item => !item.resolved)!
+    expect(requests.filter(frame => ['approval.respond', 'clarify.respond'].includes(frame.method))).toHaveLength(kind === 'approval' ? 0 : 1)
+    await runtime.respond(owner, interaction.id, kind === 'approval' ? 'once' : '下一步')
+    await finished(run.id)
+  }
+})
+
+it('defaults legacy and newly created Bots to manual approval and prevents autonomous grants', () => {
+  const parent = agent('创建者')
+  expect(parent.approvalPolicy).toBe('ask')
+  const child = store.createAgent(owner, { name: '子成员', profile: parent.profile, approvalPolicy: 'allow' }, { createdByAgentId: parent.id, createdFromRunId: randomUUID() })
+  expect(child.approvalPolicy).toBe('ask')
+  expect(() => store.updateAgent(owner, child.id, { approvalPolicy: 'always' })).toThrow('approvalPolicy')
+  store.put(owner, 'agent', child.id, { ...child, remoteAgentId: randomUUID() })
+  expect(store.updateAgent(owner, child.id, { approvalPolicy: 'deny' }).approvalPolicy).toBe('deny')
+  expect(() => store.updateAgent(owner, child.id, { name: '不能改远端资料' })).toThrow('远端管理')
+})
+
+it('keeps a failed automatic approval pending and does not retry a replayed request', async () => {
+  const bot = store.updateAgent(owner, agent('审批失败').id, { approvalPolicy: 'allow' })
+  const rpc = WorkspaceGateway.prototype.rpc
+  let attempts = 0
+  const spy = vi.spyOn(WorkspaceGateway.prototype, 'rpc').mockImplementation(function (this: WorkspaceGateway, method, params) {
+    if (method === 'approval.respond' && attempts++ === 0) return Promise.reject(new HttpError(409, '暂时拒绝答复', 'gateway_rejected'))
+    return rpc.call(this, method, params)
+  })
+  try {
+    const notify = vi.fn()
+    runtime.onNotify = notify
+    reply = (socket, params) => {
+      const frame = JSON.stringify({ method: 'event', params: { type: 'approval.request', session_id: params.session_id, payload: { request_id: 'failure', message: '确认操作' } } })
+      socket.send(frame); socket.send(frame)
+    }
+    server.on('connection', socket => socket.on('message', raw => {
+      const frame = JSON.parse(String(raw))
+      if (frame.method === 'approval.respond') completeReply(socket, frame.params, '手动处理完成')
+    }))
+    const run = runtime.send(owner, direct(bot.id).id, { requestId: randomUUID(), content: '继续' })
+    await vi.waitFor(() => expect(notify.mock.calls.some(call => call[4]?.kind === 'approval')).toBe(true))
+    expect(attempts).toBe(1)
+    const interaction = store.list<WorkspaceInteraction>(owner, 'interaction').find(item => !item.resolved)!
+    expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('waiting')
+    await runtime.respond(owner, interaction.id, 'deny')
+    await finished(run.id)
+  } finally { spy.mockRestore() }
+})
+
 it('keeps parallel approval cards scoped to their member and deduplicates replayed requests and answers', async () => {
   const a=agent('审批管理员'),b=agent('审批乙'),c=agent('审批丙'),g=store.createGroup(owner,{name:'并发审批',memberIds:[a.id,b.id,c.id],administratorId:a.id})
   let admin=0

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است