Skip to content

fix(desktop): refuse a slash submission when the command source fails, and give compaction its own deadline (#795) - #848

Merged
vastsa merged 5 commits into
mainfrom
fix/795-composer-command-fallback
Sep 22, 2026
Merged

vastsa merged 5 commits into
mainfrom
fix/795-composer-command-fallback

Conversation

@vastsa

@vastsa vastsa commented Sep 22, 2026

Copy link
Copy Markdown
Owner

摘要

修 #795 里两条已确认的缺陷,并给巨型会话空白视图补读侧防御。

Refs #795(不 closes:issue 里还有宿主读侧可区分错误、搜狗 IME 下 / 被吞两条未处理,见文末)

缺陷与根因

1. slash 指令被静默降级成自然语言发给模型(最高危)

resolveComposerCommand 在 api.composerCommands() 抛错时 catch { return null; },而 useComposerSubmit 拿不到 command 就把 startsWith("/") 的文本当普通 prompt 提交。RPC 一失败,/compact 就被当字面文本发给模型,issue 报告者实测模型据此改了代码。

resolveComposerCommand 改为三态 resolved / unknown / unavailable(不再返回 null,且失败不写 TTL 缓存 → 下次回车可重试)。新增与 React、store 无关的纯逻辑模块 slash-dispatch.ts 承载决策:unknown(别名/模板/无 id)继续走提示词路径;unavailable fail-closed —— 保留草稿、提示 chat.slashCommandSourceUnavailable、绝不调用 sendPrompt。

2. agent.compact 走默认 130s RPC 超时,真实耗时约 158s

rpc-timeouts.ts 只有 tools.execute 有专属超时。大上下文下宿主先报错、sidecar 实际跑完并落盘,产生虚警。新增 AGENT_COMPACT_RPC_TIMEOUT_MS(3×180s 流看门狗 + 重试退避 + 余量)并按方法路由,未改动全局 130s。宿主不再把传输超时当成 sidecar 的判定:超时后重读持久化的 session.compaction,新检查点已落盘则返回 accepted:true 并记 warn,未落盘原样抛错,sidecar 自己报的错(如 CONTEXT_COMPACTION_FAILED)绝不改写。

3. 巨型会话视图永久空白(读侧防御)

宿主侧根因(Windows 上 remove_file + rename 的非原子替换窗口)已由 #671 修成单次原子 rename,且不在 v0.15.1、已在 v0.15.2+;报告者当时用的 0.15.1 确实命中。仍存在的缺陷在读侧与渲染层:瞬时空窗口被当成真实的空会话并缓存(sidebar hover prefetch 复用 has(id) 早退),且不区分"读失败"与"真的没消息",于是一旦命中就永久空白、只能重启。

本 PR 补防御(不是宿主根因修复):sessionReadLooksEmpty 判定"sidebar 计为有历史却读回零消息",此时再读一次、随后保留用户已有快照,否则显示 chat.sessionTranscriptEmpty 且不提交空会话;持久化读取缓存拒绝写入这类可疑空页,悬停预取无法污染。

测试

  • node --test apps/desktop/test/slash-command-source.test.mjs → 7/7(回退源码后 5 项失败,已验证)
  • apps/desktop/test/session-transcript-empty-read.test.mjs → 4/4
  • scripts/e2e/composer-submission.tsx 增加拒绝用例;node scripts/e2e-composer-paste.mjs → ok:true(回退源码后该用例报 a refused slash submission must not reach the send path: [... "/compact"])
  • pnpm --filter @pi-desktop/shared test 940/940、@pi-desktop/host-runtime 46/46、@pi-desktop/agent-runtime 917/917、@pi-desktop/i18n 25/25
  • node --test apps/desktop/test/*.test.mjs → 2590/2590
  • pnpm -r --if-present typecheck、pnpm lint 通过

文档

03-runtime/01-ipc-protocol.md §5.4 / §13c、03-runtime/07-process-model.md、04-ux/09-interaction-patterns.md、06-delivery/04-e2e-test-plan.md(+4 场景),decisions-log D613/D614/D615,均有 docs/zh-CN/ 镜像;两个新 i18n key 覆盖全部 8 个语言。

已知边界

  • 第 3 条是防御而非根因修复;测试为接线级 + 纯函数级,未做到 store 行为级复现。
  • 读侧仍应把"记录不可读/缺失"与"会话真的没消息"区分开(返回可区分错误或标记),并给 session.get 失败加可重试路径(当前 HOST_OVERLOADED 不重试)——建议单独开 issue。
  • session-slice.selectSession 在可疑空读时提前 return,会跳过 model pin / compaction 记录等收尾逻辑,属异常路径,评审请重点看这里。
  • issue 里搜狗 IME 下 / 变 /、 后唤不出指令的问题不在本 PR 范围。

#795)

`resolveComposerCommand` answered `null` both for "no such command" and for a
failed `composer/commands` read, so the submit path could not tell them apart and
sent `/compact` to the model as literal prompt text whenever the source was
down. The model read that as an instruction and acted on it.

Resolution now answers with three outcomes: resolved (builtin/plugin/extension
dispatch), unknown (templates, aliases, and id-less entries keep the prompt
path), and unavailable. An unavailable source refuses the submission, keeps the
draft, and reports `chat.slashCommandSourceUnavailable`; the failed read is not
cached, so the next submit retries it, while a warm cache still resolves through
a source blip.

The decision lives in the React-free `resolveSlashDispatch` so it is testable on
its own. Locked by `apps/desktop/test/slash-command-source.test.mjs` (fails on
the previous code) and by a new refusal case in the composer-submission E2E
fixture, which reaches the real Composer and asserts nothing was sent.
…dict (#795)

`agent.compact` is a blocking RPC that spends a whole model summary request
inside the sidecar, but it ran under the flat 130s transport default. An 888KB
context needed ~158s, so Electron reported `sidecar RPC timeout` while the
sidecar kept summarizing and persisted the checkpoint: the user was told the
compaction failed, and the next turn proved it had succeeded.

The deadline now follows the ceilings the sidecar does enforce — one 180s stream
watchdog per attempt, `1 + 3` attempts, the 2s/4s/8s retry backoff, and transport
slack (`AGENT_COMPACT_RPC_TIMEOUT_MS`, per-method rather than a wider global
default, mirrored by `plugin-timeout-budgets.test.mjs`).

Both host paths (Electron `agentCompact` IPC and `RuntimeService.compact`) also
stop treating a lost reply as the sidecar's verdict: on a transport timeout they
re-read the durable `session.compaction` record through `compactionRecordId`,
report success when a new checkpoint landed, log the mismatch, and rethrow the
timeout otherwise. A verdict the sidecar reported itself is never reconciled.

Pinned by `packages/host-runtime/src/runtime-service.test.ts`,
`packages/shared/src/protocol.test.ts`, and
`apps/desktop/test/plugin-timeout-budgets.test.mjs`.
Every durable `session.get` window was taken as the truth and cached. The host
answers such a read from a transcript file it may be rewriting, so a window that
came back empty for a session with 2700 messages was stored as an empty
snapshot; the sidebar's hover prefetch then re-served that emptiness on every
later open and the pane stayed blank until the app restarted. Nothing retried a
read and nothing told a failed or stale read apart from an empty conversation.

A read is now judged against the session's own count (`sessionReadLooksEmpty`):
zero messages for a session the sidebar counts as having history triggers one
more read, then keeps the snapshot the user already has, and otherwise reports
`chat.sessionTranscriptEmpty` instead of committing an empty transcript. The
suspicious page is never written to the transcript cache, so a hover prefetch
cannot poison every later open.

This is a defense, not the host-side root cause: the transcript rewrite and the
read side that answers "session exists, no messages" are unchanged. Pinned by
`apps/desktop/test/session-transcript-empty-read.test.mjs`.
- `chat.slashCommandSourceUnavailable` and `chat.sessionTranscriptEmpty` in all
  eight locales (locale typing requires every catalog to carry the key).
- `03-runtime/01-ipc-protocol.md` §5.4 records the compaction deadline and the
  durable-verdict rule; §13c records the three resolutions of a typed `/name`.
- `03-runtime/07-process-model.md` states the per-method deadline rule.
- `04-ux/09-interaction-patterns.md` records that an empty read for a session
  with history is read as unreadable, not empty.
- Decisions log D613/D614/D615, and the E2E plan gains the four scenarios with
  the exact commands that automate them. zh-CN mirrors all of it.
Copilot AI lite review requested due to automatic review settings September 22, 2026 06:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vastsa
vastsa merged commit 328122f into main Sep 22, 2026
4 checks passed
@vastsa
vastsa deleted the fix/795-composer-command-fallback branch September 24, 2026 01:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants