diff --git a/CLAUDE.md b/CLAUDE.md index 629953b5..d94ae65a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,8 @@ tool registry, including changes to a reused model connection. In-flight image requests retain their original snapshot. The settings test action explicitly generates one standard-quality image and must never run on load or save. +**智能定时任务(`create_scheduled_task`,只在 daemon 注册)的两道闸。** 该工具按 `activation_skill=scheduled-task` 延迟开放,模型只能先从技能索引发现技能。用户反馈「技能经常不触发、触发了又建出子定时任务」(提示词「建立定时任务,每天帮我生成代码」被整句写进任务内容,到点执行时再建一个),现在:(1) 技能索引对所有带 `ToolImpl::activation_hint` 的门控工具单列「Skill-gated built-in tools」一节(`ToolExecutor::skill_gated_tools` 按会话策略过滤,需要 `skill_view` 且技能已安装),不受目录截断影响,门控技能也不进休眠;(2) 带 `ToolImpl::loop_execution_refusal` 的工具在 `loop.active` 会话(定时任务执行及其子代理)里经 `capture_request_source` 并入 `hidden_builtin_tools`,schema 与执行同一谓词,旧任务的坏 prompt 也不会再派生;(3) AI 创建时 `prompt_requests_scheduled_task_creation`(手写扫描「动词[+量词]紧挨定时任务」、`/scheduled-task` 等标记)拒绝仍带创建请求的 prompt(`PROMPT_CONTAINS_CREATION_REQUEST`,在权限提问之前),手动表单不经过它;执行上下文 `build_loop_system_context` 也说明任务文本里的建任务 / 时间描述只是创建时的原话。**不要**把隐藏改成只靠提示词:执行会话里权限提问会照常弹给用户,一答就建出子任务。回归:`scheduled_task_tool_test.cpp` 的 Prompt* 两条、`agent_loop_tool_lifecycle_events_test.cpp::LoopExecutionHidesScheduledTaskCreation`、`skills_index_prompt_test.cpp` 的 GatedTool* 三条、`api_request_builder_test.cpp::SkillIndexAdvertisesGatedToolUntilPolicyHidesIt`、`loop_scheduler_test.cpp::SystemContextTreatsCreationWordingAsOneExecution`。 + [AgentLoop](src/engine/agent/agent_loop.hpp) is the public facade. [TurnRunner](src/engine/agent/turn/turn_runner.cpp) owns the multi-turn state machine; [TurnFinalizer](src/engine/agent/turn/turn_finalizer.cpp) owns ordered completion. Fixed dependencies enter through [AgentLoopServices](src/engine/agent/agent_loop_services.hpp); event-backed prompters are installed before explicit start(). A text-only assistant reply ends the loop. `task_complete` is an optional explicit terminator that renders a concise completion row. `AskUserQuestion` is not a terminator; its answer returns as a tool result. `config.agent_loop.max_iterations` is an optional hard cap; `0` or omitted means unlimited. **输出损坏重发一次(``)。** 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `` / `` 及闭合标签(`find_leaked_tool_argument_markup`),说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的原生工具调用都不可信:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发;每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就按原流程继续。`` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问。 @@ -897,6 +899,8 @@ The existing region detector still probes DuckDuckGo once at startup through `Pr `saved_models` is a named registry; `default_model_name` points into it. Each entry needs `name` (must NOT start with `(` — reserved for synthesized `(session:)`), `provider`, `model`. OpenAI entries also need `base_url` + `api_key`. On load, array order is oldest-to-newest: for duplicate names, every occurrence except the last is atomically persisted as `-<6-lowercase-hex-hash>`, while the last keeps the original name. Duplicate-repair write failures remain fatal. A non-empty dangling `default_model_name` is instead replaced with the first saved-model name before validation and atomically written back; a write failure is logged but startup continues with the in-memory fallback and retries on the next load. If the main logger is not initialized yet, this repair silently opens `~/.acecode/logs/config-.log`; an active logger is never redirected. This repair does not rewrite an empty default and emits no UI message. Successful full loads and saves refresh a restricted `config-backups/last-good/config.json` snapshot. Later JSON parse/type/range/semantic failures archive the exact invalid bytes under `config-backups/invalid`, atomically restore and validate that snapshot, and persist a one-time Web/Desktop warning; recovery logs contain paths and categories only. Without a valid prior snapshot, startup keeps the invalid active file and follows the existing fatal boundary. `acecode configure` upserts one named `saved_models` entry from the selected provider/model and sets it as `default_model_name`; normal startup no longer derives a selectable model from top-level provider fields. +Each saved model may carry an optional `sampling` object (`temperature` [0,2], `top_p` (0,1], `top_k` ≥ 1, `min_p` [0,1]). Unset keys are never sent, so a profile without sampling produces byte-identical requests. Support is per provider/protocol and validated in `validate_model_sampling_support` (`saved_models.cpp`): Chat Completions sends all four (`top_k`/`min_p` are vLLM/SGLang extensions the official OpenAI API rejects), Responses only `temperature`/`top_p`, Anthropic rejects `min_p` and caps `temperature` at 1, managed Copilot/Grok reject sampling entirely — a value that would be silently dropped is a validation error, not a no-op. Edits replace the whole object (`null`/`{}` clears; a provider switch clears it). Sampling is part of the provider construction fingerprint, so running sessions rebuild on the next turn after an edit. Settings > Models > 高级设置 shows only the fields the selected provider/protocol supports (`modelSamplingFieldSupport` in `web/src/lib/modelSettings.js`). Added for the intranet wizard-ai gateway, whose GLM model ships recommended values (top_p 0.95 / top_k 20 / temperature 1 / min_p 0) that ACECode previously never sent. + ### Model profile resolution At startup and on every `--resume` / `/resume`, `src/adapters/provider/model_resolver.cpp` resolves the effective `ModelEntry` in three layers. CLI `-r` is not a `--resume` short alias; it starts the TUI normally, runs the startup hook path, then opens the same `/resume` picker immediately. diff --git a/assets/seed/MANIFEST.json b/assets/seed/MANIFEST.json index 6e2bb8c8..4b0beb51 100644 --- a/assets/seed/MANIFEST.json +++ b/assets/seed/MANIFEST.json @@ -1,5 +1,5 @@ { - "bundle_version": "2026-10-10.1", + "bundle_version": "2026-10-15.1", "description": "Versioned default ACECode global Skill, expert, and managed hook seed bundle reconciled at startup.", "skills": [ { @@ -66,7 +66,7 @@ "name": "scheduled-task", "source_id": "acecode:scheduled-task@2026-10-04", "relative_path": "acecode/scheduled-task", - "skill_md_sha256": "89354de4dc007479bf5a39aba0179928f6f53b39ead451fddcdbb5c9a65b691e" + "skill_md_sha256": "fb18793a5d66f9fe54300a9a00eaa18860aba33caa1e91d80da006dd1fa1d49f" } ], "experts": [ diff --git a/assets/seed/seed.version b/assets/seed/seed.version index 347fb9fa..2e7a6e1d 100644 --- a/assets/seed/seed.version +++ b/assets/seed/seed.version @@ -1 +1 @@ -2026-10-10.1 +2026-10-15.1 diff --git a/assets/seed/skills/acecode/scheduled-task/SKILL.md b/assets/seed/skills/acecode/scheduled-task/SKILL.md index f488de6a..fd3d1509 100644 --- a/assets/seed/skills/acecode/scheduled-task/SKILL.md +++ b/assets/seed/skills/acecode/scheduled-task/SKILL.md @@ -1,6 +1,6 @@ --- name: scheduled-task -description: 通过自然语言创建 ACECode 内置定时任务。用于定时提醒、每天或每周执行、每隔一段时间检查、重复若干天等请求,以及“智能添加定时任务”入口。只讨论计划或立即执行普通任务时不创建定时任务。 +description: 创建 ACECode 内置定时任务。用户要在某个时间点、每天/每周/工作日、每隔一段时间或连续若干天自动执行某件事或收到提醒时使用,包括“建立/添加/设置定时任务”“明天9点提醒我”“每天帮我…”与“智能添加定时任务”入口。只讨论计划或要求立即执行时不创建。 --- # 创建定时任务 @@ -11,7 +11,10 @@ description: 通过自然语言创建 ACECode 内置定时任务。用于定时 - 提取任务内容和执行时间。`X点`、`X天` 都是待用户填写的占位符,不能自行猜测或照字面创建。用简短问题补齐,必要时合并询问具体时刻、重复天数和会议内容。 - 用执行环境当前时间解析“明天”等相对日期;默认沿用 daemon 所在时区,远程连接或用户另指定时区时明确核对。不要硬编码今天、UTC 偏移或猜测 epoch 毫秒值;可用系统时间工具或本地日期计算命令计算。 -- 名称保持简短;`prompt` 是到点要做的事。提醒任务应明确要求提醒用户,而不是让执行任务再次创建定时任务。不要把整段创建对话复制为执行提示词。 +- 名称保持简短。`prompt` 是**每次到点执行一次**要做的事,写成直接指令:去掉“建立/创建/添加定时任务”和“每天、每周、明天9点”等时间描述,时间只放在 `schedule` 里。绝不能把用户原话或整段创建对话复制进 `prompt`,否则每次执行都会被当成一次新的建任务请求。 + - 用户说“建立定时任务,每天帮我生成代码” → `prompt` 写“生成代码:<具体生成什么、放在哪里>”,内容不明确时先问清楚。 + - 用户说“明天9点提醒我参加会议” → `prompt` 写“提醒用户:参加会议”。 + - 工具会拒绝仍含创建请求的 `prompt`(`PROMPT_CONTAINS_CREATION_REQUEST`),按提示改写后再调用。 - 未指定模型时省略 `model_name`,沿用手动表单默认模型。纯提醒省略工作区;项目任务使用现有工作区的 `workspace_hash` 与 `workspace_cwd`,可用工作区查询工具核对。`use_worktree` 默认为 false,只在用户要求隔离时开启。 ## 时间规则 @@ -32,6 +35,6 @@ description: 通过自然语言创建 ACECode 内置定时任务。用于定时 ## 后续执行 -任务在现有“定时任务”页面查看、修改、停用和删除;到期运行方式由调度器负责,不承诺每次新建或复用聊天。daemon 需运行,离线错过不补跑;同工作区已有定时任务运行时跳过该次。可使用截止时间或手动停用,停止正在执行的任务需打开对应执行会话。 +任务在现有“定时任务”页面查看、修改、停用和删除;到期运行方式由调度器负责,不承诺每次新建或复用聊天。定时任务的执行会话(含其子代理)中没有创建工具,执行时不会再派生新的定时任务。daemon 需运行,离线错过不补跑;同工作区已有定时任务运行时跳过该次。可使用截止时间或手动停用,停止正在执行的任务需打开对应执行会话。 普通任务沿用现有完成提示。监控类任务的执行提示词要保留用户的通知意图:无变化时保持安静,只在有实质变化、完成、失败或需要用户处理时报告;不要额外要求每次输出状态。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 3ad6e03b..57f03f46 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -816,13 +816,24 @@ visible_roots}`. `total` and `visible_roots` count non-pinned roots, while `sessions` is a flat array containing those roots, the children of expanded roots, and separately pinned rows. Every non-pinned row adds `sidebar_key`, `sidebar_parent_key` (empty for roots), `sidebar_child_count`, `sidebar_rank` -(within siblings), and `sidebar_expanded` to the normal session summary. +(within siblings), and `sidebar_expanded`. Clients must not compare `sessions.length` with root totals. -Automatic group keys use `loop:`; leaf keys use `session:`. -The newest non-detached execution by creation time represents an automatic -group. Selecting old history does not replace it. Metadata parsing is cached -by file modification time and size; transcript bodies are never read here. +Automatic group keys use `loop:`; manual groups anchored to a detached +scheduled session use `group:`. Real session keys always use +`session:`. Every execution, including the newest, is a child. +Group headers are presentation nodes with `sidebar_group:true` and the stable +synthetic `id` `sidebar-group:`; never navigate to, subscribe to, +mark read, pin, or archive that ID as a session. Clicking a header only toggles it. +`sidebar_group_name` and `title` contain the task name when available (clients +use a localized scheduled-task fallback otherwise). `sidebar_latest_session_id` +identifies the member selected by newest creation time; its timestamps supply +the header time. A rename or navigation to older history never replaces the +task name. `sidebar_members` contains compact attention snapshots for all members, +including hidden ones, so clients can aggregate subsequent status events while +collapsed. Headers are never busy: unread members produce `unread`, otherwise +`read`, even if a member is running. Metadata parsing is cached by modification +time and size; neither transcripts nor hidden members' full summaries are read. ### `POST /api/workspaces/:hash/session-sidebar` and `POST /api/session-sidebar` @@ -836,9 +847,11 @@ The same query parameters apply. Body: scope. Only a scheduled root with `loop_execution.loop_id` (the alarm marker) can accept children, including an empty group or a detached scheduled execution. Ordinary sessions may reorder or become children but cannot become parents. -A root with children can only reorder at the root; a child cannot accept -children. Moving a single-run automatic group into a scheduled parent detaches that -execution; future scheduled executions form their own automatic group. +A group header can only reorder at the root; a child cannot accept children. +Moving a single execution into a scheduled group detaches that execution from +its previous group; future executions still join their task's automatic group. +Dropping onto a detached scheduled root creates a manual group containing that +root's real session and the incoming child. Empty group headers disappear. Self moves, stale targets, and third-level relationships return HTTP 409 with `SIDEBAR_TARGET_CHANGED` or `SIDEBAR_TWO_LEVELS_ONLY`. An ordinary destination parent returns 409/`SIDEBAR_SCHEDULED_PARENT_ONLY`; malformed bodies return 400. @@ -848,10 +861,14 @@ sidebar page and reveal the destination. Relationships and sibling order are atomically persisted in the scope's `sidebar_sessions.json`, separately from task provenance and subagent `parent_session_id`. An explicit empty parent keeps a detached execution at the -root after reload and future runs. Missing or archived parents promote their -manual children; removing an automatic representative selects the next run. +root after reload and future runs. Missing or archived manual anchors promote +their remaining members; removing the newest automatic execution selects the +next member for header time without changing its task identity. Reading a legacy layout that assigns children to an ordinary session promotes those children to roots without changing their execution provenance. +Legacy scheduled-session parent keys are projected into manual groups with +the original parent execution included as a child. The next move persists the +canonical relationships and ordering using layout version 2. ### `POST /api/workspaces/:hash/sessions` @@ -3019,7 +3036,7 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. 原值和 `has_api_key` 布尔值,供编辑表单默认遮罩回填;调用方必须把整个响应视为 敏感数据,日志与错误消息仍不得包含密钥。高级字段有值时会原样返回,包括 `endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning`、 -`request_headers`、`context_window` 与 `stream_timeout_ms`。 +`sampling`、`request_headers`、`context_window` 与 `stream_timeout_ms`。 ### `POST /api/models` @@ -3048,10 +3065,22 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. "default_effort": "medium", "effort": "high", "supports_max_tokens": false - } + }, + "sampling": {"temperature": 1, "top_p": 0.95, "top_k": 20, "min_p": 0} } ``` +`sampling` 是可选的采样参数对象,四个键都可省略或为 `null`(视为未设置): +`temperature` ∈ [0, 2]、`top_p` ∈ (0, 1]、`top_k` 为正整数、`min_p` ∈ [0, 1]。 +未设置的键不会出现在发往模型服务的请求体里,沿用服务端默认值;整个对象省略或 +没有任何已设置的键时,请求体与不支持该功能的版本完全一致。支持范围按 Provider / +API 协议区分:OpenAI Chat Completions 四个都发送(`top_k` / `min_p` 是 vLLM、 +SGLang 等推理服务的扩展字段,官方 OpenAI 接口会拒绝);Responses 只支持 +`temperature` / `top_p`;Anthropic 支持 `temperature`(上限 1)、`top_p`、`top_k`, +不支持 `min_p`;Copilot / Grok 托管 Provider 不接受采样参数。越界值或当前 Provider / +协议不支持的键返回 `INVALID_SAMPLING`,托管 Provider 返回 `UNSUPPORTED_MODEL_OPTION`。 +响应只返回已设置的键。 + `api_protocol` 仅适用于 `provider:"openai"`,接受 `chat_completions` 或 `responses`;省略时,目录身份 `models_dev_provider_id:"acemodel"` 使用 Responses, 其他 OpenAI 配置继续使用 Chat Completions。模型设置的 **API 协议** 下拉框可 @@ -3075,9 +3104,10 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. 路径仍区分大小写。 省略 `base_url`、`context_window`、`stream_timeout_ms`、`capabilities`、 -`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning` 或 -`request_headers` 会保留原值。显式空 `request_headers` 会清空请求头;对应高级 -字段传 `null` 时按各字段合同清除。外部登录器留下的 legacy `readonly:true` 只是 +`endpoint_mode`、`api_protocol`、`max_output_tokens`、`capabilities_source`、`reasoning`、 +`sampling` 或 `request_headers` 会保留原值。显式空 `request_headers` 会清空请求头; +`sampling` 传入时整体替换(旧值中未出现的键会被清掉),传 `null` 或 `{}` 清除全部 +采样参数,切换到其他 Provider 时同样清除;其余高级字段传 `null` 时按各字段合同清除。外部登录器留下的 legacy `readonly:true` 只是 兼容元数据,不阻止编辑。响应包含 `api_key` 原值与 `has_api_key`,并仅允许经 Web 认证的调用方读取。 @@ -4673,7 +4703,17 @@ rather than a single newest file: every upgrade log modified within the last three days (72 hours) is merged oldest-first into `logs/upgrade.log.tail.txt`, truncated as a whole to the same 512 KiB tail; with no upgrade activity in that window the entry is omitted. If `session_id` is empty, the package contains -those logs only. The upload target is derived from `upgrade.base_url`. +those logs only. When a session is selected, the package also carries that +session's request trajectory (`//trajectory.jsonl`: +every model request's full messages and tool list, plus each response's text, +tool calls and usage) as `session/.trajectory.jsonl`. It keeps the +newest 4 MiB, starting at the first complete line after the cut so every packed +line is valid JSON; a single record larger than the cap keeps its partial tail. +A missing trajectory file is skipped without failing the upload. `feedback.json` +records it under `trajectory` (`entry_name`, `path`, `available`, `size_bytes`, +`tail_bytes`, `truncated`), and `included_files` lists the entry right after the +session JSONL when it was packed. Terminal `/feedback` attaches the active +session's trajectory the same way. The upload target is derived from `upgrade.base_url`. Success: diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index 9cce71d8..29d003f0 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -39,7 +39,7 @@ figure("AD-05", "定时任务创建与执行时间", "展示名称、工作空间、工作树、模型、权限和频率设置,使用一个无敏感数据的只读检查任务。"), code("检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。", "定时任务提示词示例")), section("manage", "查看记录、暂停与管理", - '''

侧边栏会把同一定时任务的会话收在一起,首行始终显示最近一次执行。点击首行打开该会话并展开或收起历史;历史以缩进、细竖线和浅背景区分,整组只占一个列表名额,展开历史不受五条分页限制。父项左侧默认显示展开或收起箭头,未读绿点或运行状态会覆盖箭头,不显示灰色占位点。定时任务右侧的闹钟始终可见,置顶、归档按钮与普通会话一样在悬停或聚焦时出现。

拖动父项可移动整组;拖动子项到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层定时会话中间可归入。暂无子会话的定时项也可接收;普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许父项和子项两层:已有子项的父项不能拖入其他项,子项不能再接收子项。

''', + '''

侧边栏按定时任务名称收拢执行会话,组标题的时间取自组内最新会话。点击任务名称只展开或收起,不打开或标记某次执行为已读;包括最新一次在内的全部会话都在组内,点击会话后查看内容。父子行对齐,不加缩进和竖线;展开时整组共用选中背景,选中的子会话再加深。整组只占一个列表名额,展开历史不受五条分页限制。

组标题默认显示箭头,组内有未读会话时显示绿灯;即使某次执行正在运行,组标题也不显示转圈。实际会话仍保留运行和未读提示,定时任务闹钟始终可见。置顶、归档等会话操作在实际会话行上进行,组标题不代表某一次执行。

拖动组标题可移动整组;拖动实际会话到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层项中间可归入。普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许组标题和会话两层,组不能拖入其他组,子项不能再接收子项。

''', '''

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

''', figure("AD-06", "定时任务列表与运行记录", "展示下一次执行、启用或停用按钮和一条真实运行记录,标出状态、原因及查看任务入口。")) ], ["web/src/components/LoopPage.jsx", "web/src/lib/loops.js", "src/host/loop/loop_types.hpp", "src/host/loop/loop_scheduler.cpp", "src/host/loop/loop_store.cpp", "src/host/loop/scheduled_task_tool.cpp", "assets/seed/skills/acecode/scheduled-task/SKILL.md"]), diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index be52af30..1a11d104 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -1038,7 +1038,7 @@ window.ACECODE_HELP_INDEX = [ "title": "定时任务", "url": "schedules.html", "description": "把清晰且可重复的任务保存为定时任务,按周期、间隔或指定时间执行,并通过记录检查每次结果。", - "text": "创建与设置执行时间 点击 智能添加定时任务 会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。 打开侧边栏 定时任务 ,进入“定时任务”页面,点击 手动添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 定时任务表单提供默认权限与完全访问权限。新建默认使用完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。 Windows 实际界面 · 点击图片查看原图 定时任务提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。 查看记录、暂停与管理 侧边栏会把同一定时任务的会话收在一起,首行始终显示最近一次执行。点击首行打开该会话并展开或收起历史;历史以缩进、细竖线和浅背景区分,整组只占一个列表名额,展开历史不受五条分页限制。父项左侧默认显示展开或收起箭头,未读绿点或运行状态会覆盖箭头,不显示灰色占位点。定时任务右侧的闹钟始终可见,置顶、归档按钮与普通会话一样在悬停或聚焦时出现。 拖动父项可移动整组;拖动子项到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层定时会话中间可归入。暂无子会话的定时项也可接收;普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许父项和子项两层:已有子项的父项不能拖入其他项,子项不能再接收子项。 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" + "text": "创建与设置执行时间 点击 智能添加定时任务 会打开新建任务,并带上创建定时任务的 skill 和预置文本“我希望在明天X点提醒我参加会议,重复X天”。补齐具体时间和天数后发送;AI 会询问执行权限,默认推荐完全访问权限(YOLO)。创建成功后即可结束聊天,任务在此页面统一管理。 打开侧边栏 定时任务 ,进入“定时任务”页面,点击 手动添加 或选择模板。 填写名称与提示词,选择工作空间;与项目无关的任务可不选择工作空间。 选择模型与权限。需要 Git 隔离时,在选定工作空间后启用工作树。 选择执行频率,按需设置生效和结束时间。 保存后核对下一次执行时间和启用状态。 频率 设置方式 适合的例子 周期 每天、工作日或每周指定日期与时间。 工作日早上检查仓库待处理事项。 按间隔 每隔指定分钟、小时或天执行。 定期检查本地生成结果。 单次 指定一个日期与时间。 稍后执行一次已准备好的检查。 保存前检查权限 定时任务表单提供默认权限与完全访问权限。新建默认使用完全访问权限,请主动核对。默认权限下需要确认的动作会进入等待用户状态;只有明确允许无人值守执行的操作才适合自动放行。 提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。 添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。 Windows 实际界面 · 点击图片查看原图 定时任务提示词示例 复制 检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。 查看记录、暂停与管理 侧边栏按定时任务名称收拢执行会话,组标题的时间取自组内最新会话。点击任务名称只展开或收起,不打开或标记某次执行为已读;包括最新一次在内的全部会话都在组内,点击会话后查看内容。父子行对齐,不加缩进和竖线;展开时整组共用选中背景,选中的子会话再加深。整组只占一个列表名额,展开历史不受五条分页限制。 组标题默认显示箭头,组内有未读会话时显示绿灯;即使某次执行正在运行,组标题也不显示转圈。实际会话仍保留运行和未读提示,定时任务闹钟始终可见。置顶、归档等会话操作在实际会话行上进行,组标题不代表某一次执行。 拖动组标题可移动整组;拖动实际会话到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层项中间可归入。普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许组标题和会话两层,组不能拖入其他组,子项不能再接收子项。 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" }, { "page": "定时任务", @@ -1053,8 +1053,8 @@ window.ACECODE_HELP_INDEX = [ "group": "进阶使用", "title": "查看记录、暂停与管理", "url": "schedules.html#manage", - "description": "查看记录、暂停与管理 侧边栏会把同一定时任务的会话收在一起,首行始终显示最近一次执行。点击首行打开该会话并展开或收起历史;历史以缩进、细竖线和浅背景区分,整组只占一个列表名额,展开历史不受五条分页限制。父项左侧默认显示展开或收起箭头,未读绿点或运行状", - "text": "查看记录、暂停与管理 侧边栏会把同一定时任务的会话收在一起,首行始终显示最近一次执行。点击首行打开该会话并展开或收起历史;历史以缩进、细竖线和浅背景区分,整组只占一个列表名额,展开历史不受五条分页限制。父项左侧默认显示展开或收起箭头,未读绿点或运行状态会覆盖箭头,不显示灰色占位点。定时任务右侧的闹钟始终可见,置顶、归档按钮与普通会话一样在悬停或聚焦时出现。 拖动父项可移动整组;拖动子项到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层定时会话中间可归入。暂无子会话的定时项也可接收;普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许父项和子项两层:已有子项的父项不能拖入其他项,子项不能再接收子项。 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" + "description": "查看记录、暂停与管理 侧边栏按定时任务名称收拢执行会话,组标题的时间取自组内最新会话。点击任务名称只展开或收起,不打开或标记某次执行为已读;包括最新一次在内的全部会话都在组内,点击会话后查看内容。父子行对齐,不加缩进和竖线;展开时整组共用选中背景,选", + "text": "查看记录、暂停与管理 侧边栏按定时任务名称收拢执行会话,组标题的时间取自组内最新会话。点击任务名称只展开或收起,不打开或标记某次执行为已读;包括最新一次在内的全部会话都在组内,点击会话后查看内容。父子行对齐,不加缩进和竖线;展开时整组共用选中背景,选中的子会话再加深。整组只占一个列表名额,展开历史不受五条分页限制。 组标题默认显示箭头,组内有未读会话时显示绿灯;即使某次执行正在运行,组标题也不显示转圈。实际会话仍保留运行和未读提示,定时任务闹钟始终可见。置顶、归档等会话操作在实际会话行上进行,组标题不代表某一次执行。 拖动组标题可移动整组;拖动实际会话到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层项中间可归入。普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许组标题和会话两层,组不能拖入其他组,子项不能再接收子项。 列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。 停用 用于暂停后续计划,重新 启用 后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。 daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。 远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。 定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。 Windows 实际界面 · 点击图片查看原图" }, { "page": "Agent 浏览器", diff --git a/docs/help/schedules.html b/docs/help/schedules.html index 1aa4c6a6..83d943c0 100644 --- a/docs/help/schedules.html +++ b/docs/help/schedules.html @@ -43,7 +43,7 @@

提示词应说明数据来源、允许的修改和完成条件,避免“继续上次工作”这类依赖未提供上下文的描述。当前表单中的连接器选择尚不可用,不要依赖它发送结果。

添加定时任务表单:名称、工作空间、提示词、模型、执行频率和生效日期。此示例尚未保存。
添加定时任务时填写名称、工作空间、提示词、模型、执行频率和生效日期。示例为只读检查草稿,截图后已取消,没有创建新的定时任务。Windows 实际界面 · 点击图片查看原图
定时任务提示词示例
检查此工作区的 Git 状态和最近提交,生成一份简短摘要。只读取本地文件,不修改文件、不提交、不推送。遇到错误时记录原因并结束。
-

查看记录、暂停与管理

侧边栏会把同一定时任务的会话收在一起,首行始终显示最近一次执行。点击首行打开该会话并展开或收起历史;历史以缩进、细竖线和浅背景区分,整组只占一个列表名额,展开历史不受五条分页限制。父项左侧默认显示展开或收起箭头,未读绿点或运行状态会覆盖箭头,不显示灰色占位点。定时任务右侧的闹钟始终可见,置顶、归档按钮与普通会话一样在悬停或聚焦时出现。

拖动父项可移动整组;拖动子项到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层定时会话中间可归入。暂无子会话的定时项也可接收;普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许父项和子项两层:已有子项的父项不能拖入其他项,子项不能再接收子项。

+

查看记录、暂停与管理

侧边栏按定时任务名称收拢执行会话,组标题的时间取自组内最新会话。点击任务名称只展开或收起,不打开或标记某次执行为已读;包括最新一次在内的全部会话都在组内,点击会话后查看内容。父子行对齐,不加缩进和竖线;展开时整组共用选中背景,选中的子会话再加深。整组只占一个列表名额,展开历史不受五条分页限制。

组标题默认显示箭头,组内有未读会话时显示绿灯;即使某次执行正在运行,组标题也不显示转圈。实际会话仍保留运行和未读提示,定时任务闹钟始终可见。置顶、归档等会话操作在实际会话行上进行,组标题不代表某一次执行。

拖动组标题可移动整组;拖动实际会话到顶层行间或列表空白处可脱离分组,仅拖到带闹钟的顶层项中间可归入。普通会话可排序或归入定时项,但不能成为父项。关系与排序会保存,手工移出的记录不会在刷新或下一次执行时重新归组。列表只允许组标题和会话两层,组不能拖入其他组,子项不能再接收子项。

列表展示下一次执行与最近运行情况。打开运行记录查看等待执行、执行中、等待用户、已完成、失败或已错过等状态,并检查关联任务的输出。失败应结合原因处理,不能只看定时任务仍为启用状态。

停用用于暂停后续计划,重新启用后检查新的执行时间;需要停止正在运行的那一轮,应打开对应任务并停止。编辑时可调整提示、模型与时间。删除定时任务也会移除其运行记录,保留需要的信息后再删除。

daemon 必须在执行时间保持运行,模型和工作目录也要可用。计算机离线、休眠或后台停止期间错过的执行会记录为已错过,不应期待恢复后把每次历史执行逐一补跑。同一工作空间的时间冲突会在保存或启用时提示,运行时占用也可能导致错过。

远端后台按其时间配置调度。跨时区或系统时区变化后,重新核对下一次执行时间;不要只按浏览器所在设备的时钟推测。

定时任务列表的模板、频率、模型、启用入口与最近运行失败状态;现有定时任务保持停用 定时任务真实运行记录:所选模型不可用导致停用,以及 daemon 未运行产生的已错过记录
定时任务列表提供频率、模型和启用入口。展开已有记录可见“所选模型已不可用,定时任务已停用”以及 daemon 未运行产生的“已错过”原因;这些是本机实际记录。Windows 实际界面 · 点击图片查看原图
diff --git a/openspec/changes/add-floating-side-chat/design.md b/openspec/changes/add-floating-side-chat/design.md index 63bdf968..fad1f620 100644 --- a/openspec/changes/add-floating-side-chat/design.md +++ b/openspec/changes/add-floating-side-chat/design.md @@ -14,7 +14,7 @@ ## Decisions 1. Render a nonmodal body portal above the app shell and ordinary overlays, with `data-ace-native-overlay="overlap"`. Use a compact title header with clear and minimize actions, spacious independently scrolling messages, and a 60px bottom input region including borders, with send/stop in the same location. The horizontal-line minimize button hides the window while preserving its session-local transcript and draft; its label is `最小化侧边聊天`. Long drafts scroll within the compact textarea. Match the supplied composition with existing ACECode tokens; avoid a backdrop. Pointer capture handles header drag and eight resize directions; geometry is clamped to viewport with a readable minimum that contracts on narrow screens. Announce native occlusion after geometry changes. -2. Keep a testable side-chat controller separate from `ChatView`; it owns transcript, draft, active request and epoch. All Web `/side`, `/btw` and menu entries use this one owner. Closing cancels and hides; reopening in the same view retains transcript/draft/geometry. Session changes reset. A synchronous active-request guard prevents double submission; stale callbacks are ignored by request identity. Only completed and stopped nonempty answers enter follow-up history. +2. Keep a testable side-chat controller separate from `ChatView`; it owns transcript, draft, active request and epoch. All Web `/side`, `/btw` and menu entries use this one owner. Closing cancels and hides; reopening in the same view retains transcript/draft/geometry. Session changes cancel the old request and retain session-isolated state under the configurable retention policy below. A synchronous active-request guard prevents double submission; stale callbacks are ignored by request identity. Only completed and stopped nonempty answers enter follow-up history. 3. Use a dedicated connection to the existing authenticated `/ws/sessions/` endpoint without `hello` or main subscriptions. Client sends `side_chat_start` payload `{session_id, request_id, question, history:[{role,content}]}` and `side_chat_stop` payload `{request_id}`. Server sends `side_chat_delta` `{request_id,delta}`, `side_chat_reset` `{request_id}`, `side_chat_done` `{request_id,answer,cancelled}`, or `side_chat_error` `{request_id,code,message}`. The API helper returns a cancel function and terminates each connection when finished. Reusing the main subscription was rejected because side lifecycle and errors must remain private. 4. Server-side provider execution copies the safe main snapshot, appends detached instructions, validated alternating side user/assistant pairs, and the new question. It uses no tools and a request-owned abort flag. Retry clears provisional answer. Validate history role/order/size before model invocation. Own worker lifetime through the server/connection state; disconnect aborts, safe connection identity prevents sends to a recycled pointer, and shutdown joins workers. Existing sync API stays compatible. 5. Disable the textarea throughout connect/wait/stream/stop acknowledgement, with an accessible progress indicator and enabled stop until requested. Preserve partial text on cancel and errors. Use actual provider deltas rather than simulated character animation. Markdown shares existing parsing/copy behavior; keyboard handling respects IME, Shift+Enter, and Escape inside the window, while underlying main-task keyboard handlers cannot consume float input. @@ -30,6 +30,15 @@ Initialize the window at 600 by 620 CSS pixels, contracting only when the viewpo ## Risks / Trade-offs +### 侧聊存续时间(2026-10-15) + +- 设置 → 常规,在工作模式之后增加「侧边聊天存续时间」数字输入,单位分钟,默认 30,支持正整数;沿用 UI 偏好 hook 持久化配置,修改后下次切回立即使用最新值。 +- 使用页面内存中的会话控制器缓存,按 daemon 端口、工作区与主会话 ID 隔离;聊天内容不写磁盘,刷新或重启后仍为临时状态。空会话不进入缓存。 +- 控制器通过可注入时钟记录最后一个侧聊回合完成、停止或失败的时间;切走中断生成视为停止,并保留非空的部分回答。仅打开窗口、编辑未发送草稿或输入校验失败不启动或延长聊天结束计时。 +- 切回该会话的 layout effect 检查到期:距最后结束时间达到配置分钟数时重置该会话;未到期则恢复回答、追问上下文、草稿和窗口开闭状态。切换、最小化、打开与草稿编辑不续期,正在生成的回合不判断过期。当前会话停留超过时间也不被定时清空。 +- 切走只停止生成,保持原窗口开闭状态以便切回恢复;迟到回调仍由请求身份隔离。明确清空会重置计时与该会话记录,不影响其他会话。 +- 缓存获取时更新 transport 委托,避免恢复控制器持有旧 API 凭证。React StrictMode 的 effect 清理只挂起请求,不丢弃缓存。 + - [Large temporary history] → Bound request history and return clear errors without silent truncation or modifying the main context. - [Native preview covers DOM] → Use the existing overlap coordinator and notify on mount, move, resize and unmount. - [Late network events / shutdown] → Guard by connection/request identity, independent abort and explicit cleanup; cover with lifecycle tests. diff --git a/openspec/changes/add-floating-side-chat/proposal.md b/openspec/changes/add-floating-side-chat/proposal.md index c820188b..619ede27 100644 --- a/openspec/changes/add-floating-side-chat/proposal.md +++ b/openspec/changes/add-floating-side-chat/proposal.md @@ -7,7 +7,8 @@ - Web/Desktop 的 `/side`、`/btw`、“侧边聊天”菜单和会话栏右上角气泡入口统一打开顶层浮窗,标题栏右侧提供清空和最小化按钮,支持拖动和四边四角缩放。 - 旁路聊天保留独立的多轮记录,复用主会话的安全上下文快照,但不写入主历史或执行工具。 - 提供真实流式输出、等待动画和停止生成,等待及输出期间禁用旁路输入框。 -- 最小化时停止生成并保留当前会话的临时记录;切换主会话时清空,防止上下文串线。 +- 最小化或切换主会话时停止生成,按主会话隔离保留临时记录、草稿与窗口开闭状态。 +- 在设置「常规」增加侧边聊天存续时间,单位分钟、默认 30 分钟;从最后一次侧聊完成、停止或失败开始计时,达到时间后下次切回该会话时清空,不在当前界面自动清空。 - 点击垃圾桶停止当前侧聊请求并清空记录、草稿和追问上下文,窗口保持打开;底部输入区域压缩至含边框 60px。 - 保留旧同步单轮接口及 TUI 行为,新增独立的流式旁路通信接口。 diff --git a/openspec/changes/add-floating-side-chat/specs/floating-side-chat/spec.md b/openspec/changes/add-floating-side-chat/specs/floating-side-chat/spec.md index 8086f750..9a6237ad 100644 --- a/openspec/changes/add-floating-side-chat/specs/floating-side-chat/spec.md +++ b/openspec/changes/add-floating-side-chat/specs/floating-side-chat/spec.md @@ -73,7 +73,7 @@ The UI SHALL show loading from submission, render actual streamed answer text, a - **THEN** the UI reports the failure, preserves readable partial output, and releases the input for another attempt ### Requirement: Conversation lifecycle and compatibility -Minimizing SHALL hide the window, cancel any active side request and preserve the temporary transcript and draft while the same main session remains selected. Switching sessions or leaving the view SHALL cancel the old side request and discard its temporary state. Late callbacks SHALL NOT affect another request or session. Existing synchronous side-question HTTP and TUI callers SHALL retain their single-turn behavior. +Minimizing SHALL hide the window, cancel any active side request and preserve the temporary transcript and draft while the same main session remains selected. Switching sessions or leaving the view SHALL cancel the old side request and retain its temporary state separately for that main session under the retention policy. Late callbacks SHALL NOT affect another request or session. Existing synchronous side-question HTTP and TUI callers SHALL retain their single-turn behavior. #### Scenario: Minimize and reopen - **WHEN** the user minimizes and reopens side chat in the same session @@ -89,6 +89,28 @@ Minimizing SHALL hide the window, cancel any active side request and preserve th - **WHEN** the user switches the main session during a side answer - **THEN** the old side request is cancelled and its subsequent events cannot change the new session's side chat +### Requirement: 可配置的侧边聊天存续时间 +设置「常规」SHALL 提供「侧边聊天存续时间」正整数配置,单位分钟、默认 30,并持久化用户选择。每个主会话的侧聊 SHALL 从最后一个回合完成、停止或失败开始计时。切回主会话时 SHALL 使用最新配置检查到期,未到期恢复临时记录、草稿及窗口开闭状态,到期清空该会话侧聊。当前正在查看的侧聊 SHALL NOT 因时间到期自动消失;仅切换、打开、最小化或修改草稿 SHALL NOT 延长最后聊天结束时间。 + +#### Scenario: 在存续时间内切回 +- **WHEN** 侧聊回答结束后用户切到另一主会话,并在配置时间内切回 +- **THEN** 原侧聊记录、未发送草稿和原窗口开闭状态恢复,追问仍携带原有成功或非空停止回答 +- **AND** 另一主会话的侧聊保持独立 + +#### Scenario: 达到时间后切回 +- **WHEN** 距最后一次侧聊结束恰好达到或超过配置分钟数,用户切回该主会话 +- **THEN** 原侧聊记录和草稿清空,窗口关闭,重新打开后追问历史为空 +- **AND** 一直停留在该会话直到到期不会自动清空 + +#### Scenario: 长回答及续聊 +- **WHEN** 侧聊回答生成耗时超过存续时间,随后结束并开始新的侧聊回合 +- **THEN** 生成期间不判断过期,并从最新回合结束重新计时 +- **AND** 切走中断生成时保留部分回答,从停止时开始计时,迟到事件不能更新其他会话 + +#### Scenario: 修改配置与重新挂载 +- **WHEN** 用户修改存续时间并切回之前的主会话,或聊天视图重新挂载 +- **THEN** 检查使用最新配置,未到期的内存状态仍可恢复;配置在刷新后保留,聊天记录不做磁盘持久化 + ### Requirement: Private authenticated streaming transport The streaming side interface SHALL require the existing daemon authentication, validate session identity, request identity, text-only roles and bounded payloads, and deliver side output only to the requesting connection. Cancellation and disconnect SHALL release request resources without affecting a main task. diff --git a/openspec/changes/add-floating-side-chat/tasks.md b/openspec/changes/add-floating-side-chat/tasks.md index 910da048..262c38c2 100644 --- a/openspec/changes/add-floating-side-chat/tasks.md +++ b/openspec/changes/add-floating-side-chat/tasks.md @@ -30,3 +30,9 @@ Validation (2026-09-16): geometry tests, full `pnpm test`, `pnpm build`, strict OpenSpec validation and `git diff --check` passed. Browser fixture measured 880x800 initial size and a 36px header; verified toolbar anchoring/clamping, resized dimensions and draft retention on reopen, and 1440px/390px light/dark layouts without page errors. No desktop binary rebuild or live-provider test was performed. Test/build required running outside the sandbox because its MSYS shell could not initialize. Size revision (2026-09-17): changed initial dimensions to 600x620. Geometry tests, production build, strict OpenSpec validation and scoped diff checks passed. Full Web tests failed in `settingsNavigation.test.js` because the current settings list includes the separately added security center while the expectation does not. No desktop rebuild was performed. + +## 6. 侧聊存续时间(2026-10-15) + +- [x] 6.1 在常规设置新增分钟配置(默认 30、正整数),接入偏好持久化、设置搜索和中英文文案。 +- [x] 6.2 按主会话缓存侧聊控制器,切走停止并保留,切回按最后聊天结束时间判断过期;覆盖回合结束、停止、失败、草稿、跨会话隔离和迟到回调。 +- [x] 6.3 验证浏览器中的真实设置与聊天切换行为,运行 Web 测试、生产构建、i18n 和 OpenSpec 严格校验,记录验证范围。 diff --git a/openspec/changes/add-floating-side-chat/validation.md b/openspec/changes/add-floating-side-chat/validation.md index a5bbf6ee..3834b5b6 100644 --- a/openspec/changes/add-floating-side-chat/validation.md +++ b/openspec/changes/add-floating-side-chat/validation.md @@ -44,3 +44,12 @@ Local browser results, screenshots, fixture script and Web logs are under `C:/Us - Replaced the close icon and label with a horizontal minimize action while retaining the clear button and existing hide/reopen lifecycle. - The v0.9.19 reasoning-depth browser check verified minimize/reopen preserves the side draft. Full frontend tests, i18n catalog generation, production build and strict OpenSpec validation passed. + +## 侧边聊天存续时间(2026-10-15) + +- 设置 → 常规新增「侧边聊天存续时间」,单位分钟,默认 30,仅接受正整数;使用现有偏好 hook 持久化,并接入设置搜索及英文文案。 +- 新增 15 个定向场景,覆盖从请求结束而非开始计时、精确到期边界、续聊更新计时、生成中不失效、切换及草稿不续期、输入校验失败不续期、成功/失败/停止、最新配置、会话身份隔离、重挂载、清空和迟到回调。现有控制器用例保持通过。 +- `pnpm test`、`pnpm build`(含旧 WebView 正则兼容检查)、`pnpm i18n:catalog`、`pnpm i18n:audit`、`openspec validate add-floating-side-chat --strict` 与本次改动范围的 `git diff --check` 通过;Impeccable 检测返回无问题。 +- 可复现浏览器脚本:`web/scripts/test-side-chat-retention.mjs`,依赖外部 Playwright,可用 `ACE_PLAYWRIGHT_MODULE` 指定已有模块路径。脚本挂载真实 `SettingsPage`、`ChatView`、全局会话菜单与侧聊浮窗,使用 HTTP/WebSocket 夹具和可控时钟。验证分钟配置持久化/输入回退、跨会话内容与追问隔离、草稿及窗口状态恢复、StrictMode/重新挂载、40 分钟长回答、到期仅切回清空、中断部分回答、最新配置及英文标签。浏览器无页面错误。 +- 浅色 1440px、深色 720px 截图已检查,配置行无横向溢出。截图位于 `C:/Users/shao/AppData/Local/Temp/ace-side-chat-retention-7Ypoos/`。 +- 仅修改 Web 前端及设计/验证文档;没有桌面二进制重建、已安装桌面运行证明或真实模型请求验证。 diff --git a/src/adapters/feedback/feedback_upload.cpp b/src/adapters/feedback/feedback_upload.cpp index c58d70eb..24443b5b 100644 --- a/src/adapters/feedback/feedback_upload.cpp +++ b/src/adapters/feedback/feedback_upload.cpp @@ -4,6 +4,7 @@ #include "platform/package_identity.hpp" #include "network/proxy_resolver.hpp" #include "session/session_storage.hpp" +#include "session/session_trajectory.hpp" #include "upgrade/manifest.hpp" #include "utils/encoding.hpp" #include "utils/utf8_path.hpp" @@ -362,6 +363,14 @@ std::optional collect_recent_upgrade_log_bundle( return bundle; } +fs::path session_trajectory_path_for(const fs::path& session_jsonl_path, + const std::string& session_id) { + if (session_jsonl_path.empty() || session_id.empty()) return {}; + const std::string path = SessionTrajectoryStorage::file_path( + path_to_utf8(session_jsonl_path.parent_path()), session_id); + return path.empty() ? fs::path{} : path_from_utf8(path); +} + FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& request) { FeedbackPackageResult result; const bool has_session = !request.session_id.empty() || @@ -493,6 +502,41 @@ FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& reque } } + // 会话请求原文(trajectory.jsonl):每次 model_request 的完整消息与工具清单、 + // model_response 的正文 / 工具调用 / 用量。会话 JSONL 看不到发给模型的系统 + // 提示词和工具定义,跟同一时段别的客户端比对请求时只能靠它。只随会话附带, + // 文件不存在不算失败。超限保留尾部(最近的请求),并丢掉被截断的半行,让每一行 + // 仍是完整 JSON;但如果截完一个换行都没有(单条记录就超限),保留这段残行, + // 好过什么都不给。 + std::optional trajectory; + std::string trajectory_payload; + if (has_session && !request.session_trajectory_path.empty()) { + FeedbackTrajectoryInclusion inclusion; + inclusion.entry_name = "session/" + + sanitize_feedback_filename_component(request.session_id) + + ".trajectory.jsonl"; + inclusion.source_path = path_to_utf8(request.session_trajectory_path); + std::error_code size_ec; + const auto size = fs::file_size(request.session_trajectory_path, size_ec); + std::size_t tail_bytes = 0; + if (!size_ec && read_tail(request.session_trajectory_path, + request.max_trajectory_bytes, + &trajectory_payload, &tail_bytes)) { + inclusion.included = true; + inclusion.size_bytes = size; + if (tail_bytes < size) { + inclusion.truncated = true; + const auto newline = trajectory_payload.find('\n'); + if (newline != std::string::npos && + newline + 1 < trajectory_payload.size()) { + trajectory_payload.erase(0, newline + 1); + } + } + inclusion.tail_bytes = trajectory_payload.size(); + } + trajectory = std::move(inclusion); + } + std::vector included_files; std::string session_entry; if (has_session) { @@ -500,6 +544,7 @@ FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& reque "session/" + sanitize_feedback_filename_component(request.session_id) + ".jsonl"; included_files.push_back(session_entry); } + if (trajectory && trajectory->included) included_files.push_back(trajectory->entry_name); for (const auto& [entry_name, _] : log_payloads) included_files.push_back(entry_name); included_files.push_back("feedback.json"); @@ -532,6 +577,16 @@ FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& reque if (!log_results.empty()) { metadata["log_path"] = log_results.front().source_path; } + if (trajectory) { + metadata["trajectory"] = { + {"entry_name", trajectory->entry_name}, + {"path", trajectory->source_path}, + {"available", trajectory->included}, + {"size_bytes", static_cast(trajectory->size_bytes)}, + {"tail_bytes", static_cast(trajectory->tail_bytes)}, + {"truncated", trajectory->truncated}, + }; + } const std::string metadata_text = metadata.dump(2); @@ -548,6 +603,9 @@ FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& reque if (has_session) { ok = add_file_entry(archive, session_entry, request.session_jsonl_path, &entry_error); } + if (ok && trajectory && trajectory->included) { + ok = add_buffer_entry(archive, trajectory->entry_name, trajectory_payload, &entry_error); + } for (const auto& [entry_name, content] : log_payloads) { if (!ok) break; ok = add_buffer_entry(archive, entry_name, content, &entry_error); @@ -575,6 +633,7 @@ FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& reque result.log_included = log_included; result.log_tail_bytes = log_tail_bytes; result.logs = std::move(log_results); + result.trajectory = std::move(trajectory); result.included_files = std::move(included_files); return result; } diff --git a/src/adapters/feedback/feedback_upload.hpp b/src/adapters/feedback/feedback_upload.hpp index f1835011..e66b04db 100644 --- a/src/adapters/feedback/feedback_upload.hpp +++ b/src/adapters/feedback/feedback_upload.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -10,6 +11,9 @@ namespace acecode::feedback { constexpr std::size_t kDefaultLogTailBytes = 512 * 1024; +// 会话请求原文(trajectory.jsonl)的上限。每条 model_request 都带完整历史与工具 +// 清单,长会话能到几十 MB;排查只需要最近几次请求,超限保留尾部。 +constexpr std::size_t kDefaultTrajectoryTailBytes = 4 * 1024 * 1024; // 反馈附带的升级记录窗口:「最近三天」按文件最后修改时间算,72 小时。 // 用修改时间而不是文件名里的日期,是因为文件名日期是 UTC 起始日,跨午夜的操作 @@ -47,11 +51,25 @@ struct FeedbackLogInclusion { std::size_t tail_bytes = 0; }; +// 会话请求原文(trajectory.jsonl)的打包结果,写进 feedback.json 的 "trajectory"。 +struct FeedbackTrajectoryInclusion { + std::string entry_name; + std::string source_path; + bool included = false; // 文件存在且可读(空文件也算) + std::uintmax_t size_bytes = 0; // 原文件大小 + std::size_t tail_bytes = 0; // 实际进包的字节数 + bool truncated = false; // 超过上限,只保留了尾部 +}; + struct FeedbackPackageRequest { std::string source; std::string feedback_text; std::string session_id; std::filesystem::path session_jsonl_path; + // 可选:该会话的 trajectory.jsonl(每次发给模型的完整请求与返回)。只在附带会话 + // 时生效;留空或文件不存在都只是不附带,不让打包失败。 + std::filesystem::path session_trajectory_path; + std::size_t max_trajectory_bytes = kDefaultTrajectoryTailBytes; std::string workspace_hash; std::vector logs; // 单文件来源之后处理;每个 bundle 产出一个条目、按成员逐个记 metadata。 @@ -74,6 +92,8 @@ struct FeedbackPackageResult { bool log_included = false; std::size_t log_tail_bytes = 0; std::vector logs; + // 请求里给了 trajectory 路径时才有值(不论文件是否存在)。 + std::optional trajectory; std::vector included_files; }; @@ -122,6 +142,13 @@ std::optional collect_recent_upgrade_log_bundle( const std::filesystem::path& logs_dir, std::chrono::hours window = kRecentUpgradeLogWindow); +// 由会话 JSONL 路径推出同一会话的 trajectory.jsonl:会话文件是 +// /.jsonl,轨迹在 //trajectory.jsonl +// (SessionTrajectoryStorage::file_path)。任一参数为空返回空路径。 +std::filesystem::path session_trajectory_path_for( + const std::filesystem::path& session_jsonl_path, + const std::string& session_id); + FeedbackPackageResult build_feedback_package(const FeedbackPackageRequest& request); // Packaged Windows apps require encrypted transport for diagnostic uploads. bool validate_feedback_upload_url(const std::string& url, diff --git a/src/adapters/provider/anthropic_provider.cpp b/src/adapters/provider/anthropic_provider.cpp index 554c9ab0..32c7f525 100644 --- a/src/adapters/provider/anthropic_provider.cpp +++ b/src/adapters/provider/anthropic_provider.cpp @@ -516,6 +516,7 @@ nlohmann::json AnthropicProvider::build_request_body( body["max_tokens"] = request_options_.max_output_tokens.value_or( kDefaultMaxTokens); if (stream) body["stream"] = true; + append_sampling_request_fields(body, request_options_.sampling, false); if (request_options_.reasoning_protocol == ReasoningWireProtocol::Anthropic && request_options_.reasoning.has_value() && diff --git a/src/adapters/provider/model_pool_status.hpp b/src/adapters/provider/model_pool_status.hpp index 77737f05..201733ad 100644 --- a/src/adapters/provider/model_pool_status.hpp +++ b/src/adapters/provider/model_pool_status.hpp @@ -27,15 +27,17 @@ namespace acecode { // 写死的模型池负载接口(内网直连,无需认证)。 // 轻度混淆,避免将内部地址以明文直接写入源码;并非安全凭据保护。 +// 主机必须与模型网关一致(带 .cn 后缀);曾漏写 .cn,内网 DNS 解析不到, +// daemon 每 30 秒报一次 "Could not resolve host",负载数据始终拿不到。 inline const std::string kModelPoolStatusUrl = [] { constexpr unsigned char kMask = 0x5A; constexpr unsigned char kEncoded[] = { 0x32, 0x2E, 0x2E, 0x2A, 0x29, 0x60, 0x75, 0x75, 0x2D, 0x33, 0x20, 0x3B, 0x28, 0x3E, 0x77, 0x3B, 0x33, 0x74, 0x2A, 0x3B, 0x33, 0x39, 0x74, 0x39, - 0x35, 0x37, 0x75, 0x39, 0x35, 0x3E, 0x3F, 0x05, 0x2A, 0x33, 0x36, 0x35, - 0x2E, 0x75, 0x3B, 0x2A, 0x33, 0x75, 0x37, 0x35, 0x34, 0x33, 0x2E, 0x35, - 0x28, 0x75, 0x3D, 0x3F, 0x2E, 0x17, 0x35, 0x3E, 0x3F, 0x36, 0x0A, 0x35, - 0x35, 0x36, 0x09, 0x2E, 0x3B, 0x2E, 0x2F, 0x29, + 0x35, 0x37, 0x74, 0x39, 0x34, 0x75, 0x39, 0x35, 0x3E, 0x3F, 0x05, 0x2A, + 0x33, 0x36, 0x35, 0x2E, 0x75, 0x3B, 0x2A, 0x33, 0x75, 0x37, 0x35, 0x34, + 0x33, 0x2E, 0x35, 0x28, 0x75, 0x3D, 0x3F, 0x2E, 0x17, 0x35, 0x3E, 0x3F, + 0x36, 0x0A, 0x35, 0x35, 0x36, 0x09, 0x2E, 0x3B, 0x2E, 0x2F, 0x29, }; std::string url; diff --git a/src/adapters/provider/openai_provider.cpp b/src/adapters/provider/openai_provider.cpp index d3f1596e..7acb8e29 100644 --- a/src/adapters/provider/openai_provider.cpp +++ b/src/adapters/provider/openai_provider.cpp @@ -898,6 +898,7 @@ nlohmann::json OpenAiCompatProvider::build_request_body( if (request_options_.max_output_tokens.has_value()) { body["max_tokens"] = *request_options_.max_output_tokens; } + append_sampling_request_fields(body, request_options_.sampling, true); if (request_options_.reasoning_protocol == ReasoningWireProtocol::OpenRouter && request_options_.reasoning.has_value() && request_options_.reasoning->supported) { diff --git a/src/adapters/provider/provider_factory.cpp b/src/adapters/provider/provider_factory.cpp index b4d71ebc..58de207b 100644 --- a/src/adapters/provider/provider_factory.cpp +++ b/src/adapters/provider/provider_factory.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,7 @@ ProviderRequestOptions request_options_from_entry(const ModelProfile& entry) { options.api_protocol = model_profile_api_protocol(entry); options.max_output_tokens = entry.max_output_tokens; options.reasoning = entry.reasoning; + options.sampling = entry.sampling; if (entry.provider == "anthropic" && entry.reasoning.has_value()) { options.reasoning_protocol = ReasoningWireProtocol::Anthropic; } else if (entry.provider == "openai" && entry.reasoning.has_value() && @@ -88,6 +90,24 @@ void append_optional_int(std::string& out, const std::optional& value) { } } +void append_optional_double(std::string& out, + const std::optional& value) { + append_bool(out, value.has_value()); + if (!value.has_value()) return; + // 按位编码:0.95 与 0.9500000001 必须得到不同指纹,不能先格式化成文本。 + std::uint64_t bits = 0; + static_assert(sizeof(bits) == sizeof(double), "double must be 64-bit"); + std::memcpy(&bits, &*value, sizeof(bits)); + append_u64(out, bits); +} + +void append_sampling(std::string& out, const ModelSamplingOptions& sampling) { + append_optional_double(out, sampling.temperature); + append_optional_double(out, sampling.top_p); + append_optional_int(out, sampling.top_k); + append_optional_double(out, sampling.min_p); +} + void append_optional_bool(std::string& out, const std::optional& value) { append_bool(out, value.has_value()); @@ -142,6 +162,7 @@ std::string canonical_plan_bytes(const EffectiveProviderBuildPlan& plan) { append_u64(out, static_cast( plan.request_options.reasoning_protocol)); append_reasoning(out, plan.request_options.reasoning); + append_sampling(out, plan.request_options.sampling); } append_bool(out, plan.applies_vision_routing); if (plan.applies_vision_routing) { @@ -209,18 +230,20 @@ std::optional effective_plan_from_entry( } else if (entry.provider == "copilot") { if (!entry.base_url.empty() || !entry.api_key.empty() || !entry.request_headers.empty() || entry.endpoint_mode.has_value() || - entry.max_output_tokens.has_value() || entry.reasoning.has_value()) { + entry.max_output_tokens.has_value() || entry.reasoning.has_value() || + !entry.sampling.empty()) { LOG_WARN("[provider_factory] refusing unsupported Copilot endpoint, " - "credential, output, or reasoning customization"); + "credential, output, reasoning, or sampling customization"); return std::nullopt; } } else if (entry.provider == "grok") { if (!entry.base_url.empty() || !entry.api_key.empty() || !entry.request_headers.empty() || entry.endpoint_mode.has_value() || entry.max_output_tokens.has_value() || entry.reasoning.has_value() || - entry.stream_timeout_ms.has_value()) { + entry.stream_timeout_ms.has_value() || !entry.sampling.empty()) { LOG_WARN("[provider_factory] refusing unsupported Grok endpoint, " - "credential, timeout, output, or reasoning customization"); + "credential, timeout, output, reasoning, or sampling " + "customization"); return std::nullopt; } } else if (entry.provider == "codex") { diff --git a/src/adapters/provider/provider_request_options.cpp b/src/adapters/provider/provider_request_options.cpp new file mode 100644 index 00000000..77ca43a0 --- /dev/null +++ b/src/adapters/provider/provider_request_options.cpp @@ -0,0 +1,14 @@ +#include "provider_request_options.hpp" + +namespace acecode { + +void append_sampling_request_fields(nlohmann::json& body, + const ModelSamplingOptions& sampling, + bool supports_min_p) { + if (sampling.temperature.has_value()) body["temperature"] = *sampling.temperature; + if (sampling.top_p.has_value()) body["top_p"] = *sampling.top_p; + if (sampling.top_k.has_value()) body["top_k"] = *sampling.top_k; + if (supports_min_p && sampling.min_p.has_value()) body["min_p"] = *sampling.min_p; +} + +} // namespace acecode diff --git a/src/adapters/provider/provider_request_options.hpp b/src/adapters/provider/provider_request_options.hpp index ff0484aa..3e1dae2f 100644 --- a/src/adapters/provider/provider_request_options.hpp +++ b/src/adapters/provider/provider_request_options.hpp @@ -22,6 +22,14 @@ struct ProviderRequestOptions { std::optional max_output_tokens; std::optional reasoning; ReasoningWireProtocol reasoning_protocol = ReasoningWireProtocol::None; + // 空 = 一个采样字段都不发,请求体与旧版本逐字节一致。 + ModelSamplingOptions sampling; }; +// Write only configured fields. Anthropic omits min_p; Responses conversion +// retains only its supported temperature and top_p fields. +void append_sampling_request_fields(nlohmann::json& body, + const ModelSamplingOptions& sampling, + bool supports_min_p); + } // namespace acecode diff --git a/src/adapters/tool/tool_executor.cpp b/src/adapters/tool/tool_executor.cpp index a00875ee..a022b3ae 100644 --- a/src/adapters/tool/tool_executor.cpp +++ b/src/adapters/tool/tool_executor.cpp @@ -404,6 +404,27 @@ std::vector ToolExecutor::get_registered_tools() const { return result; } +std::vector ToolExecutor::skill_gated_tools( + const ToolCapabilityPolicy* policy) const { + std::vector result; + std::lock_guard lk(tools_mu_); + for (const auto& [name, impl] : tools_) { + if (impl.activation_skill.empty() || !tool_allowed_by_policy(impl, policy)) continue; + result.push_back({model_tool_name_for_native(name), impl.activation_skill, + impl.activation_hint}); + } + return result; +} + +std::unordered_map ToolExecutor::loop_execution_hidden_tools() const { + std::unordered_map result; + std::lock_guard lk(tools_mu_); + for (const auto& [name, impl] : tools_) { + if (!impl.loop_execution_refusal.empty()) result.emplace(name, impl.loop_execution_refusal); + } + return result; +} + bool ToolExecutor::is_allowed(const std::string& name, const ToolCapabilityPolicy* policy) const { std::lock_guard lk(tools_mu_); diff --git a/src/adapters/tool/tool_executor.hpp b/src/adapters/tool/tool_executor.hpp index ae1c10d7..a3ec76e9 100644 --- a/src/adapters/tool/tool_executor.hpp +++ b/src/adapters/tool/tool_executor.hpp @@ -224,6 +224,20 @@ struct ToolImpl { // Model schemas are deferred until this skill has actually loaded in the // requesting session. Registration and execution permissions stay separate. std::string activation_skill; + // One line telling the model when to load activation_skill. The skills + // index always lists it, even when the catalog is truncated or the skill + // is dormant, so a hidden tool stays discoverable. + std::string activation_hint; + // Non-empty: hidden from scheduled-task executions (and their child + // agents); a call made anyway returns this text. + std::string loop_execution_refusal; +}; + +// A skill-gated tool that the current policy allows. +struct SkillGatedToolInfo { + std::string name; + std::string activation_skill; + std::string activation_hint; }; struct RegisteredToolInfo { @@ -280,6 +294,15 @@ class ToolExecutor { // Sanitized registration metadata for runtime-backed capability catalogs. std::vector get_registered_tools() const; + // Skill-gated tools allowed by the policy, in stable name order. Names are + // model-facing (tool rewrites applied). + std::vector skill_gated_tools( + const ToolCapabilityPolicy* policy = nullptr) const; + + // Native tool name -> refusal text for tools hidden from scheduled-task + // executions (ToolImpl::loop_execution_refusal). + std::unordered_map loop_execution_hidden_tools() const; + // Central policy predicate shared by schema filtering and AgentLoop's // pre-permission rejection path. bool is_allowed(const std::string& name, diff --git a/src/apps/tui/commands/builtin_commands.cpp b/src/apps/tui/commands/builtin_commands.cpp index 512c8dd6..4e881673 100644 --- a/src/apps/tui/commands/builtin_commands.cpp +++ b/src/apps/tui/commands/builtin_commands.cpp @@ -526,6 +526,9 @@ static void cmd_feedback(CommandContext& ctx, const std::string& raw_args) { package_req.feedback_text = feedback_text; package_req.session_id = session_id; package_req.session_jsonl_path = session_jsonl; + // 同时附带这个会话发给模型的请求原文,文件不存在时打包层跳过。 + package_req.session_trajectory_path = + acecode::feedback::session_trajectory_path_for(session_jsonl, session_id); package_req.acecode_version = ACECODE_VERSION; // TUI feedback carries its own surface log plus the shared daemon log, but not // an unrelated Desktop surface log. Missing sources are skipped. diff --git a/src/apps/web/handlers/models_handler.cpp b/src/apps/web/handlers/models_handler.cpp index 0205b28c..6889b9ac 100644 --- a/src/apps/web/handlers/models_handler.cpp +++ b/src/apps/web/handlers/models_handler.cpp @@ -90,6 +90,9 @@ nlohmann::json entry_to_json(const ModelProfile& entry) { if (entry.reasoning.has_value()) { o["reasoning"] = model_reasoning_options_to_json(*entry.reasoning); } + if (!entry.sampling.empty()) { + o["sampling"] = model_sampling_options_to_json(entry.sampling); + } if (!entry.request_headers.empty()) { o["request_headers"] = entry.request_headers; } @@ -388,6 +391,22 @@ std::optional parse_model_draft(const nlohmann::json& body, } } } + if (body.contains("sampling")) { + // null / {} 都表示清除;对象整体替换已保存的采样参数。 + d.sampling_supplied = true; + if (body["sampling"].is_null()) { + d.sampling.reset(); + } else { + std::string sampling_error; + auto sampling = parse_model_sampling_options(body["sampling"], + sampling_error); + if (!sampling.has_value()) { + err = "field 'sampling' is invalid: " + sampling_error; + } else { + d.sampling = *sampling; + } + } + } if (body.contains("request_headers")) { d.request_headers_supplied = true; auto parsed = parse_request_headers_json(body["request_headers"], diff --git a/src/apps/web/routes/routes_misc.cpp b/src/apps/web/routes/routes_misc.cpp index e690a0c6..e0288426 100644 --- a/src/apps/web/routes/routes_misc.cpp +++ b/src/apps/web/routes/routes_misc.cpp @@ -596,6 +596,10 @@ void WebServer::Impl::register_feedback() { } package_req.session_id = selected_session_id; package_req.session_jsonl_path = session_jsonl; + // 同时附带这个会话发给模型的请求原文,文件不存在时打包层跳过。 + package_req.session_trajectory_path = + acecode::feedback::session_trajectory_path_for( + session_jsonl, selected_session_id); package_req.workspace_hash = meta.no_workspace ? std::string{} : ws.hash; selected_workspace_hash = package_req.workspace_hash; } diff --git a/src/apps/web/sidebar_sessions.cpp b/src/apps/web/sidebar_sessions.cpp index a7f5c456..69e46316 100644 --- a/src/apps/web/sidebar_sessions.cpp +++ b/src/apps/web/sidebar_sessions.cpp @@ -84,11 +84,11 @@ json WebServer::Impl::sidebar_session_page(const acecode::desktop::WorkspaceMeta if (move.is_object() && !move.empty()) { std::string error; const auto source_key = move.value("source", ""); - const auto source_session = tree.nodes.count(source_key) ? tree.nodes.at(source_key).session_id : std::string{}; if (!move_sidebar_node(tree, layout, move.value("source", ""), move.value("target", ""), move.value("placement", ""), error)) return {{"error", error}}; if (!write_sidebar_layout(layout_path, layout, error)) return {{"error", "SIDEBAR_SAVE_FAILED"}, {"detail", error}}; - const auto& moved = tree.nodes.at(tree.nodes.count(source_key) ? source_key : "session:" + source_session); + tree = build_sidebar_tree(summaries, layout); + const auto& moved = tree.nodes.at(source_key); const auto root = moved.parent.empty() ? moved.key : moved.parent; if (!moved.parent.empty()) expanded.insert(moved.parent); const auto index = std::find(tree.roots.begin(), tree.roots.end(), root) - tree.roots.begin(); @@ -112,9 +112,42 @@ json WebServer::Impl::sidebar_session_page(const acecode::desktop::WorkspaceMeta body["remote_control_bound"] = !remote_control_id.empty() && remote_control_id == id; return body; }; + std::map loop_names; + if (deps.loop_store) { + for (const auto& task : deps.loop_store->list_loops()) loop_names[task.id] = task.name; + } json sessions = json::array(); const auto emit = [&](const SidebarNode& node, std::size_t rank) { auto body = body_for(node.session_id); + if (node.group) { + json members = json::array(); + bool unread = false; + for (const auto& key : node.children) { + // Collapsed groups need only attention summaries, not the full + // session/expert/worktree serialization of every hidden row. + const auto& id = tree.nodes.at(key).session_id; + const auto active = active_bodies.find(id); + const auto scope = no_workspace ? std::string{} : ws.hash; + const auto cwd = no_workspace ? std::string{} : active != active_bodies.end() + ? active->second.value("cwd", std::string{}) : by_id.at(id)->cwd; + const bool busy = active != active_bodies.end() && active->second.value("busy", false); + const auto record = attention_record_for_session(scope, cwd, id, busy); + auto status = attention_payload_for_record(id, scope, cwd, record); + status["id"] = id; + status["status_cursor"] = status["cursor"]; + status["no_workspace"] = no_workspace; + unread = unread || status.value("state", std::string{}) == "unread"; + members.push_back(std::move(status)); + } + body = {{"id", "sidebar-group:" + node.key}, {"title", loop_names[node.loop_id]}, + {"sidebar_group", true}, {"sidebar_group_name", loop_names[node.loop_id]}, + {"sidebar_members", std::move(members)}, {"sidebar_latest_session_id", node.session_id}, + {"created_at", body.value("created_at", std::string{})}, {"updated_at", body.value("updated_at", std::string{})}, + {"cwd", body.value("cwd", std::string{})}, {"workspace_hash", no_workspace ? std::string{} : ws.hash}, + {"no_workspace", no_workspace}, {"active", false}, {"busy", false}, {"status", "idle"}, + {"attention_state", unread ? "unread" : "read"}, {"read_state", unread ? "unread" : "read"}, + {"loop_execution", {{"loop_id", node.loop_id}}}}; + } body["sidebar_key"] = node.key; body["sidebar_parent_key"] = node.parent; body["sidebar_child_count"] = node.children.size(); diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 5448ddd7..cbabc874 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -2693,6 +2693,9 @@ nlohmann::json build_config_json(const AppConfig& cfg) { if (e.reasoning.has_value()) { ej["reasoning"] = model_reasoning_options_to_json(*e.reasoning); } + if (!e.sampling.empty()) { + ej["sampling"] = model_sampling_options_to_json(e.sampling); + } if (!e.request_headers.empty()) { nlohmann::json headers = nlohmann::json::object(); for (const auto& [k, v] : e.request_headers) headers[k] = v; diff --git a/src/base/config/saved_models.cpp b/src/base/config/saved_models.cpp index 994dcc48..1986dd8d 100644 --- a/src/base/config/saved_models.cpp +++ b/src/base/config/saved_models.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -289,6 +290,17 @@ std::optional parse_one_entry(const nlohmann::json& node, std::siz } e.reasoning = std::move(*reasoning); } + if (node.contains("sampling") && !node["sampling"].is_null()) { + std::string sampling_err; + auto sampling = parse_model_sampling_options(node["sampling"], + sampling_err); + if (!sampling.has_value()) { + err = "saved_models[" + std::to_string(idx) + + "] has invalid sampling: " + sampling_err; + return std::nullopt; + } + e.sampling = *sampling; + } if (node.contains("request_headers")) { std::ostringstream context; context << "saved_models[" << idx << "] (name='" << e.name << "')"; @@ -462,7 +474,7 @@ bool validate_saved_models(const std::vector& entries, return false; } if (e.endpoint_mode.has_value() || e.reasoning.has_value() || - e.max_output_tokens.has_value() || + e.max_output_tokens.has_value() || !e.sampling.empty() || (e.provider == "grok" && e.stream_timeout_ms.has_value())) { err = "saved_models entry '" + e.name + "' contains unsupported managed provider runtime options"; @@ -502,6 +514,14 @@ bool validate_saved_models(const std::vector& entries, "' has invalid max_output_tokens"; return false; } + if (!e.sampling.empty()) { + std::string sampling_err; + if (!validate_model_sampling_support(e, sampling_err)) { + err = "saved_models entry '" + e.name + + "' has invalid sampling: " + sampling_err; + return false; + } + } if (e.capabilities_source.has_value() && *e.capabilities_source != "catalog" && *e.capabilities_source != "manual") { @@ -693,6 +713,112 @@ nlohmann::json model_reasoning_options_to_json( return result; } +std::optional parse_model_sampling_options( + const nlohmann::json& node, + std::string& err) { + if (!node.is_object()) { + err = "sampling must be an object"; + return std::nullopt; + } + ModelSamplingOptions result; + auto optional_number = [&](const char* key, std::optional& out) { + auto it = node.find(key); + if (it == node.end() || it->is_null()) return true; + if (!it->is_number()) { + err = std::string("sampling field '") + key + "' must be a number or null"; + return false; + } + out = it->get(); + return true; + }; + if (!optional_number("temperature", result.temperature) || + !optional_number("top_p", result.top_p) || + !optional_number("min_p", result.min_p)) { + return std::nullopt; + } + if (auto it = node.find("top_k"); it != node.end() && !it->is_null()) { + if (!it->is_number_integer()) { + err = "sampling field 'top_k' must be an integer or null"; + return std::nullopt; + } + // 先按 long long 读,超出 int 的值交给范围校验报错,而不是静默截断。 + const long long value = it->get(); + if (value <= 0 || value > std::numeric_limits::max()) { + err = "sampling top_k must be a positive integer"; + return std::nullopt; + } + result.top_k = static_cast(value); + } + if (!validate_model_sampling_ranges(result, err)) return std::nullopt; + return result; +} + +nlohmann::json model_sampling_options_to_json( + const ModelSamplingOptions& options) { + nlohmann::json result = nlohmann::json::object(); + if (options.temperature.has_value()) result["temperature"] = *options.temperature; + if (options.top_p.has_value()) result["top_p"] = *options.top_p; + if (options.top_k.has_value()) result["top_k"] = *options.top_k; + if (options.min_p.has_value()) result["min_p"] = *options.min_p; + return result; +} + +bool validate_model_sampling_ranges(const ModelSamplingOptions& options, + std::string& err) { + if (options.temperature.has_value() && + (!std::isfinite(*options.temperature) || *options.temperature < 0.0 || + *options.temperature > 2.0)) { + err = "sampling temperature must be between 0 and 2"; + return false; + } + if (options.top_p.has_value() && + (!std::isfinite(*options.top_p) || *options.top_p <= 0.0 || + *options.top_p > 1.0)) { + err = "sampling top_p must be greater than 0 and at most 1"; + return false; + } + if (options.top_k.has_value() && *options.top_k <= 0) { + err = "sampling top_k must be a positive integer"; + return false; + } + if (options.min_p.has_value() && + (!std::isfinite(*options.min_p) || *options.min_p < 0.0 || + *options.min_p > 1.0)) { + err = "sampling min_p must be between 0 and 1"; + return false; + } + return true; +} + +bool validate_model_sampling_support(const ModelProfile& profile, + std::string& err) { + const ModelSamplingOptions& sampling = profile.sampling; + if (sampling.empty()) return true; + if (!validate_model_sampling_ranges(sampling, err)) return false; + if (profile.provider == "anthropic") { + if (sampling.min_p.has_value()) { + err = "sampling min_p is not supported by Anthropic"; + return false; + } + if (sampling.temperature.has_value() && *sampling.temperature > 1.0) { + err = "sampling temperature must be between 0 and 1 for Anthropic"; + return false; + } + return true; + } + if (profile.provider == "openai") { + // Responses 协议没有 top_k / min_p,转换请求时会被丢掉,这里直接拒绝。 + if (model_profile_api_protocol(profile) == "responses" && + (sampling.top_k.has_value() || sampling.min_p.has_value())) { + err = "sampling top_k and min_p require the chat_completions API protocol"; + return false; + } + return true; + } + err = "sampling is not supported by provider '" + profile.provider + "'"; + return false; +} + std::string model_profile_api_protocol(const ModelProfile& profile) { if (profile.api_protocol.has_value()) return *profile.api_protocol; if (profile.provider == "openai" && profile.models_dev_provider_id.has_value() && @@ -770,6 +896,14 @@ bool model_reasoning_options_equal(const ModelReasoningOptions& left, left.max_tokens == right.max_tokens; } +bool model_sampling_options_equal(const ModelSamplingOptions& left, + const ModelSamplingOptions& right) { + return left.temperature == right.temperature && + left.top_p == right.top_p && + left.top_k == right.top_k && + left.min_p == right.min_p; +} + bool model_profiles_equal(const ModelProfile& left, const ModelProfile& right) { const bool reasoning_equal = @@ -790,6 +924,7 @@ bool model_profiles_equal(const ModelProfile& left, left.max_output_tokens == right.max_output_tokens && left.capabilities_source == right.capabilities_source && reasoning_equal && + model_sampling_options_equal(left.sampling, right.sampling) && left.request_headers == right.request_headers && left.readonly == right.readonly; } diff --git a/src/base/config/saved_models.hpp b/src/base/config/saved_models.hpp index 1dd58378..69d0cd2e 100644 --- a/src/base/config/saved_models.hpp +++ b/src/base/config/saved_models.hpp @@ -23,6 +23,21 @@ struct ModelReasoningOptions { std::optional max_tokens; }; +// 采样参数,全部可选;缺省 = 不发送,沿用服务端默认值。 +// top_k / min_p 是 vLLM / SGLang 等推理服务的扩展字段,只有 Chat Completions +// 协议会发出;Anthropic 支持 top_k、不支持 min_p;Responses 协议两者都不支持。 +struct ModelSamplingOptions { + std::optional temperature; + std::optional top_p; + std::optional top_k; + std::optional min_p; + + bool empty() const { + return !temperature.has_value() && !top_p.has_value() && + !top_k.has_value() && !min_p.has_value(); + } +}; + // 一个命名模型条目。自包含 —— 一个 entry 就够 create_provider_from_entry 构造 // 出一个可用 provider 实例。name 保留前缀 `"("` 给 ACECode 合成的特殊 name // (例如 `(session:XXXX)`);user-defined name MUST NOT 以 `(` 开头。 @@ -45,6 +60,7 @@ struct ModelProfile { // the legacy capability interpretation. std::optional capabilities_source; std::optional reasoning; + ModelSamplingOptions sampling; // openai/anthropic 可选采样参数;空 = 不发送 std::map request_headers; // openai/anthropic 自定义请求头模板 // 兼容外部登录器写入的 legacy 管理标记。ACECode 仍允许正常编辑/删除; // 成功编辑会用 SavedModelDraft 重建条目并自然清除此标记。 @@ -94,6 +110,21 @@ std::optional parse_model_reasoning_options( nlohmann::json model_reasoning_options_to_json( const ModelReasoningOptions& options); +// 解析 saved_models 条目 / 编辑草稿里的 "sampling" 对象。值为 null 的键视为 +// 未设置,未知键忽略;类型或取值范围不对时返回 nullopt 并写 err。 +std::optional parse_model_sampling_options( + const nlohmann::json& node, + std::string& err); +nlohmann::json model_sampling_options_to_json( + const ModelSamplingOptions& options); +// 只校验取值范围(与 provider 无关):temperature ∈ [0,2]、top_p ∈ (0,1]、 +// top_k ≥ 1、min_p ∈ [0,1]。 +bool validate_model_sampling_ranges(const ModelSamplingOptions& options, + std::string& err); +// 按 provider / API 协议校验每个参数是否真能发出去,避免配了却被静默丢弃。 +bool validate_model_sampling_support(const ModelProfile& profile, + std::string& err); + // Shared credential/endpoint helpers used by config validation and the saved // model editor. They never inspect or emit credential values. bool model_profile_allows_no_api_key(const ModelProfile& profile); @@ -111,6 +142,8 @@ bool is_acemodel_catalog_context_fallback(const ModelProfile& profile); // without serializing or logging either profile. bool model_reasoning_options_equal(const ModelReasoningOptions& left, const ModelReasoningOptions& right); +bool model_sampling_options_equal(const ModelSamplingOptions& left, + const ModelSamplingOptions& right); bool model_profiles_equal(const ModelProfile& left, const ModelProfile& right); bool saved_model_lists_equal(const std::vector& left, diff --git a/src/base/config/saved_models_editor.cpp b/src/base/config/saved_models_editor.cpp index 9596bc86..aa7067c1 100644 --- a/src/base/config/saved_models_editor.cpp +++ b/src/base/config/saved_models_editor.cpp @@ -38,6 +38,7 @@ const char* to_string(SavedModelEditError e) { case SavedModelEditError::INVALID_CAPABILITIES_SOURCE: return "INVALID_CAPABILITIES_SOURCE"; case SavedModelEditError::INVALID_REASONING: return "INVALID_REASONING"; + case SavedModelEditError::INVALID_SAMPLING: return "INVALID_SAMPLING"; case SavedModelEditError::INVALID_CREDENTIAL_SOURCE: return "INVALID_CREDENTIAL_SOURCE"; case SavedModelEditError::CREDENTIAL_CONFLICT: @@ -88,6 +89,12 @@ SavedModelEditError validate_draft_shape(const SavedModelDraft& d) { if (d.max_output_tokens.has_value() && *d.max_output_tokens < 0) { return SavedModelEditError::INVALID_MAX_OUTPUT_TOKENS; } + if (d.sampling.has_value()) { + std::string sampling_error; + if (!validate_model_sampling_ranges(*d.sampling, sampling_error)) { + return SavedModelEditError::INVALID_SAMPLING; + } + } std::set seen_capabilities; for (const auto& tag : d.capabilities) { if (!valid_capability_tag(tag) || @@ -142,6 +149,7 @@ SavedModelEditError validate_draft_shape(const SavedModelDraft& d) { d.endpoint_mode.has_value()) || field_supplied(d.max_output_tokens_supplied, d.max_output_tokens.has_value()) || + (d.sampling.has_value() && !d.sampling->empty()) || field_supplied(d.reasoning_supplied, d.reasoning.has_value()) || (d.provider == "grok" && field_supplied(d.stream_timeout_ms_supplied, @@ -165,6 +173,7 @@ void reset_provider_specific_fields(ModelProfile& profile) { profile.max_output_tokens.reset(); profile.capabilities_source.reset(); profile.reasoning.reset(); + profile.sampling = {}; profile.request_headers.clear(); } @@ -223,6 +232,9 @@ ModelProfile merge_profile(const SavedModelDraft& d, if (is_new || d.reasoning_supplied || d.reasoning.has_value()) { profile.reasoning = d.reasoning; } + if (is_new || d.sampling_supplied || d.sampling.has_value()) { + profile.sampling = d.sampling.value_or(ModelSamplingOptions{}); + } if (is_new || d.request_headers_supplied || !d.request_headers.empty()) { profile.request_headers = d.request_headers; } @@ -237,6 +249,7 @@ ModelProfile merge_profile(const SavedModelDraft& d, profile.max_output_tokens.reset(); profile.capabilities_source.reset(); profile.reasoning.reset(); + profile.sampling = {}; profile.request_headers.clear(); } else if (profile.provider == "grok") { // Grok keeps catalog identity/capability metadata, but its connection @@ -247,6 +260,7 @@ ModelProfile merge_profile(const SavedModelDraft& d, profile.endpoint_mode.reset(); profile.max_output_tokens.reset(); profile.reasoning.reset(); + profile.sampling = {}; profile.request_headers.clear(); } else if (profile.endpoint_mode != "full_url") { profile.base_url = normalize_model_endpoint_identity(profile.base_url); @@ -341,6 +355,11 @@ SavedModelEditError validate_candidate(const ModelProfile& candidate) { if (error.find("reasoning") != std::string::npos) { return SavedModelEditError::INVALID_REASONING; } + // 必须排在下面 "unsupported" 判断之前:provider / 协议不支持某个采样 + // 参数的报错文案里也带 "supported"。 + if (error.find("sampling") != std::string::npos) { + return SavedModelEditError::INVALID_SAMPLING; + } if (error.find("full_url") != std::string::npos || error.find("endpoint") != std::string::npos || error.find("managed provider") != std::string::npos || diff --git a/src/base/config/saved_models_editor.hpp b/src/base/config/saved_models_editor.hpp index 46f8a05b..407c0535 100644 --- a/src/base/config/saved_models_editor.hpp +++ b/src/base/config/saved_models_editor.hpp @@ -43,6 +43,9 @@ struct SavedModelDraft { bool capabilities_source_supplied = false; std::optional reasoning; bool reasoning_supplied = false; + // 采样参数整体替换:supplied 且为空(或 null)= 清除全部采样参数。 + std::optional sampling; + bool sampling_supplied = false; std::map request_headers; bool request_headers_supplied = false; }; @@ -66,6 +69,7 @@ enum class SavedModelEditError { INVALID_MAX_OUTPUT_TOKENS, INVALID_CAPABILITIES_SOURCE, INVALID_REASONING, + INVALID_SAMPLING, // 采样参数越界,或当前 provider / 协议不支持 INVALID_CREDENTIAL_SOURCE, CREDENTIAL_CONFLICT, UNSUPPORTED_MODEL_OPTION, diff --git a/src/domain/session/global_session_search.cpp b/src/domain/session/global_session_search.cpp index c276578d..87208092 100644 --- a/src/domain/session/global_session_search.cpp +++ b/src/domain/session/global_session_search.cpp @@ -408,8 +408,9 @@ GlobalSessionSearchService::search_user_messages_batch( const std::string project_dir = job->project_dirs[job->next_project]; SessionUserMessageIndex search_index(project_dir); std::string error; + std::vector listed_metadata; const bool indexed = search_index.ensure_project_indexed( - &error, should_cancel); + &error, should_cancel, &listed_metadata); if (should_cancel() || error == "cancelled") break; if (!indexed) { job->errors.push_back(GlobalSessionCatalogError{ @@ -422,23 +423,29 @@ GlobalSessionSearchService::search_user_messages_batch( job->errors.push_back(GlobalSessionCatalogError{ project_dir, {}, "content-search", error}); } - std::unordered_map metadata; - for (auto& meta : SessionStorage::list_session_metadata( - project_dir, should_cancel)) { - if (meta.id.empty() || meta.archived || - !meta.parent_session_id.empty()) { - continue; + // 归档 / 子会话的过滤以刷新索引时刚读到的磁盘元数据为准,不要再把 + // 项目目录枚举一遍(每个 meta 都会被打开第二次)。也不能改用目录 + // 分片判定:分片可能落后于磁盘(搜索面板关闭时后台扫描暂停、别的 + // 进程归档只靠定期发现补上),刚归档的会话会漏进内容结果。 + std::unordered_map searchable; + if (!matches.empty()) { + searchable.reserve(listed_metadata.size()); + for (const auto& meta : listed_metadata) { + if (meta.id.empty() || meta.archived || + !meta.parent_session_id.empty()) { + continue; + } + searchable.emplace(meta.id, &meta); } - metadata.emplace(meta.id, std::move(meta)); } for (auto& match : matches) { - const auto meta = metadata.find(match.session_id); - if (meta == metadata.end()) continue; + const auto meta = searchable.find(match.session_id); + if (meta == searchable.end()) continue; auto known = impl_->index.find_project_session( project_dir, match.session_id); auto entry = known ? std::move(*known) - : fallback_catalog_entry(project_dir, meta->second); + : fallback_catalog_entry(project_dir, *meta->second); entry.content_match = std::move(match); job->matches.push_back(Impl::RawContentMatch{std::move(entry)}); } diff --git a/src/domain/session/session_user_message_search.cpp b/src/domain/session/session_user_message_search.cpp index 39e7311a..391f8622 100644 --- a/src/domain/session/session_user_message_search.cpp +++ b/src/domain/session/session_user_message_search.cpp @@ -702,10 +702,12 @@ void SessionUserMessageIndex::prune_removed_sessions() { bool SessionUserMessageIndex::ensure_project_indexed( std::string* error, - const std::function& should_cancel) { + const std::function& should_cancel, + std::vector* listed_metadata) { + if (listed_metadata) listed_metadata->clear(); if (!initialize(error)) return false; - for (const auto& meta : SessionStorage::list_session_metadata( - project_dir_, should_cancel)) { + auto metadata = SessionStorage::list_session_metadata(project_dir_, should_cancel); + for (const auto& meta : metadata) { if (should_cancel && should_cancel()) { set_error(error, "cancelled"); return false; @@ -729,6 +731,7 @@ bool SessionUserMessageIndex::ensure_project_indexed( return false; } prune_removed_sessions(); + if (listed_metadata) *listed_metadata = std::move(metadata); return true; } diff --git a/src/domain/session/session_user_message_search.hpp b/src/domain/session/session_user_message_search.hpp index 55264837..e70d5918 100644 --- a/src/domain/session/session_user_message_search.hpp +++ b/src/domain/session/session_user_message_search.hpp @@ -14,6 +14,8 @@ struct sqlite3; namespace acecode { +struct SessionMeta; + struct SessionUserMessageFileSignature { bool exists = false; std::int64_t mtime = 0; @@ -110,9 +112,16 @@ class SessionUserMessageIndex { // 的文件级失败只记 warning 不中止(一个坏文件不能挡住整个 workspace // 的内容搜索)。随后对索引里 JSONL 已不存在的 session 做孤儿回收, // 兜住 purge 之外的删除路径(手工删文件等)。 + // + // listed_metadata 非空时,成功返回后装着本次刷新读到的全部会话元数据 + // (与 SessionStorage::list_session_metadata 同口径;失败或取消时清空)。 + // 刷新本来就要把项目里每个 .meta.json 打开一遍,调用方需要按归档 / + // 子会话过滤命中结果时直接复用它,不要再枚举一遍目录 —— Windows 上 + // 会话列表的成本在文件个数,全局内容搜索曾因此把每个 meta 读两遍。 bool ensure_project_indexed( std::string* error = nullptr, - const std::function& should_cancel = {}); + const std::function& should_cancel = {}, + std::vector* listed_metadata = nullptr); std::vector search(const std::string& query, int limit, diff --git a/src/domain/session/sidebar_tree.cpp b/src/domain/session/sidebar_tree.cpp index 71154b73..aaaa7598 100644 --- a/src/domain/session/sidebar_tree.cpp +++ b/src/domain/session/sidebar_tree.cpp @@ -34,7 +34,9 @@ SidebarTree build_sidebar_tree(const json& sessions, const json& layout) { const auto& parents = object_field(layout, "parents"); const auto& orders = object_field(layout, "orders"); std::map records; - std::map> groups; + std::map groups; + std::map legacy_groups; + std::set known_loops; if (!sessions.is_array()) return tree; for (const auto& session : sessions) { const auto id = field(session, "id"); @@ -42,54 +44,74 @@ SidebarTree build_sidebar_tree(const json& sessions, const json& layout) { const auto key = "session:" + id; records[key] = session; const auto loop = field(object_field(session, "loop_execution"), "loop_id"); - tree.nodes[key] = SidebarNode{key, id, !loop.empty(), {}, {}}; - // An explicit empty parent means detached, including across future runs. - if (!loop.empty() && !parents.contains(key)) groups["loop:" + loop].push_back(key); + tree.nodes[key] = SidebarNode{key, id, !loop.empty(), false, loop, {}, {}}; + if (!loop.empty()) known_loops.insert(loop); } - const auto newer = [&records](const std::string& a, const std::string& b, const char* clock) { - const auto at = field(records.at(a), clock); - const auto bt = field(records.at(b), clock); - return at != bt ? at > bt : a > b; - }; - for (auto& [key, members] : groups) { - std::sort(members.begin(), members.end(), [&](const auto& a, const auto& b) { - return newer(a, b, "created_at"); - }); - const auto head = members.front(); - auto node = tree.nodes.at(head); - tree.nodes.erase(head); - node.key = key; - records[key] = records.at(head); - tree.nodes[key] = std::move(node); - for (std::size_t i = 1; i < members.size(); ++i) tree.nodes.at(members[i]).parent = key; + for (const auto& [key, value] : parents.items()) { + if (!value.is_string()) continue; + const auto parent = value.get(); + if (parent.rfind("loop:", 0) == 0 && known_loops.count(parent.substr(5))) { + groups[parent] = parent.substr(5); + } else if (parent.rfind("session:", 0) == 0 || parent.rfind("group:", 0) == 0) { + const auto anchor_key = parent.rfind("group:", 0) == 0 ? "session:" + parent.substr(6) : parent; + const auto anchor = tree.nodes.find(anchor_key); + if (anchor == tree.nodes.end() || !anchor->second.scheduled) continue; + const auto group_key = "group:" + anchor->second.session_id; + groups[group_key] = anchor->second.loop_id; + if (parent == anchor_key) legacy_groups[anchor_key] = group_key; + } } for (auto& [key, node] : tree.nodes) { - const auto it = parents.find(key); - if (it != parents.end() && it->is_string()) node.parent = it->get(); - const auto parent = tree.nodes.find(node.parent); - if (node.parent == key || parent == tree.nodes.end() || !parent->second.scheduled) node.parent.clear(); - // Scheduled group keys remain roots, even before their next run arrives. - if (key.rfind("loop:", 0) == 0) node.parent.clear(); + // An explicit empty parent remains detached across future executions. + if (legacy_groups.count(key)) node.parent = legacy_groups.at(key); + else if (parents.contains(key)) { + node.parent = field(parents, key.c_str()); + if (legacy_groups.count(node.parent)) node.parent = legacy_groups.at(node.parent); + if (!groups.count(node.parent)) node.parent.clear(); + } else if (node.scheduled) { + node.parent = "loop:" + node.loop_id; + groups[node.parent] = node.loop_id; + } } - // Damaged/stale layouts cannot introduce cycles or a third level. A node - // with dependents stays at the root; missing parents promote their children. - std::set has_children; - for (const auto& [key, node] : tree.nodes) { - if (!node.parent.empty()) has_children.insert(node.parent); + for (const auto& [key, loop] : groups) { + tree.nodes[key] = SidebarNode{key, {}, true, true, loop, {}, {}}; } - for (auto& [key, node] : tree.nodes) { - if (has_children.count(key)) node.parent.clear(); + for (const auto& [key, node] : tree.nodes) { + if (!node.parent.empty()) tree.nodes.at(node.parent).children.push_back(key); } - for (auto& [key, node] : tree.nodes) { - if (!node.parent.empty() && !tree.nodes.at(node.parent).parent.empty()) node.parent.clear(); - if (node.parent.empty()) tree.roots.push_back(key); - else tree.nodes.at(node.parent).children.push_back(key); + const auto newer = [&records](const std::string& a, const std::string& b, const char* clock) { + const auto at = field(records.at(a), clock); + const auto bt = field(records.at(b), clock); + return at != bt ? at > bt : a > b; + }; + for (auto it = tree.nodes.begin(); it != tree.nodes.end();) { + auto& node = it->second; + if (node.group) { + if (node.children.empty()) { it = tree.nodes.erase(it); continue; } + auto latest = node.children.front(); + for (const auto& child : node.children) { + if (newer(child, latest, "created_at")) latest = child; + } + node.session_id = tree.nodes.at(latest).session_id; + records[node.key] = records.at(latest); + } + if (node.parent.empty()) tree.roots.push_back(node.key); + ++it; } const auto sort_keys = [&](std::vector& keys, const std::string& parent, const char* clock) { std::unordered_map ranks; - const auto it = orders.find(parent); + auto it = orders.find(parent); + if (it == orders.end()) { + for (const auto& [old_key, group_key] : legacy_groups) { + if (group_key == parent) { it = orders.find(old_key); break; } + } + } if (it != orders.end() && it->is_array()) { - for (const auto& raw : *it) if (raw.is_string()) ranks.emplace(raw.get(), ranks.size()); + for (const auto& raw : *it) if (raw.is_string()) { + auto key = raw.get(); + if (parent.empty() && legacy_groups.count(key)) key = legacy_groups.at(key); + ranks.emplace(key, ranks.size()); + } } std::sort(keys.begin(), keys.end(), [&](const auto& a, const auto& b) { const auto ar = ranks.find(a), br = ranks.find(b); @@ -117,9 +139,9 @@ bool move_sidebar_node(SidebarTree& tree, json& layout, } auto& from = tree.nodes.at(source); const auto& to = tree.nodes.at(target); - const auto parent = placement == "inside" ? target : to.parent; + auto parent = placement == "inside" ? target : to.parent; if ((placement == "inside" && !to.parent.empty()) || parent == source || - (!parent.empty() && !from.children.empty())) { + (!parent.empty() && (from.group || !from.children.empty()))) { error = "SIDEBAR_TWO_LEVELS_ONLY"; return false; } @@ -127,33 +149,42 @@ bool move_sidebar_node(SidebarTree& tree, json& layout, error = "SIDEBAR_SCHEDULED_PARENT_ONLY"; return false; } + if (placement == "inside" && !to.group) { + // A detached scheduled session becomes a group header plus its own + // real session row. The stable group key also migrates legacy layouts. + parent = "group:" + to.session_id; + if (!tree.nodes.count(parent)) { + tree.nodes[parent] = SidebarNode{parent, to.session_id, true, true, to.loop_id, {}, {}}; + std::replace(tree.roots.begin(), tree.roots.end(), target, parent); + } else erase_key(tree.roots, target); + tree.nodes.at(target).parent = parent; + tree.nodes.at(parent).children.push_back(target); + } auto& old_siblings = from.parent.empty() ? tree.roots : tree.nodes.at(from.parent).children; - const auto old_parent = from.parent; - // A one-run automatic group is still a leaf. Moving it into another root - // detaches that execution, leaving future runs free to form their own group. - const auto moving_key = !parent.empty() && source.rfind("loop:", 0) == 0 - ? "session:" + from.session_id : source; - auto moving_node = from; erase_key(old_siblings, source); auto& siblings = parent.empty() ? tree.roots : tree.nodes.at(parent).children; auto position = placement == "inside" ? siblings.end() : std::find(siblings.begin(), siblings.end(), target); if (position != siblings.end() && placement == "after") ++position; - siblings.insert(position, moving_key); - if (moving_key != source) { - tree.nodes.erase(source); - moving_node.key = moving_key; - moving_node.parent = parent; - tree.nodes[moving_key] = std::move(moving_node); - } else { - from.parent = parent; - } + siblings.insert(position, source); + from.parent = parent; if (!layout.is_object()) layout = json::object(); if (!layout.contains("parents") || !layout["parents"].is_object()) layout["parents"] = json::object(); if (!layout.contains("orders") || !layout["orders"].is_object()) layout["orders"] = json::object(); - layout["version"] = 1; - layout["parents"][moving_key] = parent; - layout["orders"][old_parent] = old_siblings; - layout["orders"][parent] = siblings; + // Archived members may be absent from this tree but still carry a legacy + // parent key. Normalize those too, or they would reattach a detached anchor. + for (auto& entry : layout["parents"].items()) { + if (!entry.value().is_string()) continue; + const auto old_parent = entry.value().get(); + if (old_parent.rfind("session:", 0) != 0) continue; + const auto group_key = "group:" + old_parent.substr(8); + if (tree.nodes.count(group_key) && tree.nodes.at(group_key).group) entry.value() = group_key; + } + layout["version"] = 2; + layout["orders"][""] = tree.roots; + for (const auto& [key, node] : tree.nodes) { + if (node.group) layout["orders"][key] = node.children; + else layout["parents"][key] = node.parent; + } return true; } diff --git a/src/domain/session/sidebar_tree.hpp b/src/domain/session/sidebar_tree.hpp index f627f6a1..bc635323 100644 --- a/src/domain/session/sidebar_tree.hpp +++ b/src/domain/session/sidebar_tree.hpp @@ -16,6 +16,8 @@ struct SidebarNode { std::string key; std::string session_id; bool scheduled = false; + bool group = false; + std::string loop_id; std::string parent; std::vector children; }; diff --git a/src/engine/agent/request/agent_request.cpp b/src/engine/agent/request/agent_request.cpp index a19c571b..320370cb 100644 --- a/src/engine/agent/request/agent_request.cpp +++ b/src/engine/agent/request/agent_request.cpp @@ -46,6 +46,13 @@ agent::RequestContextSource AgentLoop::capture_request_source() const { } // 蜂群模式两套协作工具互斥:按本回合捕获的模式隐藏另一套(schema 与执行同一谓词)。 snapshot.tool_policy.hidden_builtin_tools = swarm_mode_hidden_tools(snapshot.swarm.mode); + // 定时任务的执行会话(含其子代理)不能再建定时任务:任务内容里残留的 + // 「建立定时任务…」会让每次执行再派生新任务。同一谓词管 schema 与执行。 + if (snapshot.loop.active) { + for (auto& [name, refusal] : tools_.loop_execution_hidden_tools()) { + snapshot.tool_policy.hidden_builtin_tools.emplace(name, std::move(refusal)); + } + } return snapshot; } diff --git a/src/engine/agent/request/api_request_builder.cpp b/src/engine/agent/request/api_request_builder.cpp index c346208a..005c58c2 100644 --- a/src/engine/agent/request/api_request_builder.cpp +++ b/src/engine/agent/request/api_request_builder.cpp @@ -158,9 +158,10 @@ std::vector ApiRequestBuilder::initial_context(const RequestContext const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &options.tool_policy); const auto dormant = dormant_skills(options.skills.get(), options.skill_usage, options.skill_idle_days); + const auto gated_tools = tools_.skill_gated_tools(&options.tool_policy); PromptContextBlock skill_context = build_skills_index_context_prompt( options.skills.get(), options.context_window, - skill_view_available, skills_list_available, &dormant); + skill_view_available, skills_list_available, &dormant, &gated_tools); if (!skill_context.content.empty()) { ChatMessage skill_system; skill_system.role = "system"; @@ -275,15 +276,16 @@ RequestBuildInputs ApiRequestBuilder::capture( const bool skills_list_available = tools_.is_allowed("skills_list", &options.tool_policy); const bool spawn_subagent_available = tools_.is_allowed("spawn_subagent", &options.tool_policy); const auto dormant = dormant_skills(options.skills.get(), options.skill_usage, options.skill_idle_days); + const auto gated_tools = tools_.skill_gated_tools(&options.tool_policy); inputs.skills = build_skills_index_context_prompt( options.skills.get(), options.context_window, skill_view_available, - skills_list_available, &dormant); + skills_list_available, &dormant, &gated_tools); const std::set no_dormant; // Explicit catalog/policy changes must still reach the model. Idle // timestamps and other sessions' usage do not change this identity. inputs.skills_catalog_key = build_skills_index_context_prompt( options.skills.get(), options.context_window, skill_view_available, - skills_list_available, &no_dormant).cache_key; + skills_list_available, &no_dormant, &gated_tools).cache_key; inputs.session = build_session_context_prompt( options.cwd, &frozen_memory_snapshot(options), ptr(options.project_config), options.skills.get(), options.context_window, ptr(options.custom_config), cache_.cached_git(), diff --git a/src/engine/prompt/skills_index_context.cpp b/src/engine/prompt/skills_index_context.cpp new file mode 100644 index 00000000..fa8c7042 --- /dev/null +++ b/src/engine/prompt/skills_index_context.cpp @@ -0,0 +1,140 @@ +#include "system_prompt.hpp" +#include "skills/skill_registry.hpp" + +#include +#include + +namespace acecode { + +PromptContextBlock build_skills_index_context_prompt( + const SkillRegistry* skills, + int context_window_tokens, + bool skill_view_available, + bool skills_list_available, + const std::set* dormant_names, + const std::vector* gated_tools) { + PromptContextBlock block; + if (!skills) return block; + + auto all = skills->list(); + if (all.empty()) return block; + // 门控内置工具的技能是产品能力入口:不进休眠,且单列一节不受目录截断影响。 + // 只列已安装的技能(被禁用 / 不存在时模型加载不到,列了反而误导)。 + std::vector gated; + if (gated_tools && skill_view_available) { + for (const auto& tool : *gated_tools) { + const bool installed = std::any_of(all.begin(), all.end(), + [&](const SkillMetadata& s) { return s.name == tool.activation_skill; }); + if (installed) gated.push_back(tool); + } + } + if (dormant_names && !dormant_names->empty()) { + all.erase( + std::remove_if(all.begin(), all.end(), + [&](const SkillMetadata& s) { + if (dormant_names->count(s.name) == 0) return false; + return std::none_of(gated.begin(), gated.end(), + [&](const SkillGatedToolInfo& tool) { + return tool.activation_skill == s.name; + }); + }), + all.end()); + if (all.empty()) return block; + } + + SkillIndexRenderResult rendered = format_skills_index_within_budget( + all, skills_index_budget(context_window_tokens), skills_list_available); + if (rendered.content.empty()) return block; + + std::ostringstream oss; + oss << "\n" + << "## Skills\n\n" + << "A skill is a set of local instructions to follow that is stored in a " + << "`SKILL.md` file. Below is the list of skills that can be used. Each " + << "entry includes a name, description, and source locator.\n\n"; + if (!rendered.skill_root_lines.empty()) { + oss << "### Skill roots\n\n"; + for (const auto& line : rendered.skill_root_lines) { + oss << line << "\n"; + } + oss << "\n"; + } + oss << "### Available skills\n\n" + << rendered.content << "\n\n"; + if (!gated.empty()) { + oss << "### Skill-gated built-in tools\n\n" + << "These built-in tools stay out of the tool list until their skill is " + << "loaded with `skill_view`. When a request matches a hint below, load " + << "that skill first (even if the list above shortened or omitted it), " + << "then call the tool.\n"; + for (const auto& tool : gated) { + oss << "- `" << tool.name << "` (load skill `" << tool.activation_skill << "`)"; + if (!tool.activation_hint.empty()) oss << ": " << tool.activation_hint; + oss << "\n"; + } + oss << "\n"; + } + oss << "### How to use skills\n\n" + << "- Discovery: The list above is the skills available in this session " + << "(name + description + source locator). `file` entries live on the host " + << "filesystem."; + if (!rendered.skill_root_lines.empty()) { + oss << " Expand short locators using the matching alias from `### Skill roots`."; + } + oss << "\n" + << "- Trigger rules: If the user names a skill (with `$SkillName` or plain " + << "text) OR the task clearly matches a skill's description shown above, " + << "you must use that skill for that turn. Multiple mentions mean use them " + << "all. Do not carry skills across turns unless re-mentioned.\n" + << "- Explicit selection: `$SkillName`, a linked Skill mention, and " + << "`/` cause ACECode to append a complete `` instruction " + << "fragment to that user turn. When present, follow it directly and do not " + << "load the same main `SKILL.md` again.\n" + << "- Missing/blocked: If a named skill is not in the list or its source " + << "cannot be read, say so briefly and continue with the best fallback.\n" + << "- How to use a skill (progressive disclosure):\n"; + if (skill_view_available) { + oss << " 1) After deciding to use a skill that was not already explicitly " + << "injected, call `skill_view(name=\"...\")` and read its full " + << "`SKILL.md` completely before taking task actions.\n" + << " 2) When `SKILL.md` references another file, load it with " + << "`skill_view` using the same skill name and referenced relative path.\n"; + } else { + oss << " 1) After deciding to use a skill that was not already explicitly " + << "injected, open the listed `SKILL.md` path and read it completely " + << "before taking task actions.\n" + << " 2) Resolve relative references against the directory containing " + << "that `SKILL.md`.\n"; + } + oss << " 3) If `SKILL.md` points to folders such as `references/`, use its " + << "routing instructions to identify only the files required for the task.\n" + << " 4) Prefer running or patching provided scripts instead of retyping " + << "large code blocks.\n" + << " 5) Reuse provided assets or templates instead of recreating them.\n" + << "- Coordination and sequencing:\n" + << " - If multiple skills apply, choose the minimal set that covers the " + << "request and state the order you will use them.\n" + << " - Announce which skills you are using and why. If you skip an obvious " + << "skill, say why.\n" + << "- Context hygiene:\n" + << " - Progressive disclosure applies to selecting relevant files, not " + << "partially reading a selected instruction file.\n" + << " - Avoid deep reference-chasing: prefer files directly linked from " + << "`SKILL.md` unless blocked.\n" + << " - When variants exist, select only the relevant references and note " + << "the choice.\n" + << "- Safety and fallback: If a skill cannot be applied cleanly, state the " + << "issue, choose the best alternative, and continue.\n"; + if (skills_list_available) { + oss << "- Call skills_list only when this bounded catalog omitted skills or " + << "you need to double-check the full set.\n"; + } + oss << "\n"; + + block.content = oss.str(); + block.cache_key = "skills:" + prompt_component_hash(block.content); + block.warning = rendered.report.warning_message(); + return block; +} + +} // namespace acecode diff --git a/src/engine/prompt/system_prompt.cpp b/src/engine/prompt/system_prompt.cpp index 74e8eeab..3d77dad0 100644 --- a/src/engine/prompt/system_prompt.cpp +++ b/src/engine/prompt/system_prompt.cpp @@ -1063,109 +1063,6 @@ SkillIndexRenderResult format_skills_index_within_budget( : absolute; } -PromptContextBlock build_skills_index_context_prompt( - const SkillRegistry* skills, - int context_window_tokens, - bool skill_view_available, - bool skills_list_available, - const std::set* dormant_names) { - PromptContextBlock block; - if (!skills) return block; - - auto all = skills->list(); - if (all.empty()) return block; - if (dormant_names && !dormant_names->empty()) { - all.erase( - std::remove_if(all.begin(), all.end(), - [&](const SkillMetadata& s) { - return dormant_names->count(s.name) != 0; - }), - all.end()); - if (all.empty()) return block; - } - - SkillIndexRenderResult rendered = format_skills_index_within_budget( - all, skills_index_budget(context_window_tokens), skills_list_available); - if (rendered.content.empty()) return block; - - std::ostringstream oss; - oss << "\n" - << "## Skills\n\n" - << "A skill is a set of local instructions to follow that is stored in a " - << "`SKILL.md` file. Below is the list of skills that can be used. Each " - << "entry includes a name, description, and source locator.\n\n"; - if (!rendered.skill_root_lines.empty()) { - oss << "### Skill roots\n\n"; - for (const auto& line : rendered.skill_root_lines) { - oss << line << "\n"; - } - oss << "\n"; - } - oss << "### Available skills\n\n" - << rendered.content << "\n\n" - << "### How to use skills\n\n" - << "- Discovery: The list above is the skills available in this session " - << "(name + description + source locator). `file` entries live on the host " - << "filesystem."; - if (!rendered.skill_root_lines.empty()) { - oss << " Expand short locators using the matching alias from `### Skill roots`."; - } - oss << "\n" - << "- Trigger rules: If the user names a skill (with `$SkillName` or plain " - << "text) OR the task clearly matches a skill's description shown above, " - << "you must use that skill for that turn. Multiple mentions mean use them " - << "all. Do not carry skills across turns unless re-mentioned.\n" - << "- Explicit selection: `$SkillName`, a linked Skill mention, and " - << "`/` cause ACECode to append a complete `` instruction " - << "fragment to that user turn. When present, follow it directly and do not " - << "load the same main `SKILL.md` again.\n" - << "- Missing/blocked: If a named skill is not in the list or its source " - << "cannot be read, say so briefly and continue with the best fallback.\n" - << "- How to use a skill (progressive disclosure):\n"; - if (skill_view_available) { - oss << " 1) After deciding to use a skill that was not already explicitly " - << "injected, call `skill_view(name=\"...\")` and read its full " - << "`SKILL.md` completely before taking task actions.\n" - << " 2) When `SKILL.md` references another file, load it with " - << "`skill_view` using the same skill name and referenced relative path.\n"; - } else { - oss << " 1) After deciding to use a skill that was not already explicitly " - << "injected, open the listed `SKILL.md` path and read it completely " - << "before taking task actions.\n" - << " 2) Resolve relative references against the directory containing " - << "that `SKILL.md`.\n"; - } - oss << " 3) If `SKILL.md` points to folders such as `references/`, use its " - << "routing instructions to identify only the files required for the task.\n" - << " 4) Prefer running or patching provided scripts instead of retyping " - << "large code blocks.\n" - << " 5) Reuse provided assets or templates instead of recreating them.\n" - << "- Coordination and sequencing:\n" - << " - If multiple skills apply, choose the minimal set that covers the " - << "request and state the order you will use them.\n" - << " - Announce which skills you are using and why. If you skip an obvious " - << "skill, say why.\n" - << "- Context hygiene:\n" - << " - Progressive disclosure applies to selecting relevant files, not " - << "partially reading a selected instruction file.\n" - << " - Avoid deep reference-chasing: prefer files directly linked from " - << "`SKILL.md` unless blocked.\n" - << " - When variants exist, select only the relevant references and note " - << "the choice.\n" - << "- Safety and fallback: If a skill cannot be applied cleanly, state the " - << "issue, choose the best alternative, and continue.\n"; - if (skills_list_available) { - oss << "- Call skills_list only when this bounded catalog omitted skills or " - << "you need to double-check the full set.\n"; - } - oss << "\n"; - - block.content = oss.str(); - block.cache_key = "skills:" + prompt_component_hash(block.content); - block.warning = rendered.report.warning_message(); - return block; -} - PromptContextBlock build_git_status_context_prompt( const std::string& snapshot_text) { PromptContextBlock block; diff --git a/src/engine/prompt/system_prompt.hpp b/src/engine/prompt/system_prompt.hpp index f249fa5a..0d9011c0 100644 --- a/src/engine/prompt/system_prompt.hpp +++ b/src/engine/prompt/system_prompt.hpp @@ -166,12 +166,17 @@ SkillIndexRenderResult format_skills_index_within_budget( // Null registry or empty skill list yields an empty block (not sent). // `dormant_names` (optional): skill names to hide from the rendered index // (dormant skills stay available via explicit mention but are not listed). +// `gated_tools` (optional): built-in tools hidden until their skill loads. +// Their skills are never dormant, and a dedicated section lists each tool whose +// skill is installed with its trigger hint, outside the truncatable catalog. +// Requires skill_view, the only way a model can load the gating skill. PromptContextBlock build_skills_index_context_prompt( const SkillRegistry* skills, int context_window_tokens, bool skill_view_available = true, bool skills_list_available = true, - const std::set* dormant_names = nullptr); + const std::set* dormant_names = nullptr, + const std::vector* gated_tools = nullptr); // gitStatus 快照块(openspec add-git-context):把 collector 采集的快照文本 // 包成带缓存 key 的块。空文本(非仓库/采集失败/disabled)→ 空块不发送。 diff --git a/src/host/loop/loop_scheduler.cpp b/src/host/loop/loop_scheduler.cpp index 933f0f6c..d05b386e 100644 --- a/src/host/loop/loop_scheduler.cpp +++ b/src/host/loop/loop_scheduler.cpp @@ -54,7 +54,12 @@ std::string build_loop_system_context(const LoopDefinition& loop, out << "You are executing daemon-owned LOOP '" << loop.name << "' (loop_id=" << loop.id << ", run_id=" << run.id << ").\n" << "Complete the coding task autonomously inside the execution root: " - << (execution_root.empty() ? "the daemon no-workspace root" : execution_root) << ".\n"; + << (execution_root.empty() ? "the daemon no-workspace root" : execution_root) << ".\n" + << "This run is one execution of the scheduled task; ACECode's scheduler already " + "handles timing. Do the task once now. If the task text asks to create a scheduled " + "task or describes a schedule (for example \"建立定时任务,每天…\"), that is the " + "original creation wording: perform the underlying work and do not create or " + "schedule tasks.\n"; if (loop.permission_mode == "yolo") { out << "This LOOP uses Yolo mode. Read access outside the execution root is allowed, " "but every write must remain inside the execution root. AskUserQuestion is " diff --git a/src/host/loop/scheduled_task_tool.cpp b/src/host/loop/scheduled_task_tool.cpp index 57257bcb..a3912d3b 100644 --- a/src/host/loop/scheduled_task_tool.cpp +++ b/src/host/loop/scheduled_task_tool.cpp @@ -5,8 +5,10 @@ #include "config/config.hpp" #include "workspace/workspace_registry.hpp" -#include #include +#include +#include +#include namespace acecode::loop { namespace { @@ -19,6 +21,69 @@ ToolResult failure(const std::string& code, const std::string& message) { json property(const char* type, const char* description) { return {{"type", type}, {"description", description}}; } + +// 去掉 ASCII 空白并把 ASCII 字母转小写;中文按原字节保留。UTF-8 自同步, +// 之后按字节找子串不会跨字符误配。 +std::string compact_prompt(const std::string& text) { + std::string out; + out.reserve(text.size()); + for (const unsigned char c : text) { + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v') continue; + out.push_back(c >= 'A' && c <= 'Z' ? static_cast(c - 'A' + 'a') : static_cast(c)); + } + return out; +} + +bool starts_with_at(const std::string& text, std::size_t pos, const std::string& prefix) { + return text.compare(pos, prefix.size(), prefix) == 0; +} + +// 「动词 [+ 量词] + 名词」紧挨着才算创建请求:「建立定时任务」「设置一个定时任务」 +// 命中,「检查昨天新增的定时任务」因为中间隔着「的」不命中,监控类提示词照常可用。 +bool contains_creation_phrase(const std::string& text, + const std::vector& verbs, + const std::vector& quantifiers, + const std::vector& nouns) { + for (const auto& verb : verbs) { + for (std::size_t pos = text.find(verb); pos != std::string::npos; + pos = text.find(verb, pos + 1)) { + std::vector starts{pos + verb.size()}; + for (const auto& quantifier : quantifiers) { + if (starts_with_at(text, starts.front(), quantifier)) { + starts.push_back(starts.front() + quantifier.size()); + } + } + for (const auto start : starts) { + for (const auto& noun : nouns) { + if (starts_with_at(text, start, noun)) return true; + } + } + } + } + return false; +} +} + +bool prompt_requests_scheduled_task_creation(const std::string& prompt) { + const std::string text = compact_prompt(prompt); + for (const char* marker : {"/scheduled-task", "$scheduled-task", "create_scheduled_task"}) { + if (text.find(marker) != std::string::npos) return true; + } + static const std::vector zh_verbs = { + "创建", "建立", "新建", "添加", "新增", "设置", "设定", "安排", + "设一个", "设个", "建", "加", "弄", "搞"}; + static const std::vector zh_quantifiers = { + "一个新的", "一个", "一条", "一项", "一下", "新的", "个", "条", "项"}; + static const std::vector zh_nouns = { + "定时任务", "定时提醒", "计划任务", "周期任务"}; + static const std::vector en_verbs = { + "create", "add", "setup", "schedule", "make"}; + static const std::vector en_quantifiers = { + "another", "anew", "new", "an", "a"}; + static const std::vector en_nouns = { + "scheduledtask", "scheduled-task", "recurringtask", "cronjob"}; + return contains_creation_phrase(text, zh_verbs, zh_quantifiers, zh_nouns) || + contains_creation_phrase(text, en_verbs, en_quantifiers, en_nouns); } ScheduledTaskService::ScheduledTaskService( @@ -67,6 +132,15 @@ std::optional ScheduledTaskService::prepare(json body, Validatio return std::nullopt; }; if (!parse_loop_request(body, now, models, resolve, definition, error)) return std::nullopt; + // prompt 是每次到点执行的内容。照抄「建立定时任务,每天帮我生成代码」这类创建 + // 请求,执行时会被当成新的建任务请求。只拦 AI 创建路径,手动表单不经过这里。 + if (prompt_requests_scheduled_task_creation(definition.prompt)) { + error = {"PROMPT_CONTAINS_CREATION_REQUEST", "prompt", + "prompt 是每次到点要执行的内容,不能包含“建立/创建定时任务”这类创建请求," + "否则执行时会被当成新的建任务请求。请去掉创建定时任务和执行时间的描述," + "只写单次要做的事,例如把“建立定时任务,每天帮我生成代码”写成“生成代码:……”。"}; + return std::nullopt; + } ScheduleCompilation compiled; if (!compile_schedule(definition.schedule, now, compiled, &error)) return std::nullopt; return definition; @@ -116,7 +190,11 @@ ToolImpl create_scheduled_task_tool(std::weak_ptr service) schedule["additionalProperties"] = false; tool.definition.parameters = {{"type", "object"}, {"properties", { {"name", property("string", "Short user-visible task name.")}, - {"prompt", property("string", "Instructions to execute when the task is due.")}, + {"prompt", property("string", + "What to do in ONE run when the task is due, as a direct instruction. " + "Never include the request to create a scheduled task or the schedule itself; " + "the scheduler handles timing. E.g. user '建立定时任务,每天帮我生成代码' -> " + "prompt '生成代码:'.")}, {"schedule", schedule}, {"model_name", property("string", "Saved model name; omitted uses the manual form default.")}, {"workspace_hash", property("string", "Optional registered workspace hash, paired with workspace_cwd.")}, @@ -124,6 +202,15 @@ ToolImpl create_scheduled_task_tool(std::weak_ptr service) {"use_worktree", property("boolean", "Use an isolated worktree; default false, like manual creation.")}}}, {"required", json::array({"name", "prompt", "schedule"})}, {"additionalProperties", false}}; tool.activation_skill = "scheduled-task"; + tool.activation_hint = + "the user wants something done or a reminder sent later, at a set time, or repeatedly " + "(定时 / 每天 / 每周 / 工作日 / 每隔 N 分钟 / 明天 9 点 / 建立定时任务). Use it instead of OS " + "schedulers (cron, Task Scheduler, launchd), sleep loops, or doing the work right away."; + tool.loop_execution_refusal = + "create_scheduled_task is not available while a scheduled task is running. This run is " + "one execution of an existing scheduled task and the scheduler already handles timing. " + "If the task text asks to create a scheduled task or describes a schedule, that is the " + "original creation wording: do the underlying work once now and do not schedule anything."; tool.requires_serial_execution = true; tool.execute = [service](const std::string& arguments, const ToolContext& ctx) { auto body = json::parse(arguments, nullptr, false); diff --git a/src/host/loop/scheduled_task_tool.hpp b/src/host/loop/scheduled_task_tool.hpp index 90116b14..b1f399d3 100644 --- a/src/host/loop/scheduled_task_tool.hpp +++ b/src/host/loop/scheduled_task_tool.hpp @@ -40,6 +40,11 @@ class ScheduledTaskService { Clock clock_; }; +// True when a task prompt still carries the request to create a scheduled task +// ("建立定时任务,每天…", "/scheduled-task", "create a scheduled task"). Such a prompt +// would ask every run to schedule again, so AI creation rejects it. +bool prompt_requests_scheduled_task_creation(const std::string& prompt); + ToolImpl create_scheduled_task_tool(std::weak_ptr service); } // namespace acecode::loop diff --git a/tests/agent/agent_loop_tool_lifecycle_events_test.cpp b/tests/agent/agent_loop_tool_lifecycle_events_test.cpp index 72ea60d6..b0c1236d 100644 --- a/tests/agent/agent_loop_tool_lifecycle_events_test.cpp +++ b/tests/agent/agent_loop_tool_lifecycle_events_test.cpp @@ -805,6 +805,46 @@ TEST(AgentLoopToolLifecycleEvents, LoopYoloCreateWorkspaceOutsideCwdExecutes) { fs::remove_all(cwd); } +// 触发场景:定时任务到点执行的会话(loop_execution)里,模型照着任务文本「建立定时 +// 任务,每天帮我生成代码」去调 create_scheduled_task。 +// bug 表现:执行会话弹出权限提问,用户一答就多出一个子定时任务,之后每次执行都可能再派生。 +// 期望:执行会话发给模型的工具表里没有该工具;模型硬调也只拿到工具声明的拒绝文案, +// 工具本体一次都不执行。对照:普通会话里同一工具照常可见。 +TEST(AgentLoopToolLifecycleEvents, LoopExecutionHidesScheduledTaskCreation) { + auto cwd = make_temp_dir("acecode_loop_hides_scheduled_task"); + std::atomic calls{0}; + auto tool = make_probe_tool("create_scheduled_task", false, &calls); + tool.loop_execution_refusal = "no nested scheduled tasks"; + const auto offers_tool = [](const std::vector& defs) { + return std::any_of(defs.begin(), defs.end(), [](const ToolDef& def) { + return def.name == "create_scheduled_task"; + }); + }; + { + ToolLifecycleHarness normal(cwd.string(), PermissionMode::Yolo); + normal.tools().register_tool(tool); + normal.provider().push_text("done"); + ASSERT_TRUE(normal.submit_and_wait()); + EXPECT_TRUE(offers_tool(normal.provider().tools_for_turn(0))); + } + + ToolLifecycleHarness h(cwd.string(), PermissionMode::Yolo); + h.set_loop_execution_policy({true, "loop test"}); + h.tools().register_tool(tool); + h.provider().push_tool_call("create_scheduled_task", "{}", "call-nested-task"); + h.provider().push_text("done"); + + ASSERT_TRUE(h.submit_and_wait()); + EXPECT_FALSE(offers_tool(h.provider().tools_for_turn(0))); + const auto ends = h.events_of(SessionEventKind::ToolEnd); + ASSERT_EQ(ends.size(), 1u); + EXPECT_FALSE(ends[0].payload.value("success", true)); + EXPECT_NE(ends[0].payload.value("output", std::string{}).find("no nested scheduled tasks"), + std::string::npos); + EXPECT_EQ(calls.load(), 0); + fs::remove_all(cwd); +} + TEST(AgentLoopToolLifecycleEvents, MutationOutsideCwdStillRejectedBeforeExecution) { auto cwd = make_temp_dir("acecode_lifecycle_write_outside"); std::atomic calls{0}; diff --git a/tests/agent/request/api_request_builder_test.cpp b/tests/agent/request/api_request_builder_test.cpp index cc74e3f4..e85c96ba 100644 --- a/tests/agent/request/api_request_builder_test.cpp +++ b/tests/agent/request/api_request_builder_test.cpp @@ -5,6 +5,11 @@ #include "session/compact_checkpoint.hpp" #include "session/request_context_record.hpp" #include "session/session_serializer.hpp" +#include "skills/skill_registry.hpp" + +#include +#include +#include TEST(PromptContextCache, CwdChangePreservesSkillAndSessionPins) { // cwd 失效只针对 Git;相同内容键下重采的动态文本仍钉住原字节。 @@ -82,6 +87,57 @@ TEST(ApiRequestBuilder, DeferredSkillToolsStaySessionLocalAndRespectPolicyAfterR EXPECT_TRUE(builder.capture(options, {message}, false).tool_defs.empty()); } +// 触发场景:create_scheduled_task 带 activation_skill + activation_hint 注册, +// 会话里装着 scheduled-task 技能,但还没加载它(工具表里看不到该工具)。 +// 期望:每次请求的技能索引都单列门控工具和触发说明,模型据此先加载技能; +// 工具被会话策略隐藏(定时任务执行会话 / 专家收走)时,这一节同步消失, +// 且 skills_catalog_key 跟着变化,不会复用旧提示。 +TEST(ApiRequestBuilder, SkillIndexAdvertisesGatedToolUntilPolicyHidesIt) { + namespace fs = std::filesystem; + const fs::path root = fs::path(testing::TempDir()) / "acecode-gated-tool-index"; + fs::remove_all(root); + fs::create_directories(root / "scheduled-task"); + { + std::ofstream skill(root / "scheduled-task" / "SKILL.md", std::ios::binary); + skill << "---\nname: scheduled-task\ndescription: Create scheduled tasks\n---\n\n# body\n"; + } + auto registry = std::make_shared(); + registry->set_scan_roots({root}); + registry->scan(); + + acecode::ToolExecutor tools; + acecode::ToolImpl gated; + gated.definition.name = "create_scheduled_task"; + gated.activation_skill = "scheduled-task"; + gated.activation_hint = "run later or repeatedly (每天 / 建立定时任务)"; + ASSERT_TRUE(tools.register_tool(gated)); + acecode::ToolImpl skill_view; + skill_view.definition.name = "skill_view"; + ASSERT_TRUE(tools.register_tool(skill_view)); + + acecode::agent::PromptContextCache cache; + acecode::agent::ApiRequestBuilder builder(tools, cache); + acecode::agent::RequestContextOptions options; + options.cwd = testing::TempDir(); + options.git_config.emplace(); + options.git_config->enabled = false; + options.project_config.emplace(); + options.project_config->enabled = false; + options.skills = registry; + + const auto visible = builder.capture(options, {}, false); + EXPECT_NE(visible.skills.content.find( + "- `create_scheduled_task` (load skill `scheduled-task`): run later or repeatedly"), + std::string::npos); + for (const auto& def : visible.tool_defs) EXPECT_NE(def.name, "create_scheduled_task"); + + options.tool_policy.hidden_builtin_tools.emplace("create_scheduled_task", "hidden"); + const auto hidden = builder.capture(options, {}, false); + EXPECT_EQ(hidden.skills.content.find("Skill-gated"), std::string::npos); + EXPECT_NE(hidden.skills_catalog_key, visible.skills_catalog_key); + fs::remove_all(root); +} + namespace { acecode::ChatMessage message(const std::string& role, const std::string& text) { acecode::ChatMessage result; diff --git a/tests/config/saved_models_editor_test.cpp b/tests/config/saved_models_editor_test.cpp index 975d5595..030fd6fe 100644 --- a/tests/config/saved_models_editor_test.cpp +++ b/tests/config/saved_models_editor_test.cpp @@ -785,3 +785,97 @@ TEST(SavedModelsEditor, ApiProtocolRejectsInvalidOrUnsupportedDrafts) { EXPECT_EQ(add_saved_model(cfg, anthropic), SavedModelEditError::UNSUPPORTED_MODEL_OPTION); EXPECT_TRUE(cfg.saved_models.empty()); } + +// 触发场景:设置页新增带采样参数的模型,之后编辑:不提交 sampling、提交新的 +// 一组(只剩 temperature)、提交空对象。 +// 期望:不提交 = 保留原值;提交 = 整体替换(旧的 top_k 不能残留); +// 提交空 = 清除全部。界面"全部留空再保存"靠的就是最后这条。 +TEST(SavedModelsEditor, SamplingIsReplacedWholesaleAndClearedWhenEmpty) { + AppConfig cfg; + auto draft = good_openai_draft("glm"); + acecode::ModelSamplingOptions sampling; + sampling.temperature = 1.0; + sampling.top_p = 0.95; + sampling.top_k = 20; + sampling.min_p = 0.0; + draft.sampling = sampling; + draft.sampling_supplied = true; + ASSERT_EQ(add_saved_model(cfg, draft), SavedModelEditError::OK); + EXPECT_EQ(cfg.saved_models.front().sampling.top_k, 20); + + auto untouched = good_openai_draft("glm"); + ASSERT_EQ(update_saved_model(cfg, "glm", untouched), SavedModelEditError::OK); + EXPECT_EQ(cfg.saved_models.front().sampling.top_p, 0.95); + + auto replaced = good_openai_draft("glm"); + acecode::ModelSamplingOptions only_temperature; + only_temperature.temperature = 0.3; + replaced.sampling = only_temperature; + replaced.sampling_supplied = true; + ASSERT_EQ(update_saved_model(cfg, "glm", replaced), SavedModelEditError::OK); + EXPECT_EQ(cfg.saved_models.front().sampling.temperature, 0.3); + EXPECT_FALSE(cfg.saved_models.front().sampling.top_k.has_value()); + + auto cleared = good_openai_draft("glm"); + cleared.sampling.reset(); + cleared.sampling_supplied = true; + ASSERT_EQ(update_saved_model(cfg, "glm", cleared), SavedModelEditError::OK); + EXPECT_TRUE(cfg.saved_models.front().sampling.empty()); +} + +// 触发场景:提交越界值、provider / 协议不支持的参数、给托管 provider 配采样参数。 +// 期望:前两类返回 INVALID_SAMPLING(前端据此显示采样参数的文案),托管 provider +// 返回 UNSUPPORTED_MODEL_OPTION;失败时模型列表保持不变。 +TEST(SavedModelsEditor, SamplingRejectsInvalidAndUnsupportedDrafts) { + AppConfig cfg; + auto out_of_range = good_openai_draft("glm"); + acecode::ModelSamplingOptions hot; + hot.temperature = 3.0; + out_of_range.sampling = hot; + EXPECT_EQ(add_saved_model(cfg, out_of_range), SavedModelEditError::INVALID_SAMPLING); + + auto responses = good_openai_draft("responses"); + responses.api_protocol = "responses"; + acecode::ModelSamplingOptions with_top_k; + with_top_k.top_k = 20; + responses.sampling = with_top_k; + EXPECT_EQ(add_saved_model(cfg, responses), SavedModelEditError::INVALID_SAMPLING); + + auto anthropic = good_anthropic_draft(); + acecode::ModelSamplingOptions with_min_p; + with_min_p.min_p = 0.05; + anthropic.sampling = with_min_p; + EXPECT_EQ(add_saved_model(cfg, anthropic), SavedModelEditError::INVALID_SAMPLING); + EXPECT_TRUE(cfg.saved_models.empty()); + + auto managed_cfg = make_cfg_with_one_default(); + SavedModelDraft copilot; + copilot.name = "copilot-tuned"; + copilot.provider = "copilot"; + copilot.model = "gpt-4o"; + acecode::ModelSamplingOptions mild; + mild.temperature = 0.5; + copilot.sampling = mild; + EXPECT_EQ(add_saved_model(managed_cfg, copilot), + SavedModelEditError::UNSUPPORTED_MODEL_OPTION); + EXPECT_EQ(managed_cfg.saved_models.size(), 1u); + // 托管 provider 提交 null(界面隐藏采样区时的形态)不应被拒。 + copilot.sampling.reset(); + copilot.sampling_supplied = true; + EXPECT_EQ(add_saved_model(managed_cfg, copilot), SavedModelEditError::OK); +} + +// 触发场景:OpenAI 兼容模型配了 min_p,再改成 Anthropic,且请求里没提交 sampling。 +// 期望:换 provider 时清掉采样参数,否则遗留的 min_p 会让这次保存因 +// "Anthropic 不支持 min_p" 失败,用户却看不到这个字段。 +TEST(SavedModelsEditor, ProviderChangeResetsSampling) { + AppConfig cfg; + auto draft = good_openai_draft("switch"); + acecode::ModelSamplingOptions sampling; + sampling.min_p = 0.05; + draft.sampling = sampling; + ASSERT_EQ(add_saved_model(cfg, draft), SavedModelEditError::OK); + ASSERT_EQ(update_saved_model(cfg, "switch", good_anthropic_draft("switch")), + SavedModelEditError::OK); + EXPECT_TRUE(cfg.saved_models.front().sampling.empty()); +} diff --git a/tests/config/saved_models_test.cpp b/tests/config/saved_models_test.cpp index 35657349..fc34869b 100644 --- a/tests/config/saved_models_test.cpp +++ b/tests/config/saved_models_test.cpp @@ -1375,3 +1375,153 @@ TEST(SavedModelsTest, ApiProtocolRoundTripsAndParticipatesInEquality) { std::error_code ec; std::filesystem::remove(path, ec); } + +// 触发场景:saved_models 条目带 sampling 对象(wizard-ai GLM 推荐值),其中有 +// 值为 null 的键和一个拼错的未知键。 +// 期望:四个已知字段按值解析;null 视为未设置;未知键忽略不报错;min_p = 0 +// 是有效值(不能当成未设置丢掉)。 +TEST(SavedModelsTest, SamplingParsesValuesAndTreatsNullAsUnset) { + nlohmann::json node = { + {"name", "glm"}, {"provider", "openai"}, {"model", "aicoder-pro"}, + {"base_url", "https://gateway.example/v1"}, {"api_key", "test-key"}, + {"sampling", {{"temperature", 1}, {"top_p", 0.95}, {"top_k", 20}, + {"min_p", 0}, {"topK", 99}}}, + }; + std::string error; + auto parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + const auto& sampling = parsed->front().sampling; + EXPECT_EQ(sampling.temperature, 1.0); + EXPECT_EQ(sampling.top_p, 0.95); + EXPECT_EQ(sampling.top_k, 20); + ASSERT_TRUE(sampling.min_p.has_value()); + EXPECT_EQ(*sampling.min_p, 0.0); + EXPECT_TRUE(validate_saved_models(*parsed, "", error)) << error; + + node["sampling"] = {{"temperature", nullptr}, {"top_p", 0.8}}; + parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_FALSE(parsed->front().sampling.temperature.has_value()); + EXPECT_EQ(parsed->front().sampling.top_p, 0.8); + + node["sampling"] = nullptr; + parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_TRUE(parsed->front().sampling.empty()); +} + +// 触发场景:手改 config.json 写错采样参数的类型或范围。 +// 期望:整条解析失败且错误里带 "sampling",不能静默截断(例如 top_k 1.5 → 1、 +// 超出 int 的 top_k 变成负数)或把越界值原样发给服务端。 +TEST(SavedModelsTest, SamplingRejectsWrongTypesAndOutOfRangeValues) { + const nlohmann::json base = { + {"name", "glm"}, {"provider", "openai"}, {"model", "aicoder-pro"}, + {"base_url", "https://gateway.example/v1"}, {"api_key", "test-key"}, + }; + const std::vector invalid = { + nlohmann::json::array({0.5}), + {{"temperature", "1"}}, + {{"temperature", true}}, + {{"temperature", -0.1}}, + {{"temperature", 2.5}}, + {{"top_p", 0}}, + {{"top_p", 1.01}}, + {{"top_k", 0}}, + {{"top_k", 1.5}}, + {{"top_k", 3000000000LL}}, + {{"min_p", -0.01}}, + {{"min_p", 1.2}}, + }; + for (const auto& sampling : invalid) { + auto node = base; + node["sampling"] = sampling; + std::string error; + EXPECT_FALSE(parse_saved_models(nlohmann::json::array({node}), error)) + << sampling.dump(); + EXPECT_NE(error.find("sampling"), std::string::npos) << error; + } +} + +// 触发场景:同一组采样参数配在不同 provider / API 协议的模型上。 +// 期望:能真正发出去的组合才通过校验 —— Chat Completions 四个都行; +// Responses 不支持 top_k / min_p;Anthropic 不支持 min_p 且 temperature ≤ 1; +// Copilot / Grok 是托管 provider,一律拒绝。配了却被静默丢弃的组合必须报错。 +TEST(SavedModelsTest, SamplingSupportDependsOnProviderAndProtocol) { + std::string error; + auto chat = make_valid_openai_profile("chat", "aicoder-pro"); + chat.sampling.temperature = 1.0; + chat.sampling.top_p = 0.95; + chat.sampling.top_k = 20; + chat.sampling.min_p = 0.0; + EXPECT_TRUE(validate_saved_models({chat}, "", error)) << error; + + auto responses = chat; + responses.api_protocol = "responses"; + EXPECT_FALSE(validate_saved_models({responses}, "", error)); + EXPECT_NE(error.find("sampling"), std::string::npos) << error; + responses.sampling.top_k.reset(); + responses.sampling.min_p.reset(); + EXPECT_TRUE(validate_saved_models({responses}, "", error)) << error; + + auto anthropic = make_valid_openai_profile("claude", "claude-test"); + anthropic.provider = "anthropic"; + anthropic.sampling.temperature = 0.7; + anthropic.sampling.top_k = 40; + EXPECT_TRUE(validate_saved_models({anthropic}, "", error)) << error; + anthropic.sampling.min_p = 0.05; + EXPECT_FALSE(validate_saved_models({anthropic}, "", error)); + EXPECT_NE(error.find("min_p"), std::string::npos) << error; + anthropic.sampling.min_p.reset(); + anthropic.sampling.temperature = 1.5; + EXPECT_FALSE(validate_saved_models({anthropic}, "", error)); + EXPECT_NE(error.find("temperature"), std::string::npos) << error; + + for (const auto* managed : {"copilot", "grok"}) { + ModelProfile profile; + profile.name = managed; + profile.provider = managed; + profile.model = "model-id"; + profile.sampling.temperature = 0.5; + EXPECT_FALSE(validate_saved_models({profile}, "", error)) << managed; + EXPECT_NE(error.find("managed provider"), std::string::npos) << error; + } +} + +// 触发场景:在设置里配好采样参数后保存、重启(重新加载 config.json)。 +// 期望:四个值逐位还原(0.95 不能因为文本往返变成别的 double); +// model_profiles_equal 感知采样参数变化,否则运行中的会话不会换用新参数。 +TEST(SavedModelsTest, SamplingRoundTripsAndParticipatesInEquality) { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto path = std::filesystem::temp_directory_path() / + ("acecode-sampling-profile-" + std::to_string(suffix) + ".json"); + AppConfig cfg; + auto tuned = make_valid_openai_profile("glm", "aicoder-pro"); + tuned.sampling.temperature = 1.0; + tuned.sampling.top_p = 0.95; + tuned.sampling.top_k = 20; + tuned.sampling.min_p = 0.0; + const auto plain = make_valid_openai_profile("plain", "aicoder-pro"); + cfg.saved_models = {tuned, plain}; + cfg.default_model_name = tuned.name; + save_config(cfg, path.string()); + + std::ifstream raw(path); + const auto written = nlohmann::json::parse(raw); + raw.close(); + EXPECT_TRUE(written["saved_models"][0].contains("sampling")); + EXPECT_FALSE(written["saved_models"][1].contains("sampling")); + + const auto loaded = load_config_from_path(path.string()); + ASSERT_EQ(loaded.saved_models.size(), 2u); + EXPECT_TRUE(model_profiles_equal(tuned, loaded.saved_models[0])); + EXPECT_TRUE(loaded.saved_models[1].sampling.empty()); + + auto changed = tuned; + changed.sampling.top_k = 21; + EXPECT_FALSE(model_profiles_equal(tuned, changed)); + changed = tuned; + changed.sampling.min_p.reset(); + EXPECT_FALSE(model_profiles_equal(tuned, changed)); + std::error_code ec; + std::filesystem::remove(path, ec); +} diff --git a/tests/feedback/feedback_upload_test.cpp b/tests/feedback/feedback_upload_test.cpp index caec7b8c..515f237c 100644 --- a/tests/feedback/feedback_upload_test.cpp +++ b/tests/feedback/feedback_upload_test.cpp @@ -736,3 +736,177 @@ TEST(FeedbackUpload, UploadPackageTreatsSuccessFalseAsFailure) { EXPECT_EQ(result.status_code, 200); EXPECT_NE(result.error.find("denied"), std::string::npos); } + +// 触发场景:Desktop 反馈选了一个会话,该会话有 trajectory.jsonl(每次发给模型的 +// 完整请求与返回)。 +// 期望:原文整份进包,条目名是 session/.trajectory.jsonl,排在会话 JSONL 之后; +// feedback.json 的 trajectory 记录来源路径、原大小、进包字节数且未截断。 +// 背景:排查内网 GLM 乱码时反馈包里没有请求原文,只能靠推断两个客户端发了什么。 +TEST(FeedbackUpload, BuildPackageAttachesSessionTrajectory) { + TempDir tmp("acecode_feedback_trajectory"); + const fs::path project = tmp.root / "project"; + const fs::path session = project / "sid-traj.jsonl"; + write_text(session, "{\"role\":\"user\",\"content\":\"hi\"}\n"); + const std::string trajectory_text = + "{\"type\":\"model_request\",\"payload\":{\"model\":\"aicoder-pro\"}}\n" + "{\"type\":\"model_response\",\"payload\":{\"finish_reason\":\"stop\"}}\n"; + const fs::path trajectory = + acecode::feedback::session_trajectory_path_for(session, "sid-traj"); + write_text(trajectory, trajectory_text); + + acecode::feedback::FeedbackPackageRequest req; + req.source = "desktop"; + req.session_id = "sid-traj"; + req.session_jsonl_path = session; + req.session_trajectory_path = trajectory; + req.output_dir = tmp.root / "out"; + req.created_at = "2026-10-14T08:18:47Z"; + + auto result = acecode::feedback::build_feedback_package(req); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_EQ(read_zip_entry(result.package_path, "session/sid-traj.trajectory.jsonl"), + trajectory_text); + ASSERT_GE(result.included_files.size(), 3u); + EXPECT_EQ(result.included_files[0], "session/sid-traj.jsonl"); + EXPECT_EQ(result.included_files[1], "session/sid-traj.trajectory.jsonl"); + ASSERT_TRUE(result.trajectory.has_value()); + EXPECT_TRUE(result.trajectory->included); + EXPECT_FALSE(result.trajectory->truncated); + + auto metadata = nlohmann::json::parse( + read_zip_entry(result.package_path, "feedback.json")); + ASSERT_TRUE(metadata.contains("trajectory")); + EXPECT_EQ(metadata["trajectory"]["entry_name"], "session/sid-traj.trajectory.jsonl"); + EXPECT_EQ(metadata["trajectory"]["path"], acecode::path_to_utf8(trajectory)); + EXPECT_TRUE(metadata["trajectory"]["available"].get()); + EXPECT_EQ(metadata["trajectory"]["size_bytes"], trajectory_text.size()); + EXPECT_EQ(metadata["trajectory"]["tail_bytes"], trajectory_text.size()); + EXPECT_FALSE(metadata["trajectory"]["truncated"].get()); +} + +// 触发场景:会话从没发过模型请求(或旧版本会话),trajectory.jsonl 不存在。 +// 期望:打包照常成功,不加条目、不进 included_files;feedback.json 记下 +// available=false,让看反馈的人知道是没有这份文件,而不是忘了附带。 +TEST(FeedbackUpload, MissingTrajectoryIsRecordedButDoesNotFailPackage) { + TempDir tmp("acecode_feedback_trajectory_missing"); + const fs::path session = tmp.root / "sid-none.jsonl"; + write_text(session, "{}\n"); + + acecode::feedback::FeedbackPackageRequest req; + req.session_id = "sid-none"; + req.session_jsonl_path = session; + req.session_trajectory_path = + acecode::feedback::session_trajectory_path_for(session, "sid-none"); + req.output_dir = tmp.root / "out"; + + auto result = acecode::feedback::build_feedback_package(req); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(zip_entry_exists(result.package_path, "session/sid-none.trajectory.jsonl")); + for (const auto& name : result.included_files) { + EXPECT_EQ(name.find("trajectory"), std::string::npos) << name; + } + auto metadata = nlohmann::json::parse( + read_zip_entry(result.package_path, "feedback.json")); + ASSERT_TRUE(metadata.contains("trajectory")); + EXPECT_FALSE(metadata["trajectory"]["available"].get()); + EXPECT_EQ(metadata["trajectory"]["tail_bytes"], 0); +} + +// 触发场景:长会话的 trajectory.jsonl 超过上限(每条请求都带完整历史和工具清单)。 +// 期望:只保留尾部(最近的请求),并从截断点之后第一个完整行开始 —— 进包的每一行 +// 都能单独解析成 JSON;feedback.json 记 truncated=true 与原始大小。 +TEST(FeedbackUpload, OversizedTrajectoryKeepsNewestCompleteLines) { + TempDir tmp("acecode_feedback_trajectory_cap"); + const fs::path session = tmp.root / "sid-big.jsonl"; + write_text(session, "{}\n"); + const std::string line1 = "{\"seq\":1,\"pad\":\"aaaaaaaaaaaaaaaaaaaa\"}\n"; + const std::string line2 = "{\"seq\":2,\"pad\":\"bbbbbbbbbbbbbbbbbbbb\"}\n"; + const std::string line3 = "{\"seq\":3,\"pad\":\"cccccccccccccccccccc\"}\n"; + const fs::path trajectory = + acecode::feedback::session_trajectory_path_for(session, "sid-big"); + write_text(trajectory, line1 + line2 + line3); + + acecode::feedback::FeedbackPackageRequest req; + req.session_id = "sid-big"; + req.session_jsonl_path = session; + req.session_trajectory_path = trajectory; + // 比两行少几个字节:尾部会从第二行中间开始,应被丢到第三行。 + req.max_trajectory_bytes = line2.size() + line3.size() - 5; + req.output_dir = tmp.root / "out"; + + auto result = acecode::feedback::build_feedback_package(req); + ASSERT_TRUE(result.ok) << result.error; + const std::string packed = + read_zip_entry(result.package_path, "session/sid-big.trajectory.jsonl"); + EXPECT_EQ(packed, line3); + ASSERT_TRUE(result.trajectory.has_value()); + EXPECT_TRUE(result.trajectory->truncated); + EXPECT_EQ(result.trajectory->size_bytes, line1.size() + line2.size() + line3.size()); + EXPECT_EQ(result.trajectory->tail_bytes, line3.size()); + + auto metadata = nlohmann::json::parse( + read_zip_entry(result.package_path, "feedback.json")); + EXPECT_TRUE(metadata["trajectory"]["truncated"].get()); + EXPECT_EQ(metadata["trajectory"]["tail_bytes"], line3.size()); +} + +// 触发场景:单条请求记录本身就超过上限(例如一次请求带了巨大的工具输出)。 +// 期望:截下来的尾巴里找不到换行时保留这段残行,而不是打出一个空文件。 +TEST(FeedbackUpload, SingleOversizedTrajectoryRecordKeepsPartialTail) { + TempDir tmp("acecode_feedback_trajectory_single"); + const fs::path session = tmp.root / "sid-one.jsonl"; + write_text(session, "{}\n"); + const std::string record = "{\"seq\":1,\"pad\":\"" + std::string(64, 'x') + "\"}"; + const fs::path trajectory = + acecode::feedback::session_trajectory_path_for(session, "sid-one"); + write_text(trajectory, record); + + acecode::feedback::FeedbackPackageRequest req; + req.session_id = "sid-one"; + req.session_jsonl_path = session; + req.session_trajectory_path = trajectory; + req.max_trajectory_bytes = 20; + req.output_dir = tmp.root / "out"; + + auto result = acecode::feedback::build_feedback_package(req); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_EQ(read_zip_entry(result.package_path, "session/sid-one.trajectory.jsonl"), + record.substr(record.size() - 20)); + ASSERT_TRUE(result.trajectory.has_value()); + EXPECT_TRUE(result.trajectory->truncated); + EXPECT_EQ(result.trajectory->tail_bytes, 20u); +} + +// 触发场景:Desktop 反馈选择「不附带会话」,调用方却仍填了 trajectory 路径。 +// 期望:请求原文跟着会话走,不附带会话就不附带原文;feedback.json 里也没有 +// trajectory 键。 +TEST(FeedbackUpload, TrajectoryIsIgnoredWithoutSession) { + TempDir tmp("acecode_feedback_trajectory_no_session"); + const fs::path trajectory = tmp.root / "sid" / "trajectory.jsonl"; + write_text(trajectory, "{\"type\":\"model_request\"}\n"); + + acecode::feedback::FeedbackPackageRequest req; + req.source = "desktop"; + req.session_trajectory_path = trajectory; + req.output_dir = tmp.root / "out"; + + auto result = acecode::feedback::build_feedback_package(req); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.trajectory.has_value()); + auto metadata = nlohmann::json::parse( + read_zip_entry(result.package_path, "feedback.json")); + EXPECT_FALSE(metadata.contains("trajectory")); +} + +// 触发场景:Web / TUI 入口只知道会话 JSONL 路径,需要找到同一会话的轨迹文件。 +// 期望:/.jsonl → //trajectory.jsonl(与 +// SessionTrajectoryStorage::file_path 同一布局);任一参数为空返回空路径。 +TEST(FeedbackUpload, SessionTrajectoryPathForMatchesStorageLayout) { + const fs::path project = fs::path("projects") / "31f450dafc695732"; + EXPECT_EQ(acecode::feedback::session_trajectory_path_for( + project / "20261014-081742-653c.jsonl", "20261014-081742-653c"), + project / "20261014-081742-653c" / "trajectory.jsonl"); + EXPECT_TRUE(acecode::feedback::session_trajectory_path_for({}, "sid").empty()); + EXPECT_TRUE(acecode::feedback::session_trajectory_path_for( + project / "sid.jsonl", "").empty()); +} diff --git a/tests/fixtures/model_mutation_contract.json b/tests/fixtures/model_mutation_contract.json index e965fe79..132291bc 100644 --- a/tests/fixtures/model_mutation_contract.json +++ b/tests/fixtures/model_mutation_contract.json @@ -18,6 +18,7 @@ "supported_efforts": ["low", "high"], "supports_max_tokens": true, "default_effort": "high" - } + }, + "sampling": null } } diff --git a/tests/loop/loop_scheduler_test.cpp b/tests/loop/loop_scheduler_test.cpp index 8ddb7c33..21db3ee4 100644 --- a/tests/loop/loop_scheduler_test.cpp +++ b/tests/loop/loop_scheduler_test.cpp @@ -89,6 +89,17 @@ TEST(LoopScheduler, SystemContextContainsYoloBoundaryAndWorktreeHandoff) { EXPECT_NE(text.find("Do not use AskUserQuestion"), std::string::npos); } +// 触发场景:旧任务的 prompt 里已经带着「建立定时任务,每天帮我生成代码」。 +// bug 表现:执行会话把这句当成新请求,再建一个子定时任务。 +// 期望:执行上下文明确「这是一次执行、计时由调度器负责,任务文本里的建任务 / +// 时间描述是创建时的原话,只做底层工作、不再建任务」。 +TEST(LoopScheduler, SystemContextTreatsCreationWordingAsOneExecution) { + const auto text = build_loop_system_context(sample_loop(), sample_run(), "C:/repo", {}); + EXPECT_NE(text.find("one execution of the scheduled task"), std::string::npos); + EXPECT_NE(text.find("original creation wording"), std::string::npos); + EXPECT_NE(text.find("do not create or schedule tasks"), std::string::npos); +} + TEST(LoopScheduler, DefaultContextPreservesInteractiveQuestions) { const auto text = build_loop_system_context( sample_loop("default"), sample_run(), "C:/repo", {}); diff --git a/tests/loop/scheduled_task_tool_test.cpp b/tests/loop/scheduled_task_tool_test.cpp index 479e0aa4..f61891ad 100644 --- a/tests/loop/scheduled_task_tool_test.cpp +++ b/tests/loop/scheduled_task_tool_test.cpp @@ -174,4 +174,68 @@ TEST_F(ScheduledTaskToolTest, IntervalDefaultAndInclusiveDateRangeUseExistingSch } EXPECT_FALSE(occurrence); } + +// 触发场景:用户说「建立定时任务,每天帮我生成代码」,模型把原话整句写进 prompt。 +// bug 表现:到点执行时会话把「建立定时任务」当成新请求,又建出一个子定时任务。 +// 期望:AI 创建路径在弹权限提问之前就拒绝(错误码 PROMPT_CONTAINS_CREATION_REQUEST, +// 提示改写为单次要做的事),不弹窗、不落库。 +TEST_F(ScheduledTaskToolTest, PromptCarryingCreationRequestIsRejectedBeforeAsking) { + int questions = 0; + auto ctx = context(); + ctx.ask_user_questions = [this, &questions](const json&) { + ++questions; + return response("yolo"); + }; + auto tool = create_scheduled_task_tool(service); + for (const char* prompt : {"建立定时任务,每天帮我生成代码", + "/scheduled-task 每天提醒我喝水", + "Create a scheduled task that backs up the repo"}) { + auto args = arguments(); + args["prompt"] = prompt; + const auto result = tool.execute(args.dump(), ctx); + ASSERT_FALSE(result.success) << prompt; + const auto data = json::parse(result.output); + EXPECT_EQ(data["error"], "PROMPT_CONTAINS_CREATION_REQUEST") << prompt; + EXPECT_NE(data["message"].get().find("只写单次要做的事"), std::string::npos); + } + EXPECT_EQ(questions, 0); + EXPECT_TRUE(store->list_loops().empty()); + + // 改写成单次执行内容后照常走权限提问并创建。 + auto args = arguments(); + args["prompt"] = "生成代码:每天的数据导出脚本"; + ASSERT_TRUE(tool.execute(args.dump(), ctx).success); + EXPECT_EQ(questions, 1); + EXPECT_EQ(store->list_loops().size(), 1u); +} + +// 触发场景:判定「prompt 是否仍带着创建定时任务的请求」。 +// 期望:动词(+量词)紧挨「定时任务」等名词、斜杠技能标记、英文 create a scheduled +// task 一律命中;提到定时任务但不是要新建的监控 / 汇报 / 提醒类内容不能误伤 —— +// 例如「检查昨天新增的定时任务」中间隔着「的」,「假设定时任务失败」没有建任务的动词。 +TEST(ScheduledTaskPromptCheck, FlagsCreationRequestsButKeepsOrdinaryTaskText) { + for (const char* prompt : {"建立定时任务,每天帮我生成代码", "帮我设置一个定时任务", + "请 创建 定时提醒:早上9点起床", "新建一条计划任务", + "加个定时任务提醒我开会", "$scheduled-task 每周汇总", + "call create_scheduled_task again", "Set up a cron job for backups", + "Schedule another scheduled task"}) { + EXPECT_TRUE(prompt_requests_scheduled_task_creation(prompt)) << prompt; + } + for (const char* prompt : {"生成代码:每天的数据导出脚本", "提醒用户:参加会议", + "检查昨天新增的定时任务运行情况并汇报", "假设定时任务失败就通知我", + "汇总定时任务页面上的失败记录", "Summarize the scheduled task runs", + "Remind me about the meeting"}) { + EXPECT_FALSE(prompt_requests_scheduled_task_creation(prompt)) << prompt; + } +} + +// 触发场景:create_scheduled_task 是按技能延迟开放的工具。 +// 期望:工具声明了技能索引用的触发说明(含中文触发词),并声明在定时任务执行 +// 会话中隐藏(拒绝文案要求「只做一次底层工作、不再建任务」)。 +TEST(ScheduledTaskPromptCheck, ToolDeclaresActivationHintAndLoopExecutionRefusal) { + const auto tool = create_scheduled_task_tool({}); + EXPECT_EQ(tool.activation_skill, "scheduled-task"); + EXPECT_NE(tool.activation_hint.find("建立定时任务"), std::string::npos); + EXPECT_NE(tool.loop_execution_refusal.find("do not schedule anything"), std::string::npos); +} } // namespace diff --git a/tests/prompt/skills_index_prompt_test.cpp b/tests/prompt/skills_index_prompt_test.cpp index f969f353..f423dad5 100644 --- a/tests/prompt/skills_index_prompt_test.cpp +++ b/tests/prompt/skills_index_prompt_test.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -366,6 +367,80 @@ TEST_F(SkillsIndexRegistryTest, CacheKeyTracksSkillSetChanges) { EXPECT_NE(block1.cache_key, block3.cache_key); } +// --------------------------------------------------------------------------- +// 技能门控的内置工具(create_scheduled_task 等) +// --------------------------------------------------------------------------- + +std::vector scheduled_task_gate() { + return {{"create_scheduled_task", "scheduled-task", + "the user wants something done later or repeatedly (每天 / 建立定时任务)"}}; +} + +// 触发场景:门控工具 create_scheduled_task 在工具表里默认不可见,只能靠模型 +// 先加载 scheduled-task 技能。用户反馈「智能添加定时任务的技能经常不触发」。 +// 期望:技能索引里单列「Skill-gated built-in tools」一节,写明工具名、要加载的 +// 技能和触发条件,即使目录部分被截断也照样出现。 +TEST_F(SkillsIndexRegistryTest, GatedToolSectionNamesSkillToolAndHint) { + write_skill_md(temp_root, "acecode", "scheduled-task", "Create scheduled tasks"); + write_skill_md(temp_root, "", "alpha", "First description"); + acecode::SkillRegistry registry; + registry.set_scan_roots({temp_root}); + registry.scan(); + const auto gated = scheduled_task_gate(); + + auto block = acecode::build_skills_index_context_prompt( + ®istry, 128000, true, true, nullptr, &gated); + EXPECT_NE(block.content.find("### Skill-gated built-in tools"), std::string::npos); + EXPECT_NE(block.content.find( + "- `create_scheduled_task` (load skill `scheduled-task`): the user wants something"), + std::string::npos); + EXPECT_NE(block.content.find("每天 / 建立定时任务"), std::string::npos); + + // 门控工具集变化必须反映到 cache_key,否则策略隐藏工具后旧提示仍被复用。 + const auto plain = acecode::build_skills_index_context_prompt(®istry, 128000); + EXPECT_EQ(plain.content.find("Skill-gated"), std::string::npos); + EXPECT_NE(plain.cache_key, block.cache_key); +} + +// 触发场景:scheduled-task 用过一次后超过 idle_days 没再用,被判为休眠技能。 +// 休眠技能会从目录里隐藏,自然语言「每天帮我…」就再也触发不了建任务。 +// 期望:门控工具的技能不受休眠影响,仍在目录里;普通休眠技能照常隐藏。 +TEST_F(SkillsIndexRegistryTest, GatedToolSkillIsNeverDormant) { + write_skill_md(temp_root, "acecode", "scheduled-task", "Create scheduled tasks"); + write_skill_md(temp_root, "", "alpha", "First description"); + acecode::SkillRegistry registry; + registry.set_scan_roots({temp_root}); + registry.scan(); + const auto gated = scheduled_task_gate(); + const std::set dormant{"scheduled-task", "alpha"}; + + auto block = acecode::build_skills_index_context_prompt( + ®istry, 128000, true, true, &dormant, &gated); + EXPECT_NE(block.content.find("- scheduled-task: Create scheduled tasks"), std::string::npos); + EXPECT_EQ(block.content.find("alpha: First description"), std::string::npos); +} + +// 触发场景:(1) 用户在设置里禁用了 scheduled-task 技能(registry 里没有它); +// (2) 专家策略收走了 skill_view,模型无法加载门控技能。 +// 期望:两种情况都不出门控一节 —— 列出一个加载不到的技能只会误导模型。 +TEST_F(SkillsIndexRegistryTest, GatedToolSectionNeedsInstalledSkillAndSkillView) { + write_skill_md(temp_root, "", "alpha", "First description"); + acecode::SkillRegistry registry; + registry.set_scan_roots({temp_root}); + registry.scan(); + const auto gated = scheduled_task_gate(); + + auto missing_skill = acecode::build_skills_index_context_prompt( + ®istry, 128000, true, true, nullptr, &gated); + EXPECT_EQ(missing_skill.content.find("Skill-gated"), std::string::npos); + + write_skill_md(temp_root, "acecode", "scheduled-task", "Create scheduled tasks"); + registry.reload(); + auto no_skill_view = acecode::build_skills_index_context_prompt( + ®istry, 128000, /*skill_view_available=*/false, true, nullptr, &gated); + EXPECT_EQ(no_skill_view.content.find("Skill-gated"), std::string::npos); +} + // --------------------------------------------------------------------------- // build_session_context_prompt 隔离 // --------------------------------------------------------------------------- diff --git a/tests/provider/model_pool_status_test.cpp b/tests/provider/model_pool_status_test.cpp index a2e494d9..02ec4c6c 100644 --- a/tests/provider/model_pool_status_test.cpp +++ b/tests/provider/model_pool_status_test.cpp @@ -192,3 +192,26 @@ TEST(ModelPoolStatus, FailedRefreshPreservesLastValidSnapshot) { EXPECT_EQ(retained->usage_rate, 55); EXPECT_EQ(svc.effective_context_window_for("DeepSeek-V4-Flash"), 80000); } + +// 场景:不传 url 构造服务(daemon / TUI 的实际用法),抓取时用写死的混淆地址。 +// 期望:请求地址的主机与模型网关完全一致(带 .cn),路径是 +// /code_pilot/api/monitor/getModelPoolStatus。 +// 回归点:混淆数组曾漏写 .cn,解码出 wizard-ai.paic.com,内网 DNS 解析不到, +// daemon 日志每 30 秒一条 "Could not resolve host",模型池负载和有效窗口一直拿不到。 +TEST(ModelPoolStatus, DefaultUrlUsesModelGatewayHost) { + const std::string gateway = "https://wizard-ai.paic.com.cn/code_pilot/api"; + const std::string origin = gateway.substr(0, gateway.find('/', 8)); + std::string requested; + ModelPoolStatusService svc([&requested](const std::string& url) { + requested = url; + ModelPoolFetchResult r; + r.status_code = 200; + r.body = kSampleResponse; + return r; + }); + ASSERT_TRUE(svc.refresh_once()); + EXPECT_EQ(requested, acecode::kModelPoolStatusUrl); + ASSERT_GT(requested.size(), origin.size()); + EXPECT_EQ(requested.substr(0, origin.size()), origin); + EXPECT_EQ(requested.substr(origin.size()), "/code_pilot/api/monitor/getModelPoolStatus"); +} diff --git a/tests/provider/model_sampling_request_test.cpp b/tests/provider/model_sampling_request_test.cpp new file mode 100644 index 00000000..ed668aa7 --- /dev/null +++ b/tests/provider/model_sampling_request_test.cpp @@ -0,0 +1,136 @@ +// 覆盖模型采样参数(temperature / top_p / top_k / min_p)从 Provider 配置到 +// 请求体的最后一段:OpenAiCompatProvider / AnthropicProvider::build_request_body, +// 以及 Responses 协议的请求转换。 +// +// 背景:内网 wizard-ai 网关上的 GLM 模型,平台给出的推荐值是 top_p 0.95 / +// top_k 20 / temperature 1 / min_p 0,同网关的自研 agent 会带上这些值;acecode +// 以前一个都不发,只能吃服务端默认。现在采样参数按模型配置,但没配置时绝不能 +// 出现在请求体里 —— 否则所有现有用户的请求都会被悄悄改变。 + +#include + +#include "provider/anthropic_provider.hpp" +#include "provider/openai_provider.hpp" +#include "provider/openai_responses.hpp" + +#include + +namespace { + +using acecode::AnthropicProvider; +using acecode::ChatMessage; +using acecode::ModelSamplingOptions; +using acecode::OpenAiCompatProvider; +using acecode::ProviderRequestOptions; + +// 把 protected 的 build_request_body 暴露给测试,不发起真实 HTTP 请求。 +class TestableOpenAiProvider : public OpenAiCompatProvider { +public: + using OpenAiCompatProvider::OpenAiCompatProvider; + using OpenAiCompatProvider::build_request_body; +}; + +ChatMessage user_message(const std::string& text) { + ChatMessage msg; + msg.role = "user"; + msg.content = text; + return msg; +} + +ProviderRequestOptions options_with(const ModelSamplingOptions& sampling) { + ProviderRequestOptions options; + options.sampling = sampling; + return options; +} + +ModelSamplingOptions glm_recommended() { + ModelSamplingOptions sampling; + sampling.temperature = 1.0; + sampling.top_p = 0.95; + sampling.top_k = 20; + sampling.min_p = 0.0; + return sampling; +} + +} // namespace + +// 触发场景:模型没有配置任何采样参数(所有现有用户的默认状态)。 +// 期望:Chat Completions 请求体里一个采样字段都没有,与加这个功能之前一致。 +TEST(ModelSamplingRequest, OpenAiOmitsSamplingFieldsWhenUnset) { + TestableOpenAiProvider provider("https://gateway.example/v1", "sk-test", + "aicoder-pro"); + const auto body = provider.build_request_body({user_message("hi")}, {}, true); + for (const char* key : {"temperature", "top_p", "top_k", "min_p"}) { + EXPECT_FALSE(body.contains(key)) << key; + } +} + +// 触发场景:按平台推荐值配置了四个参数(wizard-ai 的 GLM 场景)。 +// 期望:四个字段原样出现在顶层;min_p = 0 也要发出去(0 是有效值,不是"未设置")。 +TEST(ModelSamplingRequest, OpenAiSendsEveryConfiguredField) { + TestableOpenAiProvider provider("https://gateway.example/v1", "sk-test", + "aicoder-pro", 120000, {}, + options_with(glm_recommended())); + const auto body = provider.build_request_body({user_message("hi")}, {}, true); + ASSERT_TRUE(body.contains("temperature")); + EXPECT_DOUBLE_EQ(body["temperature"].get(), 1.0); + EXPECT_DOUBLE_EQ(body["top_p"].get(), 0.95); + EXPECT_EQ(body["top_k"].get(), 20); + ASSERT_TRUE(body.contains("min_p")); + EXPECT_DOUBLE_EQ(body["min_p"].get(), 0.0); +} + +// 触发场景:只配置了 temperature。 +// 期望:只发 temperature,其余三个保持服务端默认(不能补成 0 或别的默认值)。 +TEST(ModelSamplingRequest, OpenAiSendsOnlyTheFieldsThatWereSet) { + ModelSamplingOptions sampling; + sampling.temperature = 0.3; + TestableOpenAiProvider provider("https://gateway.example/v1", "sk-test", + "aicoder-pro", 120000, {}, + options_with(sampling)); + const auto body = provider.build_request_body({user_message("hi")}, {}, false); + EXPECT_DOUBLE_EQ(body["temperature"].get(), 0.3); + EXPECT_FALSE(body.contains("top_p")); + EXPECT_FALSE(body.contains("top_k")); + EXPECT_FALSE(body.contains("min_p")); +} + +// 触发场景:Responses 协议的模型配置了 temperature / top_p。 +// 期望:转换成 Responses 请求后两个字段仍在(配置校验已保证 Responses 不会带 top_k / min_p)。 +TEST(ModelSamplingRequest, ResponsesConversionKeepsTemperatureAndTopP) { + ModelSamplingOptions sampling; + sampling.temperature = 0.7; + sampling.top_p = 0.9; + ProviderRequestOptions options = options_with(sampling); + options.api_protocol = "responses"; + TestableOpenAiProvider provider("https://gateway.example/v1", "sk-test", + "gpt-test", 120000, {}, options); + const auto chat_body = provider.build_request_body({user_message("hi")}, {}, true); + std::string error; + const auto request = acecode::build_openai_responses_request(chat_body, nullptr, &error); + ASSERT_TRUE(error.empty()) << error; + EXPECT_DOUBLE_EQ(request["temperature"].get(), 0.7); + EXPECT_DOUBLE_EQ(request["top_p"].get(), 0.9); +} + +// 触发场景:Anthropic 模型没有配置采样参数,以及配置了 temperature / top_p / top_k。 +// 期望:未配置时不发任何采样字段;配置后三个字段出现在 Messages API 请求体顶层。 +TEST(ModelSamplingRequest, AnthropicSendsConfiguredFieldsOnly) { + AnthropicProvider plain("https://api.anthropic.example", "sk-test", "claude-test"); + const auto plain_body = plain.build_request_body({user_message("hi")}, {}, false); + for (const char* key : {"temperature", "top_p", "top_k", "min_p"}) { + EXPECT_FALSE(plain_body.contains(key)) << key; + } + + ModelSamplingOptions sampling; + sampling.temperature = 0.5; + sampling.top_p = 0.9; + sampling.top_k = 40; + AnthropicProvider tuned("https://api.anthropic.example", "sk-test", "claude-test", + 120000, {}, options_with(sampling)); + const auto body = tuned.build_request_body({user_message("hi")}, {}, false); + EXPECT_DOUBLE_EQ(body["temperature"].get(), 0.5); + EXPECT_DOUBLE_EQ(body["top_p"].get(), 0.9); + EXPECT_EQ(body["top_k"].get(), 40); + EXPECT_FALSE(body.contains("min_p")); +} diff --git a/tests/provider/provider_factory_test.cpp b/tests/provider/provider_factory_test.cpp index a68580db..dfb45a6c 100644 --- a/tests/provider/provider_factory_test.cpp +++ b/tests/provider/provider_factory_test.cpp @@ -369,3 +369,54 @@ TEST(ProviderFactory, FingerprintPublicSurfaceCannotExposeRawSecrets) { ASSERT_TRUE(built.provider); EXPECT_EQ(built.provider->model(), "test-model"); } + +// 触发场景:在设置里改了某个采样参数(包括从"未设置"改成 0、或只改小数点后 +// 很远的一位),运行中的会话要靠指纹变化才会重建 Provider。 +// 期望:每个字段的增删改都改变指纹;0.95 与 0.9500000001 也必须区分(按位编码, +// 不能先格式化成文本);采样参数原样进入 Provider 的请求选项。 +TEST(ProviderFactory, FingerprintCoversEverySamplingField) { + auto base = openai_profile("https://gateway.example/v1"); + base.sampling.temperature = 1.0; + base.sampling.top_p = 0.95; + base.sampling.top_k = 20; + base.sampling.min_p = 0.0; + const auto expected = fingerprint_for(base); + + const std::vector> mutations = { + [](ModelProfile& p) { p.sampling.temperature = 0.99; }, + [](ModelProfile& p) { p.sampling.temperature.reset(); }, + [](ModelProfile& p) { p.sampling.top_p = 0.9500000001; }, + [](ModelProfile& p) { p.sampling.top_p.reset(); }, + [](ModelProfile& p) { p.sampling.top_k = 21; }, + [](ModelProfile& p) { p.sampling.top_k.reset(); }, + [](ModelProfile& p) { p.sampling.min_p = 0.01; }, + [](ModelProfile& p) { p.sampling.min_p.reset(); }, + }; + for (const auto& mutate : mutations) { + auto changed = base; + mutate(changed); + EXPECT_NE(fingerprint_for(changed), expected); + } + EXPECT_EQ(fingerprint_for(base), expected); + + const auto provider = create_provider_from_entry(base); + const auto compat = std::dynamic_pointer_cast(provider); + ASSERT_TRUE(compat); + EXPECT_EQ(compat->request_options().sampling.top_k, 20); + EXPECT_EQ(compat->request_options().sampling.min_p, 0.0); +} + +// 触发场景:绕过配置校验(例如旧数据或直接构造)给托管 provider 塞了采样参数。 +// 期望:工厂拒绝构造,与托管 provider 的其它自定义项一致,不会带着用户以为 +// 生效、其实被托管协议忽略的参数运行。 +TEST(ProviderFactory, ManagedProvidersRefuseSampling) { + for (const auto* managed : {"copilot", "grok"}) { + ModelProfile profile; + profile.name = managed; + profile.provider = managed; + profile.model = "model-id"; + profile.sampling.temperature = 0.5; + EXPECT_FALSE(acecode::prepare_provider_construction(profile).has_value()) + << managed; + } +} diff --git a/tests/session/global_session_catalog_test.cpp b/tests/session/global_session_catalog_test.cpp index 1697d370..384047c9 100644 --- a/tests/session/global_session_catalog_test.cpp +++ b/tests/session/global_session_catalog_test.cpp @@ -2,11 +2,13 @@ #include "session/global_session_catalog.hpp" #include "session/global_session_search.hpp" +#include "session/session_load_metrics.hpp" #include "session/session_storage.hpp" #include "utils/cwd_hash.hpp" #include +#include #include #include #include @@ -123,6 +125,53 @@ acecode::GlobalSessionCatalogIndexSnapshot wait_for_index( return index.snapshot(); } +// 写一个带一条用户消息的会话(meta + JSONL),供内容搜索用例命中。 +void seed_session_with_message(const ProjectFixture& project, + const std::string& id, + const std::string& updated_at, + const std::string& user_text, + bool archived = false, + const std::string& parent_session_id = {}) { + seed_meta(project, id, updated_at, archived, parent_session_id); + acecode::SessionStorage::write_messages( + acecode::SessionStorage::session_path(project.path.string(), id), + {user_message(user_text)}); +} + +// 内容搜索的任务列表取自目录索引已发现的项目,所以要先等后台扫描完成。 +void wait_for_catalog_complete(acecode::GlobalSessionSearchService& service, + const std::string& request_id) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + do { + if (service.search_sessions(request_id, {}, 50).progress.complete) return; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } while (std::chrono::steady_clock::now() < deadline); + ADD_FAILURE() << "timed out waiting for global-session catalog"; +} + +// 同一 request_id + query 复用同一个任务,反复调用直到所有项目都扫完。 +acecode::GlobalSessionContentSearchPage run_content_search( + acecode::GlobalSessionSearchService& service, + const std::string& request_id, + const std::string& query) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + acecode::GlobalSessionContentSearchPage page; + do { + page = service.search_user_messages_batch(request_id, query, 50); + if (page.complete || page.cancelled) return page; + } while (std::chrono::steady_clock::now() < deadline); + ADD_FAILURE() << "timed out waiting for content search to finish"; + return page; +} + +std::vector content_result_ids( + const acecode::GlobalSessionContentSearchPage& page) { + std::vector ids; + for (const auto& entry : page.entries) ids.push_back(entry.meta.id); + std::sort(ids.begin(), ids.end()); + return ids; +} + } // namespace TEST(GlobalSessionCatalog, DiscoversSessionsOutsideVisibleWorkspaceRegistry) { @@ -499,6 +548,122 @@ TEST(GlobalSessionSearchService, ContentBatchesStopAfterCancellation) { service.stop(); } +// 触发场景:Ctrl+K 全文搜索,同一项目里普通会话、归档会话、子会话的用户消息 +// 都包含查询词;另有两个会话是目录索引扫描完成之后才落盘的(分片里没有它们, +// 结果要走「按元数据现拼条目」的回退路径),其中一个是归档的。 +// 期望行为:只返回两个未归档的主会话;归档会话与子会话即使正文命中也被排除, +// 回退路径与目录命中路径给出相同的工作区信息。 +TEST(GlobalSessionSearchService, ContentSearchExcludesArchivedAndChildSessionsMatchedByContent) { + TempProjectsRoot root; + const auto fixture = project( + root, "C:/projects/content-filter", "Content Named", true); + seed_session_with_message(fixture, "ordinary", "2026-10-01T01:00:00Z", + "ordinary mentions filter-needle"); + seed_session_with_message(fixture, "archived", "2026-10-01T02:00:00Z", + "archived mentions filter-needle", true); + seed_session_with_message(fixture, "child", "2026-10-01T03:00:00Z", + "child mentions filter-needle", false, "ordinary"); + + acecode::GlobalSessionSearchService service(root.path().string()); + service.start(); + wait_for_catalog_complete(service, "filter-request"); + + // 扫描完成后才出现的会话:不调 invalidate_project,目录分片里查不到。 + seed_session_with_message(fixture, "late-ordinary", "2026-10-01T04:00:00Z", + "late ordinary mentions filter-needle"); + seed_session_with_message(fixture, "late-archived", "2026-10-01T05:00:00Z", + "late archived mentions filter-needle", true); + + const auto page = run_content_search(service, "filter-request", "filter-needle"); + ASSERT_TRUE(page.complete); + EXPECT_TRUE(page.errors.empty()); + EXPECT_EQ(content_result_ids(page), + (std::vector{"late-ordinary", "ordinary"})); + for (const auto& entry : page.entries) { + ASSERT_TRUE(entry.content_match.has_value()) << entry.meta.id; + EXPECT_EQ(entry.workspace_hash, fixture.hash) << entry.meta.id; + EXPECT_EQ(entry.workspace_name, "Content Named") << entry.meta.id; + EXPECT_FALSE(entry.meta.archived) << entry.meta.id; + EXPECT_TRUE(entry.meta.parent_session_id.empty()) << entry.meta.id; + } + service.stop(); +} + +// 触发场景:目录索引扫描时会话还是普通会话,之后在磁盘上被归档(别的进程归档、 +// 或搜索面板关闭期间后台扫描暂停),目录分片还没刷新。 +// 期望行为:元数据搜索仍按旧分片返回它(这是分片的既有延迟,用作本用例的前提), +// 但内容搜索以磁盘上的最新元数据为准把它排除 —— 与改动前「每个项目重新枚举 +// 目录再过滤」的语义一致。 +// 回归:若改成「目录分片里找得到就直接采信」,刚归档的会话会漏进全文结果。 +TEST(GlobalSessionSearchService, ContentSearchUsesDiskArchiveStateWhenCatalogShardIsStale) { + TempProjectsRoot root; + const auto fixture = project(root, "C:/projects/content-stale"); + seed_session_with_message(fixture, "keep", "2026-10-02T01:00:00Z", + "keep mentions stale-needle"); + seed_session_with_message(fixture, "flip", "2026-10-02T02:00:00Z", + "flip mentions stale-needle"); + + acecode::GlobalSessionSearchService service(root.path().string()); + service.start(); + wait_for_catalog_complete(service, "stale-request"); + + // 只改磁盘上的 meta,不通知目录索引。 + seed_meta(fixture, "flip", "2026-10-02T02:00:00Z", /*archived=*/true); + const auto metadata = service.search_sessions("stale-request", "title-flip", 10); + bool stale_shard_still_lists_flip = false; + for (const auto& entry : metadata.entries) { + if (entry.meta.id == "flip") stale_shard_still_lists_flip = true; + } + ASSERT_TRUE(stale_shard_still_lists_flip) + << "前提不成立:目录分片已经刷新,本用例测不到分片滞后的场景"; + + const auto page = run_content_search(service, "stale-request", "stale-needle"); + ASSERT_TRUE(page.complete); + EXPECT_EQ(content_result_ids(page), (std::vector{"keep"})); + service.stop(); +} + +// 触发场景:索引已是热的,全文搜索扫过两个项目 —— A 有 10 个会话、其中 1 个 +// 命中,B 有 6 个会话、无命中。 +// 期望行为:每个项目的 .meta.json 只被打开一次(刷新内容索引时那一遍),总数 +// 不超过 16。Windows 上会话列表的成本在文件个数,2.8 万会话实测一遍约 3.3 秒。 +// 回归:改动前刷新索引读一遍、过滤命中又整目录读一遍,共 32 次;只做「无命中 +// 跳过第二遍」也还有 26 次,两种都会被这里拦下。 +TEST(GlobalSessionSearchService, ContentSearchOpensEachMetaFileOncePerProject) { + constexpr int kMatchingProjectSessions = 10; + constexpr int kQuietProjectSessions = 6; + TempProjectsRoot root; + const auto matching = project(root, "C:/projects/content-io-matching"); + const auto quiet = project(root, "C:/projects/content-io-quiet"); + for (int i = 0; i < kMatchingProjectSessions; ++i) { + seed_session_with_message( + matching, "matching-" + std::to_string(i), "2026-10-03T01:00:00Z", + i == 0 ? "only this one has io-needle" : "unrelated text"); + } + for (int i = 0; i < kQuietProjectSessions; ++i) { + seed_session_with_message( + quiet, "quiet-" + std::to_string(i), "2026-10-03T02:00:00Z", + "unrelated text"); + } + + acecode::GlobalSessionSearchService service(root.path().string()); + service.start(); + wait_for_catalog_complete(service, "io-warmup"); + // 先跑一遍把内容索引建好;建索引要读 JSONL,不在本用例的计数范围内。 + ASSERT_TRUE(run_content_search(service, "io-warmup", "io-needle").complete); + + // 计数器是线程局部的,后台目录扫描线程的读取不会混进来。 + const auto before = acecode::session_read_metrics(); + const auto page = run_content_search(service, "io-measured", "io-needle"); + const auto opened = (acecode::session_read_metrics() - before).files; + + ASSERT_TRUE(page.complete); + EXPECT_EQ(content_result_ids(page), (std::vector{"matching-0"})); + EXPECT_LE(opened, static_cast( + kMatchingProjectSessions + kQuietProjectSessions)); + service.stop(); +} + TEST(GlobalSessionCatalogIndex, WarmupWaitsForInteractionOrSearchSignal) { for (const bool search : {false, true}) { TempProjectsRoot root; diff --git a/tests/session/sidebar_tree_test.cpp b/tests/session/sidebar_tree_test.cpp index 883f4cd8..41fb0a38 100644 --- a/tests/session/sidebar_tree_test.cpp +++ b/tests/session/sidebar_tree_test.cpp @@ -22,7 +22,8 @@ TEST(SidebarTree, LatestCreatedRunRepresentsGroupEvenWhenOlderRunWasUpdated) { auto tree = build_sidebar_tree(json::array({old, row("new", "daily", "2026-10-13"), row("normal")}), {}); EXPECT_EQ(tree.roots.size(), 2u); EXPECT_EQ(tree.nodes.at("loop:daily").session_id, "new"); - EXPECT_EQ(tree.nodes.at("loop:daily").children, std::vector{"session:old"}); + EXPECT_TRUE(tree.nodes.at("loop:daily").group); + EXPECT_EQ(tree.nodes.at("loop:daily").children, (std::vector{"session:new", "session:old"})); } TEST(SidebarTree, DetachedRunStaysOutsideAfterReloadAndNewRun) { @@ -35,7 +36,7 @@ TEST(SidebarTree, DetachedRunStaysOutsideAfterReloadAndNewRun) { tree = build_sidebar_tree(records, layout); EXPECT_TRUE(tree.nodes.at("session:1").parent.empty()); EXPECT_EQ(tree.nodes.at("loop:daily").session_id, "3"); - EXPECT_EQ(tree.nodes.at("loop:daily").children, std::vector{"session:2"}); + EXPECT_EQ(tree.nodes.at("loop:daily").children, (std::vector{"session:3", "session:2"})); } TEST(SidebarTree, LeafCanMoveBetweenGroupsAndSiblingOrderSurvivesReload) { @@ -47,7 +48,7 @@ TEST(SidebarTree, LeafCanMoveBetweenGroupsAndSiblingOrderSurvivesReload) { ASSERT_TRUE(move_sidebar_node(tree, layout, "session:1", "session:3", "before", error)); tree = build_sidebar_tree(records, layout); EXPECT_EQ(tree.nodes.at("session:1").parent, "loop:b"); - EXPECT_EQ(tree.nodes.at("loop:b").children, (std::vector{"session:1", "session:3"})); + EXPECT_EQ(tree.nodes.at("loop:b").children, (std::vector{"session:4", "session:1", "session:3"})); } TEST(SidebarTree, ParentMovesAsGroupButCannotBecomeAChild) { @@ -56,7 +57,7 @@ TEST(SidebarTree, ParentMovesAsGroupButCannotBecomeAChild) { std::string error; ASSERT_TRUE(move_sidebar_node(tree, layout, "loop:a", "loop:b", "before", error)); EXPECT_EQ(tree.roots.front(), "loop:a"); - EXPECT_EQ(tree.nodes.at("loop:a").children.size(), 1u); + EXPECT_EQ(tree.nodes.at("loop:a").children.size(), 2u); const auto saved = layout; EXPECT_FALSE(move_sidebar_node(tree, layout, "loop:a", "loop:b", "inside", error)); EXPECT_FALSE(move_sidebar_node(tree, layout, "loop:a", "session:3", "before", error)); @@ -91,9 +92,11 @@ TEST(SidebarTree, DetachedScheduledRootAcceptsChildrenAndMissingParentPromotesTh ASSERT_TRUE(move_sidebar_node(tree, layout, "session:1", "session:normal", "before", error)); ASSERT_TRUE(move_sidebar_node(tree, layout, "session:normal", "session:1", "inside", error)); tree = build_sidebar_tree(records, layout); - EXPECT_TRUE(tree.nodes.at("session:1").parent.empty()); + EXPECT_EQ(tree.nodes.at("session:1").parent, "group:1"); EXPECT_TRUE(tree.nodes.at("session:1").scheduled); - EXPECT_EQ(tree.nodes.at("session:normal").parent, "session:1"); + EXPECT_EQ(tree.nodes.at("session:normal").parent, "group:1"); + EXPECT_TRUE(tree.nodes.at("group:1").group); + EXPECT_EQ(tree.nodes.at("group:1").loop_id, "daily"); tree = build_sidebar_tree(json::array({row("2", "daily"), row("normal")}), layout); EXPECT_TRUE(tree.nodes.at("session:normal").parent.empty()); } @@ -116,24 +119,60 @@ TEST(SidebarTree, RemovingRepresentativeAndInvalidLayoutsKeepAllSurvivors) { EXPECT_EQ(tree.nodes.at("loop:a").session_id, "1"); json layout = {{"parents", {{"session:1", "session:2"}, {"session:2", "session:1"}, {"session:3", "session:2"}}}}; tree = build_sidebar_tree(json::array({row("1", "a"), row("2", "b"), row("3", "c")}), layout); - EXPECT_EQ(tree.nodes.size(), 3u); + EXPECT_EQ(std::count_if(tree.nodes.begin(), tree.nodes.end(), [](const auto& entry) { return !entry.second.group; }), 3); for (const auto& [key, node] : tree.nodes) { if (!node.parent.empty()) EXPECT_TRUE(tree.nodes.at(node.parent).parent.empty()); } } -TEST(SidebarTree, SingleRunGroupCanBeAttachedWithoutCapturingFutureRuns) { +TEST(SidebarTree, SingleExecutionCanBeAttachedWithoutCapturingFutureRuns) { auto records = json::array({row("1", "daily"), row("receiver", "other")}); json layout; auto tree = build_sidebar_tree(records, layout); std::string error; - ASSERT_TRUE(move_sidebar_node(tree, layout, "loop:daily", "loop:other", "inside", error)); + EXPECT_FALSE(move_sidebar_node(tree, layout, "loop:daily", "loop:other", "inside", error)); + ASSERT_TRUE(move_sidebar_node(tree, layout, "session:1", "loop:other", "inside", error)); EXPECT_EQ(tree.nodes.at("session:1").parent, "loop:other"); records.push_back(row("2", "daily")); tree = build_sidebar_tree(records, layout); EXPECT_EQ(tree.nodes.at("loop:daily").session_id, "2"); EXPECT_EQ(tree.nodes.at("session:1").parent, "loop:other"); - EXPECT_TRUE(tree.nodes.at("loop:daily").children.empty()); + EXPECT_EQ(tree.nodes.at("loop:daily").children, std::vector{"session:2"}); +} + +TEST(SidebarTree, LegacyScheduledParentBecomesAStableManualGroup) { + const auto records = json::array({row("anchor", "daily"), row("child"), row("normal")}); + json layout = {{"parents", {{"session:anchor", ""}, {"session:child", "session:anchor"}, {"session:archived", "session:anchor"}}}, + {"orders", {{"", json::array({"session:anchor", "session:normal"})}}}}; + auto tree = build_sidebar_tree(records, layout); + EXPECT_EQ(tree.roots, (std::vector{"group:anchor", "session:normal"})); + EXPECT_EQ(tree.nodes.at("group:anchor").children.size(), 2u); + EXPECT_EQ(tree.nodes.at("session:anchor").parent, "group:anchor"); + EXPECT_EQ(tree.nodes.at("session:child").parent, "group:anchor"); + std::string error; + ASSERT_TRUE(move_sidebar_node(tree, layout, "session:anchor", "session:normal", "before", error)); + tree = build_sidebar_tree(records, layout); + EXPECT_TRUE(tree.nodes.at("session:anchor").parent.empty()); + EXPECT_EQ(tree.nodes.at("group:anchor").children, std::vector{"session:child"}); + EXPECT_EQ(layout["version"], 2); + EXPECT_EQ(layout["parents"]["session:archived"], "group:anchor"); + auto restored = records; + restored.push_back(row("archived")); + tree = build_sidebar_tree(restored, layout); + EXPECT_TRUE(tree.nodes.at("session:anchor").parent.empty()); + EXPECT_EQ(tree.nodes.at("session:archived").parent, "group:anchor"); +} + +TEST(SidebarTree, LatestExecutionCanDetachAndGroupKeepsItsIdentity) { + const auto records = json::array({row("1", "daily"), row("2", "daily"), row("normal")}); + json layout; + auto tree = build_sidebar_tree(records, layout); + std::string error; + ASSERT_TRUE(move_sidebar_node(tree, layout, "session:2", "session:normal", "before", error)); + tree = build_sidebar_tree(records, layout); + EXPECT_EQ(tree.nodes.at("loop:daily").session_id, "1"); + EXPECT_EQ(tree.nodes.at("loop:daily").children, std::vector{"session:1"}); + EXPECT_TRUE(tree.nodes.at("session:2").parent.empty()); } TEST(SidebarTree, LayoutPersistenceAndMetadataCacheObserveUpdatesAndDeletion) { diff --git a/tests/web/models_handler_test.cpp b/tests/web/models_handler_test.cpp index 5780340f..98a1e1c5 100644 --- a/tests/web/models_handler_test.cpp +++ b/tests/web/models_handler_test.cpp @@ -466,7 +466,12 @@ TEST(ModelsHandler, SharedEditPayloadParsesAndClearsSuppliedOverrides) { existing.max_output_tokens = 32000; existing.endpoint_mode = "base_url"; existing.request_headers = {{"X-Old", "value"}}; + // 共享 fixture 带 "sampling": null:编辑器把采样参数全部留空保存时, + // 已保存的值必须被清掉。 + existing.sampling.top_k = 20; cfg.saved_models.push_back(existing); + EXPECT_TRUE(draft->sampling_supplied); + EXPECT_FALSE(draft->sampling.has_value()); EXPECT_EQ(acecode::update_saved_model( cfg, "fixture-openrouter", *draft), @@ -477,6 +482,7 @@ TEST(ModelsHandler, SharedEditPayloadParsesAndClearsSuppliedOverrides) { EXPECT_FALSE(cfg.saved_models[0].max_output_tokens.has_value()); EXPECT_FALSE(cfg.saved_models[0].endpoint_mode.has_value()); EXPECT_TRUE(cfg.saved_models[0].request_headers.empty()); + EXPECT_TRUE(cfg.saved_models[0].sampling.empty()); ASSERT_TRUE(cfg.saved_models[0].reasoning.has_value()); EXPECT_EQ(cfg.saved_models[0].reasoning->default_effort, "high"); } @@ -869,3 +875,63 @@ TEST(ModelsHandler, ApiProtocolRoundTripAndDraftPresence) { EXPECT_FALSE(parse_model_draft(invalid, error)); EXPECT_NE(error.find("api_protocol"), std::string::npos); } + +// 触发场景:设置页保存模型时提交 sampling —— 一组新值、null(全部留空)、 +// 不提交、类型错误的值。 +// 期望:对象 → supplied 且解析出各字段;null → supplied 但无值(编辑器据此清除); +// 不提交 → 不 supplied(保留旧值);类型错误 → 解析失败且错误里带 "sampling"。 +TEST(ModelsHandler, ParseDraftHandlesSampling) { + const nlohmann::json wire = { + {"name", "glm"}, {"provider", "openai"}, {"model", "aicoder-pro"}, + {"base_url", "https://gateway.example/v1"}, {"api_key", "sk-test"}, + }; + std::string error; + auto with_values = wire; + with_values["sampling"] = {{"temperature", 1}, {"top_p", 0.95}, + {"top_k", 20}, {"min_p", 0}}; + auto draft = parse_model_draft(with_values, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_TRUE(draft->sampling_supplied); + ASSERT_TRUE(draft->sampling.has_value()); + EXPECT_EQ(draft->sampling->top_k, 20); + EXPECT_EQ(draft->sampling->min_p, 0.0); + + auto cleared = wire; + cleared["sampling"] = nullptr; + draft = parse_model_draft(cleared, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_TRUE(draft->sampling_supplied); + EXPECT_FALSE(draft->sampling.has_value()); + + draft = parse_model_draft(wire, error); + ASSERT_TRUE(draft.has_value()) << error; + EXPECT_FALSE(draft->sampling_supplied); + + auto invalid = wire; + invalid["sampling"] = {{"top_k", "20"}}; + error.clear(); + EXPECT_FALSE(parse_model_draft(invalid, error)); + EXPECT_NE(error.find("sampling"), std::string::npos) << error; +} + +// 触发场景:模型列表 / 保存成功后把条目回给设置页。 +// 期望:配了采样参数才输出 sampling(只含已设置的字段),没配时不输出该键, +// 编辑弹窗据此回填输入框。 +TEST(ModelsHandler, ProfileToJsonIncludesSamplingOnlyWhenSet) { + ModelProfile p; + p.name = "glm"; + p.provider = "openai"; + p.model = "aicoder-pro"; + p.base_url = "https://gateway.example/v1"; + p.api_key = "sk-test"; + EXPECT_FALSE(profile_to_json(p).contains("sampling")); + + p.sampling.top_p = 0.95; + p.sampling.top_k = 20; + const auto j = profile_to_json(p); + ASSERT_TRUE(j.contains("sampling")); + EXPECT_EQ(j["sampling"]["top_p"], 0.95); + EXPECT_EQ(j["sampling"]["top_k"], 20); + EXPECT_FALSE(j["sampling"].contains("temperature")); + EXPECT_FALSE(j["sampling"].contains("min_p")); +} diff --git a/tests/web/sidebar_sessions_test.cpp b/tests/web/sidebar_sessions_test.cpp index 1a29593b..23ea5a31 100644 --- a/tests/web/sidebar_sessions_test.cpp +++ b/tests/web/sidebar_sessions_test.cpp @@ -4,6 +4,7 @@ #include "web/server.hpp" #include "desktop/daemon_supervisor.hpp" #include "session/sidebar_tree.hpp" +#include "loop/loop_store.hpp" #include "session/session_pin_store.hpp" #include "session_host/local_session_client.hpp" #include "session_host/session_registry.hpp" @@ -13,6 +14,7 @@ #include "utils/joining_thread.hpp" #include +#include #include namespace { @@ -29,12 +31,14 @@ class SidebarSessionsHttp : public testing::Test { AppConfig config; ToolExecutor tools; desktop::WorkspaceRegistry workspaces; + loop::LoopStore loops{home.root() / "loops.sqlite3"}; std::unique_ptr registry; std::unique_ptr client; std::unique_ptr server; JoiningThread worker; void SetUp() override { + ASSERT_TRUE(loops.initialize()); config.web.bind = "127.0.0.1"; config.web.port = desktop::pick_free_loopback_port(); workspaces.register_new(projects, cwd); @@ -56,6 +60,7 @@ class SidebarSessionsHttp : public testing::Test { deps.workspace_registry = &workspaces; deps.session_client = client.get(); deps.session_registry = registry.get(); + deps.loop_store = &loops; server = std::make_unique(std::move(deps)); worker = JoiningThread([this] { server->run(); }); const auto deadline = std::chrono::steady_clock::now() + 3s; @@ -102,13 +107,16 @@ TEST_F(SidebarSessionsHttp, PagesGroupsAndReturnsAllExpandedHistory) { EXPECT_EQ(page["total"], 7); EXPECT_EQ(page["sessions"].size(), 5u); EXPECT_TRUE(page["has_more"].get()); - EXPECT_EQ(page["sessions"][0]["id"], "run-22"); + EXPECT_EQ(page["sessions"][0]["id"], "sidebar-group:loop:daily"); + EXPECT_EQ(page["sessions"][0]["sidebar_latest_session_id"], "run-22"); + EXPECT_EQ(page["sessions"][0]["sidebar_members"].size(), 13u); page = get({{"limit", "5"}, {"expanded", "[\"loop:daily\"]"}}); - EXPECT_EQ(page["sessions"].size(), 17u); + EXPECT_EQ(page["sessions"].size(), 18u); EXPECT_EQ(page["visible_roots"], 5); - EXPECT_EQ(page["sessions"][12]["id"], "run-10"); + EXPECT_EQ(page["sessions"][1]["id"], "run-22"); + EXPECT_EQ(page["sessions"][13]["id"], "run-10"); page = get({{"limit", "5"}, {"reveal", "run-10"}}); - EXPECT_EQ(page["sessions"].size(), 17u); + EXPECT_EQ(page["sessions"].size(), 18u); page = get({{"limit", "5"}, {"reveal", "normal-0"}}); EXPECT_FALSE(page["has_more"].get()); EXPECT_EQ(page["visible_roots"], 7); @@ -155,7 +163,7 @@ TEST_F(SidebarSessionsHttp, OrdinaryRootsRejectChildrenWithoutChangingSavedLayou ASSERT_EQ(move("session:other", "session:normal", "before").status_code, 200); const auto layout_path = std::filesystem::path(SessionStorage::get_project_dir(cwd)) / "sidebar_sessions.json"; const auto before = read_sidebar_layout(layout_path); - for (const auto& source : {"session:other", "loop:daily"}) { + for (const auto& source : {"session:other", "session:run"}) { const auto response = move(source, "session:normal", "inside"); EXPECT_EQ(response.status_code, 409) << response.text; EXPECT_EQ(json::parse(response.text)["error"], "SIDEBAR_SCHEDULED_PARENT_ONLY"); @@ -163,7 +171,7 @@ TEST_F(SidebarSessionsHttp, OrdinaryRootsRejectChildrenWithoutChangingSavedLayou EXPECT_EQ(read_sidebar_layout(layout_path), before); const auto page = get({{"expanded", "[\"session:normal\"]"}}); EXPECT_EQ(page["total"], 3); - for (const auto& row : page["sessions"]) EXPECT_EQ(row["sidebar_child_count"], 0); + for (const auto& row : page["sessions"]) if (!row.value("sidebar_group", false)) EXPECT_EQ(row["sidebar_child_count"], 0); ASSERT_EQ(move("session:normal", "loop:daily", "inside").status_code, 200); EXPECT_EQ(get()["total"], 2); } @@ -180,7 +188,7 @@ TEST_F(SidebarSessionsHttp, NoWorkspaceIsIsolatedAndLiveOriginsArePreserved) { ASSERT_EQ(response.status_code, 200) << response.text; const auto body = json::parse(response.text); EXPECT_EQ(body["total"], 1); - ASSERT_EQ(body["sessions"].size(), 2u); + ASSERT_EQ(body["sessions"].size(), 3u); for (const auto& row : body["sessions"]) EXPECT_TRUE(row["no_workspace"].get()); EXPECT_EQ(get()["total"], 1); } @@ -194,25 +202,70 @@ TEST_F(SidebarSessionsHttp, PinnedRepresentativeDoesNotConsumeRootSlotsOrHideHis auto page = get({{"limit", "5"}, {"expanded", "[\"loop:daily\"]"}}); EXPECT_EQ(page["total"], 7); EXPECT_EQ(page["visible_roots"], 5); - EXPECT_EQ(page["sessions"].size(), 7u); // Five roots, one history, one pin. - EXPECT_EQ(page["sessions"][0]["id"], "run-2"); - EXPECT_EQ(page["sessions"][1]["id"], "run-1"); + EXPECT_EQ(page["sessions"].size(), 8u); // Five roots, two executions, one pin. + EXPECT_EQ(page["sessions"][0]["sidebar_latest_session_id"], "run-2"); + EXPECT_EQ(page["sessions"][1]["id"], "run-2"); + EXPECT_EQ(page["sessions"][2]["id"], "run-1"); EXPECT_EQ(page["sessions"].back()["id"], "run-3"); EXPECT_FALSE(page["sessions"].back().contains("sidebar_parent_key")); } -TEST_F(SidebarSessionsHttp, SingleRunGroupMovesAsALeaf) { +TEST_F(SidebarSessionsHttp, SingleExecutionMovesAsALeaf) { record("run-1", "daily"); record("receiver", "other"); - auto response = move("loop:daily", "loop:other", "inside"); + EXPECT_EQ(move("loop:daily", "loop:other", "inside").status_code, 409); + auto response = move("session:run-1", "loop:other", "inside"); ASSERT_EQ(response.status_code, 200) << response.text; const auto moved = json::parse(response.text); EXPECT_EQ(moved["total"], 1); - ASSERT_EQ(moved["sessions"].size(), 2u); - EXPECT_EQ(moved["sessions"][1]["sidebar_key"], "session:run-1"); + ASSERT_EQ(moved["sessions"].size(), 3u); + EXPECT_EQ(moved["sessions"][2]["sidebar_key"], "session:run-1"); record("run-2", "daily"); auto page = get({{"expanded", "[\"loop:other\"]"}}); EXPECT_EQ(page["total"], 2); - ASSERT_EQ(page["sessions"].size(), 3u); - EXPECT_EQ(page["sessions"][0]["id"], "run-2"); + ASSERT_EQ(page["sessions"].size(), 4u); + EXPECT_EQ(page["sessions"][0]["sidebar_latest_session_id"], "run-2"); +} + +TEST_F(SidebarSessionsHttp, GroupUsesTaskNameLatestTimeAndUnreadAcrossAllMembers) { + loop::LoopDefinition definition; + definition.id = "daily"; + definition.name = "Daily briefing"; + definition.prompt = "Inspect only"; + definition.model_name = "fixture"; + definition.workspace_cwd = cwd; + definition.workspace_hash = compute_cwd_hash(cwd); + definition.schedule.kind = loop::ScheduleKind::Once; + definition.schedule.once_at_ms = 10'000; + const auto task = loops.create_loop(definition, 1'000); + ASSERT_TRUE(task.has_value()); + record("run-1", "daily"); record("run-2", "daily"); record("normal"); + const auto directory = std::filesystem::path(SessionStorage::get_project_dir(cwd)); + { + std::ofstream out(directory / "session_read_state.json", std::ios::binary); + out << json{{"sessions", {{"run-1", {{"read_cursor", 1}, {"update_cursor", 2}}}}}}.dump(); + } + auto old = SessionStorage::read_meta(SessionStorage::meta_path(directory.string(), "run-1")); + old.title = "Changed old conversation"; old.updated_at = "zzzz"; + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(directory.string(), "run-1"), old)); + auto page = get(); + const auto header = page["sessions"][0]; + EXPECT_EQ(header["title"], "Daily briefing"); + EXPECT_EQ(header["sidebar_latest_session_id"], "run-2"); + EXPECT_EQ(header["updated_at"], "run-2"); + EXPECT_EQ(header["attention_state"], "unread"); + EXPECT_FALSE(header["busy"].get()); + EXPECT_FALSE(header["active"].get()); + EXPECT_EQ(header["sidebar_members"].size(), 2u); + auto renamed = *task; renamed.name = "Renamed task"; + ASSERT_TRUE(loops.update_loop(task->id, renamed, 2'000).has_value()); + EXPECT_EQ(get()["sessions"][0]["title"], "Renamed task"); + const auto response = move("session:run-2", "session:normal", "before"); + ASSERT_EQ(response.status_code, 200) << response.text; + page = get({{"expanded", "[\"loop:daily\"]"}}); + for (const auto& row : page["sessions"]) if (row.value("sidebar_group", false)) { + EXPECT_EQ(row["id"], header["id"]); + EXPECT_EQ(row["title"], "Renamed task"); + EXPECT_EQ(row["sidebar_latest_session_id"], "run-1"); + } } } // namespace diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 0c2b71b9..1de3b851 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,9 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '侧边聊天存续时间': 'Side chat retention time', + 'side chat retention lifetime minutes ttl 侧聊 保留 过期 分钟': 'side chat retention lifetime minutes ttl', + '从最后一次侧聊结束开始计时,到期后切回该会话时清空;正在查看时不会自动消失。': 'Starts when the last side chat ends. After this time, returning to the session clears it. It stays visible while you are viewing it.', '保存会话分组失败,请重试': 'Could not save the session group. Please try again.', '读取会话分组失败,请重试': 'Could not load the session group. Please try again.', '修改快捷键': 'Edit shortcut', @@ -489,7 +492,7 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ 'EVA 主题下载进度': 'EVA theme download progress', '意见反馈': 'Feedback', '反馈内容不能超过 10000 字': 'Feedback cannot exceed 10,000 characters', - '默认附带近期日志,会话仅在选择后附带。': 'Recent logs are included. A session is attached only when selected.', + '默认附带近期日志;选择会话后,同时附带该会话的记录和发给模型的请求原文。': 'Recent logs are included. When you select a session, its transcript and the raw requests sent to the model are attached too.', '上传失败:': 'Upload failed: ', '保存失败,请检查文件写入权限': 'Could not save. Check file write permissions.', '保留': 'Keep', @@ -2122,4 +2125,13 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ "没有找到 cloudflared。先运行 winget install --id Cloudflare.cloudflared 安装后重试,或回到上一步填写自己的公网地址。": "cloudflared was not found. Install it with winget install --id Cloudflare.cloudflared and try again, or go back and enter your own public address.", "回调地址已登记。请在 LINE Developers 控制台的 Messaging API 页打开 Use webhook,打开后这里会自动变为已连接。": "The webhook URL is registered. Turn on Use webhook on the Messaging API page of the LINE Developers console; this changes to connected automatically once it is on.", "请把你的公网地址转发到本机 http://127.0.0.1:{{p0}},连通后会自动继续。": "Forward your public address to http://127.0.0.1:{{p0}} on this computer. Setup continues automatically once it is reachable.", + '采样参数': 'Sampling parameters', + '服务端默认': 'Server default', + '留空则不发送,由服务端决定。': 'Leave blank to omit the field and use the server default.', + 'Top K、Min P 是 vLLM、SGLang 等推理服务的扩展参数,官方 OpenAI 接口不支持,按需填写。': 'Top K and Min P are extensions supported by inference servers such as vLLM and SGLang; the official OpenAI API rejects them. Fill them in only when your server supports them.', + 'Temperature 需在 0 到 2 之间(Anthropic 为 0 到 1)': 'Temperature must be between 0 and 2 (0 to 1 for Anthropic)', + 'Top P 需大于 0 且不超过 1': 'Top P must be greater than 0 and at most 1', + 'Top K 需为正整数': 'Top K must be a positive integer', + 'Min P 需在 0 到 1 之间': 'Min P must be between 0 and 1', + '采样参数无效,或当前 Provider / API 协议不支持该参数': 'Invalid sampling parameters, or the current provider or API protocol does not support one of them', }); diff --git a/web/scripts/test-question-processed-summary.mjs b/web/scripts/test-question-processed-summary.mjs new file mode 100644 index 00000000..5fb2440d --- /dev/null +++ b/web/scripts/test-question-processed-summary.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await mkdtemp(path.join(os.tmpdir(), 'ace-question-summary-')); +const entry = ` +import React, {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {TranscriptItems} from '/src/components/TranscriptItems.jsx'; +import {projectCollapsedTranscriptItems} from '/src/lib/transcriptProjection.js'; +import {createTranscriptState, loadTranscriptHistory} from '/src/lib/sessionTranscript.js'; +import '/src/styles/globals.css'; +const messages = [ + {id:'user', role:'user', content:'清除用户数据排查邮件正文不显示问题', ts:1000}, + {id:'inspect', role:'assistant', content:'先确认清理范围', ts:2000}, + {id:'question-1', role:'tool', tool:'AskUserQuestion', content:'answered', ts:3000, + metadata:{tool_success:true, ask_user_question_result:{items:[ + {question:'你想清除哪些范围的用户数据?', answer:'只清除 SDK 数据'} + ]}}}, + {id:'check', role:'assistant', content:'检查应用状态', ts:4000}, + {id:'question-2', role:'tool', tool:'AskUserQuestion', content:'answered', ts:5000, + metadata:{tool_success:true, ask_user_question_result:{items:[ + {question:'开发环境正在运行,请先关闭应用。', answer:'关了'} + ]}}}, + {id:'cleanup', role:'assistant', content:'执行清理并检查结果', ts:6000}, + {id:'final', role:'assistant', content:'SDK 数据已清除完毕。', ts:7000}, + {id:'timing', role:'system', content:'[Turn timing]', metadata:{transcript_only:true, + turn_timing:{user_message_uuid:'user', started_at_ms:1000, + completed_at_ms:54028000, duration_ms:54027000, status:'completed'}}}, +]; +function App() { + const [expanded, setExpanded] = useState(new Set()); + const [mode, setMode] = useState('final'); + window.fixture = {mode(value) {setExpanded(new Set()); setMode(value);}}; + let source = messages; + if (mode === 'completion') source = messages.filter(m => m.id !== 'final' && m.id !== 'cleanup'); + if (mode === 'task' || mode === 'completion' || mode === 'notice') source = [...source, + {id:'done', role:'tool', tool:'task_complete', content:'清理完成', ts:8000, + metadata:{tool_summary:{verb:'complete', object:'task', metrics:[{label:'summary',value:'清理完成'}]}}}, + ]; + if (mode === 'notice') source = [...source, {id:'notice', role:'system', content:'后续系统通知',ts:9000}]; + const state = loadTranscriptHistory(createTranscriptState(), {messages:source,events:[]}).state; + const items = projectCollapsedTranscriptItems(state.items); + return
+
+ setExpanded(prev => { + const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; + })}/> +
+
; +} +createRoot(document.getElementById('root')).render(); +`; +const html = '
'; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'question-summary-fixture', + resolveId(id) { if (id === 'virtual:question-summary') return '\0virtual:question-summary'; }, + async load(id) { + if (id === '\0virtual:question-summary') { + return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx' })).code; + } + }, + configureServer(vite) { + vite.middlewares.use('/question-summary', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/question-summary', html)); + }); + }, + }], +}); +const errors = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + for (const [theme, width] of [['light', 1280], ['dark', 720]]) { + const page = await browser.newPage({ viewport: { width, height: 1000 } }); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/question-summary`); + await page.waitForFunction(() => !!window.fixture); + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + for (const mode of ['final', 'task', 'completion', 'notice']) { + await page.evaluate(mode => window.fixture.mode(mode), mode); + await page.waitForFunction(mode => document.querySelector('main')?.dataset.fixtureMode === mode, mode); + const summaries = page.locator('.ace-activity-line-processed'); + const questions = page.locator('[data-ask-user-question-result="true"]'); + assert.equal(await summaries.count(), 1, mode); + assert.match(await summaries.innerText(), /已处理 900m 27s/); + assert.equal(await questions.count(), 2, mode); + assert.match(await questions.nth(0).innerText(), /只清除 SDK 数据/); + assert.match(await questions.nth(1).innerText(), /关了/); + for (const index of [0, 1]) { + const question = questions.nth(index); + await question.getByRole('button').click(); + await question.locator('.ace-qa-answer').waitFor({ state: 'detached' }); + await question.getByRole('button').click(); + await question.locator('.ace-qa-answer').waitFor({ state: 'visible' }); + } + if (mode === 'final') await page.screenshot({ path: path.join(output, theme + '-collapsed.png'), fullPage: true }); + const summaryControl = summaries.getByRole('button'); + await summaryControl.focus(); + await page.keyboard.press('Enter'); + const details = page.locator('[data-activity-details-reveal="true"]'); + await details.waitFor({ state: 'visible' }); + await page.locator('[data-activity-details-reveal="true"].is-settled').waitFor({ state: 'visible' }); + const expectedDetails = ['先确认清理范围', '检查应用状态', + ...(mode === 'completion' ? [] : ['执行清理并检查结果'])]; + for (const expected of expectedDetails) { + await details.getByText(expected, { exact: true }).waitFor({ state: 'visible' }); + } + assert.equal(await details.locator('[data-ask-user-question-result="true"]').count(), 0); + assert.equal(await questions.count(), 2); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'horizontal overflow'); + if (mode === 'final') await page.screenshot({ path: path.join(output, theme + '-expanded.png'), fullPage: true }); + await summaryControl.click(); + await details.waitFor({ state: 'detached' }); + } + await page.close(); + } + assert.deepEqual(errors, []); + console.log(JSON.stringify({ passed: 8, output, checks: ['single-duration', 'standalone-answers', 'keyboard-expansion', 'activity-details', 'no-overflow'] })); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-side-chat-retention.mjs b/web/scripts/test-side-chat-retention.mjs new file mode 100644 index 00000000..0655d1df --- /dev/null +++ b/web/scripts/test-side-chat-retention.mjs @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = await mkdtemp(path.join(os.tmpdir(), 'ace-side-chat-retention-')); +const entry = ` +import React, {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {SettingsPage} from '/src/components/SettingsPage.jsx'; +import {DesktopContextMenu} from '/src/components/DesktopContextMenu.jsx'; +import {ThemeProvider, useTheme} from '/src/theme.jsx'; +import {Toaster} from '/src/components/Toast.jsx'; +import {i18n} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +function Harness() { + const [sid, setSid] = useState('retention-a'); + const [mounted, setMounted] = useState(true); + const [settings, setSettings] = useState(false); + const theme = useTheme(); + window.fixture = {switch: setSid, mount: setMounted, settings: setSettings, + theme: theme.set, locale: value => i18n.changeLanguage(value), + advance: ms => { window.sideChatNow += ms; }}; + return <> +
+ {mounted && } +
+ {settings && setSettings(false)}/>} + + ; +} +createRoot(document.getElementById('root')).render(); +`; +const html = '
'; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'side-chat-retention-fixture', + resolveId(id) { if (id === 'virtual:side-chat-retention') return '\0virtual:side-chat-retention'; }, + async load(id) { + if (id === '\0virtual:side-chat-retention') { + return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx' })).code; + } + }, + configureServer(vite) { + vite.middlewares.use('/side-chat-retention', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/side-chat-retention', html)); + }); + }, + }], +}); +const errors = []; +const sockets = []; +let browser; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'zh-CN' }); + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => { + window.sideChatNow = Date.now(); + Date.now = () => window.sideChatNow; + }); + await page.route('**/api/**', async route => { + const pathname = new URL(route.request().url()).pathname; + let body = {}; + if (pathname.endsWith('/messages')) body = { busy: false, messages: [ + { id: 'user', role: 'user', content: '主会话消息' }, + { id: 'answer', role: 'assistant', content: '主会话回答' }, + ], events: [] }; + else if (pathname.endsWith('/workspaces')) body = { workspaces: [] }; + else if (pathname.endsWith('/commands')) body = { builtins: [{ name: 'side', description: '侧边聊天' }], commands: [], skills: [] }; + else if (pathname.includes('/models')) body = { models: [], providers: [] }; + else if (pathname.includes('/experts')) body = { experts: [], items: [], capabilities: [] }; + else if (pathname.endsWith('/sessions')) body = { sessions: [] }; + else if (pathname.includes('/files')) body = { files: [], entries: [] }; + else if (pathname.includes('/git')) body = { is_repo: false, files: [], branches: [] }; + else if (pathname.endsWith('/ui-preferences')) body = { theme: 'light', color_theme: 'default', locale: 'zh-CN' }; + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.routeWebSocket('**/ws/**', ws => { + ws.onMessage(data => { + const message = JSON.parse(data); + if (message.type === 'side_chat_start') { + const payload = message.payload; + sockets.push({ payload, send(type, extra) { + ws.send(JSON.stringify({ type, payload: { request_id: payload.request_id, ...extra } })); + } }); + } + }); + }); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + await page.goto(`${base}/side-chat-retention`); + await page.locator('[contenteditable=true]').first().waitFor(); + const windowLocator = page.locator('[data-side-chat-window]'); + const input = page.getByRole('textbox', { name: '侧边聊天问题', exact: true }); + const open = async () => { + await page.getByRole('button', { name: '会话菜单', exact: true }).click(); + await page.getByRole('menuitem', { name: '侧边聊天', exact: true }).click(); + await windowLocator.waitFor(); + }; + const switchTo = async id => { + await page.evaluate(id => window.fixture.switch(id), id); + await page.waitForFunction(id => document.querySelector('[data-fixture-session]')?.dataset.fixtureSession === id, id); + }; + const advance = ms => page.evaluate(ms => window.fixture.advance(ms), ms); + const settings = async value => { + await page.evaluate(value => window.fixture.settings(value), value); + if (value) await page.waitForFunction(() => { + const mask = document.querySelector('[data-settings-mask]'); + const panel = document.querySelector('[data-settings-window]'); + return mask && panel && getComputedStyle(mask).opacity === '1' && getComputedStyle(panel).opacity === '1'; + }); + }; + async function until(fn) { + for (let i = 0; i < 100; i++) { + if (await fn()) return; + await page.waitForTimeout(50); + } + throw new Error('side chat fixture condition timed out'); + } + async function submit(question) { + const count = sockets.length; + await input.fill(question); + await input.press('Enter'); + await until(() => sockets.length === count + 1); + return sockets.at(-1); + } + await settings(true); + const retention = page.getByRole('spinbutton', { name: '侧边聊天存续时间' }); + await retention.waitFor(); + assert.equal(await retention.inputValue(), '30'); + assert.equal(await retention.getAttribute('min'), '1'); + await retention.fill('2'); + await retention.press('Tab'); + await until(() => page.evaluate(() => localStorage.getItem('ace.sideChatRetentionMinutes') === '2')); + await retention.fill(''); + await retention.press('Tab'); + assert.equal(await retention.inputValue(), '2'); + await retention.fill('0'); + await retention.press('Tab'); + assert.equal(await retention.inputValue(), '2'); + await settings(false); + await settings(true); + await retention.waitFor(); + assert.equal(await retention.inputValue(), '2'); + await page.screenshot({ path: path.join(output, 'settings-light-desktop.png') }); + await page.evaluate(() => window.fixture.theme('dark')); + await page.waitForFunction(() => document.documentElement.dataset.theme === 'dark'); + await page.setViewportSize({ width: 720, height: 1000 }); + await page.screenshot({ path: path.join(output, 'settings-dark-narrow.png') }); + const row = retention.locator('xpath=../..'); + assert.equal(await row.evaluate(element => element.scrollWidth <= element.clientWidth), true); + await settings(false); + await page.setViewportSize({ width: 1440, height: 1000 }); + + await open(); + const first = await submit('保留第一轮侧聊'); + first.send('side_chat_delta', { delta: '长回答内容' }); + await until(() => windowLocator.innerText().then(text => text.includes('长回答内容'))); + await advance(40 * 60_000); + assert.equal(await input.isDisabled(), true); + first.send('side_chat_done', { answer: '长回答内容', cancelled: false }); + await until(() => input.isEnabled()); + await input.fill('未发送的草稿'); + await switchTo('retention-b'); + await until(() => windowLocator.count().then(count => count === 0)); + await open(); + assert.equal(await windowLocator.locator('.ace-side-chat-turn').count(), 0); + await advance(60_000); + await switchTo('retention-a'); + await windowLocator.waitFor(); + assert.equal(await input.inputValue(), '未发送的草稿'); + assert.match(await windowLocator.innerText(), /长回答内容/); + await page.evaluate(() => window.fixture.mount(false)); + await until(() => windowLocator.count().then(count => count === 0)); + await page.evaluate(() => window.fixture.mount(true)); + await windowLocator.waitFor(); + assert.equal(await input.inputValue(), '未发送的草稿'); + const followup = await submit('继续追问'); + assert.deepEqual(followup.payload.history, [ + { role: 'user', content: '保留第一轮侧聊' }, + { role: 'assistant', content: '长回答内容' }, + ]); + followup.send('side_chat_done', { answer: '续聊回答', cancelled: false }); + await until(() => input.isEnabled()); + await advance(2 * 60_000); + assert.equal(await windowLocator.locator('.ace-side-chat-turn').count(), 2, 'visible state survives expiry'); + await page.screenshot({ path: path.join(output, 'side-chat-retained.png') }); + await switchTo('retention-b'); + await switchTo('retention-a'); + await until(() => windowLocator.count().then(count => count === 0)); + await open(); + assert.equal(await windowLocator.locator('.ace-side-chat-turn').count(), 0); + assert.equal(await input.inputValue(), ''); + const fresh = await submit('过期后的新聊天'); + assert.deepEqual(fresh.payload.history, []); + fresh.send('side_chat_delta', { delta: '停止前的部分回答' }); + await until(() => windowLocator.innerText().then(text => text.includes('停止前的部分回答'))); + await switchTo('retention-b'); + await switchTo('retention-a'); + await until(() => input.isEnabled()); + assert.match(await windowLocator.innerText(), /停止前的部分回答/); + assert.equal(await windowLocator.locator('[data-side-chat-status="stopped"]').count(), 1); + await page.getByRole('button', { name: '最小化侧边聊天', exact: true }).click(); + await switchTo('retention-b'); + await switchTo('retention-a'); + assert.equal(await windowLocator.count(), 0); + await settings(true); + await retention.fill('1'); + await retention.press('Tab'); + await settings(false); + await advance(60_000); + await switchTo('retention-b'); + await switchTo('retention-a'); + await open(); + assert.equal(await windowLocator.locator('.ace-side-chat-turn').count(), 0, 'latest preference applies'); + await page.getByRole('button', { name: '最小化侧边聊天', exact: true }).click(); + await page.reload(); + await page.locator('[contenteditable=true]').first().waitFor(); + await settings(true); + await retention.waitFor(); + assert.equal(await retention.inputValue(), '1', 'preference survives reload'); + await page.evaluate(() => window.fixture.locale('en-US')); + const english = page.getByRole('spinbutton', { name: 'Side chat retention time' }); + await english.waitFor(); + assert.equal(await english.inputValue(), '1'); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ ok: true, coverage: 'real SettingsPage and ChatView; settings persistence/validation; session isolation, history/draft/open restore, remount/StrictMode, long generation, expiry on return, interrupted output, newest preference, English, light/dark/narrow', output })); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-sidebar-session-tree.mjs b/web/scripts/test-sidebar-session-tree.mjs index fcc87ad1..c010fb1a 100644 --- a/web/scripts/test-sidebar-session-tree.mjs +++ b/web/scripts/test-sidebar-session-tree.mjs @@ -20,11 +20,15 @@ import {SESSION_LIST_CHANGED_EVENT} from '/src/lib/sessionListEvents.js'; import {validSidebarTreeDrop} from '/src/lib/sidebarSessionTree.js'; import '/src/styles/globals.css'; const initial = [ - {key:'session:n1',id:'n1',title:'普通会话一',children:[]}, - {key:'loop:daily',id:'run-new',loopId:'daily',title:'每日简报:最近执行',children:Array.from({length:8},(_,i)=>({key:'session:old-'+i,id:'old-'+i,loopId:'daily',title:'每日简报:历史 '+i,children:[]}))}, + {key:'session:n1',id:'n1',title:'普通会话一',worktree:{name:'fixture',branch:'fixture'},children:[]}, + {key:'loop:daily',id:'sidebar-group:loop:daily',group:true,loopId:'daily',title:'每日简报',children:[ + {key:'session:run-new',id:'run-new',loopId:'daily',title:'每日简报:最近执行',created:'2026-10-13T09:00:00Z',children:[]}, + ...Array.from({length:8},(_,i)=>({key:'session:old-'+i,id:'old-'+i,loopId:'daily',title:'每日简报:历史 '+i,created:'2026-10-'+String(12-i).padStart(2,'0')+'T09:00:00Z',children:[]}))]}, {key:'session:n2',id:'n2',title:'普通会话二',children:[]}, - {key:'loop:check',id:'check',loopId:'check',title:'依赖检查:最近执行',children:[{key:'session:check-old',id:'check-old',loopId:'check',title:'依赖检查:历史',children:[]}]}, - {key:'loop:single',id:'single',loopId:'single',title:'定时检查:首次执行',children:[]}, + {key:'loop:check',id:'sidebar-group:loop:check',group:true,loopId:'check',title:'依赖检查',children:[ + {key:'session:check',id:'check',loopId:'check',title:'依赖检查:最近执行',children:[]}, + {key:'session:check-old',id:'check-old',loopId:'check',title:'依赖检查:历史',children:[]}]}, + {key:'loop:single',id:'sidebar-group:loop:single',group:true,loopId:'single',title:'定时检查',children:[{key:'session:single',id:'single',loopId:'single',title:'定时检查:首次执行',children:[]}]}, ...Array.from({length:3},(_,i)=>({key:'session:n'+(i+3),id:'n'+(i+3),title:'普通会话 '+(i+3),children:[]})) ]; let roots=JSON.parse(localStorage.getItem('tree-fixture')||'null')||initial; @@ -33,14 +37,19 @@ const locate=key=>{ for(const root of roots){if(root.key===key)return {node:root,parent:null,siblings:roots}; const node=root.children.find(child=>child.key===key);if(node)return {node,parent:root,siblings:root.children};} }; -window.calls=[];window.moves=[]; +window.calls=[];window.moves=[];window.reads=[];window.retained=[]; const payload=(opts={})=>{ const expanded=new Set(opts.expanded||[]); let limit=opts.limit||roots.length; const reveal=roots.findIndex(root=>root.id===opts.reveal||root.children.some(child=>child.id===opts.reveal)); if(reveal>=0){limit=Math.max(limit,reveal+1);if(roots[reveal].id!==opts.reveal)expanded.add(roots[reveal].key);} const rows=[]; - function emit(node,parent,rank){rows.push({id:node.id,title:node.title,workspace_hash:'w',active:true,created_at:'2026-10-13T09:00:00Z',updated_at:'2026-10-13T09:00:00Z', + function emit(node,parent,rank){const latest=node.group?node.children.reduce((a,b)=>(b.created||'')>(a.created||'')?b:a):node; + rows.push({id:node.id,title:node.title,workspace_hash:'w',cwd:'/fixture',active:!node.group,created_at:latest.created||'2026-10-13T09:00:00Z',updated_at:latest.created||'2026-10-13T09:00:00Z', + attention_state:node.state||'read',busy:node.state==='in_progress', + ...(node.worktree?{worktree:node.worktree}:{}), + ...(node.group?{sidebar_group:true,sidebar_group_name:node.title,sidebar_latest_session_id:latest.id, + sidebar_members:node.children.map(child=>({id:child.id,state:child.state||'read',busy:child.state==='in_progress',workspace_hash:'w',cwd:'/fixture'}))}:{}), sidebar_key:node.key,sidebar_parent_key:parent,sidebar_child_count:node.children.length,sidebar_rank:rank,sidebar_expanded:expanded.has(node.key), ...(node.loopId?{loop_execution:{loop_id:node.loopId}}:{})});} roots.slice(0,limit).forEach((root,rank)=>{emit(root,'',rank);if(expanded.has(root.key))root.children.forEach((child,index)=>emit(child,root.key,index));}); @@ -48,16 +57,21 @@ const payload=(opts={})=>{ }; Object.assign(api,{ listWorkspaces:async()=>[{hash:'w',cwd:'/fixture',name:'acecode',active:true}], - listSidebarSessions:async(hash,opts)=>{window.calls.push({hash,opts});return hash?payload(opts):{sidebar_tree:true,sessions:[],total:0,has_more:false,visible_roots:0};}, getPinnedSessions:async()=>({session_ids:[]}),getNoWorkspacePinnedSessions:async()=>({session_ids:[]}),getPinnedSessionOrder:async()=>({items:[]}), getOpencodeImportPreview:async()=>({available:false,count:0}), moveSidebarSession:async(hash,move,opts)=>{ const source=locate(move.source),target=locate(move.target); - if(!validSidebarTreeDrop({key:source.node.key,parent:source.parent?.key,childCount:source.node.children.length}, + if(!validSidebarTreeDrop({key:source.node.key,parent:source.parent?.key,childCount:source.node.children.length,group:source.node.group}, {key:target.node.key,parent:target.parent?.key,scheduled:Boolean(target.node.loopId)},move.placement, target.parent?{key:target.parent.key,scheduled:Boolean(target.parent.loopId)}:null))throw Error('invalid tree'); source.siblings.splice(source.siblings.indexOf(source.node),1); - const into=move.placement==='inside'?target.node:target.parent; + let into=move.placement==='inside'?target.node:target.parent; + if(move.placement==='inside'&&!into.group){ + const original=into; + into={key:'group:'+original.id,id:'sidebar-group:group:'+original.id,group:true,loopId:original.loopId, + title:{daily:'每日简报',check:'依赖检查',single:'定时检查'}[original.loopId],children:[original]}; + roots.splice(roots.indexOf(original),1,into); + } const siblings=into?into.children:roots; const at=move.placement==='inside'?siblings.length:siblings.indexOf(target.node)+(move.placement==='after'?1:0); siblings.splice(at,0,source.node);persist();window.moves.push(move); @@ -66,12 +80,27 @@ Object.assign(api,{ }, }); for(const name of ['reconfigure','retainSession','releaseSession','subscribeWorkspaceStatus','unsubscribeWorkspaceStatus'])connection[name]=()=>{}; -window.fetch=async()=>new Response('{}',{headers:{'Content-Type':'application/json'}}); +connection.markSessionRead=payload=>window.reads.push(payload); +connection.retainSession=id=>window.retained.push(id); +// Exercise the production URL builder; mocking listSidebarSessions itself hides +// missing query parameters on old WebViews (notably URLSearchParams.size). +window.fetch=async(input)=>{ + const url=new URL(input,location.href); + let body={}; + if(url.pathname.endsWith('/session-sidebar')){ + const hash=url.pathname.startsWith('/api/workspaces/')?'w':''; + const opts={limit:Number(url.searchParams.get('limit'))||undefined, + expanded:JSON.parse(url.searchParams.get('expanded')||'[]'),reveal:url.searchParams.get('reveal')||undefined}; + window.calls.push({hash,opts,url:url.pathname+url.search}); + body=hash?payload(opts):{sidebar_tree:true,sessions:[],total:0,has_more:false,visible_roots:0}; + } + return new Response(JSON.stringify(body),{headers:{'Content-Type':'application/json'}}); +}; function App(){ const [active,setActive]=useState(null); window.fixture={roots:()=>roots,select:(id)=>setActive({sessionId:id,workspaceHash:'w',active:true}), - attention(id,state){connection.dispatchEvent(new CustomEvent('message',{detail:{type:'session_status',payload:{session_id:id,workspace_hash:'w',state,timestamp_ms:Date.now()}}}));}, - addRun(){const root=roots.find(item=>item.key==='loop:daily');root.children.unshift({id:root.id,key:'session:'+root.id,loopId:root.loopId,title:root.title,children:[]});root.id='run-next';root.title='每日简报:新执行';persist();window.dispatchEvent(new CustomEvent(SESSION_LIST_CHANGED_EVENT,{detail:{workspaceHash:'w'}}));}}; + attention(id,state){const found=locate('session:'+id);if(found)found.node.state=state;connection.dispatchEvent(new CustomEvent('message',{detail:{type:'session_status',payload:{session_id:id,workspace_hash:'w',cwd:'/fixture',state,timestamp_ms:Date.now()}}}));}, + addRun(){const root=roots.find(item=>item.key==='loop:daily');root.children.unshift({id:'run-next',key:'session:run-next',loopId:root.loopId,title:'每日简报:新执行',created:'2026-10-14T09:00:00Z',children:[]});persist();window.dispatchEvent(new CustomEvent(SESSION_LIST_CHANGED_EVENT,{detail:{workspaceHash:'w'}}));}}; return
{active?.sessionId||'选择会话'}
; } createRoot(document.getElementById('root')).render(); @@ -94,23 +123,42 @@ try { browser=await chromium.launch({headless:true,...(process.env.ACE_CHROMIUM_EXECUTABLE?{executablePath:process.env.ACE_CHROMIUM_EXECUTABLE}:{})}); const page=await browser.newPage({viewport:{width:1100,height:1000}}); page.on('pageerror',error=>errors.push(error.message)); - const row=id=>page.locator('.ace-sidebar-session-row[data-desktop-session-id="'+id+'"]'); + const row=id=>page.locator('.ace-sidebar-session-row[data-sidebar-tree-key="'+(/^(loop:|group:)/.test(id)?id:'session:'+id)+'"]'); const select=id=>row(id).locator('.ace-sidebar-session-title-button').click(); const assertParentIcons=async(id,expanded,attention='read')=>{ assert.equal(await row(id).locator('[data-icon-name="Alarm"]').evaluate(el=>getComputedStyle(el).opacity),'1', 'scheduled provenance must stay visible while idle, hovered, focused or selected'); const arrow=row(id).locator('.ace-sidebar-session-tree-chevron'); - if(attention==='read'){ + if(attention!=='unread'){ assert.equal(await arrow.evaluate(el=>getComputedStyle(el).opacity),'1','read parents always show their disclosure'); assert.equal(await arrow.evaluate(el=>el.classList.contains('is-expanded')),expanded); }else{ - assert.equal(await arrow.count(),0,'unread and running indicators take precedence over the arrow'); - const status=row(id).locator('.ace-sidebar-session-tree-indicator [title="'+(attention==='unread'?'未读':'进行中')+'"]'); + assert.equal(await arrow.count(),0,'unread children take precedence over the group arrow'); + const status=row(id).locator('.ace-sidebar-session-tree-indicator [title="未读"]'); assert.equal(await status.isVisible(),true); assert.equal(await status.evaluate(el=>getComputedStyle(el).opacity),'1'); } assert.equal(await row(id).locator('.ace-sidebar-session-title-button').getAttribute('aria-expanded'),String(expanded)); assert.equal(await row(id).locator('.ace-sidebar-session-tree-idle-lamp').count(),0,'parents must not add a gray placeholder dot'); + assert.equal(await row(id).locator('.ace-spinner').count(),0,'group headers never show a running indicator'); + assert.equal(await row(id).locator('[data-sidebar-row-control]').count(),0,'group headers do not expose real-session mutation controls'); + }; + const assertSessionActionIcons=async(id,marker,hovered)=>{ + const target=row(id); + await target.locator('.ace-sidebar-session-source-icon').evaluate((el,expected)=>new Promise((resolve,reject)=>{ + const deadline=performance.now()+2000; + const check=()=>getComputedStyle(el).opacity===expected?resolve() + :performance.now()>deadline?reject(new Error('source icon did not reach opacity '+expected)):requestAnimationFrame(check); + check(); + }),hovered?'0':'1'); + assert.equal(await target.locator('.ace-sidebar-session-archive-icon').evaluate(el=>getComputedStyle(el).opacity),hovered?'1':'0'); + const icons=await target.locator('[data-icon-name]').evaluateAll(elements=>elements + .filter(el=>el.closest('.ace-sidebar-row-hover-action')&&el.getBoundingClientRect().width>0&&Number(getComputedStyle(el).opacity)>0.99) + .map(el=>el.dataset.iconName)); + assert.deepEqual(icons,hovered?['Pin','Archive']:[marker], + 'real sessions share the marker/archive position; hover must not add a third icon'); + assert.equal(await target.locator('[data-icon-name="'+marker+'"]').count(),1, + 'the source marker must not be rendered again outside the shared action position'); }; const drag=async(source,target,placement='inside',allowed=true)=>{ const a=await row(source).boundingBox(),b=await row(target).boundingBox(); @@ -121,22 +169,36 @@ try { await page.mouse.up(); }; await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/sidebar-tree-fixture`); - await row('run-new').waitFor(); + await row('loop:daily').waitFor(); await page.mouse.move(950,750); - await assertParentIcons('run-new',false); + await assertParentIcons('loop:daily',false); assert.equal(await page.locator('.ace-sidebar-session-row').count(),5); - await select('run-new');await row('old-7').waitFor(); + await select('loop:daily');await row('old-7').waitFor(); + await row('run-new').waitFor(); + assert.equal(await row('loop:daily').locator('.ace-sidebar-session-title-button').textContent(),'每日简报'); + assert.equal(await page.locator('[data-selected]').textContent(),'选择会话'); + assert.equal(await page.evaluate(()=>window.reads.length),0,'opening a group must not mark any execution as read'); + assert.equal(await row('loop:daily').getAttribute('data-desktop-session-id'),null); + assert.ok(await page.evaluate(()=>window.calls.some(call=>call.opts.expanded?.includes('loop:daily'))), + 'expansion must reach the server through the production request URL'); await page.mouse.move(950,750); - await assertParentIcons('run-new',true); - assert.equal(await page.locator('.ace-sidebar-session-row').count(),13); + await assertParentIcons('loop:daily',true); + assert.equal(await page.locator('.ace-sidebar-session-row').count(),14); assert.equal(await page.locator('.ace-sidebar-session-tree-node').count(),5); await select('old-3'); assert.equal(await page.locator('[data-selected]').textContent(),'old-3'); assert.equal(await row('run-new').count(),1); - await select('run-new');await row('old-7').waitFor({state:'hidden'}); - await assertParentIcons('run-new',false); - await select('run-new');await row('old-7').waitFor(); - await drag('run-new','n1','before'); + await select('loop:daily');await row('old-7').waitFor({state:'hidden'}); + await assertParentIcons('loop:daily',false); + await page.evaluate(()=>window.fixture.attention('old-6','unread')); + await row('loop:daily').locator('[title="未读"]').waitFor(); + await assertParentIcons('loop:daily',false,'unread'); + await page.evaluate(()=>window.fixture.attention('old-6','in_progress')); + await row('loop:daily').locator('.ace-sidebar-session-tree-chevron').waitFor(); + await assertParentIcons('loop:daily',false,'in_progress'); + await page.evaluate(()=>window.fixture.attention('old-6','read')); + await select('loop:daily');await row('old-7').waitFor(); + await drag('loop:daily','n1','before'); await page.waitForFunction(()=>window.moves.length===1); assert.equal((await page.evaluate(()=>window.fixture.roots()))[0].key,'loop:daily'); await drag('old-1','n1','before'); @@ -144,15 +206,15 @@ try { assert.equal(await row('old-1').getAttribute('data-sidebar-tree-parent'),''); await page.reload();await row('old-1').waitFor(); assert.equal(await row('old-1').getAttribute('data-sidebar-tree-parent'),''); - await drag('old-1','run-new'); + await drag('old-1','loop:daily'); await page.waitForFunction(()=>window.moves.length===1); await row('old-1').waitFor(); assert.equal(await row('old-1').getAttribute('data-sidebar-tree-parent'),'loop:daily'); - await drag('old-2','check'); + await drag('old-2','loop:check'); await page.waitForFunction(()=>window.moves.length===2); assert.equal(await row('old-2').getAttribute('data-sidebar-tree-parent'),'loop:check'); - await drag('run-new','check'); - await drag('run-new','old-2','before'); + await drag('loop:daily','loop:check'); + await drag('loop:daily','old-2','before'); await drag('old-3','old-2'); assert.equal(await page.evaluate(()=>window.moves.length),2,'nested parents and third-level drops must be rejected'); await drag('old-3','n2','inside',false); @@ -160,7 +222,7 @@ try { assert.equal(await page.evaluate(()=>window.moves.length),2,'ordinary sessions cannot receive scheduled or ordinary leaves'); assert.equal(await row('n1').getAttribute('data-sidebar-tree-children'),'0'); assert.equal(await row('n2').getAttribute('data-sidebar-tree-children'),'0'); - await drag('n2','single'); + await drag('n2','loop:single'); await page.waitForFunction(()=>window.moves.length===3); assert.equal(await row('n2').getAttribute('data-sidebar-tree-parent'),'loop:single'); await drag('old-2','n1','before'); @@ -168,44 +230,83 @@ try { assert.equal(await row('old-2').getAttribute('data-sidebar-tree-scheduled'),'true'); await drag('n2','old-2'); await page.waitForFunction(()=>window.moves.length===5); - assert.equal(await row('n2').getAttribute('data-sidebar-tree-parent'),'session:old-2'); + assert.equal(await row('n2').getAttribute('data-sidebar-tree-parent'),'group:old-2'); + assert.equal(await row('old-2').getAttribute('data-sidebar-tree-parent'),'group:old-2'); + assert.equal(await row('group:old-2').locator('.ace-sidebar-session-title-button').textContent(),'每日简报'); await page.evaluate(()=>window.fixture.addRun());await row('run-next').waitFor(); assert.equal(await row('run-new').getAttribute('data-sidebar-tree-parent'),'loop:daily'); - await page.reload();await row('run-next').waitFor(); + assert.equal(await row('loop:daily').locator('.ace-sidebar-session-title-button').textContent(),'每日简报'); + await page.reload();await row('loop:daily').waitFor(); await page.evaluate(()=>window.fixture.select('old-7'));await row('old-7').waitFor(); for(const theme of ['light','dark']){ await page.evaluate(mode=>document.documentElement.dataset.theme=mode,theme); + for(const [id,marker] of [['run-next','Alarm'],['n1','Worktree']]){ + await page.evaluate(()=>document.activeElement?.blur()); + await page.mouse.move(950,750); + await assertSessionActionIcons(id,marker,false); + await row(id).hover(); + await assertSessionActionIcons(id,marker,true); + await assertParentIcons('loop:daily',true); + await page.screenshot({path:path.join(output,theme+'-'+marker.toLowerCase()+'-hover.png'),animations:'disabled'}); + await page.mouse.move(950,750); + await row(id).locator('.ace-sidebar-session-title-button').focus(); + await page.keyboard.press('Tab'); + assert.equal(await row(id).getByRole('button',{name:'置顶',exact:true}).evaluate(el=>el===document.activeElement),true); + await assertSessionActionIcons(id,marker,true); + await page.keyboard.press('Tab'); + assert.equal(await row(id).getByRole('button',{name:marker==='Alarm'?'定时任务,归档':'工作树会话,归档',exact:true}).evaluate(el=>el===document.activeElement),true); + await assertSessionActionIcons(id,marker,true); + await page.evaluate(()=>document.activeElement?.blur()); + await assertSessionActionIcons(id,marker,false); + } await page.mouse.move(950,750); - await assertParentIcons('run-next',true); - await row('run-next').hover(); - await assertParentIcons('run-next',true); - assert.equal(await row('run-next').getByRole('button',{name:'定时任务,归档',exact:true}).isVisible(),true); + await page.waitForFunction(()=>getComputedStyle(document.querySelector('[data-sidebar-tree-key="session:n1"]')).backgroundColor==='rgba(0, 0, 0, 0)',null,{timeout:2000}); + assert.equal(await row('old-0').evaluate(el=>getComputedStyle(el).backgroundColor),'rgba(0, 0, 0, 0)', + 'the group supplies its background; ordinary children remain transparent'); + assert.equal(await row('old-0').evaluate(el=>getComputedStyle(el).backgroundColor), + await row('n1').evaluate(el=>getComputedStyle(el).backgroundColor)); + await assertParentIcons('loop:daily',true); + const appearance=await page.evaluate(()=>{ + const header=document.querySelector('[data-sidebar-tree-key="loop:daily"]'); + const child=document.querySelector('[data-sidebar-tree-key="session:old-7"]'); + const children=child.closest('.ace-sidebar-session-tree-children'); + return {headerX:header.getBoundingClientRect().x,childX:child.getBoundingClientRect().x, + padding:getComputedStyle(children).paddingLeft,border:getComputedStyle(children).borderLeftWidth, + group:getComputedStyle(header.parentElement).backgroundColor,selected:getComputedStyle(child).backgroundColor}; + }); + assert.equal(appearance.headerX,appearance.childX);assert.equal(appearance.padding,'0px');assert.equal(appearance.border,'0px'); + assert.notEqual(appearance.group,'rgba(0, 0, 0, 0)');assert.notEqual(appearance.selected,appearance.group); + await row('loop:daily').hover(); + await assertParentIcons('loop:daily',true); await page.screenshot({path:path.join(output,theme+'.png'),animations:'disabled'}); await page.evaluate(()=>window.fixture.attention('check','unread')); - await row('check').locator('[title="未读"]').waitFor(); - await assertParentIcons('check',false,'unread'); - await row('check').hover(); - await assertParentIcons('check',false,'unread'); - await row('check').locator('.ace-sidebar-session-title-button').focus(); - await assertParentIcons('check',false,'unread'); + await row('loop:check').locator('[title="未读"]').waitFor(); + await assertParentIcons('loop:check',false,'unread'); + await row('loop:check').hover(); + await assertParentIcons('loop:check',false,'unread'); + await row('loop:check').locator('.ace-sidebar-session-title-button').focus(); + await assertParentIcons('loop:check',false,'unread'); await page.screenshot({path:path.join(output,theme+'-unread.png'),animations:'disabled'}); await page.evaluate(()=>window.fixture.attention('check','in_progress')); + await row('loop:check').locator('.ace-sidebar-session-tree-chevron').waitFor(); + await assertParentIcons('loop:check',false,'in_progress'); + await select('loop:check'); await row('check').getByRole('status',{name:'进行中'}).waitFor(); - await assertParentIcons('check',false,'in_progress'); + await assertParentIcons('loop:check',true,'in_progress'); + await select('loop:check'); await page.evaluate(()=>window.fixture.attention('check','read')); - await row('check').locator('.ace-sidebar-session-tree-chevron').waitFor(); - await assertParentIcons('check',false); + await assertParentIcons('loop:check',false); } await page.mouse.move(950,750); await row('run-next').locator('.ace-sidebar-session-title-button').focus(); await page.keyboard.press('Tab'); - await assertParentIcons('run-next',true); + await assertParentIcons('loop:daily',true); await page.keyboard.press('Tab'); assert.equal(await row('run-next').getByRole('button',{name:'定时任务,归档',exact:true}).evaluate(el=>el===document.activeElement),true); - await assertParentIcons('run-next',true); + await assertParentIcons('loop:daily',true); await page.setViewportSize({width:390,height:844}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); - await assertParentIcons('run-next',true); + await assertParentIcons('loop:daily',true); await page.screenshot({path:path.join(output,'narrow.png'),animations:'disabled'}); await page.evaluate(()=>window.fixture.select('missing-session')); await page.waitForFunction(()=>window.calls.some(call=>call.opts?.reveal==='missing-session')); @@ -213,5 +314,7 @@ try { assert.ok(await page.evaluate(()=>window.calls.filter(call=>call.opts?.reveal==='missing-session').length)<4, 'a stale navigation target must not trigger a request/render loop'); assert.deepEqual(errors,[]); - console.log(JSON.stringify({result:'PASS',output,scenarios:18})); + assert.equal(await page.evaluate(()=>window.retained.some(id=>id.startsWith('sidebar-group:'))),false); + assert.equal(await page.evaluate(()=>window.reads.some(item=>item.sessionId?.startsWith('sidebar-group:'))),false); + console.log(JSON.stringify({result:'PASS',browser:browser.version(),output,scenarios:28})); } finally { await browser?.close(); await server.close(); } diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 77e01814..80a6b232 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -67,7 +67,13 @@ import { ExpertPickerDialog } from './ExpertCatalog.jsx'; import { QueueCardList } from './QueueCardList.jsx'; import { TaskSuggestionCards } from './TaskSuggestionCards.jsx'; import { SideChatWindow } from './SideChatWindow.jsx'; -import { createSideChatController } from '../lib/sideChatController.js'; +import { sideChatSessionKey, sideChatSessions } from '../lib/sideChatSessions.js'; +import { readWithFallback } from '../lib/usePreference.js'; +import { + DEFAULT_SIDE_CHAT_RETENTION_MINUTES, + isSideChatRetentionMinutes, + SIDE_CHAT_RETENTION_KEY, +} from '../lib/sideChatRetention.js'; import '../styles/side-chat.css'; import { GitSessionPill } from './GitSessionPill.jsx'; import { SessionTitleBar } from './SessionTitleBar.jsx'; @@ -1010,14 +1016,20 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses const getQueueSnapshot = useCallback(() => queueStore.getState(), [queueStore]); const queueState = useSyncExternalStore(subscribeQueueStore, getQueueSnapshot, getQueueSnapshot); const [sideChatAnchor, setSideChatAnchor] = useState(null); - const sideChat = useMemo(() => createSideChatController({ - startStream: (options) => api.streamSideChat(sid, options), - }), [api, sid]); + const sideChatKey = sideChatSessionKey(ref); + const sideChat = useMemo(() => sideChatSessions.get( + sideChatKey, (options) => api.streamSideChat(sid, options), + ), [api, sid, sideChatKey]); const sideChatState = useSyncExternalStore( sideChat.subscribe, sideChat.getSnapshot, sideChat.getSnapshot, ); - // Reset cancels the old request at the session boundary and is StrictMode-safe. - useLayoutEffect(() => () => sideChat.reset(), [sideChat]); + // Expiry is checked on return, before paint; leaving stops only generation. + useLayoutEffect(() => { + sideChat.resume(readWithFallback( + SIDE_CHAT_RETENTION_KEY, DEFAULT_SIDE_CHAT_RETENTION_MINUTES, isSideChatRetentionMinutes, + )); + return () => sideChat.suspend(); + }, [sideChat]); const turnInterruptInFlightRef = useRef(new Set()); // GitSessionPill 的待生效意图(worktree 勾选 + 基线分支)。ref 不入 dep: // 只在发送首条消息那一刻读取,不驱动渲染。 diff --git a/web/src/components/FeedbackForm.jsx b/web/src/components/FeedbackForm.jsx index bd6540ec..99f45d44 100644 --- a/web/src/components/FeedbackForm.jsx +++ b/web/src/components/FeedbackForm.jsx @@ -125,7 +125,7 @@ export function FeedbackForm({ onClose }) { {sessions.map((item) => )} -

默认附带近期日志,会话仅在选择后附带。

+

默认附带近期日志;选择会话后,同时附带该会话的记录和发给模型的请求原文。

{sessionsError &&

加载会话失败:{sessionsError}

} {submitError &&

上传失败:{submitError}

} diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index 9d5fc7d7..f5ea1e32 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -59,6 +59,7 @@ import { ToolRewriteSettings } from './ToolRewriteSettings.jsx'; import { SecurityCenterSettings } from './SecurityCenterSettings.jsx'; import { ChannelsSettings } from './ChannelsSettings.jsx'; import { ShortcutsSettings } from './ShortcutsSettings.jsx'; +import { SideChatRetentionSettings } from './SideChatRetentionSettings.jsx'; import { MemorySettings } from './MemorySettings.jsx'; import { clsx, formatCount, relativeTime } from '../lib/format.js'; import { lookupErrorMessage } from '../lib/errors.js'; @@ -1016,6 +1017,8 @@ function SectionGeneral({
+ +
String(minutes)); + useEffect(() => setInput(String(minutes)), [minutes]); + + return ( +
+
+ +

+ 从最后一次侧聊结束开始计时,到期后切回该会话时清空;正在查看时不会自动消失。 +

+
+
+ { + const next = event.target.value; + setInput(next); + if (isSideChatRetentionMinutes(Number(next))) setMinutes(Number(next)); + }} + onBlur={() => setInput(String(minutes))} + onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur(); }} + className="w-20 h-7 px-2 text-[12px] text-center rounded-md border border-border bg-surface-alt text-fg outline-none focus:border-accent transition" + /> + 分钟 +
+
+ ); +} diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 732bcc6f..e0e6ef34 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -60,7 +60,7 @@ import { SessionTitleText } from './SessionTitleText.jsx'; import { pushTrayMenu } from '../lib/desktopTrayMenu.js'; import { desktopTaskbarBadge, desktopTaskbarBadgeAvailable } from '../lib/desktopTaskbarBadge.js'; import { usePreference } from '../lib/usePreference.js'; -import { sidebarNodeKey, sidebarSessionRoots, sidebarTreeDropTarget } from '../lib/sidebarSessionTree.js'; +import { applySidebarSessionStatuses, mergeSidebarSessionStatuses, sidebarNodeKey, sidebarSessionRoots, sidebarSessionStatusRows, sidebarTreeDropTarget } from '../lib/sidebarSessionTree.js'; import { createWorkspaceFolderOrderController } from '../lib/workspaceFolderOrder.js'; import { useWorkspaceFolderReorder } from '../lib/useWorkspaceFolderReorder.js'; import '../styles/workspaceFolderReorder.css'; @@ -75,7 +75,6 @@ import { applyStatusSnapshot, applyStatusUpdate, mergeSessionStatus, - mergeSessionsWithStatus, optimisticReadStatus, optimisticUnreadStatus, statusCursor, @@ -353,6 +352,7 @@ function workspaceDropTargetForPointer(clientY, workspaceHash, sourceId, clientX parent: row.dataset.sidebarTreeParent || '', childCount: Number(row.dataset.sidebarTreeChildren) || 0, scheduled: row.dataset.sidebarTreeScheduled === 'true', + group: row.dataset.sidebarTreeGroup === 'true', id: row.dataset.sidebarWorkspaceSessionId, dragKey: row.dataset.sidebarWorkspaceSessionKey, top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, @@ -805,6 +805,7 @@ function SessionRow({ treeExpanded = false, onToggleTree, }) { + const isGroup = s.sidebar_group === true; const attention = s.attention_state || s.read_state || 'read'; const meta = attentionMeta(attention); const workspaceHash = s.workspace_hash || s.workspaceHash || ''; @@ -817,12 +818,14 @@ function SessionRow({ // 侧栏与顶部标题栏显示同一个字段:服务端 title(用户改名 / 大模型生成), // 没有就用 summary(最近一条用户消息显示文本的 80 字节截断)。这里不再另拉 // 消息全文做跑马灯 —— 那会让侧栏 hover 时滚出一段与顶部不一致、且长度不受限的文本。 - const title = sessionDisplayTitle(s, s.name || ''); + const title = isGroup ? s.sidebar_group_name || tr('定时任务') : sessionDisplayTitle(s, s.name || ''); const titleAnimation = useSessionTitleAnimation(s, title); const sessionMarker = sidebarSessionMarker(s); + const archiveMarkerIcon = sessionMarker === 'loop' ? 'alarm' : sessionMarker === 'worktree' ? 'worktree' : null; const hasTreeChildren = !pinned && Number(s.sidebar_child_count) > 0; const showTreeArrow = hasTreeChildren && attention !== 'in_progress' && attention !== 'unread'; const selectRow = () => { + if (isGroup) { onToggleTree?.(s); return; } onSelect?.(s); if (hasTreeChildren) onToggleTree?.(s); }; @@ -978,6 +981,7 @@ function SessionRow({ }; useEffect(() => { + if (isGroup) return undefined; const handler = (event) => { const detail = event.detail || {}; const { action, target } = detail; @@ -997,19 +1001,20 @@ function SessionRow({ }; window.addEventListener(DESKTOP_CONTEXT_ACTION_EVENT, handler); return () => window.removeEventListener(DESKTOP_CONTEXT_ACTION_EVENT, handler); - }, [onArchive, onSelect, s, title, workspaceHash]); + }, [isGroup, onArchive, onSelect, s, title, workspaceHash]); return (
)} - {pinEnabled && ( + {pinEnabled && !isGroup && ( )} - - {sessionMarker === 'loop' && ( + } + {isGroup && sessionMarker === 'loop' && ( )} @@ -1421,6 +1427,7 @@ function SessionTreeRows({ sessions, expandedGroups, dragState, children }) { const open = expandedGroups?.has(key) ? expandedGroups.get(key) : session.sidebar_expanded === true; return (
@@ -2476,12 +2483,7 @@ export function Sidebar({ refreshNoWorkspace: false, appendNewSessions, })); - setStatusBySession((prev) => incoming.reduce((map, session) => applyStatusUpdate(map, { - ...session, - session_id: session.id, - state: session.attention_state || session.read_state, - cursor: session.status_cursor, - }), prev)); + setStatusBySession((prev) => applySidebarSessionStatuses(prev, incoming)); setSessionWorkspacesLoaded([hash], true); // 后端截断时 total 只是上界,拿 length 比它会把「已全量」判错;has_more // 才是权威信号。 @@ -2541,6 +2543,7 @@ export function Sidebar({ setSessions(previous => retainUnrefreshedSidebarSessions(previous, incoming, { refreshNoWorkspace: true, pinnedByWorkspace: pinnedByWorkspaceRef.current, })); + setStatusBySession((prev) => applySidebarSessionStatuses(prev, incoming)); }; treeReloadRef.current = async (scope) => { if (scope !== NO_WORKSPACE_PIN_SCOPE) return loadWorkspaceSessions(scope, { silent: true }); @@ -2825,7 +2828,7 @@ export function Sidebar({ const noWorkspaceIncoming = (noWorkspaceRaw?.sessions || []) .filter(isNoWorkspaceSession) .map(normalizeNoWorkspaceSession); - if (noWorkspaceRaw) desktopTaskbarBadge.replaceScope('', noWorkspaceIncoming); + if (noWorkspaceRaw) desktopTaskbarBadge.replaceScope('', sidebarSessionStatusRows(noWorkspaceIncoming)); const incomingWorkspaceSessions = perWorkspace.flatMap((item) => ( (Array.isArray(item.sessions) ? item.sessions : []) .filter((session) => !isNoWorkspaceSession(session)) @@ -2850,12 +2853,7 @@ export function Sidebar({ pinnedByWorkspace: pinnedByWorkspaceRef.current, refreshNoWorkspace: !!noWorkspaceRaw, })); - setStatusBySession((prev) => incoming.reduce((map, s) => applyStatusUpdate(map, { - ...s, - session_id: s.id, - state: s.attention_state || s.read_state, - cursor: s.status_cursor, - }), prev)); + setStatusBySession((prev) => applySidebarSessionStatuses(prev, incoming)); syncRetainedSessionIds(incoming.filter((s) => s.active && s.id).map((s) => s.id)); } catch { /* 鉴权失败不致命 */ } @@ -3045,7 +3043,7 @@ export function Sidebar({ }, []); const renderedSessions = useMemo( - () => withNewSessionDisplayTitles(mergeSessionsWithStatus(sessions, statusBySession)), + () => withNewSessionDisplayTitles(mergeSidebarSessionStatuses(sessions, statusBySession)), [sessions, statusBySession], ); useEffect(() => { diff --git a/web/src/components/model-settings/ModelProfileDialog.jsx b/web/src/components/model-settings/ModelProfileDialog.jsx index b0fb2bd6..7d28bd86 100644 --- a/web/src/components/model-settings/ModelProfileDialog.jsx +++ b/web/src/components/model-settings/ModelProfileDialog.jsx @@ -13,6 +13,7 @@ import { modelAliasProviderName, modelFieldPolicy, modelNameSuggestion, + modelSamplingFieldSupport, redactModelDraftSecrets, syncAutoModelAlias, toggleModelCapability, @@ -42,6 +43,47 @@ function fieldLabel(id, label, optional = false) { ); } +const SAMPLING_INPUTS = [ + { field: 'temperature', label: 'Temperature', min: 0, step: '0.01' }, + { field: 'top_p', label: 'Top P', min: 0, max: 1, step: '0.01' }, + { field: 'top_k', label: 'Top K', min: 1, step: '1' }, + { field: 'min_p', label: 'Min P', min: 0, max: 1, step: '0.01' }, +]; + +// 采样参数:只渲染当前 Provider / API 协议真正会发出去的字段。 +function ModelSamplingFields({ draft, provider, support, onPatchDraft }) { + const visible = SAMPLING_INPUTS.filter((input) => support[input.field]); + if (visible.length === 0) return null; + const temperatureMax = provider?.runtime_provider === 'anthropic' ? 1 : 2; + return ( +
+ 采样参数 +
+ {visible.map((input) => ( +
+ {fieldLabel(`model-sampling-${input.field}`, input.label, true)} + onPatchDraft({ [input.field]: event.target.value })} + className={inputClass()} + placeholder="服务端默认" + /> +
+ ))} +
+

+ 留空则不发送,由服务端决定。 + {support.min_p && 'Top K、Min P 是 vLLM、SGLang 等推理服务的扩展参数,官方 OpenAI 接口不支持,按需填写。'} +

+
+ ); +} + function ModelApiProtocolField({ draft, onPatchDraft }) { return (
@@ -410,6 +452,7 @@ export function ModelProfileDialog({ const updateReasoning = (patch) => patchMetadataDraft({ reasoning: { ...reasoning, ...patch }, }); + const samplingSupport = modelSamplingFieldSupport(draft, provider); const title = editing ? `编辑模型 · ${originalName}` : '新增模型'; return ( @@ -613,6 +656,13 @@ export function ModelProfileDialog({ )}
+ +
模型能力
diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 1c96ccdc..59b52672 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -278,6 +278,7 @@ export const sourceCatalogs = { "s_19107d868966ff2c": "会话标题", "s_194b5dba3c63f537": "推理设置", "s_1964dafada0807b5": "Git 分支", + "s_19abb552de9a904d": "默认附带近期日志;选择会话后,同时附带该会话的记录和发给模型的请求原文。", "s_19af3dcd414d81df": "{{p0}} 个附件", "s_19bf536853e2e78a": "协作", "s_19bfb1631d25a204": "正在保存主题…", @@ -636,6 +637,7 @@ export const sourceCatalogs = { "s_3c8bfe37fd20e9df": "粘贴的文本", "s_3cc7815f53c3c255": "{{p0}} (已被改名/删除)", "s_3cebe43e16d0fdf1": "拖动调整控制台高度", + "s_3d0849ba4a0906fd": "服务端默认", "s_3d0da3a229b049b7": "需要你授权", "s_3d13868593ae4eeb": "界面语言", "s_3d16f579a0f2ef54": "附件上传失败:", @@ -807,6 +809,7 @@ export const sourceCatalogs = { "s_4b76842c2307a730": "停止与智能体共享", "s_4b81532d80de24f5": "当前后台不支持定时任务", "s_4b902ccd56dac9af": "我已完成授权", + "s_4bd1cfcb0330e6fa": "留空则不发送,由服务端决定。", "s_4be7dbc56e70ba1a": "退出 ACECode 后继续运行后台进程", "s_4bf0fb2140bae8f4": "展开详情", "s_4bf6fc05891ac9fa": "关闭建议", @@ -983,6 +986,7 @@ export const sourceCatalogs = { "s_5b7a78ce5902332a": "钩子已关闭", "s_5b8ff047598f8a09": "待信任", "s_5bb4b6baeaf5ae0a": "请关闭控制台终端后再迁移", + "s_5bb91512b875df56": "Top K、Min P 是 vLLM、SGLang 等推理服务的扩展参数,官方 OpenAI 接口不支持,按需填写。", "s_5bd8948e884d62dd": "默认模式需确认", "s_5be0323e8adcaeae": "已连接", "s_5c0a0e91a09445de": "已保存,但当前运行环境不可用", @@ -1085,6 +1089,7 @@ export const sourceCatalogs = { "s_67035e6b7f82acdb": "蓝色", "s_670ec25af8419f48": "保留", "s_670fd17c931a0170": "旁路聊天记录过长,无法继续发送。", + "s_6714ad665cd9dce7": "Top P 需大于 0 且不超过 1", "s_6730e89aac61dfb7": "会话闲置后自动提炼经验并整合成记忆;开启后会额外调用模型", "s_67330e9d7654744c": "已错过", "s_673bfd286541c87b": "请输入提示词", @@ -1092,6 +1097,7 @@ export const sourceCatalogs = { "s_674f9ac747ebfd69": "升级已安装,点击查看重启选项", "s_67a1de4cea20e015": "打开模型设置", "s_67ee0d22e90a49f5": "已保存,技能扫描路径已更新", + "s_68029f7769600f70": "从最后一次侧聊结束开始计时,到期后切回该会话时清空;正在查看时不会自动消失。", "s_681139c14b0eea8e": "{{p0}} 等 {{p1}} 个", "s_6820f44987bb2ae6": "重试发送", "s_682d211b7142758a": "已修改", @@ -1243,6 +1249,7 @@ export const sourceCatalogs = { "s_756119c3f91c025b": "收起项目栏", "s_7580b1501edb6fff": "此电脑", "s_7587607a6342dc0b": "下载 EVA 初号机主题", + "s_758d1aea9c4b5b50": "侧边聊天存续时间", "s_75c306a7bcac0b15": "来自审计记录。加入白名单后记得保存;加入黑名单会让 AI 明确知道这条路径不可碰。", "s_75d889ac43a261ad": "已允许", "s_75dfaf262f476b6c": "取消中", @@ -1675,6 +1682,7 @@ export const sourceCatalogs = { "s_9badcf14ef9a8d08": "新建会话默认使用此模式", "s_9bcbd690c4a55f31": "导出主题失败,请重试", "s_9bd02db4e6d9ed96": "删除旧工作空间数据?", + "s_9bd125161829b7d3": "采样参数无效,或当前 Provider / API 协议不支持该参数", "s_9c49b6d64c60145c": "模型配置刷新失败:", "s_9c5d341609cd80b7": "新标签页", "s_9c773d880d0608d4": "默认终端", @@ -1768,6 +1776,7 @@ export const sourceCatalogs = { "s_a4e47879fbd494b2": "探测模型", "s_a500bd5efd41f72a": "当前问题", "s_a53a4e7bcadab26f": "取消目标", + "s_a549b06c6366a525": "采样参数", "s_a5574109f0208e89": "连接", "s_a55e054ba8777aed": "我了解这些记忆会被永久删除,且无法撤销", "s_a567bdaa11367f26": "中", @@ -1834,6 +1843,7 @@ export const sourceCatalogs = { "s_ab732547ef013de7": "允许后,这条命令将在沙盒内执行。", "s_ab7a910f5dec5480": "还差几步", "s_ab9084a640fbb864": "全不选", + "s_abb68f4ed9c98444": "Top K 需为正整数", "s_abb6edd1093c5cf2": "任务已终止", "s_abe29e9399d0301c": "加载更多失败", "s_abe83bd41de114f8": "思考深度 ({{p0}} / {{p1}})", @@ -2071,6 +2081,7 @@ export const sourceCatalogs = { "s_c2e410cd40759fb5": "向 ACECode 描述任务,或输入 / 命令...", "s_c2f0625f0f3d2c18": "展开工具详情", "s_c304f59bf05cfa14": "桌面启动", + "s_c30b1b7cffe0567e": "side chat retention lifetime minutes ttl 侧聊 保留 过期 分钟", "s_c30bd79f226e9c06": "目录项目过多,只显示前", "s_c3126c133b96f1de": "无法读取目录", "s_c330ab025baa8584": "获取目标窗口的截图", @@ -2187,6 +2198,7 @@ export const sourceCatalogs = { "s_ceb33e0328a6f776": "保存定时任务失败", "s_cec844f175819030": "读取审计日志失败", "s_ced8c6c946146849": "可先在专家组件页面创建。", + "s_cee5ce29016ed1ad": "Temperature 需在 0 到 2 之间(Anthropic 为 0 到 1)", "s_cef224a7c8f1afe1": "问题过长,请缩短后重试。", "s_cf048cf5e66b98d2": "开发者模式", "s_cf107d307f527904": "打开现有目录", @@ -2412,7 +2424,7 @@ export const sourceCatalogs = { "s_e2fe182aad1924fd": "C# 工具", "s_e30b2e8574c3bebb": "插件暂不可用", "s_e30b7b5f5246c1eb": "输入后按 Enter 添加", - "s_e30c720bef8e7841": "默认附带近期日志,会话仅在选择后附带。", + "s_e372402e6aa050e1": "Min P 需在 0 到 1 之间", "s_e3926c01897e13d6": "安全编辑守卫", "s_e399eb07dc83ef19": "排队发送失败:", "s_e3a2fb921b49a4b6": "Grok 登录成功,但凭据保存失败", @@ -3009,6 +3021,7 @@ export const sourceCatalogs = { "s_19107d868966ff2c": "Session title", "s_194b5dba3c63f537": "Reasoning settings", "s_1964dafada0807b5": "Git branch", + "s_19abb552de9a904d": "Recent logs are included. When you select a session, its transcript and the raw requests sent to the model are attached too.", "s_19af3dcd414d81df": "{{p0}} attachments", "s_19bf536853e2e78a": "Collaborating", "s_19bfb1631d25a204": "Saving theme…", @@ -3367,6 +3380,7 @@ export const sourceCatalogs = { "s_3c8bfe37fd20e9df": "Pasted text", "s_3cc7815f53c3c255": "{{p0}}(renamed/deleted)", "s_3cebe43e16d0fdf1": "Drag to adjust console height", + "s_3d0849ba4a0906fd": "Server default", "s_3d0da3a229b049b7": "Need your authorization", "s_3d13868593ae4eeb": "Display language", "s_3d16f579a0f2ef54": "Attachment upload failed:", @@ -3538,6 +3552,7 @@ export const sourceCatalogs = { "s_4b76842c2307a730": "Stop sharing with agent", "s_4b81532d80de24f5": "This server does not support scheduled tasks", "s_4b902ccd56dac9af": "I completed authorization", + "s_4bd1cfcb0330e6fa": "Leave blank to omit the field and use the server default.", "s_4be7dbc56e70ba1a": "Continue running the background process after exiting ACECode", "s_4bf0fb2140bae8f4": "Expand details", "s_4bf6fc05891ac9fa": "Close suggestion", @@ -3714,6 +3729,7 @@ export const sourceCatalogs = { "s_5b7a78ce5902332a": "Hook is closed", "s_5b8ff047598f8a09": "To be trusted", "s_5bb4b6baeaf5ae0a": "Close console terminals before migrating", + "s_5bb91512b875df56": "Top K and Min P are extensions supported by inference servers such as vLLM and SGLang; the official OpenAI API rejects them. Fill them in only when your server supports them.", "s_5bd8948e884d62dd": "Default mode requires confirmation", "s_5be0323e8adcaeae": "Connected", "s_5c0a0e91a09445de": "Saved but not available in current running environment", @@ -3816,6 +3832,7 @@ export const sourceCatalogs = { "s_67035e6b7f82acdb": "Blue", "s_670ec25af8419f48": "Keep", "s_670fd17c931a0170": "The side chat history is too long to send another question.", + "s_6714ad665cd9dce7": "Top P must be greater than 0 and at most 1", "s_6730e89aac61dfb7": "Automatically extracts lessons from idle sessions and consolidates them into memories; uses additional model calls when on", "s_67330e9d7654744c": "Missed", "s_673bfd286541c87b": "Please enter the prompt word", @@ -3823,6 +3840,7 @@ export const sourceCatalogs = { "s_674f9ac747ebfd69": "The upgrade has been installed, click to view restart options", "s_67a1de4cea20e015": "Open model settings", "s_67ee0d22e90a49f5": "Saved. Skill scan paths updated.", + "s_68029f7769600f70": "Starts when the last side chat ends. After this time, returning to the session clears it. It stays visible while you are viewing it.", "s_681139c14b0eea8e": "{{p0}} and others ({{p1}} in total)", "s_6820f44987bb2ae6": "Retry sending", "s_682d211b7142758a": "Modified", @@ -3974,6 +3992,7 @@ export const sourceCatalogs = { "s_756119c3f91c025b": "Collapse sidebar", "s_7580b1501edb6fff": "This PC", "s_7587607a6342dc0b": "Download EVA Unit-01 theme", + "s_758d1aea9c4b5b50": "Side chat retention time", "s_75c306a7bcac0b15": "From the audit log. Remember to save after adding to an allow list; adding to the deny list tells the AI explicitly that the path is off limits.", "s_75d889ac43a261ad": "allowed", "s_75dfaf262f476b6c": "Unarchiving…", @@ -4406,6 +4425,7 @@ export const sourceCatalogs = { "s_9badcf14ef9a8d08": "New sessions use this mode by default", "s_9bcbd690c4a55f31": "Could not export the theme. Try again.", "s_9bd02db4e6d9ed96": "Delete previous workspace data?", + "s_9bd125161829b7d3": "Invalid sampling parameters, or the current provider or API protocol does not support one of them", "s_9c49b6d64c60145c": "Failed to refresh model configuration:", "s_9c5d341609cd80b7": "New tab", "s_9c773d880d0608d4": "Default terminal", @@ -4499,6 +4519,7 @@ export const sourceCatalogs = { "s_a4e47879fbd494b2": "Detect models", "s_a500bd5efd41f72a": "Current issues", "s_a53a4e7bcadab26f": "Cancel goal", + "s_a549b06c6366a525": "Sampling parameters", "s_a5574109f0208e89": "Connect", "s_a55e054ba8777aed": "I understand these memories will be permanently deleted and this cannot be undone", "s_a567bdaa11367f26": "Medium", @@ -4565,6 +4586,7 @@ export const sourceCatalogs = { "s_ab732547ef013de7": "If approved, this command will run inside the sandbox.", "s_ab7a910f5dec5480": "A few more steps", "s_ab9084a640fbb864": "Deselect all", + "s_abb68f4ed9c98444": "Top K must be a positive integer", "s_abb6edd1093c5cf2": "Task terminated", "s_abe29e9399d0301c": "Failed to load more", "s_abe83bd41de114f8": "Reasoning depth ({{p0}} / {{p1}})", @@ -4802,6 +4824,7 @@ export const sourceCatalogs = { "s_c2e410cd40759fb5": "Describe a task to ACECode, or type / for commands…", "s_c2f0625f0f3d2c18": "Expand tool details", "s_c304f59bf05cfa14": "Desktop startup", + "s_c30b1b7cffe0567e": "side chat retention lifetime minutes ttl", "s_c30bd79f226e9c06": "Too many items; showing the first", "s_c3126c133b96f1de": "Unable to read directory", "s_c330ab025baa8584": "Capture screenshots of the target window", @@ -4918,6 +4941,7 @@ export const sourceCatalogs = { "s_ceb33e0328a6f776": "Failed to save scheduled task", "s_cec844f175819030": "Could not read the audit log", "s_ced8c6c946146849": "It can be created on the expert component page first.", + "s_cee5ce29016ed1ad": "Temperature must be between 0 and 2 (0 to 1 for Anthropic)", "s_cef224a7c8f1afe1": "Your question is too long. Shorten it and try again.", "s_cf048cf5e66b98d2": "Developer mode", "s_cf107d307f527904": "Open existing directory", @@ -5143,7 +5167,7 @@ export const sourceCatalogs = { "s_e2fe182aad1924fd": "C# tools", "s_e30b2e8574c3bebb": "Plugins are currently unavailable", "s_e30b7b5f5246c1eb": "After typing, press Enter to add", - "s_e30c720bef8e7841": "Recent logs are included. A session is attached only when selected.", + "s_e372402e6aa050e1": "Min P must be between 0 and 1", "s_e3926c01897e13d6": "Safe edit guard", "s_e399eb07dc83ef19": "Queued send failed:", "s_e3a2fb921b49a4b6": "Grok sign-in succeeded, but the credentials could not be saved", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index a815a4a4..bae78702 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -97,7 +97,9 @@ function sidebarSessionsPath(hash, opts = {}) { if (Number(opts.limit) > 0) qs.set('limit', String(Math.floor(opts.limit))); if (opts.expanded?.length) qs.set('expanded', JSON.stringify(opts.expanded)); if (opts.reveal) qs.set('reveal', String(opts.reveal)); - return qs.size ? `${path}?${qs}` : path; + // WebView 110 has URLSearchParams, but not its newer size property. + const text = qs.toString(); + return text ? `${path}?${text}` : path; } function usagePath(opts = {}) { diff --git a/web/src/lib/api.test.js b/web/src/lib/api.test.js index 9ba3312e..a894d676 100644 --- a/web/src/lib/api.test.js +++ b/web/src/lib/api.test.js @@ -19,6 +19,41 @@ import { sessionTodosPath, } from './api.js'; +await run('sidebar queries retain expansion, pagination and reveal when URLSearchParams.size is unavailable', async () => { + const originalFetch = globalThis.fetch; + const OriginalSearchParams = globalThis.URLSearchParams; + const calls = []; + globalThis.URLSearchParams = class extends OriginalSearchParams { + get size() { return undefined; } + }; + globalThis.fetch = async (url, options) => { + calls.push({ url: new URL(url), options }); + return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ sessions: [] }) }; + }; + try { + const api = createApi({ origin: 'http://sidebar.test', token: 'fixture-token' }); + const options = { limit: 5, expanded: ['loop:每日 / + 简报', 'session:older'], reveal: 'older / + 会话' }; + await api.listSidebarSessions('工作区 / one', options); + await api.listSidebarSessions('', { ...options, limit: 0 }); + const move = { source: 'session:older', target: 'loop:每日 / + 简报', placement: 'inside' }; + await api.moveSidebarSession('工作区 / one', move, options); + await api.listSidebarSessions('', { limit: 0, expanded: [], reveal: '' }); + assert.equal(calls[0].url.pathname, '/api/workspaces/' + encodeURIComponent('工作区 / one') + '/session-sidebar'); + assert.equal(calls[1].url.pathname, '/api/session-sidebar'); + for (const index of [0, 1, 2]) { + assert.deepEqual(JSON.parse(calls[index].url.searchParams.get('expanded')), options.expanded); + assert.equal(calls[index].url.searchParams.get('reveal'), options.reveal); + assert.equal(calls[index].url.searchParams.get('limit'), index === 1 ? null : '5'); + } + assert.equal(calls[2].options.method, 'POST'); + assert.deepEqual(JSON.parse(calls[2].options.body), move); + assert.equal(calls[3].url.search, ''); + } finally { + globalThis.fetch = originalFetch; + globalThis.URLSearchParams = OriginalSearchParams; + } +}); + await run('local import sends original binary ZIP with authentication and preview digest', async () => { const originalFetch = globalThis.fetch; try { diff --git a/web/src/lib/errors.js b/web/src/lib/errors.js index dada2605..3ab020b9 100644 --- a/web/src/lib/errors.js +++ b/web/src/lib/errors.js @@ -39,6 +39,11 @@ const TABLE = { INVALID_API_KEY: '该 provider 的 API key 不能为空', INVALID_API_PROTOCOL: 'API 协议无效,请选择 Chat Completions 或 Responses', INVALID_CONTEXT_WINDOW: '上下文窗口必须是大于 0 的数字', + INVALID_TEMPERATURE: 'Temperature 需在 0 到 2 之间(Anthropic 为 0 到 1)', + INVALID_TOP_P: 'Top P 需大于 0 且不超过 1', + INVALID_TOP_K: 'Top K 需为正整数', + INVALID_MIN_P: 'Min P 需在 0 到 1 之间', + INVALID_SAMPLING: '采样参数无效,或当前 Provider / API 协议不支持该参数', INVALID_CAPABILITY: '模型能力标签无效', INVALID_REQUEST_HEADER: '自定义请求头 JSON 无效', NOT_FOUND: '该模型已不存在,请刷新', diff --git a/web/src/lib/modelSettings.js b/web/src/lib/modelSettings.js index bddfe223..29b1e14d 100644 --- a/web/src/lib/modelSettings.js +++ b/web/src/lib/modelSettings.js @@ -21,6 +21,16 @@ import { providerDisplayName } from './providerCatalogGroups.js'; export const MODEL_CATALOG_QUERY_LIMIT = 50; export const MODEL_ENDPOINT_MODES = ['base_url', 'full_url']; export const MODEL_API_PROTOCOLS = ['chat_completions', 'responses']; +// 采样参数,全部可选:留空 = 不发送,由服务端决定。top_k / min_p 是 vLLM / SGLang +// 等推理服务的扩展字段,只有 Chat Completions 会发出;Anthropic 支持 top_k、不支持 +// min_p;Responses 协议两者都不支持。取值范围与后端 validate_model_sampling_ranges 一致。 +export const MODEL_SAMPLING_FIELDS = ['temperature', 'top_p', 'top_k', 'min_p']; +const SAMPLING_ERROR_CODES = { + temperature: 'INVALID_TEMPERATURE', + top_p: 'INVALID_TOP_P', + top_k: 'INVALID_TOP_K', + min_p: 'INVALID_MIN_P', +}; import { MODEL_REASONING_EFFORTS } from './modelReasoning.js'; export { MODEL_REASONING_EFFORTS } from './modelReasoning.js'; @@ -388,6 +398,30 @@ export function normalizeProviderModelQuery(payload, expectedProviderId = '') { }; } +function normalizeSampling(value, label) { + if (value === undefined || value === null) return {}; + const object = requireObject(value, label); + const output = {}; + for (const field of MODEL_SAMPLING_FIELDS) { + const raw = object[field]; + if (raw === undefined || raw === null) continue; + const integerOnly = field === 'top_k'; + if (typeof raw !== 'number' || !Number.isFinite(raw) + || (integerOnly && !Number.isInteger(raw))) { + throw contractError(`${label}.${field} must be ${integerOnly ? 'an integer' : 'a number'}`); + } + output[field] = raw; + } + return output; +} + +function samplingDraftFields(sampling = {}) { + return Object.fromEntries(MODEL_SAMPLING_FIELDS.map((field) => [ + field, + typeof sampling?.[field] === 'number' ? String(sampling[field]) : '', + ])); +} + export function normalizeSavedModelProfile(raw, index = 0) { const value = requireObject(raw, `saved_models[${index}]`); const provider = requireString(value.provider, `saved_models[${index}].provider`); @@ -432,6 +466,7 @@ export function normalizeSavedModelProfile(raw, index = 0) { : (() => { throw contractError(`saved_models[${index}].endpoint_mode is unsupported`); })(), api_protocol: apiProtocol, reasoning: normalizeReasoning(value.reasoning, `saved_models[${index}].reasoning`), + sampling: normalizeSampling(value.sampling, `saved_models[${index}].sampling`), request_headers: value.request_headers === undefined || value.request_headers === null ? undefined : isObject(value.request_headers) @@ -796,6 +831,7 @@ export function emptyModelProfileDraft() { capabilities: [], capabilities_source: '', reasoning: normalizeReasoning(null), + ...samplingDraftFields(), _catalog_model_metadata: {}, _model_metadata_overrides: {}, _active_catalog_model_id: '', @@ -830,6 +866,7 @@ export function modelProfileDraftFromSaved(raw) { capabilities: model.capabilities, capabilities_source: model.capabilities_source, reasoning: model.reasoning, + ...samplingDraftFields(model.sampling), }; } @@ -854,6 +891,7 @@ export function modelFieldPolicy(provider) { show_request_headers: supportsHttpOptions, show_max_output: !managed, show_reasoning: supportsHttpOptions, + show_sampling: supportsHttpOptions, can_probe: value.runtime_provider === 'copilot' || value.runtime_provider === 'grok' || value.runtime_provider === 'openai', @@ -861,6 +899,53 @@ export function modelFieldPolicy(provider) { }; } +// 当前 Provider / API 协议下哪些采样参数会真正发出去;界面只显示这些输入, +// 请求体也只带这些字段(切换协议后被隐藏的旧值不会发出去)。 +export function modelSamplingFieldSupport(draft, provider) { + const none = { temperature: false, top_p: false, top_k: false, min_p: false }; + if (!provider || !modelFieldPolicy(provider).show_sampling) return none; + if (provider.runtime_provider === 'anthropic') { + return { temperature: true, top_p: true, top_k: true, min_p: false }; + } + const protocol = draft?.api_protocol || provider.default_api_protocol || 'chat_completions'; + if (protocol === 'responses') { + return { temperature: true, top_p: true, top_k: false, min_p: false }; + } + return { temperature: true, top_p: true, top_k: true, min_p: true }; +} + +function samplingFieldValid(field, value, provider) { + if (!Number.isFinite(value)) return false; + switch (field) { + case 'temperature': + return value >= 0 && value <= (provider?.runtime_provider === 'anthropic' ? 1 : 2); + case 'top_p': + return value > 0 && value <= 1; + case 'top_k': + return Number.isInteger(value) && value > 0 && value <= 2147483647; + case 'min_p': + return value >= 0 && value <= 1; + default: + return false; + } +} + +function parseSamplingDraft(draft, provider) { + const support = modelSamplingFieldSupport(draft, provider); + const value = {}; + for (const field of MODEL_SAMPLING_FIELDS) { + if (!support[field]) continue; + const raw = String(draft?.[field] ?? '').trim(); + if (!raw) continue; + const parsed = Number(raw); + if (!samplingFieldValid(field, parsed, provider)) { + return { ok: false, code: SAMPLING_ERROR_CODES[field] }; + } + value[field] = parsed; + } + return { ok: true, value }; +} + export function isCustomOpenAiCompatibilityProvider(provider) { return provider?.runtime_provider === 'openai' && provider?.model_input === 'manual'; @@ -891,6 +976,7 @@ export function applyCatalogProviderToDraft(draft, provider) { capabilities: [], capabilities_source: '', reasoning: normalizeReasoning(null), + ...samplingDraftFields(), _catalog_model_metadata: {}, _model_metadata_overrides: {}, _active_catalog_model_id: '', @@ -997,6 +1083,8 @@ export function validateModelProfileDraft(draft, provider, { editing = false } = if (!contextWindow.ok) return contextWindow; const maxOutput = parsePositiveDraftInteger(draft.max_output_tokens, 'INVALID_MAX_OUTPUT_TOKENS'); if (!maxOutput.ok) return maxOutput; + const sampling = parseSamplingDraft(draft, provider); + if (!sampling.ok) return sampling; const headers = policy.show_request_headers ? parseRequestHeadersJson(draft.request_headers_json, draft.provider) : { ok: true, headers: undefined }; @@ -1035,6 +1123,7 @@ export function validateModelProfileDraft(draft, provider, { editing = false } = max_output_tokens: maxOutput.value, request_headers: headers.headers, reasoning, + sampling: sampling.value, }; } @@ -1107,6 +1196,12 @@ export function buildModelMutationPayload(draft, provider, options = {}) { } else if (policy.show_reasoning && options.editing) { payload.reasoning = null; } + // 采样参数整体替换:编辑时全部留空要显式发 null,才能清掉已保存的值。 + if (policy.show_sampling && Object.keys(validation.sampling).length > 0) { + payload.sampling = validation.sampling; + } else if (policy.show_sampling && options.editing) { + payload.sampling = null; + } return { ok: true, payload }; } @@ -1165,6 +1260,7 @@ export function hasAdvancedModelValues(draft) { || String(draft?.max_output_tokens || '').trim() || draft?.capabilities_source || reasoning.supported + || MODEL_SAMPLING_FIELDS.some((field) => String(draft?.[field] ?? '').trim()) ); } diff --git a/web/src/lib/modelSettings.test.js b/web/src/lib/modelSettings.test.js index f336737a..df45aa66 100644 --- a/web/src/lib/modelSettings.test.js +++ b/web/src/lib/modelSettings.test.js @@ -15,6 +15,7 @@ import { modelFieldPolicy, modelNameSuggestion, modelRowsFromProbe, + modelSamplingFieldSupport, updateModelReasoningEfforts, modelProfileDraftFromSaved, normalizeModelCatalogSummary, @@ -357,6 +358,7 @@ run('切换到 Copilot 清空秘密与端点并隐藏 API Key/Base URL', () => { show_request_headers: false, show_max_output: false, show_reasoning: false, + show_sampling: false, can_probe: true, model_input: 'catalog', }); @@ -518,6 +520,7 @@ run('Grok Coding Plan 使用受管路径且 payload 不含端点、密钥或运 show_request_headers: false, show_max_output: false, show_reasoning: false, + show_sampling: false, can_probe: true, model_input: 'catalog', }); @@ -1474,3 +1477,129 @@ run('同 OpenAI runtime 切换服务商显式恢复 Chat Completions', () => { assert.equal(result.ok, true); assert.equal(result.payload.api_protocol, 'chat_completions'); }); + +// 采样参数(wizard-ai GLM 场景:平台推荐 top_p 0.95 / top_k 20 / temperature 1 / min_p 0)。 +function samplingDraft(provider, sampling = {}) { + return { + ...applyCatalogProviderToDraft(emptyModelProfileDraft(), provider), + name: 'glm', + model: 'aicoder-pro', + api_key: 'sk-test', + ...sampling, + }; +} + +// 触发场景:自定义 OpenAI 兼容 API(Chat Completions)填了四个采样参数。 +// 期望:四个输入都显示,payload.sampling 按数字提交;min_p = 0 也要提交。 +run('采样参数:Chat Completions 显示并提交全部四个字段', () => { + const draft = samplingDraft(customProvider, { + temperature: '1', top_p: '0.95', top_k: '20', min_p: '0', + }); + assert.deepEqual(modelSamplingFieldSupport(draft, customProvider), { + temperature: true, top_p: true, top_k: true, min_p: true, + }); + const built = buildModelMutationPayload(draft, customProvider); + assert.equal(built.ok, true); + assert.deepEqual(built.payload.sampling, { + temperature: 1, top_p: 0.95, top_k: 20, min_p: 0, + }); +}); + +// 触发场景:四个字段都填了,但 API 协议切到 Responses、或 Provider 是 Anthropic。 +// 期望:只提交该协议真正支持的字段(Responses:temperature/top_p;Anthropic 再加 +// top_k),被隐藏的旧值不发,避免后端因"不支持"而拒绝整次保存。 +run('采样参数:Responses 与 Anthropic 只提交各自支持的字段', () => { + const all = { temperature: '0.7', top_p: '0.9', top_k: '40', min_p: '0.05' }; + const responses = samplingDraft(customProvider, { ...all, api_protocol: 'responses' }); + assert.deepEqual(modelSamplingFieldSupport(responses, customProvider), { + temperature: true, top_p: true, top_k: false, min_p: false, + }); + const responsesBuilt = buildModelMutationPayload(responses, customProvider); + assert.equal(responsesBuilt.ok, true); + assert.deepEqual(responsesBuilt.payload.sampling, { temperature: 0.7, top_p: 0.9 }); + + const anthropic = samplingDraft(anthropicProvider, all); + const anthropicBuilt = buildModelMutationPayload(anthropic, anthropicProvider); + assert.equal(anthropicBuilt.ok, true); + assert.deepEqual(anthropicBuilt.payload.sampling, { temperature: 0.7, top_p: 0.9, top_k: 40 }); +}); + +// 触发场景:Copilot / Grok 这类托管 Provider。 +// 期望:采样区不显示,编辑保存时也不发 sampling 字段(后端会拒绝托管 Provider 的采样参数)。 +run('采样参数:托管 Provider 不显示也不提交', () => { + for (const provider of [copilotProvider, grokProvider]) { + const draft = { + ...applyCatalogProviderToDraft(emptyModelProfileDraft(), provider), + name: 'managed', + model: 'gpt-4o', + temperature: '0.5', + }; + assert.deepEqual(modelSamplingFieldSupport(draft, provider), { + temperature: false, top_p: false, top_k: false, min_p: false, + }); + assert.equal(modelFieldPolicy(provider).show_sampling, false); + const built = buildModelMutationPayload(draft, provider, { editing: true }); + assert.equal(built.ok, true); + assert.equal(Object.hasOwn(built.payload, 'sampling'), false); + } +}); + +// 触发场景:用户把采样参数填成越界值或非数字。 +// 期望:保存前就报对应字段的错误码(errors.js 有中文文案),不发请求; +// Anthropic 的 temperature 上限是 1。 +run('采样参数:越界或格式错误返回对应错误码', () => { + const cases = [ + [customProvider, { temperature: '2.5' }, 'INVALID_TEMPERATURE'], + [customProvider, { temperature: 'abc' }, 'INVALID_TEMPERATURE'], + [anthropicProvider, { temperature: '1.5' }, 'INVALID_TEMPERATURE'], + [customProvider, { top_p: '0' }, 'INVALID_TOP_P'], + [customProvider, { top_p: '1.2' }, 'INVALID_TOP_P'], + [customProvider, { top_k: '1.5' }, 'INVALID_TOP_K'], + [customProvider, { top_k: '0' }, 'INVALID_TOP_K'], + [customProvider, { min_p: '2' }, 'INVALID_MIN_P'], + ]; + for (const [provider, sampling, code] of cases) { + const result = validateModelProfileDraft(samplingDraft(provider, sampling), provider); + assert.equal(result.ok, false, JSON.stringify(sampling)); + assert.equal(result.code, code, JSON.stringify(sampling)); + } +}); + +// 触发场景:编辑一个已配采样参数的模型 —— 回填、把它们全部清空后保存、新增时不填。 +// 期望:已保存值回填成输入框文本并自动展开高级设置;编辑时全清发 sampling: null +// (后端据此清除),新增时不填则不带 sampling 字段。 +run('采样参数:回填、编辑清空发 null、新增留空省略', () => { + const saved = modelProfileDraftFromSaved({ + name: 'glm', provider: 'openai', model: 'aicoder-pro', + base_url: 'https://gateway.example/v1', api_key: 'sk-test', + sampling: { top_p: 0.95, top_k: 20 }, + }); + assert.equal(saved.top_p, '0.95'); + assert.equal(saved.top_k, '20'); + assert.equal(saved.temperature, ''); + assert.equal(hasAdvancedModelValues(saved), true); + + const cleared = buildModelMutationPayload( + { ...saved, top_p: '', top_k: '' }, customProvider, { editing: true }, + ); + assert.equal(cleared.ok, true); + assert.equal(cleared.payload.sampling, null); + + const fresh = buildModelMutationPayload(samplingDraft(customProvider), customProvider); + assert.equal(fresh.ok, true); + assert.equal(Object.hasOwn(fresh.payload, 'sampling'), false); +}); + +// 触发场景:后端返回的 saved_models 里 sampling 类型不对(契约被破坏)。 +// 期望:像其它字段一样快速失败,而不是把字符串当数字回填。 +run('采样参数:响应契约校验', () => { + const base = { + name: 'glm', provider: 'openai', model: 'aicoder-pro', + base_url: 'https://gateway.example/v1', api_key: 'sk-test', + }; + assert.deepEqual(normalizeSavedModelList([{ ...base, sampling: { min_p: 0 } }])[0].sampling, { min_p: 0 }); + assert.deepEqual(normalizeSavedModelList([base])[0].sampling, {}); + assert.throws(() => normalizeSavedModelList([{ ...base, sampling: { top_k: 1.5 } }]), /top_k/); + assert.throws(() => normalizeSavedModelList([{ ...base, sampling: { temperature: '1' } }]), /temperature/); + assert.throws(() => normalizeSavedModelList([{ ...base, sampling: [] }]), /sampling/); +}); diff --git a/web/src/lib/previewWorkbenchArchitecture.test.js b/web/src/lib/previewWorkbenchArchitecture.test.js index 37fa079f..b5aa4c74 100644 --- a/web/src/lib/previewWorkbenchArchitecture.test.js +++ b/web/src/lib/previewWorkbenchArchitecture.test.js @@ -216,7 +216,7 @@ run('editable previews retain selection, inactive marks and annotation decoratio run('side chat entries share an isolated floating conversation controller', () => { const chat = source('../components/ChatView.jsx'); - assert.match(chat, /createSideChatController\(\{[\s\S]*?api\.streamSideChat\(sid, options\)/); + assert.match(chat, /sideChatSessions\.get\([\s\S]*?api\.streamSideChat\(sid, options\)/); assert.match(chat, /sideChat\.submit\(question\)/); assert.match(chat, / { + const user = { id: 'u-ask', role: 'user', content: '清理数据', ts: 1000 }; + const results = [ + { items: [{ question: '清理哪些数据?', answer: '只清理 SDK 数据', multiSelect: false }] }, + { cancelled: true, items: [] }, + { interjected: true, items: [] }, + ]; + for (const name of ['AskUserQuestion', 'renamed_question']) { + const messages = [user]; + const events = [{ type: 'message', payload: user }]; + results.forEach((result, index) => { + const callId = `ask-${index}`; + const success = index === 0; + const assistant = { + id: `a-ask-${index}`, role: 'assistant', content: `处理阶段 ${index + 1}`, + ts: 2000 + index * 2000, + tool_calls: [persistedToolCall(callId, name, '{"questions":[]}')], + }; + const metadata = { tool_success: success, ask_user_question_result: result }; + messages.push(assistant, { + id: `q-ask-${index}`, role: 'tool', tool_call_id: callId, + content: success ? 'answered' : 'cancelled', ts: 3000 + index * 2000, metadata, + }); + events.push( + { type: 'message', payload: assistant }, + { type: 'tool_start', payload: { tool: name, tool_call_id: callId, args: {} } }, + { type: 'tool_end', payload: { tool: name, tool_call_id: callId, success, output: '', metadata } }, + ); + }); + const final = { id: 'a-ask-final', role: 'assistant', content: 'SDK 数据已清除完毕', ts: 9000 }; + const timing = turnTimingMessage('u-ask', 54027000); + messages.push(final, timing); + events.push({ type: 'message', payload: final }, { type: 'message', payload: timing }); + const live = reduceMany(events); + const history = loadTranscriptHistory(createTranscriptState(), { messages, events: [] }).state; + const replaced = reduceTranscriptEvent(live, { type: 'transcript_replace', payload: { messages } }).state; + for (const state of [live, history, replaced]) { + const projected = projectCollapsedTranscriptItems(state.items); + const summaries = projected.filter((item) => item.mode === 'processed'); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].title, '已处理 900m 27s'); + assert.deepEqual(summaries[0].collapsedItems.map((item) => item.content), ['处理阶段 1', '处理阶段 2', '处理阶段 3']); + const questions = projected.filter((item) => item.kind === 'tool' && item.tool?.askUserQuestionResult); + assert.deepEqual(questions.map((item) => item.tool.askUserQuestionResult), results); + assert.equal(projected.filter((item) => item.role === 'user').length, 1); + assert.equal(projected.at(-1).content, final.content); + } + } +}); + run('history load 从 meta 消息恢复 turn_net_diff 并关联用户 item', () => { const loaded = loadTranscriptHistory(createTranscriptState({ title: 's1' }), { messages: [ diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index ef71b4bf..142ea2f3 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -7,6 +7,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { const fields = [ ['general', '界面语言', 'language locale english chinese'], ['general', '工作模式', 'work mode coding daily 日常工作 用于编程 进度提示 工具前言 loading'], + ['general', '侧边聊天存续时间', 'side chat retention lifetime minutes ttl 侧聊 保留 过期 分钟'], ['general', '打开任务完成通知', 'notification completion sound'], ['general', '新手指引', 'onboarding getting started tour'], ['general', '权限模式', 'permission approval sandbox'], diff --git a/web/src/lib/settingsSearch.test.js b/web/src/lib/settingsSearch.test.js index 19f65a94..cbdfcdab 100644 --- a/web/src/lib/settingsSearch.test.js +++ b/web/src/lib/settingsSearch.test.js @@ -12,6 +12,8 @@ assert.ok(searchSettings(entries, 'path').length > 3); assert.equal(searchSettings(entries, 'upgrade')[0].section, 'config'); assert.equal(searchSettings(entries, 'Skill 扫描路径')[0].section, 'config'); assert.equal(searchSettings(entries, 'skill scan paths')[0].label, 'Skill 扫描路径'); +assert.equal(searchSettings(entries, '侧边聊天存续时间')[0].section, 'general'); +assert.equal(searchSettings(entries, 'side chat retention')[0].label, '侧边聊天存续时间'); console.log('[pass] bilingual settings search, ranking, cross-section and empty queries'); // 全局搜索面板 → 设置窗口的选中项对齐(settingsSearchResultIndex)。 diff --git a/web/src/lib/sideChatController.js b/web/src/lib/sideChatController.js index 677c2bb5..74eacc02 100644 --- a/web/src/lib/sideChatController.js +++ b/web/src/lib/sideChatController.js @@ -1,3 +1,5 @@ +import { sideChatRetentionMilliseconds } from './sideChatRetention.js'; + export const SIDE_CHAT_QUESTION_MAX_BYTES = 16000; export const SIDE_CHAT_HISTORY_MAX_BYTES = 256 * 1024; export const SIDE_CHAT_HISTORY_MAX_MESSAGES = 200; @@ -66,11 +68,12 @@ function validationError(question, history) { // State ownership stays outside React so submitting, stopping and session // changes cannot race stale render closures or late transport callbacks. -export function createSideChatController({ startStream } = {}) { +export function createSideChatController({ startStream, now = Date.now } = {}) { if (typeof startStream !== 'function') throw new TypeError('startStream is required'); const listeners = new Set(); let snapshot = initialSnapshot(); let active = null; + let lastFinishedAt = null; function publish(patch) { snapshot = { ...snapshot, ...patch }; @@ -94,6 +97,7 @@ export function createSideChatController({ startStream } = {}) { function finish(request, patch) { if (active !== request) return; active = null; + lastFinishedAt = now(); request.handle?.dispose(); updateTurn(request, { ...patch, tools: settledTools(request) }, { busy: false, stopping: false }); } @@ -103,6 +107,7 @@ export function createSideChatController({ startStream } = {}) { const request = active; // Invalidate first: even a synchronous stop acknowledgement is stale. active = null; + lastFinishedAt = now(); try { request.handle?.stop(); } finally { request.handle?.dispose(); } updateTurn(request, { status: 'stopped', tools: settledTools(request) }, { busy: false, stopping: false }); @@ -222,16 +227,28 @@ export function createSideChatController({ startStream } = {}) { cancelImmediately(); publish({ open: false }); }, + suspend: cancelImmediately, + resume(retentionMinutes) { + if (!active && lastFinishedAt !== null + && now() - lastFinishedAt >= sideChatRetentionMilliseconds(retentionMinutes)) { + controller.reset(); + return true; + } + return false; + }, clear() { cancelImmediately(); + lastFinishedAt = null; publish({ ...initialSnapshot(), open: snapshot.open }); }, reset() { cancelImmediately(); + lastFinishedAt = null; publish(initialSnapshot()); }, dispose() { cancelImmediately(); + lastFinishedAt = null; listeners.clear(); snapshot = initialSnapshot(); }, diff --git a/web/src/lib/sideChatRetention.js b/web/src/lib/sideChatRetention.js new file mode 100644 index 00000000..dce875e5 --- /dev/null +++ b/web/src/lib/sideChatRetention.js @@ -0,0 +1,10 @@ +export const SIDE_CHAT_RETENTION_KEY = 'ace.sideChatRetentionMinutes'; +export const DEFAULT_SIDE_CHAT_RETENTION_MINUTES = 30; + +export function isSideChatRetentionMinutes(value) { + return Number.isSafeInteger(value) && value > 0; +} + +export function sideChatRetentionMilliseconds(minutes) { + return (isSideChatRetentionMinutes(minutes) ? minutes : DEFAULT_SIDE_CHAT_RETENTION_MINUTES) * 60_000; +} diff --git a/web/src/lib/sideChatSessions.js b/web/src/lib/sideChatSessions.js new file mode 100644 index 00000000..e8802066 --- /dev/null +++ b/web/src/lib/sideChatSessions.js @@ -0,0 +1,32 @@ +import { createSideChatController } from './sideChatController.js'; + +export function sideChatSessionKey(ref = {}) { + const id = ref?.sessionId || ref?.session_id || ref?.id; + if (!id) return ''; + return JSON.stringify([ref?.port || '', ref?.workspaceHash || ref?.workspace_hash || '', id]); +} + +// Temporary side conversations belong to the browser page, independently of +// ChatView mounts. Only the retention preference is persisted to storage. +export function createSideChatSessionStore({ now = Date.now } = {}) { + const sessions = new Map(); + return { + get(key, startStream) { + if (!key) return createSideChatController({ startStream, now }); + let entry = sessions.get(key); + if (!entry) { + entry = { startStream }; + entry.controller = createSideChatController({ + now, + startStream: (options) => entry.startStream(options), + }); + sessions.set(key, entry); + } + // A session can be restored after its API credentials have changed. + entry.startStream = startStream; + return entry.controller; + }, + }; +} + +export const sideChatSessions = createSideChatSessionStore(); diff --git a/web/src/lib/sideChatSessions.test.js b/web/src/lib/sideChatSessions.test.js new file mode 100644 index 00000000..4eaf9bd3 --- /dev/null +++ b/web/src/lib/sideChatSessions.test.js @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { createSideChatSessionStore, sideChatSessionKey } from './sideChatSessions.js'; +import { + DEFAULT_SIDE_CHAT_RETENTION_MINUTES, + isSideChatRetentionMinutes, + sideChatRetentionMilliseconds, +} from './sideChatRetention.js'; + +function run(name, fn) { + fn(); + console.log(`[pass] side chat retention: ${name}`); +} + +function setup() { + let time = 0; + const requests = []; + const store = createSideChatSessionStore({ now: () => time }); + const key = (id) => sideChatSessionKey({ port: 28080, workspaceHash: 'workspace', sessionId: id }); + const get = (id) => store.get(key(id), (options) => { + const request = { ...options, stops: 0, disposals: 0 }; + requests.push(request); + return { stop() { request.stops++; }, dispose() { request.disposals++; } }; + }); + return { get, requests, at(value) { time = value; } }; +} + +run('defaults to 30 minutes and accepts positive integer minutes only', () => { + assert.equal(DEFAULT_SIDE_CHAT_RETENTION_MINUTES, 30); + assert.equal(sideChatRetentionMilliseconds(1), 60_000); + assert.equal(isSideChatRetentionMinutes(240), true); + for (const invalid of [undefined, null, '', '5', 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.equal(isSideChatRetentionMinutes(invalid), false); + assert.equal(sideChatRetentionMilliseconds(invalid), 30 * 60_000); + } +}); + +run('session identity includes daemon and workspace and excludes empty sessions', () => { + const ref = { port: 1, workspaceHash: 'w', sessionId: 'same' }; + assert.notEqual(sideChatSessionKey(ref), sideChatSessionKey({ ...ref, port: 2 })); + assert.notEqual(sideChatSessionKey(ref), sideChatSessionKey({ ...ref, workspaceHash: 'other' })); + assert.equal(sideChatSessionKey(ref), sideChatSessionKey({ port: 1, workspace_hash: 'w', id: 'same' })); + assert.equal(sideChatSessionKey({ workspaceHash: 'home' }), ''); + const store = createSideChatSessionStore(); + assert.notEqual(store.get('', () => {}), store.get('', () => {})); +}); + +run('switching and remounting restore open state, draft and follow-up history independently', () => { + const { get, requests, at } = setup(); + const first = get('first'); + first.submit('first question'); + at(5_000); + requests[0].onDone({ answer: 'first answer' }); + first.setDraft('unsent draft'); + first.suspend(); + const second = get('second'); + assert.equal(second.getSnapshot().open, false); + assert.deepEqual(second.getSnapshot().turns, []); + second.submit('second question'); + requests[1].onDone({ answer: 'second answer' }); + second.suspend(); + at(30 * 60_000 + 4_999); + const restored = get('first'); + assert.equal(restored, first); + assert.equal(restored.resume(30), false); + assert.equal(restored.getSnapshot().open, true); + assert.equal(restored.getSnapshot().draft, 'unsent draft'); + assert.equal(restored.getSnapshot().turns[0].answer, 'first answer'); + restored.submit(); + assert.deepEqual(requests[2].history, [ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + ]); + assert.equal(second.getSnapshot().turns[0].answer, 'second answer'); +}); + +run('expiry starts at completion, stays visible until return and includes the exact boundary', () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('long answer'); + at(40 * 60_000); + assert.equal(chat.resume(30), false, 'a generating request must not expire'); + requests[0].onDone({ answer: 'finished after forty minutes' }); + at(70 * 60_000 - 1); + chat.suspend(); + assert.equal(get('session').resume(30), false); + at(70 * 60_000); + assert.equal(chat.getSnapshot().turns.length, 1, 'no background timeout clears the visible chat'); + chat.suspend(); + assert.equal(get('session').resume(30), true); + assert.deepEqual(chat.getSnapshot(), { open: false, turns: [], draft: '', busy: false, stopping: false }); + chat.open(); + chat.submit('fresh question'); + assert.deepEqual(requests[1].history, []); +}); + +run('switches, reopening and editing do not extend the end time', () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('question'); + requests[0].onDone({ answer: 'answer' }); + at(60_000); + chat.close(); + chat.open(); + chat.setDraft('new draft'); + chat.suspend(); + chat.resume(30); + at(30 * 60_000); + assert.equal(chat.resume(30), true); +}); + +run('each new completion renews retention and active follow-ups never expire', () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('first'); + requests[0].onDone({ answer: 'first answer' }); + at(29 * 60_000); + chat.submit('second'); + at(45 * 60_000); + assert.equal(chat.resume(30), false); + requests[1].onDone({ answer: 'second answer' }); + at(75 * 60_000 - 1); + assert.equal(chat.resume(30), false); + at(75 * 60_000); + assert.equal(chat.resume(30), true); +}); + +run('leaving a streaming session stops it and retains partial output, suppressing late callbacks', () => { + const { get, requests, at } = setup(); + const first = get('first'); + first.submit('question'); + requests[0].onDelta('partial answer'); + requests[0].onTool({ callId: 'tool', name: 'grep', status: 'running' }); + at(40 * 60_000); + first.suspend(); + assert.equal(requests[0].stops, 1); + assert.equal(requests[0].disposals, 1); + assert.equal(first.getSnapshot().turns[0].status, 'stopped'); + assert.equal(first.getSnapshot().turns[0].tools[0].status, 'cancelled'); + const second = get('second'); + const before = second.getSnapshot(); + requests[0].onDelta('late delta'); + requests[0].onReset(); + requests[0].onError({ message: 'late failure' }); + requests[0].onDone({ answer: 'late answer' }); + assert.equal(second.getSnapshot(), before); + at(41 * 60_000); + assert.equal(first.resume(30), false); + assert.equal(first.getSnapshot().open, true); + assert.equal(first.getSnapshot().turns[0].answer, 'partial answer'); + first.submit('follow-up'); + assert.equal(requests[1].history[1].content, 'partial answer'); + const fresh = first.getSnapshot(); + requests[0].onDone({ answer: 'late old result' }); + assert.equal(first.getSnapshot(), fresh); +}); + +for (const ending of ['success', 'error', 'stop']) { + run(`${ending} retention begins when the request ends`, () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('question'); + chat.stop(); + at(10 * 60_000); + if (ending === 'error') requests[0].onError({ message: 'model error' }); + else requests[0].onDone({ answer: 'answer', cancelled: ending === 'stop' }); + at(40 * 60_000 - 1); + assert.equal(chat.resume(30), false); + at(40 * 60_000); + assert.equal(chat.resume(30), true); + }); +} + +run('uses the newest retention setting without rewriting the last end time', () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('question'); + requests[0].onDone({ answer: 'answer' }); + at(35 * 60_000); + assert.equal(chat.resume(60), false); + assert.equal(chat.resume(10), true); +}); + +run('invalid submissions do not renew the completed conversation', () => { + const { get, requests, at } = setup(); + const chat = get('session'); + chat.submit('question'); + requests[0].onDone({ answer: 'answer' }); + at(29 * 60_000); + assert.equal(chat.submit('x'.repeat(16001)), false); + assert.equal(requests.length, 1); + at(30 * 60_000); + assert.equal(chat.resume(30), true); +}); + +run('minimized windows stay minimized and drafts without a completed chat have no expiry', () => { + const { get, at } = setup(); + const chat = get('draft'); + chat.open(); + chat.setDraft('not sent'); + chat.close(); + chat.suspend(); + at(100 * 60_000); + assert.equal(get('draft').resume(1), false); + assert.equal(chat.getSnapshot().open, false); + assert.equal(chat.getSnapshot().draft, 'not sent'); +}); + +run('clear resets the old clock and keeps other sessions intact', () => { + const { get, requests, at } = setup(); + const chat = get('clear'); + chat.submit('old question'); + requests[0].onDone({ answer: 'old answer' }); + const other = get('other'); + other.setDraft('other draft'); + chat.clear(); + chat.setDraft('fresh draft'); + at(60 * 60_000); + assert.equal(chat.resume(30), false); + assert.equal(chat.getSnapshot().draft, 'fresh draft'); + assert.equal(other.getSnapshot().draft, 'other draft'); +}); + +run('restoring a controller refreshes the transport delegate', () => { + const store = createSideChatSessionStore(); + let oldCalls = 0; + let newCalls = 0; + const chat = store.get('same', () => { oldCalls++; }); + assert.equal(store.get('same', () => { newCalls++; }), chat); + chat.submit('question'); + assert.equal(oldCalls, 0); + assert.equal(newCalls, 1); +}); diff --git a/web/src/lib/sidebarSessionTree.js b/web/src/lib/sidebarSessionTree.js index 03c257de..97db77de 100644 --- a/web/src/lib/sidebarSessionTree.js +++ b/web/src/lib/sidebarSessionTree.js @@ -1,3 +1,36 @@ +import { applyStatusUpdate, mergeSessionsWithStatus, sessionAttentionState } from './sessionStatus.js'; + +export function sidebarSessionStatusRows(sessions = []) { + const rows = new Map(); + for (const session of sessions) { + if (!session.sidebar_group) rows.set(session.id, session); + } + for (const session of sessions) { + if (session.sidebar_group) { + for (const member of session.sidebar_members || []) rows.set(member.id, { ...rows.get(member.id), ...member }); + } + } + return Array.from(rows.values()); +} + +export function applySidebarSessionStatuses(statusMap, sessions) { + return sidebarSessionStatusRows(sessions).reduce((map, session) => applyStatusUpdate(map, { + ...session, session_id: session.id, + state: session.attention_state || session.read_state || session.state, + cursor: session.status_cursor ?? session.cursor, + }), statusMap); +} + +export function mergeSidebarSessionStatuses(sessions, statusMap) { + return mergeSessionsWithStatus(sessions, statusMap).map(session => { + if (!session.sidebar_group) return session; + const unread = (session.sidebar_members || []).some(member => ( + sessionAttentionState(statusMap?.get(member.id) || member) === 'unread' + )); + return { ...session, busy: false, status: 'idle', attention_state: unread ? 'unread' : 'read', read_state: unread ? 'unread' : 'read' }; + }); +} + export function sidebarNodeKey(session) { return session?.sidebar_key || `session:${session?.id || ''}`; } @@ -33,7 +66,7 @@ export function validSidebarTreeDrop(source, target, placement, targetParent = n const parent = placement === 'inside' ? target.key : target.parent; if (placement === 'inside' && target.parent) return false; if (parent === source.key) return false; - if (parent && source.childCount > 0) return false; + if (parent && (source.group || source.childCount > 0)) return false; if (parent) { const receiver = placement === 'inside' ? target : targetParent; if (receiver?.key !== parent || !receiver.scheduled || receiver.parent) return false; diff --git a/web/src/lib/sidebarSessionTree.test.js b/web/src/lib/sidebarSessionTree.test.js index 2930ef33..6bedd2b8 100644 --- a/web/src/lib/sidebarSessionTree.test.js +++ b/web/src/lib/sidebarSessionTree.test.js @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { sidebarSessionProjection, sidebarSessionRevealLimit } from './sidebarSessions.js'; import { normalizeWorkspaceSessionListResponse } from './sidebarWorkspaceSessions.js'; -import { sidebarSessionRoots, sidebarTreeDropTarget, validSidebarTreeDrop } from './sidebarSessionTree.js'; +import { applySidebarSessionStatuses, mergeSidebarSessionStatuses, sidebarSessionRoots, sidebarSessionStatusRows, sidebarTreeDropTarget, validSidebarTreeDrop } from './sidebarSessionTree.js'; +import { applyStatusUpdate } from './sessionStatus.js'; const group = { id: 'new', workspace_hash: 'w', sidebar_key: 'loop:daily', sidebar_rank: 0, sidebar_child_count: 12 }; const history = Array.from({ length: 12 }, (_, i) => ({ @@ -47,4 +48,28 @@ assert.deepEqual(sidebarTreeDropTarget(targets, child, 60, 82), { ...other, plac assert.deepEqual(sidebarTreeDropTarget(targets, child, 60, 140), { ...single, placement: 'inside' }); assert.deepEqual(sidebarTreeDropTarget(targets, other, 60, 42), { ...child, placement: 'before' }); assert.equal(sidebarTreeDropTarget(targets, parent, 60, 140), null); +assert.equal(validSidebarTreeDrop({ ...single, group: true }, parent, 'inside'), false); + +const taskGroup = { + id: 'sidebar-group:loop:daily', sidebar_group: true, sidebar_group_name: 'Daily task', + sidebar_key: 'loop:daily', busy: false, status: 'idle', + sidebar_members: [ + { id: 'older', attention_state: 'unread', busy: false }, + { id: 'latest', attention_state: 'in_progress', busy: true }, + ], +}; +let status = applySidebarSessionStatuses(new Map(), [taskGroup]); +assert.equal(status.has(taskGroup.id), false, 'group headers are not real session status owners'); +assert.equal(sidebarSessionStatusRows([taskGroup, { id: 'latest', attention_state: 'in_progress', busy: true }]).length, 2); +let aggregate = mergeSidebarSessionStatuses([taskGroup], status)[0]; +assert.equal(aggregate.attention_state, 'unread'); +assert.equal(aggregate.busy, false); +status = applyStatusUpdate(status, { id: 'older', state: 'read' }); +aggregate = mergeSidebarSessionStatuses([taskGroup], status)[0]; +assert.equal(aggregate.attention_state, 'read', 'running children do not make their parent spin or turn green'); +assert.equal(aggregate.status, 'idle'); +status = applyStatusUpdate(status, { id: 'latest', state: 'unread' }); +assert.equal(mergeSidebarSessionStatuses([taskGroup], status)[0].attention_state, 'unread', 'collapsed groups update directly from member events'); +status = applyStatusUpdate(status, { id: 'latest', state: 'read' }); +assert.equal(mergeSidebarSessionStatuses([taskGroup], status)[0].attention_state, 'read'); console.log('[pass] sidebar tree counts roots, reveals history and only accepts children under scheduled roots'); diff --git a/web/src/lib/sidebarTitleMarqueeArchitecture.test.js b/web/src/lib/sidebarTitleMarqueeArchitecture.test.js index 2ad639bb..9749fbca 100644 --- a/web/src/lib/sidebarTitleMarqueeArchitecture.test.js +++ b/web/src/lib/sidebarTitleMarqueeArchitecture.test.js @@ -44,7 +44,7 @@ test('SessionRow measures only its non-editing title viewport', () => { assert.match(titleComponent, /metrics\.overflowing && 'is-overflowing'/); assert.match(titleComponent, /metrics\.overflowing && !animation\?\.typing && 'is-marquee-ready'/); assert.doesNotMatch(titleComponent, /]*\btitle=\{/); - assert.match(row, /const title = sessionDisplayTitle\(s, s\.name \|\| ''\);/); + assert.match(row, /const title = isGroup \? s\.sidebar_group_name \|\| tr\('定时任务'\) : sessionDisplayTitle\(s, s\.name \|\| ''\);/); assert.match( row, /aria-label=\{remoteControlBound[\s\S]*\? tr\('remoteControl\.connectedSessionAria', \{ title \}\)[\s\S]*: title\}/, diff --git a/web/src/lib/sidebarWorkspaceRefresh.test.js b/web/src/lib/sidebarWorkspaceRefresh.test.js index 39de2944..bb1feb00 100644 --- a/web/src/lib/sidebarWorkspaceRefresh.test.js +++ b/web/src/lib/sidebarWorkspaceRefresh.test.js @@ -5,6 +5,7 @@ import { setImmediate as nextTask } from 'node:timers/promises'; import { parseSync } from '@babel/core'; import * as workspaceSessions from './sidebarWorkspaceSessions.js'; import * as sidebarSessions from './sidebarSessions.js'; +import * as sidebarTree from './sidebarSessionTree.js'; import * as pinnedSessions from './pinnedSessions.js'; import * as auxiliaryFetch from './sidebarAuxiliaryFetch.js'; import { applyStatusUpdate } from './sessionStatus.js'; @@ -57,7 +58,7 @@ function fixture({ const requests = []; const context = vm.createContext({ Map, Set, Array, Promise, setTimeout, - ...workspaceSessions, ...sidebarSessions, ...pinnedSessions, ...auxiliaryFetch, + ...workspaceSessions, ...sidebarSessions, ...sidebarTree, ...pinnedSessions, ...auxiliaryFetch, applyStatusUpdate, workspaces: state.workspaces, activeWorkspaceHash: 'w', revealTarget: { noWorkspace: false, workspaceHash: 'w' }, diff --git a/web/src/lib/transcriptProjection.js b/web/src/lib/transcriptProjection.js index 81bc1ff8..8c4a36da 100644 --- a/web/src/lib/transcriptProjection.js +++ b/web/src/lib/transcriptProjection.js @@ -973,11 +973,11 @@ function appendProcessedItems(out, items, endItem) { } processed.push(item); } else { - // 跨 agent 通知保留为独立行,但不是已处理活动的分段边界。 - // 摘要锚定首个活动,避免每封邮件都重复显示整回合的持久耗时。 + // 问答卡片与跨 agent 通知保留为独立行,但不切断已处理活动。 + // 摘要锚定首个活动,避免每次提问或通知都重复显示整回合耗时。 const interAgentNotice = item?.kind === 'msg' && item.role === 'system' && interAgentEnvelope(item.metadata) !== null; - if (!interAgentNotice) flushProcessed(); + if (!interAgentNotice && !isAskUserQuestionTool(item)) flushProcessed(); out.push(item); } } diff --git a/web/src/lib/transcriptProjection.test.js b/web/src/lib/transcriptProjection.test.js index 7a6e8faa..03eb222f 100644 --- a/web/src/lib/transcriptProjection.test.js +++ b/web/src/lib/transcriptProjection.test.js @@ -1006,11 +1006,11 @@ run('蜂群通知穿插完成活动时总耗时只显示一次且通知正文保 } }); -run('蜂群通知不跨越提问、图片、普通系统行或真实用户回合合并摘要', () => { +run('蜂群通知不跨越图片、普通系统行或真实用户回合合并摘要', () => { const imageTool = tool(6); imageTool.tool.attachments = [{ kind: 'image', mime_type: 'image/png', url: '/image.png' }]; for (const boundary of [ - askQuestionTool(6), imageTool, + imageTool, { kind: 'msg', id: 6, role: 'system', content: '/root/worker 发来消息' }, user(6, 'next turn'), ]) { @@ -1047,16 +1047,13 @@ run('final assistant text 前的 AskUserQuestion 卡片不折叠进已处理', ( assistant(6, 'Final answer kept'), ]); - assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'activity_summary', 'msg']); + assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'msg']); assert.equal(projected[1].mode, 'processed'); - assert.deepEqual(projected[1].coveredItemIds, [2, 3]); + assert.deepEqual(projected[1].coveredItemIds, [2, 3, 5]); assert.equal(projected[2].tool.tool, 'AskUserQuestion'); assert.equal(projected[2].tool.askUserQuestionResult.items.length, 2); - assert.equal(projected[3].mode, 'processed'); - assert.deepEqual(projected[3].coveredItemIds, [5]); - assert.equal(projected[4].content, 'Final answer kept'); + assert.equal(projected[3].content, 'Final answer kept'); assert.equal(projected[1].collapsedItems.some((item) => item.id === 4), false); - assert.equal(projected[3].collapsedItems.some((item) => item.id === 4), false); }); run('task_complete 前的 AskUserQuestion 卡片不折叠进已处理', () => { @@ -1070,17 +1067,14 @@ run('task_complete 前的 AskUserQuestion 卡片不折叠进已处理', () => { taskComplete(7, 'all done'), ]); - assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'activity_summary', 'msg', 'completion_summary']); + assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'msg', 'completion_summary']); assert.equal(projected[1].mode, 'processed'); - assert.deepEqual(projected[1].coveredItemIds, [2, 3]); + assert.deepEqual(projected[1].coveredItemIds, [2, 3, 5]); assert.equal(projected[2].tool.tool, 'AskUserQuestion'); assert.equal(projected[2].tool.askUserQuestionResult.items.length, 2); - assert.equal(projected[3].mode, 'processed'); - assert.deepEqual(projected[3].coveredItemIds, [5]); - assert.equal(projected[4].content, 'Final answer kept'); - assert.equal(projected[5].title, '总结:all done'); + assert.equal(projected[3].content, 'Final answer kept'); + assert.equal(projected[4].title, '总结:all done'); assert.equal(projected[1].collapsedItems.some((item) => item.id === 4), false); - assert.equal(projected[3].collapsedItems.some((item) => item.id === 4), false); }); run('无 final assistant 时 AskUserQuestion 卡片也不折叠进已处理', () => { @@ -1092,15 +1086,69 @@ run('无 final assistant 时 AskUserQuestion 卡片也不折叠进已处理', () taskComplete(5, 'all done'), ]); - assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'activity_summary', 'completion_summary']); + assert.deepEqual(projected.map((item) => item.kind), ['msg', 'activity_summary', 'tool', 'completion_summary']); assert.equal(projected[1].mode, 'processed'); - assert.deepEqual(projected[1].coveredItemIds, [2]); + assert.deepEqual(projected[1].coveredItemIds, [2, 4]); assert.equal(projected[2].tool.tool, 'AskUserQuestion'); - assert.equal(projected[3].mode, 'processed'); - assert.deepEqual(projected[3].coveredItemIds, [4]); - assert.equal(projected[4].title, '总结:all done'); + assert.equal(projected[3].title, '总结:all done'); assert.equal(projected[1].collapsedItems.some((item) => item.id === 3), false); - assert.equal(projected[3].collapsedItems.some((item) => item.id === 3), false); +}); + +run('连续问答与蜂群通知共用一条整回合耗时摘要且问答结果独立可见', () => { + const questions = [askQuestionTool(3), askQuestionTool(6), askQuestionTool(8)]; + questions[1].tool.success = false; + questions[1].tool.askUserQuestionResult = { cancelled: true, items: [] }; + questions[2].tool.askUserQuestionResult = { interjected: true, items: [] }; + for (const name of ['AskUserQuestion', '', 'renamed_question']) { + questions.forEach((item) => { + item.tool.tool = name; + item.tool.toolCallId = `question-${item.id}`; + }); + const activity = [assistant(2, 'inspect'), tool(4), assistant(7, 'continue'), tool(9)] + .map((item) => ({ ...item, turnDurationMs: 54027000 })); + const notice = interAgentNotice(5); + const prefix = [user(1), activity[0], questions[0], activity[1], notice, + questions[1], activity[2], questions[2], activity[3]]; + for (const ending of [ + [assistant(10, 'final')], + [assistant(10, 'final'), taskComplete(11)], + [taskComplete(11)], + [assistant(10, 'final'), taskComplete(11), { kind: 'msg', id: 12, role: 'system', content: 'notice' }], + ]) { + const input = [...prefix, ...ending]; + const before = structuredClone(input); + const projected = projectCollapsedTranscriptItems(input); + const summaries = projected.filter((item) => item.mode === 'processed'); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].title, '已处理 900m 27s'); + assert.deepEqual(summaries[0].coveredItemIds, [2, 4, 7, 9]); + assert.deepEqual(summaries[0].collapsedItems, activity); + assert.deepEqual(projected.filter((item) => item.kind === 'tool'), questions); + assert.deepEqual(projected.filter((item) => item.metadata?.inter_agent), [notice]); + assert.equal(summaries[0].detailItems.some((item) => questions.includes(item)), false); + assert.equal(projected[1], summaries[0]); + assert.deepEqual(input, before); + } + } +}); + +run('连续问答的时间戳兜底只显示一次且不生成空摘要', () => { + const questions = [askQuestionTool(3), askQuestionTool(5)]; + const projected = projectCollapsedTranscriptItems([ + user(1), tool(2), questions[0], tool(4), questions[1], tool(6), assistant(7, 'final'), + ]); + assert.deepEqual(projected.filter((item) => item.mode === 'processed').map((item) => item.title), ['已处理 5s']); + const noActivity = [user(1), ...questions, assistant(7, 'final')]; + assert.deepEqual(projectCollapsedTranscriptItems(noActivity), noActivity); +}); + +run('运行中或关闭折叠的连续问答保持原有活动顺序', () => { + const items = [user(1), tool(2), askQuestionTool(3), tool(4), askQuestionTool(5), tool(6, { isDone: false })]; + const live = projectCollapsedTranscriptItems(items, { deferTrailingToolSummary: true }); + assert.equal(live.some((item) => item.mode === 'processed'), false); + assert.deepEqual(live.map((item) => item.id), [1, 'activity:tools:2', 3, 'activity:tools:4', 5, 'activity:tools:6']); + assert.equal(live.at(-1).mode, 'live'); + assert.deepEqual(projectCollapsedTranscriptItems(items, { messageAutoCollapse: false }), items); }); run('大折叠展开内容保留内部工具小折叠层级', () => { diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index d9d78b13..76422b0f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -2316,11 +2316,24 @@ body { } /* 会话行右侧是一个槽位,不是两栏:静止时显示日期(或「权限请求 / 等待回复」 徽标),hover / 键盘聚焦时整块换成置顶与归档图标。两者互斥,所以静止态不给 - 图标预留宽度,标题能多占一截。定时任务 / 工作树会话的标记图标是状态而非 - 悬停操作,始终显示。 */ + 图标预留宽度,标题能多占一截。实际定时 / 工作树会话的来源标记与归档共用 + 一个位置;分组标题的独立闹钟始终显示。 */ .ace-sidebar-session-row:is(:hover, :focus-within) .ace-sidebar-row-idle-slot { display: none; } +/* Keep row actions available before native CSS nesting (WebView 110). */ +.ace-sidebar-session-row:hover .ace-sidebar-row-hover-action, +.ace-sidebar-session-row:focus-within .ace-sidebar-row-hover-action { + display: flex; +} +.ace-sidebar-session-row:hover .ace-sidebar-session-source-icon, +.ace-sidebar-session-row:focus-within .ace-sidebar-session-source-icon { + opacity: 0; +} +.ace-sidebar-session-row:hover .ace-sidebar-session-archive-icon, +.ace-sidebar-session-row:focus-within .ace-sidebar-session-archive-icon { + opacity: 1; +} .ace-sidebar-session-row { position: relative; } @@ -2637,13 +2650,18 @@ body { } .ace-sidebar-session-tree-node { position: relative; } .ace-sidebar-session-tree-children { - margin-left: 18px; - padding-left: 6px; - border-left: 1px solid var(--ace-border); + margin: 0; + padding: 0; + border: 0; +} +.ace-sidebar-session-tree-node.is-tree-expanded { background: var(--ace-accent-bg); border-radius: 6px; } +.ace-sidebar-session-tree-node.is-tree-expanded .ace-sidebar-session-row.is-tree-active { + background: rgba(var(--ace-accent-rgb), 0.18); +} +.ace-sidebar-session-tree-node.is-tree-expanded .ace-sidebar-session-row:not(.is-tree-active):hover { + background: rgba(var(--ace-accent-rgb), 0.08); } -.ace-sidebar-session-tree-child:not(.is-tree-active) { background: rgba(0, 0, 0, 0.04); } -[data-theme="dark"] .ace-sidebar-session-tree-child:not(.is-tree-active) { background: rgba(0, 0, 0, 0.20); } -.ace-sidebar-session-tree-child:not(.is-tree-active):hover { background: var(--ace-surface-hi); } +.ace-sidebar-session-row[data-sidebar-tree-group="true"] .ace-sidebar-row-idle-slot { display: inline; } .ace-sidebar-session-tree-chevron { pointer-events: none; } .ace-sidebar-session-tree-chevron.is-expanded { transform: rotate(90deg); } .ace-sidebar-session-row.is-drop-inside {