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 b4bab58..c7b5db6 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/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index c1ba736..b5aaa68 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -126,6 +126,33 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_update_plan', + description: '当原生 update_plan 工具不可用时,在当前 ccweb 对话展示并更新多步骤计划。每次传入完整计划;开始、完成步骤时重新调用以同步状态。简单任务无需调用。', + inputSchema: { + type: 'object', + properties: { + explanation: { type: 'string', maxLength: 500, description: '可选。此次更新的简短原因。' }, + plan: { + type: 'array', + minItems: 1, + maxItems: 20, + items: { + type: 'object', + properties: { + step: { type: 'string', maxLength: 500, description: '计划步骤。' }, + status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: '步骤状态。' }, + }, + required: ['step', 'status'], + additionalProperties: false, + }, + description: '当前完整计划,按执行顺序排列。', + }, + }, + required: ['plan'], + additionalProperties: false, + }, + }, { name: 'ccweb_create_conversation', description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。工具调用会立即返回。启用 requestReply 后,默认由系统异步回传结果;除非用户明确要求轮询,否则不主动轮询等待。', diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 8033de4..ec26817 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -152,6 +152,10 @@ function createCodexAppRuntime(deps = {}) { return CODEX_APP_PLAN_TOOL_NAMES.has(name) || name.endsWith('_update_plan'); } + function isCcwebPlanItem(item) { + return item?.type === 'mcpToolCall' && item.server === 'ccweb' && item.tool === 'ccweb_update_plan'; + } + function isPlanLikeItem(item) { if (!item || typeof item !== 'object') return false; if (CODEX_APP_PLAN_ITEM_TYPES.has(normalizeIdentifier(item.type))) return true; @@ -248,7 +252,7 @@ function createCodexAppRuntime(deps = {}) { }) .filter(Boolean); return { - id: item.id || item.itemId || item.planId || 'codex-app-plan', + id: isCcwebPlanItem(item) ? 'ccweb-update-plan' : (item.id || item.itemId || item.planId || 'codex-app-plan'), type: 'todo_list', items, progress: summarizeTodoListProgress(items), @@ -587,7 +591,7 @@ function createCodexAppRuntime(deps = {}) { return { kind: 'todo_list', title: 'Plan List', - subtitle: item.explanation || item.title || item.tool || '', + subtitle: item.explanation || item.title || (isCcwebPlanItem(item) ? '' : item.tool || ''), status: todoListPlanStatus(item, todoList), progress: todoList.progress, }; @@ -698,7 +702,8 @@ function createCodexAppRuntime(deps = {}) { function ensureToolCall(entry, item, sessionId) { if (!item?.id) return null; const kind = itemKind(item); - let toolCall = entry.toolCalls.find((tool) => tool.id === item.id); + const itemId = isCcwebPlanItem(item) ? 'ccweb-update-plan' : item.id; + let toolCall = entry.toolCalls.find((tool) => tool.id === itemId); if (toolCall) { toolCall.name = itemName(item); toolCall.kind = kind; @@ -738,7 +743,7 @@ function createCodexAppRuntime(deps = {}) { toolCall = { name: itemName(item), - id: item.id, + id: itemId, kind, meta: itemMeta(item), input: itemInput(item), diff --git a/public/app.js b/public/app.js index abf56a4..e42444b 100644 --- a/public/app.js +++ b/public/app.js @@ -11329,7 +11329,7 @@ // - For non-Codex sessions, auto-open in-flight command execution so users can watch output. // - For Codex sessions, keep everything collapsed by default (less noise), including in-flight commands. const agent = normalizeAgent(currentAgent); - if (tool.name === 'AskUserQuestion') { + if (tool.name === 'AskUserQuestion' || kind === 'todo_list') { details.open = true; } else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') { details.open = true; diff --git a/scripts/history-browser-check.js b/scripts/history-browser-check.js index 00a4481..36afd75 100644 --- a/scripts/history-browser-check.js +++ b/scripts/history-browser-check.js @@ -19,9 +19,12 @@ const QUESTION = '没参考其他的页面么,WMSA51 是你这么写的么?' const ARCHITECTURE = '先按项目规范核对现有页面、任务票和负责会话,再把样式返工交回原实现负责人。'; const BOUNDARY = '这不是单纯的样式问题,而是 T-003 的页面职责和流程边界做错了。'; const STREAM = '当前正在生成的消息:正在核对页面职责与流程边界。'; +const PLAN_STEPS = ['读取历史计划状态', '检查实时计划卡片', '验证刷新后仍显示']; +const PLAN_SOURCE = process.env.CC_WEB_HISTORY_PLAN_SOURCE === 'mcp' ? 'mcp' : 'native'; const SESSION_ID = '11111111-2222-4333-8444-555555555555'; const THREAD_ID = '11111111-2222-4333-8444-666666666666'; const PASSWORD = 'HistoryBrowserFixture!234'; +const MCP_TOKEN = 'HistoryBrowserMcpFixture!234'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function freePort() { @@ -145,7 +148,16 @@ Module._extensions['.js'] = (module, filename) => { const existingMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8'); const timerCode = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);'; assert(existingMock.includes(timerCode), 'mock 运行时启动入口已变更'); + const planEntries = PLAN_STEPS.map((step, index) => ({ + step, status: ['completed', PLAN_SOURCE === 'mcp' ? 'in_progress' : 'inProgress', 'pending'][index], + })); + const planNotification = PLAN_SOURCE === 'mcp' + ? `const planItem={type:'mcpToolCall',id:'browser-plan',server:'ccweb',tool:'ccweb_update_plan',arguments:{plan:${JSON.stringify(planEntries)}}}; + send({method:'item/started',params:{threadId:thread.id,turnId,item:planItem}}); + send({method:'item/completed',params:{threadId:thread.id,turnId,item:planItem}});` + : `send({method:'turn/plan/updated',params:{threadId:thread.id,turnId,plan:${JSON.stringify(planEntries)}}});`; const browserMock = existingMock.replace(timerCode, ` + ${planNotification} send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-stream',delta:${JSON.stringify(STREAM)}}}); thread.timer = setInterval(() => { if (!require('fs').existsSync(${JSON.stringify(completeFlag)})) return; @@ -218,15 +230,27 @@ async function capture(driver, stage, root, requireStream = true) { assert.equal(result.messages[userIndex + 3].source, '来自「页面实现负责人」的回复'); assert(!result.messages.some((message) => message.text.includes('native 不应覆盖') || message.text.includes('native 内部输入'))); if (requireStream) assert.equal(find(STREAM).length, 1, `${stage}:当前输出重复或丢失`); + if (requireStream) { + const plan = await driver.execute(`return [...document.querySelectorAll('#messages .tool-call[data-tool-kind="todo_list"]')].map(node => ({ + open: node.open, steps: [...node.querySelectorAll('.todo-text')].map(item => item.textContent), + visible: node.getBoundingClientRect().height > 0 && getComputedStyle(node).display !== 'none', + }));`); + assert.equal(plan.length, 1, `${stage}:原生 update_plan 没有渲染为唯一 PlanList 卡片`); + assert(plan[0].open && plan[0].visible, `${stage}:PlanList 卡片存在但内容被折叠或隐藏`); + assert.deepEqual(plan[0].steps, PLAN_STEPS, `${stage}:PlanList 条目缺失或顺序错误`); + } const keys = result.messages.map((message) => message.key).filter(Boolean); assert.equal(new Set(keys).size, keys.length, `${stage}:稳定 ID 重复渲染`); await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`); - // 等待浏览器提交滚动与消息淡入,截图必须展示真实可见内容。 - await sleep(500); - result.visibleTail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => { - const box=node.getBoundingClientRect(), style=getComputedStyle(node); - return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display}; - });`); + // 等待滚动和入场动画完成后,再验证截图中的实际可见内容。 + result.visibleTail = await waitFor(async () => { + const tail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => { + const box=node.getBoundingClientRect(), style=getComputedStyle(node); + return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display}; + });`); + const question = tail.find((message) => message.text.includes(QUESTION)); + return question && question.top >= 0 && question.height > 0 && Number(question.opacity) > 0.9 ? tail : null; + }, `${stage}:目标问题没有实际显示在视口`); const visibleQuestion = result.visibleTail.find((message) => message.text.includes(QUESTION)); assert(visibleQuestion && visibleQuestion.top >= 0 && visibleQuestion.height > 0 && Number(visibleQuestion.opacity) > 0.9, `${stage}:目标问题没有实际显示在视口`); @@ -244,12 +268,24 @@ async function main() { try { server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], { cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, + CC_WEB_INTERNAL_MCP_TOKEN: MCP_TOKEN, CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'), + CODEX_HOME: path.join(fixture.home, '.codex'), CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0', }, stdio: ['ignore', 'pipe', 'pipe'], }); const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog); await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败'); + const mcpResponse = await fetch(`http://127.0.0.1:${serverPort}/api/internal/mcp/stream?sourceSessionId=${SESSION_ID}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-cc-web-mcp-token': MCP_TOKEN }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { + name: 'ccweb_update_plan', arguments: { plan: [{ step: PLAN_STEPS[0], status: 'completed' }] }, + } }), + }); + const mcpResult = await mcpResponse.json(); + assert.equal(mcpResult.result?.structuredContent?.progress?.completed, 1, + `ccweb_update_plan MCP 调用未接受有效计划:${JSON.stringify(mcpResult)}`); relay = await startRelay(relayPort, serverPort, evidence); gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] }); const geckoLog = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(geckoLog); gecko.stderr.pipe(geckoLog); @@ -281,7 +317,7 @@ async function main() { evidence.stages.completedRefreshed = await capture(driver, 'completed-refreshed', root); assert(evidence.protocol.some((message) => message.historySource === 'merged'), '没有验证到服务端真实 native 前缀合并'); evidence.ok = true; - console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2)); + console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, planSource: PLAN_SOURCE, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2)); } catch (error) { evidence.ok = false; evidence.error = error.stack; if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {}); diff --git a/scripts/regression.js b/scripts/regression.js index 632a5b0..a02ad54 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1122,10 +1122,10 @@ function assertPlanListProgressContract() { }); const entry = { ws: {}, toolCalls: [], fullText: '' }; runtime.processCodexAppNotification(entry, { - method: 'plan/updated', + method: 'turn/plan/updated', params: { - id: 'regression-plan-progress', - status: 'inProgress', + threadId: 'regression-thread', + turnId: 'regression-turn', plan: [ { step: '完成数据契约', status: 'completed' }, { step: '完成 DOM', status: 'completed' }, @@ -1135,13 +1135,41 @@ function assertPlanListProgressContract() { ], }, }, 'plan-progress-session'); - const update = sent.find((message) => message.type === 'tool_update' && message.toolUseId === 'regression-plan-progress'); + const update = sent.find((message) => message.type === 'tool_update' && message.toolUseId === 'codex-app-plan'); assert(update, 'Plan updates should emit a tool_update payload'); + const start = sent.find((message) => message.type === 'tool_start' && message.toolUseId === 'codex-app-plan'); + assert(start?.name === 'PlanList' && start?.kind === 'todo_list', 'Native update_plan should start a visible PlanList tool card'); assert(update.meta?.progress?.completed === 3 && update.meta?.progress?.total === 5, 'Plan metadata should expose 3/5 progress'); assert(update.input?.progress?.completed === 3 && update.input?.progress?.total === 5, 'Normalized todo input should expose 3/5 progress'); + const cardSource = extractFunctionSource(source, 'createToolCallElement'); + assert(cardSource.includes("tool.name === 'AskUserQuestion' || kind === 'todo_list'"), 'PlanList contents should be expanded by default'); const result = JSON.parse(update.result); assert(result.progress?.completed === 3 && result.progress?.total === 5, 'Persisted todo result should expose 3/5 progress'); + const { TOOLS } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server')); + const fallbackTool = TOOLS.find((tool) => tool.name === 'ccweb_update_plan'); + assert(fallbackTool?.inputSchema?.properties?.plan?.maxItems === 20, 'ccweb MCP should expose a bounded update_plan fallback'); + assert(fs.readFileSync(SERVER_PATH, 'utf8').includes("case 'ccweb_update_plan':"), 'Internal MCP should handle the update_plan fallback'); + const fallbackEntry = { ws: {}, toolCalls: [], fullText: '' }; + const fallbackPlan = (status) => ({ + type: 'mcpToolCall', server: 'ccweb', tool: 'ccweb_update_plan', + arguments: { plan: [ + { step: '准备计划', status: 'completed' }, + { step: '完成实现', status }, + ] }, + }); + for (const [id, status] of [['mcp-plan-first', 'in_progress'], ['mcp-plan-second', 'completed']]) { + const item = { ...fallbackPlan(status), id }; + runtime.processCodexAppNotification(fallbackEntry, { method: 'item/started', params: { item } }, 'fallback-plan-session'); + runtime.processCodexAppNotification(fallbackEntry, { method: 'item/completed', params: { item } }, 'fallback-plan-session'); + } + const fallbackStarts = sent.filter((message) => message.type === 'tool_start' && message.toolUseId === 'ccweb-update-plan'); + assert(fallbackStarts.length === 1, 'Repeated fallback updates should keep one PlanList card'); + assert(fallbackStarts[0].name === 'PlanList' && fallbackStarts[0].kind === 'todo_list', 'Fallback should render as a PlanList card'); + assert(fallbackEntry.toolCalls.length === 1, 'Repeated fallback updates should persist one PlanList tool call'); + assert(JSON.stringify(JSON.parse(fallbackEntry.toolCalls[0].result).progress) === JSON.stringify({ completed: 2, total: 2 }), + 'Fallback PlanList should persist the latest progress'); + const summarySource = extractFunctionSource(source, 'applyToolSummary'); const progressElementSource = extractFunctionSource(source, 'createPlanProgressElementFromProgress'); assert(summarySource.includes('createPlanProgressElement(tool)'), 'Tool summaries should append the plan progress element beside the title'); diff --git a/server.js b/server.js index 230471c..3bae6fb 100644 --- a/server.js +++ b/server.js @@ -1539,6 +1539,7 @@ const CCWEB_TITLE_TOOL_INSTRUCTIONS = [ ].join('\n'); const CODEX_APP_COLLABORATION_INSTRUCTIONS = [ CCWEB_TITLE_TOOL_INSTRUCTIONS, + 'For multi-step work that warrants a progress plan, use the native update_plan tool when it is available. If it is not in the current tool list, use mcp__ccweb__ccweb_update_plan instead; send the full plan and update step statuses as work progresses. Do not use a plan for trivial tasks.', 'Codex sub-agent runtime rules:', '- Follow the current runtime tool schema and tool descriptions; do not assume optional fields exist.', '- Do not hard-code version-specific spawn, wait, or completion behavior in prompts.', @@ -8414,6 +8415,30 @@ function insertJavascriptRunFailureNotice(sourceConversationId, entry = {}) { return true; } +function updateCcwebPlan(args = {}, sourceSessionId = '') { + const sessionId = sanitizeId(sourceSessionId); + if (!sessionId || !loadSession(sessionId)) { + return mcpToolError('conversation_not_found', '当前来源对话不存在。'); + } + const entries = args.plan; + if (!Array.isArray(entries) || entries.length < 1 || entries.length > 20) { + return mcpToolError('invalid_plan', 'plan 必须包含 1 到 20 个步骤。'); + } + const plan = entries.map((entry) => ({ + step: typeof entry?.step === 'string' ? entry.step.trim() : '', + status: entry?.status, + })); + if (plan.some((entry) => !entry.step || entry.step.length > 500 + || !['pending', 'in_progress', 'completed'].includes(entry.status))) { + return mcpToolError('invalid_plan', '每个步骤都需要不超过 500 字的 step 和有效的 status。'); + } + return { + ok: true, + plan, + progress: { completed: plan.filter((entry) => entry.status === 'completed').length, total: plan.length }, + }; +} + function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount, scriptContext = {}) { switch (tool) { case 'ccweb_internal_tools_list': @@ -8430,6 +8455,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount, script return listUserInputHistory(args, sourceSessionId); case 'ccweb_set_title': return setCurrentConversationTitle(args, sourceSessionId); + case 'ccweb_update_plan': + return updateCcwebPlan(args, sourceSessionId); case 'ccweb_create_conversation': return createMcpConversation(args, sourceSessionId, sourceHopCount); case 'ccweb_send_message':