diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 4f6e23c..fef482a 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -12,6 +12,7 @@ | 运营发布 | Works Square 审核与交付 | 公共 `play_url` | 客户端只消费服务端发布结果;真实合同校验 → OSS/CDN 生产链仍待整链验收 | | 可信发布门禁 | source+built+artifact contract | 服务端逐字节重算/合同校验 → 不可变 Release 固化 → 人工审核 | 服务端不运行项目 Vite;仍是不可绕过权威,未来 runtime 强门禁需可信 verifier | | AI 编程首次发送 | ChatPanel 当前 Agent | Renderer Store → Host API → Main → OpenCode session / prompt | 新建 session 已知为空时直接提交 prompt,不在关键路径等待空历史;历史未知或普通历史会话仍按默认路径加载消息 | +| AI 编程上下文压缩 | OpenCode compaction Part / `session.compacted` / `/compact` | Renderer Store → per-session transcript state → Chat mixed timeline | 手动请求先创建 immutable UI event;native identity 负责归并与回放去重;完成只更新压缩项,真实 idle 才结束 run 和释放队列 | | AI 编程模型代理错误 | OpenCode provider 请求 | Main Host AI proxy → Works 模型上游 | 配额耗尽保持独立终止态;只有明确的分组上游饱和才投影为 OpenCode 终止状态,通用限速仍保留 `429` | | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | | 设计会话创建/切换 | AI 绘画页面或侧栏 | Renderer API → Main → Workspace Conversation API | 新会话属于现有 Workspace;读取独立消息、Brief、Quote 和 `turnRevision` | @@ -31,6 +32,7 @@ - 本地开发适配器将旧单会话 schema v2 原子迁移为带默认 Conversation 的 schema v3;打包应用不使用该本地适配器作为云端失败回退。 - 注销和退出会关闭本地事件流并清除本机 Conversation Session-id 缓存;服务端持久 Session 保留,下一次访问从 Conversation API 重新读取。 - AI 编程 Store 的 `sessionMessagesBySessionId` own-key 是加载状态契约:键缺失表示历史未知,存在且值为 `[]` 表示已知为空。只有后者可使用不读取历史的快速选择;普通历史会话选择继续刷新消息。 +- AI 编程压缩状态由 transcript 中的 `compactionsById` / `compactionOrder` 单一持有。运行中 hydration 保留 manual pending identity 并用 native Part 合并;completed 不得回退为 running。`session.compacted` 不是 run idle,不能据此释放 queued prompt;失败、中止或 runtime generation 变化只清理对应未完成事件。 - Main Host AI proxy 可为固定 OpenCode 重试契约做窄化的内部状态投影:配额耗尽投影为 `402`,明确上游分组饱和的 `429` 投影为终止 `400`,其他 `429` 原样保留;升级 OpenCode 时必须重新验证该契约。 - Main AppUpdater 持有 feed、原始异常、下载和安装状态;设置页只消费状态投影。一次 electron-updater `error` 事件覆盖的并发检查不会在 Renderer 重复发错,但独立的后续检查仍有自己的报告生命周期。 @@ -45,4 +47,4 @@ ## Last Updated -2026-08-14 +2026-08-15 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 4f1048e..6d9fd94 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -15,8 +15,9 @@ | `electron/services/project-packager.ts` | 受控项目扫描、静态 ZIP 生成和敏感/历史控制文件排除 | 只允许可发布 `ProjectType`,不提供 Compose 或手工 ZIP 路径 | | `electron/services/works-submission-binding.ts` | submission binding v2 持久化与旧 schema 迁移 | 旧中间态终止为 `legacy_retired`;文件名暂作安装兼容 | | `electron/api/works-play-url.ts` | 公共播放 URL 的共享安全校验 | 公共 `play_url` 必须同源 HTTPS 且精确匹配 App 路径 | -| `src/pages/Chat/OpencodeChatPanel.tsx` | AI 编程 Agent 选择、首次 session 创建与 prompt 提交流程 | 新 session 仅在消息缓存 own-key 已知时使用不读取历史的快速选择 | -| `src/stores/opencode.ts` | OpenCode runtime、session、消息缓存与提交状态 | `sessionMessagesBySessionId` 键缺失表示未知,存在空数组表示已知为空;普通选择默认刷新历史 | +| `src/pages/Chat/OpencodeChatPanel.tsx` | AI 编程 Agent 选择、首次 session 创建、prompt 提交与消息/压缩混合时间线 | 新 session 仅在消息缓存 own-key 已知时使用不读取历史的快速选择;历史压缩项静态渲染,实时转换才进入 live region | +| `src/lib/opencode-session-state.ts` | OpenCode 会话消息与上下文压缩时间线的规范化、hydration 和事件归并 | 压缩 UI identity 不变;native Part/event identity 用于回放去重,completed 状态不可降级 | +| `src/stores/opencode.ts` | OpenCode runtime、session、消息缓存、提交与压缩生命周期 | `sessionMessagesBySessionId` 键缺失表示未知,存在空数组表示已知为空;`session.compacted` 只完成压缩项,真实 idle 才释放 run 队列 | | `electron/api/routes/ai-proxy.ts` | Main-owned 模型代理、凭据边界与上游响应投影 | 仅对明确上游分组饱和做终止态兼容投影,通用限速保持 `429` | | `shared/opencode-error-details.ts` | OpenCode 上游饱和错误的窄化共享分类 | 不以通用 `rate_limit_exceeded` 单独判定饱和 | | `electron/main/updater.ts` | 目标 feed 解析、electron-updater 生命周期与原始诊断 | 缺少稳定 manifest 保持错误;事件/Promise 重复失败按检查实例去重 | @@ -44,6 +45,7 @@ - `works-cloud-deploy.json` 是兼容文件名;不得因名称重新引入自动部署协调语义。 - `runtime_url` 是一个客户端版本的迁移回退;删除前必须确认服务端和存量数据稳定提供 `play_url`。 - `ai-proxy.ts` 的上游饱和状态投影依赖当前固定 OpenCode 的重试语义和窄化错误文案;升级 runtime 或调整上游错误格式时必须复核,不能把所有 `429` 统一终止。 +- OpenCode 压缩时间线依赖 native compaction Part、Session run token 与 runtime generation 的关联;hydration 必须保持 completed 单调,不能用 `session.compacted` 提前结束 run 或释放 queued prompt。 - `electron/main/updater.ts` 的稳定源错误归一化必须保持窄化:只识别 Works Square 对应 manifest 的 404,不得吞掉其他 feed/网络/签名错误;Renderer 的脱敏边界不能取代 Main 原始日志。 - 多 Conversation 事件处理必须区分对话快照与 Workspace 任务更新;不得用任务时间戳推进 Conversation 流水位,也不得让旧会话的迟到流覆盖当前会话。 - Gateway 命令的 REST fallback 只处理 WebSocket 发送、断连和 ACK 超时,必须复用 `client_command_id`;业务错误回退会造成重复提交。Quote 任务恢复只更新 Workspace 所有的任务,不能覆盖当前 Conversation。 @@ -52,4 +54,4 @@ ## Last Updated -2026-08-14 +2026-08-15 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index f2d32c1..92f2945 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- `fd9b5b46a913c515e94e4e26f185d43866c2581f`: Codex-style persistent AI Programming context-compaction timeline and run-lifecycle separation. - `22378efcee07e7fb80b651e65e3202f1a1dfea1d`: AI Canvas bidirectional Agent WebSocket commands, idempotent transport fallback, and Quote-based generation-task recovery. - `aba5cae286807093cf4ef643fe9f498050985c31`: Robot / AI hardware module, Main-owned Works Square proxy, and cross-repository wire contract. - `86ece3a` / `4dde8f3`:客户端登录七天滑动续期及集成提交。 @@ -24,12 +25,13 @@ This file is the integrated default-branch snapshot. Feature tasks record progre AI 绘画的一个 Workspace 可包含多条 Conversation。消息、Brief、Quote 和 `turnRevision` 随 Conversation 隔离;生成任务和资产保持 Workspace 级共享。图片 Brief 支持文生图,以及从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频复用同一选择器绑定首帧。两条路径都通过现有 Workspace Asset 契约提交一个真实 Asset ID。每条 Conversation 使用服务端持久 Agent Gateway Session;连接正常时命令、Run 与设计事件共用双向 WebSocket,只有发送、断连或 ACK 超时等传输故障才以同一 `client_command_id` 回退 REST,结构化业务错误不重复提交且未知上游文本由 Main 脱敏。确认生成会按 Quote 对账 Workspace 任务;任务已经落库但 Run 随后失败时仍恢复任务列表,内部对账失败不覆盖当前 UI 错误,同时 Conversation 写入继续受 Workspace-load 与 Conversation-selection generation 保护。 -AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。 +AI 编程首次发送在新建 OpenCode session 已知为空时不再等待冗余历史读取,prompt 可直接进入 Host API;普通历史会话仍刷新消息。Main AI proxy 只把明确的上游分组饱和投影为当前 OpenCode 的终止状态,配额耗尽保持独立终止态,通用限速继续保留 `429`。上下文压缩以每个 Session 的持久时间线事件呈现:自动与手动压缩使用不同文案,运行态原位弱化显示,完成后静态保留并可从历史恢复;`session.compacted` 只完成对应事件,只有真实 idle 才结束 run 和释放排队消息。 Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。 ## Recently Completed +- 2026-08-15:AI 编程上下文压缩改为 Codex 风格的会话内时间线事件;手动与自动压缩原位展示并持久保留,历史回放去重且状态只允许从 running 单调进入 completed,压缩完成不再冒充整个 run idle。 - 2026-08-14: AI Canvas Agent 命令与流式事件改为共用双向 Conversation WebSocket,并保留仅面向传输故障的幂等 REST 回退;结构化 Gateway 错误不重试且未知文本脱敏;确认生成按 Quote 恢复已落库任务,切换 Conversation 后仍同步 Workspace 任务且不覆盖新会话状态或错误。 - 2026-08-13: Integrated the enabled `Makelore Robot` top-level module at `/ai-hardware`. Renderer uses typed safe DTOs while Electron Main owns Works Square authentication, idempotency identities, ETag/If-Match forwarding, response projection, limits, timeouts, and credential recovery. Robot and Canvas routes no longer initialize AI Programming projects/providers. No production deployment or real activation-code smoke is claimed. - 2026-08-08:合并登录续期和一键发布;发布成功后保存精确 app/version/review 映射,Renderer 不接触 Token、ZIP 或本地路径。 @@ -72,9 +74,10 @@ Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失 - 服务端持久 Conversation Session 不由客户端在注销或退出时删除;Main 只关闭本地流、清除本机缓存并撤销本地凭据。 - 客户端对最终构建字节的 loopback 检查没有可信 receipt,且不复刻生产 opaque-origin;服务端必须独立重算 source/built/contract、校验不可变 Release,人工审核仍不可绕过。如未来需要不可绕过的 runtime gate,必须引入可信 verifier 并绑定精确构建产物。 - AI proxy 的明确饱和 `429 → 400` 是与当前 OpenCode 重试契约匹配的内部投影;升级 OpenCode 或上游饱和错误文案变化时必须复核。不得把通用 `429` 或仅含 `rate_limit_exceeded` 的响应误分类为终止饱和。 +- AI 编程压缩事件必须按 Session、run token、runtime generation 与 native Part/event identity 关联;`session.compacted` 不等于 `session.idle`,历史 hydration 不得把 completed 降回 running,失败或中止必须清理本 run 的未完成事件。 - AI 绘画单图选择器仍精确匹配 quick reply `从作品列表选择图片`,用途再从当前 Brief medium 推断;本地化或协议文案变化可能破坏入口。扩展更多素材输入前应升级为结构化 action/purpose。 - Updater 源码错误提示不能代替发布正式签名产物;稳定 feed 缺 manifest 必须保持失败,Renderer 不得展示原始堆栈、URL、路径或错误码。升级链只有在旧版本完成发现、下载、重启和安装 smoke 后才可视为生产就绪。 ## Last Updated -2026-08-14 +2026-08-15 diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index 1729c7e..ef5d232 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -12,6 +12,7 @@ ## Scope +- On 2026-08-15, resume the existing Integration owner to merge reviewed source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` for the Codex-style AI Programming context-compaction timeline into local `main` and promote its accepted lifecycle facts. - On 2026-08-14, resume the existing Integration owner to merge reviewed AI Canvas source commit `22378efcee07e7fb80b651e65e3202f1a1dfea1d` into local `main` and promote its accepted project-memory candidates. - Resume the existing integration owner to fast-forward the completed Robot / AI hardware source commit into local `main` after verifying the current remote `main` tip. - Fetch the authoritative remote `main`, inspect all commits not present in the @@ -37,6 +38,7 @@ - Push only after the merge result is clean, verified, and independently reviewed. Never use `--force`. - The 2026-08-14 user request authorizes the local `main` merge only. It does not expand this resumption into a remote push; the existing authentication blocker remains a separate follow-up. +- The 2026-08-15 context-compaction request likewise authorizes only a local `main` merge. It does not authorize a remote push or changing the existing OpenCode/model compaction threshold. ## Project Context Loaded @@ -93,6 +95,15 @@ Gate result: - Read the source task, ADR-001, canonical AI Canvas architecture/domain memory, and all registry-owned peer task Scope / Intent / Promotion Candidate sections. The source preserves Workspace-owned tasks and Conversation-owned state; no semantic conflict requires human resolution. - Gate result: Passed for the local AI Canvas merge. Remote push remains outside this resumption. +### 2026-08-15 Context Compaction Integration Resume + +- Reused the existing Integration owner because it still exclusively owns `main` and the repository integration lock; `task_context.py touch` refreshed the reservation. +- Verified the main worktree was clean at `953b0f491e44036bc8273fb29dd880e38defae24`, with local `main` two commits ahead of the locally tracked `origin/main`. +- Source task `20260814-codex-compaction-ux-74b3f2a1` was `ready_for_integration`, its final Sol review returned PASS, and source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f` is exactly one direct descendant of current `main`. +- Read the active integration record, source task outcome/verification/promotion candidate, canonical AI Programming architecture/current-state memory, and every registered peer task Scope / Intent / Promotion Candidate section. +- The related threshold and UI-audit tasks agree with the source: automatic compaction remains OpenCode/model-limit-owned, while this source changes only the persistent Renderer interaction and run lifecycle. No semantic conflict requires human resolution. +- Gate result: Passed for the local context-compaction merge. Remote push remains outside this resumption. + ## Plan 1. Fetch `origin/main` and verify local `main`, the remote tip, and reviewed AI Canvas source commit topology. @@ -101,6 +112,14 @@ Gate result: 4. Re-run focused/full checks, production build, project-document gates, and independent Sol review. 5. Commit the verified local merge; do not push unless the user separately requests it and authentication is available. +### 2026-08-15 Context Compaction Integration Plan + +1. Form the already verified feature worktree into a dedicated source commit without changing its reviewed files. +2. Merge the direct child into local `main` with a normal no-ff merge and preserve source history. +3. Promote the per-session persistent compaction timeline and `session.compacted != session.idle` invariant into canonical current-state, architecture, evidence, and upgrade commitments. +4. Re-run focused/full unit checks, typecheck, lint, production build, Electron E2E, document drift, and whitespace checks on the merged tree. +5. Obtain an independent read-only Sol PASS/FAIL review, record the result, and keep remote push outside this request. + ## Outcome - On 2026-08-13, resumed this integration task for the user's local-main merge request. `git fetch origin main --prune` succeeded and confirmed `origin/main=22add3f01f2b7cb6318495e8294db006f6f18abf`. @@ -164,6 +183,9 @@ Gate result: - The first independent AI Canvas merge review returned `FAIL` on two integration gaps and one safety subfinding: stale confirmation task-refresh errors could overwrite a newer Conversation error, top-level structured WebSocket command errors lacked focused coverage, and unknown Gateway messages could expose upstream details. Confirmation reconciliation now uses an internal task refresh without UI-error side effects; explicit user refreshes retain their error behavior. Matching WebSocket errors are tested as non-retryable, and unknown codes project a fixed Chinese fallback instead of the server message. - The second independent Sol review returned `PASS` with no blocking findings after those corrections. The verified no-ff merge is ready to commit with `88f9ee8` as first parent and `22378ef` as second parent. - This resumption intentionally does not push. The previous remote authentication follow-up remains unchanged and does not block completion of the user's requested local merge. +- On 2026-08-15, formed the reviewed context-compaction implementation into source commit `fd9b5b46a913c515e94e4e26f185d43866c2581f`; its parent is exactly current local `main` (`953b0f4`) and the source worktree is clean. +- Started a normal `--no-ff --no-commit` merge of `fd9b5b4` into local `main`; Git reported no textual conflicts. The source task record remains unchanged on `fd9b5b4` and its feature branch and is excluded from the `main` result to preserve task-document ownership boundaries. +- Promoted the accepted compaction facts into canonical current-state, module map, data flow, evidence, and upgrade commitments: compaction is a persistent per-session transcript event, completed status is monotonic, and `session.compacted` cannot end the run or release queued prompts. ## Verification @@ -200,6 +222,13 @@ Gate result: - Final post-review `pnpm run build:vite` — Renderer, Electron Main, and Preload passed; only the existing mixed-import and chunk-size warnings remain. - Second independent final review — `PASS`; it re-ran 4 files / 102 tests plus typecheck, found no unmerged entries or unstaged changes, and confirmed the previous state-isolation, structured-error coverage, and redaction findings are closed. - Final project-document ownership drift, structure checks, and staged/unstaged whitespace checks — passed before the merge commit. +- 2026-08-15 context-compaction focused selection — 3 files / 236 tests passed on the merged `main` tree. +- 2026-08-15 `pnpm test` — 156 files / 1712 tests passed on the merged `main` tree. +- 2026-08-15 `pnpm run typecheck` — passed. +- 2026-08-15 `pnpm run lint:check` — passed with 0 errors and the same 6 existing warnings outside the merge paths. +- 2026-08-15 `pnpm run build:vite` — Renderer, Electron Main, and Preload production builds passed; only existing chunk-size and mixed-import warnings remain. +- 2026-08-15 Electron E2E selection — `tests/e2e/opencode-slash-commands.spec.ts` passed 3/3, including the manual compaction running-to-completed timeline transition. +- 2026-08-15 staged/unstaged whitespace checks and project-document structure passed; task-aware drift passed after retaining the source task record only on the feature branch. ## Follow-ups @@ -215,7 +244,8 @@ Gate result: - Authenticate `git.nianxx.cn` through the local Git credential manager or an authorized SSH key, then resume this same task to push and verify remote tip equality. Do not paste a personal access token into the task conversation. +- When bundled OpenCode or the model context profile changes, run a real long-context compaction smoke to reconfirm native Part fields and event ordering; the current Electron E2E uses a controlled EventSource through the real Store/Renderer lifecycle rather than launching OpenCode. ## Promotion Candidates -- None recorded. +- The context-compaction source candidate was promoted into current state, module map, data flow, evidence, and upgrade commitments. No unresolved candidate remains for this local merge. diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 18529a9..a055381 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -12,6 +12,7 @@ Use this index for searchable, traceable evidence records. | 2026-08-13 | AI 绘画单参考图图生图客户端 | 本地功能验证通过;未执行安装包或真实 Provider smoke | 源任务 `20260812-design-image-to-image-client-c91e`、`809364e`、集成 `88281b8`、兼容修复 `7a807a2` | 25 项页面回归覆盖图片作品选择、本地上传、视频首帧及 null-medium 兼容;22 项云端适配器回归覆盖 REST/WebSocket 缺失 medium 归一化与非法值拒绝;typecheck、scoped lint 与 Vite build 通过,生产部署与付费生成另行验收 | | 2026-08-13 | Makelore Updater 稳定源诊断与错误展示 | 本地功能与构建验证通过;未发布或安装正式包 | `30-worklog/tasks/20260813-makelore-updater-client-7d3a9c.md`、`f05b9d4` | 4 files / 37 focused tests、typecheck、scoped lint 与 `build:vite` 通过;并发共享错误只投影一次,设置页不显示技术诊断。可用本地产物未签名,真实升级链仍待 Release Owner 验收 | | 2026-08-14 | AI Canvas 双向 Gateway 与确认任务恢复 | 本地功能验证通过;未执行真实账号付费生成 smoke | 源任务 `20260814-design-generation-runtime-7d3a`、源提交 `22378ef` | 3 files / 74 focused tests、156 files / 1687 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Sol review 通过;覆盖 WebSocket 命令关联、幂等传输回退、Quote 任务恢复、Conversation 切换与 ABA,生产 worker 故障仍需真实环境区分 | +| 2026-08-15 | AI 编程 Codex 风格上下文压缩时间线 | 本地功能验证通过;未运行真实长上下文 OpenCode smoke | 源任务 `20260814-codex-compaction-ux-74b3f2a1`、源提交 `fd9b5b4` | 3 files / 236 focused tests、156 files / 1712 full tests、typecheck、lint、Renderer/Main/Preload build、Electron E2E 3/3 与独立 Sol review 通过;E2E 使用受控 EventSource 穿过真实 Store/Renderer 生命周期,不冒充真实 OpenCode 进程验证 | ## When To Add Evidence diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index e4ee4e6..a318214 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -9,6 +9,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 | | 2026-08-11 | 部署并验收 AI 绘画多 Conversation 服务端契约 | 发布包含多会话客户端之前 | 客户端/服务端集成 | Pending | 确认迁移 `0033`、Conversation list/create/read/turn/confirm API、持久 Agent Session 与事件字段已上线 | | 2026-08-12 | 复核 AI 编程上游饱和终止态投影 | 升级 bundled OpenCode 或上游饱和错误格式时 | 客户端/OpenCode 集成 | Pending | 重新验证 retry contract;继续区分配额耗尽、明确分组饱和与通用 `429`,必要时调整或移除 `429 → 400` 内部投影 | +| 2026-08-15 | 复核 AI 编程上下文压缩原生事件契约 | 升级 bundled OpenCode 或模型上下文配置时 | 客户端/OpenCode 集成 | Pending | 运行真实长上下文 smoke,复核 native compaction Part 的 `auto`、Part identity、完成时序和 `session.compacted`/idle 顺序;继续保持 completed 单调和 queued prompt 只由真实 idle 释放 | | 2026-08-13 | 将 AI 绘画素材选择 quick reply 升级为结构化 action/purpose | 新增第三种素材输入用途、调整本地化文案或重构 Design Message 协议时 | 客户端/服务端集成 | Pending | 用 `select_image_asset` + `image_reference | video_first_frame` 等稳定字段替代精确中文字符串匹配,并覆盖延迟 Brief 快照兼容 | | 2026-08-13 | 完成图生图客户端与服务端生产整链 smoke | 相匹配客户端与服务端部署后 | 客户端/服务端集成 | Pending | 用当前 Workspace 已完成作品与本地上传各执行一次图生图,核对 Asset 所有权、Quote、任务创建、结果展示与下载;不以本地单测替代付费 Provider 验收 | | 2026-08-13 | 发布并验收 Makelore 稳定更新链 | 权威版本对齐且 Windows/macOS 正式产物已签名/公证后 | 客户端发布 | Pending | 将 updater-role 产物 inactive-first 发布到 Works Square,核对 manifest/签名/架构后激活,并从旧稳定版执行发现、下载、重启、安装与版本确认 smoke;源码错误文案不替代该验收 | diff --git a/README.md b/README.md index 9025da2..ee21f2f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 - 不同 Session 由 OpenCode 自己并发运行;同一 Session 的后续消息按顺序排队。Makelore 只展示运行中、待处理和未读状态,不增加额外的全局并发锁。 - 归档伙伴或单个会话前必须确认;归档会停止对应运行、保留历史,并可从列表底部恢复。未读按会话记录,打开一个会话只清除它自己的未读数。 -- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩沿用 OpenCode 自动机制,在最新消息位置显示“正在压缩上下文”。 +- OpenCode 的问题在当前会话内联处理;项目对话权限默认自动允许,遗留的待处理权限也会自动批准;上下文压缩在聊天时间线实际发生的位置显示为独立事件:自动压缩使用“正在优化对话”/“已优化对话”文案,手动执行 `/compact` 使用“正在压缩上下文”/“已压缩上下文”文案。压缩进行中使用弱化动画,完成后变为静态状态并保留在原位置;触发阈值与同一 Session 的消息排队语义保持不变。 ## 兼容标识 diff --git a/src/lib/opencode-session-state.ts b/src/lib/opencode-session-state.ts index 15cc2da..09e5765 100644 --- a/src/lib/opencode-session-state.ts +++ b/src/lib/opencode-session-state.ts @@ -1,4 +1,6 @@ import type { + OpencodeCompactionSource, + OpencodeCompactionTimelineEvent, OpencodeNativePartKind, OpencodeNativeToolState, OpencodeNormalizedSessionMessage, @@ -13,6 +15,31 @@ export interface OpenCodeEventEnvelope { payload: unknown; } +export interface OpencodeCompactionMatch { + id?: string; + nativePartID?: string; + nativeEventID?: string; + runID?: string; + generation?: number; +} + +export interface OpencodePendingCompactionInput extends OpencodeCompactionMatch { + source: OpencodeCompactionSource; + anchorMessageID?: string; + anchorPartID?: string; + startedAt?: number; +} + +export interface OpencodeCompactionCompletion { + nativePartID?: string; + nativeEventID?: string; + anchorMessageID?: string; + anchorPartID?: string; + runID?: string; + generation?: number; + completedAt?: number; +} + const PART_TYPES = new Set([ 'text', 'reasoning', @@ -79,6 +106,30 @@ function getPartId(value: unknown): string | undefined { ?? nonEmptyString(asRecord(record?.part)?.partID); } +function getNativeEventId(value: unknown): string | undefined { + const record = asRecord(value); + const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence) + ? String(record.sequence) + : nonEmptyString(record?.sequence); + return nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence; +} + +function getRunId(value: unknown): string | undefined { + const record = asRecord(value); + return nonEmptyString(record?.runID) + ?? nonEmptyString(record?.runId) + ?? nonEmptyString(asRecord(record?.part)?.runID) + ?? nonEmptyString(asRecord(record?.part)?.runId); +} + +function getGeneration(value: unknown): number | undefined { + const record = asRecord(value); + const generation = record?.generation ?? asRecord(record?.part)?.generation; + return typeof generation === 'number' && Number.isFinite(generation) + ? generation + : undefined; +} + function toMilliseconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value; @@ -217,9 +268,197 @@ function createEmptyState(sessionID: string): OpencodeSessionTranscriptState { messageOrder: [], partsById: {}, partOrderByMessageId: {}, + compactionsById: {}, + compactionOrder: [], }; } +function nextCompactionOrder(state: OpencodeSessionTranscriptState): number { + return state.compactionOrder.reduce((highest, id) => ( + Math.max(highest, state.compactionsById[id]?.order ?? -1) + ), -1) + 1; +} + +function matchesCompaction( + event: OpencodeCompactionTimelineEvent, + match: OpencodeCompactionMatch, +): boolean { + const entries = Object.entries(match).filter(([, value]) => value !== undefined); + return entries.length > 0 && entries.every(([key, value]) => ( + event[key as keyof OpencodeCompactionMatch] === value + )); +} + +function findCompactionId( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch, + runningOnly = false, +): string | undefined { + return [...state.compactionOrder].reverse().find((id) => { + const event = state.compactionsById[id]; + return event + && (!runningOnly || event.status === 'running') + && matchesCompaction(event, match); + }); +} + +function replaceCompaction( + state: OpencodeSessionTranscriptState, + event: OpencodeCompactionTimelineEvent, +): OpencodeSessionTranscriptState { + return { + ...state, + compactionsById: { ...state.compactionsById, [event.id]: event }, + compactionOrder: state.compactionOrder.includes(event.id) + ? state.compactionOrder + : [...state.compactionOrder, event.id], + }; +} + +/** Add a renderer-owned running event before its native Part is available. */ +export function createPendingCompaction( + state: OpencodeSessionTranscriptState, + input: OpencodePendingCompactionInput, +): OpencodeSessionTranscriptState { + const match: OpencodeCompactionMatch = { + ...(input.id ? { id: input.id } : {}), + ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), + ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), + ...(input.runID ? { runID: input.runID } : {}), + ...(input.generation !== undefined ? { generation: input.generation } : {}), + }; + const existingID = findCompactionId(state, match, true); + if (existingID) return state; + const order = nextCompactionOrder(state); + const id = input.id + ?? `compaction:${state.sessionID}:${input.runID ?? input.generation ?? 'pending'}:${order}`; + if (state.compactionsById[id]) return state; + return replaceCompaction(state, { + id, + sessionID: state.sessionID, + source: input.source, + status: 'running', + order, + ...(input.anchorMessageID ? { anchorMessageID: input.anchorMessageID } : {}), + ...(input.anchorPartID ? { anchorPartID: input.anchorPartID } : {}), + ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), + ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), + ...(input.runID ? { runID: input.runID } : {}), + ...(input.generation !== undefined ? { generation: input.generation } : {}), + ...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}), + }); +} + +/** Complete the precisely matched event, or the latest running event if no match is supplied. */ +export function completeCompaction( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch = {}, + completion: OpencodeCompactionCompletion = {}, +): OpencodeSessionTranscriptState { + const hasMatch = Object.values(match).some((value) => value !== undefined); + const id = hasMatch + ? findCompactionId(state, match, true) + : [...state.compactionOrder].reverse().find((candidateID) => ( + state.compactionsById[candidateID]?.status === 'running' + )); + if (!id) return state; + const current = state.compactionsById[id]; + return replaceCompaction(state, { + ...current, + status: 'completed', + ...(completion.nativePartID ? { nativePartID: completion.nativePartID } : {}), + ...(completion.nativeEventID ? { nativeEventID: completion.nativeEventID } : {}), + ...(completion.anchorMessageID ? { anchorMessageID: completion.anchorMessageID } : {}), + ...(completion.anchorPartID ? { anchorPartID: completion.anchorPartID } : {}), + ...(completion.runID ? { runID: completion.runID } : {}), + ...(completion.generation !== undefined ? { generation: completion.generation } : {}), + ...(completion.completedAt !== undefined ? { completedAt: completion.completedAt } : {}), + }); +} + +/** Remove a failed/cancelled event without relying on its mutable native correlation. */ +export function removeCompaction( + state: OpencodeSessionTranscriptState, + match: OpencodeCompactionMatch, +): OpencodeSessionTranscriptState { + const id = findCompactionId(state, match); + if (!id) return state; + const compactionsById = { ...state.compactionsById }; + delete compactionsById[id]; + return { + ...state, + compactionsById, + compactionOrder: state.compactionOrder.filter((candidateID) => candidateID !== id), + }; +} + +function compactionSource(part: OpencodeNormalizedSessionPart): OpencodeCompactionSource { + const raw = part.raw ?? {}; + return raw.source === 'manual' || raw.auto === false ? 'manual' : 'automatic'; +} + +function upsertCompactionPart( + state: OpencodeSessionTranscriptState, + part: OpencodeNormalizedSessionPart, + nativeEventID?: string, +): OpencodeSessionTranscriptState { + if (part.type !== 'compaction') return state; + const raw = part.raw ?? {}; + const runID = getRunId(raw); + const generation = getGeneration(raw); + const source = compactionSource(part); + const correlatedID = findCompactionId(state, { nativePartID: part.id }); + let id = correlatedID; + if (!id && source === 'manual') { + id = [...state.compactionOrder].reverse().find((candidateID) => { + const candidate = state.compactionsById[candidateID]; + return candidate?.source === 'manual' + && candidate.status === 'running' + && !candidate.nativePartID + && (runID === undefined || candidate.runID === undefined || candidate.runID === runID) + && (generation === undefined + || candidate.generation === undefined + || candidate.generation === generation); + }); + } + if (!id) { + id = `compaction:${state.sessionID}:part:${part.id}`; + } + let next = state; + if (!correlatedID) { + for (const candidateID of state.compactionOrder) { + if (candidateID === id) continue; + if (state.compactionsById[candidateID]?.status !== 'running') continue; + next = completeCompaction(next, { id: candidateID }); + } + } + const existing = next.compactionsById[id]; + const endedAt = part.time?.end; + return replaceCompaction(next, { + id, + sessionID: state.sessionID, + source: existing?.source ?? source, + status: endedAt !== undefined ? 'completed' : existing?.status ?? 'running', + order: existing?.order ?? nextCompactionOrder(next), + anchorMessageID: part.messageID, + anchorPartID: part.id, + nativePartID: part.id, + ...(nativeEventID ? { nativeEventID } : existing?.nativeEventID + ? { nativeEventID: existing.nativeEventID } + : {}), + ...(runID ? { runID } : existing?.runID ? { runID: existing.runID } : {}), + ...(generation !== undefined ? { generation } : existing?.generation !== undefined + ? { generation: existing.generation } + : {}), + ...(part.time?.start !== undefined ? { startedAt: part.time.start } : existing?.startedAt !== undefined + ? { startedAt: existing.startedAt } + : {}), + ...(endedAt !== undefined ? { completedAt: endedAt } : existing?.completedAt !== undefined + ? { completedAt: existing.completedAt } + : {}), + }); +} + function ensureMessage( state: OpencodeSessionTranscriptState, input: unknown, @@ -255,10 +494,11 @@ function ensureMessage( function upsertPart( state: OpencodeSessionTranscriptState, part: OpencodeNormalizedSessionPart, + nativeEventID?: string, ): OpencodeSessionTranscriptState { const message = state.messagesById[part.messageID]; const currentOrder = state.partOrderByMessageId[part.messageID] ?? []; - return { + const next = { ...state, partsById: { ...state.partsById, @@ -282,6 +522,7 @@ function upsertPart( : [...currentOrder, part.id], }, }; + return upsertCompactionPart(next, part, nativeEventID); } function removePart( @@ -293,7 +534,7 @@ function removePart( const message = state.messagesById[part.messageID]; const nextParts = { ...state.partsById }; delete nextParts[partID]; - return { + const next = { ...state, partsById: nextParts, messagesById: message @@ -311,6 +552,9 @@ function removePart( .filter((id) => id !== partID), }, }; + return part.type === 'compaction' + ? removeCompaction(next, { nativePartID: partID }) + : next; } function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined { @@ -411,6 +655,7 @@ export function hydrateOpenCodeSession( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', + current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { const state = createEmptyState(sessionID); if (!Array.isArray(messages)) return { ...state, status }; @@ -426,6 +671,69 @@ export function hydrateOpenCodeSession( if (part) next = upsertPart(next, part); } } + const snapshotOrder = [...next.compactionOrder]; + snapshotOrder.forEach((id, index) => { + const snapshotEvent = next.compactionsById[id]; + const existingID = snapshotEvent.nativePartID + ? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID }) + : undefined; + const pendingID = !existingID && snapshotEvent.source === 'manual' + ? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => { + const candidate = current?.compactionsById[candidateID]; + return candidate?.source === 'manual' + && candidate.status === 'running' + && !candidate.nativePartID + && (snapshotEvent.runID === undefined + || candidate.runID === undefined + || candidate.runID === snapshotEvent.runID) + && (snapshotEvent.generation === undefined + || candidate.generation === undefined + || candidate.generation === snapshotEvent.generation); + }) + : undefined; + const preservedID = existingID ?? pendingID; + const existing = preservedID ? current?.compactionsById[preservedID] : undefined; + const idToUse = preservedID ?? id; + const isLatestActive = status !== 'idle' && index === snapshotOrder.length - 1; + const completed = existing?.status === 'completed' + || snapshotEvent.completedAt !== undefined + || !isLatestActive; + if (idToUse !== id) { + const compactionsById = { ...next.compactionsById }; + delete compactionsById[id]; + next = { + ...next, + compactionsById, + compactionOrder: next.compactionOrder.map((candidateID) => ( + candidateID === id ? idToUse : candidateID + )), + }; + } + next = replaceCompaction(next, { + ...snapshotEvent, + ...existing, + id: idToUse, + status: completed ? 'completed' : 'running', + order: index, + anchorMessageID: snapshotEvent.anchorMessageID, + anchorPartID: snapshotEvent.anchorPartID, + nativePartID: snapshotEvent.nativePartID, + ...(snapshotEvent.nativeEventID ? { nativeEventID: snapshotEvent.nativeEventID } : {}), + ...(snapshotEvent.runID ? { runID: snapshotEvent.runID } : {}), + ...(snapshotEvent.generation !== undefined ? { generation: snapshotEvent.generation } : {}), + ...(snapshotEvent.startedAt !== undefined ? { startedAt: snapshotEvent.startedAt } : {}), + ...(snapshotEvent.completedAt !== undefined ? { completedAt: snapshotEvent.completedAt } : {}), + }); + }); + + if (status !== 'idle' && current) { + for (const id of current.compactionOrder) { + const pending = current.compactionsById[id]; + if (!pending || pending.status !== 'running' || pending.nativePartID) continue; + if (next.compactionsById[id]) continue; + next = replaceCompaction(next, { ...pending, order: nextCompactionOrder(next) }); + } + } return { ...next, status }; } @@ -434,8 +742,9 @@ export function hydrateSessionSnapshot( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', + current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { - return hydrateOpenCodeSession(sessionID, messages, status); + return hydrateOpenCodeSession(sessionID, messages, status, current); } export function reduceOpenCodeEvent( @@ -480,7 +789,7 @@ export function reduceOpenCodeEvent( const messageID = part?.messageID || getMessageId(event.payload); if (!part || !messageID) return state; const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID); - return upsertPart(withMessage, { ...part, messageID }); + return upsertPart(withMessage, { ...part, messageID }, getNativeEventId(event.payload)); } if (event.type === 'session.status') { @@ -491,14 +800,42 @@ export function reduceOpenCodeEvent( status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status, }; } - if (event.type === 'session.idle' || event.type === 'session.compacted') { - return { ...state, status: 'idle' }; + if (event.type === 'session.idle') { + return { ...completeCompaction(state), status: 'idle' }; + } + if (event.type === 'session.compacted') { + const payload = asRecord(event.payload); + const match: OpencodeCompactionMatch = { + ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), + ...(getGeneration(event.payload) !== undefined + ? { generation: getGeneration(event.payload) } + : {}), + }; + const completedAt = toMilliseconds( + asRecord(payload?.time)?.completed + ?? asRecord(payload?.time)?.end + ?? payload?.completedAt + ?? payload?.timestamp, + ); + return completeCompaction(state, match, { + ...(getNativeEventId(event.payload) ? { nativeEventID: getNativeEventId(event.payload) } : {}), + ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), + ...(getGeneration(event.payload) !== undefined + ? { generation: getGeneration(event.payload) } + : {}), + ...(completedAt !== undefined ? { completedAt } : {}), + }); } if (event.type === 'session.error') { const payload = asRecord(event.payload); const error = payload?.error; + let next = state; + for (const id of state.compactionOrder) { + if (next.compactionsById[id]?.status !== 'running') continue; + next = removeCompaction(next, { id }); + } return { - ...state, + ...next, status: 'idle', lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000), }; @@ -519,3 +856,13 @@ export function getOrderedSessionParts( export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number { return state ? Object.keys(state.partsById).length : 0; } + +export function getOrderedSessionCompactions( + state: OpencodeSessionTranscriptState | undefined, +): OpencodeCompactionTimelineEvent[] { + if (!state) return []; + return state.compactionOrder + .map((id) => state.compactionsById[id]) + .filter((event): event is OpencodeCompactionTimelineEvent => Boolean(event)) + .sort((left, right) => left.order - right.order); +} diff --git a/src/pages/Chat/ContextCompactionTimelineItem.tsx b/src/pages/Chat/ContextCompactionTimelineItem.tsx new file mode 100644 index 0000000..25929f6 --- /dev/null +++ b/src/pages/Chat/ContextCompactionTimelineItem.tsx @@ -0,0 +1,39 @@ +import { Sparkles } from 'lucide-react'; +import type { OpencodeCompactionTimelineEvent } from '@/types/opencode'; +import { cn } from '@/lib/utils'; + +function getCompactionLabel(event: OpencodeCompactionTimelineEvent): string { + if (event.source === 'manual') { + return event.status === 'running' ? '正在压缩上下文' : '已压缩上下文'; + } + return event.status === 'running' ? '正在优化对话' : '已优化对话'; +} + +export function ContextCompactionTimelineItem({ + event, + announce, +}: { + event: OpencodeCompactionTimelineEvent; + announce: boolean; +}) { + const running = event.status === 'running'; + const liveRegionProps = announce + ? { role: 'status' as const, 'aria-live': 'polite' as const, 'aria-atomic': true } + : {}; + + return ( +
+
+ ); +} diff --git a/src/pages/Chat/OpencodeChatPanel.tsx b/src/pages/Chat/OpencodeChatPanel.tsx index a377386..be4c775 100644 --- a/src/pages/Chat/OpencodeChatPanel.tsx +++ b/src/pages/Chat/OpencodeChatPanel.tsx @@ -16,6 +16,7 @@ import { } from './ChatCommandDialogs'; import { ChatCommandPalette } from './ChatCommandPalette'; import { ChatMessage } from './ChatMessage'; +import { ContextCompactionTimelineItem } from './ContextCompactionTimelineItem'; import { ComposerAttachmentCard } from './ComposerAttachmentCard'; import { ExecutionGraphCard } from './ExecutionGraphCard'; import { GameAssetBrowser } from './GameAssetBrowser'; @@ -48,6 +49,7 @@ import { } from './message-utils'; import { deriveTaskSteps, type TaskStep } from './task-visualization'; import { + buildSessionTranscriptTimeline, projectAssistantTextWithNativeParts, } from './session-transcript-view-model'; import { @@ -821,7 +823,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const sessionTranscript = useOpencodeStore((state) => ( selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined )); - const compactingSessionIds = useOpencodeStore((state) => state.compactingSessionIds); const sessionMessages = useOpencodeStore((state) => state.sessionMessages); const streamingMessage = useOpencodeStore((state) => state.streamingMessage); const streamingTools = useOpencodeStore((state) => state.streamingTools); @@ -939,6 +940,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro const messageElementsRef = useRef(new Map()); const undoOriginatingDraftRef = useRef(''); const transcriptShouldStickRef = useRef(true); + const seenCompactionIdsBySessionRef = useRef(new Map>()); + const lastCompactionAnnouncementSessionRef = useRef(null); const composerAttachmentInputRef = useRef(null); const composerStopArmTimerRef = useRef(null); const userModelConfigSyncRef = useRef | null>(null); @@ -1081,6 +1084,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro selectedSessionRunning, ); }, [selectedSessionRunning, sessionMessages, streamingMessage]); + const transcriptTimeline = useMemo(() => buildSessionTranscriptTimeline( + visibleTranscriptMessages, + sessionTranscript, + ), [sessionTranscript, visibleTranscriptMessages]); + const transcriptCompactions = useMemo(() => transcriptTimeline.flatMap((item) => ( + item.type === 'compaction' ? [item.event] : [] + )), [transcriptTimeline]); + const compactionTimelineVersion = transcriptCompactions + .map((event) => `${event.id}:${event.status}`) + .join('|'); + const [liveCompactionIds, setLiveCompactionIds] = useState>( + () => new Set(), + ); const undoableUserMessage = findUndoableUserMessage( visibleTranscriptMessages, currentSession?.revert?.messageID, @@ -1363,6 +1379,31 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro } }, [selectedSessionId]); + useEffect(() => { + if (!selectedSessionId) { + lastCompactionAnnouncementSessionRef.current = null; + setLiveCompactionIds(new Set()); + return; + } + + const seen = seenCompactionIdsBySessionRef.current.get(selectedSessionId) ?? new Set(); + const sessionChanged = lastCompactionAnnouncementSessionRef.current !== selectedSessionId; + lastCompactionAnnouncementSessionRef.current = selectedSessionId; + const newRunningIds = sessionChanged ? [] : transcriptCompactions + .filter((event) => event.status === 'running' && !seen.has(event.id)) + .map((event) => event.id); + for (const event of transcriptCompactions) seen.add(event.id); + seenCompactionIdsBySessionRef.current.set(selectedSessionId, seen); + + if (sessionChanged) { + setLiveCompactionIds(new Set()); + return; + } + + if (newRunningIds.length === 0) return; + setLiveCompactionIds((current) => new Set([...current, ...newRunningIds])); + }, [selectedSessionId, transcriptCompactions]); + useEffect(() => () => { if (composerStopArmTimerRef.current !== null) { window.clearTimeout(composerStopArmTimerRef.current); @@ -1413,7 +1454,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro setHasUnreadTranscript(false); return; } - if (visibleTranscriptMessages.length > 0) { + if (visibleTranscriptMessages.length > 0 || transcriptCompactions.length > 0) { setHasUnreadTranscript(true); } }, [ @@ -1422,6 +1463,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro awaitingFirstResponse, visiblePendingQuestions.length, visibleTranscriptMessages, + compactionTimelineVersion, + transcriptCompactions.length, ]); const handleTranscriptScroll = useCallback(() => { @@ -2610,8 +2653,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro onScroll={handleTranscriptScroll} >
- {selectedSessionId && compactingSessionIds[selectedSessionId] ? : null} - {visibleTranscriptMessages.length === 0 ? ( + {transcriptTimeline.length === 0 ? ( awaitingFirstResponse ? (
@@ -2623,7 +2665,17 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro ) ) : (
- {visibleTranscriptMessages.map((message, index) => { + {transcriptTimeline.map((timelineItem) => { + if (timelineItem.type === 'compaction') { + return ( + + ); + } + const { message, messageIndex: index } = timelineItem; const startingExecutionRun = executionRunByStartIndex.get(index); const messageExecutionRun = getExecutionRunForIndex(executionRuns, index); const liveStreaming = Boolean( @@ -3635,20 +3687,6 @@ function AssistantWaitingPlaceholder({ elapsedSeconds, assistantAvatarSrc, assis ); } -function CompactionNotice({ assistantAvatarSrc, assistantAvatarAlt }: { assistantAvatarSrc?: string; assistantAvatarAlt?: string }) { - return ( -
-
- {assistantAvatarSrc ? {assistantAvatarAlt} : } -
-
- - 正在压缩上下文 -
-
- ); -} - type PendingQuestionCardProps = { request: OpencodeQuestionRequest; onReply: (requestId: string, answers: string[][]) => Promise; diff --git a/src/pages/Chat/session-transcript-view-model.ts b/src/pages/Chat/session-transcript-view-model.ts index 11069b0..d627f67 100644 --- a/src/pages/Chat/session-transcript-view-model.ts +++ b/src/pages/Chat/session-transcript-view-model.ts @@ -1,9 +1,11 @@ import type { RawMessage } from '@/types/chat'; import type { + OpencodeCompactionTimelineEvent, OpencodeNativeToolState, OpencodeSessionExecutionState, OpencodeSessionTranscriptState, } from '@/types/opencode'; +import { getOrderedSessionCompactions } from '@/lib/opencode-session-state'; import { extractThinkingSegments, extractToolUse, @@ -60,6 +62,19 @@ export interface SessionTranscriptViewModel { nestedSubtasks: SessionTranscriptNestedSubtask[]; } +export type SessionTranscriptTimelineItem = + | { + type: 'message'; + id: string; + message: RawMessage; + messageIndex: number; + } + | { + type: 'compaction'; + id: string; + event: OpencodeCompactionTimelineEvent; + }; + export interface BuildSessionTranscriptViewModelInput { messages: RawMessage[]; nativeTranscript?: OpencodeSessionTranscriptState; @@ -76,6 +91,64 @@ function stableMessageId(message: RawMessage, index: number): string { return message.id?.trim() || `${message.role}-${index}`; } +function getCompactionInsertionIndex( + event: OpencodeCompactionTimelineEvent, + messages: RawMessage[], + nativeTranscript: OpencodeSessionTranscriptState, +): number { + const anchoredPart = event.anchorPartID + ? nativeTranscript.partsById[event.anchorPartID] + : undefined; + const anchorMessageID = event.anchorMessageID ?? anchoredPart?.messageID; + if (!anchorMessageID) return messages.length; + + const visibleMessageIndex = messages.findIndex( + (message) => message.id?.trim() === anchorMessageID, + ); + if (visibleMessageIndex >= 0) return visibleMessageIndex + 1; + + const nativeAnchorIndex = nativeTranscript.messageOrder.indexOf(anchorMessageID); + if (nativeAnchorIndex < 0) return messages.length; + for (let index = 0; index < messages.length; index += 1) { + const messageID = messages[index]?.id?.trim(); + if (!messageID) continue; + const nativeMessageIndex = nativeTranscript.messageOrder.indexOf(messageID); + if (nativeMessageIndex > nativeAnchorIndex) return index; + } + return messages.length; +} + +export function buildSessionTranscriptTimeline( + messages: RawMessage[], + nativeTranscript: OpencodeSessionTranscriptState | undefined, +): SessionTranscriptTimelineItem[] { + const messageItems: SessionTranscriptTimelineItem[] = messages.map((message, messageIndex) => ({ + type: 'message', + id: stableMessageId(message, messageIndex), + message, + messageIndex, + })); + if (!nativeTranscript) return messageItems; + + const compactionsByInsertionIndex = new Map(); + for (const event of getOrderedSessionCompactions(nativeTranscript)) { + const insertionIndex = getCompactionInsertionIndex(event, messages, nativeTranscript); + const events = compactionsByInsertionIndex.get(insertionIndex) ?? []; + events.push(event); + compactionsByInsertionIndex.set(insertionIndex, events); + } + + const timeline: SessionTranscriptTimelineItem[] = []; + for (let index = 0; index <= messages.length; index += 1) { + for (const event of compactionsByInsertionIndex.get(index) ?? []) { + timeline.push({ type: 'compaction', id: `compaction:${event.id}`, event }); + } + const messageItem = messageItems[index]; + if (messageItem) timeline.push(messageItem); + } + return timeline; +} + function nativePartStatus( part: OpencodeSessionTranscriptState['partsById'][string], ): SessionTranscriptTraceItem['status'] { diff --git a/src/stores/opencode.ts b/src/stores/opencode.ts index 15132f6..affbb1c 100644 --- a/src/stores/opencode.ts +++ b/src/stores/opencode.ts @@ -13,7 +13,10 @@ import { removeStreamingPartFromMessage, } from '@/lib/opencode-message'; import { + completeCompaction, + createPendingCompaction, hydrateOpenCodeSession, + removeCompaction, reduceOpenCodeEvent, type OpenCodeEventEnvelope, } from '@/lib/opencode-session-state'; @@ -162,7 +165,6 @@ interface OpencodeState { sessionStatuses: OpencodeSessionStatusMap; sessionTranscriptBySessionId: Record; projectEventStreamState: 'closed' | 'connecting' | 'connected' | 'reconnecting'; - compactingSessionIds: Record; sessionMessages: RawMessage[]; sessionMessagesBySessionId: Record; streamingMessage: RawMessage | null; @@ -587,8 +589,12 @@ function getRuntimeStatusPatch( status: OpencodeStatus, ): Pick< OpencodeState, - 'status' | 'runtimeGeneration' | 'commandsLoading' | 'commandsError' -> { + | 'status' + | 'runtimeGeneration' + | 'commandsLoading' + | 'commandsError' + | 'sessionTranscriptBySessionId' + > { const identityChanged = runtimeIdentity(state.status) !== runtimeIdentity(status); return { status, @@ -599,6 +605,9 @@ function getRuntimeStatusPatch( ), commandsLoading: identityChanged ? false : state.commandsLoading, commandsError: identityChanged ? null : state.commandsError, + sessionTranscriptBySessionId: identityChanged + ? removeRunningCompactionsFromTranscripts(state.sessionTranscriptBySessionId) + : state.sessionTranscriptBySessionId, }; } @@ -1712,7 +1721,6 @@ function resetProjectScopedState(sessions: OpencodeSession[] = []) { sessionStatuses: {}, sessionTranscriptBySessionId: {}, projectEventStreamState: 'closed' as const, - compactingSessionIds: {}, sessionMessages: [], sessionMessagesBySessionId: {}, streamingMessage: null, @@ -1877,6 +1885,85 @@ function getSessionTranscriptPatch( }; } +function getOrCreateSessionTranscript( + state: OpencodeState, + sessionId: string, +): OpencodeSessionTranscriptState { + return state.sessionTranscriptBySessionId[sessionId] + ?? hydrateOpenCodeSession( + sessionId, + [], + state.sessionStatuses[sessionId]?.type ?? 'idle', + ); +} + +function getCompactionEventIdentity(payload: Record): string | undefined { + if (typeof payload.eventID === 'string' && payload.eventID.trim()) return payload.eventID; + if (typeof payload.eventId === 'string' && payload.eventId.trim()) return payload.eventId; + if (typeof payload.sequence === 'string' && payload.sequence.trim()) return payload.sequence; + return typeof payload.sequence === 'number' && Number.isFinite(payload.sequence) + ? String(payload.sequence) + : undefined; +} + +function withCompactionRunIdentity( + payload: Record, + runToken: number, + generation: number, + source: 'manual' | 'automatic', +): Record { + const part = payload.part; + if (!part || typeof part !== 'object' || Array.isArray(part)) return payload; + const partRecord = part as Record; + if (partRecord.type !== 'compaction') return payload; + return { + ...payload, + part: { + ...partRecord, + runID: String(runToken), + generation, + ...(source === 'manual' ? { auto: false } : {}), + }, + }; +} + +function removeRunningCompactionsForRun( + transcript: OpencodeSessionTranscriptState, + runToken: number, + generation?: number, +): OpencodeSessionTranscriptState { + const runID = String(runToken); + let next = transcript; + for (const id of transcript.compactionOrder) { + const event = next.compactionsById[id]; + if ( + event?.status === 'running' + && event.runID === runID + && (generation === undefined || event.generation === generation) + ) { + next = removeCompaction(next, { id }); + } + } + return next; +} + +function removeRunningCompactionsFromTranscripts( + transcripts: Record, +): Record { + let changed = false; + const next: Record = {}; + for (const [sessionId, transcript] of Object.entries(transcripts)) { + let cleaned = transcript; + for (const id of transcript.compactionOrder) { + if (cleaned.compactionsById[id]?.status !== 'running') continue; + cleaned = removeCompaction(cleaned, { id }); + } + changed ||= cleaned !== transcript; + next[sessionId] = cleaned; + } + return changed ? next : transcripts; +} + function getSessionStreamingEventPatch( state: OpencodeState, sessionId: string, @@ -1907,19 +1994,9 @@ function getSessionStreamingEventPatch( const streamingTools = nextToolStatus ? mergeStreamingToolStatuses(currentTools, nextToolStatus) : currentTools; - const part = payload.part; - const compacting = Boolean( - part - && typeof part === 'object' - && !Array.isArray(part) - && (part as Record).type === 'compaction', - ); return { ...transcriptPatch, ...getSessionStreamingPatch(state, sessionId, streamingMessage, streamingTools), - compactingSessionIds: compacting - ? { ...state.compactingSessionIds, [sessionId]: true } - : state.compactingSessionIds, }; } @@ -2009,7 +2086,12 @@ function getHydratedSessionTranscriptPatch( return { sessionTranscriptBySessionId: { ...state.sessionTranscriptBySessionId, - [sessionId]: hydrateOpenCodeSession(sessionId, rawMessages, status), + [sessionId]: hydrateOpenCodeSession( + sessionId, + rawMessages, + status, + state.sessionTranscriptBySessionId[sessionId], + ), }, }; } @@ -2105,19 +2187,18 @@ function applyProjectEventToStore( sessionStatuses: { ...state.sessionStatuses, [sessionId]: status }, }; } - if (type === 'session.idle' || type === 'session.compacted') { + if (type === 'session.idle') { return { ...transcriptPatch, sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), ...(active ? {} : clearSessionStreamingPatch(state, sessionId)), }; } + if (type === 'session.compacted') return transcriptPatch; if (type === 'session.error') { return { ...transcriptPatch, sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), ...(active ? {} : clearSessionStreamingPatch(state, sessionId)), }; } @@ -2328,7 +2409,6 @@ function finishSessionRunSuccessfully( ...statuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2411,6 +2491,7 @@ async function runSessionSubmission( ? setMessageDeliveryStatus(submission.optimisticUserMessage, 'sending') : undefined; const baseline = createSessionRunBaseline(getSessionMessagesForState(get(), sessionId)); + const runGeneration = get().runtimeGeneration; const runToken = beginSessionRun(sessionId); const promptId = submission.promptId; closeSessionEventSource(sessionId); @@ -2435,6 +2516,15 @@ async function runSessionSubmission( runId: runToken, promptId, }); + const transcript = submission.kind === 'compact' + ? createPendingCompaction(getOrCreateSessionTranscript(state, sessionId), { + id: promptId, + source: 'manual', + runID: String(runToken), + generation: runGeneration, + startedAt: Date.now(), + }) + : undefined; return { ...getSessionMessagePatch(stateForMessagePatch, sessionId, sessionMessages), ...clearSessionStreamingPatch(state, sessionId), @@ -2447,6 +2537,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: { type: 'busy' }, }, + ...(transcript + ? { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: transcript, + }, + } + : {}), }; }); @@ -2497,19 +2595,35 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), })); }); registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => { const payload = JSON.parse((event as MessageEvent).data) as Record; if (!isSessionRunCurrent(sessionId, runToken)) return; + if (get().runtimeGeneration !== runGeneration) return; if (getOpencodeEventSessionId(payload) !== sessionId) return; flushSessionRunStreamBatch(set, sessionId, runToken); - set((state) => ({ - ...getSessionTranscriptPatch(state, 'session.compacted', payload), - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), - })); + set((state) => { + const transcript = completeCompaction( + getOrCreateSessionTranscript(state, sessionId), + { runID: String(runToken), generation: runGeneration }, + { + nativeEventID: getCompactionEventIdentity(payload), + runID: String(runToken), + generation: runGeneration, + completedAt: Date.now(), + }, + ); + return transcript === state.sessionTranscriptBySessionId[sessionId] + ? {} + : { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: transcript, + }, + }; + }); }); registerSessionRunEventListener(sessionId, source, 'session.error', (event) => { @@ -2556,7 +2670,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2590,7 +2711,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2657,8 +2785,16 @@ async function runSessionSubmission( }); registerSessionRunEventListener(sessionId, source, 'message.part.updated', (event) => { - const payload = JSON.parse((event as MessageEvent).data) as Record; + const rawPayload = JSON.parse((event as MessageEvent).data) as Record; if (!isSessionRunCurrent(sessionId, runToken)) return; + const payload = get().runtimeGeneration === runGeneration + ? withCompactionRunIdentity( + rawPayload, + runToken, + runGeneration, + submission.kind === 'compact' ? 'manual' : 'automatic', + ) + : rawPayload; if (getOpencodeEventSessionId(payload) !== sessionId) return; queueSessionRunStreamEvent(set, sessionId, runToken, 'message.part.updated', payload); }); @@ -2744,8 +2880,13 @@ async function runSessionSubmission( const now = Date.now(); const runListenerOwnsStream = hasSessionRunEventListener(sessionId); + const observedRunState = getSessionRunStateForSession(get(), sessionId); const eventStatus = runListenerOwnsStream - ? get().sessionStatuses[sessionId] + ? observedRunState.runId === runToken + && observedRunState.phase === 'idle' + && observedRunState.terminalReason === 'completed' + ? IDLE_SESSION_STATUS + : get().sessionStatuses[sessionId] : undefined; const shouldPollStatus = now >= nextStatusFallbackPollAt || eventStatus?.type === 'idle'; @@ -2797,9 +2938,13 @@ async function runSessionSubmission( // A missing HTTP status is common while the runtime is transitioning. // Prefer a newly observed assistant response over the optimistic/event // busy marker, otherwise a completed run can remain busy forever. - const polledStatus = shouldPollStatus - ? httpStatus ?? inferredStatus - : eventStatus ?? status ?? inferredStatus; + const polledStatus = submission.kind === 'compact' + ? shouldPollStatus + ? httpStatus ?? eventStatus ?? status + : eventStatus ?? status + : shouldPollStatus + ? httpStatus ?? inferredStatus + : eventStatus ?? status ?? inferredStatus; status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus; const currentMessages = getSessionMessagesForState(get(), sessionId); messages = assistantAborted || status.type === 'idle' @@ -2848,6 +2993,14 @@ async function runSessionSubmission( ...statuses, [sessionId]: IDLE_SESSION_STATUS, }, + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2945,7 +3098,14 @@ async function runSessionSubmission( ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + runToken, + runGeneration, + ), + }, sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: false, @@ -2987,7 +3147,6 @@ export const useOpencodeStore = create((set, get) => ({ sessionRunStates: {}, pendingQuestions: [], pendingPermissions: [], - compactingSessionIds: {}, sessionTodos: [], sessionTodosBySessionId: {}, sessionTodosLoading: false, @@ -3397,7 +3556,6 @@ export const useOpencodeStore = create((set, get) => ({ sessionStatuses: Object.fromEntries( Object.entries(state.sessionStatuses).filter(([key]) => key !== sessionId), ), - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), selectedSessionId: deletingSelected ? null : selectedSessionId, sessionMessages: deletingSelected ? [] : state.sessionMessages, sessionMessagesBySessionId: withoutKey(state.sessionMessagesBySessionId, sessionId), @@ -3437,6 +3595,7 @@ export const useOpencodeStore = create((set, get) => ({ const abortedRunId = getSessionRunStateForSession(get(), sessionId).runId ?? activeSessionRunTokens.get(sessionId) ?? null; + const abortedRunGeneration = get().runtimeGeneration; invalidateSessionRun(sessionId); closeSessionEventSource(sessionId); set((state) => { @@ -3456,7 +3615,18 @@ export const useOpencodeStore = create((set, get) => ({ ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS, }, - compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId), + ...(abortedRunId === null + ? {} + : { + sessionTranscriptBySessionId: { + ...state.sessionTranscriptBySessionId, + [sessionId]: removeRunningCompactionsForRun( + getOrCreateSessionTranscript(state, sessionId), + abortedRunId, + abortedRunGeneration, + ), + }, + }), sendingSessionIds, sendingSessionId: getSendingSessionIdForSelection(nextState), loading: true, @@ -4075,7 +4245,7 @@ export const useOpencodeStore = create((set, get) => ({ try { return await runSessionSubmission(set, get, sessionId, { kind: 'compact', - completion: 'post-success', + completion: 'observed', promptId: `compact-${sessionId}-${Date.now()}`, post: () => hostApiFetch( `/api/opencode/sessions/${encodeURIComponent(sessionId)}/summarize`, diff --git a/src/styles/globals.css b/src/styles/globals.css index 14e562c..c3a813e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -737,6 +737,31 @@ html[data-ui-language='ru'] { will-change: opacity, transform; } +@keyframes context-compaction-shimmer { + from { + background-position: 100% 50%; + } + + to { + background-position: -100% 50%; + } +} + +.context-compaction-shimmer { + color: transparent; + background-image: linear-gradient( + 100deg, + hsl(var(--muted-foreground) / 0.58) 25%, + hsl(var(--foreground) / 0.78) 50%, + hsl(var(--muted-foreground) / 0.58) 75% + ); + background-position: 100% 50%; + background-size: 200% 100%; + background-clip: text; + animation: context-compaction-shimmer 2.4s linear infinite; + -webkit-background-clip: text; +} + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -770,4 +795,10 @@ html[data-ui-language='ru'] { transform: scaleY(1); } + .context-compaction-shimmer { + color: hsl(var(--muted-foreground)); + background-image: none; + animation: none; + } + } diff --git a/src/types/opencode.ts b/src/types/opencode.ts index 1cb2944..74f087f 100644 --- a/src/types/opencode.ts +++ b/src/types/opencode.ts @@ -293,6 +293,30 @@ export interface OpencodeNormalizedSessionMessage { partIDs: string[]; } +export type OpencodeCompactionSource = 'manual' | 'automatic'; + +export type OpencodeCompactionStatus = 'running' | 'completed'; + +/** + * A renderer-owned compaction timeline item. `id` is the immutable UI + * identity; native Part/event ids are correlation keys and may arrive later. + */ +export interface OpencodeCompactionTimelineEvent { + id: string; + sessionID: string; + source: OpencodeCompactionSource; + status: OpencodeCompactionStatus; + order: number; + anchorMessageID?: string; + anchorPartID?: string; + nativePartID?: string; + nativeEventID?: string; + runID?: string; + generation?: number; + startedAt?: number; + completedAt?: number; +} + export interface OpencodeSessionTranscriptState { sessionID: string; status: OpencodeSessionExecutionState; @@ -300,6 +324,8 @@ export interface OpencodeSessionTranscriptState { messageOrder: string[]; partsById: Record; partOrderByMessageId: Record; + compactionsById: Record; + compactionOrder: string[]; lastError?: string; lastEventKey?: string; lastDeltaSignature?: string; diff --git a/tests/e2e/opencode-slash-commands.spec.ts b/tests/e2e/opencode-slash-commands.spec.ts index 66125ea..a587560 100644 --- a/tests/e2e/opencode-slash-commands.spec.ts +++ b/tests/e2e/opencode-slash-commands.spec.ts @@ -7,6 +7,16 @@ interface CapturedSlashRequest { body?: Record; } +type SlashCommandHostOptions = { + holdSummarize?: boolean; + seedTranscript?: boolean; +}; + +type SlashRuntimeEvent = { + type: 'message.part.updated' | 'session.compacted'; + payload: Record; +}; + async function readCapturedRequests( electronApp: ElectronApplication, ): Promise { @@ -30,6 +40,103 @@ async function readCapturedRequests( return requests.filter((request) => request.method !== 'GET'); } +async function readSlashRuntimeState( + page: import('@playwright/test').Page, + electronApp: ElectronApplication, +): Promise<{ clientCount: number; summarizePending: boolean }> { + const clientCount = await page.evaluate(() => { + type ControlledEventSource = { readyState: number }; + const sources = (globalThis as typeof globalThis & { + __niancodeSlashE2ESources?: Set; + }).__niancodeSlashE2ESources; + return [...(sources ?? [])].filter((source) => source.readyState !== 2).length; + }); + const summarizePending = await electronApp.evaluate(() => { + type MainState = { + summarizeRelease?: () => void; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + return Boolean(state?.summarizeRelease); + }); + return { clientCount, summarizePending }; +} + +async function emitSlashRuntimeEvent( + page: import('@playwright/test').Page, + electronApp: ElectronApplication, + event: SlashRuntimeEvent, +): Promise { + await electronApp.evaluate((value) => { + type MainState = { + messages?: Array>; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + if (value.type === 'session.compacted' && state.messages) { + const assistant = state.messages.find((message) => ( + (message.info as Record | undefined)?.id + === 'msg_slash_e2e_assistant' + )); + const parts = Array.isArray(assistant?.parts) ? assistant.parts : []; + if (assistant && !parts.some((part) => ( + (part as Record)?.id === 'part_slash_e2e_compaction' + ))) { + assistant.parts = [ + ...parts, + { + id: 'part_slash_e2e_compaction', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'compaction', + time: { + start: 1_753_190_402_000, + end: 1_753_190_403_000, + }, + }, + ]; + } + } + }, event); + await page.evaluate((value) => { + type ControlledEventSource = { + readyState: number; + dispatch: (type: string, payload: Record) => void; + }; + const sources = (globalThis as typeof globalThis & { + __niancodeSlashE2ESources?: Set; + }).__niancodeSlashE2ESources; + if (!sources || sources.size === 0) { + throw new Error('Slash E2E renderer event stream is unavailable'); + } + for (const source of sources) { + if (source.readyState !== 2) source.dispatch(value.type, value.payload); + } + }, event); +} + +async function releaseSlashSummarize( + electronApp: ElectronApplication, +): Promise { + await electronApp.evaluate(() => { + type MainState = { + summarizeRelease?: () => void; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const release = mainGlobal.__niancodeSlashE2EState?.summarizeRelease; + if (!release) { + throw new Error('Slash E2E summarize request is not pending'); + } + release(); + }); +} + async function setSlashCommandFailure( electronApp: ElectronApplication, message: string | null, @@ -56,6 +163,7 @@ async function setSlashCommandFailure( async function installSlashCommandHost( electronApp: ElectronApplication, + options: SlashCommandHostOptions = {}, ): Promise { await electronApp.evaluate(async () => { const { ipcMain } = process.mainModule!.require( @@ -69,15 +177,13 @@ async function installSlashCommandHost( type MainState = { captured: MainCapturedSlashRequest[]; commandFailure: string | null; + holdSummarize: boolean; + messages: Array>; + summarizeRelease?: () => void; }; const mainGlobal = globalThis as typeof globalThis & { __niancodeSlashE2EState?: MainState; }; - const state: MainState = { - captured: [], - commandFailure: null, - }; - mainGlobal.__niancodeSlashE2EState = state; const project = { id: 'prj_slash_e2e', path: 'D:/e2e/slash', @@ -91,6 +197,13 @@ async function installSlashCommandHost( title: 'Slash E2E', agent: 'game-development', }; + const state: MainState = { + captured: [], + commandFailure: null, + holdSummarize: false, + messages: [], + }; + mainGlobal.__niancodeSlashE2EState = state; const agent = { id: 'game-development', avatarId: 'avatar-01', @@ -253,7 +366,7 @@ async function installSlashCommandHost( === '/api/opencode/sessions/ses_slash_e2e/messages' && method === 'GET' ) { - return respond({ messages: [] }); + return respond({ messages: state.messages }); } if ( path @@ -290,6 +403,12 @@ async function installSlashCommandHost( === '/api/opencode/sessions/ses_slash_e2e/summarize' && method === 'POST' ) { + if (state.holdSummarize) { + await new Promise((resolve) => { + state.summarizeRelease = resolve; + }); + state.summarizeRelease = undefined; + } return respond({ success: true }, 202); } if ( @@ -312,14 +431,135 @@ async function installSlashCommandHost( ); }); }); + if (options.holdSummarize || options.seedTranscript) { + await configureSlashCompactionHost(electronApp); + } +} + +async function configureSlashCompactionHost( + electronApp: ElectronApplication, +): Promise { + await electronApp.evaluate(() => { + type MainState = { + holdSummarize: boolean; + messages: Array>; + }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + if (!state) { + throw new Error('Slash E2E Main state is unavailable'); + } + state.holdSummarize = true; + state.messages = [ + { + info: { + id: 'msg_slash_e2e_user', + sessionID: 'ses_slash_e2e', + role: 'user', + time: { created: 1_753_190_400_000 }, + }, + parts: [{ + id: 'part_slash_e2e_user', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_user', + type: 'text', + text: '已有的时间线消息', + }], + }, + { + info: { + id: 'msg_slash_e2e_assistant', + sessionID: 'ses_slash_e2e', + role: 'assistant', + time: { created: 1_753_190_401_000 }, + }, + parts: [{ + id: 'part_slash_e2e_assistant', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'text', + text: '已有的助手回复', + }], + }, + ]; + }); +} + +async function installSlashRendererEventSource( + page: import('@playwright/test').Page, +): Promise { + await page.addInitScript(() => { + // The fixture's Main IPC stub does not own the Host API SSE server. Keep + // this E2E at the renderer/store event boundary instead of relying on a + // real OpenCode process or an unrelated localhost listener. + type EventListener = (event: MessageEvent) => void; + type ControlledEventSource = { + readyState: number; + dispatch: (type: string, payload: Record) => void; + close: () => void; + }; + const sources = new Set(); + class SlashEventSource implements ControlledEventSource { + static readonly OPEN = 1; + static readonly CLOSED = 2; + readonly url: string; + readyState = SlashEventSource.OPEN; + onopen: (() => void) | null = null; + onerror: ((event: Event) => void) | null = null; + private readonly listeners = new Map>(); + + constructor(url: string) { + this.url = url; + sources.add(this); + } + + addEventListener(type: string, listener: EventListener): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners.get(type)?.delete(listener); + } + + close(): void { + this.readyState = SlashEventSource.CLOSED; + sources.delete(this); + } + + dispatch(type: string, payload: Record): void { + const event = new MessageEvent('message', { + data: JSON.stringify(payload), + }); + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + Object.defineProperty(window, 'EventSource', { + configurable: true, + writable: true, + value: SlashEventSource, + }); + Object.defineProperty(globalThis, '__niancodeSlashE2ESources', { + configurable: true, + value: sources, + }); + }); } test.describe('OpenCode slash commands', () => { test.afterEach(async ({ electronApp }) => { await electronApp.evaluate(async () => { - const mainGlobal = globalThis as typeof globalThis & { - __niancodeSlashE2EState?: unknown; + type MainState = { + summarizeRelease?: () => void; }; + const mainGlobal = globalThis as typeof globalThis & { + __niancodeSlashE2EState?: MainState; + }; + const state = mainGlobal.__niancodeSlashE2EState; + state?.summarizeRelease?.(); delete mainGlobal.__niancodeSlashE2EState; }); }); @@ -402,6 +642,115 @@ test.describe('OpenCode slash commands', () => { }); }); + test('keeps manual compaction running at its timeline position, then completes it from native events', async ({ + electronApp, + page, + }) => { + await completeSetup(page); + await installSlashRendererEventSource(page); + await installSlashCommandHost(electronApp, { + holdSummarize: true, + seedTranscript: true, + }); + await page.reload(); + await page.getByTestId('sidebar-module-switcher-trigger').click(); + await page.getByTestId('sidebar-module-programming').click(); + await expect(page).toHaveURL(/\/opencode-chat$/); + await page.getByTestId('project-agent-chat-game-development').click(); + + const transcript = page.getByTestId('opencode-transcript-scroll'); + const existingAssistant = transcript.locator( + '[data-chat-message-id="msg_slash_e2e_assistant"]', + ); + await expect(existingAssistant).toBeVisible(); + const composer = page.getByRole('textbox'); + await expect(composer).toBeVisible(); + await composer.fill('/compact'); + await composer.press('Enter'); + await expect(composer).toHaveValue('/compact'); + await composer.press('Enter'); + + await expect.poll(async () => ( + await readCapturedRequests(electronApp) + ).some((request) => request.path.endsWith('/summarize'))).toBe(true); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).summarizePending).toBe(true); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).clientCount).toBeGreaterThan(0); + + const compactionItem = transcript.getByTestId( + 'opencode-compaction-timeline-item', + ); + await expect(compactionItem).toHaveCount(1); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-source', + 'manual', + ); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'running', + ); + await expect(compactionItem).toContainText('正在压缩上下文'); + await expect(compactionItem.locator('span')).toHaveClass( + /context-compaction-shimmer/, + ); + const [compactionTop, assistantTop] = await Promise.all([ + compactionItem.evaluate((element) => element.getBoundingClientRect().top), + existingAssistant.evaluate((element) => element.getBoundingClientRect().top), + ]); + expect(compactionTop).toBeGreaterThan(assistantTop); + const compactionId = await compactionItem.getAttribute('data-compaction-id'); + expect(compactionId).toBeTruthy(); + + await emitSlashRuntimeEvent(page, electronApp, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + eventID: 'slash-compaction-part-updated', + part: { + id: 'part_slash_e2e_compaction', + sessionID: 'ses_slash_e2e', + messageID: 'msg_slash_e2e_assistant', + type: 'compaction', + source: 'manual', + time: { start: 1_753_190_402_000 }, + }, + }, + }); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'running', + ); + + await emitSlashRuntimeEvent(page, electronApp, { + type: 'session.compacted', + payload: { + sessionID: 'ses_slash_e2e', + eventID: 'slash-session-compacted', + }, + }); + await expect(compactionItem).toHaveCount(1); + await expect(compactionItem).toHaveAttribute( + 'data-compaction-status', + 'completed', + ); + await expect(compactionItem).toContainText('已压缩上下文'); + await expect(compactionItem.locator('span')).not.toHaveClass( + /context-compaction-shimmer/, + ); + expect(await compactionItem.getAttribute('data-compaction-id')) + .toBe(compactionId); + + await releaseSlashSummarize(electronApp); + await expect.poll(async () => ( + await readSlashRuntimeState(page, electronApp) + ).summarizePending).toBe(false); + await expect(compactionItem).toBeVisible(); + }); + test('keeps the project and conversation rails at the minimum width without covering the chat canvas', async ({ electronApp, page, diff --git a/tests/unit/opencode-chat-panel.test.tsx b/tests/unit/opencode-chat-panel.test.tsx index 3161218..e9b32aa 100644 --- a/tests/unit/opencode-chat-panel.test.tsx +++ b/tests/unit/opencode-chat-panel.test.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; import { OpencodeChatPanel } from '@/pages/Chat/OpencodeChatPanel'; +import { buildSessionTranscriptTimeline } from '@/pages/Chat/session-transcript-view-model'; import { useAuthStore } from '@/stores/auth'; import { useOpencodeStore } from '@/stores/opencode'; import { usePartnerProfilesStore } from '@/stores/partner-profiles'; @@ -14,7 +15,12 @@ import { } from '@/stores/chat-view-preferences'; import type { RawMessage } from '@/types/chat'; import type { ProcessedImageAttachment } from '@/lib/image-attachment-compression'; -import type { OpencodeProject, OpencodeSession } from '@/types/opencode'; +import type { + OpencodeCompactionTimelineEvent, + OpencodeProject, + OpencodeSession, + OpencodeSessionTranscriptState, +} from '@/types/opencode'; import { createProjectConfig, type ProjectAgentConfig } from '../../shared/project-config'; import { createProjectConversationState, @@ -380,6 +386,38 @@ function createDeferred() { return { promise, resolve }; } +function createCompactionTranscript( + sessionID: string, + messageIDs: string[], + compactions: OpencodeCompactionTimelineEvent[], +): OpencodeSessionTranscriptState { + return { + sessionID, + status: compactions.some((event) => event.status === 'running') ? 'busy' : 'idle', + messagesById: Object.fromEntries(messageIDs.map((messageID) => [messageID, { + id: messageID, + sessionID, + role: 'assistant', + partIDs: [`part_${messageID}`], + }])), + messageOrder: messageIDs, + partsById: Object.fromEntries(messageIDs.map((messageID) => [`part_${messageID}`, { + id: `part_${messageID}`, + sessionID, + messageID, + type: 'text' as const, + rawType: 'text', + text: messageID, + }])), + partOrderByMessageId: Object.fromEntries(messageIDs.map((messageID) => [ + messageID, + [`part_${messageID}`], + ])), + compactionsById: Object.fromEntries(compactions.map((event) => [event.id, event])), + compactionOrder: compactions.map((event) => event.id), + }; +} + function installCompressionChatHost(postResponse: Promise<{ success: boolean; error?: string; @@ -748,6 +786,7 @@ describe('OpencodeChatPanel', () => { sessionStatuses: {}, sessionMessages: [], sessionMessagesBySessionId: {}, + sessionTranscriptBySessionId: {}, streamingMessage: null, streamingMessagesBySessionId: {}, streamingTools: [], @@ -2809,6 +2848,267 @@ describe('OpencodeChatPanel', () => { await waitFor(() => expect(transcriptScroll.scrollTop).toBe(1400)); }); + it('inserts persistent compaction items at native message anchors with source-specific labels', async () => { + const messages: RawMessage[] = [ + { id: 'msg_first', role: 'user', content: 'First prompt' }, + { id: 'msg_second', role: 'assistant', content: 'Second answer' }, + ]; + installRunningSlashProject({ commands: [], messages }); + const automatic: OpencodeCompactionTimelineEvent = { + id: 'compact_auto', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorPartID: 'part_msg_first', + }; + const manual: OpencodeCompactionTimelineEvent = { + id: 'compact_manual', + sessionID: 'ses_slash', + source: 'manual', + status: 'completed', + order: 1, + anchorMessageID: 'msg_second', + }; + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript( + 'ses_slash', + ['msg_first', 'msg_second'], + [automatic, manual], + ), + }, + }); + + render(); + + const automaticItem = await screen.findByText('已优化对话'); + const manualItem = screen.getByText('已压缩上下文'); + const firstMessage = document.querySelector('[data-chat-message-id="msg_first"]'); + const secondMessage = document.querySelector('[data-chat-message-id="msg_second"]'); + expect(firstMessage).not.toBeNull(); + expect(secondMessage).not.toBeNull(); + expect(firstMessage!.compareDocumentPosition(automaticItem) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(automaticItem.compareDocumentPosition(secondMessage!) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(secondMessage!.compareDocumentPosition(manualItem) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(screen.queryByTestId('opencode-compaction-notice')).not.toBeInTheDocument(); + for (const item of screen.getAllByTestId('opencode-compaction-timeline-item')) { + expect(item).not.toHaveAttribute('role'); + expect(item).not.toHaveAttribute('aria-live'); + expect(item).not.toHaveClass('border', 'shadow-soft'); + expect(item.querySelector('.animate-spin')).toBeNull(); + } + }); + + it('orders a compaction by its native Part anchor when the anchor message is not visible', () => { + const event: OpencodeCompactionTimelineEvent = { + id: 'compact_hidden_anchor', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorPartID: 'part_msg_compaction', + }; + const transcript = createCompactionTranscript( + 'ses_slash', + ['msg_before', 'msg_compaction', 'msg_after'], + [event], + ); + const timeline = buildSessionTranscriptTimeline([ + { id: 'msg_before', role: 'user', content: 'Before' }, + { id: 'msg_after', role: 'assistant', content: 'After' }, + ], transcript); + + expect(timeline.map((item) => item.id)).toEqual([ + 'msg_before', + 'compaction:compact_hidden_anchor', + 'msg_after', + ]); + }); + + it('announces a live compaction transition and keeps the completed item in place', async () => { + const messages: RawMessage[] = [ + { id: 'msg_prompt', role: 'user', content: 'Keep the context concise' }, + ]; + installRunningSlashProject({ commands: [], messages }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], []), + }, + }); + render(); + await screen.findAllByText('Keep the context concise'); + + const running: OpencodeCompactionTimelineEvent = { + id: 'compact_live', + sessionID: 'ses_slash', + source: 'automatic', + status: 'running', + order: 0, + anchorMessageID: 'msg_prompt', + }; + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], [running]), + }, + }); + }); + + const liveItem = (await screen.findByText('正在优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + )!; + expect(liveItem).toHaveAttribute('role', 'status'); + expect(liveItem).toHaveAttribute('aria-live', 'polite'); + expect(liveItem.querySelector('.context-compaction-shimmer')).not.toBeNull(); + + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_prompt'], [{ + ...running, + status: 'completed', + completedAt: 2, + }]), + }, + sessionStatuses: { ses_slash: { type: 'idle' } }, + }); + }); + + const completedItem = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + )!; + expect(completedItem).toHaveAttribute('role', 'status'); + expect(completedItem).toBe(liveItem); + expect(completedItem).toHaveAttribute('data-compaction-id', 'compact_live'); + expect(completedItem.querySelector('.context-compaction-shimmer')).toBeNull(); + expect(screen.queryByText('正在优化对话')).not.toBeInTheDocument(); + }); + + it('does not expose hydrated or session-switched compactions as live regions', async () => { + const sessionAEvents: OpencodeCompactionTimelineEvent[] = [{ + id: 'compact_a', + sessionID: 'ses_slash', + source: 'automatic', + status: 'completed', + order: 0, + anchorMessageID: 'msg_a', + }]; + const sessionBEvents: OpencodeCompactionTimelineEvent[] = [{ + id: 'compact_b', + sessionID: 'ses_other', + source: 'manual', + status: 'running', + order: 0, + anchorMessageID: 'msg_b', + }]; + const messagesBySession: Record = { + ses_slash: [{ id: 'msg_a', role: 'user', content: 'Session A' }], + ses_other: [{ id: 'msg_b', role: 'user', content: 'Session B' }], + }; + installRunningSlashProject({ + commands: [], + messagesBySession, + extraSessions: [{ id: 'ses_other', title: 'Other', agent: 'game-development' }], + }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_a'], sessionAEvents), + ses_other: createCompactionTranscript('ses_other', ['msg_b'], sessionBEvents), + }, + }); + render(); + + const historicalA = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(historicalA).not.toHaveAttribute('role'); + + act(() => { + useOpencodeStore.setState({ + selectedSessionId: 'ses_other', + sessionMessages: messagesBySession.ses_other, + }); + }); + const historicalB = (await screen.findByText('正在压缩上下文')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(historicalB).not.toHaveAttribute('role'); + expect(historicalB).not.toHaveAttribute('aria-live'); + expect(screen.queryByText('已优化对话')).not.toBeInTheDocument(); + + act(() => { + useOpencodeStore.setState({ + selectedSessionId: 'ses_slash', + sessionMessages: messagesBySession.ses_slash, + }); + }); + const restoredA = (await screen.findByText('已优化对话')).closest( + '[data-testid="opencode-compaction-timeline-item"]', + ); + expect(restoredA).not.toHaveAttribute('role'); + expect(screen.queryByText('正在压缩上下文')).not.toBeInTheDocument(); + }); + + it('preserves scroll position when compacting while scrolled up and follows a tailed transcript', async () => { + const messages: RawMessage[] = [ + { id: 'msg_scroll', role: 'assistant', content: 'Scrollable answer' }, + ]; + installRunningSlashProject({ commands: [], messages }); + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], []), + }, + }); + render(); + await screen.findAllByText('Scrollable answer'); + + const transcriptScroll = screen.getByTestId('opencode-transcript-scroll'); + Object.defineProperty(transcriptScroll, 'scrollHeight', { value: 1000, configurable: true }); + Object.defineProperty(transcriptScroll, 'clientHeight', { value: 200, configurable: true }); + transcriptScroll.scrollTop = 120; + fireEvent.scroll(transcriptScroll); + const running: OpencodeCompactionTimelineEvent = { + id: 'compact_scroll', + sessionID: 'ses_slash', + source: 'manual', + status: 'running', + order: 0, + anchorMessageID: 'msg_scroll', + }; + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], [running]), + }, + }); + }); + + await screen.findByText('正在压缩上下文'); + expect(transcriptScroll.scrollTop).toBe(120); + expect(screen.getByRole('button', { name: '跳到最新消息' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '跳到最新消息' })); + expect(transcriptScroll.scrollTop).toBe(1000); + Object.defineProperty(transcriptScroll, 'scrollHeight', { value: 1400, configurable: true }); + act(() => { + useOpencodeStore.setState({ + sessionTranscriptBySessionId: { + ses_slash: createCompactionTranscript('ses_slash', ['msg_scroll'], [{ + ...running, + status: 'completed', + }]), + }, + }); + }); + + await screen.findByText('已压缩上下文'); + await waitFor(() => expect(transcriptScroll.scrollTop).toBe(1400)); + }); + it('turns the composer send button into a stop button while the session is busy', async () => { const activeProject = { id: 'prj_1', diff --git a/tests/unit/opencode-session-state.test.ts b/tests/unit/opencode-session-state.test.ts index 8e7b5c2..9724294 100644 --- a/tests/unit/opencode-session-state.test.ts +++ b/tests/unit/opencode-session-state.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + completeCompaction, + createPendingCompaction, getOrderedSessionParts, + getOrderedSessionCompactions, hydrateOpenCodeSession, reduceOpenCodeEvent, + removeCompaction, } from '@/lib/opencode-session-state'; describe('OpenCode native session state', () => { @@ -171,4 +175,261 @@ describe('OpenCode native session state', () => { }); expect(getOrderedSessionParts(secondRealChunk, 'msg_1')[0]?.text).toBe('xx'); }); + + it('hydrates historical compaction Parts as completed timeline events in transcript order', () => { + const state = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [ + { id: 'compact_1', type: 'compaction', auto: false }, + { id: 'compact_2', type: 'compaction', auto: true }, + ], + }]); + + expect(getOrderedSessionCompactions(state)).toEqual([ + expect.objectContaining({ + id: 'compaction:ses_1:part:compact_1', + source: 'manual', + status: 'completed', + order: 0, + anchorMessageID: 'msg_1', + anchorPartID: 'compact_1', + nativePartID: 'compact_1', + }), + expect.objectContaining({ + source: 'automatic', + status: 'completed', + order: 1, + nativePartID: 'compact_2', + }), + ]); + }); + + it('allows only the latest compaction in an active snapshot to remain running', () => { + const state = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [ + { id: 'compact_1', type: 'compaction', auto: true }, + { id: 'compact_2', type: 'compaction', auto: true }, + ], + }], 'busy'); + + expect(getOrderedSessionCompactions(state).map((event) => event.status)) + .toEqual(['completed', 'running']); + }); + + it('does not downgrade a completed compaction during busy history hydration', () => { + const snapshot = [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'compact_1', type: 'compaction', auto: true }], + }]; + const running = hydrateOpenCodeSession('ses_1', snapshot, 'busy'); + const completed = reduceOpenCodeEvent(running, { + type: 'session.compacted', + payload: { sessionID: 'ses_1', eventID: 'evt_compacted_1' }, + }); + + const rehydrated = hydrateOpenCodeSession('ses_1', snapshot, 'busy', completed); + + expect(getOrderedSessionCompactions(rehydrated)).toEqual([ + expect.objectContaining({ + nativePartID: 'compact_1', + status: 'completed', + }), + ]); + }); + + it('merges a manual pending event with its native Part without changing the UI id', () => { + const empty = hydrateOpenCodeSession('ses_1', [], 'busy'); + const pending = createPendingCompaction(empty, { + id: 'ui_compact_1', + source: 'manual', + runID: 'run_1', + generation: 3, + startedAt: 1_700_000_000_000, + }); + const withNativePart = reduceOpenCodeEvent(pending, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + eventID: 'evt_part_1', + messageID: 'msg_1', + part: { + id: 'native_compact_1', + type: 'compaction', + auto: false, + runID: 'run_1', + generation: 3, + }, + }, + }); + + expect(getOrderedSessionCompactions(withNativePart)).toEqual([ + expect.objectContaining({ + id: 'ui_compact_1', + nativePartID: 'native_compact_1', + nativeEventID: 'evt_part_1', + anchorMessageID: 'msg_1', + status: 'running', + }), + ]); + + const replayed = reduceOpenCodeEvent(withNativePart, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + eventID: 'evt_part_2', + messageID: 'msg_1', + part: { id: 'native_compact_1', type: 'compaction', auto: false }, + }, + }); + expect(getOrderedSessionCompactions(replayed)).toHaveLength(1); + expect(getOrderedSessionCompactions(replayed)[0]?.id).toBe('ui_compact_1'); + }); + + it('preserves unmatched running pending events across active hydration only', () => { + const current = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 }, + ); + + const active = hydrateOpenCodeSession('ses_1', [], 'busy', current); + expect(getOrderedSessionCompactions(active).map((event) => event.id)).toEqual(['ui_pending']); + + const historical = hydrateOpenCodeSession('ses_1', [], 'idle', current); + expect(getOrderedSessionCompactions(historical)).toEqual([]); + }); + + it('merges an active snapshot Part into the current manual pending UI identity', () => { + const current = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_pending', source: 'manual', runID: 'run_1', generation: 1 }, + ); + const hydrated = hydrateOpenCodeSession('ses_1', [{ + info: { id: 'msg_1', role: 'assistant', sessionID: 'ses_1' }, + parts: [{ id: 'native_compact_1', type: 'compaction', auto: false }], + }], 'busy', current); + + expect(getOrderedSessionCompactions(hydrated)).toEqual([ + expect.objectContaining({ + id: 'ui_pending', + nativePartID: 'native_compact_1', + status: 'running', + runID: 'run_1', + generation: 1, + }), + ]); + }); + + it('closes an older running native compaction when a later one arrives', () => { + let state = hydrateOpenCodeSession('ses_1', [], 'busy'); + for (const [messageID, partID] of [['msg_1', 'compact_1'], ['msg_2', 'compact_2']]) { + state = reduceOpenCodeEvent(state, { + type: 'message.part.updated', + payload: { + sessionID: 'ses_1', + messageID, + part: { id: partID, type: 'compaction', auto: true }, + }, + })!; + } + + expect(getOrderedSessionCompactions(state).map((event) => event.status)) + .toEqual(['completed', 'running']); + }); + + it('completes and removes only the compaction matching all supplied correlation keys', () => { + let state = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_1', source: 'manual', runID: 'run_1', generation: 1 }, + ); + state = createPendingCompaction(state, { + id: 'ui_2', + source: 'manual', + runID: 'run_2', + generation: 2, + nativePartID: 'native_2', + }); + + const wrongGeneration = completeCompaction( + state, + { runID: 'run_2', generation: 1, nativePartID: 'native_2' }, + ); + expect(wrongGeneration).toBe(state); + + state = completeCompaction( + state, + { runID: 'run_2', generation: 2, nativePartID: 'native_2' }, + { nativeEventID: 'evt_complete_2', completedAt: 1_700_000_000_100 }, + ); + expect(state.compactionsById.ui_2).toEqual(expect.objectContaining({ + status: 'completed', + nativeEventID: 'evt_complete_2', + completedAt: 1_700_000_000_100, + })); + expect(state.compactionsById.ui_1?.status).toBe('running'); + + const untouched = removeCompaction(state, { runID: 'run_1', generation: 2 }); + expect(untouched).toBe(state); + state = removeCompaction(state, { runID: 'run_1', generation: 1 }); + expect(getOrderedSessionCompactions(state).map((event) => event.id)).toEqual(['ui_2']); + }); + + it('completes compaction on session.compacted without marking the transcript idle', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_1', source: 'automatic', runID: 'run_1', generation: 4 }, + ); + const compacted = reduceOpenCodeEvent(busy, { + type: 'session.compacted', + payload: { + sessionID: 'ses_1', + eventID: 'evt_complete_1', + runID: 'run_1', + generation: 4, + }, + }); + + expect(compacted?.status).toBe('busy'); + expect(compacted?.compactionsById.ui_compact_1).toEqual(expect.objectContaining({ + status: 'completed', + nativeEventID: 'evt_complete_1', + })); + + const idle = reduceOpenCodeEvent(compacted, { + type: 'session.idle', + payload: { sessionID: 'ses_1' }, + }); + expect(idle?.status).toBe('idle'); + }); + + it('uses session.idle as a successful completion fallback', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_idle', source: 'manual', runID: 'run_idle', generation: 5 }, + ); + + const idle = reduceOpenCodeEvent(busy, { + type: 'session.idle', + payload: { sessionID: 'ses_1' }, + }); + + expect(idle?.status).toBe('idle'); + expect(idle?.compactionsById.ui_compact_idle?.status).toBe('completed'); + }); + + it('removes unfinished compactions on a fatal session error', () => { + const busy = createPendingCompaction( + hydrateOpenCodeSession('ses_1', [], 'busy'), + { id: 'ui_compact_error', source: 'automatic', runID: 'run_error', generation: 6 }, + ); + + const failed = reduceOpenCodeEvent(busy, { + type: 'session.error', + payload: { sessionID: 'ses_1', error: 'provider failed' }, + }); + + expect(failed?.status).toBe('idle'); + expect(failed?.compactionOrder).toEqual([]); + expect(failed?.compactionsById.ui_compact_error).toBeUndefined(); + }); }); diff --git a/tests/unit/opencode-store.test.ts b/tests/unit/opencode-store.test.ts index 68e4105..8ec16a9 100644 --- a/tests/unit/opencode-store.test.ts +++ b/tests/unit/opencode-store.test.ts @@ -4890,12 +4890,285 @@ describe('opencode store', () => { expect(createHostEventSourceMock).toHaveBeenCalledWith('/api/opencode/events'); expect(source.close).not.toHaveBeenCalled(); expect(hostApiFetchMock.mock.calls.map(([path]) => path)) - .not.toContain('/api/opencode/sessions/status'); + .toContain('/api/opencode/sessions/status'); source.emit('session.idle', { sessionID: 'ses_1' }); expect(source.close).not.toHaveBeenCalled(); }); + it('keeps one manual compaction identity and releases queued prompts only after idle', async () => { + const compactSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle'); + const promptSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle'); + createHostEventSourceMock + .mockReturnValueOnce(compactSource) + .mockReturnValueOnce(promptSource); + let promptPosted = false; + let compactOutcome: 'resolved' | 'rejected' | null = null; + useOpencodeStore.setState({ + runtimeGeneration: 7, + selectedSessionId: 'ses_compact_lifecycle', + sessions: [{ id: 'ses_compact_lifecycle' }], + sessionStatuses: { ses_compact_lifecycle: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return { success: true }; + if (path.endsWith('/messages') && init?.method === 'POST') { + promptPosted = true; + return { success: true }; + } + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + if (path.endsWith('/messages')) { + return { + messages: promptPosted + ? [ + { id: 'msg_queued_user', role: 'user', content: 'After compact' }, + { id: 'msg_queued_assistant', role: 'assistant', content: 'Done' }, + ] + : [], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_lifecycle'); + const observedCompact = compacting.then( + (value) => { + compactOutcome = 'resolved'; + return value; + }, + (error: unknown) => { + compactOutcome = 'rejected'; + throw error; + }, + ); + await vi.waitFor(() => { + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle; + expect(transcript?.compactionOrder).toHaveLength(1); + expect(transcript?.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + source: 'manual', + status: 'running', + runID: expect.any(String), + generation: 7, + }); + }); + const pendingTranscript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle!; + const immutableID = pendingTranscript.compactionOrder[0]!; + + await useOpencodeStore.getState().sendSessionMessage( + 'ses_compact_lifecycle', + 'After compact', + ); + compactSource.emit('message.part.updated', { + eventID: 'evt_compact_part', + part: { + id: 'prt_compact_manual', + sessionID: 'ses_compact_lifecycle', + messageID: 'msg_compact_manual', + type: 'compaction', + auto: false, + }, + }); + compactSource.emit('session.compacted', { + eventID: 'evt_compacted', + sessionID: 'ses_compact_lifecycle', + }); + + const compactedTranscript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_lifecycle!; + expect(compactedTranscript.compactionOrder).toEqual([immutableID]); + expect(compactedTranscript.compactionsById[immutableID]).toMatchObject({ + status: 'completed', + source: 'manual', + nativePartID: 'prt_compact_manual', + nativeEventID: 'evt_compacted', + }); + expect(compactOutcome).toBeNull(); + expect(promptPosted).toBe(false); + expect(useOpencodeStore.getState().queuedSessionPrompts.ses_compact_lifecycle) + .toHaveLength(1); + + compactSource.emit('session.idle', { sessionID: 'ses_compact_lifecycle' }); + await observedCompact; + await vi.waitFor(() => expect(promptPosted).toBe(true)); + expect(compactOutcome).toBe('resolved'); + }); + + it('removes an unfinished manual compaction when its POST fails', async () => { + const post = createDeferred<{ success: boolean }>(); + createHostEventSourceMock.mockReturnValue( + new MockEventSource('/api/opencode/events?sessionId=ses_compact_post_failure'), + ); + useOpencodeStore.setState({ + selectedSessionId: 'ses_compact_post_failure', + sessions: [{ id: 'ses_compact_post_failure' }], + sessionStatuses: { ses_compact_post_failure: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_post_failure'); + const outcome = compacting.catch((error: unknown) => error); + await vi.waitFor(() => expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_post_failure?.compactionOrder).toHaveLength(1)); + post.reject(new Error('compact POST failed')); + + await expect(outcome).resolves.toEqual(expect.objectContaining({ message: 'compact POST failed' })); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_post_failure?.compactionOrder).toEqual([]); + }); + + it('removes only the current unfinished compaction on a fatal session error', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_compact_fatal'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + selectedSessionId: 'ses_compact_fatal', + sessions: [{ id: 'ses_compact_fatal' }], + sessionStatuses: { ses_compact_fatal: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_fatal'); + await vi.waitFor(() => expect(createHostEventSourceMock).toHaveBeenCalled()); + source.emit('session.error', { + sessionID: 'ses_compact_fatal', + error: { name: 'APIError', message: 'fatal compaction failure' }, + }); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_fatal?.compactionOrder).toEqual([]); + post.resolve({ success: true }); + + await expect(compacting).rejects.toThrow('fatal compaction failure'); + }); + + it('records automatic compaction parts once and does not treat compacted as idle', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_auto_compact'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + runtimeGeneration: 5, + selectedSessionId: 'ses_auto_compact', + sessions: [{ id: 'ses_auto_compact' }], + sessionStatuses: { ses_auto_compact: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/messages') && init?.method === 'POST') return await post.promise; + if (path === '/api/opencode/sessions/status') return { statuses: {} }; + if (path.endsWith('/messages')) { + return { + messages: [ + { id: 'msg_auto_user', role: 'user', content: 'Trigger auto compact' }, + { id: 'msg_auto_assistant', role: 'assistant', content: 'Done' }, + ], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const prompting = useOpencodeStore.getState().sendSessionMessage( + 'ses_auto_compact', + 'Trigger auto compact', + ); + await vi.waitFor(() => expect(createHostEventSourceMock).toHaveBeenCalled()); + const partPayload = { + eventID: 'evt_auto_part', + part: { + id: 'prt_auto_compact', + sessionID: 'ses_auto_compact', + messageID: 'msg_auto_compact', + type: 'compaction', + auto: true, + }, + }; + source.emit('message.part.updated', partPayload); + source.emit('message.part.updated', partPayload); + source.emit('session.compacted', { + eventID: 'evt_auto_compacted', + sessionID: 'ses_auto_compact', + }); + + const transcript = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_auto_compact!; + expect(transcript.compactionOrder).toHaveLength(1); + expect(transcript.compactionsById[transcript.compactionOrder[0]!]).toMatchObject({ + source: 'automatic', + status: 'completed', + nativePartID: 'prt_auto_compact', + runID: expect.any(String), + generation: 5, + }); + expect(useOpencodeStore.getState().sendingSessionIds.ses_auto_compact).toBe(true); + + source.emit('session.idle', { sessionID: 'ses_auto_compact' }); + post.resolve({ success: true }); + await prompting; + }); + + it('preserves a pending compaction identity through hydration and removes it on abort', async () => { + const post = createDeferred<{ success: boolean }>(); + const source = new MockEventSource('/api/opencode/events?sessionId=ses_compact_hydrate'); + createHostEventSourceMock.mockReturnValue(source); + useOpencodeStore.setState({ + runtimeGeneration: 3, + selectedSessionId: 'ses_compact_hydrate', + sessions: [{ id: 'ses_compact_hydrate' }], + sessionStatuses: { ses_compact_hydrate: { type: 'idle' } }, + }); + hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { + if (path.endsWith('/summarize') && init?.method === 'POST') return await post.promise; + if (path.endsWith('/abort') && init?.method === 'POST') return { success: true }; + if (path.endsWith('/messages')) { + return { + messages: [{ + info: { + id: 'msg_compact_snapshot', + role: 'assistant', + sessionID: 'ses_compact_hydrate', + }, + parts: [{ + id: 'prt_compact_snapshot', + sessionID: 'ses_compact_hydrate', + messageID: 'msg_compact_snapshot', + type: 'compaction', + auto: false, + }], + }], + }; + } + throw new Error(`Unexpected request: ${path}`); + }); + + const compacting = useOpencodeStore.getState().compactSession('ses_compact_hydrate'); + await vi.waitFor(() => expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate?.compactionOrder).toHaveLength(1)); + const originalID = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate!.compactionOrder[0]!; + + await useOpencodeStore.getState().loadSessionMessages('ses_compact_hydrate'); + const hydrated = useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate!; + expect(hydrated.compactionOrder).toEqual([originalID]); + expect(hydrated.compactionsById[originalID]).toMatchObject({ + status: 'running', + source: 'manual', + nativePartID: 'prt_compact_snapshot', + generation: 3, + }); + + await useOpencodeStore.getState().abortSession('ses_compact_hydrate'); + expect(useOpencodeStore.getState() + .sessionTranscriptBySessionId.ses_compact_hydrate?.compactionOrder).toEqual([]); + post.resolve({ success: true }); + await compacting; + }); + it('preserves local path and preview attachment metadata in compact final history', async () => { const source = new MockEventSource( '/api/opencode/events?sessionId=ses_compact_attachments', @@ -4953,6 +5226,9 @@ describe('opencode store', () => { }], }; } + if (path === '/api/opencode/sessions/status') { + return { statuses: { ses_compact_attachments: { type: 'idle' } } }; + } throw new Error(`Unexpected request: ${path}`); });