chore: rebuild release package

This commit is contained in:
shiyue
2026-07-15 15:00:58 +08:00
parent 141a266f34
commit 614e222f14
11 changed files with 631 additions and 52 deletions

View File

@@ -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 合并进普通工具。

View File

@@ -14,3 +14,11 @@
- 2026-07-11语法、diff、完整 regression 和独立复核全部通过;进入交付清理。
- 2026-07-11最终 diff 与行号核验完成。实时服务在线但两次 5 秒请求无响应,环境无 Playwright/Chromium因此未截图且未重启服务。
- 2026-07-11Trellis 规范沉淀判断:本次为局部子代理卡片行为,核心边界已固化在行为回归中,没有形成跨模块通用约定,不更新 `.trellis/spec/`
- 2026-07-15用户反馈关闭后卡片变成“子代 0 个”。定位到同 ID 关闭更新覆盖旧工具快照,开始修复。
- 2026-07-15真实会话仅持久化空 wait 工具;方案调整为缓存 child 结构化状态,并补充工作树保护和普通工具回归约束。
- 2026-07-15Phase 7 计划复审通过,进入关闭空载荷行为回归。
- 2026-07-15行为回归覆盖 close/wait 空载荷及普通工具隔离,进入状态缓存实现。
- 2026-07-15完成 threadId 状态缓存和 toolUseId 作用域映射,进入关闭卡片重建核验。
- 2026-07-15空 close/wait 卡片重建实现与完整回归通过,进入独立复核。
- 2026-07-15独立复核发现刷新后历史仍丢失追加服务端持久化兜底修复。
- 2026-07-15服务端在 spawnToolId 缺失时回退最近协作工具,刷新闭环回归及最终独立复核通过。

View File

@@ -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 合并进最近协作工具并持久化 |

View File

@@ -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。
- 不新增依赖,不改变后端协议,不重启服务。
- 不覆盖当前工作树中图片附件数量限制相关改动。

View File

@@ -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 在临时目录中校验主路径

View File

@@ -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

View File

@@ -106,7 +106,7 @@ Codex App 原生协作工具会被转成页面上的子代理状态卡片:
- 会话运行中、等待跨对话回复、未读状态等可视化提示
- Markdown 渲染、代码块高亮、复制、预览
- 图片附件上传、剪贴板粘贴、缩略图和放大预览
- 附件大小限制 10MB单条消息最多 4 张图片,默认 7 天过期
- 附件大小限制 10MB默认 7 天过期cc-web 不限制单条消息的图片张数
- Agent 输出分隔线可显示时间,也可在设置中关闭
- 每条助手气泡末尾提供“定位到本条最后一段”的按钮
- 跨对话返回气泡支持折叠,并把折叠状态缓存到浏览器

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;