diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 455a8a8d9..029612bf1 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -1298,6 +1298,16 @@ on demand, external-session polling uses `after`, and completion self-healing reads only a bounded tail (plus one older page if the user-turn anchor is absent). Full export, fork and explicit session references keep the legacy full read. +Conversation error messages (`role: "error"`) are persisted as transcript-only +records with `metadata.transcript_only: true`, a unique `uuid`, and a timestamp. +Their `id` is that UUID in both history and `message` events; original text and +`metadata.provider_error` details are preserved. Clients reconcile these errors +by ID, so history/event overlap produces one card while separate identical +failures remain separate cards. Legacy unmarked error events retain sequence +identity because their content-derived IDs can repeat. These records are visible +after switching sessions, refresh, and restart, but are excluded from provider +history and compaction input. Errors lost by older versions are not backfilled. + Visible system messages may include `metadata.system_notice` with `{ "version": 1, "code": "goal_started", "params": { "goal": { ... } } }`. The stable event code and structured parameters describe the notice; Web and @@ -2903,11 +2913,12 @@ cannot be disabled and return `409 {"error":"HOOK_MANAGED"}`. ``` `api_protocol` 仅适用于 `provider:"openai"`,接受 `chat_completions` 或 -`responses`;省略时继续使用 Chat Completions。模型设置的 **API 协议** 下拉框可 +`responses`;省略时,目录身份 `models_dev_provider_id:"acemodel"` 使用 Responses, +其他 OpenAI 配置继续使用 Chat Completions。模型设置的 **API 协议** 下拉框可 选择 Responses,保存和连接检测使用同一协议。`base_url` 模式下 Responses 会向 基础地址追加 `/responses`;`full_url` 模式直接使用给定 URL。协议由该字段决定, 不会根据 URL 或模型名称自动推断。更新时省略该字段保留原值,传 `null` 清除并恢复 -默认 Chat Completions;切换到其他 Provider 会清除该字段,其他 Provider 显式传入 +该 Provider 的默认协议(ACEModel 为 Responses);切换到其他 Provider 会清除该字段,其他 Provider 显式传入 则返回 `UNSUPPORTED_MODEL_OPTION`,未知协议值返回 `INVALID_API_PROTOCOL`。 返回包含 `api_key` 原值的模型配置。校验错误使用 `BAD_JSON`、`BAD_REQUEST` 或 @@ -3093,7 +3104,9 @@ Errors include `COPILOT_AUTH_REQUIRED`, `GROK_AUTH_REQUIRED`, `none` 或 `managed`。Custom OpenAI-compatible Provider 明确支持 `endpoint_modes:["base_url","full_url"]`,并要求 API Key 或兼容的 `credential_source_name`。一等自营 Provider `acemodel`(展示名 ACEModel) -使用与 OpenAI 相同的 OpenAI-compatible 字段,固定 Base URL 为 +返回 `default_api_protocol:"responses"`,模型设置新增和批量新增时采用该默认值, +用户可在 **API 协议** 中修改;已有明确协议选择在加载及安装器升级时保持。 +ACEModel 使用与 OpenAI 相同的 OpenAI-compatible 字段,固定 Base URL 为 `https://ge.bigjuan.xyz/aceapi/v1`,`group` 为 `custom`(Web 再按 id 提到「自营模型」),查询时返回内置 `starrylight`、`moonlight` 与 `aurora`,三者本地回退 `context_window` 均为 `250000`,且默认返回 `capabilities:["vision","tool_use"]`;模型探测得到的有效服务器值优先。Copilot 与 Grok Coding Plan 使用 `managed`,分别由 ACECode 的 GitHub/xAI 设备登录与固定受管端点负责认证。普通 `xai` Provider diff --git a/docs/releases/v1.0.3.md b/docs/releases/v1.0.3.md new file mode 100644 index 000000000..ae30e6fa9 --- /dev/null +++ b/docs/releases/v1.0.3.md @@ -0,0 +1,7 @@ +# ACECode v1.0.3 + +- 修复模型服务报错后切换会话、刷新或重启导致红框提示丢失的问题。HTTP 451 额度用尽等错误现在会保存在会话记录中,保留原始详情;这些记录不会发送给模型或参与上下文压缩。同一次错误重放不会重复显示,不同回合的相同错误分别保留。 +- ACEModel 默认采用 Responses 协议,模型设置中仍可切换为 Chat Completions。旧配置仅在未明确指定协议时采用新默认值,保留已有选择;安装器、模型目录和连接检测同步使用有效协议。 +- 统一生成过程中的输入框主按钮:空草稿显示实心方块停止图标,有文字或附件时显示排队图标,回车或点击即可排队。按钮始终保持主题色背景,并保留停止在途、暂停队列和键盘操作反馈。 + +升级后请完整重启 ACECode;远端使用场景也需更新运行会话的服务器。旧版本未保存的错误提示无法补回。Agent Browser 集成于 Desktop,无需独立浏览器宿主或扩展。 diff --git a/installer/windows/seed_acemodel.ps1 b/installer/windows/seed_acemodel.ps1 index e6b06dfa4..74cfe447f 100644 --- a/installer/windows/seed_acemodel.ps1 +++ b/installer/windows/seed_acemodel.ps1 @@ -108,6 +108,7 @@ function New-AceModelProfile([string]$name, [string]$key) { $profile["api_key"] = $key $profile["models_dev_provider_id"] = "acemodel" $profile["endpoint_mode"] = "base_url" + $profile["api_protocol"] = "responses" $profile["context_window"] = 250000 $profile["capabilities"] = @("vision", "tool_use") $profile["capabilities_source"] = "catalog" @@ -129,6 +130,9 @@ function Upsert-AceModel($models, [string]$name, [string]$key) { $current["api_key"] = $key $current["models_dev_provider_id"] = "acemodel" $current["context_window"] = 250000 + if (-not (Config-HasKey $current "api_protocol") -or [string]::IsNullOrWhiteSpace([string]$current["api_protocol"])) { + $current["api_protocol"] = "responses" + } if (-not (Config-HasKey $current "endpoint_mode") -or [string]::IsNullOrWhiteSpace([string]$current["endpoint_mode"])) { $current["endpoint_mode"] = "base_url" } diff --git a/installer/windows/seed_acemodel.test.ps1 b/installer/windows/seed_acemodel.test.ps1 index 65c1a48d0..3c6e04113 100644 --- a/installer/windows/seed_acemodel.test.ps1 +++ b/installer/windows/seed_acemodel.test.ps1 @@ -40,6 +40,7 @@ try { "name": "starrylight", "provider": "openai", "model": "starrylight", + "api_protocol": "chat_completions", "base_url": "https://old.example/v1", "api_key": "old-key", "capabilities": ["tool_use"], @@ -75,6 +76,9 @@ try { } } foreach ($profile in @($moon, $aurora)) { + if ($profile.api_protocol -ne "responses") { + throw "default Responses protocol was not set for $($profile.name)" + } if ((@($profile.capabilities) -join ",") -ne "vision,tool_use" -or $profile.capabilities_source -ne "catalog") { throw "default vision/tool capabilities were not set for $($profile.name)" } @@ -82,6 +86,9 @@ try { if ((@($star.capabilities) -join ",") -ne "tool_use" -or $star.capabilities_source -ne "manual") { throw "manual ACEModel capabilities were overwritten" } + if ($star.api_protocol -ne "chat_completions") { + throw "explicit Chat Completions selection was overwritten" + } if ($config.default_model_name -ne "moonlight") { throw "default model was not seeded" } @@ -99,6 +106,16 @@ try { if (@($qwen.capabilities).Count -ne 1 -or @($qwen.capabilities)[0] -ne "tool_use") { throw "existing qwen capabilities were damaged" } + if ($qwen.PSObject.Properties.Name -contains "api_protocol") { + throw "unrelated qwen protocol was changed" + } + & powershell -NoProfile -ExecutionPolicy Bypass -File $seeder -KeyFile $keyFile -ConfigPath $configPath + if ($LASTEXITCODE -ne 0) { throw "second seeder run exited $LASTEXITCODE" } + $reseeded = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json + foreach ($profile in $reseeded.saved_models | Where-Object { $_.PSObject.Properties.Name -contains "models_dev_provider_id" -and $_.models_dev_provider_id -eq "acemodel" }) { + $expected = if ($profile.name -eq "starrylight") { "chat_completions" } else { "responses" } + if ($profile.api_protocol -ne $expected) { throw "reseed changed explicit protocol for $($profile.name)" } + } Write-Host "[pass] ACEModel seeder writes and upgrades all three profiles" } finally { diff --git a/openspec/changes/default-acemodel-responses-protocol/.openspec.yaml b/openspec/changes/default-acemodel-responses-protocol/.openspec.yaml new file mode 100644 index 000000000..81264324b --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/default-acemodel-responses-protocol/design.md b/openspec/changes/default-acemodel-responses-protocol/design.md new file mode 100644 index 000000000..857135c89 --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/design.md @@ -0,0 +1,26 @@ +## Context + +See proposal.md. The existing ModelProfile.api_protocol field, Responses Provider, API protocol selector, mutation validation and fingerprinting already support explicit selection. Provider factory currently defaults every missing protocol to Chat Completions. ACEModel's catalog descriptor does not supply a default; the Windows seeder also omits it. + +## Goals / Non-Goals + +Goals: share protocol resolution across configuration, runtime and API defaults; retain explicit selections; use the existing accessible selector and settings styles. + +Non-goals: infer identity from model names or URLs, modify credentials or capability metadata, implement another Responses transport, publish or restart the installed Desktop application. + +## Decisions + +1. Add a shared effective protocol helper in saved_models: an explicit value wins; an OpenAI profile with a case-insensitive ACEModel catalog identity defaults to Responses; other profiles default to Chat Completions. Using the capability-normalization predicate would incorrectly exclude manual capabilities and additional ACEModel model IDs. +2. Materialize missing ACEModel defaults when parsing saved configurations and report the effective default from model-list serialization. Keep the model editor's existing omitted/null semantics: null removes an override, then effective resolution uses the provider default. Factory uses the helper for ordinary calls, compaction and connection tests; protocol fingerprints already encode the effective request option. +3. Add optional default_api_protocol to the ACEModel catalog descriptor. Frontend normalization validates it; provider selection initializes the draft from that field and retains existing reset behavior for other providers. Every model in a batch inherits the selected protocol. Existing saved entries come from the daemon with the effective protocol. +4. Seeder writes Responses for new presets and only fills absent/empty protocol values on upgrade. Existing explicit Chat Completions remains a valid fallback. + +## Risks / Trade-offs + +- Missing-field ACEModel configurations change protocol at upgrade -> preserve explicit choices and expose Chat Completions in the existing selector. +- Default and display diverge for profiles created through TUI or direct API -> serialize effective ACEModel defaults and test in-memory profiles, parser and factory paths. +- A prior factory test assumes ACEModel always uses Chat Completions -> make that test explicitly select Chat Completions and add Responses factory coverage. + +## Migration Plan + +No direct edits to user configuration files. Load normalizes only missing ACEModel protocol values; subsequent ordinary saves persist them. Users can select Chat Completions to revert a profile. Validate targeted C++ HTTP/configuration/API tests, seeder regression, frontend tests/build, strict OpenSpec and diff checks. The current build/ Visual Studio Release directory is reusable; compile with --parallel 1. diff --git a/openspec/changes/default-acemodel-responses-protocol/proposal.md b/openspec/changes/default-acemodel-responses-protocol/proposal.md new file mode 100644 index 000000000..c5ee00ce3 --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/proposal.md @@ -0,0 +1,24 @@ +## Why + +ACECode 已支持 Responses,但 ACEModel 新建模型和未声明协议的旧配置仍默认使用 Chat Completions。用户希望 ACEModel 默认使用 Responses,并能在模型设置中修改协议。 + +## What Changes + +- ACEModel 的 OpenAI 运行时配置在未声明协议时默认使用 Responses;明确选择的协议优先。 +- 模型目录提供默认协议,新增及批量新增模型使用该默认值,编辑继续使用已有的 API 协议选择框。 +- Windows 安装器新建及升级缺省 ACEModel 配置时写入 Responses,保留已有协议选择。 +- 普通 OpenAI 兼容配置继续使用原默认值;默认协议依据目录身份,独立于模型名称、URL 及能力设置。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `web-model-management`: ACEModel 默认协议、设置修改、旧配置及安装器的一致性。 + +## Impact + +涉及保存模型配置解析、Provider 工厂、模型目录及列表 API、前端模型草稿、Windows seeder 和相关测试。复用现有 Responses 实现;不改变认证、能力及上下文信息。本变更针对 ACEModel 覆盖 `add-openai-responses-protocol` 的统一缺省 Chat Completions 约定。 diff --git a/openspec/changes/default-acemodel-responses-protocol/specs/web-model-management/spec.md b/openspec/changes/default-acemodel-responses-protocol/specs/web-model-management/spec.md new file mode 100644 index 000000000..6de2aa351 --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/specs/web-model-management/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: ACEModel configurable Responses default + +ACECode SHALL default OpenAI runtime profiles identified by catalog provider `acemodel` to Responses when `api_protocol` is omitted. Explicit `chat_completions` or `responses` selections MUST take precedence. This default SHALL apply consistently to new profiles, loaded legacy profiles, runtime requests, connection tests and Windows installer presets, independently of model names, endpoint URLs and capability overrides. Other providers SHALL retain their existing default protocol. + +#### Scenario: Create ACEModel profiles +- **WHEN** a user selects ACEModel and one or multiple models in model settings +- **THEN** the API protocol selector defaults to Responses and saving preserves that protocol on every created profile + +#### Scenario: Load a legacy ACEModel profile +- **WHEN** an OpenAI profile with catalog identity `acemodel` has no explicit API protocol +- **THEN** the runtime and model settings use Responses, including profiles with manual capabilities or a proxy endpoint + +#### Scenario: Change and retain a protocol selection +- **WHEN** the user selects Chat Completions or Responses in an ACEModel profile's API protocol selector and saves it +- **THEN** inference, connection tests and subsequent edits use the saved selection, and loading or installer upgrades preserve it + +#### Scenario: Clear an override +- **WHEN** an API update clears an ACEModel profile's explicit protocol with `null` +- **THEN** its effective protocol returns to the ACEModel default of Responses + +#### Scenario: Switch providers +- **WHEN** a user switches the model form between ACEModel and another provider +- **THEN** the new provider's default replaces the preceding protocol selection without carrying Responses into other providers implicitly + +#### Scenario: Seed installer profiles +- **WHEN** the Windows installer creates ACEModel presets or upgrades presets without an explicit protocol +- **THEN** it writes Responses and leaves existing explicit protocol choices and unrelated profiles intact diff --git a/openspec/changes/default-acemodel-responses-protocol/tasks.md b/openspec/changes/default-acemodel-responses-protocol/tasks.md new file mode 100644 index 000000000..ee083d6e0 --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/tasks.md @@ -0,0 +1,13 @@ +## 1. 默认协议与运行时 + +- [x] 1.1 实现共享 ACEModel 协议默认值、旧配置加载与模型列表/目录 API;通过配置及目录契约回归测试验证缺省、明确覆盖及其他 Provider 行为。 +- [x] 1.2 Provider 工厂使用有效默认协议;通过本地 HTTP 测试验证 Responses 的流式/非流式请求、连接检测协议和明确切回 Chat Completions。 + +## 2. 设置与安装器 + +- [x] 2.1 前端采用目录默认协议并复用 API 协议选择框;通过草稿、批量保存、编辑、切换 Provider 的 JS 回归及界面检查验证。 +- [x] 2.2 Windows seeder 写入缺省 Responses 并保留明确选择;运行 seed_acemodel.test.ps1 验证新建、升级与无关配置保留。 + +## 3. 集成验证 + +- [x] 3.1 更新 Daemon API 文档并完成定向 C++ 测试、pnpm test、pnpm build、严格 OpenSpec 校验及 git diff --check;记录验证结果及运行环境边界。 diff --git a/openspec/changes/default-acemodel-responses-protocol/verification.md b/openspec/changes/default-acemodel-responses-protocol/verification.md new file mode 100644 index 000000000..bb622eb22 --- /dev/null +++ b/openspec/changes/default-acemodel-responses-protocol/verification.md @@ -0,0 +1,22 @@ +# 验证记录 + +日期:2026-10-09,当前 master 检出。 + +## 已完成 + +- `cmake --build build --config Release --target acecode_unit_tests --parallel 1`:在 `scripts/dev_windows_env.bat` 初始化的 MSVC 环境中构建成功。首次新增测试误用 `chat(..., true)`,修正为已有 `chat_for_compaction(..., nullptr)` 后增量重编成功。 +- `build/tests/Release/acecode_unit_tests.exe --gtest_filter=SavedModelsTest.*:SavedModelsEditor*.*:ModelCatalogHandler.*:ModelsHandler.*:ModelProfileRuntimeOptions.*:OpenAiResponsesProviderTest.*:ModelConnectionTest.* --gtest_color=no`:7 个 suite、174 项全部通过。 +- `node web/src/lib/modelSettings.test.js`:包含 ACEModel 目录默认值、批量保存、编辑及服务商切换的回归通过。 +- `pnpm test`:3,080 项 Web 检查通过。 +- `pnpm build`:Vite 构建和正则兼容检查通过。 +- `powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/seed_acemodel.test.ps1`:新建、升级、重复运行保留明确协议、无关配置保留均通过。 +- Headless Edge 使用真实 `ModelProfileDialog` 与当前 Vite 源码:4 项交互通过,覆盖默认 Responses 和三模型保存、编辑/检测/保存协议一致、明确 Chat Completions 回填后再切回 Responses、切换 Provider 恢复默认;无 pageerror。 +- `python scripts/refactor/check_layer_libraries.py --build-dir build`:生产库分层检查通过。 +- `openspec validate default-acemodel-responses-protocol --strict`:通过。 +- `git diff --check`:通过。 + +## 验证边界 + +HTTP 回归使用本机 fixture,确认实际端点和请求体;浏览器检查使用 fixture API。未发送真实 ACEModel 付费请求。已验证源代码与 Web 构建,未更新当前运行的已安装 Desktop 软件。当前仓库规则忽略 `openspec/` 中新建文件,变更文档保存在本地既定目录中。 + +构建出现既有共享 Intermediate 目录警告;使用串行构建避免并发链接冲突。未提交或发布本次改动。 diff --git a/openspec/changes/persist-session-error-notices/.openspec.yaml b/openspec/changes/persist-session-error-notices/.openspec.yaml new file mode 100644 index 000000000..53f62d706 --- /dev/null +++ b/openspec/changes/persist-session-error-notices/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/persist-session-error-notices/design.md b/openspec/changes/persist-session-error-notices/design.md new file mode 100644 index 000000000..460ee9eab --- /dev/null +++ b/openspec/changes/persist-session-error-notices/design.md @@ -0,0 +1,27 @@ +## Context + +See proposal.md for the incident. Agent error messages use `TranscriptWriter::dispatch_message`, which emits callbacks and events without storing a ChatMessage. The Web reducer deliberately treats old error IDs as content-derived event identities. Durable transcript records already support `metadata.transcript_only`, and provider history filtering excludes these records and non-provider roles. + +## Goals / Non-Goals + +**Goals:** Persist every emitted red error message before publishing it; preserve diagnostics and independent occurrences; restore through ordinary transcript reads; keep current, resumed, and compacted model requests free of these records. + +**Non-Goals:** Changing quota detection, retry policy, error-card design, or reconstructing errors never written by older versions. Transport connection errors and tool-result errors retain their existing semantics. + +## Decisions + +1. Inject the loop's nullable borrowed SessionManager into TranscriptWriter at construction. At the shared error dispatch boundary create a UUID, timestamp, and transcript-only metadata, append to history and session storage, then emit the existing callback/event. This covers all red error messages rather than only HTTP 451. A browser cache was rejected because it misses background failures and does not survive a daemon restart. +2. Use UUID identity for error records when available; retain existing content-derived IDs for legacy records and all other roles. A timestamp alone is insufficient for two equal failures within the same clock interval. +3. Distinguish durable errors by `role=error`, `metadata.transcript_only=true`, and a nonempty ID. Reconcile these by ID in both replay paths; bypass content-only deduplication for different error IDs. Unmarked legacy errors retain occurrence/sequence semantics. +4. Reuse the existing provider-history filter, and test both the actual next provider request and resumed history. No UI style changes or additional context protocol is required. + +## Risks / Trade-offs + +- [Duplicate live/history cards] -> Assert the persisted UUID equals the event/API ID and cover overlapping REST plus WebSocket replay. +- [Loss of equal errors] -> Separate UUIDs for each dispatch; test identical content in separate turns. +- [Accidental prompt contamination] -> Assert transcript-only markers and provider request exclusion before and after resume/compaction. +- [Existing dirty files] -> Restrict edits to this feature and preserve pre-existing daemon documentation changes. + +## Migration Plan + +Deploy backend and Web bundle together. Records are additive JSONL messages understood by existing storage; no migration is required. Old clients still display errors, but may duplicate overlapping replay until upgraded. Rollback leaves durable records safely excluded from model context. diff --git a/openspec/changes/persist-session-error-notices/proposal.md b/openspec/changes/persist-session-error-notices/proposal.md new file mode 100644 index 000000000..d39399937 --- /dev/null +++ b/openspec/changes/persist-session-error-notices/proposal.md @@ -0,0 +1,21 @@ +## Why + +Provider failures such as HTTP 451 quota exhaustion currently appear only as transient error events. Switching sessions or reloading history loses the red error card, making the failed turn look silently interrupted. + +## What Changes + +- Persist each emitted error message with its diagnostic metadata and a unique occurrence ID in the session transcript. +- Keep these records out of model context, including resumed sessions and compaction. +- Restore the existing error cards from history and reconcile live/replayed events by persisted identity without merging separate identical failures. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `session-storage`: Durable transcript-only error notices, with consistent history and live-event identity. + +## Impact + +Agent transcript writer and composition, Web message identity/replay, session-history integration tests, and daemon API documentation. Existing red error presentation is reused. Previously lost errors cannot be reconstructed without an existing record. diff --git a/openspec/changes/persist-session-error-notices/specs/session-storage/spec.md b/openspec/changes/persist-session-error-notices/specs/session-storage/spec.md new file mode 100644 index 000000000..21ebc4a03 --- /dev/null +++ b/openspec/changes/persist-session-error-notices/specs/session-storage/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Durable transcript-only error notices + +The system SHALL persist each emitted conversation error notice with its original text, diagnostic metadata, timestamp, and unique occurrence identity. History reads after session switching, refresh, or daemon restart SHALL restore the existing error presentation. These notices MUST NOT be included in model requests or compaction input, including after session resume. + +#### Scenario: Quota failure while another session is selected +- **WHEN** a background session receives a terminal HTTP 451 quota failure +- **THEN** opening that session SHALL display the stored red error notice and its original diagnostic details + +#### Scenario: Reload and resume after a failure +- **WHEN** a session with an error notice is reloaded or resumed after restart +- **THEN** the notice SHALL remain visible in its transcript position +- **AND** the next model request SHALL exclude the notice and its diagnostic content + +#### Scenario: Repeated identical failures +- **WHEN** separate turns emit errors with identical content +- **THEN** history SHALL retain each occurrence with a distinct identity + +#### Scenario: History overlaps event replay +- **WHEN** a persisted error appears in both loaded history and replayed or live events +- **THEN** the client SHALL display that occurrence exactly once +- **AND** a later error with identical text and a different identity SHALL remain visible + +#### Scenario: Compatibility with legacy transient errors +- **WHEN** an older daemon emits identical error events without a persisted identity marker +- **THEN** the client SHALL retain distinct event occurrences and deduplicate replays of the same sequence diff --git a/openspec/changes/persist-session-error-notices/tasks.md b/openspec/changes/persist-session-error-notices/tasks.md new file mode 100644 index 000000000..7447e0ed6 --- /dev/null +++ b/openspec/changes/persist-session-error-notices/tasks.md @@ -0,0 +1,9 @@ +## 1. Durable errors + +- [x] 1.1 Persist error notices at the shared transcript dispatch boundary with UUID identity and transcript-only metadata; verify native tests cover disk reload, repeat failures, event identity, and exclusion from current/resumed/compacted model context. +- [x] 1.2 Reconcile persisted errors by ID in Web history and replay while preserving legacy occurrence semantics; verify reducer regression tests for switching, reload, overlapping replay, and repeated identical failures. + +## 2. Delivery checks + +- [x] 2.1 Document the additive daemon message contract and verify strict OpenSpec validation plus diff checks. +- [x] 2.2 Run relevant Windows native regression tests, full Web tests, Web build, and a browser check of restored red error cards; record results and runtime limitations. diff --git a/openspec/changes/persist-session-error-notices/verification.md b/openspec/changes/persist-session-error-notices/verification.md new file mode 100644 index 000000000..96eab9b58 --- /dev/null +++ b/openspec/changes/persist-session-error-notices/verification.md @@ -0,0 +1,11 @@ +# Verification + +- Web transcript reducer regression: passed, including restored HTTP 451 diagnostics, transcript replacement, overlapping history/replay/live events, independent equal errors, and legacy transient errors. +- `pnpm test`: passed (3084 pass lines). +- `pnpm build`: passed, including bundled regular-expression compatibility checks. +- `node web/scripts/test-session-errors.mjs`: passed with the real ChatView and controlled HTTP/WebSocket fixtures. Covered a failure while viewing another session, switching back, page refresh, duplicate replay, and a repeated identical failure. No browser page errors. Screenshot: `build/validation/session-errors/restored-errors.png`. +- Strict OpenSpec validation and scoped `git diff --check`: passed. +- Windows Release native build: passed with `cmake --build build --config Release --target acecode_unit_tests --parallel 1` in the Visual Studio development environment. +- Windows native regression: 141/141 passed. Covered error disk reload/resume, unique event/API identity, original diagnostic metadata, next-turn provider filtering, actual compaction requests, legacy message identity, turn timing, recovery callbacks, session resume, and conversation history. Results: `build/validation/session-errors/native-tests.json`. An initial run exposed a missing non-streaming method in the new test stub and callback/event ordering drift; both were corrected before the final passing run. + +The browser check uses isolated simulated responses, not the affected remote server. This change has not been packaged, published, or installed into a running Desktop process. Older errors that were never persisted are not reconstructed. diff --git a/openspec/changes/unify-busy-composer-action/.openspec.yaml b/openspec/changes/unify-busy-composer-action/.openspec.yaml new file mode 100644 index 000000000..81264324b --- /dev/null +++ b/openspec/changes/unify-busy-composer-action/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/unify-busy-composer-action/design.md b/openspec/changes/unify-busy-composer-action/design.md new file mode 100644 index 000000000..98127b605 --- /dev/null +++ b/openspec/changes/unify-busy-composer-action/design.md @@ -0,0 +1,23 @@ +## Context + +`InputBar` 当前分别渲染红色中断按钮及运行中带文字的排队按钮;空闲发送按钮使用另一段 JSX。`inputBarState` 集中判定文本、附件、禁用、提交请求和暂停队列继续语义,`getGoalStopControlState` 保留停止请求在途状态。动机见 proposal.md。 + +## Goals / Non-Goals + +**Goals:** 让主动作的模式由已有纯状态函数统一决定,按钮只负责显示和分派动作;复用现有图标体系及主题变量。 + +**Non-Goals:** 不修改后端、队列管理、停止快捷键或富文本键盘适配器,不改变空闲发送/重试/继续语义。 + +## Decisions + +1. 为 `getInputBarActionState` 增加 `stop` 模式,使用已存在的 `hasText` 与 `hasExtras` 判定真正空草稿。直接在 JSX 另判文本会遗漏附件和上下文。 +2. `canSubmit` 继续只表示发送/排队/继续资格,停止模式保持 false。按钮点击按模式调用 `onAbort` 或既有 `submit`;Enter 继续调用 `submit`,避免空框回车突然中断任务。 +3. 主按钮统一为现有 28px 圆形按钮,始终 `bg-accent`,使用对应 ARIA 标签、tooltip、焦点样式。停止请求在途复用已有停止状态,提交请求不会阻挡停止。 +4. 新增 `Queue` 图标及 `queue` 语义别名,使用三条队列线加折向队尾的箭头;通过图标生成脚本导出 SVG。为本按钮添加填充的 `StopFilled` 方形图标,不改变其他界面的既有 `Stop` 轮廓图标。 + +## Risks / Trade-offs + +- 隐藏独立停止按钮后,有草稿时需清空草稿或使用已有停止快捷键来中断 → 主按钮精确遵循用户指定的内容切换,保留现有停止快捷键。 +- 文本为空但附件存在时可能误判停止 → 使用现有 `hasExtras`,补纯函数和浏览器交互覆盖。 +- 修改按钮可能破坏暂停队列继续和空闲重试 → 复用原来的 `submit` 和状态闸门,运行已有回归测试。 +- Web 构建不更新已运行的 Desktop → 验证明确限定为源码/Web,Desktop 发布另行执行。 diff --git a/openspec/changes/unify-busy-composer-action/proposal.md b/openspec/changes/unify-busy-composer-action/proposal.md new file mode 100644 index 000000000..29f540d8a --- /dev/null +++ b/openspec/changes/unify-busy-composer-action/proposal.md @@ -0,0 +1,24 @@ +## Why + +会话运行时,输入框并列显示中断和排队按钮,空草稿仍占用一个禁用的排队按钮。用户希望底部只保留一个主题色图标按钮,根据草稿内容切换停止或排队。 + +## What Changes + +- 运行中且无可提交内容时,显示正方形停止图标,点击中断当前任务。 +- 运行中且有文本或附件等可提交内容时,显示排队 SVG 图标,点击或按 Enter 使用既有排队提交路径。 +- 按钮位置和尺寸固定,背景始终使用主题色;停止请求在途时防止重复停止。 +- 保留空闲发送、暂停队列继续、输入法和 Shift+Enter 换行行为。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `busy-input-queue`: 将并列的中断与排队动作改为随草稿内容切换的单一图标按钮。 + +## Impact + +影响共享的 `InputBar`、`inputBarState`、图标源及对应测试、生成的 SVG 和 i18n 源文案目录。无需修改后端/API、队列生命周期或 Desktop 原生壳。 diff --git a/openspec/changes/unify-busy-composer-action/specs/busy-input-queue/spec.md b/openspec/changes/unify-busy-composer-action/specs/busy-input-queue/spec.md new file mode 100644 index 000000000..fac95d0f9 --- /dev/null +++ b/openspec/changes/unify-busy-composer-action/specs/busy-input-queue/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Busy input shows interrupt and queue actions +The input bar SHALL expose one primary icon button while the current session is busy. Its action SHALL be stop when the draft has no submittable content and queue when the draft contains text or other submittable content. The button SHALL retain its position, dimensions, and theme-accent background across these states, with an accessible action label and tooltip. + +#### Scenario: Busy input with text +- **WHEN** the current session is busy and the input contains non-whitespace text +- **THEN** the input action area SHALL show only one primary button with a queue SVG icon +- **AND** clicking that button or pressing Enter SHALL submit the draft through the existing queue path +- **AND** blocking-input and submission-in-progress guards SHALL prevent duplicate or disallowed submissions + +#### Scenario: Busy input without text +- **WHEN** the current session is busy and the draft is empty or whitespace-only with no submittable attachments or context +- **THEN** the primary button SHALL show a solid square stop icon on the theme-accent background +- **AND** clicking the button SHALL interrupt the current task +- **AND** pressing Enter in the empty editor SHALL NOT interrupt the task or enqueue an empty message + +#### Scenario: Busy input with attachments only +- **WHEN** the current session is busy and the draft has no text but contains a submittable attachment, pasted text, or context +- **THEN** the primary button SHALL show the queue SVG icon and SHALL allow the existing attachment submission path + +#### Scenario: Stop request is pending +- **WHEN** the draft is empty and a stop request is pending +- **THEN** the primary button SHALL keep its square icon and theme-accent background +- **AND** the button SHALL be disabled and announce that the task is stopping + +#### Scenario: Draft changes during a busy turn +- **WHEN** the user enters content or removes all submittable content during a busy turn +- **THEN** the same primary button SHALL switch between queue and stop without adding another action button or shifting its position + +#### Scenario: Idle input uses normal send action +- **WHEN** the current session is not busy +- **THEN** the primary button SHALL use the existing send action, or the existing resume action for an empty draft with a paused queue +- **AND** empty-draft retry eligibility and disabled/submitting guards SHALL remain effective + +#### Scenario: Newline and composition behavior +- **WHEN** the user presses Shift+Enter or confirms an IME composition +- **THEN** the editor SHALL preserve its existing newline and composition handling without unintended queue or stop actions diff --git a/openspec/changes/unify-busy-composer-action/tasks.md b/openspec/changes/unify-busy-composer-action/tasks.md new file mode 100644 index 000000000..f5b6a33f2 --- /dev/null +++ b/openspec/changes/unify-busy-composer-action/tasks.md @@ -0,0 +1,21 @@ +## 1. 主动作实现 + +- [x] 1.1 在纯状态函数加入空草稿停止模式,覆盖空白、附件、阻塞/提交、继续与重试,运行 `node web/src/lib/inputBarState.test.js` 验证。 +- [x] 1.2 增加排队和填充停止 SVG 图标、统一 InputBar 的主题色主按钮与点击分派,更新 i18n 目录,运行图标测试并审查按钮只保留一个。 + +## 2. 综合验证 + +- [x] 2.1 用真实 Chromium 验证内容切换、停止点击、停止在途、Enter/点击排队、Shift+Enter、附件、空闲/继续、亮暗与窄屏,保存结果和截图。 +- [x] 2.2 运行 `pnpm test`、`pnpm build`、OpenSpec strict 验证、前端 detector 和 `git diff --check`,记录结果及 Desktop 未打包验收的边界。 + +## 验证记录(2026-10-09,Windows) + +- `node web/src/lib/inputBarState.test.js`:13 项状态回归通过。 +- `node web/src/lib/interfaceIcons.test.js`:通过;`node scripts/regenerate_web_icons.mjs --check`:119 个规范 SVG 通过。 +- `node web/scripts/test-composer-actions.mjs`:使用本机 Google Chrome,9 项浏览器检查通过,保存亮/暗主题、1100/390px、停止/排队共 8 张截图及 `results.json`。Playwright 使用系统临时目录的独立依赖,未修改项目依赖。 +- `pnpm test`(web):全量通过,包含既有 IME Enter、防重复提交、队列和空闲重试回归。 +- `pnpm build`(web):通过,3157 个模块构建完成,4519 个正则兼容性扫描通过。 +- `openspec validate unify-busy-composer-action --strict`:通过。 +- Impeccable detector(InputBar/Icon):返回 `[]`。 +- `git diff --check`:通过。 +- 保留原有模型/API/安装脚本等无关工作区修改。未打包、启动或验收 Desktop,本次结果为源码与 Web/Chromium 验证;本机 Windows 输入法实操未执行,IME 保护由既有生产回调回归测试覆盖。 diff --git a/src/adapters/provider/provider_factory.cpp b/src/adapters/provider/provider_factory.cpp index 7226994c9..b4d71ebc4 100644 --- a/src/adapters/provider/provider_factory.cpp +++ b/src/adapters/provider/provider_factory.cpp @@ -49,7 +49,7 @@ struct EffectiveProviderBuildPlan { ProviderRequestOptions request_options_from_entry(const ModelProfile& entry) { ProviderRequestOptions options; options.endpoint_mode = entry.endpoint_mode.value_or("base_url"); - options.api_protocol = entry.api_protocol.value_or("chat_completions"); + options.api_protocol = model_profile_api_protocol(entry); options.max_output_tokens = entry.max_output_tokens; options.reasoning = entry.reasoning; if (entry.provider == "anthropic" && entry.reasoning.has_value()) { diff --git a/src/apps/web/handlers/model_catalog_handler.cpp b/src/apps/web/handlers/model_catalog_handler.cpp index fa5bf8250..436aeee2f 100644 --- a/src/apps/web/handlers/model_catalog_handler.cpp +++ b/src/apps/web/handlers/model_catalog_handler.cpp @@ -128,6 +128,10 @@ std::vector provider_descriptors( acemodel.id, "custom", {"base_url"})); + ModelProfile acemodel_defaults; + acemodel_defaults.provider = "openai"; + acemodel_defaults.models_dev_provider_id = acemodel.id; + result.back()["default_api_protocol"] = model_profile_api_protocol(acemodel_defaults); result.push_back(provider_to_json( "custom-openai", "Custom OpenAI-compatible API", "openai", "", "", "required", true, "manual", "", std::nullopt, "custom", diff --git a/src/apps/web/handlers/models_handler.cpp b/src/apps/web/handlers/models_handler.cpp index 124ecb413..0205b28ce 100644 --- a/src/apps/web/handlers/models_handler.cpp +++ b/src/apps/web/handlers/models_handler.cpp @@ -75,8 +75,8 @@ nlohmann::json entry_to_json(const ModelProfile& entry) { if (!entry.capabilities.empty()) { o["capabilities"] = entry.capabilities; } - if (entry.api_protocol.has_value()) { - o["api_protocol"] = *entry.api_protocol; + if (entry.api_protocol.has_value() || model_profile_api_protocol(entry) == "responses") { + o["api_protocol"] = model_profile_api_protocol(entry); } if (entry.endpoint_mode.has_value()) { o["endpoint_mode"] = *entry.endpoint_mode; diff --git a/src/base/config/saved_models.cpp b/src/base/config/saved_models.cpp index 700c6e15a..994dcc483 100644 --- a/src/base/config/saved_models.cpp +++ b/src/base/config/saved_models.cpp @@ -327,6 +327,9 @@ std::optional parse_one_entry(const nlohmann::json& node, std::siz if (e.context_window == 200000) e.context_window = 250000; } + if (!e.api_protocol.has_value() && model_profile_api_protocol(e) == "responses") { + e.api_protocol = "responses"; + } return e; } @@ -690,6 +693,15 @@ nlohmann::json model_reasoning_options_to_json( return result; } +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() && + is_acemodel_provider_id(*profile.models_dev_provider_id)) { + return "responses"; + } + return "chat_completions"; +} + bool model_profile_allows_no_api_key(const ModelProfile& profile) { if (profile.provider == "copilot" || profile.provider == "grok") return true; if (profile.provider != "openai") return false; diff --git a/src/base/config/saved_models.hpp b/src/base/config/saved_models.hpp index 2663d3c73..1dd583781 100644 --- a/src/base/config/saved_models.hpp +++ b/src/base/config/saved_models.hpp @@ -38,7 +38,7 @@ struct ModelProfile { std::vector capabilities; // 用户声明的能力标签,如 vision/tool_use/web_search // Missing on legacy profiles. New profiles use base_url or full_url. std::optional endpoint_mode; - // OpenAI only; omission preserves the legacy Chat Completions protocol. + // OpenAI only; omission uses Responses for ACEModel, Chat Completions otherwise. std::optional api_protocol; // chat_completions | responses std::optional max_output_tokens; // catalog/manual marks capabilities as authoritative. Missing preserves @@ -99,6 +99,9 @@ nlohmann::json model_reasoning_options_to_json( bool model_profile_allows_no_api_key(const ModelProfile& profile); std::string normalize_model_endpoint_identity(const std::string& value); +// Explicit protocol selections win over the catalog provider's default. +std::string model_profile_api_protocol(const ModelProfile& profile); + // True only for the generated ACEModel catalog defaults (legacy 200K or // current 250K). These values are fallbacks, not manual runtime overrides. bool is_acemodel_catalog_context_fallback(const ModelProfile& profile); diff --git a/src/engine/agent/agent_loop.cpp b/src/engine/agent/agent_loop.cpp index 53dd9d742..9767f2dbb 100644 --- a/src/engine/agent/agent_loop.cpp +++ b/src/engine/agent/agent_loop.cpp @@ -70,7 +70,7 @@ AgentLoop::AgentLoop(AgentLoopServices services, AgentLoopOptions options) , published_loop_config_(std::make_shared(options.config)) , history_(std::make_unique(busy_)) , transcript_(std::make_unique( - *history_, events_, callbacks_, *turn_outcome_)) + *history_, events_, callbacks_, *turn_outcome_, session_manager_)) , task_queue_(std::make_unique(busy_)) , active_turn_gate_(std::make_unique( busy_, abort_signal_, turn_interrupt_requested_)) diff --git a/src/engine/agent/event_payload/message_payload.cpp b/src/engine/agent/event_payload/message_payload.cpp index 017207be4..a3d89b72e 100644 --- a/src/engine/agent/event_payload/message_payload.cpp +++ b/src/engine/agent/event_payload/message_payload.cpp @@ -6,11 +6,10 @@ namespace acecode::web { std::string compute_message_id(const ChatMessage& m) { - if (m.role == "user" && !m.uuid.empty()) { + if ((m.role == "user" || m.role == "error") && !m.uuid.empty()) { return m.uuid; } - // 非 user 消息一律走 sha1(role + " " + content + " " + timestamp)。 - // user 消息没有 uuid 时也走这条路径(老 session 兼容)。 + // Other roles and legacy records without UUIDs retain content-based IDs. std::string buf; buf.reserve(m.role.size() + m.content.size() + m.timestamp.size() + 2); buf.append(m.role); diff --git a/src/engine/agent/event_payload/message_payload.hpp b/src/engine/agent/event_payload/message_payload.hpp index 43af96767..0f87bf873 100644 --- a/src/engine/agent/event_payload/message_payload.hpp +++ b/src/engine/agent/event_payload/message_payload.hpp @@ -6,7 +6,7 @@ // - 抽成纯函数后可单测(不依赖 Crow / 网络栈) // // id 字段来源(见 src/utils/sha1.hpp 设计注释 + openspec session-fork spec): -// - user 消息:走 ChatMessage.uuid(rewind change 持久化的 UUID) +// - user / error 消息:有 ChatMessage.uuid 时使用持久化的 UUID // - 其它角色(assistant / tool / system):lazy 算 sha1(role + " " + content + " " + timestamp) // 不回写 JSONL,只在序列化时计算 diff --git a/src/engine/agent/transcript/transcript_writer.cpp b/src/engine/agent/transcript/transcript_writer.cpp index 5760ad829..17abeb873 100644 --- a/src/engine/agent/transcript/transcript_writer.cpp +++ b/src/engine/agent/transcript/transcript_writer.cpp @@ -10,6 +10,7 @@ #include "session/session_storage.hpp" #include "session/turn_timing.hpp" #include "utils/logger.hpp" +#include "utils/uuid.hpp" #include #include #include @@ -26,6 +27,24 @@ void TranscriptWriter::dispatch_message(const std::string& role, // 回合级错误文案的唯一收集点:provider 终止错误 / 压缩失败 / 空回复 // 耗尽 / hook 拦截都经这里派发,wait_subagent 报 ChildFailed 时带上。 outcome_.set_error(content); + ChatMessage message; + message.role = role; + message.content = content; + message.uuid = generate_uuid_v7(); + message.timestamp = SessionStorage::now_iso8601(); + message.metadata = metadata.is_object() ? std::move(metadata) : nlohmann::json::object(); + message.metadata["transcript_only"] = true; + message.content_parts = std::move(content_parts); + // Persist before publishing so background failures survive a history + // reload even when no client was subscribed. Provider history filters + // transcript_only records out of subsequent requests and compaction. + history_.append(message); + if (session_) session_->on_message(message); + if (callbacks.on_message) callbacks.on_message(role, content, is_tool); + auto payload = web::chat_message_to_payload_json(message); + payload["is_tool"] = is_tool; + events_.emit(SessionEventKind::Message, std::move(payload)); + return; } if (callbacks.on_message) { callbacks.on_message(role, content, is_tool); diff --git a/src/engine/agent/transcript/transcript_writer.hpp b/src/engine/agent/transcript/transcript_writer.hpp index bb775bd7d..96deeca60 100644 --- a/src/engine/agent/transcript/transcript_writer.hpp +++ b/src/engine/agent/transcript/transcript_writer.hpp @@ -19,8 +19,8 @@ class TurnOutcomeRecord; class TranscriptWriter { public: TranscriptWriter(ConversationHistory& history, EventDispatcher& events, - CallbacksSlot& callbacks, TurnOutcomeRecord& outcome) - : history_(history), events_(events), callbacks_(callbacks), outcome_(outcome) {} + CallbacksSlot& callbacks, TurnOutcomeRecord& outcome, SessionManager* session = nullptr) + : history_(history), events_(events), callbacks_(callbacks), outcome_(outcome), session_(session) {} void dispatch_message(const std::string& role, const std::string& content, bool is_tool, nlohmann::json metadata, nlohmann::json content_parts); void append_turn_timing_record(SessionManager* session, const std::string& user_message_uuid, @@ -45,6 +45,7 @@ class TranscriptWriter { EventDispatcher& events_; CallbacksSlot& callbacks_; TurnOutcomeRecord& outcome_; + SessionManager* session_; // Nullable, borrowed from the loop's fixed services. }; } // namespace acecode::agent diff --git a/tests/agent/agent_loop_termination_test.cpp b/tests/agent/agent_loop_termination_test.cpp index 9c5216221..34fa474ad 100644 --- a/tests/agent/agent_loop_termination_test.cpp +++ b/tests/agent/agent_loop_termination_test.cpp @@ -1550,9 +1550,21 @@ TEST(AgentLoopTermination, ProviderErrorDoesNotCreateEmptyAssistantAndNextTurnWo EXPECT_EQ(h.last_turn_outcome(), "error"); EXPECT_EQ(h.last_terminal_busy_outcome(), "error"); + const auto failed_history = h.persisted_messages(); + auto error = std::find_if(failed_history.begin(), failed_history.end(), [](const auto& message) { + return message.role == "error"; + }); + ASSERT_NE(error, failed_history.end()); + EXPECT_TRUE(error->metadata.value("transcript_only", false)); + EXPECT_FALSE(error->uuid.empty()); + h.push_text("ok"); ASSERT_TRUE(h.submit_and_wait("second")); EXPECT_EQ(h.turn_count(), 2); + for (const auto& message : h.request_messages_for_turn(1)) { + EXPECT_NE(message.role, "error"); + EXPECT_FALSE(message.metadata.is_object() && message.metadata.value("transcript_only", false)); + } EXPECT_EQ(h.count_by_role("assistant"), 1); EXPECT_EQ(h.last_turn_outcome(), "completed"); EXPECT_EQ(h.last_terminal_busy_outcome(), "completed"); diff --git a/tests/agent/transcript/transcript_writer_test.cpp b/tests/agent/transcript/transcript_writer_test.cpp new file mode 100644 index 000000000..743c60721 --- /dev/null +++ b/tests/agent/transcript/transcript_writer_test.cpp @@ -0,0 +1,112 @@ +#include + +#include "agent/compaction/compact.hpp" +#include "agent/event_payload/message_payload.hpp" +#include "agent/request/provider_history.hpp" +#include "agent/transcript/conversation_history.hpp" +#include "agent/transcript/transcript_writer.hpp" +#include "agent/turn/turn_outcome.hpp" +#include "session/event_dispatcher.hpp" +#include "session/scoped_subscription.hpp" +#include "session/session_manager.hpp" +#include "test_support/agent/stub_provider.hpp" +#include "utils/uuid.hpp" + +#include +#include +#include + +namespace { +class CompactionProvider : public acecode_test::StubLlmProvider { +public: + std::vector request; + acecode::ChatResponse chat(const std::vector& messages, + const std::vector&) override { + request = messages; + acecode::ChatResponse response; + response.content = "summary of the task"; + response.finish_reason = "stop"; + return response; + } +}; + +struct ErrorSession { + const std::filesystem::path cwd = std::filesystem::temp_directory_path() / + ("acecode_error_transcript_" + acecode::generate_uuid()); + acecode::SessionManager session; + std::string project_dir; + + ErrorSession() { + std::filesystem::create_directories(cwd); + session.start_session(cwd.string(), "stub", "stub-model"); + project_dir = session.current_project_dir(); + } + ~ErrorSession() { + session.end_current_session(); + std::error_code ec; + std::filesystem::remove_all(project_dir, ec); + std::filesystem::remove_all(cwd, ec); + } +}; +} + +TEST(TranscriptWriter, ErrorsSurviveResumeWithoutEnteringProviderOrCompactionContext) { + ErrorSession fixture; + std::atomic busy{false}; + acecode::agent::ConversationHistory history(busy); + acecode::EventDispatcher events; + acecode::CallbacksSlot callbacks; + acecode::agent::TurnOutcomeRecord outcome; + acecode::agent::TranscriptWriter writer(history, events, callbacks, outcome, &fixture.session); + std::vector published; + acecode::ScopedSubscription subscription(events, events.subscribe([&](const acecode::SessionEvent& event) { + if (event.kind != acecode::SessionEventKind::Message) return; + const auto disk = fixture.session.load_active_messages(); + ASSERT_FALSE(disk.empty()); + EXPECT_EQ(acecode::web::compute_message_id(disk.back()), event.payload.at("id")); + published.push_back(event.payload); + })); + acecode::ChatMessage user; + user.role = "user"; + user.content = "continue the task"; + history.append(user); + fixture.session.on_message(user); + const std::string error = "[Error] HTTP 451 quota exhausted"; + const nlohmann::json diagnostics = {{"provider_error", { + {"status_code", 451}, {"raw_body", "quota exhausted"}, {"request_id", "request-451"}}}}; + // Identical failures, even in one clock interval, have distinct identities. + for (int i = 0; i < 2; ++i) { + writer.dispatch_message("error", error, false, diagnostics, nlohmann::json::array()); + } + ASSERT_EQ(published.size(), 2u); + EXPECT_NE(published[0].at("id"), published[1].at("id")); + EXPECT_EQ(outcome.error(), error); + const std::string sid = fixture.session.current_session_id(); + fixture.session.end_current_session(); + acecode::SessionManager resumed; + resumed.start_session(fixture.cwd.string(), "stub", "stub-model"); + auto messages = resumed.resume_session(sid); + ASSERT_EQ(messages.size(), 3u); + for (std::size_t i = 1; i < messages.size(); ++i) { + const auto& message = messages[i]; + EXPECT_EQ(message.role, "error"); + EXPECT_EQ(message.content, error); + EXPECT_FALSE(message.timestamp.empty()); + EXPECT_FALSE(message.uuid.empty()); + EXPECT_TRUE(message.metadata.at("transcript_only").get()); + EXPECT_EQ(message.metadata.at("provider_error"), diagnostics.at("provider_error")); + EXPECT_EQ(acecode::web::chat_message_to_payload_json(message).at("id"), published[i - 1].at("id")); + } + const auto provider_history = acecode::agent::detail::model_facing_provider_messages(messages, "error-resume-test"); + ASSERT_EQ(provider_history.size(), 1u); + EXPECT_EQ(provider_history[0].content, user.content); + CompactionProvider provider; + const auto compacted = acecode::compact_messages(provider, messages); + EXPECT_TRUE(compacted.performed) << compacted.error; + ASSERT_FALSE(provider.request.empty()); + for (const auto& message : provider.request) { + EXPECT_NE(message.role, "error"); + EXPECT_EQ(message.content.find("quota exhausted"), std::string::npos); + } + resumed.end_current_session(); +} diff --git a/tests/config/saved_models_test.cpp b/tests/config/saved_models_test.cpp index 8504ee735..356573498 100644 --- a/tests/config/saved_models_test.cpp +++ b/tests/config/saved_models_test.cpp @@ -1283,7 +1283,37 @@ TEST(SavedModelsTest, ParseReadonlyFlagDefaultsFalse) { EXPECT_TRUE((*parsed)[1].readonly); } -// Responses 协议只允许 OpenAI 预设显式选择,缺省仍保留旧协议。 +TEST(SavedModelsTest, AceModelProtocolDefaultsPreserveExplicitOverridesAndIdentity) { + for (const auto* model : {"moonlight", "starrylight", "aurora", "new-model"}) { + nlohmann::json node = { + {"name", model}, {"provider", "openai"}, {"model", model}, + {"base_url", "https://proxy.example/v1"}, {"api_key", "test-key"}, + {"models_dev_provider_id", "ACEModel"}, + {"capabilities_source", "manual"}, {"capabilities", {"tool_use"}}, + }; + std::string error; + auto parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_EQ(parsed->front().api_protocol, "responses"); + EXPECT_EQ(model_profile_api_protocol(parsed->front()), "responses"); + EXPECT_EQ(parsed->front().capabilities, (std::vector{"tool_use"})); + EXPECT_TRUE(validate_saved_models(*parsed, "", error)) << error; + for (const auto* protocol : {"chat_completions", "responses"}) { + node["api_protocol"] = protocol; + parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_EQ(model_profile_api_protocol(parsed->front()), protocol); + } + node.erase("api_protocol"); + node.erase("models_dev_provider_id"); + parsed = parse_saved_models(nlohmann::json::array({node}), error); + ASSERT_TRUE(parsed.has_value()) << error; + EXPECT_FALSE(parsed->front().api_protocol.has_value()); + EXPECT_EQ(model_profile_api_protocol(parsed->front()), "chat_completions"); + } +} + +// Explicit Responses is OpenAI-only; ordinary profiles retain Chat Completions. TEST(SavedModelsTest, ApiProtocolParsesAndRejectsUnsupportedValuesAndProviders) { const nlohmann::json legacy = { {"name", "responses-test"}, {"provider", "openai"}, diff --git a/tests/fixtures/model_catalog_contract.json b/tests/fixtures/model_catalog_contract.json index 66e842dde..bb0286945 100644 --- a/tests/fixtures/model_catalog_contract.json +++ b/tests/fixtures/model_catalog_contract.json @@ -18,6 +18,7 @@ "model_input": "catalog", "api_key_env": "ACEMODEL_API_KEY", "models_dev_provider_id": "acemodel", + "default_api_protocol": "responses", "group": "custom", "endpoint_modes": ["base_url"] }, diff --git a/tests/provider/model_profile_runtime_options_test.cpp b/tests/provider/model_profile_runtime_options_test.cpp index fe743a2fb..35238fdbb 100644 --- a/tests/provider/model_profile_runtime_options_test.cpp +++ b/tests/provider/model_profile_runtime_options_test.cpp @@ -549,6 +549,7 @@ TEST(ModelProfileRuntimeOptions, FactoryForwardsOpenAiEffortThroughActualRequest }); ModelProfile profile; profile.name = "explicit-reasoning"; + profile.api_protocol = "chat_completions"; profile.provider = "openai"; profile.base_url = server.base_url(); profile.api_key = "test-key"; diff --git a/tests/provider/openai_responses_provider_test.cpp b/tests/provider/openai_responses_provider_test.cpp index cd4f90263..3653e9898 100644 --- a/tests/provider/openai_responses_provider_test.cpp +++ b/tests/provider/openai_responses_provider_test.cpp @@ -1,4 +1,5 @@ #include "provider/openai_responses_provider.hpp" +#include "provider/provider_factory.hpp" #include "utils/joining_thread.hpp" #include @@ -103,6 +104,66 @@ std::vector collect(OpenAiResponsesProvider& provider, return events; } +TEST(OpenAiResponsesProviderTest, AceModelFactoryDefaultsAndExplicitChatOverride) { + std::atomic responses_requests{0}; + std::atomic chat_requests{0}; + LocalHttpServer server([&](httplib::Server& http) { + http.Post("/v1/responses", [&](const httplib::Request& request, httplib::Response& response) { + ++responses_requests; + const auto body = Json::parse(request.body); + EXPECT_FALSE(body.contains("messages")); + EXPECT_TRUE(body.contains("input")); + EXPECT_EQ(body["store"], false); + response.set_content(body.value("stream", false) ? completed_stream() : completed().dump(), + body.value("stream", false) ? "text/event-stream" : "application/json"); + }); + http.Post("/v1/chat/completions", [&](const httplib::Request&, httplib::Response& response) { + ++chat_requests; + response.set_content( + R"({"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]})", + "application/json"); + }); + }); + ModelProfile profile; + profile.name = "ace-default"; + profile.provider = "openai"; + profile.models_dev_provider_id = "acemodel"; + profile.base_url = server.url(); + profile.api_key = "test-key"; + for (const auto* model : {"moonlight", "starrylight", "aurora"}) { + profile.model = model; + auto provider = create_provider_from_entry(profile); + ASSERT_TRUE(provider); + EXPECT_EQ(provider->chat({user_message()}, {}).content, "ok"); + EXPECT_EQ(provider->chat_for_compaction({user_message()}, {}, nullptr).content, "ok"); + std::string streamed; + provider->chat_stream({user_message()}, {}, [&](const StreamEvent& event) { + if (event.type == StreamEventType::Delta) streamed += event.content; + }); + EXPECT_EQ(streamed, "ok"); + } + const auto implicit = prepare_provider_construction(profile); + ASSERT_TRUE(implicit); + profile.api_protocol = "responses"; + const auto explicit_responses = prepare_provider_construction(profile); + ASSERT_TRUE(explicit_responses); + EXPECT_EQ(implicit->fingerprint(), explicit_responses->fingerprint()); + profile.api_protocol = "chat_completions"; + const auto explicit_chat = prepare_provider_construction(profile); + ASSERT_TRUE(explicit_chat); + EXPECT_NE(implicit->fingerprint(), explicit_chat->fingerprint()); + auto provider = explicit_chat->construct().provider; + ASSERT_TRUE(provider); + EXPECT_EQ(provider->chat({user_message()}, {}).content, "ok"); + profile.api_protocol.reset(); + profile.models_dev_provider_id.reset(); + provider = create_provider_from_entry(profile); + ASSERT_TRUE(provider); + EXPECT_EQ(provider->chat({user_message()}, {}).content, "ok"); + EXPECT_EQ(responses_requests.load(), 9); + EXPECT_EQ(chat_requests.load(), 2); +} + int count_events(const std::vector& events, StreamEventType type) { return static_cast(std::count_if(events.begin(), events.end(), [type](const StreamEvent& event) { return event.type == type; })); diff --git a/tests/web/model_connection_test_handler_test.cpp b/tests/web/model_connection_test_handler_test.cpp index 2bf37448f..a3be42c57 100644 --- a/tests/web/model_connection_test_handler_test.cpp +++ b/tests/web/model_connection_test_handler_test.cpp @@ -94,6 +94,37 @@ TEST(ModelConnectionTest, SendsOnlyOneShortPromptAndPreservesRequestOptions) { EXPECT_EQ(config.default_model_name, "saved"); } +TEST(ModelConnectionTest, AceModelDefaultAndExplicitProtocolUseMatchingEndpoints) { + std::atomic responses_requests{0}; + std::atomic chat_requests{0}; + ModelTestServer upstream([&](httplib::Server& server) { + server.Post("/responses", [&](const httplib::Request& request, httplib::Response& response) { + ++responses_requests; + const auto body = json::parse(request.body); + EXPECT_TRUE(body.contains("input")); + EXPECT_FALSE(body.contains("messages")); + response.set_content(R"({"status":"completed","output":[{"type":"message", + "role":"assistant","content":[{"type":"output_text","text":"OK"}]}]})", + "application/json"); + }); + server.Post("/chat/completions", [&](const httplib::Request&, httplib::Response& response) { + ++chat_requests; + reply_ok(response); + }); + }); + auto input = request_for(upstream); + input["models_dev_provider_id"] = "acemodel"; + const auto config = saved_config(upstream); + EXPECT_EQ(test_model_connection(input, config).status, 200); + input["api_protocol"] = "chat_completions"; + EXPECT_EQ(test_model_connection(input, config).status, 200); + input["api_protocol"] = "responses"; + EXPECT_EQ(test_model_connection(input, config).status, 200); + EXPECT_EQ(responses_requests.load(), 2); + EXPECT_EQ(chat_requests.load(), 1); + EXPECT_FALSE(config.saved_models[0].api_protocol.has_value()); +} + TEST(ModelConnectionTest, ResolvesCredentialReuseAndEditingWithoutMutation) { std::atomic requests{0}; ModelTestServer upstream([&](httplib::Server& server) { diff --git a/tests/web/models_handler_test.cpp b/tests/web/models_handler_test.cpp index db82aecc7..5780340f4 100644 --- a/tests/web/models_handler_test.cpp +++ b/tests/web/models_handler_test.cpp @@ -762,6 +762,34 @@ TEST(ModelsHandler, DiscoveryReasoningRequiresCompleteValidEffortDeclaration) { EXPECT_EQ(wire.size(), parsed.ids.size()); } +TEST(ModelsHandler, AceModelListReportsEffectiveProtocolAndHonorsClearedOverride) { + AppConfig config; + ModelProfile profile; + profile.name = "ace-model"; + profile.provider = "openai"; + profile.models_dev_provider_id = "acemodel"; + profile.model = "moonlight"; + profile.base_url = "https://proxy.example/v1"; + profile.api_key = "test-key"; + config.saved_models = {profile}; + EXPECT_EQ(list_models(config)[0]["api_protocol"], "responses"); + + std::string error; + auto draft = parse_model_draft({{"name", profile.name}, {"provider", profile.provider}, + {"model", profile.model}, {"api_protocol", "chat_completions"}}, error); + ASSERT_TRUE(draft) << error; + ASSERT_EQ(acecode::update_saved_model(config, profile.name, *draft), acecode::SavedModelEditError::OK); + EXPECT_EQ(list_models(config)[0]["api_protocol"], "chat_completions"); + draft = parse_model_draft({{"name", profile.name}, {"provider", profile.provider}, + {"model", profile.model}, {"api_protocol", nullptr}}, error); + ASSERT_TRUE(draft) << error; + ASSERT_EQ(acecode::update_saved_model(config, profile.name, *draft), acecode::SavedModelEditError::OK); + EXPECT_FALSE(config.saved_models[0].api_protocol.has_value()); + EXPECT_EQ(list_models(config)[0]["api_protocol"], "responses"); + config.saved_models[0].models_dev_provider_id.reset(); + EXPECT_FALSE(list_models(config)[0].contains("api_protocol")); +} + TEST(ModelsHandler, ProbeReasoningRemovalClearsPriorDeclarationAndAceModelTag) { const auto first = parse_openai_models(nlohmann::json::array({ {{"id", "aurora"}, {"reasoning", {{"supported_efforts", {"high"}}}}}, diff --git a/web/public/vs-icons/Queue.svg b/web/public/vs-icons/Queue.svg new file mode 100644 index 000000000..e3c885470 --- /dev/null +++ b/web/public/vs-icons/Queue.svg @@ -0,0 +1 @@ + diff --git a/web/public/vs-icons/StopFilled.svg b/web/public/vs-icons/StopFilled.svg new file mode 100644 index 000000000..95b4b3fd4 --- /dev/null +++ b/web/public/vs-icons/StopFilled.svg @@ -0,0 +1 @@ + diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 31f900840..249dcb422 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -12,6 +12,9 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '浏览器页面已关闭': 'The browser page has been closed', '浏览器启动超时,请重试': 'Browser startup timed out. Please try again', '发送消息': 'Send message', + '停止': 'Stop', + '停止当前任务': 'Stop the current task', + '输入消息可排队 · Shift+Enter 换行 · 上下键切换历史消息': 'Type a message to queue · Shift+Enter for a new line · Up/Down to browse message history', '思考深度 ({{p0}} / {{p1}})': 'Reasoning depth ({{p0}} / {{p1}})', 'keyboard shortcuts hotkeys 快捷键 键盘 搜索 前进 后退 思维深度 Ctrl Alt Cmd': 'keyboard shortcuts hotkeys search forward back reasoning depth Ctrl Alt Cmd', '快捷键': 'Keyboard shortcuts', diff --git a/web/scripts/test-composer-actions.mjs b/web/scripts/test-composer-actions.mjs new file mode 100644 index 000000000..e253c6415 --- /dev/null +++ b/web/scripts/test-composer-actions.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +// Run from web: node scripts/test-composer-actions.mjs +// Playwright is optional; ACE_PLAYWRIGHT_MODULE may point to its index.mjs. +// ACE_CHROMIUM_EXECUTABLE selects an installed Chromium. Results and screenshots +// go to ACE_COMPOSER_ACTION_OUTPUT, or a directory in the system temp folder. +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 = process.env.ACE_COMPOSER_ACTION_OUTPUT || path.join(os.tmpdir(), 'acecode-composer-actions'); +const virtual = path.join(web, '__composer-actions.jsx').replaceAll('\\', '/'); +const fixture = ` +import React, {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {InputBar} from '/src/components/InputBar.jsx'; +import {composerContentFromText} from '/src/lib/composerContent.js'; +import {i18n} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +const params = new URLSearchParams(location.search); +document.documentElement.dataset.theme = params.get('theme') || 'light'; +await i18n.changeLanguage(params.get('locale') || 'zh-CN'); +const flag = key => params.get(key) === 'true'; +const busy = params.get('busy') !== 'false'; +const attachments = flag('attachment') ? [{id:'fixture-file',key:'fixture-file',name:'notes.txt',kind:'file'}] : []; +const events = []; +function Fixture() { + const [value,setValue] = useState(params.get('text') || ''); + const [content,setContent] = useState(composerContentFromText(params.get('text') || '', attachments)); + const [stopping,setStopping] = useState(flag('stopping')); + window.fixture = {value,events}; + return
+
+ {events.push({action:busy?'queue':'send',text});setValue('');setContent(composerContentFromText(''));}} + onAbort={()=>{events.push({action:'stop'});setStopping(true);}} + onResumeQueue={()=>events.push({action:'resume'})} /> +
; +} +createRoot(document.getElementById('root')).render(); +`; +const html = `
`; +const server = await createServer({ + root: web, configFile: path.join(web, 'vite.config.js'), logLevel: 'error', + plugins: [{ + name: 'composer-actions-fixture', enforce: 'pre', + resolveId(id) { if (id === '/__composer-actions.jsx') return virtual; }, + load(id) { if (id.replaceAll('\\', '/') === virtual) return fixture; }, + configureServer(vite) { + vite.middlewares.use((req, res, next) => { + if (req.url?.split('?')[0] !== '/__composer-actions') return next(); + res.setHeader('Content-Type', 'text/html'); res.end(html); + }); + }, + }], + server: { host: '127.0.0.1', port: Number(process.env.ACE_COMPOSER_ACTION_PORT || 5192), strictPort: true, hmr: false }, +}); +let browser; +const results = []; +const errors = []; +try { + await mkdir(output, { recursive: true }); + await server.listen(); + 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: 600 } }); + page.on('pageerror', error => errors.push(error.message)); + const editor = page.locator('[data-ace-rich-composer]'); + const button = page.locator('[data-composer-action]'); + const settle = () => page.evaluate(() => new Promise(resolve => setTimeout(() => requestAnimationFrame(resolve), 150))); + async function reset(params = {}) { + await page.goto(server.resolvedUrls.local[0] + '__composer-actions?' + new URLSearchParams(params)); + await editor.waitFor(); await page.evaluate(() => document.fonts.ready); await settle(); + } + async function state() { + await settle(); + return button.evaluate(el => ({ + ...window.fixture, count: document.querySelectorAll('[data-composer-action]').length, + mode: el.dataset.composerAction, label: el.getAttribute('aria-label'), disabled: el.disabled, + icon: el.querySelector('[data-icon-name]')?.dataset.iconName, + background: getComputedStyle(el).backgroundColor, + accent: getComputedStyle(document.getElementById('accent-probe')).backgroundColor, + opacity: getComputedStyle(el).opacity, rect: (()=>{const r=el.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height};})(), + })); + } + function expectMode(actual, mode, icon, disabled = false) { + assert.equal(actual.count, 1); assert.equal(actual.mode, mode); assert.equal(actual.icon, icon); + assert.equal(actual.disabled, disabled); assert.equal(actual.background, actual.accent); + assert.equal(actual.rect.width, 28); assert.equal(actual.rect.height, 28); + } + async function run(name, fn) { + try { await fn(); results.push({ name, passed: true }); console.log('[pass] ' + name); } + catch (error) { results.push({ name, passed: false, error: error.message }); throw error; } + } + await run('empty and whitespace keep stop; empty Enter does nothing; stop cannot repeat', async () => { + await reset(); expectMode(await state(), 'stop', 'StopFilled'); + await editor.click(); await page.keyboard.insertText(' '); await page.keyboard.press('Enter'); + const empty = await state(); expectMode(empty, 'stop', 'StopFilled'); assert.deepEqual(empty.events, []); + await button.click(); const stopping = await state(); expectMode(stopping, 'stop', 'StopFilled', true); + assert.equal(stopping.label, '正在停止…'); assert.deepEqual(stopping.events, [{ action: 'stop' }]); + await page.keyboard.press('Enter'); assert.deepEqual((await state()).events, [{ action: 'stop' }]); + }); + await run('same button changes to queue; click and Enter queue then return to stop', async () => { + await reset(); const initial = await state(); + await editor.click(); await page.keyboard.insertText('next step'); + const queued = await state(); expectMode(queued, 'queue', 'Queue'); assert.equal(queued.label, '排队'); + assert.deepEqual(queued.rect, initial.rect); + await button.click(); expectMode(await state(), 'stop', 'StopFilled'); + await editor.click(); await page.keyboard.insertText('another step'); await settle(); await page.keyboard.press('Enter'); + const sent = await state(); expectMode(sent, 'stop', 'StopFilled'); + assert.deepEqual(sent.events, [{ action: 'queue', text: 'next step' }, { action: 'queue', text: 'another step' }]); + }); + await run('clearing content returns to stop; Shift+Enter inserts a newline', async () => { + await reset({ text: 'next step' }); + await editor.click(); await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.press('Backspace'); + expectMode(await state(), 'stop', 'StopFilled'); + await page.keyboard.insertText('first'); await page.keyboard.press('Shift+Enter'); await page.keyboard.insertText('second'); + const multiline = await state(); expectMode(multiline, 'queue', 'Queue'); + assert.equal(multiline.value, 'first\nsecond'); assert.deepEqual(multiline.events, []); + }); + await run('attachment-only draft queues through Enter', async () => { + await reset({ attachment: true }); expectMode(await state(), 'queue', 'Queue'); + await editor.click(); await page.keyboard.press('Enter'); + const sent = await state(); assert.equal(sent.events[0]?.action, 'queue'); expectMode(sent, 'stop', 'StopFilled'); + }); + await run('submission and blocking guards disable queue while stop stays available', async () => { + for (const blocker of ['disabled', 'submitting']) { + await reset({ text: 'next step', [blocker]: true }); expectMode(await state(), 'queue', 'Queue', true); + await reset({ [blocker]: true }); expectMode(await state(), 'stop', 'StopFilled'); + } + await reset({ stopping: true }); expectMode(await state(), 'stop', 'StopFilled', true); + }); + await run('idle send, empty retry, and paused queue resume retain their actions', async () => { + await reset({ busy: false }); expectMode(await state(), 'send', 'Send', true); + await reset({ busy: false, text: 'hello' }); await button.click(); + assert.deepEqual((await state()).events, [{ action: 'send', text: 'hello' }]); + await reset({ busy: false, retry: true }); expectMode(await state(), 'send', 'Send'); + await button.click(); assert.deepEqual((await state()).events, [{ action: 'send', text: '' }]); + await reset({ busy: false, paused: true }); expectMode(await state(), 'resume', 'Run'); + await editor.click(); await page.keyboard.press('Enter'); assert.deepEqual((await state()).events, [{ action: 'resume' }]); + }); + await run('light/dark and narrow/wide retain accent background and stable geometry', async () => { + for (const theme of ['light', 'dark']) for (const width of [1100, 390]) { + await page.setViewportSize({ width, height: 600 }); + for (const mode of ['stop', 'queue']) { + await reset({ theme, text: mode === 'queue' ? 'next step' : '' }); + const actual = await state(); expectMode(actual, mode, mode === 'stop' ? 'StopFilled' : 'Queue'); + assert.ok(actual.rect.x >= 0 && actual.rect.x + actual.rect.width <= width); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.locator('main').screenshot({ path: path.join(output, `${theme}-${width}-${mode}.png`) }); + } + } + }); + await run('English actions translate and keyboard focus is visible', async () => { + await reset({ locale: 'en-US' }); assert.equal((await state()).label, 'Stop'); + await editor.click(); + for (let index = 0; index < 12; index += 1) { + await page.keyboard.press('Tab'); + if (await button.evaluate(el => el === document.activeElement)) break; + } + const focus = await button.evaluate(el => ({ focused: el === document.activeElement, width: getComputedStyle(el).outlineWidth })); + assert.equal(focus.focused, true); assert.equal(focus.width, '2px'); + await reset({ locale: 'en-US', text: 'next step' }); assert.equal((await state()).label, 'queue'); + }); + await run('no browser runtime errors', async () => assert.deepEqual(errors, [])); +} finally { + await writeFile(path.join(output, 'results.json'), JSON.stringify({ results, errors }, null, 2) + '\n'); + await browser?.close(); await server.close(); +} diff --git a/web/scripts/test-session-errors.mjs b/web/scripts/test-session-errors.mjs new file mode 100644 index 000000000..1ef5ff3ed --- /dev/null +++ b/web/scripts/test-session-errors.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer, transformWithEsbuild } from 'vite'; + +// Real ChatView, with isolated session-history and event fixtures. +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 entry = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ChatView} from '/src/components/ChatView.jsx'; +import {connection} from '/src/lib/connection.js'; +import {applyLocalePreference} from '/src/i18n/index.js'; +import '/src/styles/globals.css'; +await applyLocalePreference('zh-CN'); +function Fixture() { + const [sid, setSid] = React.useState('quota-session'); + window.fixture = { + select: setSid, + emit(payload, seq) { connection.dispatchEvent(new CustomEvent('message', { + detail: {type:'message', payload, seq, session_id:'quota-session'} + })); }, + }; + return ; +} +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: 'session-errors', + resolveId(id) { if (id === 'virtual:session-errors') return '\0virtual:session-errors'; }, + async load(id) { if (id === '\0virtual:session-errors') return (await transformWithEsbuild(entry, 'fixture.jsx', { loader: 'jsx' })).code; }, + configureServer(vite) { + vite.middlewares.use('/session-errors', async (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/session-errors', html)); + }); + }, + }], +}); +const error = { + id: 'persisted-error-451', uuid: 'persisted-error-451', role: 'error', + content: '[Error] HTTP 451 额度已经用完', timestamp: '2026-10-10T07:00:00Z', + metadata: { transcript_only: true, provider_error: { status_code: 451, raw_body: '额度已经用完' } }, +}; +const messages = [{ id: 'quota-user', role: 'user', content: '请继续处理任务' }]; +let replay = []; +const errors = []; +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: 1280, height: 850 } }); + page.setDefaultTimeout(15000); + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/api/**', async route => { + const pathname = new URL(route.request().url()).pathname; + let data = {}; + if (pathname.includes('/messages')) data = { + messages: pathname.includes('quota-session') ? messages : [{ id: 'other-user', role: 'user', content: '另一个会话' }], + events: pathname.includes('quota-session') ? replay : [], busy: false, + }; + else if (pathname.endsWith('/model')) data = { name: 'fixture', provider: 'openai', model: 'test' }; + else if (pathname.includes('models')) data = { models: [], profiles: [] }; + else if (pathname.includes('workspaces')) data = { workspaces: [] }; + else if (pathname.includes('history')) data = { items: [], history: [] }; + else if (pathname.includes('commands')) data = { commands: [] }; + else if (pathname.includes('skills')) data = { skills: [] }; + else if (pathname.includes('experts')) data = { experts: [] }; + else if (pathname.includes('subagents')) data = { tasks: [] }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); + }); + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/session-errors`, { waitUntil: 'commit' }); + await page.getByText('请继续处理任务', { exact: true }).waitFor(); + await page.evaluate(() => window.fixture.select('other-session')); + await page.getByText('另一个会话', { exact: true }).waitFor(); + messages.push(error); // Failure occurs while the other session is selected. + await page.evaluate(() => window.fixture.select('quota-session')); + const notice = page.getByText('HTTP 451 额度已经用完', { exact: false }); + await notice.waitFor(); + assert.equal(await notice.count(), 1); + await page.evaluate(error => window.fixture.emit(error, 10), error); + assert.equal(await notice.count(), 1); + replay = [{ type: 'message', seq: 10, payload: error }]; + await page.reload({ waitUntil: 'commit' }); + await notice.waitFor(); + assert.equal(await notice.count(), 1); + const second = { ...error, id: 'persisted-error-451-again', uuid: 'persisted-error-451-again' }; + messages.push({ id: 'quota-user-2', role: 'user', content: '再试一次' }, second); + replay.push({ type: 'message', seq: 11, payload: second }); + await page.reload({ waitUntil: 'commit' }); + await page.waitForFunction(() => [...document.querySelectorAll('*')].filter(el => + el.children.length === 0 && el.textContent.includes('HTTP 451 额度已经用完')).length === 2); + assert.equal(await notice.count(), 2); + const out = path.resolve(web, '../build/validation/session-errors'); + await mkdir(out, { recursive: true }); + await page.screenshot({ path: path.join(out, 'restored-errors.png'), fullPage: true }); + assert.deepEqual(errors, []); + console.log('[pass] background error survives switch and refresh; replay deduplicates; repeated failures remain visible'); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index f5bf1ad92..070d51370 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -70,6 +70,7 @@ const ICONS = { palette: 'Palette', pin: 'Pin', pinOff: 'PinOff', + queue: 'Queue', refresh: 'Refresh', rightBar: 'RightBar', run: 'Run', @@ -82,6 +83,7 @@ const ICONS = { send: 'Send', settings: 'Settings', stop: 'Stop', + stopFilled: 'StopFilled', terminal: 'TerminalReadWrite', trajectory: 'Trajectory', tool: 'Tool', diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index 832e81684..a20a5be7d 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -1,7 +1,7 @@ // 输入框:富文本 composer 自动撑高(最多 8 行) + Enter 发 / Shift+Enter 换行 + // 空输入或未编辑的历史项用上下键翻 history。 // -// 底部工具栏单独占一行,提交按钮在右侧;空内容仅在可重试末尾用户消息时允许发送。 +// 底部工具栏单独占一行,主按钮在右侧;运行中空草稿显示停止,有内容显示排队。 // // 斜杠命令:value 以 / 开头且无空白时,SlashDropdown 浮层显示在输入框上方。 // 目标指令显示在加号旁的标签中;其他已确认命令仍在正文中显示原子 token。 @@ -1321,6 +1321,12 @@ export const InputBar = forwardRef(function InputBar({ const actionState = getInputBarActionState({ value: draftValue, disabled, busy, hasExtras, submitting, canRetryLastUserMessage, queuePaused }); const stopControl = getGoalStopControlState({ busy, stopping }); + const isStopAction = actionState.mode === 'stop'; + const primaryActionLabel = t(isStopAction && stopping ? stopControl.label : actionState.submitLabel); + const stopTitle = t(stopping ? stopControl.title : actionState.submitTitle); + const primaryActionTitle = isStopAction && mainComposer + ? withAppShortcutHint(stopTitle, 'stop') + : isStopAction ? stopTitle : t(actionState.submitTitle); const composerSpacingClass = isHero ? 'px-4 pt-3 pb-1 text-[14px]' : 'px-3 pt-2 pb-1 text-[13px]'; const hasInlineContexts = otherContextItems.length > 0; const capabilityControl = ( @@ -1515,60 +1521,25 @@ export const InputBar = forwardRef(function InputBar({ ) : null; const submitControls = ( - <> - {stopControl.visible && ( - + - ) : ( - - )} - + title={primaryActionTitle} + > + + ); return ( diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 53f7a94b0..d061f77aa 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -334,6 +334,7 @@ export const sourceCatalogs = { "s_1d84f1c673d8d0e5": "申请沙箱外执行", "s_1dcfba0e1080d653": "轮 ·", "s_1dd7e04501431a63": "拖动调整左侧栏宽度", + "s_1dfc153939645750": "停止当前任务", "s_1e28f9e9ddff99f8": "Grok 模型列表格式错误", "s_1ea2e1ed30a23050": "可写白名单", "s_1ea9e75bc2a27a29": "这里可以新建和搜索任务、继续历史任务,并在工作区之间切换。", @@ -1469,6 +1470,7 @@ export const sourceCatalogs = { "s_8ae0ef2d1a765063": "命令规则保存失败:", "s_8ae86b0db2eb0741": "解除专家失败:{{p0}}", "s_8ae96d747e59609e": "无描述", + "s_8af93cd34185c685": "输入消息可排队 · Shift+Enter 换行 · 上下键切换历史消息", "s_8afd3eb9d4a894bf": "此 Markdown 结构按原文保留", "s_8b2106ca13719cb2": "反馈", "s_8b47380e110ab22e": "主题包尚未准备好,请重新导出", @@ -2109,6 +2111,7 @@ export const sourceCatalogs = { "s_ca4353258c1aa8b6": "智能添加定时任务", "s_ca478c91d3839a44": "输入 API Key", "s_ca48ad24f64e90a1": "协助用户总结当前会话的摘要", + "s_ca4d973c0b006b75": "停止", "s_ca6343cddb75f289": "生成测试图片失败,请检查连接和模型名称", "s_ca66c2da6f5bf825": "了解更多", "s_ca74d439e7712aa5": "daemon 未运行,已错过且不会补跑", @@ -3031,6 +3034,7 @@ export const sourceCatalogs = { "s_1d84f1c673d8d0e5": "Requested to run outside the sandbox", "s_1dcfba0e1080d653": "wheel ·", "s_1dd7e04501431a63": "Drag to adjust the width of the left column", + "s_1dfc153939645750": "Stop the current task", "s_1e28f9e9ddff99f8": "The Grok model list was invalid", "s_1ea2e1ed30a23050": "Writable allow list", "s_1ea9e75bc2a27a29": "Here you can create and search tasks, continue historical tasks, and switch between workspaces.", @@ -4166,6 +4170,7 @@ export const sourceCatalogs = { "s_8ae0ef2d1a765063": "Could not save command rules:", "s_8ae86b0db2eb0741": "Failed to detach expert: {{p0}}", "s_8ae96d747e59609e": "No description", + "s_8af93cd34185c685": "Type a message to queue · Shift+Enter for a new line · Up/Down to browse message history", "s_8afd3eb9d4a894bf": "This Markdown structure is preserved as source", "s_8b2106ca13719cb2": "Feedback", "s_8b47380e110ab22e": "The theme package is not ready. Export it again.", @@ -4806,6 +4811,7 @@ export const sourceCatalogs = { "s_ca4353258c1aa8b6": "Add scheduled task with AI", "s_ca478c91d3839a44": "Enter API key", "s_ca48ad24f64e90a1": "Help summarize the current conversation", + "s_ca4d973c0b006b75": "Stop", "s_ca6343cddb75f289": "Could not generate a test image. Check the connection and model name.", "s_ca66c2da6f5bf825": "learn more", "s_ca74d439e7712aa5": "The daemon is not running, it has been missed and will not be rerun.", diff --git a/web/src/lib/icons/controls.js b/web/src/lib/icons/controls.js index 32c69f409..f0b157503 100644 --- a/web/src/lib/icons/controls.js +++ b/web/src/lib/icons/controls.js @@ -64,6 +64,7 @@ export const controlIcons = { PanelLeftFilled: [panel, p('M4.1 3.5h3.4v13H4.1q-1.6 0-1.6-1.6V5.1q0-1.6 1.6-1.6Z', { fill: 'currentColor', stroke: 'none' }), p('M7.5 3.5v13')], PanelRight: panelRight, PanelRightFilled: [panel, p('M12.5 3.5h3.4q1.6 0 1.6 1.6v9.8q0 1.6-1.6 1.6h-3.4Z', { fill: 'currentColor', stroke: 'none' }), p('M12.5 3.5v13')], + Queue: [p('M3 4.5h9M3 9h7M3 13.5h4M16 4.5v9q0 2-2 2h-4m2.5-2.5L10 15.5l2.5 2.5')], Refresh: [p('M16.5 8a6.8 6.8 0 0 0-11.4-3.3L3 7M3 3.5V7h3.5M3.5 12a6.8 6.8 0 0 0 11.4 3.3L17 13M13.5 13H17v3.5')], RightBar: panelRight, Run: [p('M6 3.8q0-.8.7-.4l10 6q.9.6 0 1.2l-10 6q-.7.4-.7-.4Z')], @@ -73,6 +74,7 @@ export const controlIcons = { Search: search, Send: [p('M3.4 3.3 17 9.4q1.1.6 0 1.2L3.4 16.7q-.8.4-.6-.5l1.7-5.6q.2-.6 0-1.2L2.8 3.8q-.2-.9.6-.5ZM4.7 10h7')], Stop: [r(4, 4, 12, 12, 1.8)], + StopFilled: [r(3, 3, 14, 14, 1, { fill: 'currentColor', stroke: 'none' })], TerminalReadWrite: [r(2.5, 4.5, 15, 11), p('m5.5 7.5 2.1 2.1q.4.4 0 .8l-2.1 2.1M10.5 12.5h4')], WorkspaceMenu: ellipsis, // Functional symbols formerly drawn locally in individual components. diff --git a/web/src/lib/inputBarState.js b/web/src/lib/inputBarState.js index fa45c0dbd..3840903de 100644 --- a/web/src/lib/inputBarState.js +++ b/web/src/lib/inputBarState.js @@ -14,11 +14,12 @@ export function getInputBarActionState({ // Enter 恢复排队消息的发送。它压过「重发末尾用户消息」—— 中断后两者往往 // 同时满足,而用户此刻看到的是「队列已暂停」横幅,按钮语义必须与横幅一致。 const isResume = !isBusy && !!queuePaused && !hasText && !hasExtras; + const isStop = isBusy && !hasText && !hasExtras; const hasSubmittableContent = hasText || !!hasExtras || (!busy && canRetryLastUserMessage); // submitting 只压住「再发一次」这个动作。它绝不能并进 disabled —— disabled // 会一路传到 Slate 的 readOnly,把整个编辑区变成 contenteditable=false。 const isSubmitting = !!submitting; - const mode = isResume ? 'resume' : isBusy ? 'queue' : 'send'; + const mode = isResume ? 'resume' : isStop ? 'stop' : isBusy ? 'queue' : 'send'; return { hasText, hasExtras: !!hasExtras, @@ -26,14 +27,16 @@ export function getInputBarActionState({ canSubmit: (isResume || hasSubmittableContent) && !isDisabled && !isSubmitting, submitting: isSubmitting, canAbort: isBusy, - submitLabel: isResume ? '继续' : isBusy ? '排队' : '发送', + submitLabel: isResume ? '继续' : isStop ? '停止' : isBusy ? '排队' : '发送', submitTitle: isResume ? '继续发送排队的消息 (Enter)' - : isBusy ? '排队下一条 (Enter)' : '发送 (Enter)', + : isStop ? '停止当前任务' : isBusy ? '排队下一条 (Enter)' : '发送 (Enter)', helperText: isResume ? 'Enter 继续发送排队的消息 · Shift+Enter 换行 · 上下键切换历史消息' - : isBusy - ? 'Enter 排队 · Shift+Enter 换行 · 上下键切换历史消息' - : 'Enter 发送 · Shift+Enter 换行 · 上下键切换历史消息', + : isStop + ? '输入消息可排队 · Shift+Enter 换行 · 上下键切换历史消息' + : isBusy + ? 'Enter 排队 · Shift+Enter 换行 · 上下键切换历史消息' + : 'Enter 发送 · Shift+Enter 换行 · 上下键切换历史消息', }; } diff --git a/web/src/lib/inputBarState.test.js b/web/src/lib/inputBarState.test.js index c84ab0a09..cfc6b7368 100644 --- a/web/src/lib/inputBarState.test.js +++ b/web/src/lib/inputBarState.test.js @@ -31,11 +31,31 @@ run('busy 输入使用排队模式并保留中断能力', () => { assert.equal(state.canAbort, true); }); -run('busy 空输入禁用排队但不禁用中断', () => { - const state = getInputBarActionState({ value: ' ', busy: true }); - assert.equal(state.mode, 'queue'); - assert.equal(state.canSubmit, false); - assert.equal(state.canAbort, true); +run('busy 空草稿显示停止,空回车不提交', () => { + for (const value of ['', ' ', '\n\t', '\u3000']) { + const state = getInputBarActionState({ value, busy: true }); + assert.equal(state.mode, 'stop'); + assert.equal(state.submitLabel, '停止'); + assert.equal(state.submitTitle, '停止当前任务'); + assert.equal(state.canSubmit, false); + assert.equal(state.canAbort, true); + } +}); + +run('busy 附件或上下文草稿显示排队,移除后回到停止', () => { + const queued = getInputBarActionState({ value: '', busy: true, hasExtras: true }); + assert.equal(queued.mode, 'queue'); + assert.equal(queued.canSubmit, true); + assert.equal(getInputBarActionState({ value: '', busy: true, hasExtras: false }).mode, 'stop'); +}); + +run('busy 停止模式不被 disabled 或 submitting 变成发送动作', () => { + for (const blocker of [{ disabled: true }, { submitting: true }]) { + const state = getInputBarActionState({ value: '', busy: true, ...blocker }); + assert.equal(state.mode, 'stop'); + assert.equal(state.canSubmit, false); + assert.equal(state.canAbort, true); + } }); run('仅空闲且末尾用户消息可重试时允许空输入发送', () => { @@ -106,7 +126,7 @@ run('队列暂停时只有空输入才是「继续」,其它情况沿用原语 assert.equal(getInputBarActionState({ value: 'hello', queuePaused: true }).mode, 'send'); assert.equal(getInputBarActionState({ value: 'hello', queuePaused: true }).submitLabel, '发送'); assert.equal(getInputBarActionState({ value: '', hasExtras: true, queuePaused: true }).mode, 'send', '附件是可发送内容'); - assert.equal(getInputBarActionState({ value: '', busy: true, queuePaused: true }).mode, 'queue', '回合运行中不出现继续'); + assert.equal(getInputBarActionState({ value: '', busy: true, queuePaused: true }).mode, 'stop', '回合运行中空草稿仍然是停止'); assert.equal(getInputBarActionState({ value: '', queuePaused: true, disabled: true }).canSubmit, false); assert.equal(getInputBarActionState({ value: '', queuePaused: true, submitting: true }).canSubmit, false); assert.equal(getInputBarActionState({ value: '', queuePaused: false }).mode, 'send', '未暂停时空输入仍是普通发送(不可点)'); diff --git a/web/src/lib/modelSettings.js b/web/src/lib/modelSettings.js index 96337ca6a..bddfe223a 100644 --- a/web/src/lib/modelSettings.js +++ b/web/src/lib/modelSettings.js @@ -284,6 +284,14 @@ function normalizeCatalogProvider(raw, index) { if (!PROVIDER_GROUPS.has(group)) { throw contractError(`providers[${index}].group is unsupported`); } + const defaultApiProtocol = optionalString( + value.default_api_protocol, + `providers[${index}].default_api_protocol`, + ); + if (defaultApiProtocol && (runtimeProvider !== 'openai' + || !MODEL_API_PROTOCOLS.includes(defaultApiProtocol))) { + throw contractError(`providers[${index}].default_api_protocol is unsupported`); + } return { id, name: requireString(value.name, `providers[${index}].name`), @@ -294,6 +302,7 @@ function normalizeCatalogProvider(raw, index) { auth_mode: authMode, endpoint_editable: endpointEditable, endpoint_modes: endpointModes, + default_api_protocol: defaultApiProtocol, model_input: modelInput, models_dev_provider_id: requireNullableText( value.models_dev_provider_id, @@ -868,10 +877,11 @@ export function applyCatalogProviderToDraft(draft, provider) { model: '', base_url: policy.show_base_url ? provider.base_url : '', endpoint_mode: provider.endpoint_modes?.[0] || 'base_url', - // An explicit provider switch must clear a previously selected Responses - // protocol even when both providers share the openai runtime kind. - api_protocol: provider.runtime_provider === 'openai' && draft?.api_protocol - ? 'chat_completions' : '', + // A provider switch uses the new catalog default, even when both + // providers share the openai runtime kind. + api_protocol: policy.show_api_protocol + ? provider.default_api_protocol || (draft?.api_protocol ? 'chat_completions' : '') + : '', api_key: '', has_api_key: false, clear_api_key: !!draft?.has_api_key && provider.auth_mode === 'none', diff --git a/web/src/lib/modelSettings.test.js b/web/src/lib/modelSettings.test.js index 8fee1f987..f336737a6 100644 --- a/web/src/lib/modelSettings.test.js +++ b/web/src/lib/modelSettings.test.js @@ -1383,6 +1383,50 @@ run('ACEModel reasoning refresh preserves same-model manual capabilities without assert.deepEqual(reprobedPayloads.payloads[1].capabilities, ['reasoning']); }); +run('ACEModel 目录默认 Responses,批量新增和编辑可保留协议选择', () => { + const provider = normalizeModelCatalogSummary(sharedCatalogContract.summary).providers + .find((item) => item.id === 'acemodel'); + assert.equal(provider.default_api_protocol, 'responses'); + assert.equal(modelFieldPolicy(provider).show_api_protocol, true); + for (const previousProtocol of ['', 'chat_completions', 'responses']) { + const selected = applyCatalogProviderToDraft({ + ...emptyModelProfileDraft(), api_protocol: previousProtocol, + }, provider); + assert.equal(selected.api_protocol, 'responses'); + const draft = { ...selected, name: 'ace', model: 'moonlight,starrylight,aurora', api_key: 'test-key' }; + const batch = buildModelMutationPayloads(draft, provider); + assert.equal(batch.ok, true); + assert.equal(batch.payloads.length, 3); + assert.deepEqual(batch.payloads.map((item) => item.api_protocol), ['responses', 'responses', 'responses']); + assert.equal(applyCatalogProviderToDraft(draft, openRouterProvider).api_protocol, 'chat_completions'); + assert.equal(applyCatalogProviderToDraft(draft, copilotProvider).api_protocol, ''); + } + for (const protocol of ['chat_completions', 'responses']) { + const saved = { + name: 'ace', provider: 'openai', model: 'moonlight', + models_dev_provider_id: 'acemodel', base_url: provider.base_url, + api_key: 'test-key', api_protocol: protocol, + }; + const restored = modelProfileDraftFromSaved(saved); + assert.equal(restored.api_protocol, protocol); + const result = buildModelMutationPayload(restored, provider, { editing: true }); + assert.equal(result.ok, true); + assert.equal(result.payload.api_protocol, protocol); + } +}); + +run('目录默认协议拒绝未知值和非 OpenAI 服务商', () => { + for (const provider of [ + { ...customProvider, default_api_protocol: 'auto' }, + { ...copilotProvider, default_api_protocol: 'responses' }, + { ...anthropicProvider, default_api_protocol: 'responses' }, + ]) { + assert.throws(() => normalizeModelCatalogSummary({ + ...catalogFixture(), providers: [provider], + }), /default_api_protocol is unsupported/); + } +}); + run('Responses 协议贯通保存、编辑及显式切回 Chat Completions', () => { const saved = { name: 'responses-model', provider: 'openai', model: 'model-id', diff --git a/web/src/lib/sessionTranscript.js b/web/src/lib/sessionTranscript.js index 85d15db08..a9866b669 100644 --- a/web/src/lib/sessionTranscript.js +++ b/web/src/lib/sessionTranscript.js @@ -22,8 +22,18 @@ export function messageKey(role, content) { return `${role || ''}\u0000${content || ''}`; } +function isPersistedErrorMessage(payload) { + return payload?.role === 'error' && payload?.metadata?.transcript_only === true && !!payload?.id; +} + function messageEventUsesOccurrenceIdentity(payload) { - return (payload?.role || 'system') === 'error'; + return payload?.role === 'error' && !isPersistedErrorMessage(payload); +} + +function replayMessageKey(payload) { + return isPersistedErrorMessage(payload) + ? `error-id\u0000${payload.id}` + : messageKey(payload?.role || 'system', payload?.content || ''); } // 回放(REST since=N 补拉 / WS 带游标订阅)时 token / reasoning 先攒着,直到看见 @@ -1221,7 +1231,7 @@ export function applyTranscriptReplayEvents(state, events = []) { .map((item) => item.messageId)); const seenMessages = new Set(next.items .filter((item) => item?.kind === 'msg') - .map((item) => messageKey(item.role || 'system', item.content || ''))); + .map((item) => replayMessageKey({ ...item, id: item.messageId }))); let pendingStreamEvents = []; const flushPendingStreamEvents = () => { for (const ev of pendingStreamEvents) { @@ -1249,7 +1259,7 @@ export function applyTranscriptReplayEvents(state, events = []) { effects.push(...reduced.effects); continue; } - const key = messageKey(p.role || 'system', p.content || ''); + const key = replayMessageKey(p); if (!occurrenceIdentity && seenMessages.has(key)) { pendingStreamEvents = []; markEventSeqApplied(next, ev); @@ -1552,8 +1562,8 @@ export function reduceTranscriptEvent(state, msg) { // 原位更新(事件可能带更完整的 content_parts / metadata),不追加, // 否则用户气泡出现两次。不同 id 相同文本(用户故意连发)不受影响。 const incomingMessageId = p.id || ''; - // provider/runtime error 不属于持久消息。不同回合可能得到完全相同的 - // role/content/id,必须按事件 seq 分别展示;同一事件重放仍由上方水位拦截。 + // Persisted errors carry a unique occurrence ID. Legacy errors still use + // sequence identity because their content-derived IDs can repeat. const existingIndex = incomingMessageId && !messageEventUsesOccurrenceIdentity(p) ? next.items.findIndex((item) => item.kind === 'msg' && item.messageId === incomingMessageId) : -1; @@ -1987,7 +1997,7 @@ export function loadTranscriptHistory(state, data = {}) { if (typeof data.summary === 'string') next.summary = data.summary; if (typeof data.swarm_mode === 'string') next.swarmMode = normalizeSwarmMode(data.swarm_mode); - const seenMessages = new Set(msgs.map((m) => messageKey(m.role || 'system', m.content || ''))); + const seenMessages = new Set(msgs.map(replayMessageKey)); let pendingStreamEvents = []; const flushPendingStreamEvents = () => { for (const ev of pendingStreamEvents) { @@ -2007,7 +2017,7 @@ export function loadTranscriptHistory(state, data = {}) { if (ev?.type === 'message') { const p = ev.payload || {}; const occurrenceIdentity = messageEventUsesOccurrenceIdentity(p); - const key = messageKey(p.role || 'system', p.content || ''); + const key = replayMessageKey(p); if (!occurrenceIdentity && seenMessages.has(key)) { pendingStreamEvents = []; markEventSeqApplied(next, ev); diff --git a/web/src/lib/sessionTranscript.test.js b/web/src/lib/sessionTranscript.test.js index a537b52ec..edb524f44 100644 --- a/web/src/lib/sessionTranscript.test.js +++ b/web/src/lib/sessionTranscript.test.js @@ -1142,6 +1142,47 @@ run('provider 非 JSON 错误 message 保留原始文本', () => { assert.equal(state.items[0].metadata.provider_error.raw_body, rawBody); }); +run('持久错误切回会话和刷新后保留详情,历史与事件重叠不重复', () => { + const error = { + id: 'error-451-first', role: 'error', content: '[Error] HTTP 451 quota exhausted', + timestamp: '2026-10-10T07:00:00Z', + metadata: { transcript_only: true, provider_error: { status_code: 451, raw_body: 'quota exhausted' } }, + }; + const user = { id: 'quota-user', role: 'user', content: 'continue' }; + const ev = { type: 'message', seq: 2, payload: error }; + let state = loadTranscriptHistory(createTranscriptState(), { messages: [user, error], events: [ev] }).state; + assert.deepEqual(state.items.map(item => item.messageId), ['quota-user', error.id]); + assert.equal(state.items[1].metadata.provider_error.status_code, 451); + state = applyTranscriptReplayEvents(state, [{ ...ev, seq: 3 }]).state; + assert.equal(state.items.length, 2); + state = reduceTranscriptEvent(state, { ...ev, seq: 4 }).state; + assert.equal(state.items.length, 2); + state = reduceTranscriptEvent(state, { + type: 'transcript_replace', seq: 5, payload: { messages: [user, error] }, + }).state; + assert.equal(state.items[1].role, 'error'); + const reloaded = loadTranscriptHistory(createTranscriptState(), { messages: [user, error], events: [], busy: false }).state; + assert.equal(reloaded.items[1].content, error.content); + assert.equal(reloaded.items[1].metadata.provider_error.raw_body, 'quota exhausted'); + const projected = projectCollapsedTranscriptItems(reloaded.items); + assert.ok(projected.some(item => item.kind === 'msg' && item.role === 'error')); +}); + +run('持久错误按唯一 ID 保留相同正文的多次失败', () => { + const first = { id: 'error-1', role: 'error', content: '[Error] HTTP 451 quota exhausted', metadata: { transcript_only: true } }; + const second = { ...first, id: 'error-2' }; + const events = [first, second].map((payload, index) => ({ type: 'message', seq: index + 1, payload })); + const live = reduceMany(events); + const fromHistory = loadTranscriptHistory(createTranscriptState(), { messages: [first, second], events }).state; + const fromReplay = applyTranscriptReplayEvents( + loadTranscriptHistory(createTranscriptState(), { messages: [first] }).state, events, + ).state; + const fromPartialHistory = loadTranscriptHistory(createTranscriptState(), { messages: [first], events }).state; + for (const state of [live, fromHistory, fromReplay, fromPartialHistory]) { + assert.deepEqual(state.items.map(item => item.messageId), ['error-1', 'error-2']); + } +}); + run('provider 错误 message 会结束已有 partial assistant streaming 状态', () => { const state = reduceMany([ { type: 'token', payload: { text: 'partial' }, seq: 1 }, diff --git a/web/src/lib/trailingUserMessageRetry.test.js b/web/src/lib/trailingUserMessageRetry.test.js index 38e9a7f84..20ca5dd4b 100644 --- a/web/src/lib/trailingUserMessageRetry.test.js +++ b/web/src/lib/trailingUserMessageRetry.test.js @@ -108,12 +108,14 @@ assert.deepEqual(abortState.items.map((item) => item.content), [ applyAbortEvent('error', { reason: 'later error' }); assert.equal(retryId(abortState), '', 'a later failure invalidates the stop'); -// The two UI actions must share a gate, and the send path must inspect the +// Submit clicks and Enter must share a gate; only the empty busy stop action +// bypasses submission. The send path must inspect the // latest complete store rather than composer history or a rendered window. const inputBar = fs.readFileSync(new URL('../components/InputBar.jsx', import.meta.url), 'utf8'); assert.match(inputBar, /const submit = \(\) => \{\s*if \(!actionState\.canSubmit\) return;/); assert.match(inputBar, /onSubmit=\{submit\}/); -assert.match(inputBar, /onClick=\{submit\}/); +assert.match(inputBar, /const isStopAction = actionState\.mode === 'stop';/); +assert.match(inputBar, /onClick=\{isStopAction \? onAbort : submit\}/); const chat = fs.readFileSync(new URL('../components/ChatView.jsx', import.meta.url), 'utf8'); assert.match(chat, /trailingUserMessageRetryId\(\{\s*sessionId: sid, items, loadState:/); assert.match(chat, /const latest = transcript\.getState\(\);/);