feat: add user input history MCP and rebuild release
This commit is contained in:
79
.planning/user-input-history-mcp/findings.md
Normal file
79
.planning/user-input-history-mcp/findings.md
Normal 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` 仍为 ready(6387 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,允许 1–50;先从全部非空 `role=user` 文本中取最近 N 条。
|
||||
- `maxChars`:默认 1000,允许 1–1000;以 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 与线程级来源上下文约定
|
||||
84
.planning/user-input-history-mcp/progress.md
Normal file
84
.planning/user-input-history-mcp/progress.md
Normal 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;因运行态门禁暂缓重启 |
|
||||
79
.planning/user-input-history-mcp/task_plan.md
Normal file
79
.planning/user-input-history-mcp/task_plan.md
Normal 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,允许 1–50。
|
||||
- `maxChars?: integer`:默认 1000,允许 1–1000;约束所有 `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`。
|
||||
2
.trellis/tasks/08-17-user-input-history-mcp/check.jsonl
Normal file
2
.trellis/tasks/08-17-user-input-history-mcp/check.jsonl
Normal file
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"核验错误码、失败结果和默认会话行为与项目模式一致。"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验只读行为、边界条件与回归覆盖。"}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"file":".trellis/spec/backend/error-handling.md","reason":"沿用项目现有 MCP 错误返回模式,避免引入不一致的异常协议。"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循后端读取与回归质量约束。"}
|
||||
88
.trellis/tasks/08-17-user-input-history-mcp/prd.md
Normal file
88
.trellis/tasks/08-17-user-input-history-mcp/prd.md
Normal 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. 跑针对性测试,再跑相关完整回归。
|
||||
|
||||
26
.trellis/tasks/08-17-user-input-history-mcp/task.json
Normal file
26
.trellis/tasks/08-17-user-input-history-mcp/task.json
Normal 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": {}
|
||||
}
|
||||
Binary file not shown.
@@ -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 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
|
||||
|
||||
472
scripts/ccweb-list-user-inputs-unit.js
Normal file
472
scripts/ccweb-list-user-inputs-unit.js
Normal 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);
|
||||
});
|
||||
98
server.js
98
server.js
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user