feat: add user input history MCP and rebuild release

This commit is contained in:
shiyue
2026-08-18 09:10:25 +08:00
parent 6ed8c7d30b
commit 389690af64
11 changed files with 957 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# Findings & Decisions: 用户输入历史 MCP
## Requirements
- 新增一个专用 ccweb MCP只读返回用户输入内容列表。
- 可指定目标对话;未指定时默认当前来源对话。
- 默认返回最近 15 次用户输入。
- 所有返回输入正文合计最多 1000 个 Unicode 字符,并明确标记截断或仍有更早内容。
- 引导表单提交后插入为普通用户消息的内容必须包含在内。
- 结果供 LLM 回看用户要求、作为实现验收参考。
## Research Findings
- 当前暴露的 `ccweb_list_conversations` 只返回会话元数据,明确不返回对话正文。
- 当前 ccweb MCP 工具清单没有读取会话用户输入历史的工具。
- 仓库已有其他活跃计划,当前任务使用 `.planning/user-input-history-mcp/` 隔离,避免覆盖根目录或其他任务计划。
- Trellis 开发者身份已初始化为 `shiyue`;共享 `.trellis/.current-task` 指向其他任务,因此本任务不覆盖该共享指针。
- codebase-memory 项目 `home-cc-web` 索引状态为 `ready`6308 nodes / 14349 edges可直接用于函数级定位。
- 第二次独立计划审查已通过,契约足以进入代码研究与实施。
- MCP 公共工具定义集中在 `lib/ccweb-mcp-server.js``TOOLS`;现有 `ccweb_list_conversations``ccweb_prompt_user` 均在此声明 schema。
- 内部 MCP 分派入口是 `server.js``callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount)`,现有列表工具转到 `listConversationSummaries`
- `listConversationSummaries` 已提供来源会话清洗、limit clamp、会话文件枚举和运行状态等可复用模式正文读取应另建只读函数避免扩大摘要接口职责。
- 引导表单链路关键函数为 `createCcwebPromptUser``handleCcwebPromptUserResponse``buildCcwebPromptUserResponseText`;需读取精确源码确认提交内容最终是否由普通 `handleMessage` 持久化为 `role=user`
- 已确认引导表单提交链路:`handleCcwebPromptUserResponse``buildCcwebPromptUserResponseText` 生成问题/选择/答案正文,再调用普通 `handleMessage(..., { emitUserMessage: true })`;因此提交结果会成为普通 `role=user` 消息,新工具只按角色筛选即可自然纳入。
- 待填写的引导卡本身是 `role=assistant` 且携带 `ccwebPrompt`,必须排除;只有用户提交后的普通消息才应返回。
- `loadSession(id)` 已是统一、安全的会话 JSON 读取入口,包含 ID 清洗、大小限制、会话规范化和运行时线程索引更新;新查询不应自行读文件。
- 公共 MCP `tools/list` 读取 `lib/ccweb-mcp-server.js``TOOLS`,共享 HTTP/JSON-RPC 调用经 `handleMcpJsonRpcMessage``isCallableToolName` 进入 `callInternalMcpTool`
- `isCallableToolName` 会自动接受所有 `TOOLS` 中的公开工具;新增工具无需维护额外 allowlist。`CODEX_APP_COMMUNICATION_TOOL_NAMES` 只控制旧 dynamic-tool 兼容集,是否纳入需结合兼容路径调用点判断。
- 现有回归函数 `assertCcwebListConversationsScopeContract` 同时断言正式 MCP schema、兼容工具定义和内部分派源码新工具应补同等级 schema/route 契约,并通过内部 MCP HTTP 做数据行为验证。
- `server.js` 的 composer 候选和共享 MCP `tools/list` 都直接展开 `CCWEB_MCP_TOOLS`,因此加入 `TOOLS` 后会自动出现在 `/` MCP mention 与正式 MCP 列表中。
- 旧 Codex App dynamic-tool 兼容路径由 `CODEX_APP_COMMUNICATION_TOOL_NAMES` 精确筛选,并最终仍调用同一个 `callInternalMcpTool`。新工具与 `ccweb_list_conversations` 同属只读会话通信能力,应纳入该兼容集合;正式 MCP 仍是主路径。
- 测试编写阶段检查工作区时,仅本任务计划/Trellis 文件与测试代理临时清单为新增文件,尚无生产代码改动或与用户既有改动冲突。
- 新增聚焦测试 `scripts/ccweb-list-user-inputs-unit.js`,覆盖正式 schema、JSON-RPC/旧内部路由、默认/指定会话、15 条、角色筛选、引导提交、Unicode 总预算、错误码与只读性。
- 首次执行按预期失败:公开 `TOOLS` 尚无新工具,调用返回 `unknown_tool`;只读 fixture hash/mtime 断言已独立通过,说明红灯指向缺失实现而非测试环境破坏。
- 主审阅发现初版测试有两处需修正:禁止覆盖 `HOME`;提交表单正文会合法包含表单标题,排除未提交卡应按角色/消息 ID 断言。
- codebase-memory 一次搜索误报项目未索引,但随后的 `list_projects`/`index_status` 显示 `home-cc-web` 仍为 ready6387 nodes / 14484 edges属于瞬时查询异常无需重建索引。
- codebase-memory 收敛重试又返回 `Transport closed`,已按降级规则停用本轮该查询并改用本地 `rg/sed`;定位范围仅限 `sanitizeId``truncateTextValue``normalizeSession`
- 测试代理已移除 HOME 覆盖、改用消息 ID 排除未提交引导卡,并增加临时目录清理;复跑仍只因 `unknown_tool`/缺失 schema 失败。
- 生产实现新增 `listUserInputHistory`,使用 `loadSession` 只读筛选 `role=user`,从最新向前分配 Unicode code point 总预算,再按旧到新返回。
- 正式 `TOOLS`、Codex App 旧兼容集合和 `callInternalMcpTool` 已统一注册 `ccweb_list_user_inputs`,没有第二套实现。
- 主线程与实现代理在交接边界发生一次 apply_patch 上下文冲突;补丁工具安全拒绝覆盖,最终 diff 仅包含代理的目标改动,无丢失或重复代码。
- 语法检查和聚焦测试全部通过,包含 fixture 文件 hash/mtime 不变断言。
- 既有 `ccweb-message-reply-unit` 通过,说明新增 fallback 工具未破坏统一发送/旧别名契约。
- 完整 `npm run regression` 在约 37 秒内通过;`git diff --check` 无空白或补丁格式问题。
- 独立 Trellis 质量审查已通过,无阻断问题;补强了空会话、预算恰好用满(有/无更早输入)和运行时 clamp 测试,复跑通过。
- 运行态检查发现除当前对话外,`63b18af6-1b9a-4ad0-8627-f2b5ba22fc39` 仍为 running按仓库运维规则不得重启 cc-web因此当前进程尚未加载新 MCP需待其他对话空闲后重启。
## Finalized Query Semantics
- `conversationId`:显式传入则读取该会话;缺省才回退到来源会话。显式无效 ID 不得静默回退。
- `limit`:默认 15允许 150先从全部非空 `role=user` 文本中取最近 N 条。
- `maxChars`:默认 1000允许 11000以 JavaScript Unicode code point`Array.from`)计数,约束所有返回 `content` 合计。
- 预算分配:从最新候选向更早候选分配;遇到剩余预算不足时保留该消息开头并标记 `truncated=true`,更早候选不再返回;最后按旧到新排序。
- `hasMore=true`:存在 limit 之外的更早用户输入,或字数预算截断/省略了候选。
- 空白/空正文 user 消息不作为“内容”返回;引导表单提交正文是非空普通 user 消息,因此自然包含。
- 缺少来源会话返回 `missing_source_conversation`;显式无效 ID 返回 `invalid_conversation_id`;会话不存在返回 `conversation_not_found`
- 返回项包含 `messageId|null``createdAt|null``content``contentChars``originalChars``truncated`;顶层包含会话 ID、limit/maxChars、总字符数、返回数与 `hasMore`
- Trellis backend 规范目前大多是占位内容;本改动不涉及“部分 JSON 预览”专项规则。实现应沿用现有 `loadSession``mcpToolError``scripts/regression.js` 的实际项目惯例。
- 已创建隔离 Trellis 任务 `.trellis/tasks/08-17-user-input-history-mcp/` 并写入完整 PRD因共享 current task 属于其他工作,本任务不改写 `.trellis/.current-task`
## Technical Decisions
| Decision | Rationale |
|----------|-----------|
| 工具采用只读查询 | 不应改变会话、消息或运行状态 |
| 当前会话默认值由 MCP 服务的来源会话上下文解析 | 与已有跨会话工具的线程级来源语义保持一致 |
| 返回最近 N 条后再按旧到新输出 | 查询高效,同时方便 LLM 顺序理解需求演进 |
| 1000 字作为整体硬预算,从最新输入向前填充 | 限制最坏上下文体积并优先保留最新验收要求 |
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| “最多 1000 字”存在逐条或整体预算歧义 | 独立计划审查后选择更保守的整体硬预算,并增加截断/更多标记 |
## Resources
- `.trellis/workflow.md`
- `AGENTS.md` 中 ccweb MCP 与线程级来源上下文约定

View File

@@ -0,0 +1,84 @@
# Progress Log: 用户输入历史 MCP
## Session: 2026-08-17
### Phase 1: 代码发现与契约确认
- **Status:** complete
- **Started:** 2026-08-17
- Actions taken:
- 核对当前暴露的 ccweb MCP 工具清单,确认缺少用户输入历史读取能力。
- 读取项目工作流以及计划跟踪技能要求。
- 建立隔离的持久计划与已知需求记录。
- 完成第一次独立计划审查根据审查意见补齐整体字数预算、MCP 契约和关键验收用例。
- 第二次独立计划审查通过。
- 确认 codebase-memory 索引可用;避免覆盖共享 Trellis 当前任务指针。
- 定位 MCP schema、内部路由、会话列表和引导表单响应的函数级入口。
- 定稿查询参数、Unicode 计数、预算顺序、错误码、返回字段和 `hasMore` 语义。
- 读取 Trellis backend 规范;确认无额外目录或错误处理约束,继续遵循现有项目模式。
- 创建 Trellis 任务并写入 PRD、implement/check 上下文,未覆盖其他任务的共享 current 指针。
- 确认正式 MCP、composer 候选和旧 Codex App 兼容工具的定义复用关系。
- 派出 Trellis 测试实现代理;其当前只做代码定位,生产文件保持未修改。
- 测试代理新增聚焦失败测试并确认红灯;未修改任何生产文件,已清理其临时 TODO CSV。
- 主线程复跑失败测试1.02 秒退出7 项因缺少工具/schema 路由失败,只读性断言通过。
- Files created/modified:
- `.planning/user-input-history-mcp/task_plan.md`
- `.planning/user-input-history-mcp/findings.md`
- `.planning/user-input-history-mcp/progress.md`
### Phase 2: 测试与实现
- **Status:** complete
- Actions taken:
- 完成失败测试并进入最小生产实现。
- 实现 `ccweb_list_user_inputs` 查询、正式 schema、兼容集合和内部统一路由。
- 运行两个语法检查与聚焦测试,全部通过。
- 运行既有通信单测、完整 regression 与 diff 检查,全部通过。
- Files created/modified:
- `scripts/ccweb-list-user-inputs-unit.js`(新增测试)
- `server.js`(查询逻辑与路由)
- `lib/ccweb-mcp-server.js`(正式 schema 与兼容工具集合)
### Phase 3: 验证与交付
- **Status:** complete
- Actions taken:
- 完整 regression、独立质量审查与补强边界测试全部通过。
- 检查运行会话;因存在另一个 running 对话,按项目规则暂缓服务重启并记录交付限制。
- Files created/modified:
- `.planning/user-input-history-mcp/*`(任务审计记录)
- `.trellis/tasks/08-17-user-input-history-mcp/*`PRD 与上下文)
## Test Results
| Test | Input | Expected | Actual | Status |
|------|-------|----------|--------|--------|
| 聚焦测试红灯 | `node scripts/ccweb-list-user-inputs-unit.js` | 因工具未实现而失败 | 7 项 `unknown_tool`/缺失 schema 失败只读断言通过1.02s | ✓ |
| 生产文件语法 | `node --check server.js``node --check lib/ccweb-mcp-server.js` | 通过 | 通过 | ✓ |
| 聚焦测试绿灯 | `node scripts/ccweb-list-user-inputs-unit.js` | 8 类断言全部通过 | 全部通过1.16s | ✓ |
| 既有通信单测 | `node scripts/ccweb-message-reply-unit.js` | 通过 | 通过 | ✓ |
| 完整回归 | `npm run regression` | 60 秒内通过 | 约 37 秒通过 | ✓ |
| Diff 检查 | `git diff --check` | 无错误 | 无错误 | ✓ |
| 补强边界测试 | 聚焦脚本:空会话、预算恰好用满、运行时 clamp | 全部通过 | 全部通过0.82s | ✓ |
| 独立质量审查 | Trellis checker 只读审查 | 无阻断问题 | 已通过 | ✓ |
| 运行态重启门禁 | `ccweb_list_conversations(status=running)` | 仅当前对话时才重启 | 发现另一个 running 对话,按规则暂缓重启 | ✓ |
## Error Log
| Timestamp | Error | Attempt | Resolution |
|-----------|-------|---------|------------|
| 2026-08-17 | 计划对 1000 字限制的解释可能导致最多约 15000 字输出 | 1 | 改为所有返回正文合计最多 1000 字,并明确预算算法 |
| 2026-08-17 | 初版失败测试覆盖 HOME 且误排除提交表单标题 | 1 | 退回测试代理按环境变量规则和消息 ID 修正 |
| 2026-08-17 | codebase-memory 搜索瞬时返回 project not indexed | 1 | 用 list_projects/index_status 复核为 ready避免无谓重建 |
| 2026-08-17 | codebase-memory 收敛查询返回 Transport closed | 2 | 停止重复调用,降级为 rg/sed 精确读取目标函数 |
| 2026-08-17 | 主线程 apply_patch 与代理最后写入并发导致上下文不匹配 | 1 | 补丁未应用;审阅现有 diff 后确认目标代码完整,再运行验证 |
## 5-Question Reboot Check
| Question | Answer |
|----------|--------|
| Where am I? | Phase 3验证与交付已完成 |
| Where am I going? | 待其他对话空闲后由运维重启加载新 MCP |
| What's the goal? | 新增可读取指定/当前对话用户输入历史的只读 MCP |
| What have I learned? | 引导表单提交落为普通 user 消息;正式/兼容 MCP 复用同一路由 |
| What have I done? | 已实现、测试和审查新 MCP因运行态门禁暂缓重启 |

View File

@@ -0,0 +1,79 @@
# Task Plan: 新增用户输入历史 MCP
## Goal
为 cc-web 增加一个只读 MCP可读取指定对话默认当前来源对话最近的用户输入纳入引导表单提交内容并把所有返回正文控制在合计 1000 个 Unicode 字符以内,供 LLM 作为验收参考。
## Current Phase
Phase 3
## Phases
### Phase 1: 代码发现与契约确认
- [x] 梳理会话消息存储与 ccweb MCP 注册链路
- [x] 明确用户输入筛选、默认会话与截断规则
- 必须确认普通用户输入与引导表单提交的持久化形态。
- 必须确认来源会话缺失、指定会话不存在时的错误语义。
- **Status:** complete
### Phase 2: 测试与实现
- [x] 编写新 MCP 的失败回归测试
- 覆盖默认当前对话、显式指定对话、默认最近 15 条、仅返回 user 内容。
- 覆盖引导表单提交消息、合计 1000 字预算、截断标记及缺失来源会话。
- [x] 实现指定/当前会话的用户输入读取与限制逻辑
- [x] 注册 MCP schema、描述与运行时上下文
- **Status:** complete
### Phase 3: 验证与交付
- [x] 运行针对性测试并修复发现的问题
- [x] 执行相关回归、核验接口并整理交付
- **Status:** complete
## Key Questions
1. 普通用户输入和引导表单提交在持久层中如何标记、是否都使用 `role=user`?(已确认:提交后经普通 `handleMessage` 持久化为 `role=user`;待提交卡是 `role=assistant`
2. 当前来源对话 ID 从哪个线程级运行时上下文解析已确认JSON-RPC/内部 HTTP 上下文把 `sourceSessionId` 传给 `callInternalMcpTool`
3. MCP 工具注册、参数校验和权限边界由哪些模块负责?(已确认:`lib/ccweb-mcp-server.js``TOOLS`/`isCallableToolName``server.js` 的内部 MCP 分派)
## Decisions Made
| Decision | Rationale |
|----------|-----------|
| 默认返回最近 15 条,最终按时间从旧到新排列 | 兼顾最近性与作为验收标准时的可读性 |
| 所有返回正文合计默认且硬性最多 1000 个 Unicode 字符 | 避免 15 条输入最坏膨胀到约 15000 字,保证验收上下文可控 |
| 从最新输入向前消耗字数预算,最终按旧到新输出 | 优先保留最新验收要求,同时维持自然阅读顺序 |
| 预算导致内容或更早消息未完整返回时给出截断/更多标记 | 避免 LLM 将不完整历史误认为完整要求 |
| 仅返回用户输入,不混入 assistant/tool/system 内容 | 保持用户原始验收意图纯净 |
## MCP Contract
- 工具名为 `ccweb_list_user_inputs`
- 入参:
- `conversationId?: string`:缺省时使用 MCP 来源会话 ID。
- `limit?: integer`:默认 15允许 150。
- `maxChars?: integer`:默认 1000允许 11000约束所有 `items[].content` 的字符总和。
- 返回:
- `conversationId``requestedLimit``maxChars``totalChars``returnedCount``hasMore`
- `items[]` 至少含 `content``createdAt``truncated`;若持久层有稳定消息 ID则同时返回 `messageId`
- 排序与预算:先选最近 `limit` 条用户输入,再从最新向前填充 `maxChars` 预算,最终将已选内容按旧到新返回。
- 默认当前会话但运行时没有来源会话 ID 时返回明确参数错误;显式指定不存在的会话时返回 not-found 错误。
## Errors Encountered
| Error | Attempt | Resolution |
|-------|---------|------------|
| 计划审查指出 1000 字是逐条还是合计存在歧义 | 1 | 收紧为所有返回正文合计硬上限 1000 字,并固化预算顺序 |
| codebase-memory 单次查询误报项目未索引 | 1 | 立即用 list_projects/index_status 复核,项目实际为 ready后续收敛查询重试不触发无谓重建 |
| codebase-memory 重试返回 Transport closed | 2 | 停止重复 MCP 调用,降级到本地 rg/sed 精确核验小函数 |
| 初版失败测试覆盖 HOME 且误排除提交表单标题 | 1 | 已退回测试代理修正环境隔离与角色/ID 断言后再验红灯 |
| 主线程生产补丁因代理并发落盘而校验失败 | 1 | apply_patch 安全拒绝覆盖;检查 git diff 后确认代理已完成目标改动,直接进入验证 |
## Notes
- 计划与根目录 CSV、`update_plan` 保持一一同步。
- 研究发现写入 `findings.md`,测试结果写入 `progress.md`
- 当前运行进程尚未重载:发现另一个非当前对话仍为 running按项目约定暂缓 `pm2 restart ccweb --update-env`

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/backend/error-handling.md","reason":"核验错误码、失败结果和默认会话行为与项目模式一致。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验只读行为、边界条件与回归覆盖。"}

View File

@@ -0,0 +1,2 @@
{"file":".trellis/spec/backend/error-handling.md","reason":"沿用项目现有 MCP 错误返回模式,避免引入不一致的异常协议。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循后端读取与回归质量约束。"}

View File

@@ -0,0 +1,88 @@
# PRD用户输入历史 MCP
## 目标
新增公开只读 MCP `ccweb_list_user_inputs`,让 LLM 在实现或验收前读取指定 cc-web 对话(默认当前来源对话)最近的用户输入,降低实现偏离用户原始要求的概率。
## 范围
- 在正式 ccweb MCP `tools/list` 中暴露工具。
- 在内部 MCP 分派中实现只读查询。
- 从持久会话的 `messages` 中只筛选非空 `role=user` 文本。
- 引导表单提交后形成的普通 user 消息必须自然包含;未提交的 assistant 引导卡必须排除。
- 补 schema 契约测试、查询行为测试和相关回归。
不包含 UI、消息修改、全文搜索、assistant/tool/system 内容、附件正文解析或跨会话权限模型改造。
## MCP 契约
### 入参
- `conversationId?: string`:指定目标会话;缺省时使用 MCP 来源会话 ID。
- `limit?: integer`:默认 15最小 1最大 50。
- `maxChars?: integer`:默认 1000最小 1最大 1000限制所有 `items[].content` 的 Unicode code point 总数。
- `additionalProperties: false`
### 选择与截断
1. 只选择正文非空的 `role=user` 消息。
2. 先取最近 `limit` 条候选。
3. 从最新候选向更早候选分配 `maxChars` 预算。
4. 若剩余预算不足,保留当前消息开头,设置 `truncated=true`,停止加入更早消息。
5. 最终按旧到新排列,便于 LLM 理解要求演进。
6. Unicode 字符使用 code point 计数,不能按 UTF-16 code unit 把 emoji 拆成两字。
### 返回
成功结果:
```json
{
"ok": true,
"conversationId": "...",
"requestedLimit": 15,
"maxChars": 1000,
"totalChars": 123,
"returnedCount": 2,
"hasMore": false,
"items": [
{
"messageId": "...或 null",
"createdAt": "...或 null",
"content": "用户输入",
"contentChars": 4,
"originalChars": 4,
"truncated": false
}
]
}
```
`hasMore=true` 表示存在 limit 之外的更早用户输入,或字数预算截断/省略了候选。
### 错误
- 默认模式缺少来源会话:`missing_source_conversation`
- 显式 `conversationId` 清洗后无效:`invalid_conversation_id`,不得回退来源会话。
- 目标会话不存在:`conversation_not_found`
## 验收标准
- 工具 schema 与描述明确说明默认当前会话、最近 15 条、合计最多 1000 字和验收用途。
- 默认当前来源会话与显式指定会话均可查询。
- 默认只返回最近 15 条用户输入,并按旧到新排列。
- assistant/tool/system、空白 user 消息和未提交引导卡不进入结果。
- 引导表单提交产生的普通 user 消息进入结果。
- `limit``maxChars` 被正确默认和限制;所有正文总字符数不超过 `maxChars`
- emoji 等非 BMP 字符按单个 code point 计数。
- 截断项、`hasMore`、计数字段和错误码可由回归测试稳定断言。
- 查询不修改会话文件、更新时间、未读状态或运行状态。
- 针对性测试与项目相关 regression 均通过。
## 实施顺序
1. 先写失败测试固定 schema、路由与行为。
2. 实现最小查询和 Unicode 预算逻辑。
3. 注册正式 MCP schema仅在现有兼容策略确有需要时纳入旧 dynamic-tool 兼容集。
4. 跑针对性测试,再跑相关完整回归。

View File

@@ -0,0 +1,26 @@
{
"id": "user-input-history-mcp",
"name": "user-input-history-mcp",
"title": "新增用户输入历史 MCP",
"description": "新增 ccweb_list_user_inputs只读返回指定或当前对话最近用户输入供 LLM 验收参考。",
"status": "completed",
"dev_type": "backend",
"scope": "lib/ccweb-mcp-server.js, server.js, scripts/regression.js",
"package": null,
"priority": "P2",
"creator": "shiyue",
"assignee": "shiyue",
"createdAt": "2026-08-17",
"completedAt": "2026-08-17",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -18,6 +18,7 @@ const CCWEB_REPLY_MODES = Object.freeze({
const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode仅当来源不需要目标结果时使用 one_way涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时必须使用 return_and_continue。工具调用会立即返回不阻塞也不等待目标对话完成。';
const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([
'ccweb_list_conversations',
'ccweb_list_user_inputs',
'ccweb_set_title',
'ccweb_create_conversation',
'ccweb_send_message',
@@ -75,6 +76,32 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'ccweb_list_user_inputs',
description: '该工具返回用户输入的近 N 次对话列表。',
inputSchema: {
type: 'object',
properties: {
conversationId: {
type: 'string',
description: '可选。目标对话 ID缺省时使用当前 MCP 来源对话。',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 50,
description: '可选。最多返回最近多少条用户输入,默认 15。',
},
maxChars: {
type: 'integer',
minimum: 1,
maximum: 1000,
description: '可选。所有返回正文合计最多多少个 Unicode 字符,默认 1000。',
},
},
additionalProperties: false,
},
},
{
name: 'ccweb_set_title',
description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',

View File

@@ -0,0 +1,472 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const { TOOLS } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const INTERNAL_MCP_TOKEN = 'ListUserInputsUnitMcp!234';
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : null;
server.close(() => resolve(port));
});
});
}
async function waitForPort(port, timeoutMs = 10000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
await new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve();
});
socket.once('error', reject);
socket.setTimeout(500, () => {
socket.destroy();
reject(new Error('timeout'));
});
});
return;
} catch (err) {
lastError = err;
await sleep(50);
}
}
throw new Error(`Timed out waiting for port ${port}: ${lastError?.message || 'unknown'}`);
}
async function withServer(env, fn) {
const child = spawn(process.execPath, [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
try {
await waitForPort(env.PORT, 10000);
await fn({ stdout: () => stdout, stderr: () => stderr });
} finally {
if (child.exitCode === null && !child.signalCode) {
child.kill('SIGTERM');
await sleep(300);
}
if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
}
}
async function postJson(port, pathname, body) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CC-Web-MCP-Token': INTERNAL_MCP_TOKEN,
},
body: JSON.stringify(body),
});
let payload = null;
try {
payload = await response.json();
} catch {}
return { status: response.status, payload };
}
async function mcpJsonRpc(port, sourceSessionId, message) {
const query = sourceSessionId ? `?sourceSessionId=${encodeURIComponent(sourceSessionId)}` : '';
return postJson(port, `/api/internal/mcp/stream${query}`, message);
}
async function callSharedTool(port, sourceSessionId, args = {}) {
const response = await mcpJsonRpc(port, sourceSessionId, {
jsonrpc: '2.0',
id: crypto.randomUUID(),
method: 'tools/call',
params: {
name: 'ccweb_list_user_inputs',
arguments: args,
},
});
assert.equal(response.status, 200, `tools/call should return HTTP 200, got ${response.status}`);
assert.ok(response.payload?.result, 'tools/call should return a JSON-RPC result');
return response.payload.result.structuredContent;
}
async function directInternalCall(port, sourceSessionId, args = {}) {
const response = await postJson(port, '/api/internal/mcp', {
tool: 'ccweb_list_user_inputs',
args,
sourceSessionId,
});
return response;
}
function writeSession(sessionsDir, session) {
const filePath = path.join(sessionsDir, `${session.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
return filePath;
}
function sha256(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function userMessage(id, content) {
return {
id,
role: 'user',
content,
timestamp: `2026-08-17T00:${String(Number(id.replace(/\D/g, '')) || 0).padStart(2, '0')}:00.000Z`,
};
}
function createFixtures(sessionsDir) {
const sourceMessages = [];
for (let index = 1; index <= 18; index += 1) {
sourceMessages.push(userMessage(`u${String(index).padStart(2, '0')}`, `用户输入 ${String(index).padStart(2, '0')}`));
}
sourceMessages.splice(4, 0,
{ id: 'assistant-guidance', role: 'assistant', content: 'assistant 说明不应进入结果', timestamp: '2026-08-17T00:04:10.000Z' },
{ id: 'tool-result', role: 'tool', content: '工具输出不应进入结果', timestamp: '2026-08-17T00:04:20.000Z' },
{ id: 'blank-user', role: 'user', content: ' \n\t', timestamp: '2026-08-17T00:04:30.000Z' },
{
id: 'assistant-prompt-card',
role: 'assistant',
content: '',
timestamp: '2026-08-17T00:04:40.000Z',
ccwebPrompt: {
id: 'prompt-1',
status: 'pending',
title: '需要确认',
questions: [{ id: 'q1', title: '方向', question: '选择方向' }],
},
},
);
sourceMessages.push(userMessage('submitted-form', '表单答案:\n\n表单需要确认\n\n1. 方向\n答案继续实现最小方案'));
const sourcePath = writeSession(sessionsDir, {
id: 'source-session',
title: '来源会话',
agent: 'codexapp',
created: '2026-08-17T00:00:00.000Z',
updated: '2026-08-17T00:30:00.000Z',
hasUnread: false,
isRunning: false,
messages: sourceMessages,
});
const explicitPath = writeSession(sessionsDir, {
id: 'explicit-session',
title: '显式会话',
agent: 'codexapp',
created: '2026-08-17T01:00:00.000Z',
updated: '2026-08-17T01:10:00.000Z',
hasUnread: true,
isRunning: false,
messages: [
userMessage('explicit-old', '显式旧输入'),
{ id: 'explicit-assistant', role: 'assistant', content: '显式 assistant' },
userMessage('explicit-new', '显式新输入'),
],
});
const unicodePath = writeSession(sessionsDir, {
id: 'unicode-session',
title: 'Unicode 会话',
agent: 'codexapp',
created: '2026-08-17T02:00:00.000Z',
updated: '2026-08-17T02:10:00.000Z',
messages: [
userMessage('unicode-old', 'abcdefghij'),
userMessage('unicode-mid', '一二三四'),
userMessage('unicode-latest', '🙂🙂🙂🙂'),
],
});
const exactSinglePath = writeSession(sessionsDir, {
id: 'exact-single-session',
title: '恰好预算单消息',
agent: 'codexapp',
messages: [userMessage('exact-single', '🙂甲')],
});
const exactMorePath = writeSession(sessionsDir, {
id: 'exact-more-session',
title: '恰好预算仍有更早消息',
agent: 'codexapp',
messages: [
userMessage('exact-old', '旧'),
userMessage('exact-latest', '🙂甲'),
],
});
const emptyPath = writeSession(sessionsDir, {
id: 'empty-session',
title: '无有效用户正文',
agent: 'codexapp',
messages: [
{ id: 'empty-assistant', role: 'assistant', content: '仅 assistant' },
{ id: 'empty-user', role: 'user', content: ' \n\t ' },
],
});
return {
sourcePath,
explicitPath,
unicodePath,
exactSinglePath,
exactMorePath,
emptyPath,
};
}
function assertToolSchema() {
const tool = TOOLS.find((item) => item.name === 'ccweb_list_user_inputs');
assert.ok(tool, '正式 ccweb MCP tools/list 应暴露 ccweb_list_user_inputs');
assert.equal(tool.description, '该工具返回用户输入的近 N 次对话列表。');
const schema = tool.inputSchema;
assert.equal(schema?.type, 'object');
assert.equal(schema.additionalProperties, false, 'schema additionalProperties 必须为 false');
assert.ok(!Array.isArray(schema.required) || !schema.required.includes('conversationId'), 'conversationId 必须可选');
assert.deepEqual(schema.properties?.conversationId?.type, 'string');
assert.equal(schema.properties?.limit?.type, 'integer');
assert.equal(schema.properties?.limit?.minimum, 1);
assert.equal(schema.properties?.limit?.maximum, 50);
assert.match(schema.properties?.limit?.description || '', /默认\s*15|15/);
assert.equal(schema.properties?.maxChars?.type, 'integer');
assert.equal(schema.properties?.maxChars?.minimum, 1);
assert.equal(schema.properties?.maxChars?.maximum, 1000);
assert.match(schema.properties?.maxChars?.description || '', /默认\s*1000|1000/);
}
async function assertServerToolList(port) {
const response = await mcpJsonRpc(port, 'source-session', {
jsonrpc: '2.0',
id: 'tools-list',
method: 'tools/list',
params: {},
});
assert.equal(response.status, 200);
const toolNames = response.payload?.result?.tools?.map((tool) => tool.name) || [];
assert.ok(toolNames.includes('ccweb_list_user_inputs'), '内部 JSON-RPC tools/list 应包含 ccweb_list_user_inputs');
}
async function assertDefaultsAndFiltering(port) {
const payload = await callSharedTool(port, 'source-session', {});
assert.equal(payload.ok, true);
assert.equal(payload.conversationId, 'source-session');
assert.equal(payload.requestedLimit, 15);
assert.equal(payload.maxChars, 1000);
assert.equal(payload.returnedCount, 15);
assert.equal(payload.items.length, 15);
assert.equal(payload.hasMore, true, '超过默认 limit 的更早用户输入应设置 hasMore=true');
assert.deepEqual(payload.items.map((item) => item.messageId), [
'u05', 'u06', 'u07', 'u08', 'u09',
'u10', 'u11', 'u12', 'u13', 'u14',
'u15', 'u16', 'u17', 'u18', 'submitted-form',
]);
const allContent = payload.items.map((item) => item.content).join('\n');
assert.match(allContent, /表单答案/, '提交后的普通 user 消息应进入结果');
assert.doesNotMatch(allContent, /assistant 说明|工具输出/, 'assistant/tool 独有内容不应进入结果');
assert.ok(
!payload.items.some((item) => item.messageId === 'assistant-prompt-card'),
'未提交的 assistant 引导卡不应以 messageId 进入结果'
);
assert.ok(payload.totalChars <= 1000);
for (const item of payload.items) {
assert.equal(item.contentChars, Array.from(item.content).length);
assert.equal(item.truncated, false);
}
}
async function assertExplicitConversation(port) {
const payload = await callSharedTool(port, 'source-session', { conversationId: 'explicit-session', limit: 10 });
assert.equal(payload.ok, true);
assert.equal(payload.conversationId, 'explicit-session');
assert.deepEqual(payload.items.map((item) => item.content), ['显式旧输入', '显式新输入']);
}
async function assertUnicodeBudgetAndTruncation(port) {
const payload = await callSharedTool(port, 'source-session', {
conversationId: 'unicode-session',
limit: 3,
maxChars: 6,
});
assert.equal(payload.ok, true);
assert.equal(payload.totalChars, 6);
assert.equal(payload.returnedCount, 2);
assert.equal(payload.hasMore, true);
assert.deepEqual(payload.items.map((item) => item.messageId), ['unicode-mid', 'unicode-latest']);
assert.equal(payload.items[0].content, '一二');
assert.equal(payload.items[0].contentChars, 2);
assert.equal(payload.items[0].originalChars, 4);
assert.equal(payload.items[0].truncated, true);
assert.equal(payload.items[1].content, '🙂🙂🙂🙂');
assert.equal(payload.items[1].contentChars, 4, 'emoji 应按 Unicode code point 计数,而不是 UTF-16 code unit');
assert.equal(payload.items[1].originalChars, 4);
assert.equal(payload.items[1].truncated, false);
assert.ok(payload.items.reduce((sum, item) => sum + item.contentChars, 0) <= 6);
}
async function assertExactBudgetEmptyAndClamps(port) {
const exactSingle = await callSharedTool(port, 'source-session', {
conversationId: 'exact-single-session',
maxChars: 2,
});
assert.equal(exactSingle.totalChars, 2);
assert.equal(exactSingle.hasMore, false, '恰好用满预算且无更早输入时不应误报 hasMore');
assert.equal(exactSingle.items[0].truncated, false);
const exactMore = await callSharedTool(port, 'source-session', {
conversationId: 'exact-more-session',
maxChars: 2,
});
assert.equal(exactMore.totalChars, 2);
assert.equal(exactMore.returnedCount, 1);
assert.equal(exactMore.items[0].messageId, 'exact-latest');
assert.equal(exactMore.items[0].truncated, false);
assert.equal(exactMore.hasMore, true, '恰好用满预算但仍有更早输入时应标记 hasMore');
const empty = await callSharedTool(port, 'source-session', { conversationId: 'empty-session' });
assert.equal(empty.returnedCount, 0);
assert.equal(empty.totalChars, 0);
assert.equal(empty.hasMore, false);
assert.deepEqual(empty.items, []);
const clamped = await callSharedTool(port, 'source-session', {
conversationId: 'explicit-session',
limit: 999,
maxChars: 9999,
});
assert.equal(clamped.requestedLimit, 50);
assert.equal(clamped.maxChars, 1000);
}
async function assertErrors(port) {
const missing = await callSharedTool(port, '', {});
assert.equal(missing.ok, false);
assert.equal(missing.code, 'missing_source_conversation');
const invalid = await callSharedTool(port, 'source-session', { conversationId: '!!!', limit: 5 });
assert.equal(invalid.ok, false);
assert.equal(invalid.code, 'invalid_conversation_id');
const notFound = await callSharedTool(port, 'source-session', { conversationId: 'missing-session', limit: 5 });
assert.equal(notFound.ok, false);
assert.equal(notFound.code, 'conversation_not_found');
}
async function assertDirectInternalRoute(port) {
const response = await directInternalCall(port, 'source-session', { limit: 1 });
assert.equal(response.status, 200);
assert.equal(response.payload?.ok, true);
assert.equal(response.payload?.conversationId, 'source-session');
assert.equal(response.payload?.returnedCount, 1);
}
async function runTest(name, fn, failures) {
try {
await fn();
console.log(`ok - ${name}`);
} catch (err) {
failures.push({ name, err });
console.error(`not ok - ${name}`);
console.error(` ${err.stack || err.message}`);
}
}
async function main() {
const failures = [];
await runTest('正式 MCP schema', assertToolSchema, failures);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-list-user-inputs-'));
try {
const sessionsDir = path.join(tempRoot, 'sessions');
const configDir = path.join(tempRoot, 'config');
const logsDir = path.join(tempRoot, 'logs');
mkdirp(sessionsDir);
mkdirp(configDir);
mkdirp(logsDir);
const fixturePaths = createFixtures(sessionsDir);
const before = Object.fromEntries(Object.entries(fixturePaths).map(([key, filePath]) => {
const stat = fs.statSync(filePath);
return [key, { filePath, hash: sha256(filePath), mtimeMs: stat.mtimeMs }];
}));
const port = await getFreePort();
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: 'ListUserInputsUnit!234',
CC_WEB_INTERNAL_MCP_TOKEN: INTERNAL_MCP_TOKEN,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
CC_WEB_USAGE_STATISTICS: '0',
}, async () => {
await runTest('内部 JSON-RPC tools/list 暴露工具', () => assertServerToolList(port), failures);
await runTest('默认来源会话、最近 15 条、过滤和旧到新排序', () => assertDefaultsAndFiltering(port), failures);
await runTest('显式指定 conversationId 查询目标会话', () => assertExplicitConversation(port), failures);
await runTest('maxChars Unicode code point 预算和截断字段', () => assertUnicodeBudgetAndTruncation(port), failures);
await runTest('恰好预算、空会话和运行时 clamp 边界', () => assertExactBudgetEmptyAndClamps(port), failures);
await runTest('错误码 missing_source_conversation / invalid_conversation_id / conversation_not_found', () => assertErrors(port), failures);
await runTest('旧版内部 /api/internal/mcp 路由分派', () => assertDirectInternalRoute(port), failures);
});
await runTest('查询不修改 fixture 会话文件', () => {
for (const entry of Object.values(before)) {
const stat = fs.statSync(entry.filePath);
assert.equal(sha256(entry.filePath), entry.hash, `${path.basename(entry.filePath)} 内容不应变化`);
assert.equal(stat.mtimeMs, entry.mtimeMs, `${path.basename(entry.filePath)} mtime 不应变化`);
}
}, failures);
} finally {
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
} catch (err) {
failures.push({ name: '清理临时目录', err });
console.error('not ok - 清理临时目录');
console.error(` ${err.stack || err.message}`);
}
}
if (failures.length > 0) {
console.error(`\n${failures.length} test(s) failed.`);
process.exitCode = 1;
return;
}
console.log('\nall ccweb_list_user_inputs focused tests passed');
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});

View File

@@ -5475,6 +5475,18 @@ function clampMcpLimit(value) {
return Math.max(1, Math.min(100, parsed));
}
function clampUserInputsLimit(value) {
const parsed = Number.parseInt(String(value || ''), 10);
if (!Number.isFinite(parsed)) return 15;
return Math.max(1, Math.min(50, parsed));
}
function clampUserInputsMaxChars(value) {
const parsed = Number.parseInt(String(value || ''), 10);
if (!Number.isFinite(parsed)) return 1000;
return Math.max(1, Math.min(1000, parsed));
}
function listConversationSummaries(args = {}, sourceSessionId = '') {
reconcilePendingCrossConversationReplies();
const scope = args.scope === 'children' ? 'children' : 'all';
@@ -5532,6 +5544,90 @@ function listConversationSummaries(args = {}, sourceSessionId = '') {
};
}
function listUserInputHistory(args = {}, sourceSessionId = '') {
const hasExplicitConversationId = Object.prototype.hasOwnProperty.call(args, 'conversationId');
let conversationId;
if (hasExplicitConversationId) {
const rawConversationId = String(args.conversationId || '').trim();
const sanitizedConversationId = sanitizeId(rawConversationId);
if (!sanitizedConversationId || sanitizedConversationId !== rawConversationId) {
return mcpToolError('invalid_conversation_id', 'conversationId 无效。', {
conversationId: rawConversationId || null,
});
}
conversationId = sanitizedConversationId;
} else {
conversationId = sanitizeId(sourceSessionId || '');
if (!conversationId) {
return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
}
}
const session = loadSession(conversationId);
if (!session) {
return mcpToolError('conversation_not_found', '目标对话不存在。', {
conversationId,
});
}
const requestedLimit = clampUserInputsLimit(args.limit);
const maxChars = clampUserInputsMaxChars(args.maxChars);
const userMessages = (Array.isArray(session.messages) ? session.messages : [])
.filter((message) => message?.role === 'user' && typeof message.content === 'string' && message.content.trim())
.map((message) => ({
messageId: message.id || message.messageId || null,
createdAt: message.createdAt || message.timestamp || null,
content: message.content,
}));
const candidates = userMessages.slice(-requestedLimit);
const items = [];
let remainingChars = maxChars;
let totalChars = 0;
let budgetLimited = false;
for (let index = candidates.length - 1; index >= 0; index -= 1) {
if (remainingChars <= 0) {
budgetLimited = true;
break;
}
const candidate = candidates[index];
const codePoints = Array.from(candidate.content);
const originalChars = codePoints.length;
const truncated = originalChars > remainingChars;
const content = truncated ? codePoints.slice(0, remainingChars).join('') : candidate.content;
const contentChars = truncated ? remainingChars : originalChars;
items.push({
messageId: candidate.messageId,
createdAt: candidate.createdAt,
content,
contentChars,
originalChars,
truncated,
});
totalChars += contentChars;
remainingChars -= contentChars;
if (truncated) {
budgetLimited = true;
break;
}
}
items.reverse();
return {
ok: true,
conversationId,
requestedLimit,
maxChars,
totalChars,
returnedCount: items.length,
hasMore: userMessages.length > candidates.length || budgetLimited || items.length < candidates.length,
items,
};
}
function normalizeMcpPromptText(value, maxChars) {
if (value === null || value === undefined) return '';
return truncateTextValue(String(value).trim(), maxChars, '...');
@@ -6296,6 +6392,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
return createCcwebDisplayImage(args, sourceSessionId);
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_list_user_inputs':
return listUserInputHistory(args, sourceSessionId);
case 'ccweb_set_title':
return setCurrentConversationTitle(args, sourceSessionId);
case 'ccweb_create_conversation':