chore: rebuild release package
This commit is contained in:
@@ -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 合并进普通工具。
|
||||
|
||||
@@ -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 缺失时回退最近协作工具,刷新闭环回归及最终独立复核通过。
|
||||
|
||||
@@ -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 合并进最近协作工具并持久化 |
|
||||
|
||||
@@ -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。
|
||||
- 不新增依赖,不改变后端协议,不重启服务。
|
||||
- 不覆盖当前工作树中图片附件数量限制相关改动。
|
||||
|
||||
@@ -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 在临时目录中校验主路径
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,7 +106,7 @@ Codex App 原生协作工具会被转成页面上的子代理状态卡片:
|
||||
- 会话运行中、等待跨对话回复、未读状态等可视化提示
|
||||
- Markdown 渲染、代码块高亮、复制、预览
|
||||
- 图片附件上传、剪贴板粘贴、缩略图和放大预览
|
||||
- 附件大小限制 10MB,单条消息最多 4 张图片,默认 7 天过期
|
||||
- 附件大小限制 10MB,默认 7 天过期;cc-web 不限制单条消息的图片张数
|
||||
- Agent 输出分隔线可显示时间,也可在设置中关闭
|
||||
- 每条助手气泡末尾提供“定位到本条最后一段”的按钮
|
||||
- 跨对话返回气泡支持折叠,并把折叠状态缓存到浏览器
|
||||
|
||||
Binary file not shown.
250
public/app.js
250
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
72
server.js
72
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;
|
||||
|
||||
Reference in New Issue
Block a user