diff --git a/.planning/subagent-card-metadata/findings.md b/.planning/subagent-card-metadata/findings.md index 33ccf08..80e91f9 100644 --- a/.planning/subagent-card-metadata/findings.md +++ b/.planning/subagent-card-metadata/findings.md @@ -15,3 +15,9 @@ - 静态源码契约不足以证明多 spawn 行为;需要提取并执行实际纯函数源码,覆盖独立 prompt 与 wait/close 保留。 - 后续状态会携带归一化空标题字段;合并时只有新状态含可读标题才允许覆盖旧标题。 - 归一化 `entry.label` 可能由 prompt 派生,必须携带 `hasReadableSourceTitle` 标记区分协议标题和派生标题。 +- 当前真实会话持久化数据只剩一个 `receiverThreadIds: [] / agentsStates: {}` 的 `wait` 工具,说明不能只依赖同 ID 的历史工具快照。 +- `closedCollabAgentIds` 只能标记已知 ID 的状态;需要额外缓存 `ccweb_mcp_child_agent_update.child` 的最后结构化状态,才能在空 wait/close 载荷中重建卡片。 +- 当前工作树另有“放开图片数量限制”改动,涉及 `CHANGELOG.md`、`README*.md`、`public/app.js`、`server.js`、`scripts/regression.js`,本修复必须限制在不重叠代码区并避免格式化整文件。 +- 独立复核确认实时缓存修复后,`renderMessages()` 会清空缓存;若历史只有空 close/wait,刷新仍退化为工具 ID。 +- 服务端 `updatePersistedCcwebMcpChildTool` 只按 `child.spawnToolId` 查找工具;真实会话缺少该 spawn 工具时返回 null,未把 child 状态写入历史。 +- 后端兜底应只选择最近的 `collab_agent_tool_call`,不得把 child 合并进普通工具。 diff --git a/.planning/subagent-card-metadata/progress.md b/.planning/subagent-card-metadata/progress.md index 0f4d58a..ef26aba 100644 --- a/.planning/subagent-card-metadata/progress.md +++ b/.planning/subagent-card-metadata/progress.md @@ -14,3 +14,11 @@ - 2026-07-11:语法、diff、完整 regression 和独立复核全部通过;进入交付清理。 - 2026-07-11:最终 diff 与行号核验完成。实时服务在线但两次 5 秒请求无响应,环境无 Playwright/Chromium,因此未截图且未重启服务。 - 2026-07-11:Trellis 规范沉淀判断:本次为局部子代理卡片行为,核心边界已固化在行为回归中,没有形成跨模块通用约定,不更新 `.trellis/spec/`。 +- 2026-07-15:用户反馈关闭后卡片变成“子代 0 个”。定位到同 ID 关闭更新覆盖旧工具快照,开始修复。 +- 2026-07-15:真实会话仅持久化空 wait 工具;方案调整为缓存 child 结构化状态,并补充工作树保护和普通工具回归约束。 +- 2026-07-15:Phase 7 计划复审通过,进入关闭空载荷行为回归。 +- 2026-07-15:行为回归覆盖 close/wait 空载荷及普通工具隔离,进入状态缓存实现。 +- 2026-07-15:完成 threadId 状态缓存和 toolUseId 作用域映射,进入关闭卡片重建核验。 +- 2026-07-15:空 close/wait 卡片重建实现与完整回归通过,进入独立复核。 +- 2026-07-15:独立复核发现刷新后历史仍丢失;追加服务端持久化兜底修复。 +- 2026-07-15:服务端在 spawnToolId 缺失时回退最近协作工具,刷新闭环回归及最终独立复核通过。 diff --git a/.planning/subagent-card-metadata/task_plan.md b/.planning/subagent-card-metadata/task_plan.md index 2428ffd..ba791c1 100644 --- a/.planning/subagent-card-metadata/task_plan.md +++ b/.planning/subagent-card-metadata/task_plan.md @@ -12,7 +12,7 @@ ## 当前阶段 -已完成 +Phase 7 ## 阶段 @@ -40,6 +40,19 @@ - **Status:** complete +### Phase 7: 修复关闭后子代理卡片消失 + +- [ ] 覆盖关闭更新缺失结构化状态的回归场景 +- [ ] 缓存子代理最后一次结构化状态 +- [ ] 用已关闭 ID 和状态缓存重建关闭卡片 +- [ ] 保留已关闭子代理的标题、简介与线程 ID +- [ ] 验证关闭状态、数量和卡片持续可见 +- [ ] 验证普通工具调用不进入子代理合并路径 +- [ ] 找不到原 spawn 工具时,将 child 状态合并到最近的协作工具历史记录 +- [ ] 刷新/历史重渲染后仍保留标题、简介、线程 ID 和关闭状态 +- [ ] 实施前后核对工作树,只改子代理相关代码与测试 +- **Status:** in_progress + ## 完成标准 - 按 `label → title → nickname → name` 选择第一个非通用、非线程 ID 的协议标题。 @@ -57,12 +70,15 @@ 2. 通用标题从各自 prompt 提炼。 3. 多代理不同 prompt 的标题和简介不串联。 4. 无 prompt 时回退短线程 ID。 +5. 关闭更新即使不携带 `receiverThreadIds/agentsStates`,也保留原子代理卡片并显示“已关闭”。 ## 范围约束 - 不新增依赖。 - 不改变后端协议。 - 不重启服务。 +- 不覆盖当前工作树中 `CHANGELOG.md`、`README*.md`、图片附件数量相关 `public/app.js` / `server.js` / `scripts/regression.js` 改动。 +- 允许定点修改 `server.js` 的子代理工具持久化函数,但不得触碰图片附件处理区。 ## 错误记录 @@ -76,3 +92,6 @@ | 多文件补丁缺少合法 hunk 边界 | 1 | 拆分为独立小补丁应用 | | prompt 派生 label 被误判为协议标题 | 1 | 归一化阶段携带标题来源标记并补行为回归 | | 规划文档阶段格式未被完成检查器识别 | 1 | 改为标准 Phase 三级标题与完成状态字段格式 | +| 空 wait/close 工具缺少可渲染的子代理状态 | 1 | 缓存 `msg.child` 状态,并用已关闭 ID 重建关闭卡片 | +| 测试先行缺少 `rememberCollabAgentState` | 1 | 实现按 threadId 缓存 helper 后回归通过 | +| 实时缓存无法覆盖刷新后的历史重渲染 | 1 | 原 spawn 工具缺失时把 child 合并进最近协作工具并持久化 | diff --git a/.trellis/tasks/07-11-subagent-card-metadata/prd.md b/.trellis/tasks/07-11-subagent-card-metadata/prd.md index d9b4ca4..ea063bd 100644 --- a/.trellis/tasks/07-11-subagent-card-metadata/prd.md +++ b/.trellis/tasks/07-11-subagent-card-metadata/prd.md @@ -16,6 +16,9 @@ 4. 多次 `spawn_agent/spawnAgent` 合并时,每个子代理必须保存自己的标题和简介,不能全部复用第一个 prompt。 5. 运行结果继续作为悬浮详情提供,不覆盖任务简介。 6. 保持现有状态标签、关闭按钮、点击复制线程 ID、折叠和聚合行为。 +7. 关闭更新即使只提供 `child.threadId/status`,且工具载荷缺少 `receiverThreadIds/agentsStates`,也必须保留此前子代理卡片,将状态显示为“已关闭”。 +8. 空的 `wait/close` 协作工具不得把已有子代理数量重置为 0;普通工具调用不得进入子代理状态合并路径。 +9. 当历史中找不到原 `spawnToolId` 时,服务端应将 child 状态合并到最近的协作工具记录,保证刷新后仍可恢复关闭卡片;不得选择普通工具。 ## 视觉要求 @@ -31,9 +34,12 @@ - 两个不同 prompt 的子代理合并后展示不同标题和简介。 - 简介 DOM 可见且应用两行截断样式。 - 无 prompt 时仍能回退到短线程 ID,不报错。 +- 关闭空载荷场景保留原标题、简介、线程 ID,并显示关闭状态与非零数量。 +- 页面刷新和历史重渲染后仍满足上述关闭卡片要求。 - JavaScript 语法检查及项目回归测试通过。 ## 范围 -- 允许修改:`public/app.js`、`public/style.css`、相关回归测试与 mock。 +- 允许修改:`public/app.js`、`public/style.css`、`server.js` 子代理持久化函数、相关回归测试与 mock。 - 不新增依赖,不改变后端协议,不重启服务。 +- 不覆盖当前工作树中图片附件数量限制相关改动。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 509262a..3bc7705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Claude 默认设置为 1M 上下文(opus / sonnet 自动使用 `[1m]` 模型,haiku 保持不变) - 新会话弹窗补充工作目录默认提示、最近目录快捷项和目录选择器 +- 取消 cc-web 的单条消息图片张数上限,保留单张 10MB 与 7 天过期规则 ## v1.2.10 @@ -25,7 +26,7 @@ ### 新功能 - **Codex 双 Agent** — 新建会话时可选 Claude 或 Codex,共享后端内核,侧边栏按 Agent 隔离 -- **图片上传** — 拖拽 / 粘贴 / 附件按钮上传图片,客户端自动压缩,单条消息最多 4 张 +- **图片上传** — 拖拽 / 粘贴 / 附件按钮上传图片,客户端自动压缩并在服务端缓存 7 天 - **主题系统** — 新增 CoolVibe Light 等多套主题,设置中一键切换 - **Codex 本地历史导入** — 导入 `~/.codex/sessions/` 下的会话历史 - **隔离式回归脚本** — `npm run regression` 使用 mock CLI 在临时目录中校验主路径 diff --git a/README.en.md b/README.en.md index 1c77bbb..d756252 100644 --- a/README.en.md +++ b/README.en.md @@ -244,12 +244,15 @@ node server.js ## Release Notes +- **v1.2.11** + - Removed cc-web's per-message image count cap; the 10 MB per-image limit and 7-day server cache remain unchanged + - **v1.2.10** - Implemented `/init` behavior aligned with native Claude Code and Codex CLI - **v1.2.8** - **Dual-agent (Codex)**: create Claude or Codex sessions on the same backend; agent-isolated sidebar, settings, and import - - **Image upload**: drag, paste, or attach images in both Claude and Codex sessions; client-side WebP compression, 7-day server cache, up to 4 images per message + - **Image upload**: drag, paste, or attach images in both Claude and Codex sessions; client-side WebP compression and a 7-day server cache - **Session loading**: loading overlay, hot session cache (4 slots, strong/weak hit), fix for streaming content disappearing on tab switch - **Theme system**: full theme engine with CoolVibe Light, washi, and editorial variants; theme picker moved to sub-page - **Mobile UX**: swipe-to-open/close sidebar, running-state badge replaces cwd label, button sizing fixes diff --git a/README.md b/README.md index a282a58..f4059f9 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Codex App 原生协作工具会被转成页面上的子代理状态卡片: - 会话运行中、等待跨对话回复、未读状态等可视化提示 - Markdown 渲染、代码块高亮、复制、预览 - 图片附件上传、剪贴板粘贴、缩略图和放大预览 -- 附件大小限制 10MB,单条消息最多 4 张图片,默认 7 天过期 +- 附件大小限制 10MB,默认 7 天过期;cc-web 不限制单条消息的图片张数 - Agent 输出分隔线可显示时间,也可在设置中关闭 - 每条助手气泡末尾提供“定位到本条最后一段”的按钮 - 跨对话返回气泡支持折叠,并把折叠状态缓存到浏览器 diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 0d46a58..cedf285 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/app.js b/public/app.js index 19370b0..420a04d 100644 --- a/public/app.js +++ b/public/app.js @@ -174,6 +174,9 @@ let activeToolCalls = new Map(); let activeTodoCallTargets = new Map(); let closedCollabAgentIds = new Set(); + let collabAgentStateCache = new Map(); + let collabAgentIdsByToolUseId = new Map(); + let closedCollabAgentIdsByToolUseId = new Map(); let toolDomSeq = 0; let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录) let hasGrouped = false; // 本次输出是否已触发过折叠 @@ -3721,10 +3724,6 @@ async function handleSelectedImageFiles(fileList) { const files = Array.from(fileList || []).filter((file) => file && /^image\//.test(file.type || '')); if (!files.length) return; - if (pendingAttachments.length + files.length > 4) { - appendError('单条消息最多附带 4 张图片。'); - return; - } const batch = files.map((file, index) => ({ id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, filename: file.name || 'image', @@ -4199,6 +4198,9 @@ activeToolCalls.clear(); activeTodoCallTargets.clear(); closedCollabAgentIds = new Set(); + collabAgentStateCache = new Map(); + collabAgentIdsByToolUseId = new Map(); + closedCollabAgentIdsByToolUseId = new Map(); updateGenerationControls(); chatTitle.textContent = '新会话'; updateSessionIdBadge(); @@ -6625,7 +6627,7 @@ const role = cleanCollabAgentText(state.role || state.agent || state.agentType || ''); let status = cleanCollabAgentText(state.status || state.state || 'pending') || 'pending'; if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed'; - const detail = cleanCollabAgentText(state.candidateResult || state.finalMessage || state.result || state.output || state.summary || state.message || state.lastMessage || state.step || state.description || ''); + const detail = cleanCollabAgentText(state.detail || state.candidateResult || state.finalMessage || state.result || state.output || state.summary || state.message || state.lastMessage || state.step || state.description || ''); return { id, label, @@ -6715,6 +6717,124 @@ getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id)); } + function normalizeCollabAgentThreadId(id) { + return cleanCollabAgentText(id); + } + + function addCollabAgentIdsToMap(map, key, ids) { + const mapKey = cleanCollabAgentText(key); + const values = Array.isArray(ids) || ids instanceof Set ? Array.from(ids) : [ids]; + const normalizedIds = values.map(normalizeCollabAgentThreadId).filter(Boolean); + if (!mapKey || normalizedIds.length === 0) return; + const set = map.get(mapKey) || new Set(); + normalizedIds.forEach((id) => set.add(id)); + map.set(mapKey, set); + } + + function rememberCollabAgentIdsForTool(toolUseId, ids) { + addCollabAgentIdsToMap(collabAgentIdsByToolUseId, toolUseId, ids); + } + + function rememberClosedCollabAgentIdsForTool(toolUseId, ids) { + addCollabAgentIdsToMap(closedCollabAgentIdsByToolUseId, toolUseId, ids); + } + + function collectScopedCollabAgentIdsForToolMap(map) { + const ids = new Set(); + if (!(map instanceof Map)) return ids; + map.forEach((tool, key) => { + getCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id)); + [key, tool?.id].forEach((toolKey) => { + const normalizedKey = cleanCollabAgentText(toolKey); + if (!normalizedKey) return; + const cachedIds = collabAgentIdsByToolUseId.get(normalizedKey); + if (cachedIds instanceof Set) cachedIds.forEach((id) => ids.add(id)); + }); + }); + return ids; + } + + function collectScopedClosedCollabAgentIdsForToolMap(map) { + const ids = new Set(); + if (!(map instanceof Map)) return ids; + map.forEach((tool, key) => { + getClosedCollabAgentIdsFromTool(tool).forEach((id) => ids.add(id)); + [key, tool?.id].forEach((toolKey) => { + const normalizedKey = cleanCollabAgentText(toolKey); + if (!normalizedKey) return; + const cachedIds = closedCollabAgentIdsByToolUseId.get(normalizedKey); + if (cachedIds instanceof Set) cachedIds.forEach((id) => ids.add(id)); + }); + }); + return ids; + } + + function rememberCollabAgentState(id, incomingState = {}, fallbackPrompt = '', index = 0) { + const threadId = normalizeCollabAgentThreadId(id); + if (!threadId) return null; + const state = incomingState && typeof incomingState === 'object' ? incomingState : { status: incomingState }; + const previousState = collabAgentStateCache.get(threadId) || {}; + const nextState = mergeCollabAgentTaskState(previousState, state, fallbackPrompt, threadId, index); + const status = cleanCollabAgentText(state.status || state.state || nextState.status || previousState.status || ''); + const detail = cleanCollabAgentText( + state.detail + || state.candidateResult + || state.finalMessage + || state.result + || state.output + || state.summary + || state.message + || state.lastMessage + || state.step + || state.description + || nextState.detail + || previousState.detail + || '' + ); + const role = cleanCollabAgentText(state.role || state.agent || state.agentType || nextState.role || previousState.role || ''); + const cachedState = { ...nextState }; + if (status) cachedState.status = status; + if (detail) cachedState.detail = detail; + if (role) cachedState.role = role; + if (state.candidateResult !== undefined) cachedState.candidateResult = state.candidateResult; + if (state.finalMessage !== undefined) cachedState.finalMessage = state.finalMessage; + if (state.closedAt !== undefined) cachedState.closedAt = state.closedAt; + if (state.returnedAt !== undefined) cachedState.returnedAt = state.returnedAt; + collabAgentStateCache.set(threadId, cachedState); + return cachedState; + } + + function rememberCollabAgentStateFromChild(child, toolUseId = '') { + if (!child || typeof child !== 'object') return ''; + const threadId = normalizeCollabAgentThreadId( + child.threadId + || child.thread_id + || child.agentId + || child.agent_id + || child.id + ); + if (!threadId) return ''; + const incomingState = { + ...child, + label: child.label || child.title || child.nickname || child.name || '', + name: child.name || child.label || threadId, + role: child.role || child.agent || child.agentType || '', + status: child.status || child.state || 'running', + taskDescription: collabAgentTaskDescription(child), + candidateResult: child.finalMessage || child.candidateResult || '', + finalMessage: child.finalMessage || '', + closedAt: child.closedAt || null, + returnedAt: child.returnedAt || null, + }; + const cachedState = rememberCollabAgentState(threadId, incomingState, '', collabAgentStateCache.size); + rememberCollabAgentIdsForTool(toolUseId, [threadId]); + if (collabStateTone(cachedState?.status || child.status) === 'closed') { + closedCollabAgentIds.add(threadId); + rememberClosedCollabAgentIdsForTool(toolUseId, [threadId]); + } + return threadId; + } + function mergeCollabAgentTools(tools, options = {}) { const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : []; if (list.length === 0) return null; @@ -6722,20 +6842,28 @@ const receiverThreadIds = []; const knownClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : closedCollabAgentIds; const localClosedIds = new Set(knownClosedIds || []); + const scopedRestoreIds = options.restoreAgentIds instanceof Set ? options.restoreAgentIds : new Set(); + const scopedClosedIds = options.closedAgentIds instanceof Set ? options.closedAgentIds : new Set(); let toolName = '子代'; let prompt = ''; let status = ''; let done = false; + let hasCloseAction = false; - list.forEach((tool, toolIndex) => { + list.forEach((tool) => { const data = normalizeCollabAgentData(tool); const action = getCollabAgentAction(tool, data); const isCloseAction = action === 'close_agent'; + if (isCloseAction) hasCloseAction = true; const displayAction = String(data.tool || tool.name || '').trim(); if (displayAction && !['wait_agent', 'close_agent'].includes(action)) toolName = displayAction; if (!prompt && data.prompt) prompt = data.prompt; + const toolAgentIds = getCollabAgentIdsFromTool(tool); + const toolClosedIds = getClosedCollabAgentIdsFromTool(tool); + rememberCollabAgentIdsForTool(tool.id, toolAgentIds); + rememberClosedCollabAgentIdsForTool(tool.id, toolClosedIds); if (isCloseAction) { - getCollabAgentIdsFromTool(tool).forEach((id) => localClosedIds.add(id)); + toolAgentIds.forEach((id) => localClosedIds.add(id)); } const dataStatus = isCloseAction ? 'closed' : data.status; if (dataStatus) status = dataStatus; @@ -6750,14 +6878,23 @@ entry.id, receiverThreadIds.indexOf(entry.id) ); - states[entry.id] = { + const nextStatus = isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status; + states[entry.id] = rememberCollabAgentState( + entry.id, + { + ...nextState, + status: nextStatus, + }, + data.prompt, + receiverThreadIds.indexOf(entry.id) + ) || { ...nextState, - status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status, + status: nextStatus, }; if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id); }); - getCollabAgentIdsFromTool(tool).forEach((id) => { + toolAgentIds.forEach((id) => { if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id); const nextState = mergeCollabAgentTaskState( states[id], @@ -6766,28 +6903,77 @@ id, receiverThreadIds.indexOf(id) ); - states[id] = { + const nextStatus = isCloseAction || localClosedIds.has(id) + ? 'closed' + : (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')); + states[id] = rememberCollabAgentState( + id, + { + ...nextState, + status: nextStatus, + }, + data.prompt, + receiverThreadIds.indexOf(id) + ) || { ...nextState, - status: isCloseAction || localClosedIds.has(id) - ? 'closed' - : (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')), + status: nextStatus, }; }); - - if (receiverThreadIds.length === 0 && list.length === 1) { - const fallbackId = tool.id || `tool-${toolIndex + 1}`; - receiverThreadIds.push(fallbackId); - states[fallbackId] = { - ...mergeCollabAgentTaskState({}, { label: '子代理' }, data.prompt, fallbackId, toolIndex), - status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')), - }; - } }); + const recoverSourceIds = hasCloseAction + ? Array.from(scopedClosedIds) + : Array.from(new Set([...scopedRestoreIds, ...scopedClosedIds])); + recoverSourceIds.forEach((id) => { + const threadId = normalizeCollabAgentThreadId(id); + if (!threadId || receiverThreadIds.includes(threadId)) return; + const cachedState = collabAgentStateCache.get(threadId); + if (!cachedState) return; + receiverThreadIds.push(threadId); + const index = receiverThreadIds.indexOf(threadId); + const nextStatus = localClosedIds.has(threadId) + ? 'closed' + : (cachedState.status || (done ? 'completed' : 'running')); + states[threadId] = rememberCollabAgentState( + threadId, + { + ...cachedState, + status: nextStatus, + }, + '', + index + ) || { + ...mergeCollabAgentTaskState(cachedState, { status: nextStatus }, '', threadId, index), + status: nextStatus, + }; + }); + + if (receiverThreadIds.length === 0 && list.length === 1) { + const tool = list[0]; + const data = normalizeCollabAgentData(tool); + const action = getCollabAgentAction(tool, data); + const isCloseAction = action === 'close_agent'; + const fallbackId = tool.id || 'tool-1'; + receiverThreadIds.push(fallbackId); + states[fallbackId] = { + ...mergeCollabAgentTaskState({}, { label: '子代理' }, data.prompt, fallbackId, 0), + status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')), + }; + } + receiverThreadIds.forEach((id, index) => { - states[id] = { + const nextStatus = localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'); + states[id] = rememberCollabAgentState( + id, + { + ...states[id], + status: nextStatus, + }, + '', + index + ) || { ...mergeCollabAgentTaskState(states[id], {}, '', id, index), - status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'), + status: nextStatus, }; }); @@ -7089,6 +7275,9 @@ const epoch = renderEpoch; const baseIndex = Number.isFinite(Number(options.baseIndex)) ? Number(options.baseIndex) : 0; closedCollabAgentIds = collectClosedCollabAgentIds(messages); + collabAgentStateCache = new Map(); + collabAgentIdsByToolUseId = new Map(); + closedCollabAgentIdsByToolUseId = new Map(); messagesDiv.innerHTML = ''; clearUserMessageIndex(); if (messages.length === 0) { @@ -7455,7 +7644,12 @@ rememberClosedCollabAgentIdsFromTool(nextTool); const map = existing?.__collabTools instanceof Map ? existing.__collabTools : new Map(); map.set(toolUseId || nextTool.id || `collab-${map.size + 1}`, nextTool); - const merged = mergeCollabAgentTools(Array.from(map.values())); + const restoreAgentIds = collectScopedCollabAgentIdsForToolMap(map); + const closedAgentIds = collectScopedClosedCollabAgentIdsForToolMap(map); + const mergeOptions = {}; + if (restoreAgentIds.size > 0) mergeOptions.restoreAgentIds = restoreAgentIds; + if (closedAgentIds.size > 0) mergeOptions.closedAgentIds = closedAgentIds; + const merged = mergeCollabAgentTools(Array.from(map.values()), mergeOptions); if (!merged) return existing; if (existing) { @@ -7643,10 +7837,12 @@ const tool = msg?.tool; const toolUseId = msg?.toolUseId || tool?.id; if (!toolUseId || !tool) return; + if (toolKind(tool) !== 'collab_agent_tool_call') return; const isCurrentSessionUpdate = msg.sessionId === currentSessionId; if (isCurrentSessionUpdate) { + const childThreadId = rememberCollabAgentStateFromChild(msg.child, toolUseId); if (msg?.child?.threadId && collabStateTone(msg.child.status) === 'closed') { - closedCollabAgentIds.add(String(msg.child.threadId)); + closedCollabAgentIds.add(String(childThreadId || msg.child.threadId)); } rememberClosedCollabAgentIdsFromTool(tool); } diff --git a/scripts/regression.js b/scripts/regression.js index e14eca1..7e7af84 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -666,6 +666,50 @@ function assertFrontendSubagentCardMetadataContract() { mergeCollabAgentTaskState, }; `)(); + const collabMergeStart = source.indexOf(' function toolKind(tool)'); + const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart); + const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)'); + const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart); + assert(collabMergeStart >= 0 && collabMergeEnd > collabMergeStart, 'Frontend should expose collab merge helpers'); + assert(childUpdateStart >= 0 && childUpdateEnd > childUpdateStart, 'Frontend should define child-agent update handling before delete helpers'); + const collabApi = new Function(` + let currentCwd = ''; + let currentSessionId = 'session-a'; + let closedCollabAgentIds = new Set(); + let collabAgentStateCache = new Map(); + let collabAgentIdsByToolUseId = new Map(); + let closedCollabAgentIdsByToolUseId = new Map(); + const activeToolCalls = new Map(); + let cachedSnapshot = { + messages: [{ + toolCalls: [{ + id: 'tool-collab', + kind: 'collab_agent_tool_call', + input: {}, + }], + }], + }; + function updateCachedSession(sessionId, updater) { + updater(cachedSnapshot); + } + function updateToolCall() {} + function shortChildAgentId(id) { + const value = String(id || ''); + return value.length > 12 ? value.slice(0, 8) : value; + } + ${source.slice(collabMergeStart, collabMergeEnd)} + ${source.slice(childUpdateStart, childUpdateEnd)} + return { + mergeCollabAgentTools, + applyCcwebMcpChildAgentUpdate, + rememberCollabAgentState, + getCachedState: (id) => collabAgentStateCache.get(id), + hasCachedState: (id) => collabAgentStateCache.has(id), + cacheSize: () => collabAgentStateCache.size, + agentIdsForTool: (id) => new Set(collabAgentIdsByToolUseId.get(id) || []), + closedIdsForTool: (id) => new Set(closedCollabAgentIdsByToolUseId.get(id) || []), + }; + `)(); assert(source.includes('function pickCollabAgentTitle(state, id, index)'), 'Frontend should pick sub-agent titles through a dedicated helper'); assert( @@ -747,6 +791,125 @@ function assertFrontendSubagentCardMetadataContract() { const noPrompt = metadataApi.mergeCollabAgentTaskState({}, { label: '子代理' }, '', uuidV7, 0); assert(/^ID\s/.test(noPrompt.label) && noPrompt.label !== uuidV7, 'Missing prompts should fall back to a short thread id'); + + const spawnedTool = { + id: 'tool-collab', + name: 'spawn_agent', + kind: 'collab_agent_tool_call', + input: { + tool: 'spawn_agent', + prompt: '请实现子代理卡片关闭状态保留。', + receiverThreadIds: ['child-thread-a'], + agentsStates: { + 'child-thread-a': { + title: '关闭验证代理', + taskDescription: '请实现子代理卡片关闭状态保留。', + role: 'implementer', + status: 'running', + }, + }, + }, + done: false, + }; + const spawnedMerge = collabApi.mergeCollabAgentTools([spawnedTool]); + assert(spawnedMerge.input.receiverThreadIds.length === 1, 'Spawned child should merge into one visible agent'); + assert(collabApi.getCachedState('child-thread-a')?.label === '关闭验证代理', 'Structured child state should be cached by thread id'); + + collabApi.rememberCollabAgentState( + 'unrelated-child', + { title: '无关历史代理', status: 'running' }, + '请处理无关历史任务。', + 1 + ); + collabApi.applyCcwebMcpChildAgentUpdate({ + type: 'ccweb_mcp_child_agent_update', + sessionId: 'session-a', + toolUseId: 'tool-collab', + child: { + threadId: 'child-thread-a', + status: 'closed', + }, + tool: { + id: 'tool-collab', + name: 'close_agent', + kind: 'collab_agent_tool_call', + input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} }, + result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }), + done: true, + }, + }); + const closedMerge = collabApi.mergeCollabAgentTools([ + { + id: 'tool-collab', + name: 'close_agent', + kind: 'collab_agent_tool_call', + input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} }, + result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }), + done: true, + }, + ], { + restoreAgentIds: new Set(['child-thread-a', 'unrelated-child']), + closedAgentIds: collabApi.closedIdsForTool('tool-collab'), + }); + assert(closedMerge.input.receiverThreadIds.length === 1, 'Empty close tool should keep exactly the closed child card'); + assert(closedMerge.input.receiverThreadIds[0] === 'child-thread-a', 'Empty close tool should restore only the closed child thread id'); + assert(!closedMerge.input.agentsStates['unrelated-child'], 'Empty close recovery must not mix unrelated cached children into the card'); + assert(closedMerge.input.agentsStates['child-thread-a'].label === '关闭验证代理', 'Empty close recovery should preserve the cached child title'); + assert( + closedMerge.input.agentsStates['child-thread-a'].taskDescription === '请实现子代理卡片关闭状态保留。', + 'Empty close recovery should preserve the cached child introduction' + ); + assert(closedMerge.input.agentsStates['child-thread-a'].status === 'closed', 'Empty close recovery should mark the child closed'); + + collabApi.rememberCollabAgentState( + 'wait-child-a', + { title: '等待验证代理', status: 'running' }, + '请等待子代理返回。', + 0 + ); + const emptyWaitMerge = collabApi.mergeCollabAgentTools([ + { + id: 'tool-wait', + name: 'wait_agent', + kind: 'collab_agent_tool_call', + input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} }, + result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }), + done: false, + }, + ], { + restoreAgentIds: new Set(['wait-child-a']), + }); + assert(emptyWaitMerge.input.receiverThreadIds.length === 1, 'Empty wait tool should not reset a cached child card count to zero'); + assert(emptyWaitMerge.input.agentsStates['wait-child-a'].label === '等待验证代理', 'Empty wait recovery should preserve the cached child title'); + + const cacheSizeBeforeOrdinaryTool = collabApi.cacheSize(); + const ordinaryMerge = collabApi.mergeCollabAgentTools([ + { + id: 'ordinary-tool', + name: 'shell', + kind: 'command_execution', + input: { + receiverThreadIds: ['ordinary-child'], + agentsStates: { + 'ordinary-child': { title: '普通工具不应进入子代理缓存', status: 'closed' }, + }, + }, + done: true, + }, + ], { + restoreAgentIds: new Set(['ordinary-child']), + closedAgentIds: new Set(['ordinary-child']), + }); + assert(ordinaryMerge === null, 'Non-collab tools should not enter the collab merge path'); + collabApi.applyCcwebMcpChildAgentUpdate({ + type: 'ccweb_mcp_child_agent_update', + sessionId: 'session-a', + toolUseId: 'ordinary-tool', + child: { threadId: 'ordinary-child', status: 'closed' }, + tool: { id: 'ordinary-tool', name: 'shell', kind: 'command_execution', input: {}, result: '', done: true }, + }); + assert(collabApi.cacheSize() === cacheSizeBeforeOrdinaryTool, 'Non-collab child updates should not write sub-agent state cache'); + assert(!collabApi.hasCachedState('ordinary-child'), 'Non-collab tools should not cache child thread state'); } function assertFrontendPrimaryCodexAppUiContract() { @@ -914,6 +1077,35 @@ function assertSessionSwitchResilienceContract() { ); } +function assertUnlimitedImageAttachmentsContract() { + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + + const handlerStart = frontendSource.indexOf('async function handleSelectedImageFiles(fileList)'); + const handlerEnd = frontendSource.indexOf('\n function getVisibleSessions()', handlerStart); + assert( + handlerStart >= 0 && handlerEnd > handlerStart, + 'Frontend should define handleSelectedImageFiles before getVisibleSessions' + ); + const handlerBlock = frontendSource.slice(handlerStart, handlerEnd); + assert( + !/pendingAttachments\.length\s*\+\s*files\.length\s*>\s*\d+/.test(handlerBlock), + 'Frontend image attachment picker should not enforce a numeric per-message attachment cap' + ); + assert( + !/单条消息最多附带\s*\d+\s*张图片/.test(handlerBlock), + 'Frontend image attachment picker should not show a per-message image count limit' + ); + assert( + !serverSource.includes('MAX_MESSAGE_ATTACHMENTS'), + 'Server should not define or use MAX_MESSAGE_ATTACHMENTS' + ); + assert( + !/msg\.attachments\s*\.slice\s*\(\s*0\s*,/.test(serverSource), + 'Server should not truncate msg.attachments with slice' + ); +} + function extractFunctionSource(source, name) { const start = source.indexOf(`function ${name}(`); assert(start >= 0, `Server should define ${name}`); @@ -945,6 +1137,94 @@ function extractFunctionSource(source, name) { throw new Error(`Could not parse function body for ${name}`); } +function assertCodexAppChildToolFallbackContract() { + const source = fs.readFileSync(SERVER_PATH, 'utf8'); + const helperStart = source.indexOf('function parseMaybeJsonObject(value)'); + const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart); + assert(helperStart >= 0 && helperEnd > helperStart, 'Server should keep ccweb MCP child helper block before sendCcwebMcpChildAgentUpdate'); + const helperSource = source.slice(helperStart, helperEnd); + const api = new Function(` + const sessions = new Map(); + let savedSession = null; + function truncateTextValue(value, maxLength, suffix = '...') { + const text = String(value || ''); + return text.length > maxLength ? text.slice(0, maxLength - suffix.length) + suffix : text; + } + function loadSession(sessionId) { + return sessions.get(sessionId) || null; + } + function saveSession(session) { + savedSession = JSON.parse(JSON.stringify(session)); + sessions.set(session.id, session); + } + function findViewingSessionWs() { + return null; + } + ${helperSource} + return { + setSession: (session) => sessions.set(session.id, session), + getSession: (sessionId) => sessions.get(sessionId), + getSavedSession: () => savedSession, + updatePersistedCcwebMcpChildTool, + }; + `)(); + + const session = { + id: 'fallback-session', + messages: [ + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'wait-collab-tool', + name: 'wait_agent', + kind: 'collab_agent_tool_call', + input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} }, + result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }), + done: false, + }, + ], + }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'ordinary-command-tool', + name: 'shell', + kind: 'command_execution', + input: { command: 'echo should-not-be-selected' }, + result: 'ordinary output', + done: true, + }, + ], + }, + ], + }; + api.setSession(session); + const persistedTool = api.updatePersistedCcwebMcpChildTool(session.id, { + threadId: 'child-refresh-thread', + spawnToolId: 'missing-spawn-tool', + label: '刷新路径代理', + taskDescription: '验证刷新路径不会丢失子代理卡片。', + status: 'closed', + candidateResult: '子代理已关闭', + closedAt: '2026-07-15T00:00:00.000Z', + }); + assert(persistedTool?.id === 'wait-collab-tool', 'Missing spawnToolId should fall back to the latest collab tool'); + assert(session.messages[1].toolCalls[0].result === 'ordinary output', 'Fallback must not merge child state into ordinary command_execution tools'); + const result = JSON.parse(persistedTool.result); + assert(result.receiverThreadIds.includes('child-refresh-thread'), 'Persisted fallback collab tool should include child thread id'); + assert(result.agentsStates?.['child-refresh-thread']?.title === '刷新路径代理', 'Persisted fallback collab tool should store child title'); + assert( + result.agentsStates?.['child-refresh-thread']?.taskDescription === '验证刷新路径不会丢失子代理卡片。', + 'Persisted fallback collab tool should store child task description' + ); + assert(result.agentsStates?.['child-refresh-thread']?.status === 'closed', 'Persisted fallback collab tool should store child closed status'); + assert(api.getSavedSession()?.id === session.id, 'Persisted fallback merge should save the session'); +} + function assertCodexAppUnroutedNotificationRoutingContract() { const source = fs.readFileSync(SERVER_PATH, 'utf8'); assert(source.includes('const codexAppThreadSessionIndex = new Map();'), 'Server should keep an O(1) Codex App thread -> session index'); @@ -996,6 +1276,7 @@ async function main() { return; } + assertUnlimitedImageAttachmentsContract(); assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); assertFrontendSlashDraftPreservationContract(); @@ -1007,6 +1288,7 @@ async function main() { assertFrontendPrimaryCodexAppUiContract(); assertSetTitleMcpContract(); assertSessionSwitchResilienceContract(); + assertCodexAppChildToolFallbackContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); @@ -2387,13 +2669,17 @@ async function main() { ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId })); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); - const claudeAttachment = await uploadAttachment(port, token, { - filename: 'claude-test.png', + const tinyPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64' + ); + const claudeAttachments = await Promise.all(Array.from({ length: 5 }, (_, index) => uploadAttachment(port, token, { + filename: `claude-test-${index + 1}.png`, mime: 'image/png', - data: Buffer.from('claude-image'), - }); - ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' })); - const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment'); + data: tinyPng, + }))); + ws.send(JSON.stringify({ type: 'message', text: 'describe attachments', attachments: claudeAttachments, mode: 'yolo', agent: 'claude' })); + const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachments'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId); const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8') .trim() @@ -2401,7 +2687,15 @@ async function main() { .find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8))); assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json'); const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8')); - assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata'); + const storedClaudeUserMessage = storedClaudeSession.messages?.find((message) => message.role === 'user' && message.content === 'describe attachments'); + assert( + Array.isArray(storedClaudeUserMessage?.attachments) && storedClaudeUserMessage.attachments.length === claudeAttachments.length, + 'Claude message should persist all attachment metadata' + ); + const storedClaudeAttachmentNames = storedClaudeUserMessage.attachments.map((attachment) => attachment.filename); + for (const attachment of claudeAttachments) { + assert(storedClaudeAttachmentNames.includes(attachment.filename), `Claude message should preserve attachment ${attachment.filename}`); + } assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run'); const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId; diff --git a/server.js b/server.js index ea03cc9..84acf0e 100644 --- a/server.js +++ b/server.js @@ -86,7 +86,6 @@ const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs'); const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments'); const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; -const MAX_MESSAGE_ATTACHMENTS = 4; const FILE_BROWSER_MAX_LIST_ENTRIES = 400; const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024; const COMPOSER_SUGGESTION_LIMIT = 20; @@ -8021,7 +8020,7 @@ function handleMessage(ws, msg, options = {}) { }; const textValue = typeof text === 'string' ? text : ''; let runtimeTextValue = typeof options.runtimeText === 'string' ? options.runtimeText : textValue; - const attachments = Array.isArray(msg.attachments) ? msg.attachments.slice(0, MAX_MESSAGE_ATTACHMENTS) : []; + const attachments = Array.isArray(msg.attachments) ? msg.attachments : []; const normalizedText = textValue.trim(); let normalizedRuntimeText = runtimeTextValue.trim(); const resolvedAttachments = resolveMessageAttachments(attachments); @@ -8704,6 +8703,47 @@ function ccwebMcpChildPublicState(child = {}) { }; } +function isCcwebMcpCollabToolCall(tool = {}) { + if (!tool || typeof tool !== 'object') return false; + const kind = String(tool.kind || tool.meta?.kind || '').trim(); + if (kind) return kind === 'collab_agent_tool_call'; + const input = parseMaybeJsonObject(tool.input) || (tool.input && typeof tool.input === 'object' ? tool.input : {}); + const result = parseMaybeJsonObject(tool.result) || (tool.result && typeof tool.result === 'object' ? tool.result : {}); + const action = codexAppCollabToolName(input.tool || result.tool || tool.tool || tool.name || tool.meta?.title || ''); + return ['spawn_agent', 'wait_agent', 'close_agent', 'send_input', 'resume_agent'].includes(action); +} + +function findExactCcwebMcpChildToolInToolCalls(toolCalls = [], spawnToolId = '') { + const targetId = String(spawnToolId || '').trim(); + if (!targetId || !Array.isArray(toolCalls)) return null; + return toolCalls.find((item) => item?.id === targetId && isCcwebMcpCollabToolCall(item)) || null; +} + +function findLatestCcwebMcpCollabToolInToolCalls(toolCalls = []) { + if (!Array.isArray(toolCalls)) return null; + for (let i = toolCalls.length - 1; i >= 0; i -= 1) { + if (isCcwebMcpCollabToolCall(toolCalls[i])) return toolCalls[i]; + } + return null; +} + +function findCcwebMcpChildTargetToolInToolCalls(toolCalls = [], spawnToolId = '') { + return findExactCcwebMcpChildToolInToolCalls(toolCalls, spawnToolId) + || findLatestCcwebMcpCollabToolInToolCalls(toolCalls); +} + +function findCcwebMcpChildTargetToolInMessages(messages = [], spawnToolId = '') { + if (!Array.isArray(messages)) return null; + let latestCollabTool = null; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const toolCalls = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : []; + const exactTool = findExactCcwebMcpChildToolInToolCalls(toolCalls, spawnToolId); + if (exactTool) return exactTool; + if (!latestCollabTool) latestCollabTool = findLatestCcwebMcpCollabToolInToolCalls(toolCalls); + } + return latestCollabTool; +} + function mergeCcwebMcpChildIntoTool(tool, child) { if (!tool) return null; const candidateResult = child.finalMessage || child.candidateResult || ''; @@ -8721,11 +8761,24 @@ function mergeCcwebMcpChildIntoTool(tool, child) { ...(result.agentsStates && typeof result.agentsStates === 'object' ? result.agentsStates : {}), ...(result.agents_states && typeof result.agents_states === 'object' ? result.agents_states : {}), }; + const previousState = agentsStates[child.threadId] && typeof agentsStates[child.threadId] === 'object' ? agentsStates[child.threadId] : {}; + const taskDescription = child.taskDescription + || child.task_description + || child.taskPrompt + || child.task_prompt + || child.prompt + || previousState.taskDescription + || previousState.task_description + || input.prompt + || result.prompt + || ''; agentsStates[child.threadId] = { - ...(agentsStates[child.threadId] && typeof agentsStates[child.threadId] === 'object' ? agentsStates[child.threadId] : {}), + ...previousState, + title: child.title || child.label || previousState.title || '', name: child.label || agentsStates[child.threadId]?.name || child.threadId, role: child.role || agentsStates[child.threadId]?.role || '', status: child.status || 'running', + taskDescription, summary: ccwebMcpChildSummary(child), candidateResult, finalMessage: child.finalMessage || '', @@ -8755,15 +8808,12 @@ function mergeCcwebMcpChildIntoTool(tool, child) { function updateCcwebMcpChildToolState(sessionId, child) { const entry = activeCodexAppTurns.get(sessionId) || null; - let tool = entry?.toolCalls?.find((item) => item.id === child.spawnToolId) || null; + let tool = findCcwebMcpChildTargetToolInToolCalls(entry?.toolCalls, child.spawnToolId); if (!tool) { const session = loadSession(sessionId); const messages = Array.isArray(session?.messages) ? session.messages : []; - for (let i = messages.length - 1; i >= 0 && !tool; i -= 1) { - const list = Array.isArray(messages[i]?.toolCalls) ? messages[i].toolCalls : []; - tool = list.find((item) => item.id === child.spawnToolId) || null; - } + tool = findCcwebMcpChildTargetToolInMessages(messages, child.spawnToolId); } return mergeCcwebMcpChildIntoTool(tool, child); } @@ -8771,11 +8821,7 @@ function updateCcwebMcpChildToolState(sessionId, child) { function updatePersistedCcwebMcpChildTool(sessionId, child) { const session = loadSession(sessionId); if (!session || !Array.isArray(session.messages)) return null; - let targetTool = null; - for (let i = session.messages.length - 1; i >= 0 && !targetTool; i -= 1) { - const list = Array.isArray(session.messages[i]?.toolCalls) ? session.messages[i].toolCalls : []; - targetTool = list.find((item) => item.id === child.spawnToolId) || null; - } + const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId); if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null; session.updated = new Date().toISOString(); if (!findViewingSessionWs(sessionId)) session.hasUnread = true;