fix(progress): 停压 task_progress 参数修进度还原 + 进度区移到对话区顶部

问题1(进度不对): 上下文压缩把旧 task_progress tool_call 参数换成
{"_compacted":true,"step_id":"sX"} 这种像合法调用的标记, 既毒化模型让它
照抄出残废 update_step(丢 step.status)入库, 又让前端 applyProgressAction
读不到 args.step → 步骤永停 pending。修复: task_progress 参数一律不压缩。

问题2(没像 codex 在顶部): 删掉每条消息卡内联进度块, 进度统一只在对话区
顶部单一 dock 实时显示(钉顶不滚); 全部完成时折叠成一行摘要。prompt/tool
描述改为跑完标 completed 而非 clear, 留住全绿收尾。

校验: unittest test_context_compaction/test_task_progress_tool 12 过;
node --test frontend_task_progress 2 过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-08 09:52:44 +08:00
parent 2136fdd306
commit 824f746571
7 changed files with 50 additions and 72 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-08(task_progress 进度工具 + 前端固定进度区 + 静态资源 no-cache) 最后更新:2026-06-08(进度还原修复:停压 task_progress 参数 + 进度区移到对话区顶部 + 完成态折叠)
--- ---
@ -23,6 +23,7 @@
### 2026-06-08 ### 2026-06-08
- **修进度还原错乱 + 进度区移到对话区顶部(codex 式)**:根因(查 DB 实锤)= 上下文压缩把旧 `task_progress` tool_call 参数换成 `{"_compacted":true,"step_id":"sX"}` 这种"看着像合法调用"的标记,① 毒化模型让它后续照抄出残废 `update_step`(丢了 `step.status`)并入库,② 残废格式前端 `applyProgressAction` 读不到 `args.step` → s4/s5 永停 pending → 进度显示不对。修复:`context.py` 对 `task_progress` 参数**一律不压缩**(参数本就小,压缩省不了几个 token 却两头坏事);旧的 `_compact_task_progress_arguments` 整个删除。**进度展示重构**:删掉每条消息卡内联进度块(`renderProgressHtml`/`renderProgressInto` 移除),进度统一只在**对话区顶部**单一 `#task-progress-dock`(从 composer 上方移到 `chat-stream` 之上、`flex-shrink:0` 钉顶不滚)实时显示;**完成态折叠**——全部步骤 completed 时 dock 自动收成一行 `✓ 全部完成 · N/N 步`(`<details>` 点开看清单)。prompt + tool 描述改为"跑完把最后一步标 `completed`、不要 `clear`",留住全绿收尾。校验:`python -m unittest tests.test_context_compaction tests.test_task_progress_tool`(12 过,改写 `test_keeps_old_task_progress_arguments_intact` 断言参数原样保留);`node --test tests/frontend_task_progress.test.mjs`(2 过)。
- **修登录无反应(`$ is not defined`)+ 补 favicon 消 404**:`newtask.js` 用了 DOM 简写 `$`(`dom.js` 导出的 `getElementById`)却漏 import,模块加载到顶层 `$("hd-new").onclick` 即抛 `ReferenceError: $ is not defined`,中断 newtask 全部绑定及其 import 的 auth/chat 链路 → 点登录无反应。补 `import { $ } from "./dom.js"` 与其余模块对齐。另在 `dev.html` `<head>` 加内联 SVG data-URI `<link rel="icon">`(蓝底白机器人),浏览器不再请求根 `/favicon.ico`,消掉 404;选内联 SVG 而非新增 `.ico` 文件 / 服务端路由,零新增文件零 app.py 改动。 - **修登录无反应(`$ is not defined`)+ 补 favicon 消 404**:`newtask.js` 用了 DOM 简写 `$`(`dom.js` 导出的 `getElementById`)却漏 import,模块加载到顶层 `$("hd-new").onclick` 即抛 `ReferenceError: $ is not defined`,中断 newtask 全部绑定及其 import 的 auth/chat 链路 → 点登录无反应。补 `import { $ } from "./dom.js"` 与其余模块对齐。另在 `dev.html` `<head>` 加内联 SVG data-URI `<link rel="icon">`(蓝底白机器人),浏览器不再请求根 `/favicon.ico`,消掉 404;选内联 SVG 而非新增 `.ico` 文件 / 服务端路由,零新增文件零 app.py 改动。
- **新增 Codex 式 `task_progress` 进度工具 + Web 固定进度区**:`TaskProgressTool` 默认注册到 agent,支持 `set_plan/update_step/clear`,返回极短 UI-only 结果;上下文压缩对旧 `task_progress` tool_call/result 做专门折叠,避免进度历史长期占 prompt。前端新增 `progress.js` 做 task 级进度状态合并,修复 `update_step` 只带 `{id,status}` 时因缺标题不显示的问题;当前进度显示从助手消息内提升到 `#task-progress-dock`(对话流下方、输入框上方),历史消息内仍保留进度块作记录。system prompt + coding/ppt/proposal/analyze skill 加轻量使用约定,要求只在多步骤关键阶段少量更新。**部署侧补静态资源 no-cache**:`NoCacheStaticFiles` 替换默认 `StaticFiles`,让浏览器重新校验 `/static/*.js` 等资源,避免前端修复已部署但旧 `chat.js` 仍被缓存导致看不到进度区。校验:`pytest tests/test_context_compaction.py tests/test_task_progress_tool.py tests/test_executor_docker.py tests/test_static_vendor.py -v` 相关集通过;`node --test tests/frontend_task_progress.test.mjs` 2 过;`node --check web/static/js/chat.js web/static/js/progress.js` 过。 - **新增 Codex 式 `task_progress` 进度工具 + Web 固定进度区**:`TaskProgressTool` 默认注册到 agent,支持 `set_plan/update_step/clear`,返回极短 UI-only 结果;上下文压缩对旧 `task_progress` tool_call/result 做专门折叠,避免进度历史长期占 prompt。前端新增 `progress.js` 做 task 级进度状态合并,修复 `update_step` 只带 `{id,status}` 时因缺标题不显示的问题;当前进度显示从助手消息内提升到 `#task-progress-dock`(对话流下方、输入框上方),历史消息内仍保留进度块作记录。system prompt + coding/ppt/proposal/analyze skill 加轻量使用约定,要求只在多步骤关键阶段少量更新。**部署侧补静态资源 no-cache**:`NoCacheStaticFiles` 替换默认 `StaticFiles`,让浏览器重新校验 `/static/*.js` 等资源,避免前端修复已部署但旧 `chat.js` 仍被缓存导致看不到进度区。校验:`pytest tests/test_context_compaction.py tests/test_task_progress_tool.py tests/test_executor_docker.py tests/test_static_vendor.py -v` 相关集通过;`node --test tests/frontend_task_progress.test.mjs` 2 过;`node --check web/static/js/chat.js web/static/js/progress.js` 过。

View File

@ -45,35 +45,13 @@ def _message_chars(msg: dict[str, Any]) -> int:
return len(str(msg)) return len(str(msg))
def _compact_task_progress_arguments(raw: Any) -> tuple[Any, bool]:
if not isinstance(raw, str):
return raw, False
try:
parsed = json.loads(raw)
except Exception:
parsed = {}
if not isinstance(parsed, dict):
parsed = {}
marker: dict[str, Any] = {
"_compacted": True,
"tool": "task_progress",
"note": "old UI progress details omitted from context",
}
action = parsed.get("action")
if isinstance(action, str) and action:
marker["action"] = action
steps = parsed.get("steps")
if isinstance(steps, list):
marker["step_count"] = len(steps)
step = parsed.get("step")
if isinstance(step, dict) and isinstance(step.get("id"), str):
marker["step_id"] = step["id"]
return json.dumps(marker, ensure_ascii=False), True
def _compact_tool_call_arguments(raw: Any, max_chars: int, tool_name: str = "") -> tuple[Any, bool]: def _compact_tool_call_arguments(raw: Any, max_chars: int, tool_name: str = "") -> tuple[Any, bool]:
# task_progress 参数本就很小(3-7 个短步骤),压缩省的 token 微乎其微,但把它换成
# `{"_compacted":true,"step_id":...}` 这种"看起来像合法调用"的标记会:① 毒化模型,
# 让它照葫芦画瓢生成残废的 update_step(丢了 step.status)入库;② 残废格式前端
# applyProgressAction 读不到 args.step → 进度还原错乱。故 task_progress 一律不压缩参数。
if tool_name == "task_progress": if tool_name == "task_progress":
return _compact_task_progress_arguments(raw) return raw, False
if not isinstance(raw, str) or len(raw) <= max_chars: if not isinstance(raw, str) or len(raw) <= max_chars:
return raw, False return raw, False
marker: dict[str, Any] = { marker: dict[str, Any] = {

View File

@ -12,6 +12,7 @@
- 多步骤任务开始后,用 `task_progress(action="set_plan", steps=[...])` 发布一份简短计划。 - 多步骤任务开始后,用 `task_progress(action="set_plan", steps=[...])` 发布一份简短计划。
- 步骤标题面向用户,写“理解需求 / 实现功能 / 运行测试 / 输出结果”这类阶段,不要写每个文件读写或每次 shell 命令。 - 步骤标题面向用户,写“理解需求 / 实现功能 / 运行测试 / 输出结果”这类阶段,不要写每个文件读写或每次 shell 命令。
- 只在关键状态变化时用 `update_step`:当前步骤进入 `in_progress`,完成后改 `completed`。不要高频更新,不要把普通 tool 调用都转成进度步骤。 - 只在关键状态变化时用 `update_step`:当前步骤进入 `in_progress`,完成后改 `completed`。不要高频更新,不要把普通 tool 调用都转成进度步骤。
- 任务全部做完时,把最后一步标成 `completed`(让用户在顶部进度面板看到"全绿"收尾),**不要用 `clear`**;`clear` 只在计划被推翻、不再相关时才用。
- 简单问答、单次文件读取、很小的改动不需要调用 `task_progress` - 简单问答、单次文件读取、很小的改动不需要调用 `task_progress`
## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现) ## 媒体生成工具(按需可用,未配置 ARK_API_KEY 时该工具不会出现)

View File

@ -150,7 +150,9 @@ class ContextCompactionTests(unittest.TestCase):
self.assertNotIn("A" * 100, tc["function"]["arguments"]) self.assertNotIn("A" * 100, tc["function"]["arguments"])
self.assertEqual(stats["compacted_tool_call_arguments"], 1) self.assertEqual(stats["compacted_tool_call_arguments"], 1)
def test_compacts_old_task_progress_arguments_to_plan_marker(self) -> None: def test_keeps_old_task_progress_arguments_intact(self) -> None:
# task_progress 参数本就很小,且压成 `{"_compacted":...,"step_id":...}` 这种"像合法调用"
# 的标记会毒化模型 + 毁掉前端进度还原。故旧的 task_progress 调用参数必须原样保留。
args = json.dumps({ args = json.dumps({
"action": "set_plan", "action": "set_plan",
"steps": [ "steps": [
@ -173,13 +175,11 @@ class ContextCompactionTests(unittest.TestCase):
prepared, stats = prepare_messages_with_stats(messages) prepared, stats = prepare_messages_with_stats(messages)
compacted_args = json.loads(prepared[1]["tool_calls"][0]["function"]["arguments"]) kept_args = json.loads(prepared[1]["tool_calls"][0]["function"]["arguments"])
self.assertTrue(compacted_args["_compacted"]) self.assertNotIn("_compacted", kept_args)
self.assertEqual(compacted_args["tool"], "task_progress") self.assertEqual(kept_args["action"], "set_plan")
self.assertEqual(compacted_args["action"], "set_plan") self.assertEqual(kept_args["steps"][0]["title"], "理解需求")
self.assertEqual(compacted_args["step_count"], 2) self.assertEqual(stats["compacted_tool_call_arguments"], 0)
self.assertNotIn("理解需求", prepared[1]["tool_calls"][0]["function"]["arguments"])
self.assertEqual(stats["compacted_tool_call_arguments"], 1)
def test_old_task_progress_tool_result_uses_tiny_marker(self) -> None: def test_old_task_progress_tool_result_uses_tiny_marker(self) -> None:
messages = [ messages = [

View File

@ -17,8 +17,9 @@ class TaskProgressTool(Tool):
description = ( description = (
"Publish or update a concise user-visible progress checklist for the current task. " "Publish or update a concise user-visible progress checklist for the current task. "
"Use only for meaningful multi-step work: set the plan once, update a step when it " "Use only for meaningful multi-step work: set the plan once, update a step when it "
"starts or completes, and clear it only when it is no longer relevant. This is a UI " "starts or completes, and when all work is done mark the final step completed (do NOT "
"progress signal, not a work product." "clear). Use clear only when the plan is no longer relevant. This is a UI progress "
"signal, not a work product."
) )
parameters = { parameters = {
"type": "object", "type": "object",

View File

@ -445,9 +445,17 @@
border: 1px solid var(--border-soft); border-radius: var(--r-md); border: 1px solid var(--border-soft); border-radius: var(--r-md);
background: #fafafa; font-size: 12px; background: #fafafa; font-size: 12px;
} }
.task-progress .tp-title { .task-progress > .tp-summary {
margin-bottom: 5px; color: var(--muted); font-family: var(--mono); font-size: 11px; cursor: pointer; list-style: none; color: var(--muted);
font-family: var(--mono); font-size: 11px; user-select: none;
} }
.task-progress > .tp-summary::-webkit-details-marker { display: none; }
.task-progress > .tp-summary::before {
content: "▸"; display: inline-block; margin-right: 5px; transition: transform .12s;
}
.task-progress[open] > .tp-summary::before { transform: rotate(90deg); }
.task-progress > .tp-summary.tp-done { color: var(--c-green); }
.task-progress[open] > .tp-summary { margin-bottom: 5px; }
.task-progress .tp-list { display: grid; gap: 4px; } .task-progress .tp-list { display: grid; gap: 4px; }
.task-progress .tp-step { .task-progress .tp-step {
display: grid; grid-template-columns: 18px minmax(0, 1fr); display: grid; grid-template-columns: 18px minmax(0, 1fr);
@ -469,7 +477,7 @@
.task-progress .tp-step.completed .tp-text { color: var(--muted); text-decoration: line-through; } .task-progress .tp-step.completed .tp-text { color: var(--muted); text-decoration: line-through; }
#task-progress-dock { #task-progress-dock {
flex-shrink: 0; display: none; flex-shrink: 0; display: none;
padding: 8px 12px; border-top: 1px solid var(--border); padding: 8px 12px; border-bottom: 1px solid var(--border);
background: #fff; background: #fff;
} }
#task-progress-dock.show { display: block; } #task-progress-dock.show { display: block; }
@ -1040,8 +1048,8 @@
</div> </div>
<div id="chat-meta"><span class="muted">(未选中任务)</span></div> <div id="chat-meta"><span class="muted">(未选中任务)</span></div>
<div id="wd-concurrent-warn" style="display:none;"></div> <div id="wd-concurrent-warn" style="display:none;"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<div id="task-progress-dock"></div> <div id="task-progress-dock"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<form id="chat-form" style="display:none;"> <form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea> <textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
<div class="row"> <div class="row">

View File

@ -417,7 +417,7 @@ function renderLiveRunIfVisible() {
const wrap = $("chat-stream"); const wrap = $("chat-stream");
const card = run.card || createLiveAssistantCard(run); const card = run.card || createLiveAssistantCard(run);
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc); if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
renderProgressInto(card, run.progressSteps || []); renderTaskProgressDock(run.progressSteps || []);
if (card.parentElement !== wrap) wrap.appendChild(card); if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
setActionMode(run.cancelling ? "cancelling" : "streaming"); setActionMode(run.cancelling ? "cancelling" : "streaming");
@ -449,32 +449,8 @@ function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = text; if (state.taskId === run.taskId) $("chat-hint").textContent = text;
} }
function renderProgressHtml(steps) { // 进度只在对话区顶部的单一 dock 里渲染(codex 式钉顶面板),不再内联进每条消息卡。
if (!Array.isArray(steps) || !steps.length) return ""; // 进行中:展开实时显示 pending/in_progress/completed;全部完成:折叠成一行摘要,点开看清单。
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
const rows = steps.map((s) => `
<div class="tp-step ${escapeHtml(s.status)}">
<span class="tp-mark">${escapeHtml(mark(s.status))}</span>
<span class="tp-text">${escapeHtml(s.title)}</span>
</div>
`).join("");
return `<div class="task-progress"><div class="tp-title">进度</div><div class="tp-list">${rows}</div></div>`;
}
function renderProgressInto(card, steps) {
let el = card.querySelector(":scope > .task-progress");
if (!Array.isArray(steps) || !steps.length) {
if (el) el.remove();
return;
}
const html = renderProgressHtml(steps);
if (el) {
el.outerHTML = html;
} else {
card.insertAdjacentHTML("beforeend", html);
}
}
function renderTaskProgressDock(steps) { function renderTaskProgressDock(steps) {
const dock = $("task-progress-dock"); const dock = $("task-progress-dock");
if (!dock) return; if (!dock) return;
@ -483,7 +459,21 @@ function renderTaskProgressDock(steps) {
dock.classList.remove("show"); dock.classList.remove("show");
return; return;
} }
dock.innerHTML = renderProgressHtml(steps); const total = steps.length;
const done = steps.filter(s => s.status === "completed").length;
const allDone = done === total;
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
const rows = steps.map((s) => `
<div class="tp-step ${escapeHtml(s.status)}">
<span class="tp-mark">${escapeHtml(mark(s.status))}</span>
<span class="tp-text">${escapeHtml(s.title)}</span>
</div>
`).join("");
const summary = allDone
? `<summary class="tp-summary tp-done">✓ 全部完成 · ${done}/${total} 步</summary>`
: `<summary class="tp-summary">进度 · ${done}/${total} 步</summary>`;
const openAttr = allDone ? "" : " open"; // 全完成默认折叠,其余展开
dock.innerHTML = `<details class="task-progress"${openAttr}>${summary}<div class="tp-list">${rows}</div></details>`;
dock.classList.add("show"); dock.classList.add("show");
} }
@ -588,7 +578,7 @@ function renderMessages(msgs) {
${renderArtifactBarHtml(rels, isProducer)} ${renderArtifactBarHtml(rels, isProducer)}
`; `;
} }
if (progressResult.sawProgress) html += renderProgressHtml(currentProgressSteps); // 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
} }
card.innerHTML = html; card.innerHTML = html;
highlightIn(card); highlightIn(card);
@ -1004,7 +994,6 @@ function handleSseEvent(ev, asstCard, ctx) {
if (fn === "task_progress") { if (fn === "task_progress") {
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args); ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
setTaskProgress(ctx.taskId, ctx.progressSteps); setTaskProgress(ctx.taskId, ctx.progressSteps);
renderProgressInto(asstCard, ctx.progressSteps);
return; return;
} }
const label = toolActivityLabel(fn, args); const label = toolActivityLabel(fn, args);