恢复 Codex PlanList 并更新发布包
This commit is contained in:
@@ -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(() => {});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user