diff --git a/.changeset/agent-workspace-minor.md b/.changeset/agent-workspace-minor.md new file mode 100644 index 000000000..9cffb86f3 --- /dev/null +++ b/.changeset/agent-workspace-minor.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": minor +--- + +Add expanded provider runtime support, agent instruction generation, skills management, and work analysis dashboard updates. diff --git a/.gitignore b/.gitignore index e2b4a05d2..ac042aa6e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ output/ .cache/ .turbo/ +.vite-style-build-*/ # Test outputs coverage/ @@ -39,6 +40,12 @@ temp/ .worktrees/ .claude/ +# Local agent instruction overrides +/AGENTS.md +/AGENTS.override.md +/CLAUDE.local.md +/GEMINI.md + # Acceptance runtime artifacts docs/验收报告/**/*.json @@ -72,4 +79,3 @@ tsconfig.tsbuildinfo # Rust build artefacts (from lsp-test/ fixture or any ad-hoc cargo) target/ Cargo.lock - diff --git a/README.md b/README.md index 55f56d993..8c1cf83bd 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ # Coder Studio -**Coder Studio, made for vibe coding.** +**The all-in-one vibe coding workspace for AI agents.** -An agentic workspace for real development. Run, inspect, and supervise coding agents with terminals, files, Git, sessions, and review in one browser workspace. +Coder Studio brings your code editor, Git, terminals, AI coding agents, session review, notifications, work analysis, and Skills into one browser workspace. -Built-in support today: Claude Code and Codex. Your code and runtime stay on your machine. +It helps keep agent context, progress, and follow-up work visible across desktop, tablet, and phone, so vibe coding feels less scattered and more controllable. + +Works with popular coding agents including Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, and Aider-style CLI agents. [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -25,31 +27,20 @@ Built-in support today: Claude Code and Codex. Your code and runtime stay on you
Preview the full workspace layout built for agent runs, review, supervision, and device switching.
-## Why It Feels Different - -- **One browser workspace for real agent work** — Keep terminals, files, Git, sessions, and review in one place. -- **Built for device switching** — Start on desktop, continue on tablet, and check progress from your phone. -- **Keep control local** — Your code and runtime stay on your machine. - ## Why Coder Studio? -Vibe coding agents are fast, but real development still gets fragmented: - -- the agent runs in one terminal -- files and diffs live in another editor -- verification happens in separate shell tabs -- long-running tasks are hard to monitor away from your desk -- mobile access usually means SSH or remote desktop +Vibe coding feels fast until the agent output turns into real project work: you still need to run agents, inspect edits, manage Git, monitor long tasks, and improve the next run. Coder Studio keeps that loop in one programming workbench. -Coder Studio turns that scattered workflow into one local browser workspace. - -| Pain | Without Coder Studio | With Coder Studio | -|------|----------------------|-------------------| -| Long agent tasks | Watch a terminal or come back later and reconstruct context | Keep sessions, terminal output, files, and Git changes visible in one workspace | -| Cross-device work | Use SSH, remote desktop, or rebuild context on another machine | Reopen the same local workspace from desktop, tablet, or phone | -| Reviewing AI changes | Jump between terminal, editor, and Git tools | Inspect files and diffs beside the agent session | -| Multiple agents | Manage separate terminal windows and histories | Run built-in Claude Code and Codex sessions side by side in one workspace today | -| Local-first control | Move work into a hosted IDE or cloud VM | Keep the runtime and project files on your own machine | +| Feature | Pain It Solves | What Coder Studio Provides | +|---------|----------------|----------------------------| +| **Agent Sessions** | Prompts, terminals, and histories scatter across tools. | Launch Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, and CLI-style agents from one workspace. | +| **Editor, Terminal, and Git** | Understanding one task means jumping between editor, shell tabs, Git tools, and diff viewers. | Keep code editing, terminal output, Git status, changed files, and diffs together. | +| **Reviewable AI Changes** | The agent says it is done, but you still need to know what is safe to keep. | Inspect changed files and diffs beside the agent session before adjusting, rejecting, or accepting edits. | +| **Supervisor Loops** | Long tasks stall, drift, or require repeated manual follow-up. | Evaluate progress and continue follow-up steps around the objective. | +| **Status and Notifications** | You keep checking terminal output just to know whether work finished or needs attention. | Surface session state changes and completion notices in the workspace. | +| **Cross-Device Workspace** | SSH, remote desktop, or another machine breaks the task context. | Reopen the same workspace from desktop, tablet, or phone to check progress and review changes. | +| **Work Analysis** | Logs and diffs do not make it easy to understand what happened over time. | Review activity, agent usage, bottlenecks, repeated patterns, and skill candidates. | +| **Skills Management** | The same instructions and workflows get repeated across agent runs. | Install and mount reusable Skills so agents start with stronger context and need fewer reminders. | ## Quick Start @@ -61,9 +52,9 @@ npm install -g @spencer-kit/coder-studio coder-studio open ``` -Your browser opens automatically. Select your project folder and start working with Claude Code or OpenAI Codex today. +Your browser opens automatically. Select your project folder and start an AI coding agent session. -> **No AI CLI installed yet?** You can still browse files and use the terminal. Install Claude Code or Codex later when needed. +> **No AI coding agent CLI installed yet?** You can still browse files and use the terminal. Install your preferred agent CLI later when needed. --- @@ -73,20 +64,26 @@ Your browser opens automatically. Select your project folder and start working w - Start an Agent task at the office, check progress on your phone during commute - Review code changes on a tablet without opening your laptop -- Continue work from a home computer with zero setup +- Reopen the same workspace from another device without rebuilding session context ### Long-Running AI Workflows - Let Supervisor push multi-step tasks toward an objective without constant babysitting - Check evaluation cycles and follow-up actions from your phone instead of watching terminal output -- Reduce repetitive prompting and manual coordination during long agent runs +- Use completion notices and status updates to know when agent work needs attention ### AI-Assisted Coding -- Run Claude Code and Codex sessions side by side today +- Run Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, or Aider-style CLI agent sessions - Keep terminal, editor, Git, and supervisor state in one unified interface - Resume active AI work from another device without rebuilding context +### Work Review and Skills + +- Use Work Analysis to review agent sessions, activity patterns, bottlenecks, and follow-up ideas +- Manage Skills from the workspace so agents can reuse the right workflow knowledge +- Turn repeated review findings into better future agent runs + --- ## 📱 Cross-Device Experience @@ -113,14 +110,16 @@ The same workspace URL works across all devices — interface adapts automatical | Feature | Description | |---------|-------------| +| **One-Stop Programming Workbench** | Combine code editing, PTY terminals, Git status, diffs, agent sessions, and review in one browser UI | | **Cross-Device Workspace** | Reopen the same coding environment from desktop, tablet, or phone without rebuilding context | | **Supervisor Loops** | Run objective-driven evaluation and follow-up cycles for long AI tasks with less manual babysitting | -| **Built-in Agent Providers** | Use Claude Code and Codex inside one workspace today instead of splitting your workflow across separate tools | -| **Unified Terminal, Files, and Git** | Keep PTY terminals, Monaco editing, diffs, and changed files in one browser UI | +| **Popular Coding Agents** | Run Claude Code, Codex, Gemini CLI, Cursor Agent, OpenCode, and CLI-style agents from one workspace | +| **Notifications and Status Updates** | Surface errors, state changes, and session completion notices without leaving the workspace | +| **Work Analysis** | Recap workspace activity, agent sessions, patterns, bottlenecks, and possible skill opportunities | +| **Skills Management** | Search, install, mount, repair, and review Skills that help agents follow reusable workflows | | **Reviewable AI Work** | Inspect changed files and diffs beside the session before trusting the result | | **Responsive Workspace UI** | Use layouts tuned for desktop, tablet, and mobile instead of a desktop-only interface squeezed onto small screens | | **Session Continuity** | Resume active sessions and keep AI work visible across device switches | -| **Local Runtime Control** | Keep code and runtime on your machine instead of relying on a cloud IDE | --- @@ -129,8 +128,7 @@ The same workspace URL works across all devices — interface adapts automatical | Dependency | Version | Notes | |------------|---------|-------| | Node.js | ≥ 24.0.0 | Required for running Coder Studio | -| Claude Code CLI | Latest | Optional — for Claude Agent sessions | -| OpenAI Codex CLI | Latest | Optional — for Codex Agent sessions | +| AI coding agent CLI | Latest | Optional — install the CLI for each agent you want to run | --- @@ -140,9 +138,10 @@ The same workspace URL works across all devices — interface adapts automatical |----------|-------------| | [Quick Start Guide](docs/help/quick-start.md) | Installation to first workspace | | [App Overview](docs/help/app-overview.md) | Core concepts and features | -| [Provider Setup](docs/help/providers.md) | Claude Code / Codex CLI installation | +| [Agent CLI Setup](docs/help/providers.md) | Install and connect coding agent CLIs | | [Desktop Guide](docs/help/desktop-guide.md) | PC interface and shortcuts | | [Mobile & Remote Access Guide](docs/help/mobile-guide.md) | Phone / tablet usage, LAN access, Tailscale/ngrok/Cloudflare Tunnel | +| [Work Analysis](docs/help/work-analysis.md) | Review workspace activity, agent sessions, and improvement opportunities | | [Common Workflows](docs/help/workflows.md) | Task-based tutorials | | [Troubleshooting](docs/help/troubleshooting.md) | FAQ and known issues | | [CLI Reference](docs/help/cli.md) | Command-line options | @@ -154,9 +153,9 @@ The same workspace URL works across all devices — interface adapts automatical ## 👥 Who Should Use Coder Studio - **Developers Running Coding Agents** — Want terminals, files, Git, sessions, and review in one place +- **Vibe Coding Users** — Want an agentic workspace instead of scattered terminal-only workflows - **Multi-Device Developers** — Switch between office, home, and mobile devices frequently - **Developers Running Long AI Tasks** — Want Supervisor to keep multi-step work moving without constant babysitting -- **Privacy-Conscious Developers** — Want code to stay on local machine, not cloud IDE --- @@ -166,7 +165,7 @@ The same workspace URL works across all devices — interface adapts automatical - [ ] Session replay and history navigation - [ ] Multi-workspace management - [ ] Plugin system for custom integrations -- [ ] Cloud sync for workspace preferences +- [ ] Workspace preference sync --- @@ -174,7 +173,7 @@ The same workspace URL works across all devices — interface adapts automatical We welcome contributions! See [Contributing Guide](CONTRIBUTING.md) for details. -### Local Development +### Development Setup ```bash git clone https://github.com/spencerkit/coder-studio.git @@ -208,4 +207,4 @@ MIT License — see [LICENSE](LICENSE) for details. ## 🔍 Keywords -`ai coding assistant` `browser ide` `claude code` `codex` `remote development` `web-based ide` `self-hosted ide` `cross-device coding` `ai agent workspace` `local-first development` `mobile coding` `tablet coding` `developer tools` `terminal in browser` `git web interface` `monaco editor` `websocket terminal` `ai pair programming` `coding anywhere` `cloud ide alternative` +`vibe coding` `agentic coding` `ai coding agent` `coding agent workspace` `browser ide` `claude code` `codex` `gemini cli` `cursor agent` `opencode` `aider` `cross-device coding` `ai agent workspace` `mobile coding` `tablet coding` `developer tools` `terminal in browser` `git web interface` `monaco editor` `websocket terminal` `ai pair programming` `supervisor loops` diff --git a/README.zh-CN.md b/README.zh-CN.md index 885c7c11b..42a7dfda3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,11 +4,13 @@ # Coder Studio -**Coder Studio,生来就是 vibe coding。** +**一站式 vibe coding 编程工作台。** -面向真实开发的 agentic workspace。用一个浏览器工作区运行、检查和监督 coding agent,把终端、文件、Git、会话和代码审查放在一起。 +Coder Studio 把代码编辑器、Git、终端、AI coding agent、会话审查、消息提醒、工作复盘和 Skills 放进同一个浏览器工作区。 -当前内置支持:Claude Code 和 Codex。你的代码和运行时保留在自己的机器上。 +它帮助你在桌面、平板和手机之间保持 Agent 上下文、任务进度和后续动作可见,让 vibe coding 不再散落在一堆窗口和工具里。 + +支持 Claude Code、Codex、Gemini CLI、Cursor Agent、OpenCode,以及 Aider 这类 CLI coding agent。 [![npm version](https://img.shields.io/npm/v/@spencer-kit/coder-studio.svg)](https://www.npmjs.com/package/@spencer-kit/coder-studio) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -25,31 +27,20 @@
预览这个为 Agent 运行、改动审查、Supervisor 监督和跨设备切换而设计的完整工作区布局。
-## 为什么它不一样 - -- **一个浏览器里完成真实 Agent 工作流** — 把终端、文件、Git、会话和代码审查放到同一个工作台。 -- **真正为设备切换而设计** — 在桌面端开始,在平板继续,用手机随时查看 Agent 进度。 -- **保留本地控制权** — 你的代码和运行时都留在自己的机器上。 - ## 为什么选择 Coder Studio? -vibe coding agent 很快,但真实开发里的工作流仍然是割裂的: - -- Agent 跑在一个终端里 -- 文件和 diff 在另一个编辑器里 -- 验证命令散落在不同 shell tab -- 长任务离开电脑后很难继续观察 -- 手机访问通常只能靠 SSH 或远程桌面 +vibe coding 一开始很快,但当 Agent 输出进入真实项目,后面还要运行 Agent、理解改动、审查 diff、管理 Git、监督长任务,并改进下一轮执行。Coder Studio 把这条链路收进一个编程工作台。 -Coder Studio 把这些分散的环节收进同一个本地浏览器工作台。 - -| 痛点 | 没有 Coder Studio | 使用 Coder Studio | -|------|-------------------|-------------------| -| 长时间 Agent 任务 | 盯着终端,或者回来后重新拼上下文 | 会话、终端输出、文件和 Git 变更都在同一个工作区里 | -| 跨设备继续 | SSH、远程桌面,或在另一台机器重新配置 | 桌面、平板、手机重新打开同一个本地工作区 | -| 审阅 AI 改动 | 在终端、编辑器、Git 工具之间切换 | 在 Agent 会话旁边直接查看文件和 diff | -| 多 Agent 并行 | 多个终端窗口和历史记录分散管理 | 今天先把内置的 Claude Code 和 Codex 会话并行放进同一个工作区 | -| 本地优先 | 把环境迁到云 IDE 或远程 VM | 运行时和项目文件留在自己的机器上 | +| 功能 | 解决的痛点 | Coder Studio 提供什么 | +|------|------------|-----------------------| +| **Agent 会话** | prompt、终端和历史记录分散在多个工具里。 | 在同一个工作区启动 Claude Code、Codex、Gemini CLI、Cursor Agent、OpenCode 和 CLI 形式的 Agent。 | +| **编辑器、终端和 Git** | 理解一个任务需要在编辑器、shell tab、Git 工具和 diff 查看器之间来回切换。 | 把代码编辑、终端输出、Git 状态、变更文件和 diff 放在一起。 | +| **可审查的 AI 改动** | Agent 说完成了,但你仍然要判断哪些改动可以信任。 | 在 Agent 会话旁审查变更文件和 diff,再决定调整、丢弃或接受。 | +| **Supervisor 监督循环** | 长任务容易卡住、跑偏,或者需要反复人工续推。 | 围绕目标评估进度,并继续推进后续步骤。 | +| **状态和消息提醒** | 你需要反复检查终端,才知道任务是否完成或需要介入。 | 在工作区内呈现会话状态变化和完成提醒。 | +| **跨设备工作区** | SSH、远程桌面或换设备会打断任务上下文。 | 桌面、平板、手机重新打开同一个工作区,继续看进度和审查改动。 | +| **工作分析** | 日志和 diff 很难解释一段时间内到底发生了什么。 | 回顾活动、Agent 使用情况、瓶颈、重复模式和 Skill 候选项。 | +| **Skills 管理** | 相同指令和工作流在多次 Agent 执行中反复重复。 | 安装和挂载可复用 Skills,让 Agent 带着更好的上下文开始,少靠人工提醒。 | ## 快速开始 @@ -61,9 +52,9 @@ npm install -g @spencer-kit/coder-studio coder-studio open ``` -浏览器会自动打开。选择你的项目文件夹,开始使用 Claude Code 或 OpenAI Codex。 +浏览器会自动打开。选择你的项目文件夹,然后启动一个 AI coding agent 会话。 -> **还没安装 AI CLI?** 你仍然可以浏览文件和使用终端。之后随时安装 Claude Code 或 Codex。 +> **还没安装 AI coding agent CLI?** 你仍然可以浏览文件和使用终端。之后随时安装你想使用的 Agent CLI。 --- @@ -73,20 +64,26 @@ coder-studio open - 在办公室启动 Agent 任务,通勤路上用手机查看进度 - 在平板上审阅代码改动,无需打开笔记本电脑 -- 在家用电脑继续工作,零配置切换 +- 在另一台设备重新打开同一个工作区,不必重建会话上下文 ### 长任务监督与调度 - 让 Supervisor 围绕目标持续推进多轮任务,不必一直盯着终端 - 用手机查看评估循环和后续动作,而不是守着每轮输出 -- 减少机械重复的人工催促,让长任务执行更稳 +- 通过完成提醒和状态更新知道什么时候需要介入 ### AI 辅助编程 -- 今天并行运行 Claude Code 和 Codex 会话 +- 运行 Claude Code、Codex、Gemini CLI、Cursor Agent、OpenCode,或 Aider 这类 CLI Agent 会话 - 终端、编辑器、Git 和 Supervisor 状态统一在一个界面 - 切换设备后继续当前 AI 工作,不必重新建立上下文 +### 工作复盘与 Skills + +- 用工作分析复盘 Agent 会话、活动模式、瓶颈和后续改进方向 +- 在工作区里管理 Skills,让 Agent 复用合适的工作流知识 +- 把重复出现的审查结论沉淀成后续更稳定的 Agent 执行方式 + --- ## 📱 跨设备体验 @@ -113,14 +110,16 @@ coder-studio open | 功能 | 描述 | |------|------| +| **一站式编程工作台** | 把代码编辑、PTY 终端、Git 状态、diff、Agent 会话和代码审查放进同一个浏览器界面 | | **跨设备工作区** | 在桌面、平板和手机之间重新打开同一个编码环境,不必重新建立上下文 | | **Supervisor 监督循环** | 围绕目标运行评估与续推循环,减少长任务中的人工盯守 | -| **内置 Agent Provider** | 今天先在同一个工作区里使用 Claude Code 和 Codex,而不是把工作流拆散到多个工具中 | -| **终端、文件和 Git 一体化** | 在同一个浏览器界面里完成 PTY 终端、Monaco 编辑、diff 和变更查看 | +| **热门 Coding Agent** | 在同一个工作区运行 Claude Code、Codex、Gemini CLI、Cursor Agent、OpenCode 和 CLI 形式的 Agent | +| **消息提醒与状态更新** | 在工作区内看到错误、状态变化和会话完成提醒 | +| **工作分析** | 复盘工作区活动、Agent 会话、常见模式、瓶颈和可能沉淀的 Skill | +| **Skills 管理** | 搜索、安装、挂载、修复和查看 Skills,让 Agent 更容易复用稳定工作流 | | **可审查的 AI 改动** | 先在 Agent 会话旁检查文件和 diff,再决定是否信任结果 | | **响应式工作区界面** | 提供面向桌面、平板和手机的布局,而不是把桌面界面硬塞进小屏幕 | | **会话连续性** | 切换设备后继续当前活跃会话,让 AI 工作保持可见 | -| **本地运行时控制** | 代码和运行时都留在你的机器上,不依赖云 IDE | --- @@ -129,8 +128,7 @@ coder-studio open | 依赖 | 版本 | 说明 | |------|------|------| | Node.js | ≥ 24.0.0 | 运行 Coder Studio 必需 | -| Claude Code CLI | 最新版 | 可选 —— 用于 Claude Agent 会话 | -| OpenAI Codex CLI | 最新版 | 可选 —— 用于 Codex Agent 会话 | +| AI coding agent CLI | 最新版 | 可选 —— 为你想运行的 Agent 安装对应 CLI | --- @@ -140,9 +138,10 @@ coder-studio open |------|------| | [快速开始](docs/help/quick-start.md) | 从安装到第一个工作区 | | [功能总览](docs/help/app-overview.md) | 核心概念和功能 | -| [Provider 配置](docs/help/providers.md) | Claude Code / Codex CLI 安装 | +| [Agent CLI 配置](docs/help/providers.md) | 安装和连接 coding agent CLI | | [桌面端指南](docs/help/desktop-guide.md) | PC 界面和快捷键 | | [移动端与远程访问指南](docs/help/mobile-guide.md) | 手机/平板使用、局域网访问、Tailscale/ngrok/Cloudflare Tunnel | +| [工作分析](docs/help/work-analysis.md) | 复盘工作区活动、Agent 会话和改进机会 | | [常用工作流](docs/help/workflows.md) | 任务式教程 | | [故障排除](docs/help/troubleshooting.md) | 常见问题和修复 | | [CLI 参考](docs/help/cli.md) | 命令行选项 | @@ -154,9 +153,9 @@ coder-studio open ## 👥 谁适合使用 - **运行 coding agent 的开发者** — 希望把终端、文件、Git、会话和代码审查放到同一个地方 +- **Vibe coding 用户** — 希望有一个 agentic workspace,而不是只靠分散的终端流程 - **多设备开发者** — 频繁在办公室、家和移动设备之间切换 - **运行长任务的开发者** — 希望由 Supervisor 持续推进多轮任务,而不是全程人工盯守 -- **注重隐私的开发者** — 希望代码留在本地机器,不依赖云 IDE --- @@ -166,7 +165,7 @@ coder-studio open - [ ] 会话回放和历史导航 - [ ] 多工作区管理 - [ ] 插件系统支持自定义集成 -- [ ] 工作区偏好云同步 +- [ ] 工作区偏好同步 --- @@ -174,7 +173,7 @@ coder-studio open 欢迎贡献!查看 [贡献指南](CONTRIBUTING.md) 了解详情。 -### 本地开发 +### 开发环境 ```bash git clone https://github.com/spencerkit/coder-studio.git @@ -208,4 +207,4 @@ MIT 许可证 —— 查看 [LICENSE](LICENSE) 了解详情。 ## 🔍 关键词 -`AI 编程助手` `浏览器 IDE` `Claude Code` `Codex` `远程开发` `网页 IDE` `自托管 IDE` `跨设备编程` `AI Agent 工作区` `本地优先开发` `移动端编程` `平板编程` `开发者工具` `浏览器终端` `Git 网页界面` `Monaco 编辑器` `WebSocket 终端` `AI 结对编程` `随处编程` `云 IDE 替代` +`vibe coding` `agentic coding` `AI coding agent` `Coding Agent 工作区` `浏览器 IDE` `Claude Code` `Codex` `Gemini CLI` `Cursor Agent` `OpenCode` `Aider` `跨设备编程` `AI Agent 工作区` `移动端编程` `平板编程` `开发者工具` `浏览器终端` `Git 网页界面` `Monaco 编辑器` `WebSocket 终端` `AI 结对编程` `Supervisor 循环` diff --git a/docs/help/README.md b/docs/help/README.md index c69bf4b1f..fb9d20231 100644 --- a/docs/help/README.md +++ b/docs/help/README.md @@ -19,6 +19,7 @@ ## 我想了解产品结构 - [App 功能总览](app-overview.md) — 核心概念与能力说明 +- [工作分析](work-analysis.md) — 多工作区、多时间范围的基础分析与深入分析 --- diff --git a/docs/help/work-analysis.md b/docs/help/work-analysis.md new file mode 100644 index 000000000..63712c091 --- /dev/null +++ b/docs/help/work-analysis.md @@ -0,0 +1,57 @@ +# 工作分析 + +## 这篇文档解决什么问题 + +帮助你理解 `工作分析` 能看什么、怎么筛选,以及 `基础分析` 和 `深入分析` 的区别。 + +## 前置条件 + +- 至少打开一个工作区,让 Coder Studio 知道要分析哪个 workspace path +- 该 workspace 在所选时间范围内最好有 provider 本地日志 +- 不要求当前有打开中的 Coder Studio session + +## 怎么用 + +1. 打开设置页,进入 `工作分析` +2. 选择一个或多个工作区 +3. 选择时间范围 +4. 先运行 `基础分析` +5. 如需更高维度结论,再运行 `深入分析` + +## 基础分析会给出什么 + +- 覆盖范围:命中的工作区数、provider 本地会话数、provider 数 +- 活动统计:总时长、平均会话时长、用户/助手/工具调用信号 +- 工作时间:provider 本地会话主要发生在哪些小时段 +- Provider 结构:常用 provider 分布,以及每个 provider 的日志状态 +- Skill 资产:已安装、已挂载、未挂载的 skill 数量 + +这部分会扫描 provider 保存在本机的日志或缓存数据,目前覆盖 5 个内置 provider:`claude`、`codex`、`gemini`、`cursor`、`opencode`。 + +不同 provider 的数据质量可能不同: + +- 有些 workspace 在选定时间范围内没有匹配日志 +- 有些 provider 只能返回部分数据 +- 有些会话缺少明确时间戳,只能回退到文件修改时间 + +## 深入分析会给出什么 + +- 工作内容总结 +- 重复出现的工作模式 +- 主要瓶颈 +- 流程改进建议 +- 可沉淀为 skill 的候选项 + +这部分会把基础分析结果和从 provider 日志中采样出来的代表性证据交给 headless agent,所以成本更高,也依赖 provider 的 headless 能力。 + +## 当前限制 + +- skill 使用次数不是 v1 的强保证,目前主要是 inventory 视角 +- 不同 provider 可读到的数据并不完全对称 +- 本地 provider 日志可能缺失、部分损坏,或者因时间戳缺失而回退到文件修改时间 +- 深入分析失败不会影响基础分析结果 + +## 下一步 + +- 想先了解整体产品结构,可以看 [App 功能总览](app-overview.md) +- 想结合真实使用场景,可以看 [常见工作流](workflows.md) diff --git a/docs/issue/opencode-linux-web-random-input-in-composer.md b/docs/issue/opencode-linux-web-random-input-in-composer.md new file mode 100644 index 000000000..183d8fe1b --- /dev/null +++ b/docs/issue/opencode-linux-web-random-input-in-composer.md @@ -0,0 +1,107 @@ +# OpenCode 在 Linux Web 终端里偶发出现异常字符被写入输入框 + +## 标题 + +`investigate(web): OpenCode 在 Linux 浏览器终端里偶发出现异常字符被写入输入框` + +## 问题描述 + +在 `coder-studio` 的 OpenCode session 场景下,用户偶发观察到 OpenCode 自己的输入框里出现一串并非主动输入的字符,内容表现为大写字母、小写字母、数字和符号混杂的异常文本。 + +从截图看,这些字符不是模型输出到历史区,而是直接出现在 OpenCode 底部 prompt / composer 输入框内,视觉上像“有人替用户敲了一串乱码”。 + +目前该问题是**偶发**的。当前还没有形成稳定复现步骤;本次继续排查时也**没有再次复现**。 + +## 已观察到的现象 + +- 异常内容出现在 OpenCode 的输入框,而不是普通 terminal 输出区。 +- 字符串形态不像自然语言,也不像正常命令,更像一串异常按键流、粘贴流或错误解码后的可打印字符。 +- 同类现象目前主要在 **Linux + 浏览器中的 coder-studio Web terminal** 场景被报告。 +- 用户反馈: + - 直接在系统终端里运行 OpenCode,好像没有遇到。 + - Windows 上好像也没有遇到。 + +## 复现步骤 + +当前**没有稳定复现步骤**。 + +已知上下文: + +1. 在 `coder-studio` 中打开 OpenCode agent session。 +2. 正常使用 OpenCode 的 TUI 输入框。 +3. 偶发看到输入框里被填入一串异常字符。 + +补充说明: + +- 本次排查过程中没有成功再次复现。 +- 现阶段更适合把这条记录为 intermittent / flaky issue,而不是宣称已有稳定复现链路。 + +## 预期行为 + +OpenCode 输入框只应显示用户真实输入的内容,或产品明确注入的受控文本;不应出现来源不明的异常字符。 + +## 实际行为 + +OpenCode 输入框偶发出现用户并未主动输入的字符,表现为输入框内容被“污染”或“鬼打字”。 + +## 已确认事实 + +- `coder-studio` 对 OpenCode 的 provider 接入很薄,主要只是启动 `opencode` 命令,本身没有发现“自动向 prompt 注入文本”的逻辑。 +- OpenCode provider 启动路径位于: + - `packages/providers/src/opencode/definition.ts` +- Web terminal 的输入路径基本是: + - `xterm.onData(...)` + - `handleInput(...)` + - `wsClient.sendTerminalInput(...)` + - PTY stdin +- 对应实现位于: + - `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- 当前没有发现 `coder-studio` 在 OpenCode session 中主动拼接这类随机文本的代码路径。 +- 当前前端对普通键盘输入的处理更接近“收到什么就往 PTY 发什么”,因此如果浏览器 / xterm / 输入法层产生了异常输入流,OpenCode 很可能会把它当成普通文本显示在自己的输入框里。 +- 相关本地环境线索: + - `opencode 1.15.13` + - Web 侧使用 `@xterm/xterm ^6.0.0` + - `TERM=xterm-256color` + - 当前排查环境未使用 `tmux` + - 当前图形会话可见 `WAYLAND_DISPLAY=wayland-0` + +## 当前判断 + +当前更像是 **Linux 浏览器 terminal 输入链路问题**,而不是 OpenCode 自己凭空生成了这串文本。 + +更具体地说,可疑边界在: + +- 浏览器键盘事件 +- `xterm.js` 的隐藏输入层 / IME / composition +- 剪贴板或粘贴事件 +- Wayland / 浏览器 / 输入法之间的偶发输入异常 + +OpenCode 只是把收到的可打印输入显示进了自己的 composer。 + +## 已排除或暂不优先的方向 + +- 暂无证据表明是模型输出错误地回显到了输入框。 +- 暂无证据表明 `coder-studio` provider 层主动生成了这串字符。 +- 暂无证据表明是 `tmux`、`kitty` 一类终端复用层特有问题;当前排查环境里并没有这些中间层。 + +## 后续排查方向 + +- 在 Web terminal 输入桥接层增加可开关的诊断日志,记录异常时真正发给 PTY 的原始输入字节。 +- 对比不同浏览器: + - Chromium / Chrome + - Firefox +- 对比 Linux 图形后端: + - Wayland + - X11 / XWayland +- 复现时同步记录: + - 当前输入法 + - 是否刚发生过粘贴 + - 是否有剪贴板增强工具 / 文本展开工具 / 键盘宏工具 + - 浏览器 console 中的 terminal trace +- 如果后续能在系统终端之外、但在最小化 `xterm.js` demo 中复现,再考虑向 `xterm.js` 或 OpenCode 上游提交更精确的问题报告。 + +## 当前状态 + +- 已记录问题现象和当前诊断边界。 +- 问题仍属 **偶发**。 +- 当前 **未稳定复现**。 diff --git a/docs/product-spec/README.zh-CN.md b/docs/product-spec/README.zh-CN.md new file mode 100644 index 000000000..c2dbbf435 --- /dev/null +++ b/docs/product-spec/README.zh-CN.md @@ -0,0 +1,347 @@ +# Coder Studio Product Spec + +本文档定义 Coder Studio 的产品功能规格体系。它不是 PRD,也不是历史设计记录,而是面向开发、测试和后续 AI agent 的功能事实与验收契约。 + +## 1. 文档目标 + +Product Spec 只回答四类问题: + +- 当前有哪些用户可达或内部可用的功能。 +- 每个功能从哪里进入、如何交互、有哪些状态和边界。 +- 每个功能依赖哪些前端入口、WebSocket command、server handler、provider 或 core 类型。 +- 每个功能如何验收,如何拆成手工或自动化测试用例。 + +Product Spec 不承担以下职责: + +- 不写市场背景、品牌叙事或愿景口号。 +- 不复述历史方案、旧 PRD 或过时 specs。 +- 不把未接线 UI、实验代码或未来设想写成已实现能力。 +- 不替代技术设计文档、实现计划或变更记录。 + +## 2. 事实来源 + +新规格以当前代码为准。旧 `docs/PRD*.md`、`docs/superpowers/specs/*`、wiki、promotion 和历史 issue 只能作为线索,不能作为事实依据。 + +可信事实来源按优先级排列: + +1. 用户可达入口:页面、按钮、菜单、快捷键、移动端 Sheet / Drawer。 +2. 前端代码:`packages/web/src/app`、`packages/web/src/shells`、`packages/web/src/features`。 +3. WebSocket 与命令分发:`packages/server/src/ws/dispatch.ts`、`packages/server/src/commands`。 +4. 共享合同:`packages/core/src`。 +5. Provider 能力:`packages/providers/src`。 +6. 测试证据:`*.test.ts`、`*.test.tsx`、`e2e-ui/specs`。 + +如果代码、旧文档和 README 描述冲突,以当前代码为准。 + +## 3. 目录结构 + +建议目录如下: + +```text +docs/product-spec/ + README.zh-CN.md + + modules/ + app-shell.zh-CN.md + auth.zh-CN.md + welcome.zh-CN.md + workspace.zh-CN.md + workspace-desktop.zh-CN.md + workspace-mobile.zh-CN.md + workspace-tabs-layout.zh-CN.md + agent-sessions.zh-CN.md + agent-panes.zh-CN.md + agent-instructions.zh-CN.md + providers.zh-CN.md + supervisor.zh-CN.md + files.zh-CN.md + editor-preview.zh-CN.md + search-quick-open.zh-CN.md + git.zh-CN.md + worktrees.zh-CN.md + terminal.zh-CN.md + settings.zh-CN.md + diagnostics.zh-CN.md + monitoring.zh-CN.md + work-analysis.zh-CN.md + skills.zh-CN.md + updates.zh-CN.md + notifications.zh-CN.md + command-palette.zh-CN.md + shortcuts.zh-CN.md + ui-components.zh-CN.md + + flows/ + startup-and-auth.zh-CN.md + open-workspace.zh-CN.md + start-agent-session.zh-CN.md + file-edit-preview.zh-CN.md + git-change-review.zh-CN.md + terminal-recovery.zh-CN.md + provider-configuration.zh-CN.md + + acceptance/ + smoke.zh-CN.md + desktop.zh-CN.md + mobile.zh-CN.md + server-commands.zh-CN.md +``` + +目录职责: + +- `modules/`:按产品模块记录功能点、状态、边界和验收标准。 +- `flows/`:记录跨模块用户流程,用于端到端验收和 e2e 用例设计。 +- `acceptance/`:记录全局验收清单,覆盖冒烟、桌面、移动端和 server command 层。 + +## 4. 模块边界 + +模块按用户能力和代码边界共同划分。优先保持一个模块内的功能能被同一类用户入口触发,并尽量对应明确的代码目录。 + +初始模块边界: + +| 模块 | 覆盖范围 | 主要代码线索 | +| --- | --- | --- | +| App Shell | 启动、路由壳层、连接态、桌面/移动壳层选择 | `packages/web/src/app`、`packages/web/src/shells` | +| Auth | 登录、会话门禁、认证状态 | `packages/web/src/features/auth`、`packages/server/src/auth` | +| Welcome | 欢迎页、打开工作区入口、设置入口 | `packages/web/src/features/welcome` | +| Workspace | 工作区总入口、active workspace、加载/错误/空态 | `packages/web/src/features/workspace`、`packages/server/src/commands/workspace.ts` | +| Workspace Desktop | 桌面工作区布局、侧栏、主区、底部终端 | `packages/web/src/features/workspace/views/desktop` | +| Workspace Mobile | 移动端 Dock、Sheet、Drawer、移动端工作区状态 | `packages/web/src/features/workspace/views/mobile` | +| Workspace Tabs / Layout | workspace tab、布局持久化、focus/fullscreen、最后查看目标 | `packages/web/src/features/workspace/actions/use-workspace-ui-state-persistence.ts`、`packages/web/src/features/workspace/actions/use-workspace-layout-actions.ts` | +| Agent Sessions | Agent 会话创建、运行态、历史、metadata | `packages/web/src/features/agent-panes`、`packages/server/src/commands/session.ts` | +| Agent Panes | Agent pane 布局、pane card、draft launcher、pane navigation | `packages/web/src/features/agent-panes` | +| Agent Instructions | Agent 指令生成、读取、编辑、token 趋势 | `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts`、`packages/server/src/agent-instructions` | +| Providers | Provider 配置、切换、运行边界、自定义 provider | `packages/web/src/features/agent-providers`、`packages/providers/src`、`packages/server/src/provider-runtime` | +| Supervisor | Supervisor 列表、目标、详情、移动端 Sheet | `packages/web/src/features/supervisor`、`packages/server/src/supervisor` | +| Files | 文件树、刷新、上下文菜单、上传、打开文件 | `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx`、`packages/server/src/commands/file.ts` | +| Editor Preview | 文本编辑、图片/Markdown/HTML 预览、Diff viewer | `packages/web/src/features/code-editor`、`packages/server/src/preview` | +| Search / Quick Open | 搜索、快速打开、搜索预览 | `packages/web/src/features/quick-open`、`packages/web/src/features/workspace/views/shared/search-panel.tsx` | +| Git | Git 状态、Diff、commit、branch、push/pull | `packages/web/src/features/workspace/actions/use-git-actions.ts`、`packages/server/src/commands/git.ts` | +| Worktrees | Worktree 列表、详情、管理入口 | `packages/web/src/features/workspace/views/shared/worktree-*`、`packages/server/src/commands/worktree.ts` | +| Terminal | shell terminal、agent terminal、多终端、恢复、上传 | `packages/web/src/features/terminal-panel`、`packages/server/src/terminal` | +| Settings | 设置页、provider 设置、外观、快捷键、监控设置、关于 | `packages/web/src/features/settings`、`packages/server/src/commands/settings.ts` | +| Diagnostics | 系统依赖、诊断页、安装流程 | `packages/web/src/features/diagnostics`、`packages/server/src/commands/diagnostics.ts` | +| Monitoring | 运行监控、指标展示、监控设置 | `packages/web/src/features/monitoring`、`packages/server/src/monitoring` | +| Work Analysis | 工作分析、时间范围、归因、趋势、导出 | `packages/web/src/features/work-analysis`、`packages/server/src/work-analysis` | +| Skills | skills 面板、挂载目录、归因和管理 | `packages/web/src/features/workspace/actions/use-skills-panel.ts`、`packages/server/src/skills` | +| Updates | 更新检查、更新提示、footer update rail | `packages/web/src/features/updates`、`packages/server/src/update` | +| Notifications | Toast、系统通知、会话完成通知 | `packages/web/src/features/notifications`、`packages/web/src/components/ui/toast` | +| Command Palette | 命令面板、命令入口、键盘交互 | `packages/web/src/features/command-palette` | +| Shortcuts | 全局快捷键、工作区导航快捷键、设置页快捷键展示 | `packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts`、`packages/web/src/features/settings/components/shortcuts-settings.tsx` | +| UI Components | 可复用 UI 原语和组件库状态 | `packages/web/src/components/ui` | + +模块边界不是永久固定的。后续盘点发现某个模块过大时,应拆分为更小的文档,但功能 ID 要保持稳定。 + +## 5. 功能状态 + +每个功能点必须标记一个状态: + +| 状态 | 定义 | +| --- | --- | +| `Implemented` | 代码已接线,用户可达,可按验收标准验证。 | +| `Partial` | 有部分代码或 UI,但流程、状态、错误处理或验收路径不完整。 | +| `Internal` | 内部能力存在,但没有稳定用户入口,或只被其他功能间接使用。 | +| `Deprecated` | 代码可能仍存在,但产品上不再承诺或不建议使用。 | +| `Planned` | 计划做,但当前代码没有实现。 | +| `Removed` | 曾经存在或曾被文档记录,但当前代码已移除。 | + +只有 `Implemented` 和 `Partial` 可以写入当前功能规格的主流程。`Planned` 必须明确标注,不得混入已实现能力。 + +## 6. 功能 ID 规则 + +功能 ID 用模块前缀加三位数字,稳定后不要随意修改。 + +建议前缀: + +| 前缀 | 模块 | +| --- | --- | +| `APP` | App Shell | +| `AUTH` | Auth | +| `WELCOME` | Welcome | +| `WS` | Workspace | +| `WSD` | Workspace Desktop | +| `WSM` | Workspace Mobile | +| `WSL` | Workspace Tabs / Layout | +| `SESSION` | Agent Sessions | +| `PANE` | Agent Panes | +| `INSTR` | Agent Instructions | +| `PROVIDER` | Providers | +| `SUP` | Supervisor | +| `FILE` | Files | +| `EDITOR` | Editor Preview | +| `SEARCH` | Search / Quick Open | +| `GIT` | Git | +| `WT` | Worktrees | +| `TERM` | Terminal | +| `SETTINGS` | Settings | +| `DIAG` | Diagnostics | +| `MON` | Monitoring | +| `WA` | Work Analysis | +| `SKILL` | Skills | +| `UPDATE` | Updates | +| `NOTIFY` | Notifications | +| `CMD` | Command Palette | +| `SHORTCUT` | Shortcuts | +| `UI` | UI Components | + +示例: + +```text +### WS-001 打开工作区 +### SESSION-004 恢复 Agent 会话 +### GIT-006 查看文件 Diff +``` + +如果功能移动到另一个模块,旧 ID 保留,并在新位置标注迁移说明。 + +## 7. 模块文档模板 + +每个 `modules/*.zh-CN.md` 使用以下结构: + +```text +# 模块名 + +## 1. 模块范围 + +覆盖: +- workspace 列表、打开、关闭。 + +不覆盖: +- Git、终端和文件编辑细节。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Workspace launch modal | Both | 浏览目录并打开 workspace。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WS-004 | 打开 workspace | Implemented | `packages/server/src/commands/workspace.ts` | `packages/server/src/__tests__/workspace-commands.test.ts` | + +## 4. 功能点规格 + +### WS-004 打开 workspace + +状态:`Implemented` + +用户行为: +- 用户选择目录并点击打开。 + +系统响应: +- 前端调用 `workspace.open`,服务端打开目录并返回 workspace。 + +状态与边界: +- Loading:打开请求处理中。 +- Empty:没有可打开 workspace 时展示空态。 +- Success:active workspace 切换到打开结果。 +- Error:打开失败时展示诊断或错误反馈。 + +桌面端差异: +- 桌面端在 workspace tab 中显示新 workspace。 + +移动端差异: +- 移动端进入移动工作区视图。 + +数据与命令: +- Frontend:`packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- WebSocket command:`workspace.open` +- Server handler:`packages/server/src/commands/workspace.ts` +- Core / provider 类型:`packages/core/src/domain/types.ts` + +验收标准: +- Given 启动器中已选择有效路径 +- When 用户确认打开 +- Then active workspace 切换为打开结果 + +代码索引: +- `packages/server/src/commands/workspace.ts` + +测试线索: +- `packages/server/src/__tests__/workspace-commands.test.ts` +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.test.tsx` + +## 5. 模块级验收清单 + +- [ ] 能打开一个有效目录作为 workspace。 +- [ ] 打开失败时有错误反馈。 + +## 6. 未确认项 + +- workspace history 的 UI 排序规则需补充更多代码证据。 +``` + +未确认项必须说明缺少哪类证据。不要用英文占位词代替问题描述。 + +## 8. 流程文档模板 + +每个 `flows/*.zh-CN.md` 用于描述跨模块路径: + +```text +# 流程名 + +## 1. 流程目标 + +## 2. 参与模块 + +## 3. 前置条件 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 用户选择目录 | 系统打开 workspace 并进入工作区 | `WS-004` | + +## 5. 分支与错误路径 + +## 6. 验收标准 + +## 7. 自动化测试建议 +``` + +流程文档不重复模块规格细节,只引用功能 ID。 + +## 9. 验收写法 + +验收标准优先使用 Given / When / Then: + +```text +验收标准: +- Given 当前没有打开的 workspace +- When 用户从欢迎页选择一个有效目录 +- Then 应用进入工作区页 +- And 顶部工作区栏显示该 workspace +- And 刷新页面后仍能恢复该 workspace +``` + +每个功能点至少要有一条可手工验证的验收标准。适合自动化的场景再补充测试建议。 + +验收标准要避免以下写法: + +- “体验正常” +- “逻辑正确” +- “展示合理” +- “和以前一样” +- “参考旧 PRD” + +## 10. 盘点顺序 + +全量盘点按三轮推进: + +1. 模块索引轮:为所有模块建立功能 ID、功能名、状态、代码入口和初始验收入口。 +2. 功能规格轮:补齐用户行为、系统响应、状态、边界、数据与命令。 +3. 验收清单轮:从功能 ID 反向生成模块验收、跨模块流程验收和冒烟清单。 + +第一轮不要追求完整叙述,重点是覆盖面和代码证据。第二轮再补细节。第三轮再生成测试用例。 + +## 11. 维护规则 + +- 修改功能行为时,同步更新对应模块 spec。 +- 新增功能时,先分配功能 ID,再补代码索引和验收标准。 +- 删除或废弃功能时,不删除 ID,改状态并说明当前代码状态。 +- 如果只存在组件代码但没有用户入口,标记为 `Internal` 或 `Partial`。 +- 如果只有旧文档描述但当前代码没有实现,标记为 `Planned`、`Deprecated` 或 `Removed`,不得写成 `Implemented`。 +- 模块文档中不得大段复制旧 PRD 或旧 specs。 +- 验收标准应能被人工执行,也应尽量能转成自动化测试。 diff --git a/docs/product-spec/acceptance/desktop.zh-CN.md b/docs/product-spec/acceptance/desktop.zh-CN.md new file mode 100644 index 000000000..3371db298 --- /dev/null +++ b/docs/product-spec/acceptance/desktop.zh-CN.md @@ -0,0 +1,26 @@ +# Desktop Acceptance + +> 第一轮验收索引。本文只记录桌面端需要覆盖的模块级验收入口。 + +## 1. 目标 + +验证宽屏桌面工作台的多区域布局、键盘入口、文件/Git/终端/agent 工作流。 + +## 2. 验收清单 + +| ID | 验收项 | 关联功能 ID | 建议方式 | +| --- | --- | --- | --- | +| DESKTOP-001 | 桌面 shell 渲染 | `APP-003` | 组件测试 / e2e | +| DESKTOP-002 | 桌面 workspace 多区域布局 | `WSD-001`、`WSD-003` | e2e / 截图 | +| DESKTOP-003 | workspace tab 切换 | `WSL-001` | 组件测试 / e2e | +| DESKTOP-004 | session mini map 展示 | `WSL-002` | 组件测试 | +| DESKTOP-005 | activity bar 切换面板 | `WSD-002` | e2e / 手工 | +| DESKTOP-006 | agent panes 渲染和 draft launcher | `PANE-001`、`PANE-004` | 组件测试 / e2e | +| DESKTOP-007 | Git panel 操作 | `GIT-001` 到 `GIT-006` | 单测 / 手工 | +| DESKTOP-008 | Terminal panel 操作 | `TERM-001` 到 `TERM-006` | 单测 / e2e | +| DESKTOP-009 | 快捷键和命令面板 | `SHORTCUT-001`、`CMD-001` | 组件测试 / 手工 | +| DESKTOP-010 | 全屏和布局持久化 | `WSL-003`、`WSL-005` | 组件测试 / 手工 | + +## 3. 未确认项 + +- 桌面端截图验收标准需在 UI 规格轮补充。 diff --git a/docs/product-spec/acceptance/mobile.zh-CN.md b/docs/product-spec/acceptance/mobile.zh-CN.md new file mode 100644 index 000000000..7f4944535 --- /dev/null +++ b/docs/product-spec/acceptance/mobile.zh-CN.md @@ -0,0 +1,26 @@ +# Mobile Acceptance + +> 第一轮验收索引。本文只记录移动端需要覆盖的模块级验收入口。 + +## 1. 目标 + +验证移动端 Dock / Sheet / Drawer 工作区体验和移动端终端输入能力。 + +## 2. 验收清单 + +| ID | 验收项 | 关联功能 ID | 建议方式 | +| --- | --- | --- | --- | +| MOBILE-001 | 移动 shell 渲染 | `APP-004` | 组件测试 / e2e | +| MOBILE-002 | 移动工作区整体渲染 | `WSM-001` | 组件测试 / e2e | +| MOBILE-003 | Mobile Dock 切换 | `WSM-003` | 组件测试 / 手工 | +| MOBILE-004 | Agent Sheet | `WSM-004` | 组件测试 | +| MOBILE-005 | Files Sheet | `WSM-005` | 组件测试 | +| MOBILE-006 | Workspace Drawer | `WSM-006` | 组件测试 | +| MOBILE-007 | Mobile Terminal soft keys | `TERM-009` | 组件测试 / 手工 | +| MOBILE-008 | Mobile long press copy line | `TERM-010` | 单测 / 手工 | +| MOBILE-009 | Mobile Supervisor Sheet | `SUP-008` | 组件测试 | +| MOBILE-010 | 移动端连接状态 | `APP-005`、`WSM-002` | 手工 | + +## 3. 未确认项 + +- 真实移动浏览器软键盘和 visual viewport 行为需设备验收。 diff --git a/docs/product-spec/acceptance/server-commands.zh-CN.md b/docs/product-spec/acceptance/server-commands.zh-CN.md new file mode 100644 index 000000000..5ac56433c --- /dev/null +++ b/docs/product-spec/acceptance/server-commands.zh-CN.md @@ -0,0 +1,35 @@ +# Server Commands Acceptance + +> 第一轮验收索引。本文按 command 域记录 server 层验收入口。 + +## 1. 目标 + +验证 WebSocket command 分发、schema 校验、handler 行为和错误格式。 + +## 2. Command 域清单 + +| ID | Command 域 | 关联模块 | 主要测试入口 | +| --- | --- | --- | --- | +| SERVER-001 | activation / connection / fencing | App Shell | `activation-commands.test.ts`、`fencing-commands.test.ts`、`dispatch.test.ts` | +| SERVER-002 | workspace / workspace activity | Workspace | `workspace-commands.test.ts`、`workspace-close-state-cleanup.test.ts` | +| SERVER-003 | file / fs | Files | `file-commands.test.ts`、`fs/*.test.ts` | +| SERVER-004 | git | Git | `git-commands.test.ts`、`git/*.test.ts` | +| SERVER-005 | session / terminal | Agent Sessions / Terminal | `session-commands.test.ts`、`terminal-commands.test.ts` | +| SERVER-006 | agent instructions / agent context | Agent Instructions | `agent-instructions-command.test.ts`、`agent-context-command.test.ts` | +| SERVER-007 | provider / custom provider | Providers | `provider-list.test.ts`、`custom-provider-command.test.ts` | +| SERVER-008 | supervisor | Supervisor | `supervisor-commands.test.ts` | +| SERVER-009 | worktree | Worktrees | `worktree-commands.test.ts` | +| SERVER-010 | settings / diagnostics / system deps | Settings / Diagnostics | `settings.test.ts`、`diagnostics-commands.test.ts`、`system-deps/commands.test.ts` | +| SERVER-011 | monitoring / work analysis | Monitoring / Work Analysis | `monitoring/commands.test.ts`、`work-analysis-commands.test.ts` | +| SERVER-012 | skills / updates / lsp | Skills / Updates / Editor Preview | `skills-command.test.ts`、`updates.test.ts`、`lsp-commands.test.ts` | + +## 3. 通用验收标准 + +- 未知 command 返回 `unknown_op`。 +- schema 校验失败返回 `validation_error`。 +- 非活动标签页调用受保护 command 返回 `activation_required`。 +- handler 抛出的业务错误应保留 code、message 和 details。 + +## 4. 未确认项 + +- 每个 command 的完整参数矩阵需在第二轮模块规格中补充。 diff --git a/docs/product-spec/acceptance/smoke.zh-CN.md b/docs/product-spec/acceptance/smoke.zh-CN.md new file mode 100644 index 000000000..293c5fc85 --- /dev/null +++ b/docs/product-spec/acceptance/smoke.zh-CN.md @@ -0,0 +1,26 @@ +# Smoke Acceptance + +> 第一轮验收索引。本文只记录可由模块功能 ID 反向生成的冒烟清单。 + +## 1. 目标 + +覆盖 Coder Studio 最小可用路径,验证应用能启动、打开 workspace、创建会话、编辑文件、查看 Git、使用终端。 + +## 2. 冒烟清单 + +| ID | 验收项 | 关联功能 ID | 建议方式 | +| --- | --- | --- | --- | +| SMOKE-001 | 应用启动并建立连接 | `APP-001`、`APP-002` | e2e / 手工 | +| SMOKE-002 | 认证门禁正确放行或拦截 | `AUTH-001`、`AUTH-002` | 单测 / e2e | +| SMOKE-003 | 打开 workspace | `WELCOME-002`、`WS-002`、`WS-004` | e2e / 手工 | +| SMOKE-004 | 文件树加载并打开文件 | `FILE-001`、`FILE-002` | e2e / 手工 | +| SMOKE-005 | 编辑并保存文本文件 | `EDITOR-002`、`FILE-003` | e2e / 手工 | +| SMOKE-006 | 创建 shell terminal 并输入命令 | `TERM-002`、`TERM-003` | e2e / 手工 | +| SMOKE-007 | 创建 Agent session | `PANE-004`、`SESSION-001` | e2e / 手工 | +| SMOKE-008 | 查看 Git 状态和 diff | `GIT-001`、`GIT-002` | e2e / 手工 | +| SMOKE-009 | 打开设置页并读取配置 | `SETTINGS-001`、`SETTINGS-003` | 单测 / 手工 | +| SMOKE-010 | 连接断开时显示状态 | `APP-005` | e2e / 手工 | + +## 3. 未确认项 + +- Provider 真实运行冒烟需要稳定测试 provider 或 mock provider。 diff --git a/docs/product-spec/flows/file-edit-preview.zh-CN.md b/docs/product-spec/flows/file-edit-preview.zh-CN.md new file mode 100644 index 000000000..2136a1d68 --- /dev/null +++ b/docs/product-spec/flows/file-edit-preview.zh-CN.md @@ -0,0 +1,49 @@ +# File Edit Preview Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述用户从文件树、搜索或 Git diff 打开文件,并进入编辑器或预览的流程。 + +## 2. 参与模块 + +- Files:`FILE-001`、`FILE-002`、`FILE-003` +- Search / Quick Open:`SEARCH-001`、`SEARCH-004` +- Editor Preview:`EDITOR-001`、`EDITOR-002`、`EDITOR-006`、`EDITOR-007` +- Git:`GIT-002` + +## 3. 前置条件 + +- 已打开 workspace。 +- workspace 中存在可读取文件。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开 Files panel | 加载文件树 | `FILE-001` | +| 2 | 点击文本文件 | 读取文件并打开 editor | `FILE-002`、`EDITOR-001`、`EDITOR-002` | +| 3 | 修改文件内容 | editor 状态更新 | `EDITOR-002` | +| 4 | 保存文件 | 写入 server 文件系统 | `FILE-003` | + +## 5. 分支与错误路径 + +- 从 Quick Open 打开:关联 `SEARCH-001`。 +- 从搜索预览打开:关联 `SEARCH-004`。 +- 打开图片:关联 `EDITOR-007`。 +- 打开文档预览:关联 `EDITOR-006`。 +- 从 Git diff 打开:关联 `GIT-002`、`EDITOR-003`。 + +## 6. 验收标准 + +- Given workspace 中存在文本文件 +- When 用户从文件树打开并编辑保存 +- Then 文件内容应被写入 +- And 重新打开该文件能看到保存后的内容 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/workspace/actions/use-open-workspace-file.test.tsx`。 +- 覆盖 `packages/web/src/features/code-editor/views/shared/editor-surface.test.tsx`。 +- server 层覆盖 `packages/server/src/__tests__/file-commands.test.ts`。 diff --git a/docs/product-spec/flows/git-change-review.zh-CN.md b/docs/product-spec/flows/git-change-review.zh-CN.md new file mode 100644 index 000000000..68f32d58d --- /dev/null +++ b/docs/product-spec/flows/git-change-review.zh-CN.md @@ -0,0 +1,47 @@ +# Git Change Review Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述用户查看 Git 变更、查看 diff、stage、commit 和同步的流程。 + +## 2. 参与模块 + +- Git:`GIT-001` 到 `GIT-010` +- Editor Preview:`EDITOR-003`、`EDITOR-008` +- Files:`FILE-002` + +## 3. 前置条件 + +- workspace 是 Git 仓库。 +- 存在至少一个文件变更。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开 Git panel | 展示 Git 状态 | `GIT-001` | +| 2 | 点击 changed file | 展示 diff | `GIT-002` | +| 3 | Stage 文件 | staged 状态更新 | `GIT-003` | +| 4 | 输入 commit message 并提交 | 创建 commit | `GIT-005` | +| 5 | Push 或 Pull | 同步远端 | `GIT-006` | + +## 5. 分支与错误路径 + +- Discard 文件:关联 `GIT-004`。 +- 切换分支:关联 `GIT-008`。 +- 查看历史 commit:关联 `GIT-009`、`GIT-010`。 +- 图片 diff:关联 `EDITOR-008`。 + +## 6. 验收标准 + +- Given Git 仓库中有一个修改文件 +- When 用户 stage 并提交该文件 +- Then Git 状态中该文件不再显示为未提交变更 +- And Git log 中出现新 commit + +## 7. 自动化测试建议 + +- 覆盖 `packages/server/src/__tests__/git-commands.test.ts`。 +- 覆盖 `packages/web/src/features/workspace/actions/use-git-actions.test.tsx`。 diff --git a/docs/product-spec/flows/open-workspace.zh-CN.md b/docs/product-spec/flows/open-workspace.zh-CN.md new file mode 100644 index 000000000..840e9cd3a --- /dev/null +++ b/docs/product-spec/flows/open-workspace.zh-CN.md @@ -0,0 +1,48 @@ +# Open Workspace Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述用户从欢迎页或工作区入口浏览目录、创建目录、打开 workspace 并进入工作区的流程。 + +## 2. 参与模块 + +- Welcome:`WELCOME-002` +- Workspace:`WS-002`、`WS-003`、`WS-004`、`WS-008` +- Workspace Tabs / Layout:`WSL-006` +- Files:`FILE-001` + +## 3. 前置条件 + +- 应用已连接 server。 +- 用户已通过认证或认证未开启。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 点击打开工作区 | 打开 workspace launch modal | `WELCOME-002` | +| 2 | 浏览目录 | 返回当前路径、父路径、目录列表和根路径 | `WS-002` | +| 3 | 选择目录并确认 | server 打开 workspace | `WS-004` | +| 4 | 打开成功 | workspace 成为 active,并进入 `/workspace` | `WS-004`、`WSL-006` | +| 5 | 工作区加载 | 文件树和相关状态开始加载 | `FILE-001` | + +## 5. 分支与错误路径 + +- 用户在启动器中创建目录:关联 `WS-003`。 +- 打开最近 workspace:关联 `WS-008`。 +- 路径不可访问:启动器展示错误。 +- 已打开同一路径:应切换或复用已有 workspace,具体行为在第二轮确认。 + +## 6. 验收标准 + +- Given 当前没有打开 workspace +- When 用户选择一个有效目录并确认 +- Then 应用进入工作区 +- And active workspace 指向该目录 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/workspace/actions/use-workspace-launch-actions.test.tsx`。 +- 增加 e2e:从欢迎页打开 workspace。 diff --git a/docs/product-spec/flows/provider-configuration.zh-CN.md b/docs/product-spec/flows/provider-configuration.zh-CN.md new file mode 100644 index 000000000..a7a90f167 --- /dev/null +++ b/docs/product-spec/flows/provider-configuration.zh-CN.md @@ -0,0 +1,47 @@ +# Provider Configuration Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述用户在设置页查看 provider、编辑配置、检查 runtime status,并在 Agent pane 中使用 provider 的流程。 + +## 2. 参与模块 + +- Providers:`PROVIDER-001`、`PROVIDER-002`、`PROVIDER-003`、`PROVIDER-009`、`PROVIDER-010` +- Settings:`SETTINGS-004`、`SETTINGS-006` +- Diagnostics:`DIAG-003`、`DIAG-004` +- Agent Panes:`PANE-008` +- Agent Sessions:`SESSION-001` + +## 3. 前置条件 + +- 应用已连接 server。 +- 至少存在一个内置 provider 定义。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开 Provider Settings | 展示 provider 列表和配置状态 | `PROVIDER-001`、`PROVIDER-010` | +| 2 | 查看 runtime status | 返回 provider 可用性 | `PROVIDER-002` | +| 3 | 编辑配置文件 | 读取或写入配置 | `SETTINGS-004` | +| 4 | 返回 workspace 启动 agent | 使用 provider 创建 session | `PANE-008`、`SESSION-001` | + +## 5. 分支与错误路径 + +- Provider 未安装:关联 `PROVIDER-003` 或 `DIAG-004`。 +- 使用自定义 provider:关联 `PROVIDER-009`。 +- 配置文件写入失败:关联 `SETTINGS-004`。 + +## 6. 验收标准 + +- Given provider 配置有效 +- When 用户从 draft launcher 创建 session +- Then 创建请求应使用该 provider +- And session 出现在工作区中 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/settings/components/provider-settings.test.tsx`。 +- 覆盖 `packages/server/src/__tests__/provider-runtime/runtime-status.test.ts`。 diff --git a/docs/product-spec/flows/start-agent-session.zh-CN.md b/docs/product-spec/flows/start-agent-session.zh-CN.md new file mode 100644 index 000000000..790ac37ee --- /dev/null +++ b/docs/product-spec/flows/start-agent-session.zh-CN.md @@ -0,0 +1,50 @@ +# Start Agent Session Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述用户从 Agent pane 选择 provider、创建 session、查看输出并继续输入 prompt 的流程。 + +## 2. 参与模块 + +- Agent Panes:`PANE-001`、`PANE-004`、`PANE-008` +- Providers:`PROVIDER-001`、`PROVIDER-002` +- Agent Sessions:`SESSION-001`、`SESSION-002`、`SESSION-005` +- Terminal:`TERM-003`、`TERM-006` + +## 3. 前置条件 + +- 已打开 workspace。 +- 至少一个 provider 可用或可配置。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开 Agent pane | 展示 draft launcher 或 session card | `PANE-001`、`PANE-004` | +| 2 | 选择 provider 并提交 prompt | 创建 session | `PANE-008`、`SESSION-001` | +| 3 | session 创建成功 | session 出现在 pane 中 | `SESSION-002` | +| 4 | provider 输出内容 | terminal 或 session card 展示输出 | `TERM-006` | +| 5 | 用户继续输入 prompt | 通过 terminal input 发送内容 | `SESSION-005`、`TERM-003` | + +## 5. 分支与错误路径 + +- Provider 不可用:关联 `PROVIDER-002`。 +- 用户停止 session:关联 `SESSION-003`。 +- 用户关闭或移除 session:关联 `SESSION-004`。 +- 终端 replay 失败:关联 `TERM-006`。 + +## 6. 验收标准 + +- Given 已打开 workspace 且 provider 可用 +- When 用户提交一个 agent prompt +- Then 系统创建 session +- And session pane 显示该 session +- And 用户能看到运行输出或状态 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx`。 +- 覆盖 `packages/server/src/__tests__/session-commands.test.ts`。 +- e2e 使用 mock provider 或可控 provider runtime。 diff --git a/docs/product-spec/flows/startup-and-auth.zh-CN.md b/docs/product-spec/flows/startup-and-auth.zh-CN.md new file mode 100644 index 000000000..a951dacae --- /dev/null +++ b/docs/product-spec/flows/startup-and-auth.zh-CN.md @@ -0,0 +1,53 @@ +# Startup and Auth Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述应用启动后从认证检查、WebSocket 连接、workspace 恢复到默认页面选择的主路径。 + +## 2. 参与模块 + +- App Shell:`APP-001`、`APP-002`、`APP-005`、`APP-006` +- Auth:`AUTH-001`、`AUTH-002`、`AUTH-003` +- Workspace:`WS-001`、`WS-007` +- Welcome:`WELCOME-001` + +## 3. 前置条件 + +- 服务端已启动。 +- 浏览器可访问 web 前端。 +- 认证可能开启或关闭。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开应用 | 初始化 providers 和 session gate | `APP-001`、`AUTH-001` | +| 2 | 认证已通过或未开启认证 | 建立 WebSocket 连接 | `AUTH-003`、`APP-002` | +| 3 | 连接成功 | 拉取 workspace 列表 | `WS-001` | +| 4 | 有 workspace | 进入或停留在工作区 | `WS-007` | +| 5 | 无 workspace | 展示欢迎页 | `WELCOME-001` | + +## 5. 分支与错误路径 + +- 未认证:进入登录页,关联 `AUTH-002`。 +- WebSocket 断开:展示连接状态横幅,关联 `APP-005`。 +- 非活动标签页:server dispatch 返回 `activation_required`,关联 `APP-006`。 +- workspace 拉取失败:工作区页展示错误态,关联 `WS-007`。 + +## 6. 验收标准 + +- Given 服务端未开启认证 +- When 用户打开应用 +- Then 应用应建立连接并进入欢迎页或工作区 + +- Given 服务端开启认证且用户未登录 +- When 用户打开应用 +- Then 应用应展示登录页 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/auth/session-gate.test.tsx`。 +- 覆盖 `packages/web/src/app/providers.test.tsx`。 +- 增加 e2e 冒烟:未认证、已认证、有 workspace、无 workspace 四种入口。 diff --git a/docs/product-spec/flows/terminal-recovery.zh-CN.md b/docs/product-spec/flows/terminal-recovery.zh-CN.md new file mode 100644 index 000000000..54332c597 --- /dev/null +++ b/docs/product-spec/flows/terminal-recovery.zh-CN.md @@ -0,0 +1,46 @@ +# Terminal Recovery Flow + +> 第一轮流程索引。本文只记录跨模块路径、关联功能 ID 和验收入口。 + +## 1. 流程目标 + +描述终端创建、输出、快照、replay 和页面恢复流程。 + +## 2. 参与模块 + +- Terminal:`TERM-001` 到 `TERM-008` +- App Shell:`APP-002` +- Workspace:`WS-004` + +## 3. 前置条件 + +- 已打开 workspace。 +- WebSocket 连接可用。 + +## 4. 主路径 + +| 步骤 | 用户行为 | 系统响应 | 关联功能 ID | +| --- | --- | --- | --- | +| 1 | 打开 terminal panel | 拉取 terminal 列表 | `TERM-001` | +| 2 | 创建 shell terminal | server 创建 PTY 并广播 created | `TERM-002` | +| 3 | 输入命令 | terminal 输出更新 | `TERM-003` | +| 4 | 刷新页面或重连 | 通过 snapshot/replay 恢复输出 | `TERM-006`、`APP-002` | + +## 5. 分支与错误路径 + +- terminal resize:关联 `TERM-004`。 +- terminal close:关联 `TERM-005`。 +- theme sync:关联 `TERM-008`。 +- recovery coordinator:关联 `TERM-007`。 + +## 6. 验收标准 + +- Given 已创建 shell terminal 并有输出 +- When 用户刷新页面 +- Then terminal panel 应恢复 terminal 列表 +- And 可看到 replay 后的历史输出 + +## 7. 自动化测试建议 + +- 覆盖 `packages/web/src/features/terminal-panel/__tests__/recovery-coordinator.test.ts`。 +- 覆盖 `packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts`。 diff --git a/docs/product-spec/modules/agent-instructions.zh-CN.md b/docs/product-spec/modules/agent-instructions.zh-CN.md new file mode 100644 index 000000000..c36d61ce1 --- /dev/null +++ b/docs/product-spec/modules/agent-instructions.zh-CN.md @@ -0,0 +1,267 @@ +# Agent Instructions + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- workspace agent instructions 读取、生成、写入、健康状态。 +- system agent instructions 读取、写入和状态。 +- token 趋势展示。 + +不覆盖: +- Agent session 创建和 provider 运行。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Workspace shared panel | Desktop | Agent instructions section。 | +| Generate dialog | Desktop | 触发生成或重生成。 | +| System agent section | Desktop | 管理系统级 agent instructions。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| INSTR-001 | 读取 workspace agent instructions | Implemented | `agentInstructions.read`、`use-agent-instructions-actions.ts` | `packages/server/src/__tests__/agent-instructions-command.test.ts` | +| INSTR-002 | 写入 workspace agent instructions | Implemented | `agentInstructions.write` | `packages/server/src/__tests__/agent-instructions-command.test.ts` | +| INSTR-003 | 生成 agent instructions | Implemented | `agentInstructions.generate`、`packages/server/src/agent-instructions/agent-generator.ts` | `packages/server/src/__tests__/agent-instructions/generator.test.ts` | +| INSTR-004 | regenerate / generate by agent | Implemented | `agentInstructions.regenerate`、`generateByAgent`、`generateAndWriteByAgent` | `packages/server/src/__tests__/agent-instructions-command.test.ts` | +| INSTR-005 | instructions status / health | Implemented | `agentInstructions.status`、`agentInstructions.health` | `packages/server/src/__tests__/agent-instructions/health.test.ts` | +| INSTR-006 | attach instructions to session | Internal | `agentInstructions.attachToSession` | `packages/server/src/__tests__/agent-instructions-command.test.ts` | +| INSTR-007 | system instructions read/write/status | Implemented | `agentInstructions.system.*`、`agent-instructions-section.tsx` | `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` | +| INSTR-008 | token 趋势展示 | Implemented | `agent-instructions-token-trend.tsx`、`agent-token-trend-section.tsx` | `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx` | + +## 4. 模块级验收线索 + +- 读取已有 instructions 时应显示内容和状态。 +- 生成成功后应能写入并重新读取。 +- system instructions 修改后应保持独立于 workspace instructions。 + +## 5. 功能点规格 + +### INSTR-001 读取 workspace agent instructions + +状态:`Implemented` + +用户行为: +- 用户打开 workspace shared panel 中的 Agent Instructions 区域,或点击查看自定义 instructions。 + +系统响应: +- 前端调用 `agentInstructions.status` 获取文档存在状态、路径和 system 状态。 +- 需要读取内容时调用 `agentInstructions.read`。 +- 服务端先校验 workspace,再读取 `.coder-studio/agent.md`。 +- 前端监听 workspace 的 `fs.dirty` 事件,文件变化后刷新 status。 + +状态与边界: +- Exists:显示文档路径和可查看/编辑入口。 +- Missing:允许创建草稿或生成。 +- Workspace missing:服务端按 workspace 查询失败返回错误。 +- Dirty:写入或外部修改后通过 dirty 事件触发刷新。 + +验收标准: +- Given workspace 中存在 `.coder-studio/agent.md` +- When 用户打开 Agent Instructions 区域 +- Then UI 显示该文档存在 +- And 查看操作打开该路径 + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` +- `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` + +### INSTR-002 写入 workspace agent instructions + +状态:`Implemented` + +用户行为: +- 用户保存 workspace agent instructions 内容。 + +系统响应: +- 前端或编辑器调用 `agentInstructions.write`,传入 content,以及可选 overwrite/baseHash。 +- 服务端写入 `.coder-studio/agent.md`。 +- 写入成功后发出 `fs.dirty`,reason 为 `file_content`。 + +状态与边界: +- Success:返回写入后的 document。 +- Conflict:baseHash 不匹配时由写入 helper 返回冲突或失败。 +- Overwrite:设置 overwrite 时允许覆盖当前文档。 + +验收标准: +- Given workspace 没有自定义 agent instructions +- When 写入内容 `# Agent Instructions` +- Then `.coder-studio/agent.md` 被创建 +- And workspace 收到 `fs.dirty` 事件 + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` + +### INSTR-003 生成 agent instructions 草稿 + +状态:`Implemented` + +用户行为: +- 用户请求生成 workspace agent instructions 草稿,但不直接覆盖自定义文件。 + +系统响应: +- `agentInstructions.generate` 基于 workspace intelligence 生成内容。 +- 返回路径为默认 agent instructions path,`exists` 为 false,包含 content。 +- 该命令不写入文件。 + +状态与边界: +- Success:返回可供预览或后续保存的内容。 +- Workspace missing:workspace 不存在时失败。 +- Source of truth:生成内容来自当前 workspace 代码分析,不应依赖旧 PRD。 + +验收标准: +- Given workspace 可读取 +- When 调用 `agentInstructions.generate` +- Then 返回生成内容 +- And 不创建或覆盖 `.coder-studio/agent.md` + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` +- `packages/server/src/agent-instructions/prompt.ts` +- `packages/server/src/workspace/intelligence.ts` + +### INSTR-004 使用 agent 生成并写入 + +状态:`Implemented` + +用户行为: +- 用户打开生成弹窗,选择支持生成的 provider,可选填写 model,然后提交。 + +系统响应: +- 前端先加载 `provider.list` 和 `provider.runtimeStatus`。 +- 仅展示支持 agent instructions generation 且 runtime available 的 provider。 +- 提交后调用 `agentInstructions.generateAndWriteByAgent`,超时为 120 秒。 +- 服务端通过 `AgentInstructionsGenerator` 运行 provider,生成内容后覆盖写入 workspace 自定义文档。 +- 成功后刷新 status。 + +状态与边界: +- No provider:没有可用生成 provider 时显示无 provider 错误。 +- Timeout:生成超时映射为生成失败提示。 +- No output:生成无输出时映射为生成失败提示。 +- Runtime:不可用 provider 不应出现在可选列表中。 + +验收标准: +- Given Codex 支持生成且 runtime available +- When 用户选择 Codex 并提交生成 +- Then 前端调用 `agentInstructions.generateAndWriteByAgent` +- And 成功后 `.coder-studio/agent.md` 更新为生成内容 +- And Agent Instructions status 被刷新 + +代码索引: +- `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` +- `packages/server/src/commands/agent-instructions.ts` +- `packages/server/src/agent-instructions/agent-generator.ts` + +### INSTR-005 instructions status / health + +状态:`Implemented` + +用户行为: +- 用户查看 workspace agent instructions 的存在状态和健康检查结果。 + +系统响应: +- `agentInstructions.status` 返回 project/system/document 三组状态。 +- `agentInstructions.health` 读取 workspace 自定义文档并运行 markdown 健康评估。 +- UI 可根据 status 决定是否允许 view、edit、generate 或 attach。 + +状态与边界: +- Project:当前 workspace 自定义文档存在与否。 +- System:按 provider 返回系统级 instructions 状态。 +- Health:评估的是当前自定义文档内容。 + +验收标准: +- Given workspace 自定义文档存在 +- When 调用 `agentInstructions.health` +- Then 返回该 markdown 的健康评估结果 + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` +- `packages/server/src/agent-instructions/health.ts` + +### INSTR-006 attach instructions to session + +状态:`Internal` + +用户行为: +- 用户或内部操作把当前有效 agent instructions 注入到正在运行的 session。 + +系统响应: +- `agentInstructions.attachToSession` 接收 workspaceId 和可选 sessionId。 +- sessionId 省略时使用 workspace UI state 的 active session。 +- 服务端校验 session 存在且处于可注入状态。 +- 解析有效 instructions 后,通过 session manager 向 session 发送输入。 + +状态与边界: +- Missing session:没有 active session 或 session 不存在时返回 `session_not_found`。 +- Non-injectable:目标 session 不可注入时返回 `inject_target_unavailable`。 +- Missing instructions:没有可用 instructions 时返回 `agent_instructions_missing`。 + +验收标准: +- Given workspace 有 active session 且存在有效 agent instructions +- When 调用 `agentInstructions.attachToSession` 且不传 sessionId +- Then 服务端向 active session 发送 instructions 内容 + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` +- `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` + +### INSTR-007 system instructions read/write/status + +状态:`Implemented` + +用户行为: +- 用户在 system agent section 中查看或编辑某个 provider 的系统级 agent instructions。 + +系统响应: +- `agentInstructions.system.status` 返回各 provider 系统文档状态。 +- `agentInstructions.system.read` 读取指定 provider 的系统文档。 +- `agentInstructions.system.write` 写入指定 provider 的系统文档,并支持 baseHash。 +- 前端编辑不存在的系统文档时,先在编辑器中创建 unsaved draft scaffold。 + +状态与边界: +- Editable:只有标记 editable 的 entry 才允许打开编辑。 +- Provider scoped:system instructions 按 providerId 独立。 +- Missing:不存在时可创建草稿,不应误写 workspace 自定义文档。 + +验收标准: +- Given Claude system instructions 不存在且 entry editable +- When 用户点击编辑 Claude system instructions +- Then 编辑器打开 Claude system path 的 unsaved draft +- And 保存后只影响 Claude system instructions + +代码索引: +- `packages/server/src/commands/agent-instructions.ts` +- `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` +- `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` + +### INSTR-008 token 趋势展示 + +状态:`Implemented` + +用户行为: +- 用户在 Agent Instructions 区域查看 token 趋势或摘要。 + +系统响应: +- 前端组件读取 agent token trend 数据并渲染趋势段。 +- 趋势展示独立于文档写入命令。 + +状态与边界: +- Empty:没有 token 数据时显示空态或不渲染趋势内容。 +- Display only:趋势组件不负责生成、读取或写入 instructions 文档。 + +验收标准: +- Given token trend 数据包含多个时间点 +- When Agent Instructions 区域渲染 +- Then 用户能看到趋势摘要 + +代码索引: +- `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx` +- `packages/web/src/features/workspace/views/shared/agent-token-trend-section.tsx` + +## 6. 未确认项 + +- `attachToSession` 的产品入口需在 session 规格轮确认。 diff --git a/docs/product-spec/modules/agent-panes.zh-CN.md b/docs/product-spec/modules/agent-panes.zh-CN.md new file mode 100644 index 000000000..8b69ef714 --- /dev/null +++ b/docs/product-spec/modules/agent-panes.zh-CN.md @@ -0,0 +1,43 @@ +# Agent Panes + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Agent pane 布局树、pane card、draft launcher。 +- pane 拖拽、导航、provider launcher。 + +不覆盖: +- session 生命周期和 provider 配置。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Agent pane 区域 | Both | 展示会话卡片和 draft launcher。 | +| Pane 拖拽 | Desktop | 调整或重排 pane。 | +| Provider launcher | Both | 从 draft pane 选择 provider 启动会话。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| PANE-001 | Agent panes 主入口渲染 | Implemented | `packages/web/src/features/agent-panes/index.tsx` | `packages/web/src/features/agent-panes/index.test.tsx` | +| PANE-002 | pane layout tree | Implemented | `packages/web/src/features/agent-panes/pane-layout-tree.ts` | `packages/web/src/features/agent-panes/pane-layout-tree.test.ts` | +| PANE-003 | pane navigation | Implemented | `packages/web/src/features/agent-panes/pane-navigation.ts` | `packages/web/src/features/agent-panes/pane-navigation.test.ts` | +| PANE-004 | draft launcher | Implemented | `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx` | `packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx` | +| PANE-005 | editor pane card | Implemented | `packages/web/src/features/agent-panes/views/shared/editor-pane-card.tsx` | `packages/web/src/features/agent-panes/views/shared/editor-pane-card.test.tsx` | +| PANE-006 | session card | Implemented | `packages/web/src/features/agent-panes/views/shared/session-card.tsx` | `packages/web/src/features/agent-panes/components/session-card.test.tsx` | +| PANE-007 | pane 拖拽控制 | Implemented | `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts` | `packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx` | +| PANE-008 | provider launcher hook | Implemented | `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` | `packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx` | + +## 4. 模块级验收线索 + +- 没有 session 时应能显示 draft launcher。 +- 创建或关闭 session 后 pane 状态应更新。 +- pane 拖拽不应丢失会话关联。 + +## 5. 未确认项 + +- 移动端是否支持同等 pane 拖拽能力需在移动端规格轮确认。 diff --git a/docs/product-spec/modules/agent-sessions.zh-CN.md b/docs/product-spec/modules/agent-sessions.zh-CN.md new file mode 100644 index 000000000..d28866e76 --- /dev/null +++ b/docs/product-spec/modules/agent-sessions.zh-CN.md @@ -0,0 +1,190 @@ +# Agent Sessions + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Agent session 创建、列表、停止、关闭、移除。 +- 会话 prompt 提交、session metadata、session review 和 analysis。 + +不覆盖: +- pane 布局,写入 Agent Panes。 +- provider 配置,写入 Providers。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Agent pane / draft launcher | Both | 选择 provider 并创建会话。 | +| Session card | Both | 停止、关闭、继续输入或查看状态。 | +| Agent terminal | Both | 通过 terminal 输入继续会话。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SESSION-001 | 创建 Agent session | Implemented | `packages/server/src/commands/session.ts`、`session.create` | `packages/server/src/__tests__/session-commands.test.ts` | +| SESSION-002 | 拉取 session 列表 | Implemented | `session.list`、`packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts` | `packages/server/src/__tests__/session-commands.test.ts` | +| SESSION-003 | 停止 session | Implemented | `packages/web/src/features/agent-panes/actions/use-session-actions.ts`、`session.stop` | `packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx` | +| SESSION-004 | 关闭或移除 session | Implemented | `use-session-actions.ts`、`session.close`、`session.remove` | `packages/server/src/__tests__/session-remove.test.ts` | +| SESSION-005 | 向 session terminal 提交 prompt | Implemented | `use-session-actions.ts`、`wsClient.sendTerminalInput` | `packages/web/src/features/agent-panes/actions/use-session-actions.test.tsx` | +| SESSION-006 | session metadata 读取 | Implemented | `packages/server/src/commands/session-metadata.ts`、`session.metadata.get` | `packages/server/src/__tests__/session-metadata-command.test.ts` | +| SESSION-007 | 添加 session 验证记录 | Internal | `session.verification.add` | `packages/server/src/__tests__/session-metadata-command.test.ts` | +| SESSION-008 | session review summary/diff | Internal | `packages/server/src/commands/session-review.ts` | `packages/server/src/__tests__/session-review-command.test.ts` | +| SESSION-009 | session analysis get/run | Internal | `session.analysis.get`、`session.analysis.run` | `packages/server/src/__tests__/session-analysis-commands.test.ts` | +| SESSION-010 | 会话恢复和 hydration | Implemented | `packages/server/src/session` | `packages/server/src/__tests__/session-hydrate-restart.test.ts` | + +## 4. 模块级验收线索 + +- 创建会话后列表中出现新 session,并能看到运行状态。 +- 停止运行中的 session 后状态应结束或进入可移除状态。 +- 关闭 session 不应破坏其他 workspace 的 session。 + +## 5. 功能点规格 + +### SESSION-001 创建 Agent session + +状态:`Implemented` + +用户行为: +- 用户从 draft launcher 选择 provider 启动 agent session。 + +系统响应: +- 前端先加载 `provider.runtimeStatus`。 +- 如果 provider 可用,前端调用 `session.create`,传入 `workspaceId`、`providerId` 和当前 terminal theme background。 +- 服务端校验 workspace 存在、provider 存在且 CLI 可用。 +- 创建前服务端同步 workspace agent instructions。 +- 创建成功后写入 session metadata,包括 provider、objective 和 baseline git head。 + +状态与边界: +- Loading:provider card 进入 loading。 +- Success:调用 `onSessionCreated`,pane 中出现新 session。 +- Error:workspace 不存在返回 `workspace_not_found`;provider 不存在返回 `unknown_provider`;CLI 不可用返回 `provider_cli_missing` 并带 missing commands。 +- Install:provider 不可用且支持自动安装时,前端先启动安装并轮询 install job。 + +验收标准: +- Given 已打开 workspace 且 provider runtime 可用 +- When 用户从 draft launcher 启动该 provider +- Then 服务端创建 session +- And 前端 pane 显示该 session +- And provider card 退出 loading 状态 + +代码索引: +- `packages/web/src/features/agent-panes/actions/use-provider-launcher.ts` +- `packages/server/src/commands/session.ts` + +### SESSION-002 拉取 session 列表 + +状态:`Implemented` + +用户行为: +- 用户进入 workspace 或切换 workspace。 + +系统响应: +- 前端按 workspace 读取会话列表。 +- 服务端 `session.list` 返回该 workspace 下的 sessions。 + +状态与边界: +- Empty:workspace 没有 session 时,Agent pane 应显示 draft launcher。 +- Success:返回的 session 用于渲染 session card 或 pane layout。 +- Error:拉取失败时不应影响 workspace 其他区域可用性。 + +验收标准: +- Given workspace 中已有两个 session +- When 用户进入该 workspace +- Then Agent pane 能展示这两个 session +- And 不展示其他 workspace 的 session + +代码索引: +- `packages/web/src/features/agent-panes/actions/use-workspace-sessions.ts` +- `packages/server/src/commands/session.ts` + +### SESSION-003 停止 session + +状态:`Implemented` + +用户行为: +- 用户点击运行中 session 的停止入口。 + +系统响应: +- 前端调用 `session.stop`。 +- 服务端调用 session manager 停止目标 session。 +- 前端失败时在控制台记录错误;具体 UI 错误反馈需后续确认。 + +状态与边界: +- Success:session 进入结束流程。 +- Error:停止失败时不移除 session。 + +验收标准: +- Given 一个运行中的 session +- When 用户触发停止 +- Then 前端发送 `session.stop` +- And session 不应被立即从列表中无条件删除 + +代码索引: +- `packages/web/src/features/agent-panes/actions/use-session-actions.ts` +- `packages/server/src/commands/session.ts` + +### SESSION-004 关闭或移除 session + +状态:`Implemented` + +用户行为: +- 用户关闭 session pane,或移除已结束 session。 + +系统响应: +- 如果 disposition 是 `remove`,前端直接调用 `session.close`。 +- 如果 session 已结束,前端调用 `session.remove`。 +- 如果 session 未结束,前端先调用 `session.stop`,轮询等待 ended,再调用 `session.remove`。 +- 服务端 `session.close` 会等待 session 结束,并按 pane disposition 更新 workspace pane layout。 + +状态与边界: +- Success:session 从 manager 和 metadata 中删除。 +- Timeout:前端最多等待 5 秒;服务端 close 也有 5 秒等待窗口。 +- Error:非 ended session 调用 `session.remove` 返回 `invalid_state`。 + +验收标准: +- Given 一个已结束 session +- When 用户关闭该 session +- Then 前端发送 `session.remove` +- And session 从列表中移除 + +- Given 一个运行中 session +- When 用户关闭该 session +- Then 系统先停止 session +- And 只有 session 结束后才移除 + +代码索引: +- `packages/web/src/features/agent-panes/actions/use-session-actions.ts` +- `packages/server/src/commands/session.ts` + +### SESSION-005 向 session terminal 提交 prompt + +状态:`Implemented` + +用户行为: +- 用户在 session 输入区提交 prompt。 + +系统响应: +- 前端 trim prompt。 +- prompt 为空或 WebSocket client 不存在时直接返回 false。 +- 有效 prompt 通过 `sendTerminalInput` 发送到 session terminal,activity 为 `submit`,submittedText 为原 prompt。 + +状态与边界: +- Success:返回 true。 +- Error:发送异常时记录 console error 并返回 false。 +- Empty:空 prompt 不发送。 + +验收标准: +- Given session terminal 可用 +- When 用户提交非空 prompt +- Then 前端向 terminal 发送以回车结尾的输入 +- And activity 标记为 `submit` + +代码索引: +- `packages/web/src/features/agent-panes/actions/use-session-actions.ts` + +## 6. 未确认项 + +- session review 和 analysis 的稳定用户入口需在 Work Analysis 或 Agent Context 规格轮确认。 diff --git a/docs/product-spec/modules/app-shell.zh-CN.md b/docs/product-spec/modules/app-shell.zh-CN.md new file mode 100644 index 000000000..ac6730799 --- /dev/null +++ b/docs/product-spec/modules/app-shell.zh-CN.md @@ -0,0 +1,45 @@ +# App Shell + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 应用级 providers、WebSocket 生命周期和全局连接态。 +- 桌面/移动 shell 选择。 +- 全局连接横幅、激活租约和非活动标签页保护。 + +不覆盖: +- 具体业务页面能力,分别写入对应模块。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 应用启动 | Both | 初始化 providers、认证门禁、WebSocket 连接和工作区数据。 | +| 桌面 shell | Desktop | 宽屏环境加载桌面壳层。 | +| 移动 shell | Mobile | 移动视口加载移动壳层。 | +| 连接状态横幅 | Both | 连接断开、重连或标签页被拒绝时显示状态。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| APP-001 | 应用 providers 初始化 | Implemented | `packages/web/src/app/providers.tsx` | `packages/web/src/app/providers.test.tsx` | +| APP-002 | WebSocket 连接和重连 | Implemented | `packages/web/src/ws/client.ts`、`packages/web/src/ws/reconnect.ts` | `packages/web/src/ws/__tests__/client.test.ts`、`packages/web/src/ws/subscription.test.ts` | +| APP-003 | 桌面壳层渲染 | Implemented | `packages/web/src/shells/desktop-shell.tsx` | `packages/web/src/shells/desktop-shell.test.tsx` | +| APP-004 | 移动壳层渲染 | Implemented | `packages/web/src/shells/mobile-shell/index.tsx` | `packages/web/src/shells/mobile-shell/index.test.tsx` | +| APP-005 | 全局连接状态横幅 | Implemented | `packages/web/src/shells/shared/connection-status-banner.tsx` | `packages/web/src/shells/shared/connection-status-banner.test.tsx` | +| APP-006 | 活跃标签页租约保护 | Implemented | `packages/server/src/commands/activation.ts`、`packages/server/src/ws/dispatch.ts` | `packages/server/src/__tests__/activation-commands.test.ts`、`packages/server/src/__tests__/dispatch.test.ts` | +| APP-007 | 连接探测命令 | Implemented | `packages/server/src/commands/connection.ts` | `connection.probe` 手工命令验收 | +| APP-008 | 服务端恢复协调 | Internal | `packages/server/src/commands/recovery.ts` | `packages/server/src/__tests__/session-hydrate-restart.test.ts`、`packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts` | + +## 4. 模块级验收线索 + +- 启动应用后能建立 WebSocket 连接,并在断开后进入重连状态。 +- 非活动标签页调用非 allowlist command 时返回 `activation_required`。 +- 桌面与移动视口能加载不同 shell。 + +## 5. 未确认项 + +- APP-008 是否需要暴露为产品级恢复功能,需在流程规格轮结合用户入口确认。 diff --git a/docs/product-spec/modules/auth.zh-CN.md b/docs/product-spec/modules/auth.zh-CN.md new file mode 100644 index 000000000..2b905b13c --- /dev/null +++ b/docs/product-spec/modules/auth.zh-CN.md @@ -0,0 +1,39 @@ +# Auth + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 登录页、认证状态检查和会话门禁。 +- 服务端认证 session、密码失败阻断。 + +不覆盖: +- Provider 登录或第三方账号体系。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/login` | Both | 服务端启用密码保护时进入登录页。 | +| Session Gate | Both | 应用启动时根据认证状态决定是否放行业务页面。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| AUTH-001 | 认证状态检查 | Implemented | `packages/web/src/features/auth/session-gate.tsx`、`packages/server/src/auth` | `packages/web/src/features/auth/session-gate.test.tsx` | +| AUTH-002 | 密码登录页 | Implemented | `packages/web/src/features/auth/index.tsx` | `packages/web/src/features/auth/index.test.tsx` | +| AUTH-003 | 已认证会话放行 | Implemented | `packages/web/src/features/auth/session-gate.tsx` | `packages/web/src/features/auth/session-gate.test.tsx` | +| AUTH-004 | 登录失败与阻断状态 | Implemented | `packages/server/src/auth` | `packages/server/src/__tests__/auth-login-block-repo.test.ts` | +| AUTH-005 | auth session 持久化 | Internal | `packages/server/src/auth` | `packages/server/src/__tests__/auth-session-repo.test.ts` | + +## 4. 模块级验收线索 + +- 未认证且服务端开启认证时,业务页面不可进入。 +- 密码错误时登录页显示错误状态。 +- 已认证 session 再次访问时应跳过登录页。 + +## 5. 未确认项 + +- 当前是否有显式退出登录入口,需在功能规格轮从页面入口核实。 diff --git a/docs/product-spec/modules/command-palette.zh-CN.md b/docs/product-spec/modules/command-palette.zh-CN.md new file mode 100644 index 000000000..a4cd18b6b --- /dev/null +++ b/docs/product-spec/modules/command-palette.zh-CN.md @@ -0,0 +1,36 @@ +# Command Palette + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 命令面板入口、展示、过滤和键盘交互。 + +不覆盖: +- 每个命令背后的业务功能。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Command Palette | Both | 展示可执行命令。 | +| 快捷键入口 | Desktop | 通过全局快捷键打开命令面板。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| CMD-001 | 命令面板主入口 | Implemented | `packages/web/src/features/command-palette/index.tsx` | `packages/web/src/features/command-palette/components/command-palette.test.tsx` | +| CMD-002 | 命令列表展示和过滤 | Implemented | `components/command-palette.tsx` | `command-palette.test.tsx` | +| CMD-003 | 命令键盘交互 | Implemented | `components/command-palette.tsx` | `command-palette.test.tsx` | + +## 4. 模块级验收线索 + +- 打开命令面板后能搜索命令。 +- 键盘上下选择和确认执行应可用。 +- 空搜索结果应有可理解状态。 + +## 5. 未确认项 + +- 当前注册命令集合需在第二轮从 UI 状态和 shortcut 入口完整列出。 diff --git a/docs/product-spec/modules/diagnostics.zh-CN.md b/docs/product-spec/modules/diagnostics.zh-CN.md new file mode 100644 index 000000000..0c7e96693 --- /dev/null +++ b/docs/product-spec/modules/diagnostics.zh-CN.md @@ -0,0 +1,42 @@ +# Diagnostics + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 诊断页。 +- 系统依赖状态和安装流程。 +- diagnostics get/recheck。 + +不覆盖: +- Provider runtime status 细节。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Diagnostics page | Both | 查看系统诊断结果。 | +| System dependency install panel | Both | 安装缺失依赖,处理交互输入。 | +| Settings 或引导入口 | Both | 可能跳转到诊断页。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| DIAG-001 | 诊断页渲染 | Implemented | `packages/web/src/features/diagnostics/page.tsx` | `packages/web/src/features/diagnostics/index.test.tsx` | +| DIAG-002 | diagnostics get/recheck | Implemented | `diagnostics.get`、`diagnostics.recheck` | `packages/server/src/__tests__/diagnostics-commands.test.ts` | +| DIAG-003 | runtime dependency status | Implemented | `systemDeps.runtimeStatus` | `packages/server/src/__tests__/system-deps/runtime-status.test.ts` | +| DIAG-004 | 依赖安装 get/start/cancel/input | Implemented | `systemDeps.install.*`、`use-system-dependency-installer.ts` | `packages/server/src/__tests__/system-deps/commands.test.ts` | +| DIAG-005 | 依赖安装面板 | Implemented | `components/system-dependency-install-panel.tsx` | 手工验收:缺失依赖时显示安装流程 | +| DIAG-006 | 交互提示检测 | Internal | `packages/server/src/system-deps/interaction-detector.ts` | `packages/server/src/__tests__/system-deps/interaction-detector.test.ts` | + +## 4. 模块级验收线索 + +- 诊断页能展示当前环境状态。 +- 点击 recheck 后应刷新结果。 +- 依赖安装过程应支持取消和必要输入。 + +## 5. 未确认项 + +- 不同系统平台的依赖安装命令需按环境单独验收。 diff --git a/docs/product-spec/modules/editor-preview.zh-CN.md b/docs/product-spec/modules/editor-preview.zh-CN.md new file mode 100644 index 000000000..00e08c675 --- /dev/null +++ b/docs/product-spec/modules/editor-preview.zh-CN.md @@ -0,0 +1,179 @@ +# Editor Preview + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Monaco 编辑器、打开位置、pending editor loads。 +- 文档预览、图片预览、图片 diff、commit file list preview。 +- LSP 前端桥接和状态提示。 + +不覆盖: +- 文件树操作和 Git command 本身。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 打开文件 | Both | 从文件树、搜索、Git diff 等进入编辑器或预览。 | +| Diff viewer | Desktop | 展示文本或图片 diff。 | +| LSP 状态提示 | Desktop | 编辑器内显示语言服务状态。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| EDITOR-001 | 编辑器主入口渲染 | Implemented | `packages/web/src/features/code-editor/index.tsx` | `packages/web/src/features/code-editor/index.test.tsx` | +| EDITOR-002 | Monaco host | Implemented | `components/monaco-host.tsx` | `components/monaco-host.test.tsx` | +| EDITOR-003 | Monaco diff host | Implemented | `components/monaco-diff-host.tsx` | `components/monaco-diff-host.test.tsx` | +| EDITOR-004 | 打开指定位置 | Implemented | `actions/use-open-location.ts` | `actions/use-open-location.test.tsx` | +| EDITOR-005 | pending editor loads | Implemented | `actions/pending-editor-loads.ts` | `actions/pending-editor-loads.test.ts` | +| EDITOR-006 | document preview | Implemented | `components/document-preview.tsx` | `components/document-preview.test.tsx` | +| EDITOR-007 | image preview | Implemented | `components/image-preview.tsx` | `components/image-preview.test.tsx` | +| EDITOR-008 | image diff preview | Implemented | `components/image-diff-preview.tsx` | `components/image-diff-preview.test.tsx` | +| EDITOR-009 | commit file list preview | Implemented | `components/commit-file-list-preview.tsx` | `components/commit-file-list-preview.test.tsx` | +| EDITOR-010 | preview session API | Implemented | `actions/use-preview-session.ts`、`preview/api.ts` | `actions/use-preview-session.test.tsx`、`preview/api.test.ts` | +| EDITOR-011 | LSP 状态提示和前端桥接 | Implemented | `lsp/bridge.ts`、`components/lsp-status-notice.tsx` | `lsp/bridge.test.tsx`、`components/lsp-status-notice.test.tsx` | +| EDITOR-012 | LSP server commands | Internal | `packages/server/src/commands/lsp.ts` | `packages/server/src/__tests__/lsp-commands.test.ts` | + +## 4. 模块级验收线索 + +- 打开文本文件应进入 Monaco 编辑器。 +- 打开图片文件应进入图片预览。 +- Git diff 应能展示文本或图片 diff。 +- LSP 可用时编辑器应能显示相关状态。 + +## 5. 功能点规格 + +### EDITOR-001 / EDITOR-002 编辑器主入口与 Monaco host + +状态:`Implemented` + +用户行为: +- 用户打开可编辑文本文件。 + +系统响应: +- 前端根据打开文件状态渲染 code editor。 +- Monaco host 管理模型、语言、高亮和编辑器实例。 +- 保存动作通过 Files 模块写入文件。 + +状态与边界: +- Success:文本内容进入 Monaco,并可编辑。 +- Loading:文件内容尚未读取完成时应显示加载或占位状态。 +- Error:文件读取失败时不应创建错误内容模型。 + +验收标准: +- Given workspace 中存在文本文件 +- When 用户打开该文件 +- Then Monaco editor 显示文件内容 +- And 修改内容后可触发保存动作 + +代码索引: +- `packages/web/src/features/code-editor/index.tsx` +- `packages/web/src/features/code-editor/components/monaco-host.tsx` + +### EDITOR-003 Monaco diff host + +状态:`Implemented` + +用户行为: +- 用户从 Git diff、commit diff 或搜索替换预览进入 diff 视图。 + +系统响应: +- 前端渲染 Monaco diff host。 +- Diff host 展示原始内容和修改后内容。 + +状态与边界: +- Success:文本 diff 可读。 +- Empty:无 diff 内容时应显示空差异或占位。 +- Error:diff 输入缺失时不应导致页面崩溃。 + +验收标准: +- Given 一个文本文件存在 Git diff +- When 用户打开该文件 diff +- Then diff host 显示左右两侧内容差异 + +代码索引: +- `packages/web/src/features/code-editor/components/monaco-diff-host.tsx` +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` + +### EDITOR-004 打开指定位置 + +状态:`Implemented` + +用户行为: +- 用户从搜索结果、诊断、LSP definition/reference 或其他定位入口打开文件位置。 + +系统响应: +- 前端通过 open location action 设置目标文件和定位信息。 +- 如果文件尚未加载,pending editor loads 记录等待定位。 + +状态与边界: +- Success:文件打开后光标或视图定位到指定位置。 +- Pending:文件模型尚未 ready 时延迟执行定位。 + +验收标准: +- Given 搜索结果指向某文件第 N 行 +- When 用户打开该结果 +- Then editor 打开该文件 +- And 视图定位到目标位置 + +代码索引: +- `packages/web/src/features/code-editor/actions/use-open-location.ts` +- `packages/web/src/features/code-editor/actions/pending-editor-loads.ts` + +### EDITOR-006 / EDITOR-007 文档与图片预览 + +状态:`Implemented` + +用户行为: +- 用户打开非直接编辑型文档或图片文件。 + +系统响应: +- document preview 渲染文档内容。 +- image preview 渲染图片,并提供适合当前容器的展示。 + +状态与边界: +- Success:预览内容可见。 +- Error:资源加载失败时展示失败状态。 +- Unsupported:不支持的类型应回退到可理解状态。 + +验收标准: +- Given workspace 中存在图片文件 +- When 用户打开该图片 +- Then 图片预览组件显示图片 + +代码索引: +- `packages/web/src/features/code-editor/components/document-preview.tsx` +- `packages/web/src/features/code-editor/components/image-preview.tsx` + +### EDITOR-011 LSP 状态提示和前端桥接 + +状态:`Implemented` + +用户行为: +- 用户打开支持语言服务的代码文件。 + +系统响应: +- 前端 LSP bridge 和 server LSP commands 协作打开 document、同步变更、查询 hover/definition/references 等能力。 +- LSP status notice 展示运行状态或不可用状态。 + +状态与边界: +- Success:语言服务可用时返回对应能力结果。 +- Unavailable:runtime 或工具未安装时展示状态提示。 +- Mode:LSP runtime mode 可通过 command 设置。 + +验收标准: +- Given LSP runtime 可用且文件类型受支持 +- When 用户打开代码文件 +- Then 前端打开 LSP document +- And LSP 状态提示不应显示失败 + +代码索引: +- `packages/web/src/features/code-editor/lsp/bridge.ts` +- `packages/server/src/commands/lsp.ts` + +## 6. 未确认项 + +- Markdown / HTML 预览的具体入口需在第二轮结合 preview API 核实。 diff --git a/docs/product-spec/modules/files.zh-CN.md b/docs/product-spec/modules/files.zh-CN.md new file mode 100644 index 000000000..89f67dbc7 --- /dev/null +++ b/docs/product-spec/modules/files.zh-CN.md @@ -0,0 +1,215 @@ +# Files + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 文件树读取、刷新、创建、删除、重命名、创建目录。 +- 文件搜索、内容搜索、搜索替换 session。 +- 文件上下文菜单和打开文件动作。 + +不覆盖: +- 文件内容编辑器和预览渲染。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Files panel | Desktop | 文件树、上下文菜单、打开文件。 | +| Mobile Files Sheet | Mobile | 移动端文件树和文件打开。 | +| Search panel | Both | 文件搜索和内容搜索入口。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| FILE-001 | 读取文件树 | Implemented | `file.readTree`、`file-tree-panel.tsx` | `packages/server/src/__tests__/file-commands.test.ts`、`file-tree-panel.test.tsx` | +| FILE-002 | 打开/读取文件 | Implemented | `file.read`、`use-open-workspace-file.ts` | `packages/web/src/features/workspace/actions/use-open-workspace-file.test.tsx` | +| FILE-003 | 写入文件 | Implemented | `file.write`、`use-file-actions.ts` | `packages/web/src/features/workspace/actions/use-file-actions.test.tsx` | +| FILE-004 | 创建文件 | Implemented | `file.create`、`use-file-context-actions.ts` | `packages/server/src/__tests__/file-commands.test.ts` | +| FILE-005 | 创建目录 | Implemented | `file.mkdir`、`use-file-context-actions.ts` | `packages/server/src/__tests__/file-commands.test.ts` | +| FILE-006 | 删除文件或目录 | Implemented | `file.delete`、`file-context-menu.tsx` | `packages/web/src/features/workspace/views/shared/file-context-menu.test.tsx` | +| FILE-007 | 重命名文件或目录 | Implemented | `file.rename` | `packages/server/src/__tests__/file-commands.test.ts` | +| FILE-008 | 刷新文件树 | Implemented | `file-tree-refresh.ts` | `packages/web/src/features/workspace/actions/file-tree-refresh.test.ts` | +| FILE-009 | 文件名搜索 | Implemented | `file.search`、`quick-open` | `packages/web/src/features/quick-open/components/quick-open.test.tsx` | +| FILE-010 | 内容搜索 | Implemented | `file.searchContent`、`search-panel.tsx` | `packages/server/src/__tests__/fs/content-search.test.ts` | +| FILE-011 | 搜索替换 session | Implemented | `file.searchSession.start/previewFile/apply` | `packages/server/src/__tests__/fs/search-replace.test.ts` | +| FILE-012 | Gitignore 和 watcher 支持 | Internal | `packages/server/src/fs` | `packages/server/src/__tests__/fs/gitignore.test.ts`、`watcher.test.ts` | + +## 4. 模块级验收线索 + +- 文件树能加载当前 workspace 根目录。 +- 打开文本文件后进入 editor。 +- 创建、重命名、删除后文件树应刷新。 +- 搜索结果能定位到文件或预览替换。 + +## 5. 功能点规格 + +### FILE-001 读取文件树 + +状态:`Implemented` + +用户行为: +- 用户打开 Files panel 或展开目录。 + +系统响应: +- 前端调用 `file.readTree`,传入 workspace id 和可选 subPath。 +- 服务端校验 workspace 存在,并从 workspace root 读取文件树。 + +状态与边界: +- Success:返回文件树节点,前端渲染目录和文件。 +- Error:workspace 不存在返回 `workspace_not_found`。 +- Refresh:文件系统 dirty 或用户刷新时重新读取。 + +验收标准: +- Given workspace root 下有目录和文件 +- When 用户打开 Files panel +- Then 文件树显示 root 下的目录和文件 + +代码索引: +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- `packages/server/src/commands/file.ts` + +### FILE-002 打开/读取文件 + +状态:`Implemented` + +用户行为: +- 用户点击文件树、搜索结果或 Git diff 中的文件。 + +系统响应: +- 前端调用 `file.read`。 +- 服务端校验 workspace 存在,并读取 workspace-relative path。 +- 前端根据文件类型进入 editor 或 preview。 + +状态与边界: +- Success:返回文件内容和相关 metadata。 +- Error:workspace 不存在或文件读取失败时,打开动作应展示错误或保持当前视图。 + +验收标准: +- Given workspace 中存在 `README.md` +- When 用户点击该文件 +- Then 前端读取文件内容 +- And editor/preview 显示该文件 + +代码索引: +- `packages/web/src/features/workspace/actions/use-open-workspace-file.ts` +- `packages/server/src/commands/file.ts` + +### FILE-003 写入文件 + +状态:`Implemented` + +用户行为: +- 用户在 editor 修改文件并保存。 + +系统响应: +- 前端调用 `file.write`,传入 workspace id、path、content 和可选 baseHash。 +- 服务端写入文件,并发出 `fs.dirty` event,reason 为 `file_content`。 +- 返回写入结果,用于前端更新保存状态。 + +状态与边界: +- Success:文件内容写入 workspace。 +- Conflict:当 baseHash 不匹配时,底层写入逻辑可返回冲突结果。 +- Error:workspace 不存在返回 `workspace_not_found`。 + +验收标准: +- Given 一个已打开文本文件 +- When 用户修改内容并保存 +- Then server 写入新内容 +- And 发出 `fs.dirty` 事件 + +代码索引: +- `packages/web/src/features/workspace/actions/use-file-actions.ts` +- `packages/server/src/commands/file.ts` + +### FILE-004 / FILE-005 创建文件或目录 + +状态:`Implemented` + +用户行为: +- 用户通过文件树上下文菜单创建文件或目录。 + +系统响应: +- 前端调用 `file.create` 或 `file.mkdir`。 +- 服务端校验 workspace 存在,在 workspace root 下创建目标,并发出 `fs.dirty` event,reason 为 `fs_change`。 + +状态与边界: +- Success:创建完成后文件树刷新。 +- Error:路径非法、已存在或 workspace 不存在时返回错误。 + +验收标准: +- Given Files panel 已打开 +- When 用户创建一个新文件 +- Then 文件树刷新后显示新文件 + +- Given Files panel 已打开 +- When 用户创建一个新目录 +- Then 文件树刷新后显示新目录 + +代码索引: +- `packages/web/src/features/workspace/actions/use-file-context-actions.ts` +- `packages/server/src/commands/file.ts` + +### FILE-006 / FILE-007 删除或重命名文件 + +状态:`Implemented` + +用户行为: +- 用户通过上下文菜单删除或重命名文件/目录。 + +系统响应: +- 删除调用 `file.delete`。 +- 重命名调用 `file.rename`。 +- 服务端执行文件系统操作后发出 `fs.dirty` event,reason 为 `fs_change`。 + +状态与边界: +- Success:文件树刷新,目标路径消失或变更。 +- Error:workspace 不存在、目标不存在、权限不足或路径冲突时返回错误。 + +验收标准: +- Given 文件树中存在 `old.txt` +- When 用户把它重命名为 `new.txt` +- Then 文件树不再显示 `old.txt` +- And 显示 `new.txt` + +代码索引: +- `packages/web/src/features/workspace/views/shared/file-context-menu.tsx` +- `packages/server/src/commands/file.ts` + +### FILE-010 / FILE-011 内容搜索与搜索替换 + +状态:`Implemented` + +用户行为: +- 用户在搜索面板输入内容搜索条件,或启动搜索替换。 + +系统响应: +- 内容搜索调用 `file.searchContent`,限制 `maxFiles` 和 `maxMatchesPerFile` 最大为 100。 +- 搜索替换通过 `file.searchSession.start` 创建 session。 +- 用户预览文件时调用 `file.searchSession.previewFile`。 +- 用户应用替换时调用 `file.searchSession.apply`,成功或部分成功后发出 `fs.dirty` event,reason 为 `file_content`。 + +状态与边界: +- Success:返回匹配文件和 match 信息。 +- Stale:预览或应用不存在的 search session 返回 `stale_session`。 +- Partial:部分替换成功时仍发出 dirty event。 + +验收标准: +- Given workspace 中多个文件包含同一字符串 +- When 用户执行内容搜索 +- Then 搜索结果按文件返回匹配项 + +- Given 已创建搜索替换 session +- When 用户应用全部替换 +- Then 匹配文件内容被更新 +- And 发出 `fs.dirty` 事件 + +代码索引: +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` +- `packages/server/src/commands/file.ts` + +## 6. 未确认项 + +- 文件上传入口横跨 Terminal 和 Files,第一轮仅在 Terminal 记录上传实现线索。 diff --git a/docs/product-spec/modules/git.zh-CN.md b/docs/product-spec/modules/git.zh-CN.md new file mode 100644 index 000000000..0811daa47 --- /dev/null +++ b/docs/product-spec/modules/git.zh-CN.md @@ -0,0 +1,232 @@ +# Git + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Git 状态、diff、stage/unstage/discard、commit、push/pull/fetch。 +- branch 列表、checkout、quick pick。 +- commit log、commit detail、commit file diff。 + +不覆盖: +- Worktree 管理。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Git panel | Desktop | 查看状态、stage、commit、sync。 | +| Git status bar | Desktop | 分支和状态入口。 | +| Branch quick pick | Desktop | 快速切换分支。 | +| Diff viewer | Both | 查看文件 diff。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| GIT-001 | Git 状态读取 | Implemented | `git.status`、`git-panel.tsx` | `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` | +| GIT-002 | 文件 diff | Implemented | `git.diff`、`git-diff-viewer.tsx` | `git-diff-viewer.test.tsx` | +| GIT-003 | stage / unstage | Implemented | `git.stage`、`git.unstage`、`use-git-actions.ts` | `use-git-actions.test.tsx` | +| GIT-004 | discard | Implemented | `git.discard` | `packages/server/src/__tests__/git-commands.test.ts` | +| GIT-005 | commit | Implemented | `git.commit` | `packages/server/src/__tests__/git/commit.test.ts` | +| GIT-006 | push / pull / fetch | Implemented | `git.push`、`git.pull`、`git.fetch` | `packages/server/src/__tests__/git/fetch.test.ts` | +| GIT-007 | branch 当前状态 | Implemented | `git.branch`、`git-status-bar.tsx` | `git-status-bar.test.tsx` | +| GIT-008 | branch 列表和 checkout | Implemented | `git.branches`、`git.checkout`、`branch-quick-pick.tsx` | `branch-quick-pick.test.tsx` | +| GIT-009 | commit log/detail/show | Implemented | `git.log`、`git.commitDetail`、`git.show` | `packages/server/src/__tests__/git-commands.test.ts` | +| GIT-010 | commit file diff | Implemented | `git.commitFileDiff` | `packages/server/src/__tests__/git/diff.test.ts` | +| GIT-011 | Git 事件广播 | Internal | `packages/server/src/commands/git-events.ts`、`packages/server/src/git` | `packages/server/src/__tests__/git/auto-fetch.test.ts` | + +## 4. 模块级验收线索 + +- 修改文件后 Git panel 应显示状态。 +- stage/unstage/discard 后状态应更新。 +- commit 成功后状态应清理对应 staged changes。 +- Branch quick pick 能切换分支或反馈失败。 + +## 5. 功能点规格 + +### GIT-001 Git 状态读取 + +状态:`Implemented` + +用户行为: +- 用户打开 Git panel 或 Git status bar。 + +系统响应: +- 前端调用 `git.status`。 +- 服务端校验 workspace 存在,并读取 Git status。 +- UI 根据返回值展示分支、变更文件和同步状态。 + +状态与边界: +- Success:展示当前仓库状态。 +- Error:workspace 不存在返回 `workspace_not_found`;非 Git 仓库错误由 git cli 层返回。 + +验收标准: +- Given workspace 是 Git 仓库且存在一个修改文件 +- When 用户打开 Git panel +- Then Git panel 显示该修改文件 + +代码索引: +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` +- `packages/server/src/commands/git.ts` + +### GIT-002 文件 diff + +状态:`Implemented` + +用户行为: +- 用户点击 Git panel 中的 changed file。 + +系统响应: +- 前端调用 `git.diff`,传入 path 和 staged 标记。 +- 服务端返回目标文件 staged 或 unstaged diff。 +- 前端用 Git diff viewer 或 editor diff host 展示。 + +状态与边界: +- Success:展示 diff 内容。 +- Empty:无差异时显示空 diff 状态。 +- Error:workspace 不存在返回 `workspace_not_found`。 + +验收标准: +- Given 文件 `a.txt` 有 unstaged 修改 +- When 用户打开该文件 diff +- Then diff viewer 展示 unstaged diff + +代码索引: +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` +- `packages/server/src/commands/git.ts` + +### GIT-003 stage / unstage + +状态:`Implemented` + +用户行为: +- 用户 stage 或 unstage 一个或多个文件。 + +系统响应: +- Stage 调用 `git.stage`,unstage 调用 `git.unstage`。 +- 服务端执行 Git 操作后调用 `emitGitStateChanged`。 + +状态与边界: +- Success:文件在 staged/unstaged 分组之间移动。 +- Error:workspace 不存在或 Git 操作失败时返回错误。 + +验收标准: +- Given 文件 `a.txt` 是 unstaged +- When 用户 stage 该文件 +- Then Git 状态刷新后 `a.txt` 出现在 staged 区域 + +代码索引: +- `packages/web/src/features/workspace/actions/use-git-actions.ts` +- `packages/server/src/commands/git.ts` + +### GIT-004 discard + +状态:`Implemented` + +用户行为: +- 用户丢弃一个或多个未提交变更。 + +系统响应: +- 前端调用 `git.discard`。 +- 服务端执行 discard,并发出 Git state changed,包含 `treeChanged: true`。 + +状态与边界: +- Success:目标变更从 Git status 中消失,文件树可能刷新。 +- Destructive:该操作会丢弃本地修改,UI 应有确认或明确入口。 +- Error:Git 操作失败时返回错误。 + +验收标准: +- Given 文件 `a.txt` 有本地修改 +- When 用户确认 discard +- Then `a.txt` 恢复到 Git 版本 +- And Git status 不再显示该修改 + +代码索引: +- `packages/web/src/features/workspace/actions/use-git-actions.ts` +- `packages/server/src/commands/git.ts` + +### GIT-005 commit + +状态:`Implemented` + +用户行为: +- 用户输入 commit message 并提交 staged changes。 + +系统响应: +- 前端调用 `git.commit`。 +- 服务端执行 commit,并发出 Git state changed,包含 branch 和 worktree 变化。 + +状态与边界: +- Success:返回 commit 结果,Git status 更新。 +- Error:无 staged changes、message 无效或 Git 失败时返回错误。 + +验收标准: +- Given 有一个 staged 文件且 commit message 非空 +- When 用户提交 +- Then Git 创建新 commit +- And staged 区域清空或反映最新状态 + +代码索引: +- `packages/web/src/features/workspace/actions/use-git-actions.ts` +- `packages/server/src/commands/git.ts` + +### GIT-006 push / pull / fetch + +状态:`Implemented` + +用户行为: +- 用户同步远端分支,或系统执行后台 fetch。 + +系统响应: +- Push 调用 `git.push`,pull 调用 `git.pull`,fetch 调用 `git.fetch`。 +- 网络操作通过 autoFetch exclusive gate 串行化。 +- Pull 和 fetch 成功后记录 fetch 时间。 +- Background fetch 遇到 HTTP auth 错误时返回失败结果而不是抛出。 + +状态与边界: +- Success:返回 Git 网络操作结果并更新状态。 +- Auth:需要认证时可传入 HTTP username/password。 +- Background:后台 fetch timeout 使用 30 秒。 + +验收标准: +- Given workspace 配置了远端 +- When 用户执行 fetch +- Then 返回 updated refs 或明确失败信息 +- And Git branch 状态刷新 + +代码索引: +- `packages/server/src/commands/git.ts` +- `packages/server/src/git/auto-fetch.ts` + +### GIT-008 branch 列表和 checkout + +状态:`Implemented` + +用户行为: +- 用户打开 branch quick pick,选择已有分支或创建新分支。 + +系统响应: +- 分支列表调用 `git.branches`。 +- checkout 调用 `git.checkout`,可选 `createBranch`。 +- 创建分支调用 `git.branch`。 +- checkout 成功后发出 tree、branch、worktree changed。 + +状态与边界: +- Success:当前分支切换,Git 状态刷新。 +- Error:workspace 不存在、分支不存在或 Git 阻止 checkout 时返回错误。 + +验收标准: +- Given 仓库存在分支 `feature/a` +- When 用户通过 branch quick pick 选择该分支 +- Then 当前分支切换为 `feature/a` +- And Git status bar 显示新分支 + +代码索引: +- `packages/web/src/features/workspace/views/shared/branch-quick-pick.tsx` +- `packages/server/src/commands/git.ts` + +## 6. 未确认项 + +- 冲突、未提交变更阻止 checkout 等边界需在第二轮补验收路径。 diff --git a/docs/product-spec/modules/monitoring.zh-CN.md b/docs/product-spec/modules/monitoring.zh-CN.md new file mode 100644 index 000000000..e7f38f5db --- /dev/null +++ b/docs/product-spec/modules/monitoring.zh-CN.md @@ -0,0 +1,43 @@ +# Monitoring + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 监控页面、指标展示、sparkline。 +- monitoring get/recheck。 +- 服务端监控 aggregation、history、host collector、process table。 + +不覆盖: +- Work Analysis 统计和归因。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Monitoring page | Both | 查看运行时监控信息。 | +| Monitoring settings | Both | 配置监控选项。 | +| Recheck action | Both | 重新采集监控状态。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| MON-001 | Monitoring page 渲染 | Implemented | `packages/web/src/features/monitoring/page.tsx` | `packages/web/src/features/monitoring/page.test.tsx` | +| MON-002 | 指标格式化 | Implemented | `formatters.ts` | `packages/web/src/features/monitoring/page.test.tsx` | +| MON-003 | sparkline 展示 | Implemented | `sparkline.tsx` | `packages/web/src/features/monitoring/page.test.tsx` | +| MON-004 | monitoring get/recheck | Implemented | `monitoring.get`、`monitoring.recheck` | `packages/server/src/__tests__/monitoring/commands.test.ts` | +| MON-005 | monitoring aggregation/history | Internal | `packages/server/src/monitoring` | `aggregation.test.ts`、`history-store.test.ts` | +| MON-006 | host collector | Internal | `packages/server/src/monitoring` | `host-collector.test.ts` | +| MON-007 | managed process registry / process table | Internal | `packages/server/src/monitoring/process-table` | `managed-process-registry.test.ts`、`process-table.test.ts` | + +## 4. 模块级验收线索 + +- 监控页能展示当前采样数据。 +- Recheck 后页面数据应刷新。 +- 无监控数据时应有空态或降级展示。 + +## 5. 未确认项 + +- 监控页的路由入口和设置入口需在第二轮确认。 diff --git a/docs/product-spec/modules/notifications.zh-CN.md b/docs/product-spec/modules/notifications.zh-CN.md new file mode 100644 index 000000000..300181ea5 --- /dev/null +++ b/docs/product-spec/modules/notifications.zh-CN.md @@ -0,0 +1,43 @@ +# Notifications + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Toast container。 +- 会话完成通知。 +- 浏览器焦点相关通知抑制。 +- 通知文案格式化。 + +不覆盖: +- 操作系统级通知权限设置。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Toast 区域 | Both | 显示错误、成功或状态通知。 | +| Session completion | Both | Agent session 完成时触发通知。 | +| 浏览器焦点变化 | Both | 根据焦点决定通知策略。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| NOTIFY-001 | Toast container | Implemented | `packages/web/src/features/notifications/toast-container.tsx` | `toast-container.test.tsx` | +| NOTIFY-002 | Toast state atoms | Implemented | `packages/web/src/features/notifications/atoms.ts` | Toast 组件测试 | +| NOTIFY-003 | session notifications hook | Implemented | `use-session-notifications.ts` | `use-session-notifications.test.tsx` | +| NOTIFY-004 | 焦点 session 判断 | Implemented | `focus-session.ts` | `focus-session.test.ts` | +| NOTIFY-005 | 通知格式化 | Implemented | `format.ts` | `format.test.ts` | +| NOTIFY-006 | UI toast primitive | Implemented | `packages/web/src/components/ui/toast` | `components/ui/toast/index.test.tsx` | + +## 4. 模块级验收线索 + +- 操作失败时应显示 error toast。 +- 会话完成且页面不在焦点时应触发通知提示。 +- Toast 应可关闭且不阻断主要操作。 + +## 5. 未确认项 + +- 浏览器原生 notification 权限流程需在第二轮确认。 diff --git a/docs/product-spec/modules/providers.zh-CN.md b/docs/product-spec/modules/providers.zh-CN.md new file mode 100644 index 000000000..82a7b4284 --- /dev/null +++ b/docs/product-spec/modules/providers.zh-CN.md @@ -0,0 +1,195 @@ +# Providers + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- provider 列表、runtime status、安装状态。 +- Claude、Codex、Gemini、Cursor、OpenCode provider 定义。 +- 自定义 provider 管理。 + +不覆盖: +- Provider 启动后的 session 生命周期。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Settings Providers | Both | 配置 provider 和配置文件。 | +| Draft launcher | Both | 选择 provider 启动 session。 | +| Diagnostics / runtime status | Both | 查看 provider 运行依赖状态。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| PROVIDER-001 | provider 列表 | Implemented | `provider.list`、`packages/providers/src/registry.ts` | `packages/server/src/__tests__/provider-list.test.ts` | +| PROVIDER-002 | provider runtime status | Implemented | `provider.runtimeStatus`、`packages/server/src/provider-runtime/runtime-status.ts` | `packages/server/src/__tests__/provider-runtime/runtime-status.test.ts` | +| PROVIDER-003 | provider 安装状态和启动安装 | Implemented | `provider.install.get`、`provider.install.start` | `packages/server/src/__tests__/provider-runtime/install-manager.test.ts` | +| PROVIDER-004 | Claude provider | Implemented | `packages/providers/src/claude/definition.ts` | `packages/providers/src/claude/definition.test.ts` | +| PROVIDER-005 | Codex provider | Implemented | `packages/providers/src/codex/definition.ts`、`headless.ts` | `packages/providers/src/codex/definition.test.ts` | +| PROVIDER-006 | Gemini provider | Implemented | `packages/providers/src/gemini/definition.ts` | `packages/providers/src/gemini/definition.test.ts` | +| PROVIDER-007 | Cursor provider | Implemented | `packages/providers/src/cursor/definition.ts` | `packages/providers/src/cursor/definition.test.ts` | +| PROVIDER-008 | OpenCode provider | Implemented | `packages/providers/src/opencode/definition.ts` | `packages/providers/src/opencode/definition.test.ts` | +| PROVIDER-009 | 自定义 provider 列表/创建/更新/删除 | Implemented | `packages/server/src/commands/custom-provider.ts` | `packages/server/src/__tests__/custom-provider-command.test.ts` | +| PROVIDER-010 | provider settings UI | Implemented | `packages/web/src/features/settings/components/provider-settings.tsx` | `packages/web/src/features/settings/components/provider-settings.test.tsx` | + +## 4. 模块级验收线索 + +- 设置页应能展示 provider 列表和配置状态。 +- Draft launcher 应能使用可用 provider 创建 session。 +- 自定义 provider 创建后应在列表中可见。 + +## 5. 功能点规格 + +### PROVIDER-001 provider 列表 + +状态:`Implemented` + +用户行为: +- 用户进入需要选择 provider 的入口,例如设置页、session draft launcher 或 agent instructions 生成弹窗。 + +系统响应: +- 前端通过 `provider.list` 拉取 provider registry。 +- 服务端返回每个 provider 的展示名称、类型、稳定性、能力摘要,以及是否支持 agent instructions 生成。 +- 内置 provider 和已注册的自定义 provider 都应出现在同一列表中。 + +状态与边界: +- Success:返回 registry 当前快照。 +- Capability:`supportsAgentInstructionsGeneration` 由 provider 定义能力推导,不由 UI 猜测。 +- Registry:自定义 provider 创建或删除后,后续列表应反映最新 registry。 + +验收标准: +- Given provider registry 包含内置 Claude 和一个自定义 provider +- When 前端调用 `provider.list` +- Then 返回列表包含 Claude 和该自定义 provider +- And 每项包含用于 UI 展示的名称和能力信息 + +代码索引: +- `packages/server/src/commands/provider.ts` +- `packages/providers/src/registry.ts` + +### PROVIDER-002 provider runtime status + +状态:`Implemented` + +用户行为: +- 用户在设置页或诊断入口查看某个 provider 是否可用。 + +系统响应: +- 前端调用 `provider.runtimeStatus`。 +- 服务端基于 provider registry 和 runtime dependencies 生成 provider 状态。 +- UI 对可用 provider 展示 ready 状态;对不可用 provider 展示 warning、文档链接、手工安装提示和诊断入口。 + +状态与边界: +- Available:provider CLI 或运行依赖可用。 +- Unavailable:返回手工 guide key、文档 URL 或缺失信息。 +- UI:设置页只展示已返回的 runtime 状态,不在前端自行探测 CLI。 + +验收标准: +- Given Claude CLI 不可用且 runtime status 包含 manual guide +- When 用户打开 Provider Settings 中的 Claude +- Then 设置页显示 warning 状态 +- And 用户可以打开诊断页或 provider 文档 + +代码索引: +- `packages/server/src/commands/provider.ts` +- `packages/server/src/provider-runtime/runtime-status.ts` +- `packages/web/src/features/settings/components/provider-settings.tsx` + +### PROVIDER-003 provider 安装状态和启动安装 + +状态:`Implemented` + +用户行为: +- 用户或诊断流程启动 provider 安装,并轮询安装任务。 + +系统响应: +- `provider.install.start` 创建安装 job 并返回 job snapshot。 +- `provider.install.get` 根据 jobId 返回任务状态。 +- provider install manager 不存在时返回 `provider_install_unavailable`。 +- 查询不存在的 jobId 时返回 `provider_install_job_not_found`。 + +状态与边界: +- Started:安装 job 已创建,可轮询。 +- Succeeded / Failed:由 install manager 维护终态。 +- Unavailable:服务端未配置安装管理器时不能启动或查询。 + +验收标准: +- Given provider install manager 已配置 +- When 调用 `provider.install.start` 启动 Claude 安装 +- Then 返回包含 jobId 的安装任务 +- When 使用该 jobId 调用 `provider.install.get` +- Then 返回同一个任务的当前状态 + +代码索引: +- `packages/server/src/commands/provider.ts` +- `packages/server/src/provider-runtime/install-manager.ts` + +### PROVIDER-009 自定义 provider 列表/创建/更新/删除 + +状态:`Implemented` + +用户行为: +- 用户或内部设置流程管理一个自定义 provider。 + +系统响应: +- `customProvider.list` 返回已保存的自定义 provider 列表。 +- `customProvider.create` 校验 id、名称、命令、参数、env、工作目录模式、会话模式和 capabilities。 +- 创建成功后写入 custom provider repo,并更新 provider registry。 +- `customProvider.update` 只能更新已存在的自定义 provider。 +- `customProvider.delete` 删除配置,并从 provider registry 移除定义。 + +状态与边界: +- Validation:id 必须匹配 `/^[a-z0-9][a-z0-9-_]*$/`。 +- Duplicate:id 与现有 registry 冲突时返回 `custom_provider_exists`。 +- Missing:更新或删除不存在的 provider 返回 `custom_provider_not_found`。 +- Unavailable:缺少 custom provider repo 或 registry setter 时返回 `custom_provider_unavailable`。 + +验收标准: +- Given 当前不存在 id 为 `review-bot` 的 provider +- When 创建 `review-bot` +- Then `customProvider.create` 返回 provider list item +- And 后续 `provider.list` 包含 `review-bot` +- When 删除 `review-bot` +- Then 后续 `provider.list` 不再包含 `review-bot` + +代码索引: +- `packages/server/src/commands/custom-provider.ts` +- `packages/server/src/provider-runtime/custom-provider.ts` + +### PROVIDER-010 provider settings UI + +状态:`Implemented` + +用户行为: +- 用户在 Settings Providers 中切换 provider、编辑启动参数、查看命令预览,或打开 Claude/Codex 配置文件编辑器。 + +系统响应: +- Provider tab 根据 `provider.list` 数据渲染。 +- 选中 provider 后加载 runtime status,并展示可用状态、文档和诊断入口。 +- 启动参数按行解析,空行会被过滤,保存到 `settings.update` 的 `providers..additionalArgs`。 +- 命令预览通过 `settings.previewCommand` 生成。 +- 仅 Claude 和 Codex 支持配置文件编辑入口。 + +状态与边界: +- Loading:runtime status 和 command preview 异步加载。 +- Preview race:切换 provider 或参数变化时,旧 preview 结果不能覆盖新 provider。 +- Mobile:移动端配置文件编辑器通过二级入口打开。 +- Unsupported config:非 Claude/Codex provider 不显示 config file 子页。 + +验收标准: +- Given 用户打开 Settings Providers 并选中 Codex +- When 用户输入两行启动参数 +- Then 前端调用 `settings.update` 保存为数组 +- And command preview 使用最新参数刷新 +- And Codex 配置文件编辑入口可打开 + +代码索引: +- `packages/web/src/features/settings/components/provider-settings.tsx` +- `packages/server/src/commands/settings.ts` + +## 6. 未确认项 + +- 各 provider 的真实 CLI 可执行验收需要单独环境准备。 diff --git a/docs/product-spec/modules/search-quick-open.zh-CN.md b/docs/product-spec/modules/search-quick-open.zh-CN.md new file mode 100644 index 000000000..dcfdf05f4 --- /dev/null +++ b/docs/product-spec/modules/search-quick-open.zh-CN.md @@ -0,0 +1,42 @@ +# Search / Quick Open + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Quick Open。 +- Workspace search panel。 +- 搜索结果预览和跳转。 + +不覆盖: +- 底层文件搜索实现细节,写入 Files。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Quick Open | Both | 快速搜索并打开文件。 | +| Search panel | Desktop | 搜索内容、预览搜索结果。 | +| Command palette command | Both | 可能触发搜索或打开入口。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SEARCH-001 | Quick Open 组件 | Implemented | `packages/web/src/features/quick-open/components/quick-open.tsx` | `packages/web/src/features/quick-open/components/quick-open.test.tsx` | +| SEARCH-002 | Search panel 展示 | Implemented | `packages/web/src/features/workspace/views/shared/search-panel.tsx` | `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` | +| SEARCH-003 | Search panel state | Implemented | `search-panel-state.ts` | `search-panel-state.test.ts` | +| SEARCH-004 | 搜索预览动作 | Implemented | `use-search-preview-actions.ts` | `use-search-preview-actions.test.tsx` | +| SEARCH-005 | 文件名搜索 command | Implemented | `file.search` | `packages/server/src/__tests__/file-commands.test.ts` | +| SEARCH-006 | 内容搜索 command | Implemented | `file.searchContent` | `packages/server/src/__tests__/fs/content-search.test.ts` | + +## 4. 模块级验收线索 + +- Quick Open 输入关键字后应显示匹配文件。 +- 选择搜索结果后应打开对应文件。 +- 内容搜索应能展示匹配文件和预览。 + +## 5. 未确认项 + +- 搜索替换是否作为独立用户入口展示需在第二轮确认。 diff --git a/docs/product-spec/modules/settings.zh-CN.md b/docs/product-spec/modules/settings.zh-CN.md new file mode 100644 index 000000000..07864b2fc --- /dev/null +++ b/docs/product-spec/modules/settings.zh-CN.md @@ -0,0 +1,342 @@ +# Settings + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 设置页导航和分区。 +- Provider 设置、配置文件读写、命令预览。 +- 外观、快捷键、监控设置、关于。 + +不覆盖: +- Provider runtime 实际执行。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/settings` | Both | 设置页入口。 | +| Settings navigation | Both | 切换设置分区或子页。 | +| Provider Settings | Both | 编辑 provider 配置。 | +| Monitoring Settings | Both | 配置监控相关选项。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SETTINGS-001 | 设置页渲染 | Implemented | `packages/web/src/features/settings/components/settings-page.tsx` | `settings-page.test.tsx` | +| SETTINGS-002 | 设置页导航 | Implemented | `settings-navigation.ts` | `settings-navigation.test.ts` | +| SETTINGS-003 | settings.get/update | Implemented | `packages/server/src/commands/settings.ts` | `packages/server/src/commands/settings.test.ts` | +| SETTINGS-004 | 读取/写入配置文件 | Implemented | `settings.readConfigFile`、`settings.writeConfigFile`、`config-editor.tsx` | `config-editor.test.tsx` | +| SETTINGS-005 | 命令预览 | Implemented | `settings.previewCommand` | `packages/server/src/commands/settings.test.ts` | +| SETTINGS-006 | Provider settings UI | Implemented | `provider-settings.tsx` | `provider-settings.test.tsx` | +| SETTINGS-007 | Shortcuts settings UI | Implemented | `shortcuts-settings.tsx` | `shortcuts-settings.test.tsx` | +| SETTINGS-008 | Monitoring settings UI | Implemented | `monitoring-settings-card.tsx`、`monitoring-settings-subpage.tsx` | `monitoring-settings-subpage.test.tsx` | +| SETTINGS-009 | About settings | Implemented | `about-settings.tsx` | `about-settings.test.tsx` | +| SETTINGS-010 | Session gate dispatch | Internal | `use-session-gate-dispatch.ts` | 设置页手工验收 | + +## 4. 模块级验收线索 + +- 进入设置页后能切换主要设置分区。 +- 修改设置后刷新应保留持久化结果。 +- 配置文件编辑失败时应展示错误。 + +## 5. 功能点规格 + +### SETTINGS-001 设置页渲染 + +状态:`Implemented` + +用户行为: +- 用户进入 `/settings`,在桌面或移动端查看设置页面。 + +系统响应: +- 设置页加载 server settings、provider 列表、runtime 状态、监控数据和更新状态等上下文。 +- 根据当前 section 渲染 General、Providers、Appearance、Shortcuts、Monitoring、Analysis、Diagnostics、About。 +- 移动端使用同一 section 定义,但布局适配移动视口。 + +状态与边界: +- Loading:设置数据未准备好时部分控件使用默认值或禁用态。 +- Session gate:部分 dispatch 遇到 `activation_required` 会导航到 `/session-gate`。 +- Section fallback:未知 section 应回落到默认 section。 + +验收标准: +- Given 用户访问 `/settings` +- When settings 数据加载完成 +- Then 页面显示设置导航 +- And 默认 section 可渲染对应内容 + +代码索引: +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/features/settings/components/settings-sections.tsx` + +### SETTINGS-002 设置页导航 + +状态:`Implemented` + +用户行为: +- 用户点击设置导航中的分区或通过 URL 进入某个分区。 + +系统响应: +- `SETTINGS_SECTIONS` 定义所有分区 id、i18n label 和 icon semantic。 +- navigation 工具解析和生成设置路径。 +- 页面根据 section id 切换内容。 + +状态与边界: +- Sections:当前分区包括 `general`、`providers`、`appearance`、`shortcuts`、`monitoring`、`analysis`、`diagnostics`、`about`。 +- Mobile:移动端 section 集合与桌面相同。 +- Unknown:未知 id 不应导致页面崩溃。 + +验收标准: +- Given 用户在 Settings 页面 +- When 点击 Providers 分区 +- Then URL 和内容切换到 providers +- And Provider Settings 被渲染 + +代码索引: +- `packages/web/src/features/settings/components/settings-sections.tsx` +- `packages/web/src/features/settings/components/settings-navigation.ts` +- `packages/web/src/features/settings/components/settings-page.tsx` + +### SETTINGS-003 settings.get/update + +状态:`Implemented` + +用户行为: +- 用户修改设置项,例如默认 provider、通知、外观、LSP、更新、监控、Supervisor 或 provider 配置。 + +系统响应: +- `settings.get` 从 settings repo 读取非 provider 设置,并从 provider config repo 合并 provider 配置。 +- provider config 会按 provider schema sanitize,非法配置回退到默认值。 +- Supervisor 设置通过 resolver 归一化默认值和范围。 +- `settings.update` 校验 schema、flatten 非 provider 设置、合并 provider config,并写入 repo。 +- 更新 updates 或 monitoring 设置时触发对应服务 reload。 + +状态与边界: +- Provider keys:`settings.get` 不直接暴露原始 `providers.*` settingsRepo 条目,而是来自 providerConfigRepo。 +- Unknown provider:更新未知 provider 配置返回 `unknown_provider`。 +- Personalization snapshot:完整 personalization snapshot 中缺失的 override 字段会删除旧 override key。 +- Validation:数值范围由 zod schema 和 core validator 限制。 + +验收标准: +- Given settings 中已配置 monitoring.enabled 为 false +- When 调用 `settings.update` 设置 monitoring.enabled 为 true +- Then settings repo 写入该值 +- And monitoring service reload 被触发 + +代码索引: +- `packages/server/src/commands/settings.ts` +- `packages/server/src/storage/settings-repo.ts` + +### SETTINGS-004 读取/写入配置文件 + +状态:`Implemented` + +用户行为: +- 用户在 Provider Settings 中打开 Claude 或 Codex 配置文件编辑器,编辑内容、格式化、保存或重置。 + +系统响应: +- `settings.readConfigFile` 读取 `codex` 或 `claude` 配置文件,返回 configPath、content、exists。 +- 编辑器显示文件路径、存在状态、Monaco 编辑区和保存状态。 +- Claude 配置支持 JSON 格式化;Codex TOML 格式化当前不实现。 +- `settings.writeConfigFile` 写入内容,并在可能时返回 backupPath。 +- 保存成功后 toast 展示成功,带 backup 信息;失败后展示错误状态和 error toast。 + +状态与边界: +- File missing:文件不存在时显示空态提示,但仍允许编辑保存。 +- Dirty:content 与 originalContent 不同即为 dirty,启用保存和重置。 +- Saving:保存中禁用重复保存。 +- Load error:读取失败且没有 configPath 时只显示错误卡片。 + +验收标准: +- Given Claude 配置文件存在 +- When 用户修改内容并点击保存 +- Then 前端调用 `settings.writeConfigFile` +- And 保存成功后 dirty 状态清除 +- And 如果有 backupPath,toast 中展示备份路径 + +代码索引: +- `packages/server/src/commands/settings.ts` +- `packages/server/src/config/config-io.ts` +- `packages/web/src/features/settings/components/config-editor.tsx` + +### SETTINGS-005 命令预览 + +状态:`Implemented` + +用户行为: +- 用户在 Provider Settings 中编辑 provider 启动参数并查看命令预览。 + +系统响应: +- 前端调用 `settings.previewCommand`,传入 providerId、临时 config 和可选 workspacePath。 +- 服务端将临时 config 与默认/现有 provider config 合并,通过 provider `buildCommand` 生成 argv、cwd、env 和 preview 字符串。 +- UI 展示 preview 字符串。 + +状态与边界: +- Unknown provider:providerId 不存在时返回 `unknown_provider`。 +- Workspace fallback:workspacePath 未传时使用当前进程 cwd。 +- Preview failure:前端展示 `Error loading preview`。 + +验收标准: +- Given 用户在 Codex 启动参数输入 `--model gpt-5` +- When preview 请求完成 +- Then UI 展示包含该参数的启动命令预览 + +代码索引: +- `packages/server/src/commands/settings.ts` +- `packages/web/src/features/settings/components/provider-settings.tsx` + +### SETTINGS-006 Provider settings UI + +状态:`Implemented` + +用户行为: +- 用户在 Providers 分区查看 provider 概览、能力、runtime 状态、启动参数和配置文件入口。 + +系统响应: +- Provider tabs 来自 provider 列表。 +- provider badge、capability、stability 和 supported capabilities 作为摘要展示。 +- runtime 状态可用时显示 success,不可用时显示 warning、手工说明、文档和诊断入口。 +- 启动参数保存到 provider config。 +- Claude/Codex 允许切换到 config file 子视图。 + +状态与边界: +- Runtime loading:runtime 未返回时不展示状态块。 +- Mobile:移动端使用单独入口进入 config file 编辑器,再返回 base。 +- Unsupported config:非 Claude/Codex provider 不显示 config file 入口。 + +验收标准: +- Given Provider Settings 打开并选中不可用的 Claude +- When runtime status 返回 missing CLI +- Then 页面显示 warning +- And 用户可以跳转诊断页 + +代码索引: +- `packages/web/src/features/settings/components/provider-settings.tsx` + +### SETTINGS-007 Shortcuts settings UI + +状态:`Implemented` + +用户行为: +- 用户查看快捷键分类、点击某个快捷键进入录制、按键设置自定义绑定,或重置单个/全部快捷键。 + +系统响应: +- UI 按 global、workspace、editor、terminal 分类展示默认快捷键。 +- 进入编辑后输入框捕获 keydown,生成 binding 字符串。 +- 保存自定义 binding 到 `customShortcutsAtom`,并调用 `settings.update` 写入 `shortcuts.`。 +- Escape 取消录制。 +- 重置单个快捷键写入 null;重置全部写入空 shortcuts 对象。 + +状态与边界: +- Platform:Mac 上 Meta 映射为 Mod,非 Mac 上 Ctrl 映射为 Mod。 +- Arrow:Ctrl+Arrow 会保留 Ctrl。 +- UI state:失焦退出编辑态。 + +验收标准: +- Given 用户打开 Shortcuts 的 editor 分类 +- When 点击某个快捷键并按 `Mod+Shift+P` +- Then 该快捷键显示新绑定 +- And 前端调用 `settings.update` + +代码索引: +- `packages/web/src/features/settings/components/shortcuts-settings.tsx` +- `packages/web/src/lib/shortcuts.ts` + +### SETTINGS-008 Monitoring settings UI + +状态:`Implemented` + +用户行为: +- 用户在 Monitoring 分区启用/关闭监控,调整监控设置,并查看主机和 runtime 指标。 + +系统响应: +- Monitoring subpage 渲染 hero、状态卡、设置卡、KPI 和 MonitoringDashboard。 +- 启用开关调用 `onChange` 更新 settings。 +- 设置卡可刷新监控数据并切换 time window。 +- 如果 optimistic settings 已启用但最新监控 response 仍是 disabled,页面用空 response 合成等待态。 + +状态与边界: +- Disabled:监控关闭时显示 disabled 摘要和空 KPI。 +- Degraded/error:telemetry degraded 或 error 时状态显示 attention。 +- Not ready:monitoring settings 未 ready 时开关禁用。 +- Mobile:subpage 根据 viewport 添加移动/桌面 class。 + +验收标准: +- Given monitoring settings ready 且 enabled 为 false +- When 用户打开开关 +- Then onChange 收到 enabled 为 true 的 settings +- And 页面状态进入 enabled 或 waiting 展示 + +代码索引: +- `packages/web/src/features/settings/components/monitoring-settings-subpage.tsx` +- `packages/web/src/features/settings/components/monitoring-settings-card.tsx` +- `packages/server/src/commands/settings.ts` + +### SETTINGS-009 About settings + +状态:`Implemented` + +用户行为: +- 用户查看版本、server instance、安装支持、更新状态,并手动检查或安装更新。 + +系统响应: +- About 展示产品名、当前版本、serverInstanceId、安装支持状态、最新版本、上次检查时间、可用性和更新状态。 +- 用户可开关自动检查,并选择更新检查间隔。 +- 手动检查调用 `updates.check`。 +- 准备安装调用 `updates.prepareInstall`;如果存在 active work,显示确认框。 +- 安装调用 `updates.startInstall`,可传 force。 + +状态与边界: +- Unsupported:不支持安装时展示 unsupported reason。 +- Manual required:更新状态为 manual_required 时显示 manual command。 +- Failure:检查或安装失败时推送 error toast。 +- Confirmation:有 active work 时必须确认后才 force install。 + +验收标准: +- Given updateState 表示有新版本且支持安装 +- When 用户点击更新并 prepareInstall 返回 active work +- Then About 显示确认对话框 +- When 用户确认 +- Then 前端调用 `updates.startInstall` 且 force 为 true + +代码索引: +- `packages/web/src/features/settings/components/about-settings.tsx` + +### SETTINGS-010 外观和终端偏好 + +状态:`Implemented` + +用户行为: +- 用户在 Appearance 或 General 中切换主题、语言、LSP runtime mode、终端 renderer、复制选择、终端字号,以及背景/玻璃个性化设置。 + +系统响应: +- 主题切换立即更新 document `data-theme`,并保存 `appearance.themeId`。 +- 语言写入 `appearance.locale`。 +- LSP mode 写入 `lsp.mode`。 +- 终端 renderer/copy/font size 写入 `appearance` 对应字段。 +- 背景图片上传后保存 assetId;删除时先删除 asset,再清空 personalization 字段。 +- personalization 支持 common、desktop override、mobile override。 + +状态与边界: +- Terminal font:字号必须在 10-18,保存节流。 +- Bounded personalization:dimness 0-100,blur 0-40,glass/surface 0-100。 +- Override clearing:关闭 desktop/mobile override 时保存空 override。 +- Asset failure:上传或删除失败时显示 appearance asset 错误。 + +验收标准: +- Given 当前桌面终端字号为 13 +- When 用户输入 20 并提交 +- Then UI 恢复为 13 +- And 显示字号范围错误 +- When 用户输入 14 +- Then 调用 `settings.update` 保存 desktopTerminalFontSize 为 14 + +代码索引: +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/server/src/commands/settings.ts` + +## 6. 未确认项 + +- Diagnostics 分区在本模块只记录入口,详细诊断流程放到 Diagnostics 模块展开。 diff --git a/docs/product-spec/modules/shortcuts.zh-CN.md b/docs/product-spec/modules/shortcuts.zh-CN.md new file mode 100644 index 000000000..e8b26aca8 --- /dev/null +++ b/docs/product-spec/modules/shortcuts.zh-CN.md @@ -0,0 +1,41 @@ +# Shortcuts + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 工作区导航快捷键。 +- 命令面板快捷键入口。 +- 设置页快捷键展示。 +- Kbd UI primitive。 + +不覆盖: +- Monaco 编辑器内建快捷键。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 全局快捷键 | Desktop | 打开命令面板或切换工作区视图。 | +| Settings Shortcuts | Desktop | 查看当前快捷键说明。 | +| Kbd 组件 | Internal | 展示快捷键标签。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SHORTCUT-001 | 工作区导航快捷键 | Implemented | `use-workspace-navigation-shortcuts.ts` | `use-workspace-navigation-shortcuts.test.tsx` | +| SHORTCUT-002 | 设置页快捷键展示 | Implemented | `shortcuts-settings.tsx` | `shortcuts-settings.test.tsx` | +| SHORTCUT-003 | Kbd UI primitive | Implemented | `packages/web/src/components/ui/kbd` | `components/ui/kbd/index.test.tsx` | +| SHORTCUT-004 | 命令面板键盘交互 | Implemented | `command-palette.tsx` | `command-palette.test.tsx` | + +## 4. 模块级验收线索 + +- 快捷键应在输入框聚焦时避免误触全局动作。 +- 设置页展示的快捷键应与实际监听一致。 +- 命令面板键盘交互应支持选择和确认。 + +## 5. 未确认项 + +- 是否支持用户自定义快捷键需在第二轮确认。 diff --git a/docs/product-spec/modules/skills.zh-CN.md b/docs/product-spec/modules/skills.zh-CN.md new file mode 100644 index 000000000..406b18a92 --- /dev/null +++ b/docs/product-spec/modules/skills.zh-CN.md @@ -0,0 +1,274 @@ +# Skills + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Skills panel。 +- skill 搜索、信息、安装、卸载、修复。 +- skill library、targets、mount/unmount、health scan。 + +不覆盖: +- Agent instructions 对 skill 内容的引用细节。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Skills panel | Desktop | 查看目标 skills、挂载状态、归因信息。 | +| Skills install/management UI | Both | 安装、修复或移除 skill。 | +| Agent 工作区上下文 | Internal | 供 agent instructions 或 provider 使用。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SKILL-001 | Skills panel 展示 | Implemented | `skills-panel.tsx`、`use-skills-panel.ts` | `packages/web/src/features/workspace/views/shared/skills-panel.test.tsx` | +| SKILL-002 | skills search/info | Implemented | `skills.search`、`skills.info` | `packages/server/src/__tests__/skills-command.test.ts` | +| SKILL-003 | skills library list | Implemented | `skills.library.list` | `packages/server/src/__tests__/skills/commands.test.ts` | +| SKILL-004 | skills install get/start | Implemented | `skills.install.get`、`skills.install.start` | `packages/server/src/__tests__/skills/commands.test.ts` | +| SKILL-005 | skills uninstall/repair | Implemented | `skills.uninstall`、`skills.repair` | `packages/server/src/__tests__/skills/commands.test.ts` | +| SKILL-006 | skills mount/unmount | Implemented | `skills.mount`、`skills.unmount` | `packages/server/src/__tests__/skills/commands.test.ts` | +| SKILL-007 | skills targets list | Implemented | `skills.targets.list` | `packages/server/src/__tests__/skills/target-registry.test.ts` | +| SKILL-008 | skills health scan | Implemented | `skills.health.scan` | `packages/server/src/__tests__/skills/commands.test.ts` | +| SKILL-009 | skill repositories | Internal | `packages/server/src/storage/repositories/skill-*` | `packages/server/src/__tests__/storage/skill-library-repo.test.ts` | + +## 4. 模块级验收线索 + +- Skills panel 能展示当前 workspace 或目标的 skill 摘要。 +- 安装或挂载操作后列表应刷新。 +- Health scan 应能反馈不可用 skill。 + +## 5. 功能点规格 + +### SKILL-001 Skills panel 展示 + +状态:`Implemented` + +用户行为: +- 用户在 workspace sidebar 打开 Skills panel,查看已安装 skill、发现远端 skill,并查看每个 provider 的挂载状态。 + +系统响应: +- panel 初始化时调用 `skills.library.list` 和 `skills.health.scan`。 +- Library 区按 displayName/slug 排序展示已安装 skill。 +- Discover 区支持搜索,输入变化后 250ms debounce 调用搜索。 +- 每个 skill 行展示 provider target 摘要 token;展开后展示每个 target 的状态、原因、路径和操作。 + +状态与边界: +- Loading:library 或 search 加载时显示 loading。 +- Empty library:没有已安装 skill 时显示空态。 +- Empty search:有 query 但无结果时显示无结果状态。 +- Error:搜索、扫描、安装、挂载等失败时在 Discover 区显示错误 Notice。 + +验收标准: +- Given 已安装一个 skill 且 Codex target 已挂载 +- When 用户打开 Skills panel +- Then Library 区显示该 skill +- And skill 摘要中 Codex token 显示 mounted 状态 + +代码索引: +- `packages/web/src/features/workspace/views/shared/skills-panel.tsx` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` + +### SKILL-002 skills search/info + +状态:`Implemented` + +用户行为: +- 用户在 Discover 区输入关键词搜索 skill,或内部流程查看 skill 详情。 + +系统响应: +- `skills.search` 要求 query trim 后非空。 +- 服务端从 skills hub 搜索远端结果,并合并本地安装状态、已安装版本、已挂载 provider ids。 +- `skills.info` 返回远端信息、本地 library entry 和 mount 列表。 + +状态与边界: +- Unavailable:缺少 skills hub、library repo 或 mount repo 时返回 `skills_unavailable`。 +- Remote info fallback:`skills.info` 远端详情请求失败时,仍可用本地 library entry 回退展示。 +- Installed ordering:前端搜索结果中已安装项排在未安装项前。 + +验收标准: +- Given skills hub 搜索返回 `react-tools` 且本地已安装 +- When 调用 `skills.search` +- Then 返回项标记 `installed: true` +- And 包含 installedVersion 和 mountedProviderIds + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` + +### SKILL-003 skills library list + +状态:`Implemented` + +用户行为: +- 用户查看本地已安装 skills。 + +系统响应: +- `skills.library.list` 读取 skill library repo。 +- 服务端合并当前 mount repo 中 enabled mounts。 +- 返回每个 skill 的 mountedProviderIds、mountStatus 和 errorCount。 + +状态与边界: +- Mount status:有 failed/stale mount 时为 `error`。 +- Unmounted:没有 enabled mount 时为 `unmounted`。 +- Partially mounted:当前代码在 mounts.length 为 1 时返回 `partially_mounted`。 +- Fully mounted:多个 enabled mount 且无错误时返回 `fully_mounted`。 + +验收标准: +- Given skill `a` 有一个 enabled mount 且状态为 `stale` +- When 调用 `skills.library.list` +- Then `a.mountStatus` 为 `error` +- And `a.errorCount` 大于 0 + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/server/src/storage/repositories/skill-library-repo.ts` +- `packages/server/src/storage/repositories/skill-mount-repo.ts` + +### SKILL-004 skills install get/start + +状态:`Implemented` + +用户行为: +- 用户在 Discover 搜索结果中点击安装 skill。 + +系统响应: +- 前端调用 `skills.install.start`,成功后把 slug 到 jobId 的映射存入 panel state。 +- hook 每秒调用 `skills.install.get` 轮询安装任务。 +- job 状态为 `succeeded` 或 `failed` 时移除 jobId,并刷新 search、library 和 health。 +- 安装中按钮显示 loading,已安装项禁用安装按钮。 + +状态与边界: +- Unavailable:缺少 skill install manager 时返回 `skill_install_unavailable`。 +- Missing job:查询不存在 jobId 返回 `skill_install_job_not_found`。 +- Polling:组件卸载或 effect 清理后停止轮询。 + +验收标准: +- Given Discover 搜索结果包含未安装 skill `x` +- When 用户点击安装 +- Then 前端调用 `skills.install.start` +- And 安装按钮进入 loading +- When job 进入 succeeded +- Then panel 刷新 library 和 health + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` +- `packages/web/src/features/workspace/views/shared/skills-panel.tsx` + +### SKILL-005 skills uninstall/repair + +状态:`Implemented` + +用户行为: +- 用户卸载 skill,或修复一个状态异常的 skill mount。 + +系统响应: +- `skills.uninstall` 删除 library entry 和 mount 关系;force 为 true 时先尝试 unmount 所有 mounts。 +- 如果 skill 仍有 enabled mounts 且 force 未设置,返回 `skill_uninstall_blocked`,details 包含 provider ids。 +- `skills.repair` 要求 mount 已存在,重新执行 mount 并扫描健康状态。 +- 前端 repair 成功后刷新 search 和 health。 + +状态与边界: +- Blocked:仍挂载时普通卸载被阻止。 +- Missing mount:repair 不存在关系时返回 `skill_mount_not_found`。 +- File cleanup:卸载时会尝试删除 libraryPath,失败不会阻断返回。 + +验收标准: +- Given skill `x` 已挂载到 Codex +- When 调用 `skills.uninstall` 且 force 为 false +- Then 返回 `skill_uninstall_blocked` +- When 对异常 mount 点击 repair +- Then 前端调用 `skills.repair` +- And 成功后 target 状态刷新 + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` + +### SKILL-006 skills mount/unmount + +状态:`Implemented` + +用户行为: +- 用户展开某个 skill,在 provider target 上点击 mount 或 unmount。 + +系统响应: +- Mount 调用 `skills.mount`,传入 providerId、skillSlug、enabled true。 +- 服务端执行 mount 后立即扫描该 relation,并 upsert 扫描结果。 +- Unmount 调用 `skills.unmount`,服务端解除 provider 与 skill 的挂载关系。 +- 前端操作成功后刷新 search 和 health。 + +状态与边界: +- Unconfigured:target 没有 skillDir 或 health 为 unconfigured 时 UI 不展示 mount 操作。 +- Needs repair:enabled 但 status 不是 mounted 时展示 repair 操作。 +- Unavailable:缺少 mount manager、health manager 或 target repo 时返回对应 unavailable 错误。 + +验收标准: +- Given skill `x` 未挂载到 Claude 且 Claude target 已配置 +- When 用户点击 Claude 行的 mount +- Then 前端调用 `skills.mount` +- And 刷新后 Claude token 显示 mounted 状态 + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/web/src/features/workspace/views/shared/skills-panel.tsx` + +### SKILL-007 skills targets list + +状态:`Implemented` + +用户行为: +- 用户查看每个 provider 的 skill target 配置状态和挂载数量。 + +系统响应: +- `skills.targets.list` 基于 provider registry、provider skillMountDirectories、mount counts 和 target health 构建 target 列表。 +- target 包含 providerId、displayName、skillDir、mountedSkillCount、lastHealthState 等信息。 + +状态与边界: +- Unconfigured:provider 没有 skill mount directory 或健康状态为 unconfigured。 +- Counts:mountedSkillCount 来自 mount repo enabled relation 统计。 +- Health required:list targets 需要 health manager 和 target repo。 + +验收标准: +- Given Codex provider 配置了 skill mount directory 且有两个 enabled mounts +- When 调用 `skills.targets.list` +- Then Codex target 返回 mountedSkillCount 为 2 + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/server/src/skills/target-registry.ts` + +### SKILL-008 skills health scan + +状态:`Implemented` + +用户行为: +- 用户点击 Skills panel 的 scan 操作,或 panel 初始化时自动扫描。 + +系统响应: +- `skills.health.scan` 先 discover 当前 mount repo 中的 mounts。 +- 对每个 mount 执行 scan,并把扫描结果 upsert 到 mount repo。 +- 返回 targets 和 scanned mounts。 +- 前端根据 mounts 构建 mountsBySkillSlug,并刷新 library。 + +状态与边界: +- Discover:扫描前会同步已发现 relation。 +- Per-mount scan:所有 mount 并行扫描。 +- Error:命令失败时前端保留错误消息,不覆盖为成功态。 + +验收标准: +- Given mount repo 中有一个 missing_target relation +- When 用户点击 scan +- Then `skills.health.scan` 返回该 mount 的扫描状态 +- And UI 对应 target 显示需要修复或异常原因 + +代码索引: +- `packages/server/src/commands/skills.ts` +- `packages/web/src/features/workspace/actions/use-skills-panel.ts` + +## 6. 未确认项 + +- 外部 skills hub 网络失败时的 UI 错误态需在第二轮确认。 diff --git a/docs/product-spec/modules/supervisor.zh-CN.md b/docs/product-spec/modules/supervisor.zh-CN.md new file mode 100644 index 000000000..414d03a95 --- /dev/null +++ b/docs/product-spec/modules/supervisor.zh-CN.md @@ -0,0 +1,272 @@ +# Supervisor + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Supervisor 创建、列表、详情、更新、暂停、恢复、触发、删除、restore。 +- 桌面组件和移动端 Supervisor Sheet。 + +不覆盖: +- Provider 内部执行策略。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Supervisor panel/card | Desktop | 查看和管理 supervisor。 | +| Mobile Supervisor Sheet | Mobile | 移动端查看 supervisor 状态和详情。 | +| Objective dialog | Both | 创建或编辑目标。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| SUP-001 | 创建 supervisor | Implemented | `supervisor.create`、`packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` | `packages/server/src/__tests__/supervisor-commands.test.ts` | +| SUP-002 | 获取/列表 supervisor | Implemented | `supervisor.get`、`packages/web/src/features/supervisor/actions/use-supervisor.ts` | `packages/server/src/__tests__/supervisor-commands.test.ts` | +| SUP-003 | 更新 supervisor objective | Implemented | `supervisor.update`、`objective-dialog-content.tsx` | `packages/web/src/features/supervisor/views/shared/objective-dialog-content.test.tsx` | +| SUP-004 | 暂停/恢复/触发 supervisor | Implemented | `supervisor.pause`、`resume`、`trigger` | `packages/server/src/__tests__/supervisor-commands.test.ts` | +| SUP-005 | 删除 supervisor | Implemented | `supervisor.delete` | `packages/server/src/__tests__/supervisor-commands.test.ts` | +| SUP-006 | restore supervisor | Implemented | `supervisor.restore`、`supervisor.listRecoverableTargets` | `packages/server/src/__tests__/supervisor-hydrate-restart.test.ts` | +| SUP-007 | 桌面 supervisor card/details | Implemented | `packages/web/src/features/supervisor/views/shared` | `packages/web/src/features/supervisor/views/shared/supervisor-details-content.test.tsx` | +| SUP-008 | 移动端 supervisor sheet | Implemented | `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` | `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx` | + +## 4. 模块级验收线索 + +- 创建 supervisor 后应出现在列表。 +- 暂停、恢复和触发应更新状态。 +- 移动端 sheet 能进入详情层。 + +## 5. 功能点规格 + +### SUP-001 创建 supervisor + +状态:`Implemented` + +用户行为: +- 用户在某个 agent session 上启用 Supervisor,填写 objective、评估 provider、可选 model、最大监督次数和计划时间。 + +系统响应: +- 前端打开 enable dialog,并维护 draft 表单。 +- 确认时调用 `supervisor.create`,传入 sessionId、workspaceId、objective、evaluatorProviderId、可选 evaluatorModel、maxSupervisionCount、scheduledAt。 +- 服务端创建 supervisor,并返回 supervisor 对象。 +- 创建成功后关闭弹窗。 + +状态与边界: +- Validation:objective trim 后必须 1-4000 字符。 +- Evaluator model:trim 后为空则不传。 +- Max count:必须是非负整数;`0` 表示无上限。 +- Scheduled:为空则不传;有值时传毫秒时间戳。 + +验收标准: +- Given 当前 session 尚未启用 Supervisor +- When 用户填写 objective 并确认启用 +- Then 前端调用 `supervisor.create` +- And 创建成功后弹窗关闭 +- And 该 session 显示 Supervisor 状态 + +代码索引: +- `packages/server/src/commands/supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` + +### SUP-002 获取/列表 supervisor + +状态:`Implemented` + +用户行为: +- 用户查看 session 的 Supervisor 卡片或详情。 + +系统响应: +- 前端通过 `supervisor.get` 查询 session 对应 supervisor。 +- 服务端按 sessionId 返回 supervisor 或 null。 +- 前端根据 supervisor state 渲染 inactive、idle、evaluating、injecting、paused、error、stopped 等状态。 + +状态与边界: +- Null:没有 supervisor 时显示未启用入口。 +- Busy:`evaluating` 或 `injecting` 被视为 busy。 +- Error:action error 在前端保留 6 秒后清理。 + +验收标准: +- Given session 已有 state 为 `paused` 的 supervisor +- When UI 加载该 session 的 Supervisor 卡片 +- Then 卡片显示 paused 状态 +- And 提供 resume 操作 + +代码索引: +- `packages/server/src/commands/supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` + +### SUP-003 更新 supervisor objective + +状态:`Implemented` + +用户行为: +- 用户编辑已有 supervisor 的 objective、评估 provider、model、最大监督次数或计划时间。 + +系统响应: +- 前端 edit dialog 以当前 supervisor 字段作为初始值。 +- 确认时调用 `supervisor.update`,传入 supervisor id 和变更字段。 +- 服务端要求至少一个可更新字段存在。 +- 更新成功后关闭弹窗,可返回详情层。 + +状态与边界: +- Validation:objective 仍需 1-4000 字符。 +- Model clearing:model 清空时传 `null`。 +- Scheduled clearing:计划时间清空时传 `null`。 +- No-op:无变更时确认按钮应不可用或命令因缺少字段失败。 + +验收标准: +- Given supervisor objective 为 `修复测试` +- When 用户改为 `完成发布验收` 并保存 +- Then 前端调用 `supervisor.update` +- And 后续详情显示新 objective + +代码索引: +- `packages/server/src/commands/supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` + +### SUP-004 暂停/恢复/触发 supervisor + +状态:`Implemented` + +用户行为: +- 用户从 Supervisor 卡片或详情执行暂停、恢复、立即触发检查。 + +系统响应: +- 暂停调用 `supervisor.pause`。 +- 恢复调用 `supervisor.resume`。 +- 手动触发调用 `supervisor.trigger`。 +- 操作失败时前端展示带失败标签的 action error。 + +状态与边界: +- Busy:evaluating/injecting 状态下 UI 应表现为忙碌,避免重复触发。 +- Error message:服务端错误 message 会拼接到失败标签后。 +- Trigger result:`supervisor.trigger` 返回 cycle,不直接返回 supervisor。 + +验收标准: +- Given supervisor 当前 state 为 `idle` +- When 用户点击暂停 +- Then 前端调用 `supervisor.pause` +- And 状态刷新后显示 `paused` +- When 用户点击恢复 +- Then 前端调用 `supervisor.resume` + +代码索引: +- `packages/server/src/commands/supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-supervisor-actions.ts` +- `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` + +### SUP-005 删除 supervisor + +状态:`Implemented` + +用户行为: +- 用户或内部流程删除一个 supervisor。 + +系统响应: +- 调用 `supervisor.delete`,传入 supervisor id。 +- 服务端删除目标 supervisor 并返回空对象。 + +状态与边界: +- Missing:删除不存在 id 的行为由 supervisor manager 决定。 +- UI entry:当前代码中删除命令存在,稳定用户入口需结合详情页继续确认。 + +验收标准: +- Given supervisor id 存在 +- When 调用 `supervisor.delete` +- Then 后续 `supervisor.get` 对该 session 返回 null + +代码索引: +- `packages/server/src/commands/supervisor.ts` + +### SUP-006 restore supervisor + +状态:`Implemented` + +用户行为: +- 用户在创建或编辑 supervisor 时打开恢复入口,从历史 recoverable target 中恢复目标记忆。 + +系统响应: +- 前端进入 restore step 后调用 `supervisor.listRecoverableTargets`。 +- 返回目标会过滤掉当前 supervisor 的 targetId。 +- 用户选择 target 后确认,前端调用 `supervisor.restore`。 +- 服务端基于 sourceTargetId 创建或恢复到当前 session,并应用 evaluator 设置。 + +状态与边界: +- Loading:recoverable targets 加载中显示恢复加载态。 +- Empty:没有可恢复目标时显示空态。 +- Selection required:restore step 没有选中 target 时不能确认。 +- Restore fields:restore 不使用当前 objective 文本,而使用 source target。 + +验收标准: +- Given workspace 有一个可恢复 target +- When 用户打开 restore step 并选择该 target +- Then 前端调用 `supervisor.restore` +- And 不调用 `supervisor.create` +- And 成功后弹窗关闭 + +代码索引: +- `packages/server/src/commands/supervisor.ts` +- `packages/web/src/features/supervisor/actions/use-objective-dialog-state.ts` +- `packages/web/src/features/supervisor/views/shared/objective-dialog-content.tsx` + +### SUP-007 桌面 supervisor card/details + +状态:`Implemented` + +用户行为: +- 用户在桌面工作区查看 Supervisor 卡片、打开详情、编辑 objective 或查看 target memory。 + +系统响应: +- 卡片展示标题、状态、完成周期数和主要操作。 +- 详情展示 objective、evaluator、cycle 数、运行状态、错误原因、reasoning、进度列表和 active item。 +- 编辑入口打开 objective dialog。 + +状态与边界: +- Error:state 为 error 时优先展示最近错误 cycle 或 supervisor errorReason。 +- Runtime:evaluating/injecting 展示运行中状态。 +- Target memory:没有 target memory 时对应区域应保持可渲染,不阻断详情。 + +验收标准: +- Given supervisor 有 currentTargetMemory 和 recentTargetCycles +- When 用户打开详情 +- Then 详情显示 objective、evaluator、cycle 数和进度项 + +代码索引: +- `packages/web/src/features/supervisor/views/shared/supervisor-card.tsx` +- `packages/web/src/features/supervisor/views/shared/supervisor-details-content.tsx` + +### SUP-008 移动端 supervisor sheet + +状态:`Implemented` + +用户行为: +- 移动端用户点击 Supervisor badge,打开 sheet 查看状态、详情或编辑。 + +系统响应: +- 移动端 sheet 复用 supervisor dialog state 和详情内容。 +- enable/edit/restore 流程在 sheet 内以层级视图呈现。 +- 保存或恢复成功后关闭对应层级。 + +状态与边界: +- Mobile form:确认按钮受 objective、变更状态、restore selection、max count 校验约束。 +- Return:编辑完成后可返回详情层。 +- Restore:移动端 restore 行为与桌面一致。 + +验收标准: +- Given 移动端 session 已有 supervisor +- When 用户打开 Supervisor sheet 并点击编辑 +- Then sheet 展示编辑表单 +- And 保存后调用 `supervisor.update` + +代码索引: +- `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.tsx` +- `packages/web/src/features/supervisor/views/mobile/mobile-supervisor-badge.tsx` + +## 6. 未确认项 + +- recoverable targets 的用户选择流程需在第二轮细化。 diff --git a/docs/product-spec/modules/terminal.zh-CN.md b/docs/product-spec/modules/terminal.zh-CN.md new file mode 100644 index 000000000..de7ce512f --- /dev/null +++ b/docs/product-spec/modules/terminal.zh-CN.md @@ -0,0 +1,214 @@ +# Terminal + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Shell terminal 创建、列表、输入、关闭、resize、snapshot、replay。 +- Agent terminal 输出承载。 +- 恢复协调、hydration、移动端软键和长按复制。 +- 终端粘贴/拖拽上传。 + +不覆盖: +- Agent session 生命周期本身。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 底部 Terminal Panel | Desktop | 创建、切换、关闭 shell terminal。 | +| Mobile Terminal Sheet | Mobile | 移动端终端输入和软键。 | +| Agent session terminal | Both | 显示 agent 运行输出并接收输入。 | +| 粘贴/拖拽文件 | Desktop | 上传文件并插入终端命令。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| TERM-001 | terminal 列表 | Implemented | `terminal.list`、`use-terminal-actions.ts` | `packages/server/src/__tests__/terminal-commands.test.ts` | +| TERM-002 | 创建 shell terminal | Implemented | `terminal.create`、`use-create-shell-terminal.ts` | `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.test.tsx` | +| TERM-003 | terminal 输入 | Implemented | `terminal.input`、`wsClient.sendTerminalInput` | `packages/server/src/__tests__/terminal-commands.test.ts` | +| TERM-004 | terminal resize | Implemented | `terminal.resize`、`xterm-host.tsx` | `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` | +| TERM-005 | terminal close | Implemented | `terminal.close`、`use-terminal-actions.ts` | `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` | +| TERM-006 | snapshot / replay | Implemented | `terminal.snapshot`、`terminal.replay`、`replay-state.ts` | `packages/server/src/__tests__/terminal-ring-buffer-tail.test.ts` | +| TERM-007 | hydration 和 recovery coordinator | Implemented | `hydration-coordinator.ts`、`recovery-coordinator.ts` | `hydration-coordinator.test.ts`、`recovery-coordinator.test.ts` | +| TERM-008 | terminal theme background sync | Implemented | `terminal.syncThemeBackground` | 手工验收:主题切换后终端背景 | +| TERM-009 | 移动端软键 | Implemented | `mobile/virtual-terminal-keys.ts`、`mobile-terminal-input-bar.tsx` | `virtual-terminal-keys.test.ts`、`mobile-terminal-input-bar.test.tsx` | +| TERM-010 | 移动端长按复制行 | Implemented | `mobile/long-press-copy-line.ts` | `mobile/long-press-copy-line.test.ts` | +| TERM-011 | 粘贴/拖拽上传 | Implemented | `uploads/use-paste-drop-upload.ts`、`uploads/upload-files.ts` | `use-paste-drop-upload.test.tsx`、`upload-files.test.ts` | +| TERM-012 | shell quote 上传路径 | Implemented | `uploads/quote-shell.ts` | `quote-shell.test.ts` | + +## 4. 模块级验收线索 + +- 新建 terminal 后应出现在 tab 列表并自动激活。 +- 输入命令后终端应显示输出。 +- 刷新或重连后应能 replay 终端快照。 +- 移动端软键应能发送常用控制输入。 + +## 5. 功能点规格 + +### TERM-001 terminal 列表 + +状态:`Implemented` + +用户行为: +- 用户进入已打开的 workspace,并查看 terminal panel。 + +系统响应: +- 前端调用 `terminal.list`。 +- 服务端返回所有属于该 workspace 的 terminal DTO。 +- 前端只把 kind 为 `shell` 的 terminal 加入 shell terminal tab 列表。 + +状态与边界: +- Loading:切换 workspace 时先清空当前 terminal ids 和 active terminal。 +- Success:恢复 shell terminal metadata,并默认激活第一个 shell terminal。 +- Error:拉取失败时显示 error toast。 + +验收标准: +- Given workspace 中存在两个 shell terminal 和一个 agent terminal +- When terminal panel 拉取列表 +- Then shell terminal tab 只显示两个 shell terminal +- And agent terminal 不出现在 shell tab 列表中 + +代码索引: +- `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` +- `packages/server/src/commands/terminal.ts` + +### TERM-002 创建 shell terminal + +状态:`Implemented` + +用户行为: +- 用户点击新建 terminal。 + +系统响应: +- 前端调用 `terminal.create`。 +- 服务端校验 workspace 存在。 +- 如果传入 `cwdPath`,必须是 workspace-relative,且必须存在并是目录。 +- 服务端根据平台选择 shell:Windows 使用 `ComSpec`/`COMSPEC`/`cmd.exe`,其他平台使用 `SHELL` 或 `/bin/bash -i`。 +- 创建成功后通过 terminal created 事件加入 tab 并激活。 + +状态与边界: +- Success:新 terminal kind 为 `shell`,cwd 默认为 workspace path。 +- Error:workspace 不存在返回 `workspace_not_found`;cwd 越界或绝对路径返回 `invalid_cwd_path`;目录不存在或不是目录返回对应错误。 + +验收标准: +- Given 已打开 workspace +- When 用户创建 shell terminal +- Then terminal 列表新增一个 shell terminal +- And 新 terminal 成为 active terminal + +代码索引: +- `packages/web/src/features/terminal-panel/actions/use-create-shell-terminal.ts` +- `packages/server/src/commands/terminal.ts` + +### TERM-003 terminal 输入 + +状态:`Implemented` + +用户行为: +- 用户在 shell terminal 或 agent terminal 中输入内容。 + +系统响应: +- `terminal.input` 支持 base64 bytes 和 binary transport。 +- 如果 terminal 关联 session,输入转发到 session manager。 +- 如果不是 session terminal,输入写入 terminal manager。 + +状态与边界: +- Success:输入写入目标 terminal/session。 +- Error:binary transport 缺少 payload 时返回 `terminal_input_binary_missing`。 + +验收标准: +- Given 一个 shell terminal +- When 用户输入命令并回车 +- Then 服务端把输入写入 terminal manager + +- Given 一个 agent session terminal +- When 用户提交 prompt +- Then 服务端把输入转发到 session manager + +代码索引: +- `packages/server/src/commands/terminal.ts` +- `packages/web/src/ws/client.ts` + +### TERM-004 terminal resize + +状态:`Implemented` + +用户行为: +- 用户调整 terminal 面板尺寸或浏览器窗口尺寸变化。 + +系统响应: +- 前端发送 `terminal.resize`,包含正整数 cols 和 rows。 +- 服务端如果 terminal 属于 session,则 resize session;否则 resize shell terminal。 + +状态与边界: +- Success:目标 PTY/session 接收新尺寸。 +- Validation:cols 和 rows 必须是正整数。 + +验收标准: +- Given 一个 active shell terminal +- When terminal host 计算出新的 cols/rows +- Then 前端发送 `terminal.resize` +- And 服务端 resize 对应 terminal + +代码索引: +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- `packages/server/src/commands/terminal.ts` + +### TERM-006 snapshot / replay + +状态:`Implemented` + +用户行为: +- 用户刷新页面、重连或重新打开 terminal。 + +系统响应: +- 前端请求 `terminal.snapshot` 或 `terminal.replay`。 +- 服务端通过 binary frame 返回 snapshot/replay payload。 +- 返回结果包含 `transport: "binary"`、`streamId`、`size` 和 seq。 + +状态与边界: +- Success:前端用 binary payload 恢复 terminal 内容。 +- Not ok:terminal manager 返回非 ok status 时,命令直接返回该 status,不发送 binary frame。 + +验收标准: +- Given terminal 已产生输出 +- When 前端请求 replay +- Then 服务端返回 binary transport metadata +- And 对应 client 收到 replay binary frame + +代码索引: +- `packages/server/src/commands/terminal.ts` +- `packages/web/src/features/terminal-panel/replay-state.ts` + +### TERM-011 粘贴/拖拽上传 + +状态:`Implemented` + +用户行为: +- 用户把文件粘贴或拖拽到 terminal 区域。 + +系统响应: +- 前端上传文件,并生成可粘贴进 shell 的 quoted path。 +- 上传后的命令片段插入 terminal 输入路径。 + +状态与边界: +- Success:上传完成后生成 shell-safe 路径。 +- Error:上传失败时应显示或保留错误反馈。 + +验收标准: +- Given terminal panel active +- When 用户拖入一个文件 +- Then 文件上传流程启动 +- And 生成的路径经过 shell quote + +代码索引: +- `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts` +- `packages/web/src/features/terminal-panel/uploads/upload-files.ts` +- `packages/web/src/features/terminal-panel/uploads/quote-shell.ts` + +## 6. 未确认项 + +- 上传文件的 server 端路径和清理策略需在第二轮结合 `packages/server/src/uploads` 确认。 diff --git a/docs/product-spec/modules/ui-components.zh-CN.md b/docs/product-spec/modules/ui-components.zh-CN.md new file mode 100644 index 000000000..d32a6bfbc --- /dev/null +++ b/docs/product-spec/modules/ui-components.zh-CN.md @@ -0,0 +1,44 @@ +# UI Components + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- `packages/web/src/components/ui` 下的可复用 UI 原语。 +- 组件 README 和单测覆盖。 + +不覆盖: +- 具体业务页面的组合逻辑。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 业务页面引用 | Internal | UI primitive 被业务模块组合使用。 | +| UI preview | Internal | 组件预览或设计验证。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| UI-001 | Button / IconButton | Implemented | `components/ui/button`、`icon-button` | `button/index.test.tsx`、`icon-button/index.test.tsx` | +| UI-002 | Input / Textarea / Select / Switch | Implemented | `components/ui/input`、`textarea`、`select`、`switch` | 对应 `index.test.tsx` | +| UI-003 | Modal / Drawer / Sheet / ConfirmDialog | Implemented | `components/ui/modal`、`drawer`、`sheet`、`confirm-dialog` | 对应 `index.test.tsx` | +| UI-004 | Popover / Tooltip / ActionMenu / LocalOverlay | Implemented | `components/ui/popover`、`tooltip`、`action-menu`、`local-overlay` | 对应 `index.test.tsx` | +| UI-005 | Badge / Tag / Pill / Notice / EmptyState | Implemented | `components/ui/badge`、`tag`、`pill`、`notice`、`empty-state` | 对应测试文件 | +| UI-006 | Tabs / SegmentedControl | Implemented | `components/ui/tabs`、`segmented-control` | 对应测试文件 | +| UI-007 | Spinner / ProgressBar / StatusDot | Implemented | `components/ui/spinner`、`progress-bar`、`status-dot` | 对应测试文件 | +| UI-008 | Toast / ThemedIcon / WorkbenchLayer | Implemented | `components/ui/toast`、`themed-icon`、`workbench-layer` | 对应测试文件 | +| UI-009 | DateTimePicker | Implemented | `components/ui/datetime-picker` | `datetime-picker/index.test.tsx` | +| UI-010 | 内部 portal、focus trap、body scroll lock | Internal | `components/ui/_internal` | `_internal/use-viewport.test.tsx` | + +## 4. 模块级验收线索 + +- 每个 primitive 应通过对应组件单测。 +- Overlay 类组件应处理焦点、关闭和滚动锁定。 +- 表单类组件应支持禁用、错误和键盘交互。 + +## 5. 未确认项 + +- UI preview 覆盖范围需在第二轮结合 `packages/web/src/ui-preview` 确认。 diff --git a/docs/product-spec/modules/updates.zh-CN.md b/docs/product-spec/modules/updates.zh-CN.md new file mode 100644 index 000000000..ea35a764d --- /dev/null +++ b/docs/product-spec/modules/updates.zh-CN.md @@ -0,0 +1,40 @@ +# Updates + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 更新状态获取、检查、准备安装、开始安装。 +- Footer update rail。 + +不覆盖: +- 发布说明文案。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Footer update rail | Desktop | 展示更新状态和操作入口。 | +| Settings / About | Both | 可能展示版本或更新状态。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| UPDATE-001 | 获取更新状态 | Implemented | `updates.getState`、`packages/web/src/features/updates/atoms.ts` | `packages/server/src/commands/updates.test.ts` | +| UPDATE-002 | 检查更新 | Implemented | `updates.check` | `packages/server/src/commands/updates.test.ts` | +| UPDATE-003 | 准备安装 | Implemented | `updates.prepareInstall` | `packages/server/src/commands/updates.test.ts` | +| UPDATE-004 | 开始安装 | Implemented | `updates.startInstall` | `packages/server/src/commands/updates.test.ts` | +| UPDATE-005 | Footer update rail | Implemented | `footer-update-rail.tsx` | `footer-update-rail.test.tsx` | +| UPDATE-006 | update state repo | Internal | `packages/server/src/update`、`storage` | `packages/server/src/__tests__/update-state-repo.test.ts` | + +## 4. 模块级验收线索 + +- 检查更新后应更新状态。 +- 有可安装更新时 footer rail 应展示操作。 +- 安装流程失败时应保留错误信息。 + +## 5. 未确认项 + +- 实际安装命令的跨平台验收需单独环境验证。 diff --git a/docs/product-spec/modules/welcome.zh-CN.md b/docs/product-spec/modules/welcome.zh-CN.md new file mode 100644 index 000000000..9a3042adc --- /dev/null +++ b/docs/product-spec/modules/welcome.zh-CN.md @@ -0,0 +1,39 @@ +# Welcome + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 欢迎页。 +- 打开工作区入口。 +- 设置入口。 + +不覆盖: +- 工作区目录浏览和打开后的状态更新,写入 Workspace 模块。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/` | Both | 默认欢迎入口或无 workspace 时的回退页面。 | +| 欢迎页打开工作区按钮 | Both | 打开 workspace launch modal。 | +| 欢迎页设置按钮 | Both | 进入设置页。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WELCOME-001 | 欢迎页渲染 | Implemented | `packages/web/src/features/welcome/index.tsx` | `packages/web/src/features/welcome/index.test.tsx` | +| WELCOME-002 | 从欢迎页打开工作区启动器 | Implemented | `packages/web/src/features/welcome/index.tsx`、`packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` | `packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` | +| WELCOME-003 | 从欢迎页进入设置页 | Implemented | `packages/web/src/features/welcome/index.tsx` | `packages/web/src/features/welcome/index.test.tsx` | + +## 4. 模块级验收线索 + +- 无 workspace 时访问首页能看到欢迎页。 +- 点击打开工作区应显示目录浏览入口。 +- 点击设置应进入设置页。 + +## 5. 未确认项 + +- 欢迎页文案和视觉信息不在第一轮索引中验收,后续可在 UI/文案规格中细化。 diff --git a/docs/product-spec/modules/work-analysis.zh-CN.md b/docs/product-spec/modules/work-analysis.zh-CN.md new file mode 100644 index 000000000..eee8f789f --- /dev/null +++ b/docs/product-spec/modules/work-analysis.zh-CN.md @@ -0,0 +1,263 @@ +# Work Analysis + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 工作分析页面、时间范围、dashboard、basic/deep analysis。 +- 任务分类、效率、重试、证据采样、日志源适配。 + +不覆盖: +- 系统监控。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Work Analysis page | Both | 查看工作分析数据。 | +| 时间范围控件 | Both | 选择分析范围。 | +| Refresh / Rebuild / Run | Both | 触发分析刷新或重建。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WA-001 | Work Analysis page 渲染 | Implemented | `packages/web/src/features/work-analysis/page.tsx` | `packages/web/src/features/work-analysis/page.test.tsx` | +| WA-002 | 前端 controller 和 dispatch | Implemented | `use-work-analysis-controller.ts`、`use-work-analysis-dispatch.ts` | `use-work-analysis-controller.test.tsx` | +| WA-003 | 时间范围和格式化 | Implemented | `lib/time-range.ts`、`format.ts` | `format.test.ts` | +| WA-004 | work analysis get/basic/deep | Implemented | `work.analysis.get`、`runBasic`、`runDeep` | `packages/server/src/__tests__/work-analysis-commands.test.ts` | +| WA-005 | dashboard get/refresh/rebuild | Implemented | `work.analysis.dashboard.*` | `packages/server/src/__tests__/work-analysis-commands.test.ts` | +| WA-006 | log collector 和 source adapters | Internal | `packages/server/src/work-analysis/log-sources` | `work-analysis-log-collector.test.ts`、`work-analysis-log-sources-file-adapters.test.ts` | +| WA-007 | task classifier | Internal | `work-analysis/classification` | `work-analysis-task-classifier.test.ts` | +| WA-008 | efficiency/retry metrics | Internal | `work-analysis/metrics` | `work-analysis-efficiency-metrics.test.ts`、`work-analysis-retry-metrics.test.ts` | +| WA-009 | optimize/exporters | Internal | `work-analysis/optimize`、`exporters` | `work-analysis-efficiency-and-optimize.test.ts` | + +## 4. 模块级验收线索 + +- 页面加载后能展示当前时间范围内的分析结果。 +- Refresh 或 rebuild 后 dashboard 数据更新。 +- 缺少数据时应显示空态或说明。 + +## 5. 功能点规格 + +### WA-001 Work Analysis page 渲染 + +状态:`Implemented` + +用户行为: +- 用户进入 Work Analysis 页面或 Settings 的 Analysis 分区。 + +系统响应: +- 页面创建 work analysis controller,并从 URL query 解析时间范围和目录筛选。 +- 控制器加载 dashboard 后渲染状态条、KPI、token 趋势、贡献排行、任务/工具/skill 归因和小时热力图。 +- 没有 dashboard 时展示空态,并提供立即刷新按钮。 + +状态与边界: +- Loading:首次读取索引时显示读取提示。 +- Empty:没有索引或数据时显示“暂无工作分析索引”。 +- Warning:scanState error 或 dashboard quality warnings 以 Notice 展示。 +- URL sync:筛选条件变化后写回路由 query。 + +验收标准: +- Given 当前没有 dashboard 数据 +- When 用户打开 Work Analysis 页面 +- Then 页面显示空态 +- And 提供立即刷新操作 + +代码索引: +- `packages/web/src/features/work-analysis/page.tsx` +- `packages/web/src/features/work-analysis/navigation.ts` + +### WA-002 前端 controller 和 dispatch + +状态:`Implemented` + +用户行为: +- 用户切换时间范围、选择目录、刷新或强制重建 dashboard。 + +系统响应: +- `useWorkAnalysisController` 维护 selected/available workspace paths、range preset、custom range、dashboard loading 和刷新状态。 +- query 无效时不发请求,并清空 dashboard record。 +- 加载 dashboard 时使用 request id 和 cancelled guard,避免旧请求覆盖新结果。 +- `useWorkAnalysisDispatch` 遇到 `activation_required` 时跳转 `/session-gate`。 + +状态与边界: +- Invalid time:custom start/end 无效时 query 为 null。 +- Empty customized paths:用户自定义目录但选择为空时 query 为 null。 +- Race:后返回的旧 dashboard 请求不能覆盖新筛选结果。 +- Session gate:服务端要求激活时中断当前数据流并导航。 + +验收标准: +- Given 用户快速切换 24h 到 7d +- When 较旧的 24h 请求晚于 7d 返回 +- Then controller 保留 7d 对应 dashboard + +代码索引: +- `packages/web/src/features/work-analysis/use-work-analysis-controller.ts` +- `packages/web/src/features/work-analysis/use-work-analysis-dispatch.ts` + +### WA-003 时间范围和目录筛选 + +状态:`Implemented` + +用户行为: +- 用户选择 24h、7d、30d、90d 或自定义开始/结束时间。 +- 用户按 workspace path 筛选 dashboard,或恢复全部目录。 + +系统响应: +- 时间筛选构造 `timeRange`,preset 使用 `{ preset }`,自定义范围使用 `{ startAt, endAt }`。 +- 目录筛选默认跟随 dashboard 中的项目路径。 +- 第一次点击目录时进入自定义模式,并只选择被点击目录。 +- 在只剩一个目录时取消该目录,会退出自定义模式并恢复全部目录。 + +状态与边界: +- Custom range:开始/结束时间由 DateTimePicker 输入。 +- No directories:没有可筛选目录时显示空提示。 +- URL:自定义筛选会写入路由,便于刷新恢复。 + +验收标准: +- Given dashboard 中有 `/repo/a` 和 `/repo/b` +- When 用户点击 `/repo/a` +- Then query 只包含 `/repo/a` +- When 用户再次取消 `/repo/a` +- Then query 恢复为全部目录 + +代码索引: +- `packages/web/src/features/work-analysis/page.tsx` +- `packages/web/src/features/work-analysis/lib/time-range.ts` +- `packages/web/src/features/work-analysis/use-work-analysis-controller.ts` + +### WA-004 work analysis get/basic/deep + +状态:`Implemented` + +用户行为: +- 内部流程或测试直接请求基础/深度工作分析。 + +系统响应: +- `work.analysis.get` 返回当前查询结果。 +- `work.analysis.runBasic` 触发基础分析。 +- `work.analysis.runDeep` 触发深度分析。 +- 三个命令都接受可选 workspacePaths 和必填 timeRange。 + +状态与边界: +- Preset:timeRange 可为 `24h|7d|30d|90d` preset。 +- Custom:timeRange 可为 startAt/endAt 数值。 +- Unavailable:workAnalysisService 缺失时返回 `work_analysis_unavailable`。 +- UI:当前主页面主要使用 dashboard 命令;basic/deep 是服务端能力。 + +验收标准: +- Given workAnalysisService 已配置 +- When 调用 `work.analysis.runBasic` 且 timeRange 为 `{ preset: "7d" }` +- Then 服务端调用 basic analysis 并返回结果 + +代码索引: +- `packages/server/src/commands/work-analysis.ts` +- `packages/server/src/work-analysis` + +### WA-005 dashboard get/refresh/rebuild + +状态:`Implemented` + +用户行为: +- 用户查看 dashboard、点击立即刷新,或确认强制刷新索引。 + +系统响应: +- 页面加载时调用 `work.analysis.dashboard.get`。 +- 点击刷新调用 `work.analysis.dashboard.refresh`,成功后替换 dashboardRecord。 +- 点击强制刷新先弹出确认框,确认后调用 `work.analysis.dashboard.rebuild`。 +- 强制刷新会清空并重建小时索引,不删除原始日志。 + +状态与边界: +- Refreshing:刷新按钮展示 loading。 +- Rebuilding:确认按钮在重建中禁用并展示进行中文案。 +- Error:命令失败时当前代码保持原 dashboard,不自动展示单独错误,只依赖 scanState/quality warnings。 +- Unavailable:服务缺失返回 `work_analysis_unavailable`。 + +验收标准: +- Given 页面已有 dashboard +- When 用户点击强制刷新并确认 +- Then 前端调用 `work.analysis.dashboard.rebuild` +- And 成功返回后 dashboardRecord 被更新 + +代码索引: +- `packages/server/src/commands/work-analysis.ts` +- `packages/web/src/features/work-analysis/page.tsx` +- `packages/web/src/features/work-analysis/use-work-analysis-controller.ts` + +### WA-006 log collector 和 source adapters + +状态:`Internal` + +用户行为: +- 无直接稳定 UI;作为 dashboard 和 analysis 服务的数据源。 + +系统响应: +- log collector 从配置的日志源读取 agent/session 记录。 +- source adapters 适配不同 provider 或文件日志格式。 +- 采集结果供 classifier、metrics 和 dashboard projection 使用。 + +状态与边界: +- Missing logs:没有可读日志时上层 dashboard 应进入空态或低数据质量提示。 +- Adapter errors:源适配失败应被服务层汇总为 scanState 或 warning。 + +验收标准: +- Given 日志源包含可解析 session 记录 +- When collector 执行扫描 +- Then 返回标准化记录供后续分析 + +代码索引: +- `packages/server/src/work-analysis/log-sources` +- `packages/server/src/work-analysis/log-collector.ts` + +### WA-007 task classifier + +状态:`Internal` + +用户行为: +- 无直接 UI;用户在 dashboard 中看到任务类型分布。 + +系统响应: +- classifier 将 session 或 message 证据归入任务类别。 +- dashboard 将分类结果汇总到任务类型 token 分布。 + +状态与边界: +- Unknown:无法识别的记录应落入默认/未知类别,而不是阻断 dashboard。 +- Evidence:分类基于日志证据,不依赖旧 PRD。 + +验收标准: +- Given 一组包含编码任务证据的记录 +- When classifier 处理 +- Then 输出对应任务类别供 dashboard 聚合 + +代码索引: +- `packages/server/src/work-analysis/classification` + +### WA-008 efficiency/retry metrics + +状态:`Internal` + +用户行为: +- 用户在 dashboard KPI 或质量提示中查看效率、重试等指标。 + +系统响应: +- metrics 模块基于分析记录计算 active time、token、retry 等指标。 +- dashboard projection 将指标格式化为 KPI、趋势和排行。 + +状态与边界: +- Empty records:输入为空时应返回空指标或 0 值,不阻断页面。 +- Formatting:前端负责 duration、percent、token 的展示格式。 + +验收标准: +- Given 分析记录包含 token 和 activeDuration +- When metrics 聚合 +- Then dashboard KPI 包含总 token 和活跃时间 + +代码索引: +- `packages/server/src/work-analysis/metrics` +- `packages/web/src/features/work-analysis/format.ts` + +## 6. 未确认项 + +- 导出功能是否有稳定 UI 入口需在第二轮确认。 diff --git a/docs/product-spec/modules/workspace-desktop.zh-CN.md b/docs/product-spec/modules/workspace-desktop.zh-CN.md new file mode 100644 index 000000000..f8f528e55 --- /dev/null +++ b/docs/product-spec/modules/workspace-desktop.zh-CN.md @@ -0,0 +1,41 @@ +# Workspace Desktop + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 桌面工作区视图。 +- 活动栏、侧栏、主区、底部终端组合。 +- 桌面专属工作区控件。 + +不覆盖: +- 各子面板内部功能,分别写入对应模块。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/workspace` 宽屏视口 | Desktop | 渲染桌面工作区布局。 | +| Workspace activity bar | Desktop | 切换 Files、Git、Search、Skills 等区域。 | +| 顶栏和底部面板 | Desktop | workspace 操作和终端面板入口。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WSD-001 | 桌面工作区整体渲染 | Implemented | `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` | `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` | +| WSD-002 | 桌面活动栏 | Implemented | `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx` | `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` | +| WSD-003 | 桌面 explorer 面板组合 | Implemented | `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` | `packages/web/src/features/workspace/views/shared/explorer-panel.test.tsx` | +| WSD-004 | 桌面工作区状态栏 | Implemented | `packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx` | 手工验收:桌面工作区底部/状态区域 | +| WSD-005 | observer banner 展示 | Implemented | `packages/web/src/features/workspace/views/shared/observer-banner.tsx` | `packages/web/src/features/workspace/views/shared/observer-banner.test.tsx` | + +## 4. 模块级验收线索 + +- 宽屏进入 `/workspace` 时应显示桌面多区域工作台。 +- 活动栏切换不应丢失当前 workspace。 +- 子面板错误不应破坏整体布局。 + +## 5. 未确认项 + +- 各面板的精确视觉尺寸和 resize 行为在 Workspace Tabs / Layout 模块细化。 diff --git a/docs/product-spec/modules/workspace-mobile.zh-CN.md b/docs/product-spec/modules/workspace-mobile.zh-CN.md new file mode 100644 index 000000000..10110d48d --- /dev/null +++ b/docs/product-spec/modules/workspace-mobile.zh-CN.md @@ -0,0 +1,45 @@ +# Workspace Mobile + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- 移动端工作区视图。 +- Dock、Sheet、Drawer、移动端顶部栏。 +- 移动端文件、agent、terminal、supervisor 入口编排。 + +不覆盖: +- 每个 Sheet 内部业务细节。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/workspace` 移动视口 | Mobile | 渲染移动工作区布局。 | +| Mobile Dock | Mobile | 打开 agent、files、terminal、supervisor 等区域。 | +| Workspace Drawer | Mobile | 查看和切换 workspace。 | +| Mobile Topbar | Mobile | 移动端顶栏状态和 workspace 入口。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WSM-001 | 移动工作区整体渲染 | Implemented | `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx` | `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx` | +| WSM-002 | 移动端顶部栏 | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx` | `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx` | +| WSM-003 | 移动端 Dock | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-dock.tsx` | `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx` | +| WSM-004 | Agent Sheet | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-agent-sheet.tsx` | `packages/web/src/features/workspace/views/mobile/mobile-agent-sheet.test.tsx` | +| WSM-005 | Files Sheet | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` | `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` | +| WSM-006 | Workspace Drawer | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx` | `packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.test.tsx` | +| WSM-007 | Mobile explorer panel | Implemented | `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx` | `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` | +| WSM-008 | 移动布局/动效模式 hook | Internal | `packages/web/src/features/workspace/views/mobile/hooks` | 手工验收:移动视口布局与键盘视口变化 | + +## 4. 模块级验收线索 + +- 移动视口进入工作区时应显示 Dock 驱动布局。 +- Dock 打开不同 Sheet 后应保持当前 workspace 上下文。 +- Workspace Drawer 能展示 workspace 列表并切换 active workspace。 + +## 5. 未确认项 + +- 视觉视口 inset 在不同移动浏览器的边界需后续人工设备验收。 diff --git a/docs/product-spec/modules/workspace-tabs-layout.zh-CN.md b/docs/product-spec/modules/workspace-tabs-layout.zh-CN.md new file mode 100644 index 000000000..5cde940c8 --- /dev/null +++ b/docs/product-spec/modules/workspace-tabs-layout.zh-CN.md @@ -0,0 +1,44 @@ +# Workspace Tabs / Layout + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- workspace tab、顶部 tab 交互。 +- workspace UI state 持久化、布局操作、focus/fullscreen。 +- 最近查看目标。 + +不覆盖: +- 文件、Git、终端等面板内容。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| 顶部 workspace tabs | Desktop | 切换、展示 workspace 和 session mini map。 | +| 全屏/专注控件 | Desktop | 切换工作区展示模式。 | +| 工作区导航快捷键 | Desktop | 键盘切换视图或目标。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WSL-001 | workspace tab 展示 | Implemented | `packages/web/src/features/topbar/components/tab.tsx` | `packages/web/src/features/topbar/components/tab.test.tsx` | +| WSL-002 | workspace session mini map | Implemented | `packages/web/src/features/topbar/components/workspace-session-mini-map.tsx` | `packages/web/src/features/topbar/components/workspace-session-mini-map.test.tsx` | +| WSL-003 | UI state 持久化 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-ui-state-persistence.ts`、`workspace.uiState.set` | `packages/web/src/features/workspace/actions/use-workspace-ui-state-persistence.test.tsx` | +| WSL-004 | 工作区布局操作 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-layout-actions.ts` | 手工验收:侧栏、底栏和布局状态 | +| WSL-005 | 工作区全屏 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts`、`workspace-fullscreen-button.tsx` | `packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx` | +| WSL-006 | 最后查看目标持久化 | Implemented | `packages/web/src/features/workspace/actions/use-persist-workspace-last-viewed-target.ts`、`workspace.lastViewedTarget.get/set` | `packages/web/src/features/workspace/actions/use-persist-workspace-last-viewed-target.test.tsx` | +| WSL-007 | 工作区导航快捷键 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.ts` | `packages/web/src/features/workspace/actions/use-workspace-navigation-shortcuts.test.tsx` | +| WSL-008 | 独立 focus mode 组件 | Partial | `packages/web/src/features/focus-mode` | `packages/web/src/features/focus-mode/components/focus-mode.test.tsx` | + +## 4. 模块级验收线索 + +- 切换 workspace tab 后 active workspace 应变化。 +- 修改布局后刷新页面应恢复已持久化状态。 +- 全屏/专注状态不应破坏 workspace 数据。 + +## 5. 未确认项 + +- WSL-008 是否仍是稳定用户入口需在第二轮确认。 diff --git a/docs/product-spec/modules/workspace.zh-CN.md b/docs/product-spec/modules/workspace.zh-CN.md new file mode 100644 index 000000000..389f933a1 --- /dev/null +++ b/docs/product-spec/modules/workspace.zh-CN.md @@ -0,0 +1,192 @@ +# Workspace + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- workspace 列表、打开、关闭、浏览目录、创建目录。 +- active workspace、空态、加载态、错误态。 +- workspace intelligence 和历史记录。 + +不覆盖: +- 桌面布局、移动布局、文件/Git/终端细节。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| `/workspace` | Both | 主工作区页面。 | +| Workspace launch modal | Both | 浏览目录、创建目录、打开 workspace。 | +| Workspace tab / drawer | Both | 切换或关闭 workspace。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WS-001 | 拉取 workspace 列表 | Implemented | `packages/server/src/commands/workspace.ts`、`workspace.list` | `packages/server/src/__tests__/workspace-commands.test.ts` | +| WS-002 | 浏览可打开目录 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts`、`workspace.browse` | `packages/web/src/features/workspace/actions/use-workspace-launch-actions.test.tsx` | +| WS-003 | 创建目录 | Implemented | `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts`、`workspace.mkdir` | `packages/server/src/__tests__/workspace-commands.test.ts` | +| WS-004 | 打开 workspace | Implemented | `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts`、`workspace.open` | `packages/web/src/features/workspace/actions/use-workspace-launch-actions.test.tsx` | +| WS-005 | 关闭 workspace | Implemented | `packages/web/src/features/workspace/actions/use-workspace-close-action.ts`、`workspace.close` | `packages/server/src/__tests__/workspace-close-state-cleanup.test.ts` | +| WS-006 | 激活/停用 workspace | Implemented | `packages/server/src/commands/workspace-activity.ts`、`workspace.activate`、`workspace.deactivate` | `packages/server/src/__tests__/workspace-commands.test.ts` | +| WS-007 | workspace 加载、空态和错误态 | Implemented | `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx`、`workspace-loading-state.tsx`、`workspace-empty-state.tsx` | `packages/web/src/features/workspace/views/shared/workspace-route-gate.test.tsx` | +| WS-008 | workspace 历史列表 | Implemented | `workspace.history.list`、`packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` | 手工验收:启动器最近 workspace 区域 | +| WS-009 | workspace intelligence | Internal | `workspace.intelligence`、`packages/server/src/workspace/intelligence.ts` | `packages/server/src/__tests__/workspace-intelligence-command.test.ts` | +| WS-010 | workspace runtime check 和 validator | Internal | `packages/server/src/workspace` | `packages/server/src/__tests__/workspace/runtime-check.test.ts`、`packages/server/src/__tests__/workspace/validator.test.ts` | + +## 4. 模块级验收线索 + +- 打开有效目录后进入 `/workspace` 并成为 active workspace。 +- 关闭最后一个 workspace 后应进入无 workspace 状态。 +- 目录浏览失败和打开失败应有错误反馈。 + +## 5. 功能点规格 + +### WS-002 浏览可打开目录 + +状态:`Implemented` + +用户行为: +- 用户打开 workspace 启动器。 +- 用户进入某个目录、返回父目录或选择根路径。 + +系统响应: +- 前端通过 `workspace.browse` 请求目录列表。 +- 服务端把空路径、`~` 或相对路径解析到用户 home 目录下。 +- 返回 `currentPath`、`parentPath`、按名称排序的目录列表和 `rootPaths`。 +- 普通文件不出现在目录列表;指向目录的符号链接可以出现。 + +状态与边界: +- Loading:启动器处于 `browsing` 状态。 +- Success:更新当前路径、父路径、目录列表、根路径和 home 路径。 +- Error:请求失败时在启动器内展示错误。 + +验收标准: +- Given 启动器已打开 +- When 用户进入一个包含多个子目录的路径 +- Then 启动器显示该路径下的目录项 +- And 目录项按名称排序 +- And 非目录文件不出现在列表中 + +代码索引: +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- `packages/server/src/commands/workspace.ts` + +### WS-003 创建目录 + +状态:`Implemented` + +用户行为: +- 用户在 workspace 启动器中输入新目录名并提交。 + +系统响应: +- 前端校验目录名不能为空,且不能包含 `/` 或 `\`。 +- 服务端通过 `workspace.mkdir` 创建目录。 +- 创建成功后重新 browse 当前目录,并选中新目录。 + +状态与边界: +- Loading:创建中时 `creatingFolder` 为 true。 +- Success:关闭创建输入态,清空错误,并选中新目录。 +- Error:目录名非法、当前路径为空、创建失败或重新 browse 失败时显示 `createFolderError`。 +- Race:如果用户关闭创建态或发起新的创建请求,旧请求结果会被 request id 忽略。 + +验收标准: +- Given 启动器当前路径有效 +- When 用户创建一个合法的新目录 +- Then 目录创建成功 +- And 启动器刷新当前路径 +- And 新目录被选中 + +代码索引: +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- `packages/server/src/commands/workspace.ts` + +### WS-004 打开 workspace + +状态:`Implemented` + +用户行为: +- 用户选择目录并点击打开,或从最近 workspace 记录打开路径。 + +系统响应: +- 前端调用 `workspace.open`。 +- 服务端通过 workspace manager 打开路径,记录 workspace history,并同步 agent instructions。 +- 打开成功后前端设置 active workspace,写入 workspace map,恢复 editor UI state,并把 workspace id 放到 order 头部。 +- 如果当前不在 `/workspace`,前端跳转到 `/workspace`。 + +状态与边界: +- Loading:打开过程中 `loading` 为 true。 +- Success:modal 关闭,workspace load state 进入 `ready`,load error 清空。 +- Error:命令失败或返回缺少 workspace id 时跳转到 diagnostics,context 为 `workspace_open`。 + +验收标准: +- Given 启动器中已选择有效路径 +- When 用户确认打开 +- Then active workspace 切换为打开结果 +- And 页面进入 `/workspace` +- And workspace order 中该 workspace 位于最前 + +代码索引: +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- `packages/server/src/commands/workspace.ts` + +### WS-005 关闭 workspace + +状态:`Implemented` + +用户行为: +- 用户从 workspace tab、drawer 或其他关闭入口关闭 workspace。 + +系统响应: +- 前端调用 `workspace.close`。 +- 服务端关闭 workspace,并清理对应 workspace 状态。 +- 前端应从 workspace map/order 中移除该 workspace,并选择下一个可用 workspace 或进入空态。 + +状态与边界: +- Success:关闭目标 workspace 后不再显示在列表中。 +- Empty:关闭最后一个 workspace 后进入无 workspace 状态。 +- Error:关闭失败时应保持原 workspace 状态并反馈错误。 + +验收标准: +- Given 当前有两个 workspace +- When 用户关闭 active workspace +- Then active workspace 从列表中移除 +- And 应自动选择另一个 workspace + +代码索引: +- `packages/web/src/features/workspace/actions/use-workspace-close-action.ts` +- `packages/server/src/commands/workspace.ts` + +### WS-007 workspace 加载、空态和错误态 + +状态:`Implemented` + +用户行为: +- 用户访问 `/workspace` 或刷新工作区页面。 + +系统响应: +- route gate 根据 workspace load state、active workspace 和 workspace 列表决定显示内容。 +- 没有 workspace 时展示空态或回到欢迎路径。 +- active workspace 未解析或加载失败时展示加载态或错误态。 + +状态与边界: +- Loading:workspace 列表或 active workspace 仍在解析。 +- Empty:当前没有可进入 workspace。 +- Ready:active workspace 可用,渲染桌面或移动工作区。 +- Error:加载失败时展示错误状态。 + +验收标准: +- Given workspace 列表为空 +- When 用户访问 `/workspace` +- Then 页面不应渲染无效工作区 +- And 应展示空态或返回欢迎入口 + +代码索引: +- `packages/web/src/features/workspace/views/shared/workspace-route-gate.tsx` +- `packages/web/src/features/workspace/views/shared/workspace-loading-state.tsx` +- `packages/web/src/features/workspace/views/shared/workspace-empty-state.tsx` + +## 6. 未确认项 + +- workspace intelligence 的用户入口需在 Agent Instructions 或 Work Analysis 规格轮确认。 diff --git a/docs/product-spec/modules/worktrees.zh-CN.md b/docs/product-spec/modules/worktrees.zh-CN.md new file mode 100644 index 000000000..b81a148b9 --- /dev/null +++ b/docs/product-spec/modules/worktrees.zh-CN.md @@ -0,0 +1,43 @@ +# Worktrees + +> 第一轮模块索引。本文只记录代码可见的功能点、状态、代码入口和验收入口。 + +## 1. 模块范围 + +覆盖: +- Git worktree 列表、创建、移除、状态、diff、tree。 +- Worktree 管理 surface、详情面板、摘要卡。 + +不覆盖: +- 普通 Git branch 操作。 + +## 2. 用户入口 + +| 入口 | 端 | 说明 | +| --- | --- | --- | +| Worktree manager surface | Desktop | 查看和管理 worktree。 | +| Worktree detail panel | Desktop | 查看详情、状态和 diff。 | +| Worktree modal | Desktop | 创建或管理 worktree。 | + +## 3. 功能点清单 + +| ID | 功能点 | 状态 | 代码入口 | 验收入口 | +| --- | --- | --- | --- | --- | +| WT-001 | worktree 列表 | Implemented | `worktree.list`、`worktree-manager-surface.tsx` | `packages/server/src/__tests__/worktree-commands.test.ts` | +| WT-002 | 创建 worktree | Implemented | `worktree.create`、`use-worktree-management-actions.ts` | `packages/server/src/__tests__/worktree-commands.test.ts` | +| WT-003 | 移除 worktree | Implemented | `worktree.remove` | `packages/server/src/__tests__/worktree-commands.test.ts` | +| WT-004 | worktree status | Implemented | `worktree.status`、`worktree-detail-panel.tsx` | `worktree-detail-panel.test.tsx` | +| WT-005 | worktree diff | Implemented | `worktree.diff` | `packages/server/src/__tests__/worktree-commands.test.ts` | +| WT-006 | worktree tree | Implemented | `worktree.tree` | `packages/server/src/__tests__/worktree-commands.test.ts` | +| WT-007 | worktree summary card | Implemented | `worktrees-summary-card.tsx` | `worktrees-summary-card.test.tsx` | +| WT-008 | worktree modal | Implemented | `worktree-modal.tsx` | `worktree-modal.test.tsx` | + +## 4. 模块级验收线索 + +- Worktree 列表应显示当前仓库 worktree。 +- 创建成功后列表刷新并展示新 worktree。 +- 移除时应处理未清理状态和错误反馈。 + +## 5. 未确认项 + +- 移动端是否暴露 worktree 管理入口需在第二轮确认。 diff --git a/docs/settings-desktop-wide-layout-preview.html b/docs/settings-desktop-wide-layout-preview.html new file mode 100644 index 000000000..ad68c454f --- /dev/null +++ b/docs/settings-desktop-wide-layout-preview.html @@ -0,0 +1,679 @@ + + + + + + Settings Desktop Wide Layout Preview + + + +
+
+
+
← Back
+
+
Desktop Settings Redesign
+

Settings

+
+
+
+
Autosave enabled
+
v0.2.6
+
+
+ +
+ + +
+
+
+
+

Monitoring now uses the full workspace width

+

+ The global rounded card is removed. The page shell stays flat, while summary + blocks, controls, and helper content become local panels. This gives complex + settings room to breathe on large desktop screens. +

+
+
+
Open diagnostics
+
Apply profile
+
+
+ +
+
+
Runtime Mode
+
Standard
+
Host and managed process sampling are enabled.
+
+
+
Refresh Interval
+
5s
+
Lower cadence keeps overhead controlled.
+
+
+
Host Pressure
+
Moderate
+
CPU 41%, memory 68%, no throttling detected.
+
+
+
Managed Processes
+
18
+
Across 3 workspaces and 6 active agent sessions.
+
+
+ +
+
+
+
+

Performance Monitoring

+

+ Wide-mode sections can show explanation, status, and controls at the same + time instead of compressing everything into a narrow single column. +

+
+
Wide Section
+
+ +
+
+
+

Enable monitoring

+

+ Master switch for runtime sampling and status summaries. Disabling this + stops server-side collection and hides monitoring details in the app. +

+
+
+
+
+
+ +
+
+

Sampling level

+

+ Choose how deep the system should inspect managed processes. Higher levels + improve drill-down but cost more CPU budget. +

+
+
+
Standard
+
+
+ +
+
+

Refresh frequency

+

+ Set how often the monitoring page and related summaries are refreshed from + the server. This stays readable because the controls have more lateral + space. +

+
+
+
Every 5 seconds
+
+
+ +
+
+

Subprocess drill-down

+

+ Capture subprocess-level attribution for active workspaces. This is now + easier to explain because helper content can live beside the main form. +

+
+
+
+
+
+
+
+ +
+
+
+
+

Operator Notes

+

+ The right rail is optional. It carries context that used to compete with the + form in the same narrow column. +

+
+
+
+

Why this layout works better

+
    +
  • Summary stays visible above the controls.
  • +
  • Instructions do not push switches downward.
  • +
  • Longer labels and values wrap less aggressively.
  • +
+
+
+ +
+
+
+

Status Snapshot

+

Example of local panels replacing the old global card.

+
+
+
+
+
CPU
+
41%
+
host average
+
+
+
Memory
+
7.4 GB
+
used by managed tree
+
+
+
Load Avg
+
2.8
+
15-minute window
+
+
+
+
+
+
+
+
+ +
+
Autosave changes immediately
+
Preview: flat desktop shell + wide monitoring section
+
+
+ + diff --git a/docs/superpowers/plans/2026-06-01-agent-instructions-publish.md b/docs/superpowers/plans/2026-06-01-agent-instructions-publish.md new file mode 100644 index 000000000..e5b73c701 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-agent-instructions-publish.md @@ -0,0 +1,500 @@ +# Agent Instructions Publish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop session-start instruction injection and instead publish the workspace's effective agent instructions into provider-native project files that Claude and Codex already read. + +**Architecture:** Keep `.coder-studio/AGENTS.md`, `.coder-studio/AGENTS.generated.md`, and `.coder-studio/AGENTS.effective.md` as the source of truth. Add one server-side publisher that materializes the effective markdown into provider-specific files (`AGENTS.override.md` for Codex, `CLAUDE.local.md` for Claude), then wire that publisher into workspace open, dirty events, and `session.create`. Remove the terminal-input rewrite path so the default behavior is file-based, not submit-time injection. + +**Tech Stack:** TypeScript, Node `fs/promises`, existing event bus, Chokidar watcher, Vitest, React. + +--- + +## File Map + +- Create: `packages/server/src/agent-instructions/publish-targets.ts` + Owns the provider-to-target-file map and keeps the target names centralized. +- Create: `packages/server/src/agent-instructions/publisher.ts` + Resolves the effective instructions, writes/deletes the managed target files, and serializes per-workspace syncs. +- Create: `packages/server/src/__tests__/agent-instructions-publisher.test.ts` + Covers publish, delete, no-op, and overlap behavior. +- Modify: `packages/server/src/ws/dispatch.ts` + Adds the optional publisher dependency to command handlers. +- Modify: `packages/server/src/server.ts` + Instantiates the publisher, hydrates it on startup, and subscribes it to dirty events. +- Modify: `packages/server/src/commands/workspace.ts` + Triggers an eager publish after `workspace.open`. +- Modify: `packages/server/src/commands/session.ts` + Forces a publish before `session.create` starts the agent process. +- Modify: `packages/server/src/commands/terminal.ts` + Removes the submit-time rewrite path and its auto-attach metadata side effects. +- Modify: `packages/server/src/commands/agent-instructions.ts` + Removes the unused auto-attach payload helper. +- Modify: `packages/server/src/fs/gitignore.ts` + Ignores the managed target files so publisher writes do not loop back into `fs.dirty`. +- Modify: `packages/server/src/__tests__/fs/watcher.test.ts` + Verifies the watcher ignores the managed target files but still watches internal source files. +- Modify: `packages/server/src/__tests__/workspace-commands.test.ts` + Verifies workspace open still succeeds and no longer persists auto-attach UI state. +- Modify: `packages/server/src/__tests__/session-commands.test.ts` + Verifies `session.create` waits for publish before starting the session. +- Modify: `packages/server/src/__tests__/terminal-commands.test.ts` + Verifies submit payloads are no longer rewritten. +- Modify: `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` + Removes auto-attach state management and related dispatches. +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` + Removes the auto-attach switch and leaves manual attach as the explicit fallback. +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` + Updates the panel tests to match the reduced UI. +- Modify: `packages/web/src/locales/en.json` + Removes stale auto-attach copy. +- Modify: `packages/web/src/locales/zh.json` + Removes stale auto-attach copy. +- Modify: `packages/core/src/domain/types.ts` + Removes `agentInstructionsAutoAttach` from `UiState`. + +## Guardrails + +- Keep `.coder-studio/AGENTS.*` as the internal source of truth; never make the published target files the source of truth. +- Use `AGENTS.override.md` for Codex and `CLAUDE.local.md` for Claude; do not target `.codex`. +- Keep the sync best-effort. A transient write failure should log and continue, not block workspace open or session creation. +- Ignore managed target writes in the workspace watcher so publisher output does not re-trigger the publisher. +- Keep `agentInstructions.attachToSession` as a manual fallback only. +- Do not add a new public publish-status API in this phase. + +### Task 1: Add the publisher service and target registry + +**Files:** +- Create: `packages/server/src/agent-instructions/publish-targets.ts` +- Create: `packages/server/src/agent-instructions/publisher.ts` +- Create: `packages/server/src/__tests__/agent-instructions-publisher.test.ts` + +- [ ] **Step 1: Write the failing publisher tests** + +Add tests that exercise the service against a temp workspace: + +```ts +it("publishes the effective instructions into provider-native files", async () => { + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "AGENTS.generated.md"), + "# Generated\n\n- Generated rule.\n" + ); + await writeFile( + join(rootPath, ".coder-studio", "AGENTS.md"), + "# Custom\n\n- Custom rule.\n" + ); + + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ providerId: "codex", path: "AGENTS.override.md" }), + expect.objectContaining({ providerId: "claude", path: "CLAUDE.local.md" }), + ]) + ); + expect(await readFile(join(rootPath, "AGENTS.override.md"), "utf8")).toContain( + "# Effective Agent Instructions" + ); + expect(await readFile(join(rootPath, "CLAUDE.local.md"), "utf8")).toContain( + "# Effective Agent Instructions" + ); +}); +``` + +Add a second test for the empty-source case: + +```ts +it("deletes managed targets when no effective instructions exist", async () => { + await publisher.syncWorkspace("ws-1"); + + await expect(stat(join(rootPath, "AGENTS.override.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(stat(join(rootPath, "CLAUDE.local.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); +}); +``` + +Add a third test for a second sync staying unchanged: + +```ts +it("leaves managed targets unchanged on a second sync", async () => { + await publisher.syncWorkspace("ws-1"); + + const second = await publisher.syncWorkspace("ws-1"); + + expect(second.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ providerId: "codex", action: "unchanged" }), + expect.objectContaining({ providerId: "claude", action: "unchanged" }), + ]) + ); +}); +``` + +- [ ] **Step 2: Run the new test file and confirm it fails** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-publisher.test.ts +``` + +Expected: the new assertions fail because the publisher does not exist yet. + +- [ ] **Step 3: Implement the minimal publisher** + +Create `publish-targets.ts` with a small registry: + +```ts +export const AGENT_INSTRUCTION_PUBLISH_TARGETS = [ + { providerId: "codex", path: "AGENTS.override.md" }, + { providerId: "claude", path: "CLAUDE.local.md" }, +] as const; +``` + +Implement `AgentInstructionsPublisher` so it: + +- resolves the effective markdown from `.coder-studio/AGENTS.*` +- writes that markdown into each managed target when the content differs +- deletes the managed target when the effective markdown is absent +- keeps per-workspace syncs serialized +- returns a small result object with the action taken per target + +- [ ] **Step 4: Rerun the publisher tests** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-publisher.test.ts +``` + +Expected: pass. + +### Task 2: Wire publishing into workspace open, dirty events, and session startup + +**Files:** +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/server.ts` +- Modify: `packages/server/src/commands/workspace.ts` +- Modify: `packages/server/src/commands/session.ts` +- Modify: `packages/server/src/__tests__/workspace-commands.test.ts` +- Modify: `packages/server/src/__tests__/session-commands.test.ts` +- Modify: `packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts` + +- [ ] **Step 1: Add failing wiring tests** + +Add a workspace-open test that proves the publisher is called before the command returns: + +```ts +it("publishes agent instructions during workspace.open", async () => { + const calls: string[] = []; + ctx.agentInstructionPublisher = { + syncWorkspace: vi.fn(async () => { + calls.push("publish"); + }), + scheduleWorkspaceSync: vi.fn(), + syncAllOpenWorkspaces: vi.fn(), + } as never; + + const result = await dispatch( + { + kind: "command", + id: "workspace-open-publish", + op: "workspace.open", + args: { path: dir }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(calls).toEqual(["publish"]); +}); +``` + +Add a session-create ordering test: + +```ts +it("publishes agent instructions before session.create starts the agent", async () => { + const calls: string[] = []; + const sessionStub = { + id: "sess-1", + workspaceId, + providerId: "claude", + terminalId: "term-1", + capability: "full", + state: "starting", + startedAt: Date.now(), + lastActiveAt: Date.now(), + }; + ctx.agentInstructionPublisher = { + syncWorkspace: vi.fn(async () => { + calls.push("publish"); + }), + scheduleWorkspaceSync: vi.fn(), + syncAllOpenWorkspaces: vi.fn(), + } as never; + sessionMgr.create = vi.fn(async () => { + calls.push("create"); + return sessionStub; + }) as never; + + await dispatch( + { + kind: "command", + id: "session-create-publish", + op: "session.create", + args: { workspaceId, providerId: "claude" }, + }, + ctx + ); + + expect(calls).toEqual(["publish", "create"]); +}); +``` + +Add a restart test that proves startup hydration republishes missing target files: + +```ts +it("restores managed target files after restart", async () => { + mkdirSync(join(workspaceDir, ".coder-studio"), { recursive: true }); + writeFileSync( + join(workspaceDir, ".coder-studio", "AGENTS.generated.md"), + "# Generated\n\n- Generated rule.\n" + ); + writeFileSync( + join(workspaceDir, ".coder-studio", "AGENTS.md"), + "# Custom\n\n- Custom rule.\n" + ); + + const openResult = await dispatch( + { + kind: "command", + id: "workspace-open", + op: "workspace.open", + args: { path: workspaceDir }, + }, + firstCtx + ); + expect(openResult.ok).toBe(true); + + rmSync(join(workspaceDir, "AGENTS.override.md"), { force: true }); + rmSync(join(workspaceDir, "CLAUDE.local.md"), { force: true }); + + server = await createServer({ stateDir, host: "127.0.0.1", port: 0 }); + await server.stop(); + server = await createServer({ stateDir, host: "127.0.0.1", port: 0 }); + + expect(await readFile(join(workspaceDir, "AGENTS.override.md"), "utf8")).toContain( + "# Effective Agent Instructions" + ); +}); +``` + +- [ ] **Step 2: Run the wiring tests and confirm they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- \ + src/__tests__/workspace-commands.test.ts \ + src/__tests__/session-commands.test.ts \ + src/__tests__/workspace-watcher-hydrate-restart.test.ts +``` + +Expected: the new publisher-related assertions fail until the wiring exists. + +- [ ] **Step 3: Implement the server wiring** + +Update `CommandContext` to carry an optional publisher. + +In `server.ts`: + +- create one `AgentInstructionsPublisher` after `workspaceMgr` is available +- call `await publisher.syncAllOpenWorkspaces()` after `workspaceMgr.hydrateWatchers()` +- subscribe `eventBus` to `fs.dirty` and call `publisher.scheduleWorkspaceSync(event.workspaceId)` +- add the publisher to `commandContext` + +In `workspace.ts`: + +- after `workspaceMgr.open()` returns, call `await ctx.agentInstructionPublisher?.syncWorkspace(workspace.id)` + +In `session.ts`: + +- before `ctx.sessionMgr.create(...)`, call `await ctx.agentInstructionPublisher?.syncWorkspace(args.workspaceId)` + +- [ ] **Step 4: Rerun the wiring tests** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- \ + src/__tests__/workspace-commands.test.ts \ + src/__tests__/session-commands.test.ts \ + src/__tests__/workspace-watcher-hydrate-restart.test.ts +``` + +Expected: pass. + +### Task 3: Remove submit-time auto-attach and the auto-attach UI state + +**Files:** +- Modify: `packages/server/src/commands/terminal.ts` +- Modify: `packages/server/src/commands/agent-instructions.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/commands/workspace.ts` +- Modify: `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/server/src/__tests__/terminal-commands.test.ts` +- Modify: `packages/server/src/__tests__/workspace-commands.test.ts` + +- [ ] **Step 1: Write the failing regression tests** + +Replace the current submit-rewrite expectation with a plain-submit expectation: + +```ts +it("leaves submit payload untouched", async () => { + const result = await dispatch( + { + kind: "command", + id: "terminal-input-submit", + op: "terminal.input", + args: { + terminalId: "term-1", + bytes: Buffer.from("ship it\r").toString("base64"), + activity: "submit", + submittedText: "ship it", + }, + }, + ctx + ); + + expect(sendInput).toHaveBeenCalledWith("sess-1", Buffer.from("ship it\r"), "submit", "ship it"); + expect(sessionMetadataRepo.get("sess-1")?.attachedAgentInstructions).toBeUndefined(); +}); +``` + +Update the workspace UI test to remove the auto-attach switch assertion and the `workspace.uiState.set` write for `agentInstructionsAutoAttach`. + +- [ ] **Step 2: Run the focused tests and confirm they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/terminal-commands.test.ts src/__tests__/workspace-commands.test.ts +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: the tests still reference the removed auto-attach behavior. + +- [ ] **Step 3: Remove the auto-attach path and state** + +In `terminal.ts`: + +- delete the `maybeRewriteSessionSubmit` branch entirely +- stop importing `resolveEffectiveAgentInstructions` and `buildAutoAttachSubmitPayload` +- keep plain `sessionMgr.sendInput(...)` + +In `agent-instructions.ts`: + +- delete `buildAutoAttachSubmitPayload` +- keep `attachToSession` as the explicit manual fallback + +In `types.ts` and the workspace UI: + +- remove `agentInstructionsAutoAttach` from `UiState` +- remove the switch, related action, and stale locale copy +- keep the manual attach button and the three internal status pills + +- [ ] **Step 4: Rerun the terminal and UI tests** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/terminal-commands.test.ts src/__tests__/workspace-commands.test.ts +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: pass. + +### Task 4: Ignore managed target files in the watcher + +**Files:** +- Modify: `packages/server/src/fs/gitignore.ts` +- Modify: `packages/server/src/__tests__/fs/watcher.test.ts` + +- [ ] **Step 1: Write the failing ignore test** + +Add assertions that the watcher ignores the managed target files but still watches the internal source files: + +```ts +it("ignores managed target files used for agent instruction publishing", () => { + new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); + + const options = watchSpy.mock.calls[0]?.[1]; + const ignored = options?.ignored; + + expect(ignored?.(join(testDir, "AGENTS.override.md"))).toBe(true); + expect(ignored?.(join(testDir, "CLAUDE.local.md"))).toBe(true); + expect(ignored?.(join(testDir, ".coder-studio", "AGENTS.md"))).toBe(false); +}); +``` + +- [ ] **Step 2: Run the watcher test and confirm it fails** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/fs/watcher.test.ts +``` + +Expected: the new ignore assertions fail until the filter is updated. + +- [ ] **Step 3: Add the ignore patterns** + +Update the watcher ignore regexes so they skip the managed root files only: + +```ts +/(^|\/)(AGENTS\.override\.md|CLAUDE\.local\.md)$/ +``` + +Keep the existing `.git`, `node_modules`, and transient lock-file filters unchanged. + +- [ ] **Step 4: Rerun the watcher test** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/fs/watcher.test.ts +``` + +Expected: pass. + +## Final Verification + +Run the focused slice first: + +```bash +pnpm --filter @coder-studio/server test -- \ + src/__tests__/agent-instructions-publisher.test.ts \ + src/__tests__/workspace-commands.test.ts \ + src/__tests__/session-commands.test.ts \ + src/__tests__/terminal-commands.test.ts \ + src/__tests__/fs/watcher.test.ts \ + src/__tests__/workspace-watcher-hydrate-restart.test.ts +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Then run the broader workspace slice if the focused tests pass: + +```bash +pnpm --filter @coder-studio/server test +pnpm --filter @coder-studio/web test +``` + +Expected: all relevant tests pass, the auto-attach toggle is gone, `terminal.input` no longer rewrites submissions, and the provider-native instruction files are present after workspace open and after source changes. diff --git a/docs/superpowers/plans/2026-06-03-agent-instructions-multi-provider-generation.md b/docs/superpowers/plans/2026-06-03-agent-instructions-multi-provider-generation.md new file mode 100644 index 000000000..abbde70a1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-agent-instructions-multi-provider-generation.md @@ -0,0 +1,347 @@ +# Agent Instructions Multi-Provider Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand `agent.md` generation from Codex-only headless execution to the built-in `codex`, `claude`, `gemini`, and `cursor` providers while keeping server-owned validation and file writes. + +**Architecture:** Keep `agent_instructions_generate` as an explicit headless scenario, but add it to the built-in providers that already support stable headless execution. Replace the Codex-only output parser with provider-aware envelope extraction plus a unified generation JSON payload parser, then update prompt, command, and UI filtering tests around the new contract. + +**Tech Stack:** TypeScript, Vitest, React Testing Library, Jotai, existing provider/server command architecture + +--- + +### Task 1: Expand Built-In Provider Generation Capability + +**Files:** +- Modify: `packages/providers/src/claude/definition.ts` +- Modify: `packages/providers/src/gemini/definition.ts` +- Modify: `packages/providers/src/cursor/definition.ts` +- Test: `packages/providers/src/claude/definition.test.ts` +- Test: `packages/providers/src/gemini/definition.test.ts` +- Test: `packages/providers/src/cursor/definition.test.ts` + +- [ ] **Step 1: Write the failing provider definition tests** + +Add assertions that `supportedScenarios` includes `agent_instructions_generate` and that the provider `headless.buildCommand(...)` returns a non-null command for that scenario. + +```ts +expect(claudeDefinition.headless?.supportedScenarios).toEqual([ + "supervisor_eval", + "agent_instructions_generate", + "session_analysis", +]); +expect( + claudeDefinition.headless?.buildCommand({}, "agent_instructions_generate", { + prompt: "Return strict JSON", + sessionId: "sess-1", + workspacePath: "/workspace", + }) +).not.toBeNull(); +``` + +- [ ] **Step 2: Run the targeted provider tests and verify they fail** + +Run: + +```bash +pnpm vitest run packages/providers/src/claude/definition.test.ts packages/providers/src/gemini/definition.test.ts packages/providers/src/cursor/definition.test.ts +``` + +Expected: FAIL because the scenario list does not yet include `agent_instructions_generate` and `buildCommand(...)` returns `null` for that scenario. + +- [ ] **Step 3: Update the provider definitions** + +Extend each built-in headless definition to accept `agent_instructions_generate` and reuse the existing headless command builder. + +```ts +headless: { + supportedScenarios: ["supervisor_eval", "agent_instructions_generate", "session_analysis"], + buildCommand(config, scenario, req) { + if ( + scenario !== "supervisor_eval" && + scenario !== "agent_instructions_generate" && + scenario !== "session_analysis" + ) { + return null; + } + + return buildClaudeSupervisorEvalCommand(config, req); + }, +}, +``` + +- [ ] **Step 4: Re-run the targeted provider tests and verify they pass** + +Run: + +```bash +pnpm vitest run packages/providers/src/claude/definition.test.ts packages/providers/src/gemini/definition.test.ts packages/providers/src/cursor/definition.test.ts +``` + +Expected: PASS + +### Task 2: Replace Codex-Only Output Parsing With Provider-Aware Extraction + +**Files:** +- Modify: `packages/server/src/agent-instructions/output.ts` +- Modify: `packages/server/src/__tests__/agent-instructions/output.test.ts` + +- [ ] **Step 1: Write the failing parser tests** + +Add tests for: + +- extracting final text from Codex JSONL +- extracting final text from Claude/Gemini/Cursor JSON envelopes +- parsing unified generation payload JSON +- surfacing `ok: false` +- rejecting invalid JSON and invalid headings + +Representative fixtures: + +```ts +const claudeEnvelope = JSON.stringify({ + type: "result", + result: '{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview\\n"}', +}); + +const codexJsonl = [ + JSON.stringify({ + type: "item.completed", + item: { + type: "agent_message", + text: '{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview\\n"}', + }, + }), +].join("\n"); +``` + +- [ ] **Step 2: Run the parser tests and verify they fail** + +Run: + +```bash +pnpm vitest run packages/server/src/__tests__/agent-instructions/output.test.ts +``` + +Expected: FAIL because only Codex markdown extraction exists today and there is no unified payload parser. + +- [ ] **Step 3: Implement provider-aware extraction and unified payload parsing** + +Refactor `output.ts` to expose two layers: + +- provider-specific final-text extraction +- provider-agnostic generation payload parsing + +Target API: + +```ts +export function extractAgentInstructionsReplyText(providerId: string, stdout: string): string +export function parseGeneratedAgentInstructionsPayload(replyText: string): string +``` + +Behavior: + +- `extractAgentInstructionsReplyText(...)` + - `codex`: read JSONL `item.completed` / `agent_message` + - `claude`, `gemini`, `cursor`: decode the final text field from the JSON envelope +- `parseGeneratedAgentInstructionsPayload(...)` + - parse JSON + - require `ok === true` + - require non-empty `content` + - normalize markdown through `normalizeGeneratedAgentInstructionsMarkdown(...)` + +- [ ] **Step 4: Re-run the parser tests and verify they pass** + +Run: + +```bash +pnpm vitest run packages/server/src/__tests__/agent-instructions/output.test.ts +``` + +Expected: PASS + +### Task 3: Switch Generation Prompt and Server Flow to the Unified Contract + +**Files:** +- Modify: `packages/server/src/agent-instructions/prompt.ts` +- Modify: `packages/server/src/agent-instructions/agent-generator.ts` +- Test: `packages/server/src/__tests__/agent-instructions-command.test.ts` + +- [ ] **Step 1: Write the failing generation command tests** + +Update or add command tests so each supported provider returns a provider-specific envelope containing a unified payload, and the command result still returns normalized markdown content. + +Representative assertions: + +```ts +expect(result.data).toEqual({ + content: "# Agent Instructions\n\nGenerated for tests\n", + meta: { + providerId: "claude", + model: "sonnet", + }, +}); +``` + +Add explicit failure cases for: + +- payload `{ "ok": false, "error": "..." }` +- malformed payload JSON +- valid payload with invalid heading + +- [ ] **Step 2: Run the targeted command tests and verify they fail** + +Run: + +```bash +pnpm vitest run packages/server/src/__tests__/agent-instructions-command.test.ts +``` + +Expected: FAIL because `agent-generator.ts` still calls the Codex-only extractor and `prompt.ts` still requests raw markdown output. + +- [ ] **Step 3: Update the generation prompt** + +Rewrite `buildAgentInstructionsGenerationPrompt(...)` so it requires exactly one JSON object with: + +```json +{ + "ok": true, + "content": "# Agent Instructions\n..." +} +``` + +and optionally: + +```json +{ + "ok": false, + "error": "..." +} +``` + +Keep the existing fixed section order and required bullet lists. + +- [ ] **Step 4: Update the generator flow** + +In `agent-generator.ts`: + +- keep provider resolution logic +- run the provider headless command +- call `extractAgentInstructionsReplyText(provider.id, stdout)` +- call `parseGeneratedAgentInstructionsPayload(replyText)` +- return normalized markdown content and metadata + +Representative change: + +```ts +const replyText = extractAgentInstructionsReplyText(provider.id, stdout); +const content = parseGeneratedAgentInstructionsPayload(replyText); + +return { + content, + meta: { + providerId: provider.id, + model, + }, +}; +``` + +- [ ] **Step 5: Re-run the targeted command tests and verify they pass** + +Run: + +```bash +pnpm vitest run packages/server/src/__tests__/agent-instructions-command.test.ts +``` + +Expected: PASS + +### Task 4: Verify Provider Listing and UI Filtering Regression Coverage + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` +- Modify: `packages/server/src/__tests__/provider-list.test.ts` +- Modify: `packages/server/src/__tests__/provider-runtime/runtime-status.test.ts` + +- [ ] **Step 1: Write the failing listing/filtering assertions** + +Update test fixtures so `claude`, `gemini`, and `cursor` can appear as generation-capable providers when runtime-available. + +Representative UI assertion: + +```ts +expect(within(providerSelect).getByRole("option", { name: "Claude" })).toBeInTheDocument(); +expect(within(providerSelect).getByRole("option", { name: "Codex" })).toBeInTheDocument(); +``` + +- [ ] **Step 2: Run the targeted provider listing and UI tests** + +Run: + +```bash +pnpm vitest run packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx packages/server/src/__tests__/provider-list.test.ts packages/server/src/__tests__/provider-runtime/runtime-status.test.ts +``` + +Expected: FAIL where old assumptions still treat only Codex as generation-capable. + +- [ ] **Step 3: Adjust fixtures or assertions to match the expanded capability set** + +Make the tests assert the intended rule: + +- generation-capable means provider advertises `agent_instructions_generate` +- visible in UI only when runtime-available + +Do not change the filtering rule itself unless a failing test shows an actual logic gap. + +- [ ] **Step 4: Re-run the targeted provider listing and UI tests** + +Run: + +```bash +pnpm vitest run packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx packages/server/src/__tests__/provider-list.test.ts packages/server/src/__tests__/provider-runtime/runtime-status.test.ts +``` + +Expected: PASS + +### Task 5: Full Regression Verification + +**Files:** +- No additional code changes expected + +- [ ] **Step 1: Run the full targeted regression suite** + +Run: + +```bash +pnpm vitest run \ + packages/providers/src/claude/definition.test.ts \ + packages/providers/src/gemini/definition.test.ts \ + packages/providers/src/cursor/definition.test.ts \ + packages/server/src/__tests__/agent-instructions/output.test.ts \ + packages/server/src/__tests__/agent-instructions-command.test.ts \ + packages/server/src/__tests__/provider-list.test.ts \ + packages/server/src/__tests__/provider-runtime/runtime-status.test.ts \ + packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: PASS + +- [ ] **Step 2: Run lint or type-aware validation if the changed packages require it** + +Run: + +```bash +pnpm vitest run +``` + +If that is too slow or noisy in the current branch, run the repo-standard targeted verification command that covers changed packages and record what was actually run. + +- [ ] **Step 3: Review git diff for scope** + +Run: + +```bash +git diff -- packages/providers/src packages/server/src packages/web/src +``` + +Expected: only provider capability, prompt/parser/generator flow, and related tests change. diff --git a/docs/superpowers/plans/2026-06-03-provider-work-log-analysis.md b/docs/superpowers/plans/2026-06-03-provider-work-log-analysis.md new file mode 100644 index 000000000..1f5a7eccc --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-provider-work-log-analysis.md @@ -0,0 +1,2037 @@ +# Provider Work Log Analysis Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild Work Analysis so it scans each built-in provider's local work logs for the selected workspace/time range instead of reading Coder Studio sessions. + +**Architecture:** Add provider-specific log adapters under `packages/server/src/work-analysis/log-sources`, normalize their output into `WorkLogSession[]`, and feed that collection into the existing basic/deep analysis flow. Persist analysis results as last-run records only; explicit runs always rescan provider logs. + +**Tech Stack:** TypeScript, Node filesystem APIs, `node:child_process` for injectable `sqlite3` CLI access, Zod, Vitest, React, Jotai, existing WebSocket command architecture + +--- + +## Reference Spec + +Read first: + +- `docs/superpowers/specs/2026-06-03-provider-work-log-analysis-design.md` + +Key behavioral constraints: + +- Work analysis must not use `SessionManager` as the source of analyzed work activity. +- All 5 built-in providers are in scope: `claude`, `codex`, `gemini`, `cursor`, `opencode`. +- `work.analysis.get` may show the last saved result. +- `work.analysis.runBasic` and `work.analysis.runDeep` must rescan provider log sources. +- Deep analysis must use bounded sampled provider evidence, not terminal snapshots. + +## File Structure + +Create these focused server modules: + +- `packages/server/src/work-analysis/log-sources/types.ts` + - Shared provider-log types: `BuiltInProviderId`, `WorkLogSession`, `ProviderWorkLogSource`, `ProviderWorkLogDiscovery`, `WorkLogSourceRef`, status/warning/evidence types. +- `packages/server/src/work-analysis/log-sources/path-encoding.ts` + - Helpers for home expansion, workspace path encoding, Cursor md5 workspace hash, JSONL iteration, safe timestamp parsing. +- `packages/server/src/work-analysis/log-sources/collector.ts` + - Runs all provider adapters, sorts sessions, computes `sourceDigest`. +- `packages/server/src/work-analysis/log-sources/codex.ts` + - Reads `~/.codex/sessions/YYYY/MM/DD/*.jsonl`. +- `packages/server/src/work-analysis/log-sources/claude.ts` + - Reads `~/.claude/projects//*.jsonl`. +- `packages/server/src/work-analysis/log-sources/gemini.ts` + - Reads `~/.gemini/tmp|history/` using `.project_root`. +- `packages/server/src/work-analysis/log-sources/cursor.ts` + - Reads `~/.cursor/projects//agent-transcripts`. +- `packages/server/src/work-analysis/log-sources/opencode.ts` + - Reads `~/.local/share/opencode/opencode.db` through an injectable SQLite query runner. +- `packages/server/src/work-analysis/evidence-sampler.ts` + - Converts normalized sessions to bounded `WorkAnalysisEvidence`. + +Modify these existing modules: + +- `packages/server/src/work-analysis/types.ts` +- `packages/server/src/work-analysis/basic-schema.ts` +- `packages/server/src/work-analysis/basic-analyzer.ts` +- `packages/server/src/work-analysis/service.ts` +- `packages/server/src/work-analysis/deep-prompt.ts` +- `packages/server/src/work-analysis/deep-runner.ts` +- `packages/server/src/storage/repositories/work-analysis-repo.ts` +- `packages/server/src/server.ts` +- `packages/web/src/features/work-analysis/types.ts` +- `packages/web/src/features/settings/components/session-analysis-settings.tsx` +- `packages/web/src/locales/en.json` +- `packages/web/src/locales/zh.json` +- `docs/help/work-analysis.md` + +Retire these after the new service is wired: + +- `packages/server/src/work-analysis/session-selector.ts` +- `packages/server/src/work-analysis/evidence-collector.ts` +- `packages/server/src/__tests__/work-analysis-session-selector.test.ts` +- `packages/server/src/__tests__/work-analysis-evidence-collector.test.ts` + +--- + +### Task 1: Define Provider Log Types And Shared Helpers + +**Files:** +- Create: `packages/server/src/work-analysis/log-sources/types.ts` +- Create: `packages/server/src/work-analysis/log-sources/path-encoding.ts` +- Test: `packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + buildCursorWorkspaceHash, + encodeProviderWorkspacePath, + parseOptionalTimestamp, + safeJsonParse, +} from "../work-analysis/log-sources/path-encoding.js"; + +describe("work analysis log source helpers", () => { + it("encodes absolute workspace paths for provider project directories", () => { + expect(encodeProviderWorkspacePath("/home/spencer/workspace/coder-studio")).toBe( + "-home-spencer-workspace-coder-studio" + ); + }); + + it("builds the Cursor md5 workspace hash from the absolute workspace path", () => { + expect(buildCursorWorkspaceHash("/home/spencer/workspace/coder-studio")).toBe( + "cf4c2089ed329fb5e3bba38e6a05f0bc" + ); + }); + + it("parses ISO and numeric timestamps and rejects invalid input", () => { + expect(parseOptionalTimestamp("2026-06-03T00:00:00.000Z")).toBe( + Date.parse("2026-06-03T00:00:00.000Z") + ); + expect(parseOptionalTimestamp(1_770_000_000_000)).toBe(1_770_000_000_000); + expect(parseOptionalTimestamp("not-a-date")).toBeUndefined(); + }); + + it("parses JSON safely without throwing", () => { + expect(safeJsonParse<{ ok: boolean }>("{\"ok\":true}")?.ok).toBe(true); + expect(safeJsonParse("{bad json")).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run the helper test and verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-source-helpers.test.ts +``` + +Expected: FAIL because `log-sources/path-encoding.ts` does not exist. + +- [ ] **Step 3: Add shared provider-log types** + +Create `packages/server/src/work-analysis/log-sources/types.ts`: + +```ts +import type { ProviderDefinition } from "@coder-studio/core"; +import type { ResolvedWorkAnalysisTimeRange } from "../types.js"; + +export type BuiltInProviderId = ProviderDefinition["id"] & string; + +export type WorkLogProviderStatus = + | "supported" + | "no_logs" + | "missing_root" + | "partial" + | "unsupported"; + +export interface WorkLogWarning { + code: string; + message: string; + sourceRef?: string; +} + +export interface WorkLogEvidenceExcerpt { + role: "user" | "assistant" | "tool" | "system" | "unknown"; + at?: number; + text?: string; + toolName?: string; + commandKind?: string; + filePath?: string; +} + +export interface WorkLogEvidence { + providerId: BuiltInProviderId; + sessionId: string; + workspacePath: string; + title?: string; + startedAt: number; + lastActiveAt: number; + excerpts: WorkLogEvidenceExcerpt[]; +} + +export interface WorkLogSession { + providerId: BuiltInProviderId; + sessionId: string; + workspacePath: string; + startedAt: number; + lastActiveAt: number; + sourceRef: string; + title?: string; + modelId?: string; + gitBranch?: string; + gitCommit?: string; + userTurnCount: number; + assistantTurnCount: number; + toolUseCount: number; + parseErrorCount: number; + timestampQuality: "explicit" | "file_mtime" | "mixed"; + evidence?: WorkLogEvidence[]; +} + +export interface WorkLogSourceRef { + providerId: BuiltInProviderId; + kind: "file" | "sqlite"; + path: string; + mtimeMs?: number; + sizeBytes?: number; + maxUpdatedAt?: number; +} + +export interface ProviderWorkLogDiscoverInput { + workspacePaths: string[]; + timeRange: ResolvedWorkAnalysisTimeRange; +} + +export interface ProviderWorkLogDiscovery { + providerId: BuiltInProviderId; + status: WorkLogProviderStatus; + sessions: WorkLogSession[]; + sourceRefs: WorkLogSourceRef[]; + parseErrorCount: number; + warnings: WorkLogWarning[]; +} + +export interface ProviderWorkLogSource { + providerId: BuiltInProviderId; + discover(input: ProviderWorkLogDiscoverInput): Promise; +} + +export interface WorkLogCollection { + sessions: WorkLogSession[]; + providers: ProviderWorkLogDiscovery[]; + sourceDigest: string; +} + +export interface WorkLogCollector { + collect(input: ProviderWorkLogDiscoverInput): Promise; +} +``` + +- [ ] **Step 4: Add shared helper functions** + +Create `packages/server/src/work-analysis/log-sources/path-encoding.ts`: + +```ts +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; + +export function resolveHomePath(path: string, home = homedir()): string { + return path.startsWith("~/") ? `${home}/${path.slice(2)}` : path; +} + +export function encodeProviderWorkspacePath(workspacePath: string): string { + return workspacePath.replaceAll("/", "-").replaceAll("\\", "-"); +} + +export function buildCursorWorkspaceHash(workspacePath: string): string { + return createHash("md5").update(workspacePath).digest("hex"); +} + +export function parseOptionalTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value !== "string" || value.trim().length === 0) { + return undefined; + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function safeJsonParse(text: string): T | undefined { + try { + return JSON.parse(text) as T; + } catch { + return undefined; + } +} + +export function isWithinRange(startedAt: number, lastActiveAt: number, range: { + startAt: number; + endAt: number; +}): boolean { + return lastActiveAt >= range.startAt && startedAt <= range.endAt; +} +``` + +- [ ] **Step 5: Re-run the helper test and verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-source-helpers.test.ts +``` + +Expected: PASS + +- [ ] **Step 6: Commit Task 1** + +Run: + +```bash +git add packages/server/src/work-analysis/log-sources/types.ts packages/server/src/work-analysis/log-sources/path-encoding.ts packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts +git commit -m "feat: add work analysis log source types" +``` + +--- + +### Task 2: Implement Codex, Claude, Gemini, And Cursor File Adapters + +**Files:** +- Create: `packages/server/src/work-analysis/log-sources/codex.ts` +- Create: `packages/server/src/work-analysis/log-sources/claude.ts` +- Create: `packages/server/src/work-analysis/log-sources/gemini.ts` +- Create: `packages/server/src/work-analysis/log-sources/cursor.ts` +- Test: `packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts` + +- [ ] **Step 1: Write fixture-building tests for file adapters** + +Create `packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts` with temp-home fixtures: + +```ts +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createClaudeWorkLogSource } from "../work-analysis/log-sources/claude.js"; +import { createCodexWorkLogSource } from "../work-analysis/log-sources/codex.js"; +import { createCursorWorkLogSource } from "../work-analysis/log-sources/cursor.js"; +import { createGeminiWorkLogSource } from "../work-analysis/log-sources/gemini.js"; + +async function makeHome() { + return await mkdtemp(join(tmpdir(), "work-log-home-")); +} + +describe("file provider work log sources", () => { + it("reads Codex sessions by metadata cwd and time range", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "session.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-session-1", + cwd: "/repo/app", + model_provider: "openai", + git: { branch: "main", commit_hash: "abc123" }, + }, + }), + JSON.stringify({ type: "user_message", payload: { text: "fix tests" } }), + JSON.stringify({ type: "agent_message", payload: { text: "done" } }), + JSON.stringify({ type: "tool_call", payload: { name: "shell" } }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + providerId: "codex", + sessionId: "codex-session-1", + workspacePath: "/repo/app", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + gitBranch: "main", + gitCommit: "abc123", + }); + }); + + it("reads Claude sessions from encoded workspace project logs", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-session-1", + cwd: "/repo/app", + gitBranch: "feature", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-session-1", + cwd: "/repo/app", + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + providerId: "claude", + sessionId: "claude-session-1", + workspacePath: "/repo/app", + userTurnCount: 1, + assistantTurnCount: 1, + gitBranch: "feature", + }); + }); + + it("reads Gemini chats by .project_root", async () => { + const home = await makeHome(); + const dir = join(home, ".gemini/tmp/app"); + mkdirSync(join(dir, "chats"), { recursive: true }); + writeFileSync(join(dir, ".project_root"), "/repo/app"); + writeFileSync( + join(dir, "chats/session-2026-06-03T01-00-abcd.json"), + JSON.stringify({ + kind: "chat", + sessionId: "gemini-session-1", + startTime: "2026-06-03T03:00:00.000Z", + lastUpdated: "2026-06-03T03:10:00.000Z", + summary: "Fix tests", + messages: [ + { type: "user", timestamp: "2026-06-03T03:00:00.000Z", content: [{ text: "fix" }] }, + { + type: "assistant", + timestamp: "2026-06-03T03:10:00.000Z", + content: [{ text: "done" }], + }, + ], + }) + ); + + const result = await createGeminiWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + providerId: "gemini", + sessionId: "gemini-session-1", + title: "Fix tests", + userTurnCount: 1, + assistantTurnCount: 1, + }); + }); + + it("reads Cursor transcripts by encoded workspace and reports mtime timestamp quality", async () => { + const home = await makeHome(); + const dir = join( + home, + ".cursor/projects/-repo-app/agent-transcripts/cursor-session-1" + ); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "cursor-session-1.jsonl"), + [ + JSON.stringify({ role: "user", message: { content: [{ type: "text", text: "fix" }] } }), + JSON.stringify({ + role: "assistant", + message: { content: [{ type: "tool_call", name: "shell" }] }, + }), + ].join("\n") + ); + + const result = await createCursorWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" }, + }); + + expect(result.sessions[0]).toMatchObject({ + providerId: "cursor", + sessionId: "cursor-session-1", + workspacePath: "/repo/app", + timestampQuality: "file_mtime", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + }); + }); +}); +``` + +- [ ] **Step 2: Run the file adapter tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-sources-file-adapters.test.ts +``` + +Expected: FAIL because the adapter modules do not exist. + +- [ ] **Step 3: Implement the Codex adapter** + +Create `packages/server/src/work-analysis/log-sources/codex.ts` with this API: + +```ts +export function createCodexWorkLogSource(options: { home?: string } = {}): ProviderWorkLogSource +``` + +Implementation requirements: + +- Scan `join(home, ".codex/sessions")` recursively for `.jsonl`. +- If the root is missing, return `missing_root`. +- Parse first valid JSON line for metadata. +- Match `payload.cwd` or `cwd` against `workspacePaths`. +- Count user records when `type` or role includes `user`. +- Count assistant records when `type` or role includes `assistant` or `agent_message`. +- Count tool records when `type` or payload contains a tool-like event. +- Use explicit timestamp when available; otherwise file mtime. +- Return `partial` when any matched file has parse errors. + +- [ ] **Step 4: Implement the Claude adapter** + +Create `packages/server/src/work-analysis/log-sources/claude.ts` with this API: + +```ts +export function createClaudeWorkLogSource(options: { home?: string } = {}): ProviderWorkLogSource +``` + +Implementation requirements: + +- For each workspace path, scan `~/.claude/projects/${encodeProviderWorkspacePath(path)}`. +- Match records whose `cwd` equals the workspace path when `cwd` is present. +- Group lines by `sessionId`; if no `sessionId`, use the file basename. +- Use min/max explicit timestamps for start/end. +- Count roles from `type`, `role`, and `message.role`. +- Treat records with `toolUse` or attachment/tool fields as tool activity. + +- [ ] **Step 5: Implement the Gemini adapter** + +Create `packages/server/src/work-analysis/log-sources/gemini.ts` with this API: + +```ts +export function createGeminiWorkLogSource(options: { home?: string } = {}): ProviderWorkLogSource +``` + +Implementation requirements: + +- Scan both `~/.gemini/tmp` and `~/.gemini/history`. +- Only read project directories whose `.project_root` exactly matches a selected workspace path. +- Read `chats/*.json`. +- Use `sessionId`, `startTime`, `lastUpdated`, `summary`, and `messages`. +- Count `messages[].type` values. +- Include short evidence excerpts from `messages[].content[].text` when present. + +- [ ] **Step 6: Implement the Cursor adapter** + +Create `packages/server/src/work-analysis/log-sources/cursor.ts` with this API: + +```ts +export function createCursorWorkLogSource(options: { home?: string } = {}): ProviderWorkLogSource +``` + +Implementation requirements: + +- For each workspace path, scan `~/.cursor/projects/${encodeProviderWorkspacePath(path)}/agent-transcripts`. +- Read `*/.jsonl`. +- Use transcript directory/file name as `sessionId`. +- Use file mtime for `startedAt` and `lastActiveAt`. +- Set `timestampQuality: "file_mtime"`. +- Count `role === "user"` and `role === "assistant"`. +- Count tool usage when content type/name includes `tool`, `command`, or `function`. + +- [ ] **Step 7: Re-run the file adapter tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-sources-file-adapters.test.ts +``` + +Expected: PASS + +- [ ] **Step 8: Commit Task 2** + +Run: + +```bash +git add packages/server/src/work-analysis/log-sources/codex.ts packages/server/src/work-analysis/log-sources/claude.ts packages/server/src/work-analysis/log-sources/gemini.ts packages/server/src/work-analysis/log-sources/cursor.ts packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts +git commit -m "feat: read provider work logs from file sources" +``` + +--- + +### Task 3: Implement The OpenCode SQLite Adapter + +**Files:** +- Create: `packages/server/src/work-analysis/log-sources/opencode.ts` +- Test: `packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts` + +- [ ] **Step 1: Write the failing OpenCode adapter test** + +Create `packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts`: + +```ts +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createOpenCodeWorkLogSource } from "../work-analysis/log-sources/opencode.js"; + +async function createDbFixture() { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + const dir = join(home, ".local/share/opencode"); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "opencode.db"); + execFileSync("sqlite3", [ + dbPath, + ` + create table project ( + id text primary key, + worktree text not null, + time_created integer not null, + time_updated integer not null + ); + create table session ( + id text primary key, + project_id text not null, + directory text not null, + title text not null, + version text not null, + summary_files integer, + summary_additions integer, + summary_deletions integer, + time_created integer not null, + time_updated integer not null + ); + create table message ( + id text primary key, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + create table part ( + id text primary key, + message_id text not null, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + insert into project values ('proj-1', '/repo/app', 1000, 3000); + insert into session values ('ses-1', 'proj-1', '/repo/app', 'Fix tests', '1.2.15', 2, 10, 1, 1000, 3000); + insert into message values ('msg-1', 'ses-1', 1000, 1000, '{"role":"user","text":"fix"}'); + insert into message values ('msg-2', 'ses-1', 2000, 3000, '{"role":"assistant","text":"done"}'); + insert into part values ('part-1', 'msg-2', 'ses-1', 2500, 2500, '{"type":"tool","tool":"bash"}'); + `, + ]); + return home; +} + +describe("OpenCode work log source", () => { + it("reads sessions from the OpenCode SQLite database by workspace path", async () => { + const home = await createDbFixture(); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + providerId: "opencode", + sessionId: "ses-1", + workspacePath: "/repo/app", + title: "Fix tests", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + timestampQuality: "explicit", + }); + expect(result.sourceRefs[0]).toMatchObject({ + providerId: "opencode", + kind: "sqlite", + }); + }); +}); +``` + +- [ ] **Step 2: Run the OpenCode test and verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-source-opencode.test.ts +``` + +Expected: FAIL because `opencode.ts` does not exist. + +- [ ] **Step 3: Implement an injectable SQLite query runner** + +Create `packages/server/src/work-analysis/log-sources/opencode.ts` with: + +```ts +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type OpenCodeSqliteRunner = (dbPath: string, sql: string) => Promise; + +export async function runSqliteJsonQuery(dbPath: string, sql: string): Promise { + const { stdout } = await execFileAsync("sqlite3", ["-json", dbPath, sql], { + windowsHide: true, + maxBuffer: 10 * 1024 * 1024, + }); + return stdout; +} +``` + +Testing/runtime notes: + +- In adapter tests, gate the real `sqlite3` fixture with `if (!hasSqlite3())` and `it.skip(...)` when the CLI is unavailable. +- Add a small `hasSqlite3()` helper that probes `execFileSync("sqlite3", ["-version"])`. +- In production code, catch `ENOENT` from `sqlite3` launch and return provider status `unsupported` with a warning that the SQLite CLI is unavailable. + +- [ ] **Step 4: Implement `createOpenCodeWorkLogSource`** + +Implementation requirements: + +- Accept options `{ home?: string; sqliteRunner?: OpenCodeSqliteRunner }`. +- Look for `~/.local/share/opencode/opencode.db`. +- Return `missing_root` when the DB does not exist. +- Query sessions joined to projects: + +```sql +select + s.id as sessionId, + p.worktree as worktree, + s.directory as directory, + s.title as title, + s.version as version, + s.summary_files as summaryFiles, + s.summary_additions as summaryAdditions, + s.summary_deletions as summaryDeletions, + s.time_created as startedAt, + s.time_updated as lastActiveAt, + ( + select count(*) from message m + where m.session_id = s.id and lower(m.data) like '%"role":"user"%' + ) as userTurnCount, + ( + select count(*) from message m + where m.session_id = s.id and lower(m.data) like '%"role":"assistant"%' + ) as assistantTurnCount, + ( + select count(*) from part p2 + where p2.session_id = s.id and lower(p2.data) like '%tool%' + ) as toolUseCount +from session s +join project p on p.id = s.project_id +where + (p.worktree in (__WORKSPACES__) or s.directory in (__WORKSPACES__)) + and s.time_updated >= __START__ + and s.time_created <= __END__ +order by s.time_updated asc; +``` + +- Build the workspace `in (...)` list by SQL-escaping single quotes. +- Parse `sqlite3 -json` output with `JSON.parse`. +- Use explicit session timestamps. +- Return `partial` with a warning if the query fails. + +- [ ] **Step 5: Re-run the OpenCode test and verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-source-opencode.test.ts +``` + +Expected: PASS + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add packages/server/src/work-analysis/log-sources/opencode.ts packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts +git commit -m "feat: read opencode work logs from sqlite" +``` + +--- + +### Task 4: Add The Work Log Collector And Source Digest + +**Files:** +- Create: `packages/server/src/work-analysis/log-sources/collector.ts` +- Test: `packages/server/src/__tests__/work-analysis-log-collector.test.ts` + +- [ ] **Step 1: Write the failing collector tests** + +Create `packages/server/src/__tests__/work-analysis-log-collector.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { createWorkLogCollector } from "../work-analysis/log-sources/collector.js"; +import type { ProviderWorkLogSource } from "../work-analysis/log-sources/types.js"; + +function source(input: Awaited>): ProviderWorkLogSource { + return { + providerId: input.providerId, + discover: async () => input, + }; +} + +describe("WorkLogCollector", () => { + it("runs sources, sorts sessions, and reports provider statuses", async () => { + const collector = createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [{ providerId: "codex", kind: "file", path: "/b", mtimeMs: 2, sizeBytes: 20 }], + sessions: [ + { + providerId: "codex", + sessionId: "b", + workspacePath: "/repo", + startedAt: 20, + lastActiveAt: 30, + sourceRef: "/b", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit", + }, + ], + }), + source({ + providerId: "claude", + status: "no_logs", + parseErrorCount: 0, + warnings: [], + sourceRefs: [], + sessions: [], + }), + ], + }); + + const result = await collector.collect({ + workspacePaths: ["/repo"], + timeRange: { startAt: 0, endAt: 100, label: "custom" }, + }); + + expect(result.sessions.map((session) => session.sessionId)).toEqual(["b"]); + expect(result.providers.map((provider) => provider.providerId)).toEqual(["codex", "claude"]); + expect(result.sourceDigest).toMatch(/^[a-f0-9]{64}$/); + }); + + it("changes sourceDigest when source refs change", async () => { + const left = await createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [{ providerId: "codex", kind: "file", path: "/a", mtimeMs: 1, sizeBytes: 10 }], + sessions: [], + }), + ], + }).collect({ workspacePaths: ["/repo"], timeRange: { startAt: 0, endAt: 1, label: "x" } }); + + const right = await createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [{ providerId: "codex", kind: "file", path: "/a", mtimeMs: 2, sizeBytes: 10 }], + sessions: [], + }), + ], + }).collect({ workspacePaths: ["/repo"], timeRange: { startAt: 0, endAt: 1, label: "x" } }); + + expect(left.sourceDigest).not.toBe(right.sourceDigest); + }); +}); +``` + +- [ ] **Step 2: Run the collector tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-collector.test.ts +``` + +Expected: FAIL because `collector.ts` does not exist. + +- [ ] **Step 3: Implement the collector** + +Create `packages/server/src/work-analysis/log-sources/collector.ts`: + +```ts +import { createHash } from "node:crypto"; +import type { + ProviderWorkLogSource, + WorkLogCollection, + WorkLogCollector, + WorkLogSourceRef, +} from "./types.js"; + +function buildSourceDigest(input: { + sourceRefs: WorkLogSourceRef[]; + sessionIds: string[]; +}): string { + return createHash("sha256") + .update( + JSON.stringify({ + sourceRefs: [...input.sourceRefs].sort((left, right) => + `${left.providerId}:${left.path}`.localeCompare(`${right.providerId}:${right.path}`) + ), + sessionIds: [...input.sessionIds].sort(), + }) + ) + .digest("hex"); +} + +export function createWorkLogCollector(deps: { sources: ProviderWorkLogSource[] }): WorkLogCollector { + return { + async collect(input: Parameters[0]): Promise { + const providers = await Promise.all(deps.sources.map((source) => source.discover(input))); + const sessions = providers + .flatMap((provider) => provider.sessions) + .sort( + (left, right) => + left.lastActiveAt - right.lastActiveAt || + left.providerId.localeCompare(right.providerId) || + left.sessionId.localeCompare(right.sessionId) + ); + + return { + sessions, + providers, + sourceDigest: buildSourceDigest({ + sourceRefs: providers.flatMap((provider) => provider.sourceRefs), + sessionIds: sessions.map((session) => `${session.providerId}:${session.sessionId}`), + }), + }; + }, + }; +} +``` + +- [ ] **Step 4: Re-run the collector tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-collector.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit Task 4** + +Run: + +```bash +git add packages/server/src/work-analysis/log-sources/collector.ts packages/server/src/__tests__/work-analysis-log-collector.test.ts +git commit -m "feat: collect provider work log sessions" +``` + +--- + +### Task 5: Extend Analysis Types, Schema, Repo, And Basic Aggregation + +**Files:** +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/work-analysis/basic-schema.ts` +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` +- Modify: `packages/server/src/storage/repositories/work-analysis-repo.ts` +- Modify: `packages/web/src/features/work-analysis/types.ts` +- Test: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-repo.test.ts` + +- [ ] **Step 1: Update the failing basic analyzer tests** + +Modify `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` so sessions include provider-log metrics: + +```ts +{ + sessionId: "sess-1", + workspacePath: "/repo/app", + providerId: "codex", + startedAt: Date.UTC(2026, 0, 1, 18, 0, 0), + lastActiveAt: Date.UTC(2026, 0, 1, 18, 30, 0), + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, +} +``` + +Add provider source status input: + +```ts +dataSources: { + providers: [ + { + providerId: "codex", + status: "supported", + sessionCount: 2, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "cursor", + status: "no_logs", + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, + ], +} +``` + +Add assertions: + +```ts +expect(result.executionSignals).toEqual({ + sessionsWithActivity: 3, + userTurnCount: 5, + assistantTurnCount: 4, + toolUseCount: 2, + fileMtimeTimestampCount: 1, +}); +expect(result.dataSources.providers).toEqual([ + { + providerId: "codex", + status: "supported", + sessionCount: 2, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "cursor", + status: "no_logs", + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, +]); +``` + +- [ ] **Step 2: Add repo persistence tests for source snapshots** + +Modify `packages/server/src/__tests__/work-analysis-repo.test.ts` to persist and reload: + +```ts +sourceSnapshot: { + sourceDigest: "digest-source", + collectedAt: 1_234, + providerStatuses: [ + { providerId: "codex", status: "supported", sessionCount: 1, parseErrorCount: 0 }, + ], +}, +``` + +Assert the reloaded record includes `sourceSnapshot`. + +- [ ] **Step 3: Run targeted tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-basic-analyzer.test.ts src/__tests__/work-analysis-repo.test.ts +``` + +Expected: FAIL because schemas and types do not include the new fields. + +- [ ] **Step 4: Extend server analysis result types** + +Modify `packages/server/src/work-analysis/types.ts`: + +- Add `WorkAnalysisSourceSnapshot`. +- Add `sourceSnapshot?: WorkAnalysisSourceSnapshot` to `WorkAnalysisRecord`. +- Add `dataSources` and expanded `executionSignals` to `WorkBasicAnalysisResult`. +- Change basic analysis session input shape indirectly through `basic-analyzer.ts`; do not expose Coder Studio `workspaceId` as session identity. + +Representative additions: + +```ts +export interface WorkAnalysisSourceSnapshot { + sourceDigest: string; + providerStatuses: Array<{ + providerId: string; + status: string; + sessionCount: number; + parseErrorCount: number; + }>; + collectedAt: number; +} +``` + +- [ ] **Step 5: Extend the Zod schema** + +Modify `packages/server/src/work-analysis/basic-schema.ts`: + +```ts +dataSources: z.object({ + providers: z.array( + z.object({ + providerId: z.string(), + status: z.enum(["supported", "no_logs", "missing_root", "partial", "unsupported"]), + sessionCount: nonNegativeIntegerSchema, + parseErrorCount: nonNegativeIntegerSchema, + warningCount: nonNegativeIntegerSchema, + }) + ), +}), +executionSignals: z.object({ + sessionsWithActivity: nonNegativeIntegerSchema, + userTurnCount: nonNegativeIntegerSchema, + assistantTurnCount: nonNegativeIntegerSchema, + toolUseCount: nonNegativeIntegerSchema, + fileMtimeTimestampCount: nonNegativeIntegerSchema, +}), +``` + +- [ ] **Step 6: Update `analyzeWorkBasic`** + +Modify `packages/server/src/work-analysis/basic-analyzer.ts` so the input session type uses: + +```ts +type BasicAnalyzerSession = { + sessionId: string; + workspacePath: string; + providerId: string; + startedAt: number; + lastActiveAt: number; + userTurnCount: number; + assistantTurnCount: number; + toolUseCount: number; + parseErrorCount: number; + timestampQuality: "explicit" | "file_mtime" | "mixed"; +}; +``` + +Update output calculation: + +```ts +const userTurnCount = input.sessions.reduce((sum, session) => sum + session.userTurnCount, 0); +const assistantTurnCount = input.sessions.reduce( + (sum, session) => sum + session.assistantTurnCount, + 0 +); +const toolUseCount = input.sessions.reduce((sum, session) => sum + session.toolUseCount, 0); +const fileMtimeTimestampCount = input.sessions.filter( + (session) => session.timestampQuality === "file_mtime" +).length; +``` + +Pass `input.dataSources` through to the parsed result. + +- [ ] **Step 7: Update repo normalization** + +Modify `packages/server/src/storage/repositories/work-analysis-repo.ts`: + +- Accept optional `sourceSnapshot` in `isWorkAnalysisRecord`. +- Preserve it in `normalizeRecord`. + +Representative check: + +```ts +function isSourceSnapshot(value: unknown): value is WorkAnalysisRecord["sourceSnapshot"] { + if (!isRecord(value)) return false; + return ( + typeof value.sourceDigest === "string" && + typeof value.collectedAt === "number" && + Array.isArray(value.providerStatuses) + ); +} +``` + +- [ ] **Step 8: Update frontend work-analysis types** + +Modify `packages/web/src/features/work-analysis/types.ts` with the same new fields: + +- `sourceSnapshot?: { sourceDigest: string; collectedAt: number; providerStatuses: Array<{ providerId: string; status: string; sessionCount: number; parseErrorCount: number }> }` +- `basicResult.dataSources.providers` +- expanded `executionSignals` + +- [ ] **Step 9: Re-run targeted tests and typecheck server** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-basic-analyzer.test.ts src/__tests__/work-analysis-repo.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS + +- [ ] **Step 10: Commit Task 5** + +Run: + +```bash +git add packages/server/src/work-analysis/types.ts packages/server/src/work-analysis/basic-schema.ts packages/server/src/work-analysis/basic-analyzer.ts packages/server/src/storage/repositories/work-analysis-repo.ts packages/web/src/features/work-analysis/types.ts packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-repo.test.ts +git commit -m "feat: aggregate provider work log metrics" +``` + +--- + +### Task 6: Replace Session-Based WorkAnalysisService With Log Collection + +**Files:** +- Modify: `packages/server/src/work-analysis/service.ts` +- Modify: `packages/server/src/server.ts` +- Test: `packages/server/src/__tests__/work-analysis-service.test.ts` + +- [ ] **Step 1: Rewrite service tests around `workLogCollector`** + +Modify `packages/server/src/__tests__/work-analysis-service.test.ts`. + +Replace the old cache test with: + +```ts +it("rescans provider logs when running basic analysis even if a previous result succeeded", async () => { + const upsert = vi.fn((record) => record); + const collect = vi.fn(async () => ({ + sourceDigest: "source-1", + providers: [ + { + providerId: "codex", + status: "supported", + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => ({ + id: "analysis-1", + queryDigest: "digest-1", + workspaceIds: ["ws-1"], + timeRange: { preset: "7d" as const }, + basicStatus: "succeeded" as const, + deepStatus: "idle" as const, + })), + upsert, + }, + workspaceMgr: { get: vi.fn(() => ({ id: "ws-1", path: "/repo/app" })) }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { run: vi.fn() }, + now: () => 1_000, + }); + + await service.runBasic({ workspaceIds: ["ws-1"], timeRange: { preset: "7d" } }); + + expect(collect).toHaveBeenCalledWith({ + workspacePaths: ["/repo/app"], + timeRange: expect.any(Object), + }); + expect(upsert).toHaveBeenCalled(); +}); +``` + +Update deep tests so `workLogCollector.collect` returns a session with `evidence`, and `deepRunner.run` receives sampled provider evidence instead of terminal snapshots. + +- [ ] **Step 2: Run service tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-service.test.ts +``` + +Expected: FAIL because `WorkAnalysisService` still expects `sessionSelector` and `evidenceCollector`. + +- [ ] **Step 3: Change `WorkAnalysisServiceDeps`** + +Modify `packages/server/src/work-analysis/service.ts` dependency shape: + +```ts +export interface WorkAnalysisServiceDeps { + repo: { + findByQueryDigest(queryDigest: string): WorkAnalysisRecord | undefined; + upsert(record: WorkAnalysisRecord): WorkAnalysisRecord; + }; + workspaceMgr: Pick; + workLogCollector: Pick; + skillLibraryRepo: Pick; + skillMountRepo: Pick; + basicAnalyzer?: typeof analyzeWorkBasic; + deepRunner: Pick; + now?: () => number; +} +``` + +Do not import `session-selector.ts` or `evidence-collector.ts`. + +- [ ] **Step 4: Resolve workspace IDs to paths** + +Add a helper inside `service.ts`: + +```ts +private resolveWorkspacePaths(workspaceIds: string[]): { + workspacePaths: string[]; + missingWorkspaceIds: string[]; +} { + const workspacePaths: string[] = []; + const missingWorkspaceIds: string[] = []; + + for (const workspaceId of workspaceIds) { + const path = this.deps.workspaceMgr.get(workspaceId)?.path; + if (typeof path === "string" && path.length > 0) { + workspacePaths.push(path); + } else { + missingWorkspaceIds.push(workspaceId); + } + } + + return { workspacePaths, missingWorkspaceIds }; +} +``` + +If any selected workspace id cannot be resolved, fail the run instead of silently dropping it. Throw: + +```ts +{ + code: "work_analysis_workspace_unavailable", + message: `Some selected workspaces were unavailable for work analysis: ${missingWorkspaceIds.join(", ")}`, +} +``` + +- [ ] **Step 5: Add a private collection helper and rework `runBasic`** + +Add a private helper that both `runBasic` and `runDeep` can call: + +```ts +private async collectForQuery(input: { + normalized: WorkAnalysisQuery; + timeRange: ResolvedWorkAnalysisTimeRange; +}) { + const { workspacePaths, missingWorkspaceIds } = this.resolveWorkspacePaths( + input.normalized.workspaceIds + ); + if (missingWorkspaceIds.length > 0) { + throw { + code: "work_analysis_workspace_unavailable", + message: `Some selected workspaces were unavailable for work analysis: ${missingWorkspaceIds.join(", ")}`, + }; + } + + const collection = await this.deps.workLogCollector.collect({ + workspacePaths, + timeRange: input.timeRange, + }); + const skillInventory = { + installedSkills: this.deps.skillLibraryRepo.list(), + mounts: this.deps.skillMountRepo.list(), + }; + + return { workspacePaths, collection, skillInventory }; +} +``` + +Behavior: + +- Always create/update a running record. +- Always call `collectForQuery(...)`. +- Pass normalized sessions to `basicAnalyzer`. +- Save `sourceSnapshot`. +- Do not return early for existing `basicStatus === "succeeded"`. + +Provider status mapping: + +```ts +const dataSources = { + providers: collection.providers.map((provider) => ({ + providerId: provider.providerId, + status: provider.status, + sessionCount: provider.sessions.length, + parseErrorCount: provider.parseErrorCount, + warningCount: provider.warnings.length, + })), +}; +``` + +- [ ] **Step 6: Rework `runDeep`** + +Behavior: + +- Do not call `runBasic(query)` and then scan again. +- Use a private `runBasicWithCollection(query)` helper that returns `{ record, collection, skillInventory, workspacePaths }`. +- Public `runBasic(query)` should call `runBasicWithCollection(query)` and return `record`. +- Public `runDeep(query)` should call `runBasicWithCollection(query)` once, then use that same `collection` for evidence sampling. +- Add a private `buildEvidenceFromWorkLogSessions(...)` helper in `service.ts` for this task: + +```ts +private buildEvidenceFromWorkLogSessions(input: { + sessions: WorkLogSession[]; + skillInventory: WorkAnalysisEvidence["skillInventory"]; +}): WorkAnalysisEvidence { + return { + sessions: input.sessions.slice(0, 12).map((session) => { + const evidence = session.evidence?.[0]; + return { + providerId: session.providerId, + sessionId: session.sessionId, + workspacePath: session.workspacePath, + title: session.title, + startedAt: session.startedAt, + lastActiveAt: session.lastActiveAt, + excerpts: (evidence?.excerpts ?? []).slice(0, 8), + }; + }), + skillInventory: input.skillInventory, + }; +} +``` + +- Do not add a session-count-based `resolveDeepProviderId(...)` helper in `service.ts`. Deep execution provider selection belongs in `deep-runner.ts`, where the runner can prefer a provider inferred from sampled evidence but safely fall back to any configured provider that supports `session_analysis`. + +- [ ] **Step 7: Wire the new collector in `server.ts`** + +Modify imports: + +```ts +import { createWorkLogCollector } from "./work-analysis/log-sources/collector.js"; +import { createClaudeWorkLogSource } from "./work-analysis/log-sources/claude.js"; +import { createCodexWorkLogSource } from "./work-analysis/log-sources/codex.js"; +import { createCursorWorkLogSource } from "./work-analysis/log-sources/cursor.js"; +import { createGeminiWorkLogSource } from "./work-analysis/log-sources/gemini.js"; +import { createOpenCodeWorkLogSource } from "./work-analysis/log-sources/opencode.js"; +``` + +Construct: + +```ts +const builtInProviderIds = new Set(providerRegistry.map((provider) => provider.id)); + +const workLogCollector = createWorkLogCollector({ + sources: [ + ...(builtInProviderIds.has("claude") ? [createClaudeWorkLogSource()] : []), + ...(builtInProviderIds.has("codex") ? [createCodexWorkLogSource()] : []), + ...(builtInProviderIds.has("gemini") ? [createGeminiWorkLogSource()] : []), + ...(builtInProviderIds.has("cursor") ? [createCursorWorkLogSource()] : []), + ...(builtInProviderIds.has("opencode") ? [createOpenCodeWorkLogSource()] : []), + ], +}); +``` + +Pass into service: + +```ts +const workAnalysisService = new WorkAnalysisService({ + repo: workAnalysisRepo, + workspaceMgr: { + get: (workspaceId) => workspaceMgr.get(workspaceId), + } as WorkspaceManager, + workLogCollector, + skillLibraryRepo, + skillMountRepo, + deepRunner: new WorkDeepAnalysisRunner({ + providerRegistry: activeProviderRegistry, + providerConfigRepo, + }), +}); +``` + +- [ ] **Step 8: Re-run service tests and server typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-service.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS + +- [ ] **Step 9: Commit Task 6** + +Run: + +```bash +git add packages/server/src/work-analysis/service.ts packages/server/src/server.ts packages/server/src/__tests__/work-analysis-service.test.ts +git commit -m "feat: run work analysis from provider logs" +``` + +--- + +### Task 7: Add Evidence Sampling And Deep Provider Selection + +**Files:** +- Create: `packages/server/src/work-analysis/evidence-sampler.ts` +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/work-analysis/deep-prompt.ts` +- Modify: `packages/server/src/work-analysis/deep-runner.ts` +- Modify: `packages/server/src/work-analysis/service.ts` +- Test: `packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-deep-runner.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-service.test.ts` + +- [ ] **Step 1: Write the failing evidence sampler tests** + +Create `packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { sampleWorkLogEvidence } from "../work-analysis/evidence-sampler.js"; +import type { WorkLogSession } from "../work-analysis/log-sources/types.js"; + +function session(id: string, providerId: WorkLogSession["providerId"], lastActiveAt: number): WorkLogSession { + return { + providerId, + sessionId: id, + workspacePath: "/repo/app", + startedAt: lastActiveAt - 100, + lastActiveAt, + sourceRef: `/logs/${id}`, + title: id, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit", + evidence: [ + { + providerId, + sessionId: id, + workspacePath: "/repo/app", + startedAt: lastActiveAt - 100, + lastActiveAt, + excerpts: [ + { role: "user", text: "x".repeat(1000) }, + { role: "tool", toolName: "shell", commandKind: "test" }, + ], + }, + ], + }; +} + +describe("sampleWorkLogEvidence", () => { + it("caps excerpts and truncates long text", () => { + const result = sampleWorkLogEvidence({ + sessions: [session("s1", "codex", 100)], + skillInventory: { installedSkills: [], mounts: [] }, + maxSessions: 1, + maxExcerptsPerSession: 1, + maxTextChars: 20, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.excerpts).toHaveLength(1); + expect(result.sessions[0]?.excerpts[0]?.text?.length).toBeLessThanOrEqual(20); + }); + + it("keeps provider diversity before filling remaining slots", () => { + const result = sampleWorkLogEvidence({ + sessions: [ + session("old-codex", "codex", 10), + session("new-codex", "codex", 30), + session("claude", "claude", 20), + ], + skillInventory: { installedSkills: [], mounts: [] }, + maxSessions: 2, + maxExcerptsPerSession: 2, + maxTextChars: 100, + }); + + expect(new Set(result.sessions.map((entry) => entry.providerId))).toEqual( + new Set(["codex", "claude"]) + ); + }); +}); +``` + +- [ ] **Step 2: Run sampler tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-evidence-sampler.test.ts +``` + +Expected: FAIL because `evidence-sampler.ts` does not exist. + +- [ ] **Step 3: Update `WorkAnalysisEvidence` type** + +Modify `packages/server/src/work-analysis/types.ts`: + +```ts +export interface WorkAnalysisSessionEvidence { + providerId?: string; + sessionId?: string; + workspacePath?: string; + title?: string; + startedAt: number; + lastActiveAt: number; + excerpts?: Array<{ + role: "user" | "assistant" | "tool" | "system" | "unknown"; + at?: number; + text?: string; + toolName?: string; + commandKind?: string; + filePath?: string; + }>; +} +``` + +Remove `latestUserInput` and `terminalSnapshot` from required usage. They can remain optional for backward compatibility if needed, but new code must not set them from Coder Studio sessions. + +- [ ] **Step 4: Implement `sampleWorkLogEvidence`** + +Create `packages/server/src/work-analysis/evidence-sampler.ts`: + +```ts +import type { WorkAnalysisEvidence } from "./types.js"; +import type { WorkLogSession } from "./log-sources/types.js"; + +interface SampleInput { + sessions: WorkLogSession[]; + skillInventory: WorkAnalysisEvidence["skillInventory"]; + maxSessions?: number; + maxExcerptsPerSession?: number; + maxTextChars?: number; +} + +function truncateText(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(0, maxChars); +} + +export function sampleWorkLogEvidence(input: SampleInput): WorkAnalysisEvidence { + const maxSessions = input.maxSessions ?? 12; + const maxExcerptsPerSession = input.maxExcerptsPerSession ?? 8; + const maxTextChars = input.maxTextChars ?? 1_000; + + const newestByProvider = new Map(); + for (const session of [...input.sessions].sort((left, right) => right.lastActiveAt - left.lastActiveAt)) { + if (!newestByProvider.has(session.providerId)) { + newestByProvider.set(session.providerId, session); + } + } + + const selected = [...newestByProvider.values()]; + for (const session of [...input.sessions].sort((left, right) => right.lastActiveAt - left.lastActiveAt)) { + if (selected.length >= maxSessions) break; + if (!selected.includes(session)) selected.push(session); + } + + return { + sessions: selected.slice(0, maxSessions).map((session) => { + const evidence = session.evidence?.[0]; + return { + providerId: session.providerId, + sessionId: session.sessionId, + workspacePath: session.workspacePath, + title: session.title, + startedAt: session.startedAt, + lastActiveAt: session.lastActiveAt, + excerpts: (evidence?.excerpts ?? []) + .slice(0, maxExcerptsPerSession) + .map((excerpt) => ({ + ...excerpt, + text: + typeof excerpt.text === "string" + ? truncateText(excerpt.text, maxTextChars) + : undefined, + })), + }; + }), + skillInventory: input.skillInventory, + }; +} +``` + +- [ ] **Step 5: Update the deep prompt test** + +Modify `packages/server/src/__tests__/work-analysis-deep-runner.test.ts` evidence fixture: + +```ts +evidence: { + sessions: [ + { + providerId: "codex", + sessionId: "session-1", + workspacePath: "/repo/project", + title: "Session", + startedAt: 100, + lastActiveAt: 200, + excerpts: [{ role: "user", text: "investigate" }], + }, + ], + skillInventory: { + installedSkills: [{ slug: "review" }], + mounts: [{ skillSlug: "review", enabled: true }], + }, +} +``` + +Assert prompt contains `"excerpts"` instead of `"snapshot"`. + +- [ ] **Step 6: Add deep provider selection to runner** + +Modify `packages/server/src/work-analysis/deep-runner.ts`: + +- Add method: + +```ts +resolveProviderId(preferredProviderId?: string): string +``` + +Behavior: + +- If preferred provider id is present and supports `session_analysis`, return it. +- Otherwise return the first provider in `providerRegistry` whose `headless.supportedScenarios` includes `session_analysis`. +- Throw `work_analysis_provider_unavailable` if none exists. + +Use this method for all deep runs. The service may pass a preferred provider id derived from sampled evidence, but the runner owns the final selection and fallback behavior. + +- [ ] **Step 7: Update service to use sampler and runner selection** + +Modify `packages/server/src/work-analysis/service.ts`: + +- Build skill inventory once. +- Pass `sampleWorkLogEvidence({ sessions: collection.sessions, skillInventory })` to `deepRunner.run`. +- Derive an optional preferred provider id from sampled evidence only if at least one sampled session exists. +- Pass that preferred provider id into `deepRunner.resolveProviderId(preferredProviderId)` or an equivalent runner-owned selection path. +- Do not keep or reintroduce the old "provider with most sessions" selection helper in `service.ts`. + +- [ ] **Step 8: Re-run deep and service tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-evidence-sampler.test.ts src/__tests__/work-analysis-deep-runner.test.ts src/__tests__/work-analysis-service.test.ts +``` + +Expected: PASS + +- [ ] **Step 9: Commit Task 7** + +Run: + +```bash +git add packages/server/src/work-analysis/evidence-sampler.ts packages/server/src/work-analysis/types.ts packages/server/src/work-analysis/deep-prompt.ts packages/server/src/work-analysis/deep-runner.ts packages/server/src/work-analysis/service.ts packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts packages/server/src/__tests__/work-analysis-deep-runner.test.ts packages/server/src/__tests__/work-analysis-service.test.ts +git commit -m "feat: sample provider log evidence for deep analysis" +``` + +--- + +### Task 8: Update Work Analysis UI And Localization + +**Files:** +- Modify: `packages/web/src/features/settings/components/session-analysis-settings.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Test: `packages/web/src/features/settings/components/settings-page.test.tsx` + +- [ ] **Step 1: Extend the existing settings page work-analysis test** + +Modify the existing work-analysis coverage in `packages/web/src/features/settings/components/settings-page.test.tsx`. The relevant tests already dispatch `work.analysis.get`, `work.analysis.runBasic`, and `work.analysis.runDeep`; extend the fixture returned by `work.analysis.get` so `basicResult` includes `dataSources.providers` and expanded `executionSignals`. + +- [ ] **Step 2: Write failing UI assertions** + +Add assertions that a completed analysis with `basicResult.dataSources.providers` renders: + +- provider status section +- all provider rows supplied by the result +- provider log wording, not current session wording + +Representative expected text: + +```ts +expect(screen.getByText(/Provider log sources/i)).toBeInTheDocument(); +expect(screen.getByText(/codex/i)).toBeInTheDocument(); +expect(screen.getByText(/local log matches/i)).toBeInTheDocument(); +``` + +For Chinese locale updates, ensure keys exist but do not add a separate i18n test unless the repo already has one. + +- [ ] **Step 3: Run the UI test and verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx +``` + +Expected: FAIL until the component renders provider source status. + +- [ ] **Step 4: Add localization keys** + +Modify `packages/web/src/locales/en.json` under `settings.analysis`: + +```json +"source_hint": "Analysis scans each provider's local logs for the selected workspace and time range.", +"provider_sources": "Provider log sources", +"provider_source_row": "{providerId}: {sessionCount} local log matches, {status}", +"provider_status_supported": "Ready", +"provider_status_no_logs": "No matching logs", +"provider_status_missing_root": "Log root missing", +"provider_status_partial": "Partial data", +"provider_status_unsupported": "Unsupported", +"mtime_fallback_hint": "{count} sessions used file modification time because the provider log did not include explicit timestamps.", +"log_coverage_summary": "Found {sessionCount} provider-local sessions across {workspaceCount} workspaces and {providerCount} providers." +``` + +Modify `packages/web/src/locales/zh.json` with equivalent Chinese strings: + +```json +"source_hint": "分析会扫描各 provider 在本机保存的日志,并按所选工作区和时间范围筛选。", +"provider_sources": "Provider 日志来源", +"provider_source_row": "{providerId}: 命中 {sessionCount} 个本地日志会话,状态 {status}", +"provider_status_supported": "可用", +"provider_status_no_logs": "无匹配日志", +"provider_status_missing_root": "日志目录不存在", +"provider_status_partial": "部分数据", +"provider_status_unsupported": "暂不支持", +"mtime_fallback_hint": "{count} 个会话使用了文件修改时间,因为 provider 日志没有明确时间戳。", +"log_coverage_summary": "在 {workspaceCount} 个工作区、{providerCount} 个 provider 中命中 {sessionCount} 个 provider 本地会话。" +``` + +- [ ] **Step 5: Update `SessionAnalysisSettings` rendering** + +Modify `packages/web/src/features/settings/components/session-analysis-settings.tsx`: + +- Add `formatProviderStatusLabel(status, t)`. +- Replace `coverage_summary` display with `log_coverage_summary` when new data exists. +- Render `analysis.basicResult.dataSources.providers` as a compact list under `provider_sources`. +- Render `mtime_fallback_hint` when `executionSignals.fileMtimeTimestampCount > 0`. +- Keep existing provider mix list. + +Do not redesign the whole settings page in this task. + +- [ ] **Step 6: Re-run UI test and web typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS + +- [ ] **Step 7: Commit Task 8** + +Run: + +```bash +git add packages/web/src/features/settings/components/session-analysis-settings.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json packages/web/src/features/settings/components/settings-page.test.tsx +git commit -m "feat: show provider log sources in work analysis" +``` + +--- + +### Task 9: Retire Session-Based Work Analysis Modules And Update Docs + +**Files:** +- Delete: `packages/server/src/work-analysis/session-selector.ts` +- Delete: `packages/server/src/work-analysis/evidence-collector.ts` +- Delete: `packages/server/src/__tests__/work-analysis-session-selector.test.ts` +- Delete: `packages/server/src/__tests__/work-analysis-evidence-collector.test.ts` +- Modify: `docs/help/work-analysis.md` + +- [ ] **Step 1: Verify no production imports remain** + +Run: + +```bash +rg -n "createWorkAnalysisSessionSelector|createWorkAnalysisEvidenceCollector|session-selector|evidence-collector" packages/server/src +``` + +Expected before deletion: only old modules/tests remain. If production imports remain, finish Task 6/7 wiring first. + +- [ ] **Step 2: Delete old modules and tests** + +Remove the four files listed above after imports are gone. + +- [ ] **Step 3: Update help docs** + +Modify `docs/help/work-analysis.md`: + +- Remove the prerequisite that a workspace must have an open agent session. +- Explain that data comes from provider local logs/cache. +- Mention all 5 built-in providers. +- Mention that provider data can be missing, partial, or timestamp-fallback. +- Explain that deep analysis uses sampled evidence. + +Use this replacement for the "前置条件" section: + +```md +## 前置条件 + +- 至少打开一个工作区,让 Coder Studio 知道要分析哪个 workspace path +- 该 workspace 在所选时间范围内最好有 provider 本地日志 +- 不要求当前有打开中的 Coder Studio session +``` + +- [ ] **Step 4: Run deletion/doc checks** + +Run: + +```bash +rg -n "createWorkAnalysisSessionSelector|createWorkAnalysisEvidenceCollector|terminalSnapshot|latestUserInput" packages/server/src/work-analysis packages/server/src/__tests__ +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: + +- `rg` has no matches in work-analysis production code. If tests still mention old evidence field names only as legacy fixtures, update them. +- Typecheck PASS. + +- [ ] **Step 5: Commit Task 9** + +Run: + +```bash +git add -A packages/server/src/work-analysis/session-selector.ts packages/server/src/work-analysis/evidence-collector.ts packages/server/src/__tests__/work-analysis-session-selector.test.ts packages/server/src/__tests__/work-analysis-evidence-collector.test.ts docs/help/work-analysis.md +git commit -m "docs: document provider log work analysis" +``` + +--- + +### Task 10: Final Verification + +**Files:** +- No planned edits unless verification finds a defect. + +- [ ] **Step 1: Run all targeted server work-analysis tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/work-analysis-log-source-helpers.test.ts \ + src/__tests__/work-analysis-log-sources-file-adapters.test.ts \ + src/__tests__/work-analysis-log-source-opencode.test.ts \ + src/__tests__/work-analysis-log-collector.test.ts \ + src/__tests__/work-analysis-basic-analyzer.test.ts \ + src/__tests__/work-analysis-service.test.ts \ + src/__tests__/work-analysis-deep-runner.test.ts \ + src/__tests__/work-analysis-repo.test.ts \ + src/__tests__/work-analysis-commands.test.ts +``` + +Expected: PASS + +- [ ] **Step 2: Run package typechecks** + +Run: + +```bash +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS + +- [ ] **Step 3: Run full workspace tests if time allows** + +Run: + +```bash +pnpm ci:test:workspace +``` + +Expected: PASS + +- [ ] **Step 4: Run lint/check** + +Run: + +```bash +pnpm ci:lint +``` + +Expected: PASS + +- [ ] **Step 5: Manual smoke check with local logs** + +Start the app: + +```bash +pnpm dev +``` + +Manual check: + +- Open Settings > Work Analysis. +- Select `/home/spencer/workspace/coder-studio`. +- Select `Last 7 days`. +- Run Basic Analysis. +- Confirm provider source rows appear for `claude`, `codex`, `gemini`, `cursor`, and `opencode`. +- Confirm Codex reports more than one local log match when local logs exist. +- Confirm no text implies only currently open Coder Studio sessions are analyzed. + +- [ ] **Step 6: Commit any verification fixes** + +If Step 1-5 required fixes: + +```bash +git status --short +git add docs/help/work-analysis.md packages/server/src packages/web/src +git commit -m "fix: stabilize provider work log analysis" +``` + +If no fixes were required, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-06-04-agent-instructions-richer-generation.md b/docs/superpowers/plans/2026-06-04-agent-instructions-richer-generation.md new file mode 100644 index 000000000..18d45e0b8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-agent-instructions-richer-generation.md @@ -0,0 +1,594 @@ +# Richer Agent Instructions Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade generated `.coder-studio/agent.md` from a thin repository summary into a compact project operating guide with architecture map, key directories, stronger commands, and file constraints. + +**Architecture:** Expand the structured workspace intelligence summary first, because generation quality is limited by missing input facts today. Then update the agent-generation prompt and the deterministic fallback generator to consume the richer summary while keeping output pure Markdown, compact, and conservative. + +**Tech Stack:** TypeScript, Vitest, Node.js filesystem inspection, existing server-side workspace intelligence and agent-instructions generation pipeline + +--- + +## File Map + +- Modify: `packages/core/src/domain/types.ts` + - Extend `WorkspaceIntelligenceSummary` and related command types to carry richer inferred repository facts. +- Modify: `packages/server/src/workspace/intelligence.ts` + - Add monorepo/package/directory/doc/command/constraint inference. +- Modify: `packages/server/src/agent-instructions/prompt.ts` + - Change prompt contract to request richer sections and pure Markdown architecture hierarchy. +- Modify: `packages/server/src/agent-instructions/generator.ts` + - Keep deterministic non-agent generation aligned with the richer section structure. +- Modify: `packages/server/src/__tests__/agent-instructions/generator.test.ts` + - Update deterministic output expectations. +- Modify: `packages/server/src/__tests__/agent-instructions-command.test.ts` + - Verify richer prompt-driven generation behavior and summary-derived content. +- Create: `packages/server/src/__tests__/workspace/intelligence.test.ts` + - Add focused tests for summary inference and prioritization. +- Optional modify if needed by exports only: `packages/core/src/index.ts` or existing export barrel files + - Only if `WorkspaceIntelligenceSummary` type changes require export updates. + +## Task 1: Expand Workspace Intelligence Types + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Test: `packages/server/src/__tests__/workspace/intelligence.test.ts` + +- [ ] **Step 1: Write the failing type-driven workspace intelligence tests** + +Create `packages/server/src/__tests__/workspace/intelligence.test.ts` with focused cases for richer inference: + +```ts +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { inspectWorkspaceIntelligence } from "../../workspace/intelligence.js"; + +describe("inspectWorkspaceIntelligence", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.map(async (dir) => { + const { rm } = await import("node:fs/promises"); + await rm(dir, { recursive: true, force: true }); + }) + ); + }); + + it("infers a monorepo architecture summary with key directories and stronger verification commands", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-")); + tempDirs.push(rootPath); + + await writeFile(join(rootPath, "pnpm-workspace.yaml"), "packages:\\n - packages/*\\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify({ + scripts: { + dev: "tsx scripts/dev.ts", + build: "tsx scripts/build.ts", + lint: "biome lint .", + "ci:test": "pnpm -r test", + "ci:typecheck": "pnpm -r exec tsc -p tsconfig.json --noEmit", + "ci:verify": "pnpm ci:test && pnpm ci:typecheck", + "acceptance:phase1": "pnpm --dir e2e exec playwright test --grep @phase1", + }, + }) + ); + await writeFile(join(rootPath, "README.md"), "# Repo\\n"); + await mkdir(join(rootPath, "docs", "help"), { recursive: true }); + await writeFile(join(rootPath, "docs", "help", "quick-start.md"), "# Quick Start\\n"); + await mkdir(join(rootPath, "packages", "web"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "web", "package.json"), + JSON.stringify({ name: "@repo/web", scripts: { test: "vitest run" } }) + ); + await mkdir(join(rootPath, "packages", "server"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "server", "package.json"), + JSON.stringify({ name: "@repo/server" }) + ); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-1", + rootPath, + }); + + expect(summary.workspaceKind).toBe("monorepo"); + expect(summary.keyDirectories.map((entry) => entry.path)).toEqual([ + "packages/web", + "packages/server", + "docs", + ]); + expect(summary.packages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "packages/web", role: "frontend_ui" }), + expect.objectContaining({ path: "packages/server", role: "backend_runtime" }), + ]) + ); + expect(summary.verificationCommands.map((entry) => entry.command)).toEqual( + expect.arrayContaining(["pnpm ci:test", "pnpm ci:typecheck", "pnpm ci:verify"]) + ); + expect(summary.fileConstraints).toEqual( + expect.arrayContaining([ + expect.stringContaining("package boundaries"), + expect.stringContaining("unrelated refactors"), + ]) + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest packages/server/src/__tests__/workspace/intelligence.test.ts` + +Expected: FAIL because `WorkspaceIntelligenceSummary` and `inspectWorkspaceIntelligence` do not yet expose `workspaceKind`, `keyDirectories`, `packages`, `verificationCommands`, or `fileConstraints`. + +- [ ] **Step 3: Extend the shared summary types with richer repository facts** + +Update `packages/core/src/domain/types.ts` to add the new summary structures while preserving existing fields: + +```ts +export interface WorkspaceIntelligenceKeyDirectory { + path: string; + kind: + | "frontend" + | "backend" + | "providers" + | "shared" + | "cli" + | "docs" + | "tests" + | "scripts" + | "other"; + reason: string; +} + +export interface WorkspaceIntelligencePackageSummary { + path: string; + name?: string; + role: + | "frontend_ui" + | "backend_runtime" + | "provider_integrations" + | "shared_contracts" + | "cli_entrypoint" + | "shared_utilities" + | "shared_package"; + scripts: string[]; +} + +export interface WorkspaceIntelligenceCommand { + command: string; + reason: string; + priority: "verification" | "quality" | "dev"; +} + +export interface WorkspaceIntelligenceDocEntry { + path: string; + kind: "readme" | "docs" | "guide" | "wiki"; +} + +export interface WorkspaceIntelligenceSummary { + // existing fields... + workspaceKind?: "monorepo" | "node_app" | "unknown"; + topLevelDirectories?: string[]; + keyDirectories?: WorkspaceIntelligenceKeyDirectory[]; + packages?: WorkspaceIntelligencePackageSummary[]; + documentationEntries?: WorkspaceIntelligenceDocEntry[]; + verificationCommands?: WorkspaceIntelligenceCommand[]; + fileConstraints?: string[]; +} +``` + +- [ ] **Step 4: Run test to verify the new type shape compiles but behavior still fails** + +Run: `pnpm vitest packages/server/src/__tests__/workspace/intelligence.test.ts` + +Expected: FAIL on assertions rather than type/property-missing errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/domain/types.ts packages/server/src/__tests__/workspace/intelligence.test.ts +git commit -m "feat: extend workspace intelligence summary types" +``` + +## Task 2: Implement Richer Workspace Intelligence Inference + +**Files:** +- Modify: `packages/server/src/workspace/intelligence.ts` +- Test: `packages/server/src/__tests__/workspace/intelligence.test.ts` + +- [ ] **Step 1: Write the next failing test for conservative selection limits** + +Add a second test in `packages/server/src/__tests__/workspace/intelligence.test.ts`: + +```ts +it("caps key directories and skips noisy root folders", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-noise-")); + tempDirs.push(rootPath); + + await writeFile(join(rootPath, "package.json"), JSON.stringify({ scripts: {} })); + await mkdir(join(rootPath, "packages", "core"), { recursive: true }); + await writeFile(join(rootPath, "packages", "core", "package.json"), JSON.stringify({ name: "@repo/core" })); + await mkdir(join(rootPath, "packages", "providers"), { recursive: true }); + await writeFile(join(rootPath, "packages", "providers", "package.json"), JSON.stringify({ name: "@repo/providers" })); + await mkdir(join(rootPath, "node_modules"), { recursive: true }); + await mkdir(join(rootPath, ".git"), { recursive: true }); + await mkdir(join(rootPath, "scripts"), { recursive: true }); + await mkdir(join(rootPath, "e2e"), { recursive: true }); + + const summary = await inspectWorkspaceIntelligence({ workspaceId: "ws-1", rootPath }); + + expect(summary.keyDirectories?.length).toBeLessThanOrEqual(6); + expect(summary.keyDirectories?.map((entry) => entry.path)).not.toContain("node_modules"); + expect(summary.topLevelDirectories).not.toContain(".git"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest packages/server/src/__tests__/workspace/intelligence.test.ts` + +Expected: FAIL because the current intelligence builder does not collect or filter these structures. + +- [ ] **Step 3: Implement deterministic repository inference helpers** + +Refactor `packages/server/src/workspace/intelligence.ts` to: + +- read root `package.json` scripts once and preserve current command behavior +- discover top-level directories with filtering for hidden/system noise +- scan `packages/*/package.json` +- infer package roles from path/name +- select 3-6 key directories in stable order +- derive documentation entries from `README.md`, `docs/help/*`, and `docs/wiki/*` +- derive verification commands from root scripts using explicit priority +- derive compact file-constraint strings from repo shape + +Use helpers like: + +```ts +function inferWorkspaceKind(rootPath: string, packageEntries: PackageEntry[]): WorkspaceKind +function inferPackageRole(packagePath: string, packageName?: string): WorkspaceIntelligencePackageSummary["role"] +function selectKeyDirectories(input: { + packageEntries: PackageEntry[]; + rootDirectories: string[]; + docs: WorkspaceIntelligenceDocEntry[]; +}): WorkspaceIntelligenceKeyDirectory[] +function buildVerificationCommands( + packageManager: PackageManager | undefined, + rootScripts: Record +): WorkspaceIntelligenceCommand[] +function buildFileConstraints(summary: { + workspaceKind: WorkspaceKind; + keyDirectories: WorkspaceIntelligenceKeyDirectory[]; +}): string[] +``` + +Keep the inference conservative: + +- omit uncertain facts instead of guessing +- prefer stable ordering over filesystem order +- cap list sizes aggressively + +- [ ] **Step 4: Run the focused intelligence tests to verify they pass** + +Run: `pnpm vitest packages/server/src/__tests__/workspace/intelligence.test.ts` + +Expected: PASS + +- [ ] **Step 5: Run the existing agent instructions tests that depend on workspace intelligence** + +Run: `pnpm vitest packages/server/src/__tests__/agent-instructions-command.test.ts packages/server/src/__tests__/agent-instructions/generator.test.ts` + +Expected: FAIL in deterministic output expectations because the section contract has not been updated yet. + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/workspace/intelligence.ts packages/server/src/__tests__/workspace/intelligence.test.ts +git commit -m "feat: infer richer workspace intelligence for agent instructions" +``` + +## Task 3: Update Prompt Contract for Richer Agent Output + +**Files:** +- Modify: `packages/server/src/agent-instructions/prompt.ts` +- Test: `packages/server/src/__tests__/agent-instructions-command.test.ts` + +- [ ] **Step 1: Add a failing prompt expectation test** + +In `packages/server/src/__tests__/agent-instructions-command.test.ts`, add a focused case that captures the prompt passed into the provider command builder: + +```ts +it("builds a richer generation prompt with architecture and file-constraint sections", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-prompt-rich-")); + tempDirs.push(rootPath); + + const commandBuilder = vi.fn((_config, _scenario, req) => ({ + argv: ["codex", "exec", req.prompt], + })); + runCommandAsStringMock.mockResolvedValue({ + stdout: codexJsonlPayload(generationPayload("# Agent Instructions\\n\\n## Project Overview\\n")), + stderr: "", + }); + + await dispatch( + { + kind: "command", + id: "agent-instructions-rich-prompt", + op: "agentInstructions.generateByAgent", + args: { workspaceId: "ws-1", providerId: "codex" }, + }, + createContext(rootPath, { + providerRegistry: [ + createAgentGenerationProvider({ + commandBuilder, + }), + ], + }) + ); + + const prompt = commandBuilder.mock.calls[0]?.[2]?.prompt as string; + expect(prompt).toContain("Architecture Map"); + expect(prompt).toContain("Key Directories"); + expect(prompt).toContain("File Constraints"); + expect(prompt).toContain("Review Checklist"); + expect(prompt).toContain("Use a Markdown hierarchy under 'Architecture Map'"); + expect(prompt).toContain("List only 3-6 key directories"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest packages/server/src/__tests__/agent-instructions-command.test.ts` + +Expected: FAIL because the prompt still asks for the old five-section document. + +- [ ] **Step 3: Rewrite the prompt to use the richer section contract** + +Update `packages/server/src/agent-instructions/prompt.ts` so it: + +- requests the new eight-section order +- explicitly asks for a pure Markdown hierarchy in `Architecture Map` +- limits `Key Directories` to 3-6 items +- asks for short, concrete `File Constraints` +- renames rule-oriented sections to `Workflow Expectations` and `Review Checklist` +- still requires exact JSON output and no commentary + +The section contract should look like: + +```ts +const REQUIRED_WORKFLOW_EXPECTATIONS = [ + "Keep changes focused on the requested task.", + "Do not revert user changes unless explicitly asked.", + "Prefer the project's existing patterns.", + "Run the relevant verification command before reporting completion.", +] as const; + +const REQUIRED_REVIEW_CHECKLIST = [ + "Summarize changed files.", + "Report verification commands and results.", + "Call out risks, skipped tests, and assumptions.", +] as const; +``` + +And the prompt should include lines like: + +```ts +"Use exactly these second-level sections in this order:", +"- Project Overview", +"- Architecture Map", +"- Key Directories", +"- Development Commands", +"- Workflow Expectations", +"- File Constraints", +"- Review Checklist", +"- Provider Notes", +"Use a Markdown hierarchy under 'Architecture Map'.", +"List only 3-6 entries under 'Key Directories'.", +``` + +- [ ] **Step 4: Run the prompt-focused command test to verify it passes** + +Run: `pnpm vitest packages/server/src/__tests__/agent-instructions-command.test.ts` + +Expected: PASS for the new prompt expectations, with possible failures still remaining in deterministic generator tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/agent-instructions/prompt.ts packages/server/src/__tests__/agent-instructions-command.test.ts +git commit -m "feat: enrich agent instructions generation prompt" +``` + +## Task 4: Align the Deterministic Generator with the New Section Shape + +**Files:** +- Modify: `packages/server/src/agent-instructions/generator.ts` +- Modify: `packages/server/src/__tests__/agent-instructions/generator.test.ts` + +- [ ] **Step 1: Update the deterministic generator test first** + +Rewrite `packages/server/src/__tests__/agent-instructions/generator.test.ts` to assert the new compact structure: + +```ts +expect(buildAgentInstructionsMarkdown(summary)).toBe( + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- This workspace is a monorepo on branch `main`.", + "", + "## Architecture Map", + "", + "- packages/", + " - web: frontend UI", + " - server: backend runtime and websocket commands", + "", + "## Key Directories", + "", + "- `packages/web`: frontend UI and workspace interactions.", + "- `packages/server`: backend runtime and command dispatch.", + "", + "## Development Commands", + "", + "- `pnpm ci:verify`", + "- `pnpm test`", + "- `pnpm dev`", + "", + "## Workflow Expectations", + "", + "- Keep changes focused on the requested task.", + // ... + ].join("\\n") +); +``` + +- [ ] **Step 2: Run the deterministic generator test to verify it fails** + +Run: `pnpm vitest packages/server/src/__tests__/agent-instructions/generator.test.ts` + +Expected: FAIL because the current static generator still emits the old five-section layout. + +- [ ] **Step 3: Implement the minimal deterministic generator update** + +Update `packages/server/src/agent-instructions/generator.ts` to render: + +- compact overview from new summary fields +- Markdown hierarchy under `Architecture Map` +- bullet list for `Key Directories` +- stronger command list using `verificationCommands` first, then existing recommended commands +- `Workflow Expectations`, `File Constraints`, and `Review Checklist` sections + +Use helper-style rendering functions so the output remains deterministic: + +```ts +function renderArchitectureMap(summary: WorkspaceIntelligenceSummary): string[] +function renderKeyDirectories(summary: WorkspaceIntelligenceSummary): string[] +function renderDevelopmentCommands(summary: WorkspaceIntelligenceSummary): string[] +function renderFileConstraints(summary: WorkspaceIntelligenceSummary): string[] +``` + +Keep the fallback compact even if optional richer fields are missing. + +- [ ] **Step 4: Run the deterministic generator and command-level test suite** + +Run: `pnpm vitest packages/server/src/__tests__/agent-instructions/generator.test.ts packages/server/src/__tests__/agent-instructions-command.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/agent-instructions/generator.ts packages/server/src/__tests__/agent-instructions/generator.test.ts +git commit -m "feat: align static agent instructions generator with richer layout" +``` + +## Task 5: Full Verification and Real Codex Generation + +**Files:** +- Verify only: `packages/server/src/workspace/intelligence.ts` +- Verify only: `packages/server/src/agent-instructions/prompt.ts` +- Verify only: `packages/server/src/agent-instructions/generator.ts` +- Output: `.coder-studio/agent.md` + +- [ ] **Step 1: Run the full targeted server verification suite** + +Run: + +```bash +pnpm vitest \ + packages/server/src/__tests__/workspace/intelligence.test.ts \ + packages/server/src/__tests__/agent-instructions/generator.test.ts \ + packages/server/src/__tests__/agent-instructions-command.test.ts \ + packages/server/src/__tests__/provider-runtime/command-runner.test.ts +``` + +Expected: PASS + +- [ ] **Step 2: Start a real server instance without provider mocks** + +Run: + +```bash +env HOST=127.0.0.1 PORT=35153 STATE_DIR=/tmp/coder-studio-real-e2e.a0UvvH/state RUNTIME_DIR=/tmp/coder-studio-real-e2e.a0UvvH/runtime NO_AUTH=true pnpm exec tsx packages/server/src/server.ts +``` + +Expected: server listens on `http://127.0.0.1:35153` + +- [ ] **Step 3: Exercise the real WS command path with Codex** + +Run a minimal WS client that sends: + +```js +activation.claim +workspace.open +workspace.activate +agentInstructions.generateAndWriteByAgent +``` + +Use: + +```bash +node scripts/tmp-run-agent-instructions-ws-check.js +``` + +Expected: success result with `meta.providerId === "codex"` and a written `.coder-studio/agent.md` + +If a temporary script is needed, create it under `scripts/` and delete it before final commit unless the repository benefits from keeping it. + +- [ ] **Step 4: Inspect the generated file content** + +Run: + +```bash +sed -n '1,220p' .coder-studio/agent.md +``` + +Expected content includes: + +- `## Architecture Map` +- `## Key Directories` +- `## File Constraints` +- more than just `pnpm dev/build/lint` + +- [ ] **Step 5: Commit implementation if all verification passes** + +```bash +git add packages/core/src/domain/types.ts \ + packages/server/src/workspace/intelligence.ts \ + packages/server/src/agent-instructions/prompt.ts \ + packages/server/src/agent-instructions/generator.ts \ + packages/server/src/__tests__/workspace/intelligence.test.ts \ + packages/server/src/__tests__/agent-instructions/generator.test.ts \ + packages/server/src/__tests__/agent-instructions-command.test.ts +git commit -m "feat: enrich generated agent instructions" +``` + +## Self-Review + +Spec coverage check: + +- richer architecture map: covered by Tasks 2-4 +- key directories: covered by Tasks 1-4 +- stronger command guidance: covered by Tasks 2-4 +- file constraints and workflow/review guidance: covered by Tasks 2-4 +- real Codex verification: covered by Task 5 + +Placeholder scan: + +- no `TODO` or `TBD` +- all file paths are explicit +- all verification commands are explicit + +Type consistency check: + +- new summary fields are introduced in Task 1 before later tasks consume them +- prompt section names are defined in Task 3 before generator alignment in Task 4 +- real verification path in Task 5 matches the known working WS flow diff --git a/docs/superpowers/plans/2026-06-04-tasks-verification-git-review.md b/docs/superpowers/plans/2026-06-04-tasks-verification-git-review.md new file mode 100644 index 000000000..6fefe119d --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-tasks-verification-git-review.md @@ -0,0 +1,3426 @@ +# Tasks Verification And Git Review Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a bottom-panel `Tasks / Verification` workflow that runs managed verification commands through task terminals, then upgrade Git review with hunk-level stage, unstage, and discard actions. + +**Architecture:** Ship this as two ordered milestones. Milestone A adds shared task contracts, a server-side `TaskManager`, task discovery, task commands, task events, and a bottom-panel tab beside Terminal that launches task output in the existing xterm surface. Milestone B enriches Git diff payloads with server-generated hunk IDs and applies hunk operations server-side with `git apply`, then renders local hunk actions in the diff viewer. + +**Tech Stack:** TypeScript, Zod, React, Jotai, xterm.js, node-pty, Git CLI, Fastify WebSocket command handlers, Vitest, Biome, pnpm + +--- + +## Execution Rules + +- Implement Milestone A completely before starting Milestone B. Tasks must be usable before Git hunk staging work begins. +- Do not add VS Code extension compatibility, Debug Adapter Protocol support, a full Problems panel, line-level staging, commit-message AI, or SQLite task history in this plan. +- Keep task run history in memory for MVP. Store the latest run per task and a short tail summary only; terminal replay remains the full output source. +- Preserve existing terminal behavior for `shell` and `agent` terminals. `task` terminals are managed but still use the same terminal output, replay, snapshot, resize, close, and mobile rendering infrastructure. +- The client must not send patch text for Git hunk operations. It sends `workspaceId`, `path`, `staged`, `hunkId`, and `operation`; the server validates the hunk against the current diff before applying. + +## File Map + +- `packages/core/src/domain/types.ts` + - Add `TerminalKind`, task contracts, and Git hunk contracts. + - Extend `Terminal.kind` and `GitFileDiffPayload`. +- `packages/core/src/domain/events.ts` + - Add task domain events and extend terminal-created kind to `TerminalKind`. +- `packages/core/src/protocol/topics.ts` + - Add workspace task topics. +- `packages/core/src/domain/types.test.ts` + - Lock task and Git hunk contracts with type assertions. +- `packages/server/src/terminal/types.ts` + - Use shared `TerminalKind` for `TerminalSpec.kind` and terminal spawn details. +- `packages/server/src/terminal/manager.ts` + - Allow `task` terminals to get snapshot buffers and emit `terminal.created` with `kind: "task"`. +- `packages/server/src/storage/repositories/terminal-repo.ts` + - Accept persisted `task` terminal records. +- `packages/server/src/tasks/discovery.ts` + - Discover tasks from `.coder-studio/tasks.json`, `package.json`, `pnpm-workspace.yaml`, `Cargo.toml`, `go.mod`, `pyproject.toml`, and `Makefile`. +- `packages/server/src/tasks/discovery.test.ts` + - Verify explicit config, package scripts, monorepo verify preference, ecosystem detection, and malformed-source warnings. +- `packages/server/src/tasks/manager.ts` + - Own in-memory task definitions, latest runs, terminal-run mapping, output tail summaries, run/stop/rerun, and task events. +- `packages/server/src/tasks/manager.test.ts` + - Verify task terminal creation, run status transitions, output summary capping, stop, and rerun. +- `packages/server/src/commands/task.ts` + - Register `task.discover`, `task.list`, `task.run`, `task.stop`, `task.rerun`, and `task.history`. +- `packages/server/src/commands/index.ts` + - Import `./task.js`. +- `packages/server/src/ws/dispatch.ts` + - Add `taskMgr` to `CommandContext`. +- `packages/server/src/ws/hub.ts` + - Broadcast task events on task topics. +- `packages/server/src/server.ts` + - Construct `TaskManager`, inject it into command context, and stop workspace tasks during workspace teardown. +- `packages/server/src/__tests__/task-commands.test.ts` + - Verify command registration and command-to-manager behavior. +- `packages/server/src/__tests__/terminal-commands.test.ts` + - Verify `terminal.list` includes task terminals server-side. +- `packages/web/src/features/bottom-panel/atoms.ts` + - Store active bottom-panel tab per workspace. +- `packages/web/src/features/bottom-panel/index.ts` + - Export the bottom-panel atoms. +- `packages/web/src/features/workspace/views/shared/workspace-bottom-panel.tsx` + - Render top-level bottom-panel tabs: `Terminal` and `Tasks`. +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + - Replace direct `TerminalPanel` mount with `WorkspaceBottomPanel`. +- `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx` + - Keep mobile terminal fullscreen behavior intact; do not show the task list inside the mobile terminal overlay in MVP. +- `packages/web/src/features/terminal-panel/atoms/terminals.ts` + - Extend terminal metadata kind to include `task`. +- `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` + - Include `shell` and `task` terminals in the bottom Terminal selector, while continuing to exclude `agent` terminals. +- `packages/web/src/features/terminal-panel/components/title-format.ts` + - Preserve task titles such as `Task: Verify` instead of formatting them as shell labels. +- `packages/web/src/features/terminal-panel/views/shared/terminal-selector-item.tsx` + - Show a managed/task marker for `task` terminals. +- `packages/web/src/features/terminal-panel/views/shared/terminal-tab.tsx` + - Show a managed/task marker for `task` terminal tabs. +- `packages/web/src/features/tasks/atoms.ts` + - Store discovered task definitions, latest runs, loading state, and errors per workspace. +- `packages/web/src/features/tasks/actions/use-task-actions.ts` + - Fetch tasks, subscribe to task events, run/stop/rerun tasks, and switch output to the Terminal tab. +- `packages/web/src/features/tasks/views/shared/tasks-panel.tsx` + - Render discovered tasks with status, duration, command preview, and `Run` / `Stop` / `Rerun`. +- `packages/web/src/features/tasks/__tests__/tasks-panel.test.tsx` + - Verify rendering, run, stop, rerun, event updates, and terminal-tab switching. +- `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` + - Verify task terminals appear beside shell terminals and agent terminals remain excluded. +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` + - Verify the bottom panel renders both top-level tabs on desktop. +- `packages/web/src/features/agent-panes/components/session-card.test.tsx` + - Verify compact latest verify status in an agent pane. +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` + - Render latest verify state with `View output` and `Rerun`. +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` + - Render compact verification banner in Git review context. +- `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` + - Verify verification banner and rerun behavior. +- `packages/web/src/locales/en.json` + - Add English task and Git hunk labels. +- `packages/web/src/locales/zh.json` + - Add Chinese task and Git hunk labels. +- `packages/server/src/git/hunks.ts` + - Parse unified diffs into stable server-generated hunk descriptors. +- `packages/server/src/git/hunks.test.ts` + - Verify hunk parsing, stable IDs, and single-hunk patch construction. +- `packages/server/src/git/hunk-operations.ts` + - Validate current diff hunk ID and apply stage, unstage, and discard with `git apply`. +- `packages/server/src/git/hunk-operations.test.ts` + - Verify hunk stage, unstage, discard, and stale hunk rejection in real temporary Git repositories. +- `packages/server/src/git/diff.ts` + - Include `hunks` on text diff payloads. +- `packages/server/src/commands/git.ts` + - Register `git.hunk` command. +- `packages/server/src/__tests__/git-commands.test.ts` + - Verify command validation and state-change event emission for `git.hunk`. +- `packages/web/src/features/workspace/actions/use-git-actions.ts` + - Add hunk operation action, refresh affected preview, and surface stale diff errors. +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` + - Render file-level and hunk-level review actions in the diff surface. +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.test.tsx` + - Verify hunk actions, stale error behavior, file actions, and stable selected preview. + +## Milestone A: Tasks / Verification + +### Task 1: Add Shared Task, Terminal, and Git Hunk Contracts + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/core/src/domain/events.ts` +- Modify: `packages/core/src/protocol/topics.ts` +- Modify: `packages/core/src/domain/types.test.ts` + +- [ ] **Step 1: Write the failing shared-contract tests** + +Append these imports to `packages/core/src/domain/types.test.ts`: + +```ts +import type { + GitDiffHunk, + GitHunkOperation, + TaskDefinition, + TaskRun, + Terminal, + TerminalKind, +} from "./types"; +``` + +Append these tests to `packages/core/src/domain/types.test.ts`: + +```ts +describe("Task contracts", () => { + it("defines managed workspace task definitions", () => { + expectTypeOf().toEqualTypeOf<{ + id: string; + workspaceId: string; + kind: "verify" | "test" | "lint" | "build" | "dev" | "custom"; + label: string; + command: string; + args: string[]; + cwdPath?: string; + source: + | "coder-studio" + | "package-json" + | "pnpm-workspace" + | "cargo" + | "go" + | "python" + | "makefile" + | "inferred"; + priority: number; + }>(); + }); + + it("defines managed task run state", () => { + expectTypeOf().toEqualTypeOf<{ + id: string; + workspaceId: string; + taskId: string; + terminalId: string; + status: "queued" | "running" | "passed" | "failed" | "stopped"; + command: string; + args: string[]; + cwdPath?: string; + startedAt: number; + finishedAt?: number; + exitCode?: number; + summary?: { + tailLines: string[]; + }; + }>(); + }); + + it("allows task terminals as managed terminal DTOs", () => { + expectTypeOf().toEqualTypeOf<"agent" | "shell" | "task">(); + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe("Git hunk contracts", () => { + it("defines hunk descriptors returned by diff payloads", () => { + expectTypeOf().toEqualTypeOf<{ + id: string; + header: string; + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + patch: string; + lines: string[]; + }>(); + expectTypeOf().toEqualTypeOf(); + }); + + it("defines server-validated hunk operations", () => { + expectTypeOf().toEqualTypeOf<{ + workspaceId: string; + path: string; + staged: boolean; + hunkId: string; + operation: "stage" | "unstage" | "discard"; + }>(); + }); +}); +``` + +- [ ] **Step 2: Run the focused core test and confirm it fails** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/types.test.ts +``` + +Expected: FAIL with missing exports for `TaskDefinition`, `TaskRun`, `TerminalKind`, `GitDiffHunk`, and `GitHunkOperation`. + +- [ ] **Step 3: Add shared contracts** + +Add these types in `packages/core/src/domain/types.ts` near the existing `Terminal` and Git types: + +```ts +export type TerminalKind = "agent" | "shell" | "task"; + +export type TaskKind = "verify" | "test" | "lint" | "build" | "dev" | "custom"; + +export type TaskSource = + | "coder-studio" + | "package-json" + | "pnpm-workspace" + | "cargo" + | "go" + | "python" + | "makefile" + | "inferred"; + +export type TaskRunStatus = "queued" | "running" | "passed" | "failed" | "stopped"; + +export interface TaskDefinition { + id: string; + workspaceId: string; + kind: TaskKind; + label: string; + command: string; + args: string[]; + cwdPath?: string; + source: TaskSource; + priority: number; +} + +export interface TaskRun { + id: string; + workspaceId: string; + taskId: string; + terminalId: string; + status: TaskRunStatus; + command: string; + args: string[]; + cwdPath?: string; + startedAt: number; + finishedAt?: number; + exitCode?: number; + summary?: { + tailLines: string[]; + }; +} + +export interface GitDiffHunk { + id: string; + header: string; + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + patch: string; + lines: string[]; +} + +export interface GitHunkOperation { + workspaceId: string; + path: string; + staged: boolean; + hunkId: string; + operation: "stage" | "unstage" | "discard"; +} +``` + +Change `Terminal.kind` in `packages/core/src/domain/types.ts` from: + +```ts +kind: "agent" | "shell"; +``` + +to: + +```ts +kind: TerminalKind; +``` + +Add `hunks` to `GitFileDiffPayload`: + +```ts +hunks?: GitDiffHunk[]; +``` + +- [ ] **Step 4: Add task domain events and topics** + +Modify imports in `packages/core/src/domain/events.ts`: + +```ts +import type { SessionState, TaskDefinition, TaskRun, TerminalKind, Workspace } from "./types"; +``` + +Change `terminal.created.kind` in `DomainEvent` to: + +```ts +kind: TerminalKind; +``` + +Add these task event variants before the LSP event variant: + +```ts +| { type: "task.discovered"; workspaceId: string; tasks: TaskDefinition[] } +| { type: "task.run.started"; workspaceId: string; run: TaskRun } +| { type: "task.run.updated"; workspaceId: string; run: TaskRun } +| { type: "task.run.finished"; workspaceId: string; run: TaskRun } +| { type: "task.run.stopped"; workspaceId: string; run: TaskRun } +``` + +Add these topic helpers in `packages/core/src/protocol/topics.ts` after terminal helpers: + +```ts + // Task-level + workspaceTaskDiscovered: (workspaceId: string) => `workspace.${workspaceId}.task.discovered`, + workspaceTaskRun: (workspaceId: string, runId: string) => + `workspace.${workspaceId}.task.${runId}`, + workspaceTasksAll: (workspaceId: string) => `workspace.${workspaceId}.task.*`, +``` + +- [ ] **Step 5: Run the focused core test and typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/types.test.ts +pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 6: Commit shared contracts** + +Run: + +```bash +git add packages/core/src/domain/types.ts packages/core/src/domain/events.ts packages/core/src/protocol/topics.ts packages/core/src/domain/types.test.ts +git commit -m "feat: add task and hunk contracts" +``` + +### Task 2: Extend Terminal Infrastructure for Task Terminals + +**Files:** +- Modify: `packages/server/src/terminal/types.ts` +- Modify: `packages/server/src/terminal/manager.ts` +- Modify: `packages/server/src/storage/repositories/terminal-repo.ts` +- Modify: `packages/server/src/terminal/manager.test.ts` +- Modify: `packages/server/src/__tests__/terminal-commands.test.ts` + +- [ ] **Step 1: Write failing terminal kind tests** + +Add this test to `packages/server/src/terminal/manager.test.ts` in the create/snapshot behavior section: + +```ts +it("creates task terminals with snapshot support", async () => { + const terminal = manager.create({ + workspaceId: "ws-123", + kind: "task", + argv: ["pnpm", "ci:verify"], + cwd: "/test", + title: "Task: Verify", + cols: 100, + rows: 24, + }); + + expect(terminal.kind).toBe("task"); + expect(terminal.title).toBe("Task: Verify"); + expect(manager.get(terminal.id)?.snapshotBuffer).toBeDefined(); +}); +``` + +Add this test to `packages/server/src/__tests__/terminal-commands.test.ts` in the `terminal.list` section: + +```ts +it("returns task terminals from terminal.list", async () => { + const ctx = createContext({ + terminalMgr: { + getAll: vi.fn(() => [ + { + id: "term-task", + workspaceId: "ws-1", + kind: "task", + title: "Task: Verify", + alive: true, + }, + ]), + } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "cmd-1", + op: "terminal.list", + args: { workspaceId: "ws-1" }, + }, + ctx, + "client-1" + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + id: "term-task", + workspaceId: "ws-1", + kind: "task", + title: "Task: Verify", + alive: true, + }, + ]); +}); +``` + +- [ ] **Step 2: Run focused terminal tests and confirm failures** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/terminal/manager.test.ts src/__tests__/terminal-commands.test.ts +``` + +Expected: FAIL because server terminal types and repository validation only allow `agent` and `shell`. + +- [ ] **Step 3: Update server terminal kind types** + +Modify `packages/server/src/terminal/types.ts`: + +```ts +import type { Terminal, TerminalKind } from "@coder-studio/core"; +``` + +Change `TerminalSpec.kind` to: + +```ts +kind: TerminalKind; +``` + +Change `TerminalSpawnError.details.terminalKind` to: + +```ts +terminalKind?: TerminalKind; +``` + +- [ ] **Step 4: Give task terminals snapshot buffers** + +In `packages/server/src/terminal/manager.ts`, change: + +```ts +if (spec.kind === "shell" || spec.kind === "agent") { +``` + +to: + +```ts +if (spec.kind === "shell" || spec.kind === "agent" || spec.kind === "task") { +``` + +- [ ] **Step 5: Accept persisted task terminals** + +In `packages/server/src/storage/repositories/terminal-repo.ts`, import `TerminalKind`: + +```ts +import type { Terminal, TerminalKind } from "@coder-studio/core"; +``` + +Add: + +```ts +const TERMINAL_KINDS = new Set(["agent", "shell", "task"]); +``` + +Change the `NewTerminal.kind` type to: + +```ts +kind: TerminalKind; +``` + +Change `isTerminal` kind validation to: + +```ts +TERMINAL_KINDS.has(value.kind as TerminalKind) +``` + +- [ ] **Step 6: Run focused terminal tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/terminal/manager.test.ts src/__tests__/terminal-commands.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 7: Commit task terminal support** + +Run: + +```bash +git add packages/server/src/terminal/types.ts packages/server/src/terminal/manager.ts packages/server/src/storage/repositories/terminal-repo.ts packages/server/src/terminal/manager.test.ts packages/server/src/__tests__/terminal-commands.test.ts +git commit -m "feat: support managed task terminals" +``` + +### Task 3: Implement Server Task Discovery + +**Files:** +- Create: `packages/server/src/tasks/discovery.ts` +- Create: `packages/server/src/tasks/discovery.test.ts` + +- [ ] **Step 1: Write failing discovery tests** + +Create `packages/server/src/tasks/discovery.test.ts` with: + +```ts +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { discoverTasks } from "./discovery.js"; + +describe("discoverTasks", () => { + let root: string; + + beforeEach(async () => { + root = join(tmpdir(), `coder-studio-task-discovery-${Date.now()}-${Math.random()}`); + await mkdir(root, { recursive: true }); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("uses explicit .coder-studio/tasks.json definitions first", async () => { + await mkdir(join(root, ".coder-studio"), { recursive: true }); + await writeFile( + join(root, ".coder-studio", "tasks.json"), + JSON.stringify({ + version: 1, + tasks: [ + { + id: "verify", + label: "Verify", + kind: "verify", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + }, + ], + }) + ); + await writeFile( + join(root, "package.json"), + JSON.stringify({ scripts: { test: "vitest run" } }) + ); + + const result = await discoverTasks({ workspaceId: "ws-1", rootPath: root }); + + expect(result.tasks[0]).toEqual({ + id: "verify", + workspaceId: "ws-1", + kind: "verify", + label: "Verify", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + source: "coder-studio", + priority: 1000, + }); + expect(result.tasks.map((task) => task.source)).toContain("package-json"); + }); + + it("prefers pnpm ci:verify as the default verify task", async () => { + await writeFile( + join(root, "package.json"), + JSON.stringify({ + scripts: { + "ci:verify": "pnpm changeset:validate && pnpm ci:lint && pnpm ci:test && pnpm ci:build", + test: "vitest run", + lint: "biome lint .", + build: "tsc -p tsconfig.json", + }, + }) + ); + await writeFile(join(root, "pnpm-lock.yaml"), ""); + + const result = await discoverTasks({ workspaceId: "ws-1", rootPath: root }); + + expect(result.tasks[0]).toMatchObject({ + id: "verify", + kind: "verify", + label: "Verify", + command: "pnpm", + args: ["ci:verify"], + source: "package-json", + }); + expect(result.tasks.map((task) => task.id)).toEqual(["verify", "test", "lint", "build"]); + }); + + it("discovers ecosystem convention tasks", async () => { + await writeFile(join(root, "Cargo.toml"), "[package]\nname = \"demo\"\n"); + await writeFile(join(root, "go.mod"), "module demo\n"); + await writeFile(join(root, "pyproject.toml"), "[project]\nname = \"demo\"\n"); + await writeFile(join(root, "Makefile"), "verify:\n\tpnpm ci:verify\n"); + + const result = await discoverTasks({ workspaceId: "ws-1", rootPath: root }); + + expect(result.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "cargo-test", command: "cargo", args: ["test"] }), + expect.objectContaining({ id: "go-test", command: "go", args: ["test", "./..."] }), + expect.objectContaining({ id: "python-test", command: "python", args: ["-m", "pytest"] }), + expect.objectContaining({ id: "make-verify", command: "make", args: ["verify"] }), + ]) + ); + }); + + it("returns warnings for malformed sources without failing all discovery", async () => { + await writeFile(join(root, "package.json"), "{ broken json"); + await writeFile(join(root, "Makefile"), "test:\n\tpnpm test\n"); + + const result = await discoverTasks({ workspaceId: "ws-1", rootPath: root }); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + source: "package-json", + message: expect.stringContaining("package.json"), + }), + ]); + expect(result.tasks).toEqual([ + expect.objectContaining({ + id: "make-test", + command: "make", + args: ["test"], + }), + ]); + }); +}); +``` + +- [ ] **Step 2: Run discovery tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/tasks/discovery.test.ts +``` + +Expected: FAIL because `packages/server/src/tasks/discovery.ts` does not exist. + +- [ ] **Step 3: Implement discovery API and helpers** + +Create `packages/server/src/tasks/discovery.ts` with these exported shapes and behavior: + +```ts +import type { TaskDefinition, TaskKind, TaskSource } from "@coder-studio/core"; +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; + +export interface TaskDiscoveryInput { + workspaceId: string; + rootPath: string; +} + +export interface TaskDiscoveryWarning { + source: TaskSource; + message: string; +} + +export interface TaskDiscoveryResult { + tasks: TaskDefinition[]; + warnings: TaskDiscoveryWarning[]; +} + +const coderStudioTaskSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + kind: z.enum(["verify", "test", "lint", "build", "dev", "custom"]), + command: z.string().min(1), + args: z.array(z.string()).default([]), + cwdPath: z.string().optional(), +}); + +const coderStudioTasksFileSchema = z.object({ + version: z.literal(1), + tasks: z.array(coderStudioTaskSchema), +}); + +function uniqueTasks(tasks: TaskDefinition[]): TaskDefinition[] { + const seen = new Set(); + const result: TaskDefinition[] = []; + for (const task of tasks.sort((left, right) => right.priority - left.priority)) { + const key = `${task.kind}:${task.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(task); + } + return result; +} + +function packageManagerFor(rootFiles: Set): "pnpm" | "yarn" | "bun" | "npm" { + if (rootFiles.has("pnpm-lock.yaml") || rootFiles.has("pnpm-workspace.yaml")) return "pnpm"; + if (rootFiles.has("yarn.lock")) return "yarn"; + if (rootFiles.has("bun.lockb") || rootFiles.has("bun.lock")) return "bun"; + return "npm"; +} + +function scriptTask( + workspaceId: string, + scriptName: string, + kind: TaskKind, + packageManager: "pnpm" | "yarn" | "bun" | "npm", + priority: number +): TaskDefinition { + return { + id: kind === "verify" ? "verify" : kind, + workspaceId, + kind, + label: kind[0]!.toUpperCase() + kind.slice(1), + command: packageManager, + args: [scriptName], + cwdPath: ".", + source: "package-json", + priority, + }; +} +``` + +Implement `discoverTasks(input)` so it: + +- reads `.coder-studio/tasks.json` first and maps valid entries to priority `1000 - index` +- reads root `package.json` and maps `ci:verify` to `verify` priority `900` +- maps `verify`, `test`, `lint`, `build`, and `dev` package scripts when present, with priorities `800`, `700`, `600`, `500`, and `100` +- maps `Cargo.toml` to `cargo test` +- maps `go.mod` to `go test ./...` +- maps `pyproject.toml` to `python -m pytest` +- maps `Makefile` targets named `verify`, `test`, `lint`, and `build` to `make ` +- catches per-source parse/read errors and pushes warnings instead of throwing +- returns `uniqueTasks(tasks)` sorted by descending priority + +Use IDs exactly as asserted in the tests: `verify`, `test`, `lint`, `build`, `dev`, `cargo-test`, `go-test`, `python-test`, and `make-${target}`. + +- [ ] **Step 4: Run discovery tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/tasks/discovery.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit discovery** + +Run: + +```bash +git add packages/server/src/tasks/discovery.ts packages/server/src/tasks/discovery.test.ts +git commit -m "feat: discover workspace tasks" +``` + +### Task 4: Implement Server Task Manager + +**Files:** +- Create: `packages/server/src/tasks/manager.ts` +- Create: `packages/server/src/tasks/manager.test.ts` + +- [ ] **Step 1: Write failing manager tests** + +Create `packages/server/src/tasks/manager.test.ts` with: + +```ts +import type { DomainEvent, TaskDefinition, Terminal } from "@coder-studio/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventBus } from "../bus/event-bus.js"; +import { TaskManager } from "./manager.js"; + +function createTask(overrides: Partial = {}): TaskDefinition { + return { + id: "verify", + workspaceId: "ws-1", + kind: "verify", + label: "Verify", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + source: "package-json", + priority: 900, + ...overrides, + }; +} + +describe("TaskManager", () => { + let eventBus: EventBus; + let events: DomainEvent[]; + let terminalMgr: { + create: ReturnType; + close: ReturnType; + }; + let manager: TaskManager; + + beforeEach(() => { + eventBus = new EventBus(); + events = []; + for (const type of [ + "task.discovered", + "task.run.started", + "task.run.updated", + "task.run.finished", + "task.run.stopped", + ] as const) { + eventBus.on(type, (event) => events.push(event)); + } + terminalMgr = { + create: vi.fn((spec) => ({ + id: "term-task", + workspaceId: spec.workspaceId, + kind: spec.kind, + title: spec.title, + cwd: spec.cwd, + argv: spec.argv, + cols: 120, + rows: 30, + alive: true, + createdAt: 1, + })), + close: vi.fn(async () => undefined), + }; + manager = new TaskManager({ + eventBus, + terminalMgr: terminalMgr as never, + now: () => 1000, + }); + }); + + it("stores discovered tasks and emits task.discovered", () => { + const tasks = [createTask()]; + + manager.setDiscoveredTasks("ws-1", tasks); + + expect(manager.list("ws-1")).toEqual(tasks); + expect(events).toEqual([ + { + type: "task.discovered", + workspaceId: "ws-1", + tasks, + }, + ]); + }); + + it("runs a task through a managed task terminal", async () => { + manager.setDiscoveredTasks("ws-1", [createTask()]); + + const run = await manager.run({ + workspaceId: "ws-1", + workspacePath: "/repo", + taskId: "verify", + themeBackground: "#0b1218", + }); + + expect(terminalMgr.create).toHaveBeenCalledWith({ + workspaceId: "ws-1", + kind: "task", + argv: ["pnpm", "ci:verify"], + cwd: "/repo", + title: "Task: Verify", + cols: 120, + rows: 30, + themeBackground: "#0b1218", + }); + expect(run).toMatchObject({ + workspaceId: "ws-1", + taskId: "verify", + terminalId: "term-task", + status: "running", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + startedAt: 1000, + }); + expect(events.at(-1)).toEqual({ + type: "task.run.started", + workspaceId: "ws-1", + run, + }); + }); + + it("marks a run passed when its task terminal exits with zero", async () => { + manager.setDiscoveredTasks("ws-1", [createTask()]); + const run = await manager.run({ workspaceId: "ws-1", workspacePath: "/repo", taskId: "verify" }); + + eventBus.emit({ + type: "terminal.exited", + workspaceId: "ws-1", + terminalId: run.terminalId, + exitCode: 0, + }); + + expect(manager.history("ws-1")[0]).toMatchObject({ + id: run.id, + status: "passed", + exitCode: 0, + finishedAt: 1000, + }); + expect(events.at(-1)?.type).toBe("task.run.finished"); + }); + + it("marks a run failed and records capped output tail on non-zero exit", async () => { + manager.setDiscoveredTasks("ws-1", [createTask()]); + const run = await manager.run({ workspaceId: "ws-1", workspacePath: "/repo", taskId: "verify" }); + + for (let index = 0; index < 14; index += 1) { + eventBus.emit({ + type: "terminal.output", + workspaceId: "ws-1", + terminalId: run.terminalId, + chunk: Buffer.from(`line ${index}\n`), + seq: index + 1, + }); + } + eventBus.emit({ + type: "terminal.exited", + workspaceId: "ws-1", + terminalId: run.terminalId, + exitCode: 1, + }); + + const latest = manager.history("ws-1")[0]!; + expect(latest.status).toBe("failed"); + expect(latest.summary?.tailLines).toEqual([ + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "line 9", + "line 10", + "line 11", + "line 12", + "line 13", + ]); + }); + + it("stops a running task terminal and emits task.run.stopped", async () => { + manager.setDiscoveredTasks("ws-1", [createTask()]); + const run = await manager.run({ workspaceId: "ws-1", workspacePath: "/repo", taskId: "verify" }); + + const stopped = await manager.stop({ workspaceId: "ws-1", runId: run.id }); + + expect(terminalMgr.close).toHaveBeenCalledWith(run.terminalId); + expect(stopped).toMatchObject({ + id: run.id, + status: "stopped", + finishedAt: 1000, + }); + expect(events.at(-1)?.type).toBe("task.run.stopped"); + }); +}); +``` + +- [ ] **Step 2: Run manager tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/tasks/manager.test.ts +``` + +Expected: FAIL because `packages/server/src/tasks/manager.ts` does not exist. + +- [ ] **Step 3: Implement TaskManager public API** + +Create `packages/server/src/tasks/manager.ts` with: + +```ts +import type { DomainEvent, TaskDefinition, TaskRun } from "@coder-studio/core"; +import { isAbsolute, join } from "node:path"; +import type { EventBus } from "../bus/event-bus.js"; +import { resolveSafe } from "../fs/file-io.js"; +import type { TerminalManager } from "../terminal/manager.js"; + +interface TaskManagerDeps { + eventBus: EventBus; + terminalMgr: Pick; + now?: () => number; +} + +interface RunTaskInput { + workspaceId: string; + workspacePath: string; + taskId: string; + themeBackground?: string; +} + +interface StopTaskInput { + workspaceId: string; + runId: string; +} + +const TASK_TAIL_LINE_LIMIT = 10; + +function createRunId(): string { + return `taskrun_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function normalizeOutputLines(chunk: Buffer): string[] { + return chunk + .toString("utf8") + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} + +export class TaskManager { + private readonly now: () => number; + private tasksByWorkspace = new Map(); + private runsByWorkspace = new Map(); + private runByTerminalId = new Map(); + private outputTailByRunId = new Map(); + + constructor(private readonly deps: TaskManagerDeps) { + this.now = deps.now ?? (() => Date.now()); + this.deps.eventBus.on("terminal.output", (event) => this.onTerminalOutput(event)); + this.deps.eventBus.on("terminal.exited", (event) => this.onTerminalExited(event)); + } + + setDiscoveredTasks(workspaceId: string, tasks: TaskDefinition[]): TaskDefinition[] { + this.tasksByWorkspace.set(workspaceId, tasks); + this.deps.eventBus.emit({ type: "task.discovered", workspaceId, tasks }); + return tasks; + } + + list(workspaceId: string): TaskDefinition[] { + return this.tasksByWorkspace.get(workspaceId) ?? []; + } + + history(workspaceId: string): TaskRun[] { + return this.runsByWorkspace.get(workspaceId) ?? []; + } + + latestVerify(workspaceId: string): TaskRun | undefined { + return this.history(workspaceId).find((run) => run.taskId === "verify"); + } + + async run(input: RunTaskInput): Promise { + const task = this.list(input.workspaceId).find((candidate) => candidate.id === input.taskId); + if (!task) { + throw { code: "task_not_found", message: `Task not found: ${input.taskId}` }; + } + + const cwd = this.resolveTaskCwd(input.workspacePath, task.cwdPath); + const terminal = this.deps.terminalMgr.create({ + workspaceId: input.workspaceId, + kind: "task", + argv: [task.command, ...task.args], + cwd, + title: `Task: ${task.label}`, + cols: 120, + rows: 30, + themeBackground: input.themeBackground, + }); + + const run: TaskRun = { + id: createRunId(), + workspaceId: input.workspaceId, + taskId: task.id, + terminalId: terminal.id, + status: "running", + command: task.command, + args: task.args, + cwdPath: task.cwdPath, + startedAt: this.now(), + }; + + this.storeRun(run); + this.runByTerminalId.set(terminal.id, run); + this.deps.eventBus.emit({ type: "task.run.started", workspaceId: input.workspaceId, run }); + return run; + } + + async rerun(input: RunTaskInput): Promise { + return this.run(input); + } + + async stop(input: StopTaskInput): Promise { + const run = this.history(input.workspaceId).find((candidate) => candidate.id === input.runId); + if (!run) { + throw { code: "task_run_not_found", message: `Task run not found: ${input.runId}` }; + } + if (run.status !== "running" && run.status !== "queued") { + return run; + } + + await this.deps.terminalMgr.close(run.terminalId); + const stopped = this.updateRun(run, { + status: "stopped", + finishedAt: this.now(), + summary: { tailLines: this.outputTailByRunId.get(run.id) ?? [] }, + }); + this.runByTerminalId.delete(run.terminalId); + this.deps.eventBus.emit({ + type: "task.run.stopped", + workspaceId: input.workspaceId, + run: stopped, + }); + return stopped; + } + + clearWorkspace(workspaceId: string): void { + this.tasksByWorkspace.delete(workspaceId); + const runs = this.runsByWorkspace.get(workspaceId) ?? []; + for (const run of runs) { + this.runByTerminalId.delete(run.terminalId); + this.outputTailByRunId.delete(run.id); + } + this.runsByWorkspace.delete(workspaceId); + } + + private resolveTaskCwd(workspacePath: string, cwdPath: string | undefined): string { + if (!cwdPath || cwdPath === ".") { + return workspacePath; + } + if (isAbsolute(cwdPath)) { + throw { code: "invalid_task_cwd", message: "Task cwdPath must be workspace-relative" }; + } + return resolveSafe(workspacePath, cwdPath); + } + + private storeRun(run: TaskRun): void { + const current = this.runsByWorkspace.get(run.workspaceId) ?? []; + const withoutSameTask = current.filter((candidate) => candidate.taskId !== run.taskId); + this.runsByWorkspace.set(run.workspaceId, [run, ...withoutSameTask]); + } + + private updateRun(run: TaskRun, patch: Partial): TaskRun { + const next = { ...run, ...patch }; + this.storeRun(next); + this.runByTerminalId.set(next.terminalId, next); + return next; + } + + private onTerminalOutput(event: Extract): void { + const run = this.runByTerminalId.get(event.terminalId); + if (!run) { + return; + } + const previous = this.outputTailByRunId.get(run.id) ?? []; + const next = [...previous, ...normalizeOutputLines(event.chunk)].slice(-TASK_TAIL_LINE_LIMIT); + this.outputTailByRunId.set(run.id, next); + } + + private onTerminalExited(event: Extract): void { + const run = this.runByTerminalId.get(event.terminalId); + if (!run || run.status === "stopped") { + return; + } + + const finished = this.updateRun(run, { + status: event.exitCode === 0 ? "passed" : "failed", + finishedAt: this.now(), + exitCode: event.exitCode, + summary: { tailLines: this.outputTailByRunId.get(run.id) ?? [] }, + }); + this.runByTerminalId.delete(event.terminalId); + this.deps.eventBus.emit({ + type: "task.run.finished", + workspaceId: event.workspaceId, + run: finished, + }); + } +} +``` + +- [ ] **Step 4: Run manager tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/tasks/manager.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit manager** + +Run: + +```bash +git add packages/server/src/tasks/manager.ts packages/server/src/tasks/manager.test.ts +git commit -m "feat: manage task runs" +``` + +### Task 5: Add Task Commands and WebSocket Events + +**Files:** +- Create: `packages/server/src/commands/task.ts` +- Create: `packages/server/src/__tests__/task-commands.test.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/server/src/ws/dispatch.ts` +- Modify: `packages/server/src/ws/hub.ts` +- Modify: `packages/server/src/server.ts` + +- [ ] **Step 1: Write failing command tests** + +Create `packages/server/src/__tests__/task-commands.test.ts` with: + +```ts +import type { TaskDefinition, TaskRun } from "@coder-studio/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "../commands/index.js"; +import { dispatch, type CommandContext } from "../ws/dispatch.js"; + +function createContext(): CommandContext { + const workspace = { id: "ws-1", path: "/repo" }; + const task: TaskDefinition = { + id: "verify", + workspaceId: "ws-1", + kind: "verify", + label: "Verify", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + source: "package-json", + priority: 900, + }; + const run: TaskRun = { + id: "run-1", + workspaceId: "ws-1", + taskId: "verify", + terminalId: "term-task", + status: "running", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + startedAt: 100, + }; + + return { + workspaceMgr: { + get: vi.fn(() => workspace), + }, + taskMgr: { + setDiscoveredTasks: vi.fn((_workspaceId, tasks) => tasks), + list: vi.fn(() => [task]), + history: vi.fn(() => [run]), + run: vi.fn(async () => run), + rerun: vi.fn(async () => run), + stop: vi.fn(async () => ({ ...run, status: "stopped", finishedAt: 200 })), + }, + terminalMgr: {}, + sessionMgr: {}, + eventBus: {}, + broadcaster: {}, + settingsRepo: {}, + providerConfigRepo: {}, + providerRegistry: [], + fencingMgr: {}, + supervisorMgr: {}, + autoFetch: {}, + activationMgr: { getLease: vi.fn(() => ({ wsClientId: "client-1" })) }, + lspMgr: {}, + } as unknown as CommandContext; +} + +describe("task commands", () => { + let ctx: CommandContext; + + beforeEach(() => { + ctx = createContext(); + }); + + it("lists task definitions", async () => { + const result = await dispatch( + { kind: "command", id: "cmd-1", op: "task.list", args: { workspaceId: "ws-1" } }, + ctx, + "client-1" + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual(ctx.taskMgr.list("ws-1")); + }); + + it("runs a task for the workspace root", async () => { + const result = await dispatch( + { + kind: "command", + id: "cmd-1", + op: "task.run", + args: { workspaceId: "ws-1", taskId: "verify", themeBackground: "#0b1218" }, + }, + ctx, + "client-1" + ); + + expect(result.ok).toBe(true); + expect(ctx.taskMgr.run).toHaveBeenCalledWith({ + workspaceId: "ws-1", + workspacePath: "/repo", + taskId: "verify", + themeBackground: "#0b1218", + }); + }); + + it("stops a running task", async () => { + const result = await dispatch( + { kind: "command", id: "cmd-1", op: "task.stop", args: { workspaceId: "ws-1", runId: "run-1" } }, + ctx, + "client-1" + ); + + expect(result.ok).toBe(true); + expect(ctx.taskMgr.stop).toHaveBeenCalledWith({ workspaceId: "ws-1", runId: "run-1" }); + }); + + it("returns workspace_not_found for missing workspaces", async () => { + vi.mocked(ctx.workspaceMgr.get).mockReturnValueOnce(undefined); + + const result = await dispatch( + { kind: "command", id: "cmd-1", op: "task.run", args: { workspaceId: "missing", taskId: "verify" } }, + ctx, + "client-1" + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("workspace_not_found"); + }); +}); +``` + +- [ ] **Step 2: Run command tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/task-commands.test.ts +``` + +Expected: FAIL because task commands are not registered and `CommandContext` lacks `taskMgr`. + +- [ ] **Step 3: Register task commands** + +Create `packages/server/src/commands/task.ts`: + +```ts +import { z } from "zod"; +import { discoverTasks } from "../tasks/discovery.js"; +import { registerCommand } from "../ws/dispatch.js"; + +const workspaceSchema = z.object({ + workspaceId: z.string(), +}); + +const taskRunSchema = z.object({ + workspaceId: z.string(), + taskId: z.string(), + themeBackground: z + .string() + .regex(/^#[0-9a-fA-F]{3,8}$/) + .optional(), +}); + +function getWorkspaceOrThrow(ctx: Parameters[2]>[1], workspaceId: string) { + const workspace = ctx.workspaceMgr.get(workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${workspaceId}` }; + } + return workspace; +} + +registerCommand("task.discover", workspaceSchema, async (args, ctx) => { + const workspace = getWorkspaceOrThrow(ctx, args.workspaceId); + const result = await discoverTasks({ workspaceId: args.workspaceId, rootPath: workspace.path }); + const tasks = ctx.taskMgr.setDiscoveredTasks(args.workspaceId, result.tasks); + return { tasks, warnings: result.warnings }; +}); + +registerCommand("task.list", workspaceSchema, async (args, ctx) => { + getWorkspaceOrThrow(ctx, args.workspaceId); + const existing = ctx.taskMgr.list(args.workspaceId); + if (existing.length > 0) { + return existing; + } + const workspace = getWorkspaceOrThrow(ctx, args.workspaceId); + const result = await discoverTasks({ workspaceId: args.workspaceId, rootPath: workspace.path }); + return ctx.taskMgr.setDiscoveredTasks(args.workspaceId, result.tasks); +}); + +registerCommand("task.run", taskRunSchema, async (args, ctx) => { + const workspace = getWorkspaceOrThrow(ctx, args.workspaceId); + if (ctx.taskMgr.list(args.workspaceId).length === 0) { + const result = await discoverTasks({ workspaceId: args.workspaceId, rootPath: workspace.path }); + ctx.taskMgr.setDiscoveredTasks(args.workspaceId, result.tasks); + } + return ctx.taskMgr.run({ + workspaceId: args.workspaceId, + workspacePath: workspace.path, + taskId: args.taskId, + themeBackground: args.themeBackground, + }); +}); + +registerCommand("task.rerun", taskRunSchema, async (args, ctx) => { + const workspace = getWorkspaceOrThrow(ctx, args.workspaceId); + return ctx.taskMgr.rerun({ + workspaceId: args.workspaceId, + workspacePath: workspace.path, + taskId: args.taskId, + themeBackground: args.themeBackground, + }); +}); + +registerCommand( + "task.stop", + z.object({ + workspaceId: z.string(), + runId: z.string(), + }), + async (args, ctx) => { + getWorkspaceOrThrow(ctx, args.workspaceId); + return ctx.taskMgr.stop({ workspaceId: args.workspaceId, runId: args.runId }); + } +); + +registerCommand("task.history", workspaceSchema, async (args, ctx) => { + getWorkspaceOrThrow(ctx, args.workspaceId); + return ctx.taskMgr.history(args.workspaceId); +}); +``` + +Use an explicit `CommandContext` import if TypeScript rejects the helper type: + +```ts +import type { CommandContext } from "../ws/dispatch.js"; + +function getWorkspaceOrThrow(ctx: CommandContext, workspaceId: string) { + ... +} +``` + +- [ ] **Step 4: Wire command registration and context** + +Add this import to `packages/server/src/commands/index.ts`: + +```ts +import "./task.js"; +``` + +Add this import to `packages/server/src/ws/dispatch.ts`: + +```ts +import type { TaskManager } from "../tasks/manager.js"; +``` + +Add this field to `CommandContext`: + +```ts +taskMgr: TaskManager; +``` + +Add this import to `packages/server/src/server.ts`: + +```ts +import { TaskManager } from "./tasks/manager.js"; +``` + +Construct the manager after `terminalMgr`: + +```ts +const taskMgr = new TaskManager({ + eventBus, + terminalMgr, +}); +``` + +In workspace teardown, before closing terminals, add: + +```ts +taskMgr.clearWorkspace(workspaceId); +``` + +Add `taskMgr` to `commandContext`. + +- [ ] **Step 5: Broadcast task events** + +In `packages/server/src/ws/hub.ts`, add task event types to `eventTypes`: + +```ts +"task.discovered", +"task.run.started", +"task.run.updated", +"task.run.finished", +"task.run.stopped", +``` + +Add switch cases: + +```ts +case "task.discovered": + topic = Topics.workspaceTaskDiscovered(event.workspaceId); + data = { tasks: event.tasks }; + break; + +case "task.run.started": +case "task.run.updated": +case "task.run.finished": +case "task.run.stopped": + topic = Topics.workspaceTaskRun(event.workspaceId, event.run.id); + data = { event: event.type, run: event.run }; + break; +``` + +- [ ] **Step 6: Run command and server type tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/task-commands.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 7: Commit task commands** + +Run: + +```bash +git add packages/server/src/commands/task.ts packages/server/src/commands/index.ts packages/server/src/ws/dispatch.ts packages/server/src/ws/hub.ts packages/server/src/server.ts packages/server/src/__tests__/task-commands.test.ts +git commit -m "feat: expose task commands" +``` + +### Task 6: Add Web Bottom Panel Tabs and Task Terminal Visibility + +**Files:** +- Create: `packages/web/src/features/bottom-panel/atoms.ts` +- Create: `packages/web/src/features/bottom-panel/index.ts` +- Create: `packages/web/src/features/workspace/views/shared/workspace-bottom-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/terminal-panel/atoms/terminals.ts` +- Modify: `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts` +- Modify: `packages/web/src/features/terminal-panel/components/title-format.ts` +- Modify: `packages/web/src/features/terminal-panel/views/shared/terminal-selector-item.tsx` +- Modify: `packages/web/src/features/terminal-panel/views/shared/terminal-tab.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx` + +- [ ] **Step 1: Write failing bottom-panel tests** + +In `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx`, add a mock for the bottom panel if the file currently mocks `TerminalPanel` directly: + +```ts +vi.mock("../shared/workspace-bottom-panel", () => ({ + WorkspaceBottomPanel: () =>
Terminal Tasks
, +})); +``` + +Add this test: + +```ts +it("renders the shared workspace bottom panel on desktop", () => { + renderWorkspaceDesktopView(); + + expect(screen.getByTestId("workspace-bottom-panel")).toHaveTextContent("Terminal Tasks"); +}); +``` + +In `packages/web/src/features/terminal-panel/__tests__/terminal-panel.test.tsx`, add a terminal list response that includes a shell, a task, and an agent, then assert the shell and task appear while the agent does not: + +```ts +it("shows shell and task terminals but excludes agent terminals", async () => { + sendCommand.mockImplementation(async (op) => { + if (op === "terminal.list") { + return { + ok: true, + data: [ + { id: "term-shell", workspaceId: "ws-test", kind: "shell", title: "bash", alive: true }, + { id: "term-task", workspaceId: "ws-test", kind: "task", title: "Task: Verify", alive: true }, + { id: "term-agent", workspaceId: "ws-test", kind: "agent", title: "Codex", alive: true }, + ], + }; + } + return { ok: true, data: {} }; + }); + + renderWithStore(); + + expect(await screen.findByText(/bash/i)).toBeInTheDocument(); + expect(screen.getByText("Task: Verify")).toBeInTheDocument(); + expect(screen.queryByText("Codex")).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run focused web tests and confirm failures** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/terminal-panel/__tests__/terminal-panel.test.tsx +``` + +Expected: FAIL because `WorkspaceBottomPanel` does not exist and terminal metadata excludes `task`. + +- [ ] **Step 3: Add bottom-panel active tab state** + +Create `packages/web/src/features/bottom-panel/atoms.ts`: + +```ts +import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; + +export type BottomPanelTab = "terminal" | "tasks"; + +export const bottomPanelActiveTabAtomFamily = atomFamily((_workspaceId: string) => + atom("terminal") +); +``` + +Create `packages/web/src/features/bottom-panel/index.ts`: + +```ts +export * from "./atoms"; +``` + +- [ ] **Step 4: Add WorkspaceBottomPanel** + +Create `packages/web/src/features/workspace/views/shared/workspace-bottom-panel.tsx`: + +```tsx +import { useAtom } from "jotai"; +import { Tab, TabList, Tabs } from "../../../../components/ui"; +import { bottomPanelActiveTabAtomFamily } from "../../../bottom-panel"; +import { TasksPanel } from "../../../tasks/views/shared/tasks-panel"; +import { TerminalPanel } from "../../../terminal-panel"; +import { useTranslation } from "../../../../lib/i18n"; + +interface WorkspaceBottomPanelProps { + workspaceId: string; +} + +export function WorkspaceBottomPanel({ workspaceId }: WorkspaceBottomPanelProps) { + const t = useTranslation(); + const [activeTab, setActiveTab] = useAtom(bottomPanelActiveTabAtomFamily(workspaceId)); + + return ( +
+ setActiveTab(value as "terminal" | "tasks")} + > + + {t("bottom_panel.terminal")} + {t("bottom_panel.tasks")} + + +
+ {activeTab === "terminal" ? : } +
+
+ ); +} +``` + +If the UI `Tabs` package does not export `Tab`, use the existing primitive names from `packages/web/src/components/ui/tabs.tsx` and keep the rendered labels and value behavior identical. + +- [ ] **Step 5: Mount WorkspaceBottomPanel on desktop** + +In `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`, replace: + +```tsx +import { TerminalPanel } from "../../../terminal-panel"; +``` + +with: + +```tsx +import { WorkspaceBottomPanel } from "../shared/workspace-bottom-panel"; +``` + +Replace: + +```tsx + +``` + +with: + +```tsx + +``` + +- [ ] **Step 6: Include task terminals in terminal panel state** + +In `packages/web/src/features/terminal-panel/atoms/terminals.ts`, import `TerminalKind`: + +```ts +import type { TerminalKind } from "@coder-studio/core"; +``` + +Change `TerminalMeta.kind` to: + +```ts +kind: TerminalKind; +``` + +In `packages/web/src/features/terminal-panel/actions/use-terminal-actions.ts`, change: + +```ts +const shellTerminals = result.data.filter((terminal) => terminal.kind === "shell"); +const shellIds = shellTerminals.map((terminal) => terminal.id); + +for (const terminal of shellTerminals) { + store.set(terminalMetaAtomFamily(terminal.id), toTerminalMeta(terminal)); +} + +setTerminalIds((current) => mergeTerminalIds(current, shellIds)); +setActiveTerminalId((current) => current ?? shellIds[0] ?? null); +``` + +to: + +```ts +const panelTerminals = result.data.filter( + (terminal) => terminal.kind === "shell" || terminal.kind === "task" +); +const panelIds = panelTerminals.map((terminal) => terminal.id); + +for (const terminal of panelTerminals) { + store.set(terminalMetaAtomFamily(terminal.id), toTerminalMeta(terminal)); +} + +setTerminalIds((current) => mergeTerminalIds(current, panelIds)); +setActiveTerminalId((current) => current ?? panelIds[0] ?? null); +``` + +In the created-event branch, change: + +```ts +const createData = payload as { id: string; kind: "shell" | "agent" }; +if (createData.kind !== "shell") { + return; +} +``` + +to: + +```ts +const createData = payload as { id: string; kind: "shell" | "agent" | "task"; title?: string; workspaceId?: string }; +if (createData.kind !== "shell" && createData.kind !== "task") { + return; +} +store.set(terminalMetaAtomFamily(createData.id), { + id: createData.id, + workspaceId: createData.workspaceId ?? activeWorkspaceId, + kind: createData.kind, + alive: true, + title: createData.title, +}); +``` + +Keep the existing output subscription guard aligned: + +```ts +if (!meta || (meta.kind !== "shell" && meta.kind !== "task")) { + return; +} +``` + +- [ ] **Step 7: Preserve task terminal labels and add managed markers** + +In `packages/web/src/features/terminal-panel/components/title-format.ts`, short-circuit task labels: + +```ts +if (meta?.kind === "task" && rawTitle) { + return rawTitle; +} +``` + +Add a managed marker in `terminal-selector-item.tsx` and `terminal-tab.tsx` by reading `terminalMeta.kind === "task"` and rendering: + +```tsx +{terminalMeta?.kind === "task" ? ( + {t("terminal.managed_task")} +) : null} +``` + +Add styles for `.terminal-managed-badge` in the existing terminal CSS file used by these components. Keep it compact: uppercase, 10px font, muted border, no layout shift. + +- [ ] **Step 8: Add translations** + +Add these keys to `packages/web/src/locales/en.json`: + +```json +{ + "bottom_panel": { + "tabs_label": "Bottom panel", + "terminal": "Terminal", + "tasks": "Tasks" + }, + "terminal": { + "managed_task": "Managed" + } +} +``` + +Merge these keys into the existing objects rather than replacing existing `terminal` content. + +Add these keys to `packages/web/src/locales/zh.json`: + +```json +{ + "bottom_panel": { + "tabs_label": "底部面板", + "terminal": "终端", + "tasks": "任务" + }, + "terminal": { + "managed_task": "托管" + } +} +``` + +- [ ] **Step 9: Run focused web tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/terminal-panel/__tests__/terminal-panel.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 10: Commit bottom-panel terminal support** + +Run: + +```bash +git add packages/web/src/features/bottom-panel packages/web/src/features/workspace/views/shared/workspace-bottom-panel.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.test.tsx packages/web/src/features/terminal-panel packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: add bottom panel task tab shell" +``` + +### Task 7: Add Tasks Panel and Task Actions + +**Files:** +- Create: `packages/web/src/features/tasks/atoms.ts` +- Create: `packages/web/src/features/tasks/actions/use-task-actions.ts` +- Create: `packages/web/src/features/tasks/views/shared/tasks-panel.tsx` +- Create: `packages/web/src/features/tasks/__tests__/tasks-panel.test.tsx` +- Create: `packages/web/src/features/tasks/index.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write failing TasksPanel tests** + +Create `packages/web/src/features/tasks/__tests__/tasks-panel.test.tsx` with: + +```tsx +import type { TaskDefinition, TaskRun } from "@coder-studio/core"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createStore, Provider } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatchCommandAtom, wsClientAtom } from "../../../atoms/connection"; +import { bottomPanelActiveTabAtomFamily } from "../../bottom-panel"; +import { terminalActiveIdAtomFamily, terminalIdsAtomFamily, terminalMetaAtomFamily } from "../../terminal-panel/atoms"; +import { TasksPanel } from "../views/shared/tasks-panel"; + +const verifyTask: TaskDefinition = { + id: "verify", + workspaceId: "ws-test", + kind: "verify", + label: "Verify", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + source: "package-json", + priority: 900, +}; + +const runningRun: TaskRun = { + id: "run-1", + workspaceId: "ws-test", + taskId: "verify", + terminalId: "term-task", + status: "running", + command: "pnpm", + args: ["ci:verify"], + cwdPath: ".", + startedAt: 100, +}; + +function renderPanel(options: { dispatch?: ReturnType; wsClient?: unknown } = {}) { + const store = createStore(); + const dispatch = options.dispatch ?? vi.fn(async (op: string) => { + if (op === "task.list") return { ok: true, data: [verifyTask] }; + if (op === "task.history") return { ok: true, data: [] }; + if (op === "task.run") return { ok: true, data: runningRun }; + return { ok: true, data: {} }; + }); + store.set(dispatchCommandAtom, dispatch as never); + store.set(wsClientAtom, (options.wsClient ?? { subscribe: vi.fn(() => vi.fn()) }) as never); + + render( + + + + ); + + return { store, dispatch }; +} + +describe("TasksPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders discovered tasks with command previews", async () => { + renderPanel(); + + expect(await screen.findByText("Verify")).toBeInTheDocument(); + expect(screen.getByText("pnpm ci:verify")).toBeInTheDocument(); + expect(screen.getByText("Not run")).toBeInTheDocument(); + }); + + it("runs a task and switches output to the terminal tab", async () => { + const user = userEvent.setup(); + const { store, dispatch } = renderPanel(); + + await user.click(await screen.findByRole("button", { name: /run verify/i })); + + expect(dispatch).toHaveBeenCalledWith("task.run", expect.objectContaining({ + workspaceId: "ws-test", + taskId: "verify", + })); + await waitFor(() => { + expect(store.get(bottomPanelActiveTabAtomFamily("ws-test"))).toBe("terminal"); + }); + expect(store.get(terminalActiveIdAtomFamily("ws-test"))).toBe("term-task"); + expect(store.get(terminalIdsAtomFamily("ws-test"))).toContain("term-task"); + expect(store.get(terminalMetaAtomFamily("term-task"))).toMatchObject({ + kind: "task", + title: "Task: Verify", + }); + }); + + it("stops a running task", async () => { + const user = userEvent.setup(); + const dispatch = vi.fn(async (op: string) => { + if (op === "task.list") return { ok: true, data: [verifyTask] }; + if (op === "task.history") return { ok: true, data: [runningRun] }; + if (op === "task.stop") return { ok: true, data: { ...runningRun, status: "stopped", finishedAt: 200 } }; + return { ok: true, data: {} }; + }); + renderPanel({ dispatch }); + + await user.click(await screen.findByRole("button", { name: /stop verify/i })); + + expect(dispatch).toHaveBeenCalledWith("task.stop", { + workspaceId: "ws-test", + runId: "run-1", + }); + }); +}); +``` + +- [ ] **Step 2: Run TasksPanel tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/tasks/__tests__/tasks-panel.test.tsx +``` + +Expected: FAIL because the tasks feature files do not exist. + +- [ ] **Step 3: Add task atoms** + +Create `packages/web/src/features/tasks/atoms.ts`: + +```ts +import type { TaskDefinition, TaskRun } from "@coder-studio/core"; +import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; + +export interface TaskState { + tasks: TaskDefinition[]; + runs: TaskRun[]; + loading: boolean; + error?: string; +} + +export const taskStateAtomFamily = atomFamily((_workspaceId: string) => + atom({ + tasks: [], + runs: [], + loading: false, + }) +); + +export const latestVerifyRunAtomFamily = atomFamily((workspaceId: string) => + atom((get) => get(taskStateAtomFamily(workspaceId)).runs.find((run) => run.taskId === "verify")) +); +``` + +Create `packages/web/src/features/tasks/index.ts`: + +```ts +export * from "./atoms"; +export { TasksPanel } from "./views/shared/tasks-panel"; +``` + +- [ ] **Step 4: Add task actions** + +Create `packages/web/src/features/tasks/actions/use-task-actions.ts` with: + +```ts +import type { TaskDefinition, TaskRun, Terminal } from "@coder-studio/core"; +import { Topics } from "@coder-studio/core"; +import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; +import { useCallback, useEffect } from "react"; +import { dispatchCommandAtom, wsClientAtom } from "../../../atoms/connection"; +import { useTranslation } from "../../../lib/i18n"; +import { useTerminalThemeBackground } from "../../../theme"; +import { bottomPanelActiveTabAtomFamily } from "../../bottom-panel"; +import { pushToastAtom } from "../../notifications/atoms"; +import { + terminalActiveIdAtomFamily, + terminalIdsAtomFamily, + terminalMetaAtomFamily, +} from "../../terminal-panel/atoms"; +import { taskStateAtomFamily } from "../atoms"; + +function commandPreview(task: TaskDefinition): string { + return [task.command, ...task.args].join(" "); +} + +function taskTerminalTitle(task: TaskDefinition): string { + return `Task: ${task.label}`; +} + +export function useTaskActions(workspaceId: string) { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const wsClient = useAtomValue(wsClientAtom); + const pushToast = useSetAtom(pushToastAtom); + const store = useStore(); + const themeBackground = useTerminalThemeBackground(); + const [state, setState] = useAtom(taskStateAtomFamily(workspaceId)); + + const load = useCallback(async () => { + setState((previous) => ({ ...previous, loading: true, error: undefined })); + const [tasksResult, historyResult] = await Promise.all([ + dispatch("task.list", { workspaceId }), + dispatch("task.history", { workspaceId }), + ]); + + if (!tasksResult.ok || !tasksResult.data) { + const message = tasksResult.error?.message ?? t("tasks.load_failed_body"); + setState((previous) => ({ ...previous, loading: false, error: message })); + pushToast({ kind: "error", title: t("tasks.load_failed_title"), body: message }); + return; + } + + setState({ + tasks: tasksResult.data, + runs: historyResult.ok && historyResult.data ? historyResult.data : [], + loading: false, + }); + }, [dispatch, pushToast, setState, t, workspaceId]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!wsClient) { + return; + } + return wsClient.subscribe([Topics.workspaceTasksAll(workspaceId)], (_topic, payload) => { + const data = payload as { tasks?: TaskDefinition[]; run?: TaskRun }; + if (data.tasks) { + setState((previous) => ({ ...previous, tasks: data.tasks })); + } + if (data.run) { + setState((previous) => ({ + ...previous, + runs: [data.run!, ...previous.runs.filter((run) => run.id !== data.run!.id)], + })); + } + }); + }, [setState, workspaceId, wsClient]); + + const activateRunTerminal = useCallback( + (task: TaskDefinition, run: TaskRun) => { + store.set(terminalMetaAtomFamily(run.terminalId), { + id: run.terminalId, + workspaceId, + kind: "task", + alive: run.status === "running" || run.status === "queued", + exitCode: run.exitCode, + title: taskTerminalTitle(task), + }); + store.set(terminalIdsAtomFamily(workspaceId), (current) => + current.includes(run.terminalId) ? current : [...current, run.terminalId] + ); + store.set(terminalActiveIdAtomFamily(workspaceId), run.terminalId); + store.set(bottomPanelActiveTabAtomFamily(workspaceId), "terminal"); + }, + [store, workspaceId] + ); + + const runTask = useCallback( + async (task: TaskDefinition) => { + const result = await dispatch("task.run", { + workspaceId, + taskId: task.id, + themeBackground, + }); + if (!result.ok || !result.data) { + pushToast({ + kind: "error", + title: t("tasks.run_failed_title"), + body: result.error?.message ?? t("tasks.run_failed_body"), + }); + return null; + } + activateRunTerminal(task, result.data); + setState((previous) => ({ + ...previous, + runs: [result.data!, ...previous.runs.filter((run) => run.taskId !== task.id)], + })); + return result.data; + }, + [activateRunTerminal, dispatch, pushToast, setState, t, themeBackground, workspaceId] + ); + + const rerunTask = useCallback( + async (task: TaskDefinition) => { + const result = await dispatch("task.rerun", { + workspaceId, + taskId: task.id, + themeBackground, + }); + if (!result.ok || !result.data) { + pushToast({ + kind: "error", + title: t("tasks.run_failed_title"), + body: result.error?.message ?? t("tasks.run_failed_body"), + }); + return null; + } + activateRunTerminal(task, result.data); + return result.data; + }, + [activateRunTerminal, dispatch, pushToast, t, themeBackground, workspaceId] + ); + + const stopTask = useCallback( + async (run: TaskRun) => { + const result = await dispatch("task.stop", { workspaceId, runId: run.id }); + if (!result.ok || !result.data) { + pushToast({ + kind: "error", + title: t("tasks.stop_failed_title"), + body: result.error?.message ?? t("tasks.stop_failed_body"), + }); + return null; + } + setState((previous) => ({ + ...previous, + runs: [result.data!, ...previous.runs.filter((candidate) => candidate.id !== run.id)], + })); + return result.data; + }, + [dispatch, pushToast, setState, t, workspaceId] + ); + + return { + ...state, + commandPreview, + load, + runTask, + rerunTask, + stopTask, + }; +} +``` + +- [ ] **Step 5: Add TasksPanel UI** + +Create `packages/web/src/features/tasks/views/shared/tasks-panel.tsx`: + +```tsx +import type { TaskDefinition, TaskRun } from "@coder-studio/core"; +import { Button, EmptyState, ThemedIcon } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useTaskActions } from "../../actions/use-task-actions"; + +interface TasksPanelProps { + workspaceId: string; +} + +function formatDuration(run: TaskRun | undefined): string { + if (!run?.finishedAt) { + return ""; + } + const seconds = Math.max(1, Math.round((run.finishedAt - run.startedAt) / 1000)); + return `${seconds}s`; +} + +function statusLabel(t: ReturnType, run: TaskRun | undefined): string { + if (!run) return t("tasks.status_not_run"); + if (run.status === "running") return t("tasks.status_running"); + if (run.status === "queued") return t("tasks.status_queued"); + if (run.status === "passed") return t("tasks.status_passed"); + if (run.status === "failed") return t("tasks.status_failed"); + return t("tasks.status_stopped"); +} + +function latestRunFor(task: TaskDefinition, runs: TaskRun[]): TaskRun | undefined { + return runs.find((run) => run.taskId === task.id); +} + +export function TasksPanel({ workspaceId }: TasksPanelProps) { + const t = useTranslation(); + const { tasks, runs, loading, error, commandPreview, runTask, rerunTask, stopTask } = + useTaskActions(workspaceId); + + return ( +
+
+
+ {t("tasks.kicker")} +

{t("tasks.title")}

+
+
+ {tasks + .filter((task) => task.kind === "verify" || task.kind === "test" || task.kind === "lint") + .slice(0, 3) + .map((task) => ( + + ))} +
+
+ + {error ?
{error}
: null} + + {tasks.length === 0 && !loading ? ( + } + title={

{t("tasks.empty_title")}

} + description={

{t("tasks.empty_body")}

} + /> + ) : ( +
+ {tasks.map((task) => { + const run = latestRunFor(task, runs); + const running = run?.status === "running" || run?.status === "queued"; + return ( +
+
+ {task.label} + {commandPreview(task)} +
+ {statusLabel(t, run)} + {formatDuration(run)} +
+ {running && run ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ )} +
+ ); +} +``` + +- [ ] **Step 6: Add translations** + +Add these keys under the root object in `packages/web/src/locales/en.json`: + +```json +"tasks": { + "kicker": "Managed commands", + "title": "Tasks", + "empty_title": "No tasks found", + "empty_body": "Add .coder-studio/tasks.json or package scripts to run verification here.", + "load_failed_title": "Failed to load tasks", + "load_failed_body": "Task discovery failed.", + "run_failed_title": "Failed to run task", + "run_failed_body": "The task could not be started.", + "stop_failed_title": "Failed to stop task", + "stop_failed_body": "The task could not be stopped.", + "run_label": "Run {label}", + "rerun_label": "Rerun {label}", + "stop_label": "Stop {label}", + "status_not_run": "Not run", + "status_queued": "Queued", + "status_running": "Running", + "status_passed": "Passed", + "status_failed": "Failed", + "status_stopped": "Stopped" +} +``` + +Add these keys under the root object in `packages/web/src/locales/zh.json`: + +```json +"tasks": { + "kicker": "托管命令", + "title": "任务", + "empty_title": "未发现任务", + "empty_body": "添加 .coder-studio/tasks.json 或 package scripts 后可在这里运行验证。", + "load_failed_title": "任务加载失败", + "load_failed_body": "任务发现失败。", + "run_failed_title": "任务运行失败", + "run_failed_body": "无法启动该任务。", + "stop_failed_title": "任务停止失败", + "stop_failed_body": "无法停止该任务。", + "run_label": "运行 {label}", + "rerun_label": "重新运行 {label}", + "stop_label": "停止 {label}", + "status_not_run": "未运行", + "status_queued": "排队中", + "status_running": "运行中", + "status_passed": "通过", + "status_failed": "失败", + "status_stopped": "已停止" +} +``` + +- [ ] **Step 7: Run TasksPanel tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/tasks/__tests__/tasks-panel.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 8: Commit TasksPanel** + +Run: + +```bash +git add packages/web/src/features/tasks packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: add managed tasks panel" +``` + +### Task 8: Surface Latest Verification in Agent and Git Contexts + +**Files:** +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write failing context tests** + +In `packages/web/src/features/agent-panes/components/session-card.test.tsx`, seed `taskStateAtomFamily("ws-test")` with a failed verify run and assert: + +```ts +expect(screen.getByText("Last verify: Failed")).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "View output" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Rerun Verify" })).toBeInTheDocument(); +``` + +In `packages/web/src/features/workspace/views/shared/git-panel.test.tsx`, seed the same task state and assert: + +```ts +expect(screen.getByText("Verification: Failed")).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "View Tasks" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Rerun Verify" })).toBeInTheDocument(); +``` + +- [ ] **Step 2: Run focused tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/components/session-card.test.tsx src/features/workspace/views/shared/git-panel.test.tsx +``` + +Expected: FAIL because no verification context UI exists. + +- [ ] **Step 3: Add compact agent verify status** + +In `session-card.tsx`, read: + +```ts +const latestVerifyRun = useAtomValue(latestVerifyRunAtomFamily(session.workspaceId)); +const setBottomPanelTab = useSetAtom(bottomPanelActiveTabAtomFamily(session.workspaceId)); +const setActiveTerminalId = useSetAtom(terminalActiveIdAtomFamily(session.workspaceId)); +``` + +Render a compact block near the session status area: + +```tsx +{latestVerifyRun ? ( +
+ + {t("tasks.last_verify", { + status: t(`tasks.status_${latestVerifyRun.status}`), + })} + + + {[latestVerifyRun.command, ...latestVerifyRun.args].join(" ")} + {latestVerifyRun.exitCode !== undefined ? ` · exit ${latestVerifyRun.exitCode}` : ""} + + + +
+) : null} +``` + +Use the existing button component if `session-card.tsx` already uses shared UI buttons. + +- [ ] **Step 4: Add compact Git verify banner** + +In `git-panel.tsx`, read `latestVerifyRunAtomFamily(workspaceId)` and `bottomPanelActiveTabAtomFamily(workspaceId)`. + +Render near the top of the Git panel body: + +```tsx +{latestVerifyRun ? ( +
+ + {t("tasks.verification_status", { + status: t(`tasks.status_${latestVerifyRun.status}`), + })} + + + +
+) : null} +``` + +The `rerunVerify` helper must find the discovered task with `kind === "verify"` from `taskStateAtomFamily(workspaceId)` and call `task.rerun` with that task id. If no verify task exists, show an info toast using `tasks.no_verify_task`. + +- [ ] **Step 5: Add translations** + +Add these keys to the existing `tasks` object in `en.json`: + +```json +"last_verify": "Last verify: {status}", +"verification_status": "Verification: {status}", +"view_output": "View output", +"view_tasks": "View Tasks", +"rerun_verify": "Rerun Verify", +"no_verify_task": "No Verify task is available for this workspace." +``` + +Add these keys to the existing `tasks` object in `zh.json`: + +```json +"last_verify": "最近验证:{status}", +"verification_status": "验证:{status}", +"view_output": "查看输出", +"view_tasks": "查看任务", +"rerun_verify": "重新验证", +"no_verify_task": "当前工作区没有可用的 Verify 任务。" +``` + +- [ ] **Step 6: Run focused context tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/agent-panes/components/session-card.test.tsx src/features/workspace/views/shared/git-panel.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 7: Commit context integration** + +Run: + +```bash +git add packages/web/src/features/agent-panes packages/web/src/features/workspace/views/shared/git-panel.tsx packages/web/src/features/workspace/views/shared/git-panel.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: show latest verification context" +``` + +### Task 9: Verify Milestone A End-to-End + +**Files:** +- No new files. + +- [ ] **Step 1: Run focused Milestone A validation** + +Run: + +```bash +pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit +pnpm --filter @coder-studio/server exec vitest run src/tasks/discovery.test.ts src/tasks/manager.test.ts src/__tests__/task-commands.test.ts src/__tests__/terminal-commands.test.ts +pnpm --filter @coder-studio/web exec vitest run src/features/tasks/__tests__/tasks-panel.test.tsx src/features/terminal-panel/__tests__/terminal-panel.test.tsx src/features/workspace/views/desktop/workspace-desktop-view.test.tsx src/features/agent-panes/components/session-card.test.tsx src/features/workspace/views/shared/git-panel.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: all commands complete successfully. + +- [ ] **Step 2: Manual smoke test** + +Run: + +```bash +pnpm dev +``` + +Expected manual checks: + +- Desktop bottom panel shows `Terminal` and `Tasks`. +- `Tasks` lists `Verify` as `pnpm ci:verify` in this repository. +- Clicking `Run Verify` switches to `Terminal` and selects `Task: Verify`. +- Stopping a running task marks it `Stopped`. +- A non-zero exit marks it `Failed` and shows a short tail summary in task state. +- Git panel shows latest verification status after a verify run. + +- [ ] **Step 3: Commit any smoke-test fixes** + +If the smoke test required fixes, run: + +```bash +git add packages/core packages/server packages/web +git commit -m "fix: stabilize tasks verification flow" +``` + +If no fixes were needed, do not create an empty commit. + +## Milestone B: Git Hunk Review + +### Task 10: Parse Git Diff Hunks Server-Side + +**Files:** +- Create: `packages/server/src/git/hunks.ts` +- Create: `packages/server/src/git/hunks.test.ts` +- Modify: `packages/server/src/git/diff.ts` + +- [ ] **Step 1: Write failing hunk parser tests** + +Create `packages/server/src/git/hunks.test.ts` with: + +```ts +import { describe, expect, it } from "vitest"; +import { buildSingleHunkPatch, parseDiffHunks } from "./hunks.js"; + +const SAMPLE_DIFF = `diff --git a/src/app.ts b/src/app.ts +index 1111111..2222222 100644 +--- a/src/app.ts ++++ b/src/app.ts +@@ -1,5 +1,6 @@ + import { boot } from "./boot"; + +-boot("old"); ++boot("new"); ++console.log("ready"); + export {}; +@@ -20,3 +21,4 @@ export function label() { + return "label"; + } ++export const ready = true; +`; + +describe("parseDiffHunks", () => { + it("returns stable hunk descriptors with patch text", () => { + const hunks = parseDiffHunks({ + diff: SAMPLE_DIFF, + path: "src/app.ts", + staged: false, + }); + + expect(hunks).toHaveLength(2); + expect(hunks[0]).toMatchObject({ + header: "@@ -1,5 +1,6 @@", + oldStart: 1, + oldLines: 5, + newStart: 1, + newLines: 6, + lines: [ + " import { boot } from \"./boot\";", + " ", + "-boot(\"old\");", + "+boot(\"new\");", + "+console.log(\"ready\");", + " export {};", + ], + }); + expect(hunks[0]!.id).toMatch(/^hunk_/); + expect(hunks[0]!.patch).toContain("@@ -1,5 +1,6 @@"); + }); + + it("builds a single-hunk patch with the file header", () => { + const [hunk] = parseDiffHunks({ diff: SAMPLE_DIFF, path: "src/app.ts", staged: false }); + const patch = buildSingleHunkPatch(SAMPLE_DIFF, hunk!.id, { + path: "src/app.ts", + staged: false, + }); + + expect(patch).toContain("diff --git a/src/app.ts b/src/app.ts"); + expect(patch).toContain("--- a/src/app.ts"); + expect(patch).toContain("+++ b/src/app.ts"); + expect(patch).toContain("@@ -1,5 +1,6 @@"); + expect(patch).not.toContain("@@ -20,3 +21,4 @@"); + }); + + it("returns null when the requested hunk is stale", () => { + const patch = buildSingleHunkPatch(SAMPLE_DIFF, "hunk_missing", { + path: "src/app.ts", + staged: false, + }); + + expect(patch).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run hunk parser tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/git/hunks.test.ts +``` + +Expected: FAIL because `packages/server/src/git/hunks.ts` does not exist. + +- [ ] **Step 3: Implement hunk parsing** + +Create `packages/server/src/git/hunks.ts` with: + +```ts +import { createHash } from "node:crypto"; +import type { GitDiffHunk } from "@coder-studio/core"; + +interface ParseDiffHunksInput { + diff: string; + path: string; + staged: boolean; +} + +const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +function hunkId(input: ParseDiffHunksInput, header: string, lines: string[]): string { + const hash = createHash("sha256") + .update(input.path) + .update(input.staged ? "staged" : "unstaged") + .update(header) + .update(lines.join("\n")) + .digest("hex") + .slice(0, 16); + return `hunk_${hash}`; +} + +function parseCount(value: string | undefined): number { + return value ? Number.parseInt(value, 10) : 1; +} + +function diffFileHeader(diffLines: string[]): string[] { + const header: string[] = []; + for (const line of diffLines) { + if (line.startsWith("@@ ")) { + break; + } + header.push(line); + } + return header; +} + +export function parseDiffHunks(input: ParseDiffHunksInput): GitDiffHunk[] { + const lines = input.diff.split("\n"); + const hunks: GitDiffHunk[] = []; + let current: + | { + header: string; + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; + } + | null = null; + + const flush = () => { + if (!current) return; + const id = hunkId(input, current.header, current.lines); + hunks.push({ + id, + header: current.header, + oldStart: current.oldStart, + oldLines: current.oldLines, + newStart: current.newStart, + newLines: current.newLines, + patch: [current.header, ...current.lines].join("\n"), + lines: current.lines, + }); + }; + + for (const line of lines) { + const match = HUNK_HEADER_PATTERN.exec(line); + if (match) { + flush(); + current = { + header: line, + oldStart: Number.parseInt(match[1]!, 10), + oldLines: parseCount(match[2]), + newStart: Number.parseInt(match[3]!, 10), + newLines: parseCount(match[4]), + lines: [], + }; + continue; + } + if (current) { + current.lines.push(line); + } + } + flush(); + + return hunks; +} + +export function buildSingleHunkPatch( + diff: string, + requestedHunkId: string, + input: ParseDiffHunksInput +): string | null { + const lines = diff.split("\n"); + const header = diffFileHeader(lines); + const hunk = parseDiffHunks(input).find((candidate) => candidate.id === requestedHunkId); + if (!hunk) { + return null; + } + return [...header, hunk.patch, ""].join("\n"); +} +``` + +- [ ] **Step 4: Include hunks in text diff payloads** + +In `packages/server/src/git/diff.ts`, import: + +```ts +import { parseDiffHunks } from "./hunks.js"; +``` + +In `buildTextDiffResult`, add: + +```ts +hunks: parseDiffHunks({ diff: payload.diff, path: filePath, staged }), +``` + +to the returned object. + +- [ ] **Step 5: Run hunk parser tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/git/hunks.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 6: Commit hunk parser** + +Run: + +```bash +git add packages/server/src/git/hunks.ts packages/server/src/git/hunks.test.ts packages/server/src/git/diff.ts +git commit -m "feat: parse git diff hunks" +``` + +### Task 11: Apply Git Hunk Operations Server-Side + +**Files:** +- Create: `packages/server/src/git/hunk-operations.ts` +- Create: `packages/server/src/git/hunk-operations.test.ts` +- Modify: `packages/server/src/commands/git.ts` +- Modify: `packages/server/src/__tests__/git-commands.test.ts` + +- [ ] **Step 1: Write failing hunk operation tests** + +Create `packages/server/src/git/hunk-operations.test.ts` with real temporary Git repositories: + +```ts +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getFileDiff } from "./diff.js"; +import { applyGitHunkOperation } from "./hunk-operations.js"; +import { runGit } from "./cli.js"; + +describe("applyGitHunkOperation", () => { + let root: string; + + beforeEach(async () => { + root = join(tmpdir(), `coder-studio-hunk-op-${Date.now()}-${Math.random()}`); + await mkdir(root, { recursive: true }); + await runGit(root, ["init"]); + await runGit(root, ["config", "user.email", "test@example.com"]); + await runGit(root, ["config", "user.name", "Test User"]); + await writeFile(join(root, "file.txt"), "one\ntwo\nthree\nfour\nfive\n"); + await runGit(root, ["add", "file.txt"]); + await runGit(root, ["commit", "-m", "initial"]); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("stages one unstaged hunk", async () => { + await writeFile(join(root, "file.txt"), "ONE\ntwo\nthree\nfour\nFIVE\n"); + const diff = await getFileDiff(root, "file.txt", false); + const hunkId = diff.hunks![0]!.id; + + await applyGitHunkOperation(root, { + workspaceId: "ws-1", + path: "file.txt", + staged: false, + hunkId, + operation: "stage", + }); + + const staged = await runGit(root, ["diff", "--staged", "--", "file.txt"]); + const unstaged = await runGit(root, ["diff", "--", "file.txt"]); + expect(staged.stdout).toContain("ONE"); + expect(unstaged.stdout).toContain("FIVE"); + }); + + it("unstages one staged hunk", async () => { + await writeFile(join(root, "file.txt"), "ONE\ntwo\nthree\nfour\nFIVE\n"); + await runGit(root, ["add", "file.txt"]); + const diff = await getFileDiff(root, "file.txt", true); + const hunkId = diff.hunks![0]!.id; + + await applyGitHunkOperation(root, { + workspaceId: "ws-1", + path: "file.txt", + staged: true, + hunkId, + operation: "unstage", + }); + + const staged = await runGit(root, ["diff", "--staged", "--", "file.txt"]); + const unstaged = await runGit(root, ["diff", "--", "file.txt"]); + expect(staged.stdout).not.toContain("ONE"); + expect(unstaged.stdout).toContain("ONE"); + }); + + it("discards one unstaged hunk", async () => { + await writeFile(join(root, "file.txt"), "ONE\ntwo\nthree\nfour\nFIVE\n"); + const diff = await getFileDiff(root, "file.txt", false); + const hunkId = diff.hunks![0]!.id; + + await applyGitHunkOperation(root, { + workspaceId: "ws-1", + path: "file.txt", + staged: false, + hunkId, + operation: "discard", + }); + + const unstaged = await runGit(root, ["diff", "--", "file.txt"]); + expect(unstaged.stdout).not.toContain("ONE"); + expect(unstaged.stdout).toContain("FIVE"); + }); + + it("rejects stale hunk ids", async () => { + await writeFile(join(root, "file.txt"), "ONE\ntwo\nthree\nfour\nfive\n"); + + await expect( + applyGitHunkOperation(root, { + workspaceId: "ws-1", + path: "file.txt", + staged: false, + hunkId: "hunk_stale", + operation: "stage", + }) + ).rejects.toMatchObject({ + code: "git_hunk_stale", + }); + }); +}); +``` + +- [ ] **Step 2: Run hunk operation tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/git/hunk-operations.test.ts +``` + +Expected: FAIL because `packages/server/src/git/hunk-operations.ts` does not exist. + +- [ ] **Step 3: Implement hunk operations** + +Create `packages/server/src/git/hunk-operations.ts`: + +```ts +import type { GitHunkOperation } from "@coder-studio/core"; +import { getFileDiff } from "./diff.js"; +import { buildSingleHunkPatch } from "./hunks.js"; +import { runGit } from "./cli.js"; + +function gitApplyArgs(operation: GitHunkOperation["operation"], staged: boolean): string[] { + if (operation === "stage" && !staged) { + return ["apply", "--cached", "--unidiff-zero"]; + } + if (operation === "unstage" && staged) { + return ["apply", "--cached", "--reverse", "--unidiff-zero"]; + } + if (operation === "discard" && !staged) { + return ["apply", "--reverse", "--unidiff-zero"]; + } + throw { + code: "git_hunk_operation_invalid", + message: `Invalid hunk operation ${operation} for ${staged ? "staged" : "unstaged"} diff`, + }; +} + +export async function applyGitHunkOperation( + cwd: string, + operation: GitHunkOperation +): Promise { + const diff = await getFileDiff(cwd, operation.path, operation.staged); + if (diff.renderAs !== "text") { + throw { code: "git_hunk_not_text", message: "Hunk operations are only available for text diffs" }; + } + + const patch = buildSingleHunkPatch(diff.diff, operation.hunkId, { + path: operation.path, + staged: operation.staged, + }); + if (!patch) { + throw { + code: "git_hunk_stale", + message: "Diff changed. Refresh and try again.", + }; + } + + const args = gitApplyArgs(operation.operation, operation.staged); + await runGit(cwd, [...args, "--check"], { stdin: patch }); + await runGit(cwd, args, { stdin: patch }); +} +``` + +`runGit` already accepts `stdin` in `packages/server/src/git/cli.ts`, so do not add a second option name for patch input. + +- [ ] **Step 4: Register `git.hunk` command** + +In `packages/server/src/commands/git.ts`, import: + +```ts +import { applyGitHunkOperation } from "../git/hunk-operations.js"; +``` + +Add this command: + +```ts +registerCommand( + "git.hunk", + z.object({ + workspaceId: z.string(), + path: z.string(), + staged: z.boolean(), + hunkId: z.string(), + operation: z.enum(["stage", "unstage", "discard"]), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + await applyGitHunkOperation(workspace.path, args); + emitGitStateChanged(ctx, args.workspaceId, { + worktreeChanged: true, + }); + return {}; + } +); +``` + +- [ ] **Step 5: Add command test** + +In `packages/server/src/__tests__/git-commands.test.ts`, add a mocked command test that dispatches `git.hunk` and asserts `workspaceMgr.get` and event emission behavior. The expected dispatch args are: + +```ts +{ + workspaceId: "ws-1", + path: "src/app.ts", + staged: false, + hunkId: "hunk_abc", + operation: "stage" +} +``` + +The expected successful result is `{ ok: true, data: {} }`. + +- [ ] **Step 6: Run hunk operation tests** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/git/hunks.test.ts src/git/hunk-operations.test.ts src/__tests__/git-commands.test.ts +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 7: Commit hunk operations** + +Run: + +```bash +git add packages/server/src/git/hunk-operations.ts packages/server/src/git/hunk-operations.test.ts packages/server/src/commands/git.ts packages/server/src/__tests__/git-commands.test.ts packages/server/src/git/cli.ts +git commit -m "feat: apply git hunk operations" +``` + +### Task 12: Add Git Diff Review Actions in the Web UI + +**Files:** +- Modify: `packages/web/src/features/workspace/actions/use-git-actions.ts` +- Modify: `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-diff-viewer.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write failing diff viewer tests** + +In `packages/web/src/features/workspace/views/shared/git-diff-viewer.test.tsx`, add a preview with `hunks`: + +```ts +store.set(gitDiffPreviewAtomFamily("ws-test"), { + kind: "worktree-file-diff", + path: "src/app.ts", + staged: false, + diff: "diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n", + renderAs: "text", + status: "modified", + hunks: [ + { + id: "hunk_abc", + header: "@@ -1 +1 @@", + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + patch: "@@ -1 +1 @@\n-old\n+new", + lines: ["-old", "+new"], + }, + ], +}); +``` + +Add assertions: + +```ts +expect(screen.getByRole("button", { name: "Stage hunk" })).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Stage file" })).toBeInTheDocument(); +``` + +Click `Stage hunk` and expect dispatch: + +```ts +expect(sendCommand).toHaveBeenCalledWith("git.hunk", { + workspaceId: "ws-test", + path: "src/app.ts", + staged: false, + hunkId: "hunk_abc", + operation: "stage", +}); +``` + +Add a stale error test where `git.hunk` returns: + +```ts +{ + ok: false, + error: { + code: "git_hunk_stale", + message: "Diff changed. Refresh and try again." + } +} +``` + +Expected UI: an error toast or inline alert with `Diff changed. Refresh and try again.` and the preview remains selected. + +- [ ] **Step 2: Run diff viewer tests and confirm failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-diff-viewer.test.tsx +``` + +Expected: FAIL because hunk action UI does not exist. + +- [ ] **Step 3: Add Git hunk actions** + +In `packages/web/src/features/workspace/actions/use-git-actions.ts`, add a hunk operation helper in `useGitDiffViewerActions`: + +```ts +const runHunkOperation = useCallback( + async (input: { + path: string; + staged: boolean; + hunkId: string; + operation: "stage" | "unstage" | "discard"; + }) => { + const result = await dispatch("git.hunk", { + workspaceId, + path: input.path, + staged: input.staged, + hunkId: input.hunkId, + operation: input.operation, + }); + + if (!result.ok) { + pushToast({ + kind: "error", + title: t("git.hunk_failed_title"), + body: result.error?.message ?? t("git.hunk_failed_body"), + }); + return false; + } + + const refreshed = await dispatch("git.diff", { + workspaceId, + path: input.path, + staged: input.staged, + }); + if (refreshed.ok && refreshed.data) { + setPreview((current) => + current?.kind === "worktree-file-diff" && current.path === input.path + ? { ...current, ...refreshed.data } + : current + ); + } + await refreshGitState(); + return true; + }, + [dispatch, pushToast, refreshGitState, setPreview, t, workspaceId] +); +``` + +Return `runHunkOperation` from `useGitDiffViewerActions`. + +If `refreshGitState` is scoped inside another hook, extract a small shared helper in the same file that dispatches `git.status` and writes `gitStateAtomFamily(workspaceId)`. + +- [ ] **Step 4: Add diff viewer hunk and file actions** + +In `git-diff-viewer.tsx`, use `preview.kind === "worktree-file-diff"` to render file actions: + +```tsx +{preview?.kind === "worktree-file-diff" ? ( +
+ + {!preview.staged ? : null} + +
+) : null} +``` + +Render hunk action rows before each hunk header: + +```tsx +{preview?.kind === "worktree-file-diff" && preview.hunks?.map((hunk) => ( +
+
+ {hunk.header} + {preview.staged ? ( + + ) : ( + <> + + + + )} +
+ {hunk.lines.map((line, index) => ( +
+ {index + 1} + {line || " "} +
+ ))} +
+))} +``` + +For previews without `hunks`, keep the existing flat diff line rendering as a fallback. + +- [ ] **Step 5: Add translations** + +Add to `packages/web/src/locales/en.json` under `git`: + +```json +"stage_file": "Stage file", +"unstage_file": "Unstage file", +"discard_file": "Discard file", +"refresh_diff": "Refresh diff", +"stage_hunk": "Stage hunk", +"unstage_hunk": "Unstage hunk", +"discard_hunk": "Discard hunk", +"hunk_failed_title": "Hunk operation failed", +"hunk_failed_body": "The selected hunk could not be applied." +``` + +Add to `packages/web/src/locales/zh.json` under `git`: + +```json +"stage_file": "暂存文件", +"unstage_file": "取消暂存文件", +"discard_file": "放弃文件改动", +"refresh_diff": "刷新差异", +"stage_hunk": "暂存此段", +"unstage_hunk": "取消暂存此段", +"discard_hunk": "放弃此段", +"hunk_failed_title": "Hunk 操作失败", +"hunk_failed_body": "无法应用选中的改动段。" +``` + +- [ ] **Step 6: Run diff viewer tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-diff-viewer.test.tsx src/features/workspace/views/shared/git-panel.test.tsx +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS for tests and no TypeScript errors. + +- [ ] **Step 7: Commit Git diff actions** + +Run: + +```bash +git add packages/web/src/features/workspace/actions/use-git-actions.ts packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx packages/web/src/features/workspace/views/shared/git-diff-viewer.test.tsx packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "feat: add git hunk review actions" +``` + +### Task 13: Verify Milestone B and Full Plan + +**Files:** +- No new files. + +- [ ] **Step 1: Run focused Git validation** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/git/hunks.test.ts src/git/hunk-operations.test.ts src/__tests__/git-commands.test.ts +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-diff-viewer.test.tsx src/features/workspace/views/shared/git-panel.test.tsx +pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: all commands complete successfully. + +- [ ] **Step 2: Run repository verification** + +Run: + +```bash +pnpm ci:verify +``` + +Expected: command completes successfully. + +- [ ] **Step 3: Manual smoke test** + +Run: + +```bash +pnpm dev +``` + +Expected manual checks: + +- Modify a file in two separate hunks. +- Open Git panel and select the unstaged diff. +- `Stage hunk` stages only the selected hunk. +- `Discard hunk` discards only the selected unstaged hunk. +- Stage the file, open staged diff, and `Unstage hunk` unstages only the selected hunk. +- If the file changes between opening a diff and clicking a hunk action, the UI shows `Diff changed. Refresh and try again.` and keeps the file selected. + +- [ ] **Step 4: Commit smoke-test fixes** + +If fixes were required, run: + +```bash +git add packages/core packages/server packages/web +git commit -m "fix: stabilize git hunk review" +``` + +If no fixes were needed, do not create an empty commit. + +## Final Acceptance Criteria + +- `Tasks` appears beside `Terminal` in the desktop bottom panel. +- `Verify` is discovered as `pnpm ci:verify` for this repository. +- Running a task creates a `task` terminal, switches to the Terminal tab, and selects that terminal. +- Task rows show `Not run`, `Running`, `Passed`, `Failed`, and `Stopped` states. +- Task runs track duration, exit code, and capped tail output summary. +- Agent and Git contexts show latest verification state without becoming secondary task managers. +- Git text diffs include server-generated hunk IDs. +- Hunk stage, unstage, and discard actions validate against current diff state server-side. +- Stale hunk operations show a clear refresh error and do not apply arbitrary client patch text. + +## Self-Review Checklist + +- Spec coverage: + - Managed Tasks MVP is covered by Tasks 1 through 7. + - Agent and Git latest verification context is covered by Task 8. + - Hunk-level Git staging, unstaging, discard, and diff-local actions are covered by Tasks 10 through 12. + - Non-goals are explicitly excluded in Execution Rules. +- Placeholder scan: + - This plan contains no deferred placeholder markers. + - Each code-changing task includes concrete file paths, public contracts, test commands, and expected outcomes. +- Type consistency: + - `TerminalKind` is the single shared terminal-kind source. + - `TaskDefinition`, `TaskRun`, `GitDiffHunk`, and `GitHunkOperation` names are consistent across core, server, and web. + - Task event names match `DomainEvent`, `WsHub`, and topic usage. diff --git a/docs/superpowers/plans/2026-06-04-work-analysis-foundation-alignment-implementation-plan.md b/docs/superpowers/plans/2026-06-04-work-analysis-foundation-alignment-implementation-plan.md new file mode 100644 index 000000000..7c004805e --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-work-analysis-foundation-alignment-implementation-plan.md @@ -0,0 +1,719 @@ +# Work Analysis Foundation Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade `/analytics` from summary cards into a token-first, event-driven analytics surface with real workspace discovery, trend charts, efficiency signals, compare views, yield views, and budget/export support aligned to the approved foundation design. + +**Architecture:** Extend provider log parsing into a canonical event stream, derive stable analytics contracts from those events, then upgrade the web analytics page to consume the shared contract for charts, rankings, and drill-down tables. Keep workspace filtering result-driven, preserve token-first semantics, and surface provider capability gaps explicitly. + +**Tech Stack:** TypeScript, pnpm monorepo, Vitest, Playwright, React, Vite, Jotai, existing server work-analysis pipeline, existing web `/analytics` feature + +--- + +## File Structure + +### Server + +- Modify: `packages/server/src/work-analysis/log-sources/types.ts` + - Expand provider parse output into canonical event records and capability flags. +- Modify: `packages/server/src/work-analysis/log-sources/claude.ts` + - Extract ordered turn and usage signals from Claude logs. +- Modify: `packages/server/src/work-analysis/log-sources/codex.ts` + - Extract ordered turn and usage signals from Codex logs. +- Modify: `packages/server/src/work-analysis/types.ts` + - Add analytics contract fields for trends, share series, efficiency signals, compare tables, yield samples, and budget forecast payloads. +- Modify: `packages/server/src/work-analysis/basic-schema.ts` + - Validate new server response shape. +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` + - Build canonical event aggregates and expose richer analytics payloads. +- Create: `packages/server/src/work-analysis/metrics/trends.ts` + - Build day buckets and share series. +- Create: `packages/server/src/work-analysis/metrics/efficiency.ts` + - Compute one-shot, retry, self-correction, read:edit, command-to-edit, and cache-hit metrics. +- Modify: `packages/server/src/work-analysis/metrics/compare.ts` + - Return chart-friendly and table-friendly compare structures. +- Modify: `packages/server/src/work-analysis/metrics/yield.ts` + - Add yield scorecards and sample session lists. +- Modify: `packages/server/src/work-analysis/metrics/token-budgets.ts` + - Add active-day average, forecast, and threshold detail. +- Modify: `packages/server/src/work-analysis/metrics/token-efficiency.ts` + - Reconcile older optimize/token waste helpers with the new efficiency contract. +- Modify: `packages/server/src/work-analysis/optimize/detect-findings.ts` + - Add low-yield and retry-heavy findings using canonical events. +- Modify: `packages/server/src/work-analysis/service.ts` + - Return enriched snapshots without changing command routing semantics. + +### Server Tests + +- Modify: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` + - Cover day trends, workspace discovery, capability signaling, and compare payload shape. +- Create: `packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts` + - Cover retry, one-shot, command-to-edit, read:edit, and cache-hit calculations. +- Modify: `packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts` + - Cover new optimize detectors and yield sample outputs. +- Modify: `packages/server/src/__tests__/work-analysis-service.test.ts` + - Cover enriched snapshot fields. +- Modify: `packages/server/src/__tests__/work-analysis-commands.test.ts` + - Cover contract shape exposed by commands. + +### Web + +- Modify: `packages/web/src/features/work-analysis/types.ts` + - Match the enriched server contract. +- Modify: `packages/web/src/features/work-analysis/format.ts` + - Add chart/table formatting helpers for trends, shares, efficiency, and budgets. +- Modify: `packages/web/src/features/work-analysis/page.tsx` + - Add chart-ready overview, compare, efficiency, yield, and budget sections. +- Modify: `packages/web/src/features/work-analysis/use-work-analysis-controller.ts` + - Preserve filter state and expose derived chart inputs if needed. +- Modify: `packages/web/src/locales/en.json` + - Add analytics chart, legend, scorecard, and table labels. +- Modify: `packages/web/src/locales/zh.json` + - Add corresponding Chinese labels. + +### Web Tests + +- Modify: `packages/web/src/features/work-analysis/page.test.tsx` + - Assert chart headings, tab content, and capability notices render from enriched data. +- Modify: `packages/web/src/features/settings/components/session-analysis-settings.tsx` + - Keep launcher + lightweight summary aligned with the richer analytics surface. +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` + - Keep settings entry behavior stable. + +### Acceptance + +- Modify: `e2e/specs/settings/analysis.spec.ts` + - Assert discovered workspace list still appears and analytics page exposes richer sections. + +--- + +### Task 1: Lock The Canonical Event Contract + +**Files:** +- Modify: `packages/server/src/work-analysis/log-sources/types.ts` +- Modify: `packages/server/src/work-analysis/log-sources/claude.ts` +- Modify: `packages/server/src/work-analysis/log-sources/codex.ts` +- Test: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` + +- [ ] **Step 1: Write the failing analyzer test for canonical event-driven workspace discovery and daily trend inputs** + +```ts +it("collects ordered canonical events and discovered workspace paths from provider logs", () => { + const result = runBasicAnalysis({ + sessions: [ + makeClaudeSession({ + workspacePath: "/root/workspace/a", + turns: [ + makeTurn({ type: "message_turn", timestamp: 1000, totalTokens: 120 }), + makeTurn({ type: "command", timestamp: 1500 }), + makeTurn({ type: "edit", timestamp: 1700 }), + ], + }), + makeCodexSession({ + workspacePath: "/root/workspace/b", + turns: [ + makeTurn({ type: "message_turn", timestamp: 2000, totalTokens: 80 }), + makeTurn({ type: "tool_call", timestamp: 2100 }), + ], + }), + ], + }); + + expect(result.coverage.discoveredWorkspacePaths).toEqual([ + "/root/workspace/a", + "/root/workspace/b", + ]); + expect(result.activity.daily.length).toBeGreaterThan(0); + expect(result.capabilityMatrix.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ providerId: "claude" }), + expect.objectContaining({ providerId: "codex" }), + ]) + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts -t "collects ordered canonical events and discovered workspace paths from provider logs"` + +Expected: FAIL because `activity.daily` or canonical-event-derived fields are missing. + +- [ ] **Step 3: Write the canonical event types and provider extraction** + +```ts +export interface WorkAnalysisEvent { + type: + | "session_boundary" + | "message_turn" + | "tool_call" + | "tool_result" + | "command" + | "edit" + | "plan" + | "agent_spawn" + | "git_signal" + | "usage"; + sessionId: string; + providerId: string; + modelId?: string; + workspacePath?: string; + timestamp?: number; + timestampSource: "explicit" | "inferred"; + usage?: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + totalTokens?: number; + }; + rawRefs: string[]; +} +``` + +```ts +return { + ...session, + discoveredWorkspacePath: workspacePath, + events: extractedEvents.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0)), + capabilities: { + tokenUsage: hasUsage ? "full" : "partial", + modelIdentity: hasModel ? "full" : "partial", + timestamps: hasExplicitTimestamps ? "full" : "partial", + }, +}; +``` + +- [ ] **Step 4: Run the targeted test to verify it passes** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts -t "collects ordered canonical events and discovered workspace paths from provider logs"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/work-analysis/log-sources/types.ts \ + packages/server/src/work-analysis/log-sources/claude.ts \ + packages/server/src/work-analysis/log-sources/codex.ts \ + packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts +git commit -m "feat: extract canonical analytics events from provider logs" +``` + +### Task 2: Add Trend, Compare, And Budget Metrics To The Server Contract + +**Files:** +- Create: `packages/server/src/work-analysis/metrics/trends.ts` +- Modify: `packages/server/src/work-analysis/metrics/compare.ts` +- Modify: `packages/server/src/work-analysis/metrics/token-budgets.ts` +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/work-analysis/basic-schema.ts` +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` +- Test: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-service.test.ts` + +- [ ] **Step 1: Write failing tests for daily trends, share series, compare tables, and forecast budgets** + +```ts +it("returns chart-friendly trend and share payloads", () => { + const result = runBasicAnalysis(makeMultiDayFixture()); + + expect(result.activity.daily).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + day: "2026-06-01", + totalTokens: expect.any(Number), + sessionCount: expect.any(Number), + }), + ]) + ); + expect(result.compare.workspaces[0]).toEqual( + expect.objectContaining({ + workspacePath: expect.any(String), + totalTokens: expect.any(Number), + sharePercent: expect.any(Number), + }) + ); + expect(result.budgets.forecast30d).toBeGreaterThan(0); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts` + +Expected: FAIL because new trend/share/budget fields do not exist yet. + +- [ ] **Step 3: Implement minimal trend, compare, and budget payloads** + +```ts +export interface WorkAnalysisDailyBucket { + day: string; + totalTokens: number; + sessionCount: number; +} + +export function buildDailyBuckets(events: WorkAnalysisEvent[]): WorkAnalysisDailyBucket[] { + const buckets = new Map(); + for (const event of events) { + if (!event.timestamp || !event.usage?.totalTokens) continue; + const day = new Date(event.timestamp).toISOString().slice(0, 10); + const bucket = buckets.get(day) ?? { day, totalTokens: 0, sessionCount: 0 }; + bucket.totalTokens += event.usage.totalTokens; + buckets.set(day, bucket); + } + return [...buckets.values()].sort((left, right) => left.day.localeCompare(right.day)); +} +``` + +```ts +budgets: { + rolling7d, + rolling30d, + activeDayAverage, + forecast30d: Math.round(activeDayAverage * 30), + thresholds: { + focus: buildBudgetStatus(rolling30d, 25_731_068), + current: buildBudgetStatus(rolling30d, 34_308_090), + stretch: buildBudgetStatus(rolling30d, 42_885_113), + }, +}, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/work-analysis/metrics/trends.ts \ + packages/server/src/work-analysis/metrics/compare.ts \ + packages/server/src/work-analysis/metrics/token-budgets.ts \ + packages/server/src/work-analysis/types.ts \ + packages/server/src/work-analysis/basic-schema.ts \ + packages/server/src/work-analysis/basic-analyzer.ts \ + packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts \ + packages/server/src/__tests__/work-analysis-service.test.ts +git commit -m "feat: add analytics trend compare and budget contracts" +``` + +### Task 3: Add Efficiency, Optimize, And Yield Signals + +**Files:** +- Create: `packages/server/src/work-analysis/metrics/efficiency.ts` +- Modify: `packages/server/src/work-analysis/metrics/token-efficiency.ts` +- Modify: `packages/server/src/work-analysis/metrics/yield.ts` +- Modify: `packages/server/src/work-analysis/optimize/detect-findings.ts` +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` +- Test: `packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts` + +- [ ] **Step 1: Write failing tests for retry, one-shot, read:edit, and low-yield expensive sessions** + +```ts +it("computes efficiency scorecards from canonical events", () => { + const metrics = buildEfficiencyMetrics([ + makeSessionWithEvents({ + events: [ + event("message_turn", { totalTokens: 300 }), + event("command"), + event("edit"), + event("git_signal"), + ], + }), + makeSessionWithEvents({ + events: [event("message_turn", { totalTokens: 800 }), event("message_turn", { totalTokens: 500 })], + }), + ]); + + expect(metrics.oneShotRate).toBeGreaterThan(0); + expect(metrics.retryRate).toBeGreaterThan(0); + expect(metrics.readToEditRatio).toBeGreaterThan(0); +}); +``` + +```ts +it("flags high-cost low-yield sessions in optimize findings", () => { + const result = runBasicAnalysis(makeLowYieldFixture()); + + expect(result.optimize.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "high-cost-low-yield", + severity: "high", + }), + ]) + ); + expect(result.yield.lowYieldSessions.length).toBeGreaterThan(0); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts` + +Expected: FAIL because the efficiency metric builder and low-yield findings are incomplete. + +- [ ] **Step 3: Implement minimal efficiency and yield derivation** + +```ts +export interface WorkAnalysisEfficiencyMetrics { + oneShotRate: number; + retryRate: number; + selfCorrectionRate: number; + readToEditRatio: number; + commandToEditRatio: number; + cacheHitShare: number; + gitAwareSessionRate: number; +} +``` + +```ts +const expensiveSessions = sessions + .filter((session) => session.totalTokens >= HIGH_COST_THRESHOLD) + .map((session) => ({ + sessionId: session.id, + totalTokens: session.totalTokens, + hasEdit: session.editCount > 0, + hasGit: session.gitSignalCount > 0, + })); + +const lowYieldSessions = expensiveSessions.filter((session) => !session.hasEdit && !session.hasGit); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/work-analysis/metrics/efficiency.ts \ + packages/server/src/work-analysis/metrics/token-efficiency.ts \ + packages/server/src/work-analysis/metrics/yield.ts \ + packages/server/src/work-analysis/optimize/detect-findings.ts \ + packages/server/src/work-analysis/basic-analyzer.ts \ + packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts \ + packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts +git commit -m "feat: add efficiency and low-yield analytics signals" +``` + +### Task 4: Upgrade The Web Analytics Surface For Overview And Compare + +**Files:** +- Modify: `packages/web/src/features/work-analysis/types.ts` +- Modify: `packages/web/src/features/work-analysis/format.ts` +- Modify: `packages/web/src/features/work-analysis/page.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Test: `packages/web/src/features/work-analysis/page.test.tsx` + +- [ ] **Step 1: Write failing page tests for trend, share, and compare sections** + +```tsx +it("renders overview trends and compare rankings from analytics data", async () => { + renderWorkAnalyticsPage({ + analysis: makeAnalyticsRecord({ + basicResult: { + activity: { daily: [{ day: "2026-06-01", totalTokens: 3000, sessionCount: 4 }] }, + compare: { + workspaces: [{ workspacePath: "/root/workspace/a", totalTokens: 3000, sharePercent: 0.6 }], + }, + }, + }), + }); + + expect(screen.getByText("Daily Token Trend")).toBeInTheDocument(); + expect(screen.getByText("/root/workspace/a")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/web/src/features/work-analysis/page.test.tsx -t "renders overview trends and compare rankings from analytics data"` + +Expected: FAIL because the current page does not render those sections. + +- [ ] **Step 3: Implement minimal overview and compare sections** + +```tsx +
+ {t("settings.analysis.daily_token_trend")} +
    + {basicResult.activity.daily.map((bucket) => ( +
  • + {bucket.day}: {formatTokenMetric(bucket.totalTokens)} tokens, {formatInteger(bucket.sessionCount)} sessions +
  • + ))} +
+
+``` + +```tsx +
+ {t("settings.analysis.workspace_ranking")} +
    + {compare?.workspaces.map((entry) => ( +
  • + {entry.workspacePath}: {formatTokenMetric(entry.totalTokens)} tokens, {formatPercent(entry.sharePercent)} +
  • + ))} +
+
+``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run packages/web/src/features/work-analysis/page.test.tsx -t "renders overview trends and compare rankings from analytics data"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/work-analysis/types.ts \ + packages/web/src/features/work-analysis/format.ts \ + packages/web/src/features/work-analysis/page.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json \ + packages/web/src/features/work-analysis/page.test.tsx +git commit -m "feat: add overview and compare analytics sections" +``` + +### Task 5: Upgrade The Web Analytics Surface For Efficiency, Yield, And Budgets + +**Files:** +- Modify: `packages/web/src/features/work-analysis/page.tsx` +- Modify: `packages/web/src/features/work-analysis/format.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Test: `packages/web/src/features/work-analysis/page.test.tsx` + +- [ ] **Step 1: Write failing tests for efficiency scorecards, low-yield tables, and budget forecast** + +```tsx +it("renders efficiency scorecards and budget forecast", async () => { + renderWorkAnalyticsPage({ + analysis: makeAnalyticsRecord({ + basicResult: { + efficiency: { oneShotRate: 0.42, retryRate: 0.18, readToEditRatio: 2.4, commandToEditRatio: 1.3 }, + yield: { lowYieldSessions: [{ sessionId: "s-1", totalTokens: 1200, labels: ["no_edit"] }] }, + budgets: { forecast30d: 40000, thresholds: { current: { status: "over", percentUsed: 1.1 } } }, + }, + }), + }); + + expect(screen.getByText("One-Shot Rate")).toBeInTheDocument(); + expect(screen.getByText("Forecast 30 Days")).toBeInTheDocument(); + expect(screen.getByText("s-1")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/web/src/features/work-analysis/page.test.tsx -t "renders efficiency scorecards and budget forecast"` + +Expected: FAIL because the current tabs do not render those fields. + +- [ ] **Step 3: Implement minimal efficiency, yield, and budget sections** + +```tsx +
+ {t("settings.analysis.efficiency_scorecards")} +
    +
  • {t("settings.analysis.one_shot_rate")}: {formatPercent(basicResult.efficiency.oneShotRate)}
  • +
  • {t("settings.analysis.retry_rate")}: {formatPercent(basicResult.efficiency.retryRate)}
  • +
  • {t("settings.analysis.read_to_edit_ratio")}: {basicResult.efficiency.readToEditRatio.toFixed(2)}
  • +
+
+``` + +```tsx +
+ {t("settings.analysis.forecast_30d")} + {formatTokenMetric(basicResult.budgets.forecast30d)} +
+``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run packages/web/src/features/work-analysis/page.test.tsx -t "renders efficiency scorecards and budget forecast"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/work-analysis/page.tsx \ + packages/web/src/features/work-analysis/format.ts \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json \ + packages/web/src/features/work-analysis/page.test.tsx +git commit -m "feat: add efficiency yield and budget analytics views" +``` + +### Task 6: Keep Settings Entry And Acceptance Coverage Stable + +**Files:** +- Modify: `packages/web/src/features/settings/components/session-analysis-settings.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `e2e/specs/settings/analysis.spec.ts` + +- [ ] **Step 1: Write failing tests for the settings launcher summary and richer analytics acceptance** + +```tsx +it("keeps the settings analysis section focused on launch + summary", async () => { + renderSettingsPageWithAnalysisSummary(); + + expect(screen.getByRole("button", { name: /open analytics/i })).toBeInTheDocument(); + expect(screen.queryByText(/daily token trend/i)).not.toBeInTheDocument(); +}); +``` + +```ts +await expect(page.getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_compare") })).toBeVisible(); +await expect(page.getByText(/workspace-b$/)).toBeVisible(); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run packages/web/src/features/settings/components/settings-page.test.tsx` + +Run: `pnpm --dir e2e exec playwright test --config playwright.config.ts e2e/specs/settings/analysis.spec.ts` + +Expected: At least one assertion fails until launcher text and richer analytics expectations are aligned. + +- [ ] **Step 3: Implement the minimal settings and acceptance updates** + +```tsx + + +``` + +```ts +await expect( + page.getByRole("tablist", { + name: translatePatternForE2E("settings.analysis.analytics_sections"), + }) +).toBeVisible(); +await expect(page.getByText(translatePatternForE2E("settings.analysis.coverage_summary_title"))).toBeVisible(); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run packages/web/src/features/settings/components/settings-page.test.tsx` + +Run: `pnpm --dir e2e exec playwright test --config playwright.config.ts e2e/specs/settings/analysis.spec.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/settings/components/session-analysis-settings.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + e2e/specs/settings/analysis.spec.ts +git commit -m "test: keep analytics launcher and acceptance coverage aligned" +``` + +### Task 7: Full Verification And Real-Log Screenshot Pass + +**Files:** +- Modify: `docs/superpowers/plans/2026-06-04-work-analysis-foundation-alignment-implementation-plan.md` + - Check off completed tasks during execution only. +- Output: `e2e/test-results/work-analytics-real-overview.png` +- Output: `e2e/test-results/work-analytics-real-compare.png` +- Output: `e2e/test-results/work-analytics-real-yield.png` +- Output: `e2e/test-results/work-analytics-real-budgets.png` + +- [ ] **Step 1: Run focused server and web verification** + +Run: `pnpm vitest run packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-commands.test.ts packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts packages/server/src/__tests__/work-analysis-service.test.ts packages/web/src/features/work-analysis/page.test.tsx packages/web/src/features/settings/components/settings-page.test.tsx` + +Expected: PASS, or document exact failures before continuing. + +- [ ] **Step 2: Run acceptance coverage** + +Run: `pnpm --dir e2e exec playwright test --config playwright.config.ts e2e/specs/settings/analysis.spec.ts` + +Expected: PASS with discovered workspace paths still visible. + +- [ ] **Step 3: Capture real-log screenshots from the live analytics page** + +```bash +HOST=127.0.0.1 PORT=4273 STATE_DIR=/tmp/coder-studio-analytics-state \ +RUNTIME_DIR=/tmp/coder-studio-analytics-runtime NO_AUTH=true \ +pnpm exec tsx packages/server/src/server.ts +``` + +```bash +HOST=127.0.0.1 VITE_BACKEND_HTTP_URL=http://127.0.0.1:4273 \ +VITE_BACKEND_WS_URL=ws://127.0.0.1:4273/ws \ +pnpm --dir packages/web exec vite --host 127.0.0.1 --port 5273 +``` + +```bash +pnpm --dir e2e exec node --input-type=module - <<'EOF' +import { chromium } from '@playwright/test'; +import path from 'node:path'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1400 } }); +const outDir = path.resolve('../e2e/test-results'); +await page.goto('http://127.0.0.1:5273/analytics', { waitUntil: 'domcontentloaded' }); +await page.getByRole('button', { name: '运行基础分析' }).click(); +await page.getByRole('tablist', { name: '工作分析分区' }).waitFor({ state: 'visible', timeout: 180000 }); +await page.screenshot({ path: path.join(outDir, 'work-analytics-real-overview.png'), fullPage: true }); +for (const [label, file] of [['对比', 'work-analytics-real-compare.png'], ['产出', 'work-analytics-real-yield.png'], ['预算', 'work-analytics-real-budgets.png']]) { + await page.getByRole('tab', { name: label }).click(); + await page.waitForTimeout(900); + await page.screenshot({ path: path.join(outDir, file), fullPage: true }); +} +await browser.close(); +EOF +``` + +- [ ] **Step 4: Verify the screenshots and branch state** + +Run: `file e2e/test-results/work-analytics-real-*.png && git status -sb` + +Expected: PNG files exist and branch contains only intended changes plus known local untracked files. + +- [ ] **Step 5: Commit** + +```bash +git add . +git reset AGENTS.md AGENTS.override.md CLAUDE.local.md GEMINI.md docs/superpowers/plans/2026-06-04-workspace-history.md +git commit -m "chore: verify work analysis foundation alignment" +``` + +## Self-Review + +### Spec Coverage + +- canonical event model: Task 1 +- derived metrics layer: Tasks 2 and 3 +- materialized analytics contract: Tasks 2 and 3 via analyzer/service/schema updates +- overview / compare / efficiency / yield / budgets surface: Tasks 4 and 5 +- settings launcher and acceptance continuity: Task 6 +- real-log validation and screenshots: Task 7 + +No spec requirement is currently uncovered. + +### Placeholder Scan + +- No `TBD`, `TODO`, or deferred implementation placeholders remain. +- Every task includes concrete files, commands, and at least one representative code block. + +### Type Consistency + +- `activity.daily`, `compare.workspaces`, `efficiency`, `yield.lowYieldSessions`, and `budgets.forecast30d` are used consistently across server and web tasks. +- Canonical event terminology is consistent across provider parsing, metrics, and analyzer steps. + diff --git a/docs/superpowers/plans/2026-06-04-work-analysis-workspace-path-filter.md b/docs/superpowers/plans/2026-06-04-work-analysis-workspace-path-filter.md new file mode 100644 index 000000000..9250181f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-work-analysis-workspace-path-filter.md @@ -0,0 +1,599 @@ +# Work Analysis Workspace Path Filter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign work analysis so provider logs are scanned by time range first, all discovered `workspacePath` values become selectable result filters, and opened workspaces are no longer a prerequisite. + +**Architecture:** Rewrite the work-analysis query model from `workspaceIds` to optional `workspacePaths`, remove discover-time workspace whitelists from provider log collection, and shift path filtering into `WorkAnalysisService` after provider sessions are collected. Update analyzer/result types to expose `availableWorkspacePaths`, then rebuild the settings UI around result-driven path multi-select with default-all behavior. + +**Tech Stack:** TypeScript, Vitest, Playwright, React, Jotai, Zod + +--- + +### Task 1: Rewrite Work Analysis Query Types And Command Schema + +**Files:** +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/commands/work-analysis.ts` +- Modify: `packages/server/src/work-analysis/query.ts` +- Modify: `packages/server/src/__tests__/work-analysis-commands.test.ts` +- Test: `packages/server/src/__tests__/work-analysis-query.test.ts` + +- [ ] **Step 1: Write the failing query helper test for path-based input** + +```ts +it("normalizes workspacePaths instead of workspaceIds", () => { + expect( + normalizeWorkAnalysisQuery({ + workspacePaths: ["/repo/b", "/repo/a", "/repo/a"], + timeRange: { preset: "7d" }, + }) + ).toEqual({ + workspacePaths: ["/repo/a", "/repo/b"], + timeRange: { preset: "7d" }, + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-query.test.ts` +Expected: FAIL because `workspacePaths` is not part of the query type or normalization result. + +- [ ] **Step 3: Write the failing command schema test** + +```ts +it("dispatches work.analysis.runBasic with workspacePaths", async () => { + const ctx = { + workAnalysisService: { + runBasic: vi.fn().mockResolvedValue({ ok: true }), + }, + }; + + const result = await dispatchCommand({ + op: "work.analysis.runBasic", + args: { + workspacePaths: ["/repo/a"], + timeRange: { preset: "7d" }, + }, + ctx, + }); + + expect(ctx.workAnalysisService.runBasic).toHaveBeenCalledWith({ + workspacePaths: ["/repo/a"], + timeRange: { preset: "7d" }, + }); + expect(result).toEqual({ ok: true }); +}); +``` + +- [ ] **Step 4: Run command test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-commands.test.ts` +Expected: FAIL because schema still requires `workspaceIds`. + +- [ ] **Step 5: Implement the minimal query/type rewrite** + +```ts +export interface WorkAnalysisQuery { + workspacePaths?: string[]; + timeRange: WorkAnalysisTimeRange; +} + +export function normalizeWorkAnalysisQuery(input: WorkAnalysisQuery): WorkAnalysisQuery { + const workspacePaths = input.workspacePaths + ? [...new Set(input.workspacePaths)].sort((left, right) => left.localeCompare(right)) + : undefined; + + return { + ...(workspacePaths && workspacePaths.length > 0 ? { workspacePaths } : {}), + timeRange: "preset" in input.timeRange ? input.timeRange : { ...input.timeRange }, + }; +} +``` + +- [ ] **Step 6: Update the command schema** + +```ts +const workAnalysisQuerySchema = z.object({ + workspacePaths: z.array(z.string().trim().min(1)).optional(), + timeRange: z.union([ + z.object({ preset: z.enum(["24h", "7d", "30d", "90d"]) }), + z.object({ startAt: z.number(), endAt: z.number() }), + ]), +}); +``` + +- [ ] **Step 7: Run focused server tests to verify they pass** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-query.test.ts src/__tests__/work-analysis-commands.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add packages/server/src/work-analysis/types.ts \ + packages/server/src/work-analysis/query.ts \ + packages/server/src/commands/work-analysis.ts \ + packages/server/src/__tests__/work-analysis-query.test.ts \ + packages/server/src/__tests__/work-analysis-commands.test.ts +git commit -m "refactor: switch work analysis queries to workspace paths" +``` + +### Task 2: Remove Discover-Time Workspace Whitelists From Provider Collection + +**Files:** +- Modify: `packages/server/src/work-analysis/log-sources/types.ts` +- Modify: `packages/server/src/work-analysis/log-sources/collector.ts` +- Modify: `packages/server/src/work-analysis/log-sources/codex.ts` +- Modify: `packages/server/src/work-analysis/log-sources/claude.ts` +- Modify: `packages/server/src/work-analysis/log-sources/gemini.ts` +- Modify: `packages/server/src/work-analysis/log-sources/cursor.ts` +- Modify: `packages/server/src/work-analysis/log-sources/opencode.ts` +- Modify: `packages/server/src/__tests__/work-analysis-log-collector.test.ts` +- Modify: `packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts` +- Modify: `packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts` + +- [ ] **Step 1: Write the failing collector test for all discovered paths** + +```ts +it("collects sessions without a workspace path allowlist", async () => { + const source = { + providerId: "codex", + discover: vi.fn().mockResolvedValue({ + providerId: "codex", + status: "supported", + sessions: [ + { providerId: "codex", sessionId: "s1", workspacePath: "/repo/a", startedAt: 1, lastActiveAt: 2, sourceRef: "a", userTurnCount: 0, assistantTurnCount: 0, toolUseCount: 0, parseErrorCount: 0, timestampQuality: "explicit" }, + { providerId: "codex", sessionId: "s2", workspacePath: "/repo/b", startedAt: 3, lastActiveAt: 4, sourceRef: "b", userTurnCount: 0, assistantTurnCount: 0, toolUseCount: 0, parseErrorCount: 0, timestampQuality: "explicit" }, + ], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }), + }; + + const collector = createWorkLogCollector({ sources: [source as ProviderWorkLogSource] }); + const result = await collector({ timeRange: { startAt: 0, endAt: 10, label: "7d" } }); + + expect(result.sessions.map((session) => session.workspacePath)).toEqual(["/repo/a", "/repo/b"]); +}); +``` + +- [ ] **Step 2: Run collector test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-collector.test.ts` +Expected: FAIL because collector and source input still require `workspacePaths`. + +- [ ] **Step 3: Rewrite source discover input to time-range only** + +```ts +export interface ProviderWorkLogDiscoverInput { + timeRange: ResolvedWorkAnalysisTimeRange; +} +``` + +- [ ] **Step 4: Remove workspace allowlist checks from adapters** + +```ts +const workspacePath = metadata?.payload?.cwd ?? metadata?.cwd; +if (!workspacePath) { + return; +} + +sessions.push({ + providerId: "codex", + sessionId, + workspacePath, + startedAt, + lastActiveAt, + sourceRef, + userTurnCount, + assistantTurnCount, + toolUseCount, + parseErrorCount, + timestampQuality, +}); +``` + +- [ ] **Step 5: Update adapter tests to expect sessions from non-opened paths** + +```ts +expect(discovery.sessions.map((session) => session.workspacePath)).toContain("/repo/not-opened"); +``` + +- [ ] **Step 6: Run provider collection tests to verify they pass** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-log-collector.test.ts src/__tests__/work-analysis-log-sources-file-adapters.test.ts src/__tests__/work-analysis-log-source-opencode.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/server/src/work-analysis/log-sources/types.ts \ + packages/server/src/work-analysis/log-sources/collector.ts \ + packages/server/src/work-analysis/log-sources/codex.ts \ + packages/server/src/work-analysis/log-sources/claude.ts \ + packages/server/src/work-analysis/log-sources/gemini.ts \ + packages/server/src/work-analysis/log-sources/cursor.ts \ + packages/server/src/work-analysis/log-sources/opencode.ts \ + packages/server/src/__tests__/work-analysis-log-collector.test.ts \ + packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts \ + packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts +git commit -m "refactor: collect provider work logs without workspace prefilter" +``` + +### Task 3: Move Workspace Path Filtering Into WorkAnalysisService And Analyzer + +**Files:** +- Modify: `packages/server/src/work-analysis/service.ts` +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` +- Modify: `packages/server/src/work-analysis/basic-schema.ts` +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/storage/repositories/work-analysis-repo.ts` +- Modify: `packages/server/src/__tests__/work-analysis-service.test.ts` +- Modify: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` +- Modify: `packages/server/src/__tests__/work-analysis-repo.test.ts` + +- [ ] **Step 1: Write the failing service test for result-level path filtering** + +```ts +it("filters collected sessions by workspacePaths after discovery", async () => { + const collector = vi.fn().mockResolvedValue({ + sessions: [ + { providerId: "codex", sessionId: "a", workspacePath: "/repo/a", startedAt: 1, lastActiveAt: 2, sourceRef: "a", userTurnCount: 1, assistantTurnCount: 1, toolUseCount: 0, parseErrorCount: 0, timestampQuality: "explicit" }, + { providerId: "codex", sessionId: "b", workspacePath: "/repo/b", startedAt: 3, lastActiveAt: 4, sourceRef: "b", userTurnCount: 1, assistantTurnCount: 1, toolUseCount: 0, parseErrorCount: 0, timestampQuality: "explicit" }, + ], + providers: [], + }); + + const service = buildService({ collector }); + const result = await service.runBasic({ workspacePaths: ["/repo/b"], timeRange: { preset: "7d" } }); + + expect(result.basicResult?.availableWorkspacePaths).toEqual(["/repo/a", "/repo/b"]); + expect(result.basicResult?.workSurface.workspacePaths).toEqual(["/repo/b"]); +}); +``` + +- [ ] **Step 2: Run service test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-service.test.ts` +Expected: FAIL because service still resolves `workspaceIds` and analyzer still emits workspace ids. + +- [ ] **Step 3: Write the failing analyzer test for path semantics** + +```ts +it("emits workspacePaths and availableWorkspacePaths", () => { + const result = analyzeWorkLogs({ + query: { workspacePaths: ["/repo/b"], timeRange: { preset: "7d" } }, + sessions: [sessionA, sessionB], + availableWorkspacePaths: ["/repo/a", "/repo/b"], + dataSources: { providers: [] }, + }); + + expect(result.availableWorkspacePaths).toEqual(["/repo/a", "/repo/b"]); + expect(result.workSurface.workspacePaths).toEqual(["/repo/b"]); +}); +``` + +- [ ] **Step 4: Run analyzer test to verify it fails** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-basic-analyzer.test.ts` +Expected: FAIL because result types still expose `workspaceIds`. + +- [ ] **Step 5: Implement service-level path filtering** + +```ts +const availableWorkspacePaths = [...new Set(collection.sessions.map((session) => session.workspacePath))] + .sort((left, right) => left.localeCompare(right)); + +const filteredSessions = + normalized.workspacePaths && normalized.workspacePaths.length > 0 + ? collection.sessions.filter((session) => normalized.workspacePaths!.includes(session.workspacePath)) + : collection.sessions; +``` + +- [ ] **Step 6: Rewrite analyzer result shape** + +```ts +return workBasicAnalysisResultSchema.parse({ + availableWorkspacePaths: [...input.availableWorkspacePaths], + workSurface: { + workspacePaths: + input.query.workspacePaths && input.query.workspacePaths.length > 0 + ? [...input.query.workspacePaths] + : [...input.availableWorkspacePaths], + }, + // existing summary fields... +}); +``` + +- [ ] **Step 7: Update repo normalization for the new result shape** + +```ts +...(record.basicResult === undefined + ? {} + : { + basicResult: { + ...record.basicResult, + availableWorkspacePaths: [...record.basicResult.availableWorkspacePaths], + workSurface: { + workspacePaths: [...record.basicResult.workSurface.workspacePaths], + }, + }, + }), +``` + +- [ ] **Step 8: Run focused server tests to verify they pass** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-service.test.ts src/__tests__/work-analysis-basic-analyzer.test.ts src/__tests__/work-analysis-repo.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add packages/server/src/work-analysis/service.ts \ + packages/server/src/work-analysis/basic-analyzer.ts \ + packages/server/src/work-analysis/basic-schema.ts \ + packages/server/src/work-analysis/types.ts \ + packages/server/src/storage/repositories/work-analysis-repo.ts \ + packages/server/src/__tests__/work-analysis-service.test.ts \ + packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts \ + packages/server/src/__tests__/work-analysis-repo.test.ts +git commit -m "refactor: filter work analysis by discovered workspace paths" +``` + +### Task 4: Rebuild Settings UI Around Result-Driven Path Multi-Select + +**Files:** +- Modify: `packages/web/src/features/work-analysis/types.ts` +- Modify: `packages/web/src/features/settings/components/session-analysis-settings.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing settings-page test for initial unfiltered request** + +```ts +it("requests work analysis without workspaceIds on first load", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") return {}; + if (op === "provider.list") return DEFAULT_PROVIDER_LIST; + if (op === "work.analysis.get") { + return { + id: "analysis-1", + queryDigest: "digest-1", + workspacePaths: undefined, + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + basicResult: { + availableWorkspacePaths: ["/repo/a", "/repo/b"], + // remaining required summary fields... + }, + }; + } + return {}; + }); + + renderSettingsPage(createConnectedStore(sendCommand), { initialEntry: "/settings?section=analysis" }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "work.analysis.get", + { timeRange: { preset: "7d" } }, + undefined + ); + }); +}); +``` + +- [ ] **Step 2: Run settings test to verify it fails** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx --testNamePattern "without workspaceIds on first load"` +Expected: FAIL because UI still sends `workspaceIds`. + +- [ ] **Step 3: Write the failing settings-page test for result-driven path options** + +```ts +it("renders workspace path filters from analysis results and re-queries with selected paths", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") return {}; + if (op === "provider.list") return DEFAULT_PROVIDER_LIST; + if (op === "work.analysis.get") { + return { + id: "analysis-1", + queryDigest: "digest-1", + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + basicResult: { + availableWorkspacePaths: ["/repo/a", "/repo/b"], + // remaining required summary fields... + }, + }; + } + return {}; + }); + + renderSettingsPage(createConnectedStore(sendCommand), { initialEntry: "/settings?section=analysis" }); + + expect(await screen.findByText("/repo/a")).toBeInTheDocument(); + expect(screen.getByText("/repo/b")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("/repo/a")); + + await waitFor(() => { + expect(sendCommand).toHaveBeenLastCalledWith( + "work.analysis.get", + { workspacePaths: ["/repo/b"], timeRange: { preset: "7d" } }, + undefined + ); + }); +}); +``` + +- [ ] **Step 4: Run the focused UI test to verify it fails** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx --testNamePattern "renders workspace path filters from analysis results"` +Expected: FAIL because UI still renders `orderedWorkspaces`. + +- [ ] **Step 5: Rewrite the work-analysis client types** + +```ts +export interface WorkBasicAnalysisResult { + availableWorkspacePaths: string[]; + workSurface: { + workspacePaths: string[]; + }; + // existing fields... +} +``` + +- [ ] **Step 6: Rewrite settings state around result-driven paths** + +```tsx +const [selectedWorkspacePaths, setSelectedWorkspacePaths] = useState([]); +const [hasCustomizedWorkspacePaths, setHasCustomizedWorkspacePaths] = useState(false); + +const query = useMemo(() => { + if (!timeRange) { + return null; + } + + return selectedWorkspacePaths.length > 0 && hasCustomizedWorkspacePaths + ? { workspacePaths: selectedWorkspacePaths, timeRange } + : { timeRange }; +}, [hasCustomizedWorkspacePaths, selectedWorkspacePaths, timeRange]); +``` + +- [ ] **Step 7: Populate path filters from analysis result and default to all** + +```tsx +useEffect(() => { + const availableWorkspacePaths = analysis?.basicResult?.availableWorkspacePaths ?? []; + if (hasCustomizedWorkspacePaths || availableWorkspacePaths.length === 0) { + return; + } + setSelectedWorkspacePaths(availableWorkspacePaths); +}, [analysis, hasCustomizedWorkspacePaths]); +``` + +- [ ] **Step 8: Update path filter copy** + +```json +"workspace": "Workspace Paths", +"empty_workspace_paths": "No provider log directories were found for the selected time range." +``` + +- [ ] **Step 9: Run the full settings-page test file** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx` +Expected: PASS + +- [ ] **Step 10: Commit** + +```bash +git add packages/web/src/features/work-analysis/types.ts \ + packages/web/src/features/settings/components/session-analysis-settings.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: filter work analysis by discovered workspace paths" +``` + +### Task 5: Add End-To-End Coverage For Undiscovered-By-App Workspaces + +**Files:** +- Modify: `e2e/fixtures/phase2-i18n.ts` +- Create: `e2e/fixtures/seed-work-analysis-settings-db.ts` +- Modify: `e2e/specs/settings/analysis.spec.ts` + +- [ ] **Step 1: Write the failing e2e assertion for multiple discovered paths** + +```ts +await expect(page.getByText("/tmp/path-a")).toBeVisible(); +await expect(page.getByText("/tmp/path-b")).toBeVisible(); +``` + +- [ ] **Step 2: Run the Playwright spec to verify it fails** + +Run: `pnpm --dir e2e exec playwright test specs/settings/analysis.spec.ts --config playwright.config.ts` +Expected: FAIL because the seed and UI only expose the currently opened workspace path. + +- [ ] **Step 3: Update the seed to write two provider-log-discovered paths** + +```ts +workAnalysisRepo.upsert({ + // ... + basicResult: { + availableWorkspacePaths: ["/tmp/path-a", "/tmp/path-b"], + workSurface: { workspacePaths: ["/tmp/path-a", "/tmp/path-b"] }, + // remaining required fields... + }, +}); +``` + +- [ ] **Step 4: Extend the e2e flow to narrow to a single path** + +```ts +await page.getByLabel("/tmp/path-a").uncheck(); +await expect( + page.getByText( + translatePatternForE2E("settings.analysis.log_coverage_summary", { + workspaceCount: "1", + sessionCount: "1", + providerCount: "1", + }) + ) +).toBeVisible(); +``` + +- [ ] **Step 5: Re-run the Playwright spec to verify it passes** + +Run: `pnpm --dir e2e exec playwright test specs/settings/analysis.spec.ts --config playwright.config.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add e2e/fixtures/phase2-i18n.ts \ + e2e/fixtures/seed-work-analysis-settings-db.ts \ + e2e/specs/settings/analysis.spec.ts +git commit -m "test: cover work analysis path filtering in settings e2e" +``` + +### Task 6: Run Final Verification + +**Files:** +- Modify: none +- Test: `packages/server/src/__tests__/work-analysis-*.test.ts` +- Test: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Test: `e2e/specs/settings/analysis.spec.ts` + +- [ ] **Step 1: Run focused server regression** + +Run: `pnpm --filter @coder-studio/server exec vitest run src/__tests__/work-analysis-query.test.ts src/__tests__/work-analysis-commands.test.ts src/__tests__/work-analysis-log-collector.test.ts src/__tests__/work-analysis-log-sources-file-adapters.test.ts src/__tests__/work-analysis-log-source-opencode.test.ts src/__tests__/work-analysis-basic-analyzer.test.ts src/__tests__/work-analysis-service.test.ts src/__tests__/work-analysis-repo.test.ts` +Expected: PASS + +- [ ] **Step 2: Run server typecheck** + +Run: `pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit` +Expected: PASS + +- [ ] **Step 3: Run focused web regression** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx` +Expected: PASS + +- [ ] **Step 4: Run the settings analysis Playwright spec** + +Run: `pnpm --dir e2e exec playwright test specs/settings/analysis.spec.ts --config playwright.config.ts` +Expected: PASS + +- [ ] **Step 5: Commit any final test-only adjustments** + +```bash +git add -A +git commit -m "test: finalize work analysis workspace path filter coverage" +``` diff --git a/docs/superpowers/plans/2026-06-05-work-analysis-protocol-first-alignment.md b/docs/superpowers/plans/2026-06-05-work-analysis-protocol-first-alignment.md new file mode 100644 index 000000000..501bd6347 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-work-analysis-protocol-first-alignment.md @@ -0,0 +1,372 @@ +# Work Analysis Protocol-First Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a backend-first V2 work-analysis snapshot contract and migrate the analytics UI to consume domain-oriented snapshot data while preserving the current basic analysis workflow. + +**Architecture:** Keep `WorkBasicAnalysisResult` as the transport envelope for this round, add a `snapshotV2` domain contract inside it, and derive V2 from the same normalized session/event inputs already used by `basic-analyzer`. Update the web analytics page to prefer `snapshotV2` domains for overview, breakdown, sessions, efficiency, optimize, delivery, capabilities, and data-source rendering, while retaining legacy fallbacks until the old fields can be deleted safely. + +**Tech Stack:** TypeScript, Zod, Vitest, React, Jotai, pnpm + +--- + +## File Map + +- Modify: `packages/server/src/work-analysis/types.ts` + Responsibility: define V2 domain snapshot interfaces and attach them to `WorkBasicAnalysisResult`. +- Modify: `packages/server/src/work-analysis/basic-schema.ts` + Responsibility: validate the new `snapshotV2` contract shape. +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` + Responsibility: derive `snapshotV2` domains from collected sessions and keep legacy fields populated. +- Modify: `packages/server/src/work-analysis/exporters/basic-export.ts` + Responsibility: ensure exported basic analysis includes V2 payload without special handling regressions. +- Modify: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` + Responsibility: cover V2 domain derivation on analyzer output. +- Modify: `packages/server/src/__tests__/work-analysis-service.test.ts` + Responsibility: cover service-level propagation of `snapshotV2`. +- Modify: `packages/web/src/features/work-analysis/types.ts` + Responsibility: mirror the server-side V2 contract in the web client types. +- Modify: `packages/web/src/features/work-analysis/page.tsx` + Responsibility: read V2 domains first, then fall back to legacy fields. +- Modify: `packages/web/src/features/work-analysis/page.test.tsx` + Responsibility: cover V2-first rendering paths and fallback behavior. + +### Task 1: Add The V2 Snapshot Contract + +**Files:** +- Modify: `packages/server/src/work-analysis/types.ts` +- Modify: `packages/server/src/work-analysis/basic-schema.ts` +- Modify: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` + +- [ ] **Step 1: Write the failing analyzer contract test** + +```ts +it("builds snapshotV2 domains from basic analysis inputs", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" }, workspacePaths: ["/repo/app"] }, + timeRange: { startAt: 1, endAt: 2, label: "Last 7 days" }, + availableWorkspacePaths: ["/repo/app", "/repo/lib"], + sessions: [buildSessionFixture()], + dataSources: { + providers: [ + { + providerId: "codex", + status: "supported", + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { installedSkills: [], mounts: [] }, + }); + + expect(result.snapshotV2?.version).toBe(2); + expect(result.snapshotV2?.query.availableWorkspacePaths).toEqual(["/repo/app", "/repo/lib"]); + expect(result.snapshotV2?.overview.totals.totalTokens).toBe(175); + expect(result.snapshotV2?.breakdowns.byWorkspace[0]?.label).toBe("/repo/app"); + expect(result.snapshotV2?.sessions.featured.topByTotalTokens[0]?.sessionId).toBe("session-1"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts -t "builds snapshotV2 domains from basic analysis inputs"` +Expected: FAIL with `snapshotV2` missing from `WorkBasicAnalysisResult` + +- [ ] **Step 3: Add V2 types in the server contract** + +```ts +export interface WorkBasicAnalysisResultV2 { + version: 2; + query: { + timeRangeLabel: string; + selectedWorkspacePaths: string[]; + availableWorkspacePaths: string[]; + }; + overview: WorkAnalysisOverviewDomain; + breakdowns: WorkAnalysisBreakdownsDomain; + sessions: WorkAnalysisSessionsDomain; + efficiency: WorkAnalysisEfficiencyDomain; + optimize: WorkAnalysisOptimizeDomain; + delivery: WorkAnalysisDeliveryDomain; + capabilities: WorkAnalysisCapabilitiesDomain; + dataSources: WorkAnalysisDataSourcesDomain; + exports: WorkAnalysisExportsDomain; +} + +export interface WorkBasicAnalysisResult { + availableWorkspacePaths: string[]; + snapshotV2?: WorkBasicAnalysisResultV2; + // existing legacy fields remain in place during migration +} +``` + +- [ ] **Step 4: Extend the Zod schema for `snapshotV2`** + +```ts +const snapshotV2Schema = z.object({ + version: z.literal(2), + query: z.object({ + timeRangeLabel: z.string(), + selectedWorkspacePaths: z.array(z.string()), + availableWorkspacePaths: z.array(z.string()), + }), + overview: overviewDomainSchema, + breakdowns: breakdownsDomainSchema, + sessions: sessionsDomainSchema, + efficiency: efficiencyDomainSchema, + optimize: optimizeDomainSchema, + delivery: deliveryDomainSchema, + capabilities: capabilitiesDomainSchema, + dataSources: dataSourcesDomainSchema, + exports: exportsDomainSchema, +}); + +export const workBasicAnalysisResultSchema = z.object({ + availableWorkspacePaths: z.array(z.string()), + snapshotV2: snapshotV2Schema.optional(), + // existing legacy schema continues below +}); +``` + +- [ ] **Step 5: Run the analyzer test to verify it now reaches implementation failure** + +Run: `pnpm vitest packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts -t "builds snapshotV2 domains from basic analysis inputs"` +Expected: FAIL on `snapshotV2` assertions rather than schema/type errors + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/work-analysis/types.ts packages/server/src/work-analysis/basic-schema.ts packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts docs/superpowers/plans/2026-06-05-work-analysis-protocol-first-alignment.md +git commit -m "test: define work analysis snapshot v2 contract" +``` + +### Task 2: Derive V2 Domains In The Analyzer And Service + +**Files:** +- Modify: `packages/server/src/work-analysis/basic-analyzer.ts` +- Modify: `packages/server/src/work-analysis/exporters/basic-export.ts` +- Modify: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` +- Modify: `packages/server/src/__tests__/work-analysis-service.test.ts` + +- [ ] **Step 1: Add failing analyzer and service expectations for populated V2 domains** + +```ts +expect(result.snapshotV2?.overview.activity.byDay[0]).toMatchObject({ + day: "2026-06-01", + sessionCount: 1, +}); +expect(result.snapshotV2?.delivery.budgets.forecast30d).toBe(0); +expect(result.snapshotV2?.capabilities.providers[0]?.providerId).toBe("codex"); +expect(serviceResult.basicResult?.snapshotV2?.dataSources.providers[0]?.status).toBe("supported"); +``` + +- [ ] **Step 2: Run the focused server tests and confirm failure** + +Run: `pnpm vitest packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts` +Expected: FAIL because `snapshotV2` is absent or incomplete + +- [ ] **Step 3: Implement V2 derivation in `basic-analyzer.ts`** + +```ts +const snapshotV2 = { + version: 2 as const, + query: { + timeRangeLabel: input.timeRange.label, + selectedWorkspacePaths: input.query.workspacePaths ?? [], + availableWorkspacePaths: [...input.availableWorkspacePaths], + }, + overview: buildOverviewDomain(...), + breakdowns: buildBreakdownsDomain(...), + sessions: buildSessionsDomain(...), + efficiency: buildEfficiencyDomain(...), + optimize: buildOptimizeDomain(...), + delivery: buildDeliveryDomain(...), + capabilities: buildCapabilitiesDomain(...), + dataSources: buildDataSourcesDomain(...), + exports: { artifactFormats: ["json", "csv"] }, +}; + +return workBasicAnalysisResultSchema.parse({ + availableWorkspacePaths: [...input.availableWorkspacePaths], + snapshotV2, + // existing legacy payload remains populated here +}); +``` + +- [ ] **Step 4: Keep exporter and service behavior aligned** + +```ts +export function buildBasicAnalysisExports(result: WorkBasicAnalysisResult, generatedAt: number) { + const payload = { + version: 1 as const, + exportedAt: generatedAt, + result, + }; + + return { + generatedAt, + artifacts: [ + buildJsonArtifact(payload), + buildCsvArtifact(result.snapshotV2 ?? result), + ], + }; +} +``` + +- [ ] **Step 5: Run focused server tests to verify pass** + +Run: `pnpm vitest packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/server/src/work-analysis/basic-analyzer.ts packages/server/src/work-analysis/exporters/basic-export.ts packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts +git commit -m "feat: derive work analysis snapshot v2 domains" +``` + +### Task 3: Migrate The Analytics Page To V2-First Consumption + +**Files:** +- Modify: `packages/web/src/features/work-analysis/types.ts` +- Modify: `packages/web/src/features/work-analysis/page.tsx` +- Modify: `packages/web/src/features/work-analysis/page.test.tsx` + +- [ ] **Step 1: Add a failing page test for V2-first rendering** + +```tsx +it("prefers snapshotV2 domain data when present", async () => { + renderWorkAnalyticsPage({ + basicResult: { + availableWorkspacePaths: ["/repo/project", "/repo/lib"], + snapshotV2: { + version: 2, + query: { + timeRangeLabel: "Last 7 days", + selectedWorkspacePaths: ["/repo/project"], + availableWorkspacePaths: ["/repo/project", "/repo/lib"], + }, + overview: { + totals: { totalTokens: 9148820, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, reasoningOutputTokens: 0, sessionCount: 397, workspaceCount: 4, providerCount: 2, taskTypeCount: 6 }, + activity: { totalDurationMs: 1, averageDurationMs: 1, byDay: [], byHour: [] }, + shares: { topDimension: "workspace", items: [] }, + coverage: { sessionCount: 397, workspaceCount: 4, providerCount: 2, timeRangeLabel: "Last 7 days" }, + }, + breakdowns: { byWorkspace: [], byProvider: [], byModel: [], byTask: [], byTool: [], byCommand: [] }, + sessions: { featured: { topByTotalTokens: [], topByOutputTokens: [], lowYield: [], longest: [], latest: [] } }, + efficiency: { overall: { sessionCount: 397, averageTokensPerSession: 23045, averageInputTokensPerSession: 0, averageOutputTokensPerSession: 0, averageTokensPerToolUse: 0, commandSessionRate: 0, cacheParticipationRate: 0, editSignalCoverageRate: 0, highTokenSessionRate: 0, toolHeavySessionCount: 0, oneShotRate: 0, retryRate: 0, selfCorrectionRate: 0, readToEditRatio: 0, commandToEditRatio: 0, cacheHitShare: 0, gitAwareSessionRate: 0 }, byProvider: [], byTask: [] }, + optimize: { totalFindings: 0, totalEstimatedWastedTokens: 0, findings: [] }, + delivery: { yield: { sessionCount: 0, shippedSessionCount: 0, shippedSessionRate: 0, editSessionCount: 0, commandSessionCount: 0, gitSessionCount: 0, artifactSessionCount: 0, shippedTokens: 0, shippedTokenShare: 0, averageTokensPerShippedSession: 0, averageTokensPerNonShippedSession: 0, outputToInputRatio: 0, artifactSignalPerThousandTokens: 0, gitAwareSessionRate: 0 }, budgets: { thresholds: [], forecast30d: 0, totalTokens: 9148820 } }, + capabilities: { providers: [], skillInventory: { installedCount: 0, mountedCount: 0, unmountedCount: 0 } }, + dataSources: { providers: [] }, + exports: { artifactFormats: ["json", "csv"] }, + }, + }, + }); + + expect(await screen.findByText(/9,148,820/i)).toBeInTheDocument(); + expect(screen.getByText("/repo/lib")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the page test and verify it fails** + +Run: `pnpm vitest packages/web/src/features/work-analysis/page.test.tsx -t "prefers snapshotV2 domain data when present"` +Expected: FAIL because the page only reads legacy fields + +- [ ] **Step 3: Add web-side V2 types and page selectors** + +```ts +const snapshotV2 = basicResult?.snapshotV2; +const overview = snapshotV2?.overview; +const breakdowns = snapshotV2?.breakdowns; +const delivery = snapshotV2?.delivery; +const sessions = snapshotV2?.sessions; +const capabilities = snapshotV2?.capabilities; +const dataSources = snapshotV2?.dataSources; + +const availableWorkspacePaths = + snapshotV2?.query.availableWorkspacePaths ?? basicResult?.availableWorkspacePaths ?? []; +const compareWorkspaces = + breakdowns?.byWorkspace.map(toWorkspaceRankingEntry) ?? + legacyCompareWorkspaces; +const topTokenSessions = + sessions?.featured.topByTotalTokens ?? basicResult?.usage?.topSessionsByTotalTokens ?? []; +``` + +- [ ] **Step 4: Keep compatibility fallback paths intact** + +```ts +const totalTokens = + overview?.totals.totalTokens ?? basicResult?.usage?.totals?.totalTokens ?? 0; +const yieldSummary = delivery?.yield ?? basicResult?.yield; +const budgets = delivery?.budgets ?? basicResult?.budgets; +const providerCapabilities = capabilities?.providers ?? basicResult?.capabilityMatrix?.providers ?? []; +const providerSources = dataSources?.providers ?? basicResult?.dataSources?.providers ?? []; +``` + +- [ ] **Step 5: Run focused web tests to verify pass** + +Run: `pnpm vitest packages/web/src/features/work-analysis/page.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/web/src/features/work-analysis/types.ts packages/web/src/features/work-analysis/page.tsx packages/web/src/features/work-analysis/page.test.tsx +git commit -m "feat: consume work analysis snapshot v2 in analytics page" +``` + +### Task 4: Focused Verification And Real-Data Validation + +**Files:** +- Modify as needed: `packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts` +- Modify as needed: `packages/web/src/features/work-analysis/page.test.tsx` + +- [ ] **Step 1: Run focused automated verification** + +Run: `pnpm vitest packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/server/src/__tests__/work-analysis-service.test.ts packages/web/src/features/work-analysis/page.test.tsx` +Expected: PASS + +- [ ] **Step 2: Run broader repository checks that are relevant to the touched surface** + +Run: `pnpm ci:test -- --runInBand` +Expected: PASS for touched areas, or document unrelated pre-existing failures clearly if repo debt blocks full green + +- [ ] **Step 3: Run the app against real local logs and verify V2 data appears** + +```bash +pnpm dev +``` + +Expected: +- workspace filter lists all discovered log workspaces, not just opened workspaces +- overview token totals reflect real logs +- rankings show real provider/workspace/task splits +- top sessions and token-focused cards render from V2 domains + +- [ ] **Step 4: Capture acceptance screenshots after real-data validation** + +Run: `pnpm exec playwright test e2e/specs/settings/analysis.spec.ts --project=chromium` +Expected: PASS, with updated screenshots showing the full analytics page sections instead of a cropped single viewport + +- [ ] **Step 5: Commit verification-only follow-ups if needed** + +```bash +git add packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts packages/web/src/features/work-analysis/page.test.tsx +git commit -m "test: cover work analysis snapshot v2 verification" +``` + +## Self-Review + +- Spec coverage: + - unified V2 snapshot contract: Task 1 + - backend domain derivation: Task 2 + - frontend domain-based consumption: Task 3 + - verification and real-data validation: Task 4 +- Placeholder scan: + - No `TODO` or deferred implementation placeholders left in tasks. +- Type consistency: + - `snapshotV2`, `overview`, `breakdowns`, `sessions`, `delivery`, `capabilities`, `dataSources`, and `exports` are named consistently across server and web tasks. diff --git a/docs/superpowers/plans/2026-06-06-system-agent-instructions.md b/docs/superpowers/plans/2026-06-06-system-agent-instructions.md new file mode 100644 index 000000000..d149a3de2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-system-agent-instructions.md @@ -0,0 +1,433 @@ +# System Agent Instructions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the Agent panel manage both the workspace-local `agent.md` and a controlled allowlist of provider-level system `agent.md` files. + +**Architecture:** Keep `.coder-studio/agent.md` as the project source of truth, but relabel it in the UI as `项目 Agent.md`. Add a separate system-file flow for a small backend allowlist of global agent instruction paths, with virtual editor paths like `agent-system:codex` so the editor can open, save, and conflict-check them without treating them as workspace files. The panel renders both groups, the server owns path resolution and creation, and the editor routes system paths through dedicated `agentInstructions.system.*` commands. + +**Tech Stack:** TypeScript, Node `fs/promises`, Vitest, React Testing Library, Jotai, existing workspace/editor command architecture + +--- + +## File Map + +- Modify: `packages/server/src/commands/agent-instructions.ts` + Adds the system allowlist commands, path resolution, scaffold creation, and conflict-aware write logic. +- Modify: `packages/server/src/__tests__/agent-instructions-command.test.ts` + Covers system-file read/write/status behavior, unsupported providers, scaffold creation, and conflict handling. +- Modify: `packages/server/src/fs/file-io.ts` + May need a small helper or test coverage update if the new system-file writer reuses low-level conflict checks. +- Modify: `packages/core/src/domain/types.ts` + Extends the agent-instructions document/status types to represent system provider entries and display paths. +- Modify: `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` + Adds project-vs-system orchestration, system status loading, and edit/open actions for provider files. +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` + Renders `项目 Agent.md` plus a new `系统 Agent.md` group with provider rows and edit buttons. +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` + Updates the panel tests for the new grouping, labels, and system-provider actions. +- Modify: `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts` + Routes `agent-system:*` reads/writes through the new system commands and keeps save/reconcile working. +- Modify: `packages/web/src/features/code-editor/views/shared/code-editor-host.tsx` + Shows the real display path for system files instead of the virtual path in the title chrome. +- Modify: `packages/web/src/features/code-editor/index.test.tsx` + Adds coverage for system file open/save, display labels, and late refresh behavior. +- Modify: `packages/web/src/features/code-editor/monaco/model-registry.ts` + May need a minimal path-key or language-routing adjustment if the virtual path is used as the Monaco key. +- Modify: `packages/web/src/features/code-editor/monaco/uri.ts` + May need a small helper for mapping `agent-system:*` to a stable Monaco URI scheme or to keep virtual paths isolated from workspace-file URIs. +- Modify: `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx` + Confirms the new Agent panel wording is visible on mobile surfaces too. +- Modify: `packages/web/src/locales/en.json` + Adds the new project/system labels, provider-row copy, and error strings. +- Modify: `packages/web/src/locales/zh.json` + Adds the same new strings for the Chinese locale. + +## Guardrails + +- Keep the existing workspace-local `agent.md` behavior; this feature is additive in behavior and mostly a UI rename for that group. +- Do not expose a generic external file browser or arbitrary `$HOME` editor. +- Keep the system allowlist finite and server-owned. Frontend code should pass `providerId`, not an absolute path. +- Cursor stays unsupported for direct file editing unless a stable Markdown path is discovered later. +- Missing supported system files should be created with a scaffold before opening, not by silently opening a blank buffer. +- Save operations must keep `baseHash` conflict detection. +- System-file writes must not emit workspace `fs.dirty`. +- The editor title should show the real filesystem path (`~/.codex/AGENTS.md`), while the internal open-file key can remain virtual. + +### Task 1: Add the system provider metadata and server commands + +**Files:** +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/commands/agent-instructions.ts` +- Modify: `packages/server/src/__tests__/agent-instructions-command.test.ts` + +- [x] **Step 1: Write the failing command tests** + +Add tests for the new system flow before changing production code. + +```ts +it("reads a missing system agent file as empty content with a display path", async () => { + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-read", + op: "agentInstructions.system.read", + args: { + workspaceId: "ws-1", + providerId: "codex", + }, + }, + createContext(null) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: false, + content: "", + }); +}); +``` + +Add a write test that creates the file with conflict detection: + +```ts +it("creates a missing system agent file before opening it", async () => { + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-write", + op: "agentInstructions.system.write", + args: { + workspaceId: "ws-1", + providerId: "claude", + content: "# Agent Instructions\n\n## Personal Defaults\n- Be concise.\n", + }, + }, + createContext(null) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId: "claude", + path: ".claude/CLAUDE.md", + displayPath: "~/.claude/CLAUDE.md", + exists: true, + }); +}); +``` + +Add a conflict test and an unsupported-provider test: + +```ts +it("rejects stale baseHash writes for system agent files", async () => { + // seed an initial file, then call write with a stale baseHash +}); + +it("rejects unsupported system providers instead of inventing a path", async () => { + // providerId: "cursor" => agent_system_instructions_unsupported +}); +``` + +- [x] **Step 2: Run the server tests and verify they fail for the right reason** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-command.test.ts +``` + +Expected: the new system-agent assertions fail because no `agentInstructions.system.*` commands exist yet. + +- [x] **Step 3: Add the provider allowlist and system document types** + +Extend `packages/core/src/domain/types.ts` with system-provider document metadata instead of overloading the workspace-local `.coder-studio/agent.md` shape. Keep the project document type intact, and add a separate system result shape that includes `providerId`, `path`, `displayPath`, `exists`, `content`, and `baseHash`. + +- [x] **Step 4: Implement the new server commands** + +Add a small allowlist resolver in `packages/server/src/commands/agent-instructions.ts` with these v1 mappings: + +```ts +const SYSTEM_AGENT_INSTRUCTIONS = { + codex: { relPath: ".codex/AGENTS.md", displayPath: "~/.codex/AGENTS.md" }, + claude: { relPath: ".claude/CLAUDE.md", displayPath: "~/.claude/CLAUDE.md" }, + gemini: { relPath: ".gemini/GEMINI.md", displayPath: "~/.gemini/GEMINI.md" }, + opencode: { relPath: ".config/opencode/AGENTS.md", displayPath: "~/.config/opencode/AGENTS.md" }, +} as const; +``` + +Add three commands: + +```ts +registerCommand("agentInstructions.system.status", ...) +registerCommand("agentInstructions.system.read", ...) +registerCommand("agentInstructions.system.write", ...) +``` + +Behavior: + +- `status` returns one row per built-in provider, including unsupported Cursor. +- `read` returns empty content and `exists: false` when the file is absent. +- `write` creates parent directories, writes a scaffold or user content, and returns a fresh hash. +- `write` checks `baseHash` if the caller provides one and throws `conflict` when hashes differ. +- Unsupported providers throw `agent_system_instructions_unsupported` from read/write. +- Do not emit `fs.dirty` for these writes. + +Use a shared helper so `read` and `write` agree on the returned path/displayPath shape. + +- [x] **Step 5: Re-run the server tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-command.test.ts +``` + +Expected: PASS + +### Task 2: Wire the system files into the editor open/save flow + +**Files:** +- Modify: `packages/web/src/features/code-editor/actions/use-code-editor-actions.ts` +- Modify: `packages/web/src/features/code-editor/views/shared/code-editor-host.tsx` +- Modify: `packages/web/src/features/code-editor/index.test.tsx` +- Modify: `packages/web/src/features/code-editor/monaco/model-registry.ts` +- Modify: `packages/web/src/features/code-editor/monaco/uri.ts` + +- [ ] **Step 1: Write the failing editor tests** + +Add coverage that opening `agent-system:codex` loads from `agentInstructions.system.read`, saving goes through `agentInstructions.system.write`, and the editor header shows `~/.codex/AGENTS.md` rather than the virtual path. + +Representative assertions: + +```ts +expect(sendCommand).toHaveBeenCalledWith( + "agentInstructions.system.read", + { + workspaceId: "ws-1", + providerId: "codex", + }, + undefined +); +expect(screen.getByText("~/.codex/AGENTS.md")).toBeInTheDocument(); +``` + +Also add a stale-refresh case that confirms the editor keeps system-file buffers keyed by the virtual path while the display label stays the real path. + +- [ ] **Step 2: Run the editor tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/code-editor/index.test.tsx +``` + +Expected: FAIL because `useCodeEditorActions` still routes everything through `file.read` / `file.write`. + +- [ ] **Step 3: Add the system-path router in the editor actions** + +Teach `useCodeEditorActions` to detect `agent-system:` paths. + +```ts +function parseSystemAgentPath(path: string): { providerId: string; virtualPath: string } | null { + if (!path.startsWith("agent-system:")) return null; + return { providerId: path.slice("agent-system:".length), virtualPath: path }; +} +``` + +For these paths: + +- `loadFile` dispatches `agentInstructions.system.read` +- `handleSave` dispatches `agentInstructions.system.write` +- reconcile-on-refresh uses the same read command +- the open-file record should retain the virtual path as its stable key + +Keep the existing text-file behavior for workspace files unchanged. + +- [ ] **Step 4: Show the real display path in the editor chrome** + +Add a display-path field to the open-file model or derived editor state so `CodeEditorView` can render `~/.codex/AGENTS.md` while the active buffer path stays `agent-system:codex`. + +A minimal shape is fine: + +```ts +interface OpenTextFile { + kind: "text"; + path: string; + displayPath?: string; + // existing fields... +} +``` + +Use that display path in the title/header code path for system files only. + +- [ ] **Step 5: Re-run the editor tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/code-editor/index.test.tsx +``` + +Expected: PASS + +### Task 3: Rework the Agent panel into project and system groups + +**Files:** +- Modify: `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` +- Modify: `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing panel tests** + +Update the section tests so they expect: + +- the project group title to read `项目 Agent.md` +- the old generic `agent.md` label to disappear from the group heading +- a new `系统 Agent.md` group +- one row each for Codex, Claude Code, Gemini CLI, OpenCode, and an unsupported Cursor row +- edit buttons for supported system providers that call the new open flow + +Example row expectations: + +```ts +expect(screen.getByText("系统 Agent.md")).toBeInTheDocument(); +expect(screen.getByRole("button", { name: "Edit ~/.codex/AGENTS.md" })).toBeInTheDocument(); +expect(screen.getByText("Cursor")).toBeInTheDocument(); +expect(screen.getByText(/managed through Cursor Settings > Rules/i)).toBeInTheDocument(); +``` + +Also update the mobile test to assert that the new group copy is still visible in the compact workspace shell. + +- [ ] **Step 2: Run the panel tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx +``` + +Expected: FAIL because the UI still only renders the workspace-local `agent.md` block. + +- [ ] **Step 3: Add system-status loading and provider rows to the action hook** + +Extend `useAgentInstructionsActions` so it loads the new `agentInstructions.system.status` data alongside the existing workspace status. Keep the current project generation/edit behavior, but add new actions like `openSystemAgent(providerId)` and `editSystemAgent(providerId)` that: + +- call `agentInstructions.system.read` first when the file is missing +- write a scaffold when needed +- open `agent-system:` through `useOpenLocation` + +Keep provider filtering honest: unsupported providers should surface as non-editable rows rather than hidden entries. + +- [ ] **Step 4: Rebuild the section UI around two explicit groups** + +Render the project block as `项目 Agent.md` with the same status/generate/regenerate/edit flow it has today. + +Render a second `系统 Agent.md` block with rows for each allowlisted provider, including: + +- provider name +- status chip (`Ready`, `Missing`, `Unsupported`, or `Error`) +- real display path for supported providers +- edit button for supported providers only + +Keep the layout dense and utilitarian. Do not turn the rows into oversized cards. + +- [ ] **Step 5: Update locale strings** + +Add explicit strings for: + +- `项目 Agent.md` +- `系统 Agent.md` +- provider display names +- unsupported Cursor copy +- system-file edit/open labels +- missing-file scaffold messaging +- system command error messages + +Be consistent in `en.json` and `zh.json`. + +- [ ] **Step 6: Re-run the panel tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx +``` + +Expected: PASS + +### Task 4: Tighten URI/model handling and do a full regression pass + +**Files:** +- Modify: `packages/web/src/features/code-editor/monaco/uri.ts` +- Modify: `packages/web/src/features/code-editor/monaco/model-registry.ts` +- Modify: `packages/web/src/features/code-editor/index.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` +- Modify: `packages/server/src/__tests__/agent-instructions-command.test.ts` + +- [ ] **Step 1: Add a final red-green regression test for conflict and refresh behavior** + +Add one test that opens a missing system file, writes a scaffold, then simulates an external edit and confirms the next save fails with `conflict` rather than silently overwriting. + +- [ ] **Step 2: Run the focused regression tests and verify the exact failure mode** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-command.test.ts +pnpm --filter @coder-studio/web test -- src/features/code-editor/index.test.tsx src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: any failure should be a concrete missing behavior, not a type error or a path-mapping mistake. + +- [ ] **Step 3: Adjust URI/model helpers only as needed** + +If `agent-system:*` needs a dedicated Monaco URI scheme or a display-path helper, keep the change minimal and local: + +- do not pollute workspace-file URIs +- do not attach LSP to system files unless there is a clear, tested reason +- keep Monaco model keys stable across reopen/save cycles + +- [ ] **Step 4: Run the full targeted test set** + +Run: + +```bash +pnpm --filter @coder-studio/server test -- src/__tests__/agent-instructions-command.test.ts +pnpm --filter @coder-studio/web test -- src/features/code-editor/index.test.tsx src/features/workspace/views/shared/agent-instructions-section.test.tsx src/features/workspace/views/mobile/workspace-mobile-view.test.tsx +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/commands/agent-instructions.ts \ + packages/server/src/__tests__/agent-instructions-command.test.ts \ + packages/core/src/domain/types.ts \ + packages/web/src/features/code-editor/actions/use-code-editor-actions.ts \ + packages/web/src/features/code-editor/views/shared/code-editor-host.tsx \ + packages/web/src/features/code-editor/index.test.tsx \ + packages/web/src/features/code-editor/monaco/model-registry.ts \ + packages/web/src/features/code-editor/monaco/uri.ts \ + packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts \ + packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx \ + packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx \ + packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json + +git commit -m "feat: manage system agent instructions" +``` + +## Self-Review + +- Spec coverage: project agent UI rename, new system allowlist, server-owned path resolution, virtual editor paths, scaffold creation, conflict detection, unsupported Cursor handling, and mobile/shared UI coverage all have explicit tasks. +- Placeholder scan: no `TBD` / `TODO` / vague steps remain. +- Type consistency: project document types remain separate from system file payloads, and the editor path key is kept distinct from the display path. +- Scope check: this stays inside one feature slice rather than expanding into a general external file manager. diff --git a/docs/superpowers/plans/2026-06-06-work-analysis-hourly-dashboard-redesign.md b/docs/superpowers/plans/2026-06-06-work-analysis-hourly-dashboard-redesign.md new file mode 100644 index 000000000..435f4a5f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-work-analysis-hourly-dashboard-redesign.md @@ -0,0 +1,54 @@ +# Work Analysis Hourly Dashboard Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land a first working version of the work analysis dashboard with cached dashboard projection, manual refresh, automatic hourly scheduling, a full-width token trend, and three token contribution rankings. + +**Architecture:** Add a dashboard projection contract beside the existing `runBasic` path. Reuse current provider log collection and basic analysis derivation for the first slice, but persist dashboard cache and scan state separately so the UI can load without requiring users to run analysis first. + +**Tech Stack:** TypeScript, Zod, Vitest, React, Jotai, CSS-in-TS inline styles, pnpm + +--- + +## File Map + +- Modify: `packages/server/src/work-analysis/types.ts` + - Add dashboard query, projection, scan state, KPI, trend, ranking, and quality types. +- Modify: `packages/server/src/storage/repositories/work-analysis-repo.ts` + - Persist dashboard cache alongside existing query records. +- Modify: `packages/server/src/work-analysis/service.ts` + - Add `getDashboard`, `refreshDashboard`, and auto-scan scheduling. +- Modify: `packages/server/src/commands/work-analysis.ts` + - Register `work.analysis.dashboard.get` and `work.analysis.dashboard.refresh`. +- Modify: `packages/server/src/__tests__/work-analysis-service.test.ts` + - Add dashboard refresh/cache tests. +- Modify: `packages/web/src/features/work-analysis/types.ts` + - Mirror dashboard types. +- Modify: `packages/web/src/features/work-analysis/use-work-analysis-controller.ts` + - Load dashboard projection and expose refresh state. +- Modify: `packages/web/src/features/work-analysis/page.tsx` + - Replace current report/tab UI with flat dashboard layout. +- Modify: `packages/web/src/features/work-analysis/page.test.tsx` + - Assert full-width trend and three contribution rankings render. + +## Tasks + +- [ ] Write failing service tests for dashboard refresh, cache read, and failure preservation. +- [ ] Implement dashboard cache persistence in `WorkAnalysisRepo`. +- [ ] Implement dashboard projection builder in `WorkAnalysisService` using current basic analysis output. +- [ ] Add dashboard commands. +- [ ] Write failing page test for token trend plus project/model/agent contribution rankings. +- [ ] Replace the work analysis page with the approved flat dashboard layout. +- [ ] Add controller support for dashboard get/refresh. +- [ ] Run focused server and web tests. +- [ ] Run typecheck/build if available. + +## Verification Commands + +```bash +pnpm vitest packages/server/src/__tests__/work-analysis-service.test.ts +pnpm vitest packages/web/src/features/work-analysis/page.test.tsx +pnpm -w test -- --runInBand +``` + +If the workspace test runner does not support the final aggregate command, use the focused server/web commands plus the package build commands reported in `package.json`. diff --git a/docs/superpowers/plans/2026-06-07-agent-token-trend.md b/docs/superpowers/plans/2026-06-07-agent-token-trend.md new file mode 100644 index 000000000..7dc354684 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-agent-token-trend.md @@ -0,0 +1,852 @@ +# Agent Token Trend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a compact 24-hour token consumption trend chart at the top of the expanded AGENT.MD sidebar panel. + +**Architecture:** Keep the feature frontend-only by querying the existing `work.analysis.dashboard.get` command with the current workspace path and `{ preset: "24h" }`. Add a focused `AgentInstructionsTokenTrend` child component beside the existing Agent.md panel component, and let `AgentInstructionsSection` render it before the project/system Agent.md groups. Use ECharts already present in `@coder-studio/web`; no new dependency or server command is needed. + +**Tech Stack:** React 19, Jotai command dispatch atom, ECharts, Vitest, Testing Library, existing CSS token system. + +--- + +## File Structure + +- Create `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx`: self-contained chart component that loads 24h work-analysis data, normalizes totals, renders loading/ready/empty/error states, and manages ECharts lifecycle. +- Create `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx`: focused tests for dispatch payload, data rendering, empty state, and error state. +- Modify `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx`: import and render `AgentInstructionsTokenTrend` as the first expanded body block when `workspace?.path` exists. +- Modify `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx`: add the token trend translation keys, mock the child component, and assert placement before the project Agent.md group. +- Modify `packages/web/src/styles/components.css`: add token-backed classes for the compact trend block and responsive behavior. +- Modify `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: add user-facing labels for title, subtitle, empty/error/loading, total, peak, and sessions. + +--- + +### Task 1: Token Trend Component Behavior + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx` +- Create: `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx` + +- [ ] **Step 1: Write the failing component tests** + +Create `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { AgentInstructionsTokenTrend } from "./agent-instructions-token-trend"; + +const echartsMock = vi.hoisted(() => { + const chart = { + dispose: vi.fn(), + resize: vi.fn(), + setOption: vi.fn(), + }; + return { + chart, + init: vi.fn(() => chart), + }; +}); + +vi.mock("echarts", () => ({ + init: echartsMock.init, +})); + +vi.mock("../../../../lib/i18n", () => ({ + useTranslation: () => (key: string, params?: Record) => { + const translations: Record = { + "workspace.agent_instructions.token_trend.title": "Token trend", + "workspace.agent_instructions.token_trend.subtitle": "Current project · Last 24 hours", + "workspace.agent_instructions.token_trend.loading": "Loading token trend...", + "workspace.agent_instructions.token_trend.empty": "No token data in the last 24 hours.", + "workspace.agent_instructions.token_trend.error": "Token trend unavailable.", + "workspace.agent_instructions.token_trend.total": "Total {value}", + "workspace.agent_instructions.token_trend.peak": "Peak {value}/h", + "workspace.agent_instructions.token_trend.sessions": "{count} sessions", + "workspace.agent_instructions.token_trend.chart_label": + "Token consumption trend for the current project over the last 24 hours", + }; + + return (translations[key] ?? key) + .replace("{value}", String(params?.value ?? "")) + .replace("{count}", String(params?.count ?? "")); + }, +})); + +function createStoreWithDispatch(dispatch: ReturnType) { + const store = createStore(); + store.set(wsClientAtom, { + sendCommand: dispatch, + subscribe: vi.fn(() => () => {}), + } as never); + return store; +} + +function buildDashboard(tokenHourly = [ + { + hourStart: Date.UTC(2026, 5, 7, 8), + inputTokens: 1000, + outputTokens: 500, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 1500, + sessionCount: 1, + activeDurationMs: 60_000, + }, + { + hourStart: Date.UTC(2026, 5, 7, 9), + inputTokens: 2000, + outputTokens: 1000, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 3000, + sessionCount: 2, + activeDurationMs: 120_000, + }, +]) { + return { + ok: true, + data: { + version: 1, + queryDigest: "digest", + query: { + workspacePaths: ["/repo/project"], + timeRange: { preset: "24h" }, + }, + mode: "manual", + requestedAt: Date.UTC(2026, 5, 7, 10), + scanState: { + mode: "manual", + status: "succeeded", + providerStatuses: [], + }, + dashboard: { + generatedAt: Date.UTC(2026, 5, 7, 10), + timeRange: { + startAt: Date.UTC(2026, 5, 6, 10), + endAt: Date.UTC(2026, 5, 7, 10), + label: "24h", + }, + filters: { + workspacePaths: ["/repo/project"], + timeRange: { preset: "24h" }, + }, + kpis: [], + trends: { + tokenHourly, + tokenDaily: [], + hourHeatmap: [], + }, + rankings: { + projects: [], + models: [], + agents: [], + }, + breakdowns: { + tasks: [], + tools: [], + }, + quality: { + providers: [], + warnings: [], + }, + }, + }, + }; +} + +function renderTrend(dispatch: ReturnType, workspacePath = "/repo/project") { + render( + + + + ); +} + +describe("AgentInstructionsTokenTrend", () => { + beforeEach(() => { + echartsMock.init.mockClear(); + echartsMock.chart.dispose.mockClear(); + echartsMock.chart.resize.mockClear(); + echartsMock.chart.setOption.mockClear(); + }); + + it("loads a 24h token trend for the current workspace path", async () => { + const dispatch = vi.fn(async () => buildDashboard()); + + renderTrend(dispatch); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith("work.analysis.dashboard.get", { + workspacePaths: ["/repo/project"], + timeRange: { preset: "24h" }, + }); + }); + expect(await screen.findByText("Total 4.5k")).toBeInTheDocument(); + expect(screen.getByText("Peak 3k/h")).toBeInTheDocument(); + expect(screen.getByText("3 sessions")).toBeInTheDocument(); + expect(screen.getByTestId("agent-token-trend-chart")).toBeInTheDocument(); + }); + + it("initializes ECharts with hourly token data", async () => { + const dispatch = vi.fn(async () => buildDashboard()); + + renderTrend(dispatch); + + await waitFor(() => { + expect(echartsMock.init).toHaveBeenCalled(); + expect(echartsMock.chart.setOption).toHaveBeenCalled(); + }); + + const option = echartsMock.chart.setOption.mock.calls.at(-1)?.[0] as { + series?: Array<{ data?: Array<[number, number]>; type?: string }>; + xAxis?: { type?: string }; + yAxis?: { type?: string }; + }; + expect(option.xAxis).toMatchObject({ type: "time" }); + expect(option.yAxis).toMatchObject({ type: "value" }); + expect(option.series?.[0]).toMatchObject({ + data: [ + [Date.UTC(2026, 5, 7, 8), 1500], + [Date.UTC(2026, 5, 7, 9), 3000], + ], + type: "line", + }); + }); + + it("renders an empty state when the dashboard has no token usage points", async () => { + const dispatch = vi.fn(async () => + buildDashboard([ + { + hourStart: Date.UTC(2026, 5, 7, 8), + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + sessionCount: 0, + activeDurationMs: 0, + }, + ]) + ); + + renderTrend(dispatch); + + expect(await screen.findByText("No token data in the last 24 hours.")).toBeInTheDocument(); + expect(echartsMock.init).not.toHaveBeenCalled(); + }); + + it("renders an error state without throwing when the dashboard request fails", async () => { + const dispatch = vi.fn(async () => ({ + ok: false, + error: { code: "command_error", message: "boom" }, + })); + + renderTrend(dispatch); + + expect(await screen.findByText("Token trend unavailable.")).toBeInTheDocument(); + expect(echartsMock.init).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the component test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx +``` + +Expected: FAIL because `./agent-instructions-token-trend` does not exist. + +- [ ] **Step 3: Implement the minimal component** + +Create `packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx`: + +```tsx +import * as echarts from "echarts"; +import { useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { useTranslation } from "../../../../lib/i18n"; +import type { + WorkAnalysisDashboardRecord, + WorkAnalysisTokenTrendPoint, +} from "../../../work-analysis/types"; + +interface AgentInstructionsTokenTrendProps { + workspacePath: string; +} + +type TrendState = + | { status: "loading" } + | { status: "ready"; points: WorkAnalysisTokenTrendPoint[] } + | { status: "empty" } + | { status: "error" }; + +function formatTokenValue(value: number) { + if (value >= 1_000_000) { + return `${Number((value / 1_000_000).toFixed(1))}m`; + } + if (value >= 1_000) { + return `${Number((value / 1_000).toFixed(1))}k`; + } + return String(value); +} + +function getPointTimestamp(point: WorkAnalysisTokenTrendPoint) { + return typeof point.hourStart === "number" ? point.hourStart : null; +} + +function hasTokenData(points: readonly WorkAnalysisTokenTrendPoint[]) { + return points.some((point) => point.totalTokens > 0 || point.sessionCount > 0); +} + +function summarizePoints(points: readonly WorkAnalysisTokenTrendPoint[]) { + return points.reduce( + (summary, point) => ({ + peakTokens: Math.max(summary.peakTokens, point.totalTokens), + sessionCount: summary.sessionCount + point.sessionCount, + totalTokens: summary.totalTokens + point.totalTokens, + }), + { + peakTokens: 0, + sessionCount: 0, + totalTokens: 0, + } + ); +} + +export function AgentInstructionsTokenTrend({ workspacePath }: AgentInstructionsTokenTrendProps) { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const chartRef = useRef(null); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + async function loadTrend() { + const result = await dispatch("work.analysis.dashboard.get", { + workspacePaths: [workspacePath], + timeRange: { preset: "24h" }, + }); + + if (cancelled) { + return; + } + + if (!result.ok || !result.data?.dashboard) { + setState({ status: "error" }); + return; + } + + const points = result.data.dashboard.trends.tokenHourly; + setState(hasTokenData(points) ? { status: "ready", points } : { status: "empty" }); + } + + void loadTrend(); + + return () => { + cancelled = true; + }; + }, [dispatch, workspacePath]); + + const chartData = useMemo(() => { + if (state.status !== "ready") { + return []; + } + + return state.points + .map((point): [number, number] | null => { + const timestamp = getPointTimestamp(point); + return typeof timestamp === "number" ? [timestamp, point.totalTokens] : null; + }) + .filter((point): point is [number, number] => point !== null); + }, [state]); + + const summary = useMemo( + () => (state.status === "ready" ? summarizePoints(state.points) : null), + [state] + ); + + useEffect(() => { + const container = chartRef.current; + if (!container || chartData.length === 0) { + return; + } + + const style = getComputedStyle(container); + const textColor = style.getPropertyValue("--text-tertiary").trim() || "#8aa7b8"; + const gridColor = style.getPropertyValue("--border-subtle").trim() || "#214458"; + const accentColor = style.getPropertyValue("--status-success-fg").trim() || "#67d6b3"; + const chart = echarts.init(container); + + chart.setOption({ + animationDuration: 500, + grid: { + bottom: 8, + containLabel: false, + left: 4, + right: 4, + top: 8, + }, + series: [ + { + areaStyle: { + color: `${accentColor}22`, + }, + data: chartData, + lineStyle: { + color: accentColor, + width: 2, + }, + showSymbol: chartData.length <= 12, + smooth: true, + symbolSize: 5, + type: "line", + }, + ], + tooltip: { + confine: true, + trigger: "axis", + }, + xAxis: { + axisLabel: { + color: textColor, + hideOverlap: true, + show: false, + }, + axisLine: { + lineStyle: { + color: gridColor, + }, + }, + axisTick: { + show: false, + }, + splitLine: { + show: false, + }, + type: "time", + }, + yAxis: { + axisLabel: { + color: textColor, + show: false, + }, + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + lineStyle: { + color: gridColor, + type: "dashed", + }, + }, + type: "value", + }, + }); + + const resizeChart = () => chart.resize(); + window.addEventListener("resize", resizeChart); + + return () => { + window.removeEventListener("resize", resizeChart); + chart.dispose(); + }; + }, [chartData]); + + return ( +
+
+
+

+ {t("workspace.agent_instructions.token_trend.title")} +

+

+ {t("workspace.agent_instructions.token_trend.subtitle")} +

+
+ {summary ? ( + + {t("workspace.agent_instructions.token_trend.total", { + value: formatTokenValue(summary.totalTokens), + })} + + ) : null} +
+ + {state.status === "loading" ? ( +
+ {t("workspace.agent_instructions.token_trend.loading")} +
+ ) : null} + + {state.status === "ready" ? ( + <> +
+
+ + {t("workspace.agent_instructions.token_trend.peak", { + value: formatTokenValue(summary?.peakTokens ?? 0), + })} + + + {t("workspace.agent_instructions.token_trend.sessions", { + count: summary?.sessionCount ?? 0, + })} + +
+ + ) : null} + + {state.status === "empty" ? ( +

+ {t("workspace.agent_instructions.token_trend.empty")} +

+ ) : null} + + {state.status === "error" ? ( +

+ {t("workspace.agent_instructions.token_trend.error")} +

+ ) : null} +
+ ); +} +``` + +- [ ] **Step 4: Run the component test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the component behavior** + +Run: + +```bash +git add packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx +git commit -m "feat(workspace): add agent token trend component" +``` + +Expected: commit succeeds. + +--- + +### Task 2: Panel Integration and Placement + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx` + +- [ ] **Step 1: Write the failing placement test** + +In `packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx`, add this mock near the existing mocks: + +```tsx +vi.mock("./agent-instructions-token-trend", () => ({ + AgentInstructionsTokenTrend: ({ workspacePath }: { workspacePath: string }) => ( +
+ Token trend mock +
+ ), +})); +``` + +Add this test in the `describe("AgentInstructionsSection", () => { ... })` block after the existing expanded-default test: + +```tsx + it("renders the token trend as the first expanded body block for the current workspace", async () => { + renderSection({}); + + const trend = await screen.findByTestId("agent-token-trend"); + const projectHeading = await screen.findByRole("heading", { level: 3, name: "Project Agent.md" }); + + expect(trend).toHaveAttribute("data-workspace-path", "/repo/project"); + expect(trend.compareDocumentPosition(projectHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +``` + +- [ ] **Step 2: Run the section test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: FAIL because `agent-token-trend` is not rendered. + +- [ ] **Step 3: Render the token trend at the top of the expanded panel body** + +Modify `packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx`. + +Add the import: + +```tsx +import { AgentInstructionsTokenTrend } from "./agent-instructions-token-trend"; +``` + +Add this as the first child inside `
`, before the error notice: + +```tsx + {workspace?.path ? : null} +``` + +- [ ] **Step 4: Run the section test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the panel integration** + +Run: + +```bash +git add packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx packages/web/src/features/workspace/views/shared/agent-instructions-section.test.tsx +git commit -m "feat(workspace): show token trend in agent panel" +``` + +Expected: commit succeeds. + +--- + +### Task 3: Styling and Localization + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing style token guard** + +In `packages/web/src/styles/components.theme.test.ts`, add this test near the existing workspace agent instruction or monitoring style guard tests: + +```ts + it("keeps the agent token trend chart on shared theme tokens", () => { + const tokenTrend = getLastRuleBlock(".workspace-agent-instructions__token-trend"); + const chart = getLastRuleBlock(".workspace-agent-instructions__token-trend-chart"); + const skeleton = getLastRuleBlock(".workspace-agent-instructions__token-trend-skeleton"); + + expect(tokenTrend).toContain("border: 1px solid var(--border-subtle)"); + expect(tokenTrend).toContain("background: var(--surface-subtle)"); + expect(chart).toContain("height: 72px"); + expect(skeleton).toContain("color: var(--text-tertiary)"); + }); +``` + +- [ ] **Step 2: Run the style test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts +``` + +Expected: FAIL because the new CSS selectors do not exist. + +- [ ] **Step 3: Add CSS for the compact chart block** + +In `packages/web/src/styles/components.css`, add these rules after the existing `.workspace-agent-instructions__status-action` rule and before `.workspace-agent-instructions__system-list`: + +```css +.workspace-agent-instructions__token-trend { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--gap-tight); + padding: var(--sp-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: var(--surface-subtle); +} + +.workspace-agent-instructions__token-trend-header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: var(--gap-tight); +} + +.workspace-agent-instructions__token-trend-title { + margin: 0; + color: var(--text-primary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-agent-instructions__token-trend-subtitle { + margin: var(--sp-0-5) 0 0; + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); +} + +.workspace-agent-instructions__token-trend-total { + flex: 0 0 auto; + color: var(--text-primary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); + white-space: nowrap; +} + +.workspace-agent-instructions__token-trend-chart { + width: 100%; + height: 72px; + min-width: 0; +} + +.workspace-agent-instructions__token-trend-footer { + display: flex; + min-width: 0; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--gap-compact); + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); +} + +.workspace-agent-instructions__token-trend-skeleton, +.workspace-agent-instructions__token-trend-state { + display: flex; + min-height: 72px; + align-items: center; + justify-content: center; + margin: 0; + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + text-align: center; +} +``` + +- [ ] **Step 4: Add localization strings** + +In `packages/web/src/locales/en.json`, under `workspace.agent_instructions`, add: + +```json +"token_trend": { + "title": "Token Trend", + "subtitle": "Current project · Last 24 hours", + "loading": "Loading token trend...", + "empty": "No token data in the last 24 hours.", + "error": "Token trend unavailable.", + "total": "Total {value}", + "peak": "Peak {value}/h", + "sessions": "{count} sessions", + "chart_label": "Token consumption trend for the current project over the last 24 hours" +} +``` + +In `packages/web/src/locales/zh.json`, under `workspace.agent_instructions`, add: + +```json +"token_trend": { + "title": "Token 消耗趋势", + "subtitle": "当前项目 · 最近 24 小时", + "loading": "正在加载 token 趋势...", + "empty": "最近 24 小时暂无 token 数据。", + "error": "Token 趋势暂不可用。", + "total": "总量 {value}", + "peak": "峰值 {value}/h", + "sessions": "{count} 个会话", + "chart_label": "当前项目最近 24 小时 Token 消耗趋势图" +} +``` + +- [ ] **Step 5: Run style and focused component tests** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx src/features/workspace/views/shared/agent-instructions-section.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit styling and localization** + +Run: + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts packages/web/src/locales/en.json packages/web/src/locales/zh.json +git commit -m "style(workspace): polish agent token trend" +``` + +Expected: commit succeeds. + +--- + +### Task 4: Final Verification + +**Files:** +- Read: `docs/superpowers/specs/2026-06-07-agent-token-trend-design.md` +- Read: `docs/superpowers/plans/2026-06-07-agent-token-trend.md` + +- [ ] **Step 1: Run focused web tests** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/agent-instructions-token-trend.test.tsx src/features/workspace/views/shared/agent-instructions-section.test.tsx src/styles/components.theme.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run web typecheck** + +Run: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: PASS. + +- [ ] **Step 3: Review the git diff against the spec** + +Run: + +```bash +git diff --stat HEAD~3..HEAD +git diff HEAD~3..HEAD -- packages/web/src/features/workspace/views/shared/agent-instructions-token-trend.tsx packages/web/src/features/workspace/views/shared/agent-instructions-section.tsx packages/web/src/styles/components.css packages/web/src/locales/en.json packages/web/src/locales/zh.json +``` + +Expected: diff shows only the planned frontend chart, integration, style, and locale changes. diff --git a/docs/superpowers/research/2026-06-04-work-analysis-provider-usage-capability-matrix.md b/docs/superpowers/research/2026-06-04-work-analysis-provider-usage-capability-matrix.md new file mode 100644 index 000000000..ad6bb3358 --- /dev/null +++ b/docs/superpowers/research/2026-06-04-work-analysis-provider-usage-capability-matrix.md @@ -0,0 +1,74 @@ +# Work Analysis Provider Usage Capability Matrix + +Date: 2026-06-04 +Owner: docs/superpowers/research +Scope: current `packages/server/src/work-analysis/log-sources/*` adapters, related tests, and a limited check of local provider roots where available + +## Rating Scale + +- `full`: current adapter already extracts the metric as a first-class normalized field +- `partial`: raw logs appear to contain the signal, but the adapter only extracts it indirectly, incompletely, or not at all +- `none`: no current adapter support and no confirming evidence from the inspected fixtures/roots + +## Matrix + +| Provider | Workspace path | Timestamps | Session counts | Tool counts | Model identity | Token usage | Cache usage | Reasoning usage | Cost estimation | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `codex` | full | full | full | full | full | partial | partial | partial | none | +| `claude` | full | full | full | full | none | partial | partial | partial | none | +| `gemini` | full | full | full | none | none | none | none | none | none | +| `cursor` | full | partial | full | full | none | none | none | none | none | +| `opencode` | full | full | full | full | full | none | none | none | none | + +## Evidence Notes + +### `codex` + +- Workspace path is extracted from first valid metadata record `payload.cwd`; tests assert matching by `cwd`. +- Timestamps are explicit when record timestamps exist, otherwise file `mtime` fallback. +- Session counts are supported because each parsed JSONL becomes one normalized `WorkLogSession`. +- Tool counts are extracted from `tool` / `command` / `function`-like records into `toolUseCount`. +- Model identity is normalized as `payload.model ?? payload.model_provider`. +- Raw local Codex logs include `event_msg` `token_count` records with `input_tokens`, `cached_input_tokens`, `output_tokens`, and `reasoning_output_tokens`, but the adapter does not read them. That makes token, cache, and reasoning usage `partial`, not `full`. +- No inspected Codex source exposes a normalized cost field or adapter-side cost calculation. + +### `claude` + +- Workspace path comes from record `cwd`; timestamps come from record `timestamp`; tests cover session grouping and workspace attribution. +- Session counts are available through grouped `sessionId` records. +- Tool counts are inferred from `toolUse`, `attachment`, or `tool` presence, so current coverage is broad enough for `full` in V1 terms. +- Current adapter does not normalize model identity even though raw local Claude assistant records include `message.model`. +- Raw local Claude logs also include `message.usage` with `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, and `cache_read_input_tokens`, and assistant content may include `thinking`. The adapter ignores all of these, so token/cache/reasoning usage are `partial`. +- No cost field or cost estimation path is implemented. + +### `gemini` + +- Workspace path is authoritative via `.project_root`; tests verify tmp/history dedupe and workspace matching. +- Timestamps come from `startTime` / `lastUpdated` with file `mtime` fallback. +- Session counts are supported at the chat-file level. +- Current adapter hardcodes `toolUseCount: 0`, and inspected tests/fixtures do not prove an extractable tool metric from the current Gemini chat shape. +- No normalized model, token, cache, reasoning, or cost support is present in the current adapter or fixtures. + +### `cursor` + +- Workspace path is extracted from transcript record `cwd`; tests verify logs without `cwd` are skipped. +- Timestamps are only file `mtime`, and the design doc explicitly calls this out as a V1 limitation. That keeps timestamps `partial`. +- Session counts are available because each transcript file becomes one session. +- Tool counts are inferred from transcript content parts with `tool` / `command` / `function` markers. +- No normalized model, token, cache, reasoning, or cost support is present in the adapter or tests. + +### `opencode` + +- Workspace path comes from `project.worktree` with `session.directory` fallback. +- Timestamps are explicit from `session.time_created` and `session.time_updated`. +- Session counts, tool counts, and model identity are all normalized from SQLite query results; tests cover the query shape and a real fixture path. +- The current SQL does not query any token, cache, reasoning, or cost fields. The inspected adapter and tests do not establish those metrics as available for V1. + +## V1 Conclusion + +The current work-analysis implementation already has the strongest usage-source foundation in `codex` and `claude`. + +- `codex` already normalizes workspace, timestamps, session counts, tool counts, and model identity, and its raw logs clearly expose token/cache/reasoning counters that can be added later. +- `claude` already normalizes workspace, timestamps, session counts, and tool counts, and its raw logs also expose token/cache usage plus model and thinking data that are not yet harvested. + +If V1 usage reporting needs the best initial provider coverage with the lowest research risk, `codex` and `claude` are the right starting sources. The findings here support that conclusion. diff --git a/docs/superpowers/specs/2026-06-02-draft-pane-launcher-internal-redesign-design.md b/docs/superpowers/specs/2026-06-02-draft-pane-launcher-internal-redesign-design.md new file mode 100644 index 000000000..11078b9b8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-draft-pane-launcher-internal-redesign-design.md @@ -0,0 +1,202 @@ +# Draft Pane Launcher Internal Redesign + +> Status: Draft for review +> Date: 2026-06-02 +> Scope: `packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx`, `packages/web/src/styles/components.css`, related launcher tests and theme tests + +## Goal + +Refine the `draft pane` launcher so it feels cleaner, flatter, and more stable without changing the surrounding pane shell or the product's existing visual language. + +This redesign is intentionally narrow: + +- keep the current `session-card` and `session-header` +- keep the current theme/token system +- keep the current two-entry mental model: `Agent` on the left, `file open` on the right +- only redesign the internal launcher area inside the draft pane + +## Non-Goals + +- Do not redesign the outer draft pane frame or header actions +- Do not introduce a new welcome-page-like layout language +- Do not turn the launcher into a new full-screen entry experience +- Do not remove provider install guidance, diagnostics links, or drag-and-drop behavior +- Do not replace the compact-width carousel model with a new navigation pattern + +## Confirmed Constraints + +The following constraints were explicitly confirmed during design review and are mandatory: + +- Preserve the current overall product style +- Keep the redesign focused inside the `draft pane` body +- Avoid strong left/right split-panel tension +- Avoid large, tall provider cards that reduce provider density +- Avoid moving the helper copy into the outer draft pane header +- Keep the file-open affordance recognizable and still mapped to the right side on desktop + +## Current Problems + +### 1. The desktop launcher feels visually split in half + +The current implementation renders two separate `agent-draft-panel` blocks with a strong side-by-side presence. This creates a "two panels fighting each other" effect instead of one coherent launcher surface. + +### 2. Provider items are too tall for a launcher + +The provider buttons behave more like stacked cards than launcher rows. Their vertical size makes the area feel heavy and reduces the number of providers that can be scanned quickly. + +### 3. The file area is visually too isolated + +The drag-and-drop zone reads like a separate empty module instead of a secondary utility inside the same launcher surface. + +### 4. Helper copy lacks a natural home + +The helper sentence: + +`点击启动 Agent,或将文件拖到右侧区域直接打开。` + +should explain the relationship between the two entry paths, but placing it in the outer header makes that header too large, while placing it as a heavy footer pulls attention downward too much. + +## Design Conclusion + +Adopt a `shared internal workarea` design for the draft launcher. + +The draft pane keeps its existing outer shell, but the content area is reorganized into one shared internal surface: + +- a lightweight helper-copy strip at the top of the internal workarea +- a primary `Agent` region on the left +- a secondary `file utility` region on the right +- a very light divider between the two regions + +This keeps the existing interaction model intact while removing the current visual feeling of two independent cards. + +## Detailed Design + +### 1. Internal Structure + +Replace the visual grammar of two independent `agent-draft-panel` blocks with one shared internal container, conceptually: + +- `.agent-draft-workarea` + - `.agent-draft-workarea-copy` + - `.agent-draft-workarea-body` + - `.agent-draft-workarea-main` + - `.agent-draft-workarea-side` + +Rules: + +- The outer `session-card`, `session-header`, action buttons, and pane states remain unchanged +- The new workarea is the only visually framed surface inside the draft body +- The left and right regions share the same background and boundary +- The left region remains dominant at roughly `1.2fr` +- The right region remains secondary at roughly `0.8fr` + +### 2. Helper Copy Placement + +Move the helper sentence into the top of the internal workarea, not the outer header and not a heavy footer. + +Rules: + +- It spans the internal workarea width +- It uses small, low-contrast supporting text styling +- It should read as orientation copy, not as a standalone banner or badge +- It should visually sit above both entry areas so it explains both of them together + +### 3. Provider Region + +The provider area keeps the current provider list behavior, but changes its visual density and hierarchy. + +Rules: + +- Provider items shift from tall card-buttons to flatter launcher rows +- Each row keeps: + - provider icon + - title + - subtitle + - CTA +- Row height should compress to roughly `70% - 80%` of the current visual height +- Claude / Codex may retain subtle semantic tinting, but only as restrained row treatment +- The row should scan as one line-first unit, not as a three-layer mini-card +- Install or diagnostics guidance remains available, but should read as lightweight appended detail instead of expanding the main row into a large block whenever possible + +### 4. File Utility Region + +The right-side file-open area remains present, but it becomes a quieter utility region inside the same workarea. + +Rules: + +- Keep the right-side file entry mental model on desktop +- Reduce the visual size and emphasis of the drag-and-drop box +- Keep the drop target clear and discoverable +- Use a lighter dashed treatment and smaller icon footprint than today +- Ensure its weight is lower than the provider region so the launcher still reads as `Agent-first` + +### 5. Shared Surface Styling + +The redesign should flatten the internal area without creating a new design system. + +Rules: + +- Use one shared internal surface instead of two card-like sub-panels +- Reduce internal padding and gaps +- Weaken the center divider +- Reduce the visual contrast between left and right background treatments +- Preserve existing theme tokens and component vocabulary rather than inventing a separate style language + +### 6. Compact Width Behavior + +The current compact carousel behavior remains the interaction model for narrow widths. + +Rules: + +- Preserve the existing `agent / file` panel switching logic +- Preserve current swipe and dot navigation behavior +- Only adjust the compact-mode visuals to align with the flatter shared-workarea styling +- Do not introduce a third mode or a new mobile information architecture + +## Interaction and Behavior Constraints + +The redesign is visual and structural only. Existing behavior must remain intact: + +- provider click still launches or opens install guidance +- drag-and-drop still targets the file-open area +- pane assignment / replacement behavior remains unchanged +- drag overlay and drop-target states remain unchanged +- diagnostics and provider documentation links remain accessible + +## Test and Validation Requirements + +Update tests to match the new internal launcher grammar without weakening behavior coverage. + +Required updates: + +- keep coverage for provider launch behavior +- keep coverage for drag-and-drop file open behavior +- keep coverage for compact carousel switching +- update assertions for helper-copy placement so the sentence is verified in the internal workarea, not the old footer position +- update structure/style assertions so tests stop depending on two strongly independent launcher panel visuals +- update theme/style tests so the new shared workarea and flatter rows remain token-aligned + +## Scope Boundaries for Implementation + +Implementation may adjust: + +- markup structure inside the draft launcher body +- draft launcher class names +- launcher-specific CSS in `components.css` +- related unit and style tests + +Implementation must not expand into: + +- global pane header redesign +- unrelated workspace panel restyling +- provider behavior changes +- new localization copy beyond relocation or minimal wording polish if strictly needed + +## Success Criteria + +The redesign is successful if: + +- the launcher reads as one coherent internal surface instead of two separate cards +- provider scanning feels denser and faster +- the right-side file area remains understandable but clearly secondary +- the helper sentence explains the two entry paths without enlarging the outer header +- the desktop layout feels less visually split while preserving existing interaction logic diff --git a/docs/superpowers/specs/2026-06-02-image-preview-zoom-design.md b/docs/superpowers/specs/2026-06-02-image-preview-zoom-design.md new file mode 100644 index 000000000..926080d2f --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-image-preview-zoom-design.md @@ -0,0 +1,32 @@ +# Image Preview Zoom Design + +## Scope + +Improve the existing file image preview in `packages/web/src/features/code-editor/components/image-preview.tsx`. +The change is limited to the normal workspace image preview surface, not image diff or document preview. + +## Behavior + +- Add image zoom controls inside the preview chrome: zoom out, zoom in, fit to window, and show actual size. +- Keep the current metadata strip with image type, dimensions, and size. +- Show the current zoom percentage so the user can see the active scale. +- Support keyboard-modified wheel zoom on the image canvas with `Ctrl` or `Meta`. +- Clamp zoom between 25% and 400%. +- Reset zoom to fit mode when the image URL or version changes. + +## UI + +Use icon-only `IconButton` controls with `Tooltip`, matching the existing editor toolbar style. +The controls live in the image preview footer so they do not compete with the editor mode toolbar. +The image canvas remains the scroll container; when zoomed beyond the viewport, the user can scroll around the enlarged image. + +## Testing + +Add focused component tests for: + +- Rendering zoom controls with accessible names. +- Zoom-in and zoom-out changing the percentage. +- Actual-size and fit controls switching the transform state. +- Version changes resetting the zoom state. + +Run the image preview test file after implementation. diff --git a/docs/superpowers/specs/2026-06-02-skills-panel-target-summary-design.md b/docs/superpowers/specs/2026-06-02-skills-panel-target-summary-design.md new file mode 100644 index 000000000..9eb1e608f --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-skills-panel-target-summary-design.md @@ -0,0 +1,337 @@ +# Skills Panel Target Summary Design + +Date: 2026-06-02 +Status: Draft +Owner: spencer + +## Problem + +当前 `Skills` 面板已经支持: + +- 已安装 skill 排序在前 +- 顶层分组折叠 +- 每个 skill 展示完整 target 列表并执行挂载操作 + +但现有已安装 skill 卡片仍然过高。每个卡片默认直接展开所有 target,导致以下问题: + +- 当一个 skill 对应多个 agent target 时,卡片高度迅速膨胀 +- 用户很难快速扫描“哪些 skill 已挂到哪些 agent” +- 挂载状态、健康状态、路径和操作同时暴露,信息层级过重 +- 视觉密度和文件面板的 row 风格不一致 + +这不是一个单纯的间距问题。核心问题是:当前卡片默认把“摘要信息”和“诊断/操作信息”混在同一层展示,导致面板把垂直空间消耗在低频细节上。 + +## Goals + +- 显著压缩已安装 skill 卡片的默认高度。 +- 让用户在不展开的情况下,快速看清每个 skill 在各 agent 上的挂载概况。 +- 保留三种用户可理解的大状态:`未配置 / 未挂载 / 已挂载`。 +- 通过颜色和 tooltip 传达状态,不把所有异常细节都堆在默认视图里。 +- 展开后仍能查看详细 target 信息并执行挂载、卸载、修复或配置操作。 +- 样式和交互密度向文件面板对齐,而不是继续使用大块卡片式 target 列表。 + +## Non-Goals + +- 这次不重做整个 `Skills` 面板布局。 +- 不改变 skill 安装、卸载、挂载、修复的后端行为。 +- 不引入新的挂载状态枚举;只在前端把现有状态归并成更易读的摘要语义。 +- 不把 target 详细操作迁移到别的页面;仍保留在当前 skill 卡片展开态内。 + +## Current Context + +当前实现位于: + +- [`packages/web/src/features/workspace/views/shared/skills-panel.tsx`](../../../packages/web/src/features/workspace/views/shared/skills-panel.tsx) +- [`packages/web/src/styles/components.css`](../../../packages/web/src/styles/components.css) + +当前每个已安装 skill 卡片会: + +- 渲染 skill 名称、slug、版本、描述和卸载按钮 +- 在卡片下方直接渲染完整 `.skills-panel__targets` +- 为每个 target 展示: + - agent 名称 + - health tag + - mount relation tag + - target path + - mount/unmount/repair 按钮 + +现有 target 数据模型已经提供足够信息完成新的摘要层: + +- `AgentSkillTargetEntry.displayName` +- `AgentSkillTargetEntry.skillDir` +- `AgentSkillTargetEntry.lastHealthState` +- `AgentSkillTargetEntry.lastHealthError` +- `SkillMountRelation.enabled` +- `SkillMountRelation.status` +- `SkillMountRelation.targetPath` +- `SkillMountRelation.lastError` + +## User Decisions Captured + +- 每个 skill 卡片默认收起 target 详情。 +- 收起态显示 agent 缩写,而不是完整 target 行。 +- 用颜色区分 `未配置 / 未挂载 / 已挂载`。 +- 鼠标悬浮时用 tooltip 展示具体原因。 +- 展开后再显示详细 target 行和执行挂载操作。 +- `未配置` 作为独立状态保留,不合并进 `未挂载`。 + +## Approaches Considered + +### Option A: 缩写状态胶囊行 + 展开看详情(推荐) + +收起态只显示一行 agent 缩写胶囊,每个胶囊用颜色表达大状态,tooltip 解释原因;展开后显示完整 target 详情和操作。 + +优点: + +- 压缩高度最明显 +- 仍能一眼看到每个 agent 的状态分布 +- 展开态和收起态之间的心智映射最稳定 + +缺点: + +- 用户需要理解缩写含义 +- 需要新增一层摘要状态和 tooltip 文案逻辑 + +### Option B: 状态计数摘要 + 少量 target 标记 + +收起态显示 `已挂载 2 · 未挂载 1 · 未配置 1`,再附少量 agent 标记。 + +优点: + +- 总体状态统计最清楚 + +缺点: + +- 不能一眼看清是哪些 agent 异常 +- 仍然需要展开才能建立 target 对应关系 + +### Option C: 纯彩色点阵摘要 + +收起态只展示一排彩色点或方块,每个点代表一个 agent。 + +优点: + +- 最省空间 + +缺点: + +- 语义过弱 +- 不利于和展开态建立连续关系 +- 对低频用户不够自解释 + +## Final Choice + +采用 Option A。 + +每个已安装 skill 卡片增加自己的 target 展开状态。默认只显示一条摘要行,摘要行用 agent 缩写胶囊展示所有 target 的大状态;点击摘要行后,在当前卡片内部展开明细行和操作区。 + +## Interaction Design + +### 1. Skill Card Structure + +每个已安装 skill 卡片保留现有信息层: + +- 标题 +- slug / 版本 +- 描述 +- 卸载按钮 + +在描述区下方新增一个 `target summary row`: + +- 左侧是 agent 缩写胶囊组 +- 右侧是展开/收起箭头 +- 点击摘要行空白区域或箭头可展开/收起当前卡片 + +默认情况下不渲染完整 target 详情区,只显示摘要行。 + +### 2. Per-Skill Expansion + +展开状态是按 skill 卡片独立维护的,不影响其他 skill。 + +- 默认:全部收起 +- 展开后:只在当前 skill 卡片内部显示 target 详情 +- 收起后:恢复为单行摘要 + +这样可以避免一个 skill 的详情把整段列表全部推开,也符合文件面板“按项查看细节”的交互节奏。 + +### 3. Collapsed Summary Row + +摘要行展示所有 target 对应的 agent 缩写,例如: + +- `CC` for `Claude Code` +- `CX` for `Codex` +- `GM` for `Gemini CLI` + +内置 provider 使用稳定映射,避免缩写在不同会话中变化。非内置或自定义 provider 使用 `displayName` 自动生成 1-2 个大写字母简称。 + +摘要行本身使用接近 `workspace-sidebar-row` 的交互风格: + +- 紧凑高度 +- 轻 hover 背景 +- 整行可点击 +- 不使用厚重卡片分割 + +### 4. Expanded Detail Rows + +展开后,摘要行下方显示一层内嵌明细区域,而不是恢复成大块 target 列表卡片。 + +每个 target 明细行结构: + +- 第一行左侧:agent 全名 +- 第一行右侧:操作按钮 +- 名称旁边:大状态标签 +- 第二行:target 目录路径或异常原因 + +布局目标是向文件面板的 row 密度靠拢: + +- 行分隔轻量化 +- 弱化每个 target 的独立卡片感 +- 在桌面端保持左右布局 +- 在窄屏下回落为纵向堆叠 + +### 5. Action Rules + +展开态中的操作规则: + +- `已挂载` + - 显示 `卸载挂载` + - 若 mount relation 存在异常状态,则额外显示 `修复` +- `未挂载` + - 显示 `挂载` +- `未配置` + - 不显示禁用的 `挂载` + - 显示 `配置目录` + - 点击后打开现有 `Agent Targets` drawer + +这样可以把 `未配置` 从“不可点击的失败按钮”改成“正确的下一步动作”。 + +## State Model + +### Visible States in Summary Layer + +收起态只暴露三种颜色语义: + +- 绿色:`已挂载` +- 琥珀色:`未挂载` +- 粉红色:`未配置` + +收起态不再直接暴露 `stale / missing_target / missing_source / failed` 这些细粒度异常颜色,避免摘要层重新变复杂。 + +### Mapping Rules + +目标状态归类规则: + +- `未配置` + - `target.skillDir` 为空 + - 或 `target.lastHealthState === "unconfigured"` +- `已挂载` + - 存在启用中的 relation + - 且 `relation.status === "mounted"` +- `未挂载` + - 其余全部情况,包括: + - 没有 relation + - relation 未启用 + - `stale` + - `missing_target` + - `missing_source` + - `failed` + +这个规则保证摘要层永远稳定在三态,不把诊断状态数量扩展到用户难以快速扫描的程度。 + +## Tooltip Design + +每个缩写胶囊支持 hover tooltip。tooltip 结构固定为三行: + +1. agent 全名 +2. 大状态:`未配置 / 未挂载 / 已挂载` +3. 具体原因 + +具体原因文案规则: + +- `未配置` + - 默认:`未配置 skill 目录` +- `未挂载` + - 无 relation:`尚未挂载到此 Agent` + - `stale`:`挂载已漂移,需要修复` + - `missing_target`:`目标目录缺失` + - `missing_source`:`Skill 源目录缺失` + - `failed`:`最近一次挂载失败` +- `已挂载` + - `已挂载到 ` + +若存在 `relation.lastError` 或 `target.lastHealthError`,优先用真实错误摘要覆盖第 3 行原因文案。 + +路径较长时在视觉上允许截断,但 tooltip 中展示完整值。 + +## Visual Language + +### Summary Tokens + +摘要胶囊需要同时满足“紧凑”和“可扫描”: + +- 尺寸明显小于展开态按钮和 tag +- 文本为 1-2 个大写字母 +- 使用轻量背景和边框,而不是大体积 badge + +颜色语义: + +- `已挂载`:绿色系 +- `未挂载`:琥珀系 +- `未配置`:粉红系 + +### Row Styling + +整体样式对齐文件面板,而不是继续沿用厚重的子卡片: + +- skill 卡片本身仍然保持现有容器 +- target 层由“多行卡片组”改为“摘要行 + 轻量内嵌明细” +- hover、focus 和行间距向 `workspace-sidebar-row` 靠拢 + +### Click Behavior + +- hover 摘要行:整行高亮 +- hover 缩写胶囊:显示 tooltip +- 点击摘要行空白区域:展开/收起 +- 点击胶囊本身:仅显示 tooltip,不触发展开 + +这样可以避免用户在查看状态原因时误触发展开。 + +## Implementation Notes + +前端需要新增一层针对 target 的派生视图模型,至少包含: + +- 缩写 +- 三态摘要状态 +- tooltip 文案 +- 展开态按钮类型 +- 是否需要修复 + +建议把这层派生逻辑集中在 `skills-panel.tsx` 附近的纯函数中,而不是把状态分支散落在 JSX 内部。 + +内置 provider 的稳定缩写映射表本次先放在 `skills-panel.tsx` 附近的本地纯函数内,而不是提取到共享工具文件。原因是这套缩写目前只服务当前面板的摘要层,先保持局部收敛,等第二个消费方出现后再提取共享工具。 + +样式上建议: + +- 保留 `.skills-panel__list-item` +- 用新的 summary row class 替代当前默认展示的 `.skills-panel__targets` +- 让展开态 target 行更接近现有 sidebar row 节奏 + +`配置目录` 动作需要直接打开现有 `Agent Targets` drawer,并自动聚焦到当前 provider 对应项。这样用户从 `未配置` 状态进入配置流时,不需要再在 drawer 里额外查找目标 agent。 + +## Testing + +需要新增或更新组件测试,覆盖以下行为: + +- 已安装 skill 默认渲染为收起态摘要行 +- target 胶囊按 `已挂载 / 未挂载 / 未配置` 三态着色 +- hover 胶囊时能看到正确 tooltip 文案 +- 点击摘要行可展开/收起当前 skill +- 展开后可看到详细 target 行和正确操作按钮 +- `未配置` target 显示 `配置目录` 而不是禁用挂载按钮 +- 异常 relation 在收起态归并为 `未挂载`,但展开态仍能看到具体原因或修复按钮 + +## Risks + +- 缩写如果自动生成不稳定,用户会失去识别连续性。 +- 如果 tooltip 文案直接复用底层错误字符串,可能出现过长或不可读的问题。 +- 如果摘要行点击区和胶囊 hover 区没有处理好,容易出现 tooltip 和展开操作冲突。 diff --git a/docs/superpowers/specs/2026-06-03-agent-instructions-multi-provider-generation-design.md b/docs/superpowers/specs/2026-06-03-agent-instructions-multi-provider-generation-design.md new file mode 100644 index 000000000..4b48fcf3a --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-agent-instructions-multi-provider-generation-design.md @@ -0,0 +1,242 @@ +# Agent Instructions Multi-Provider Generation Design + +## Summary + +Extend `.coder-studio/agent.md` generation from the current Codex-only headless path to a unified multi-provider headless flow for built-in providers that already expose headless execution. The server remains responsible for prompt construction, output validation, and file writes. Providers only generate the response payload. + +This keeps the existing generate dialog and write path intact while removing the current mismatch where the UI is provider-aware but the backend generation contract is effectively Codex-specific. + +## Goals + +- Support `agent.md` generation for built-in headless providers beyond Codex. +- Preserve the existing saved path: `.coder-studio/agent.md`. +- Keep generation as a request-scoped headless task, not a supervisor workflow. +- Unify the business-level generation payload across providers. +- Keep server-side ownership of validation and file writes. + +## Non-Goals + +- Do not let providers write `.coder-studio/agent.md` directly. +- Do not add preview, retry queues, background jobs, or scheduling. +- Do not redesign the generate dialog UX in this change. +- Do not expand support to providers that do not already have stable built-in headless execution. + +## Current State + +The current generation flow is provider-selectable in the UI, but the backend only treats providers as generation-capable if they expose the `agent_instructions_generate` headless scenario. Today that scenario is only declared by Codex. + +The execution path also parses Codex-specific stdout: + +- `packages/server/src/agent-instructions/agent-generator.ts` +- `packages/server/src/agent-instructions/output.ts` +- `packages/providers/src/codex/headless.ts` + +This creates two problems: + +1. Generation capability is narrower than the existing headless provider set. +2. Output parsing is tied to Codex JSONL rather than a provider-agnostic generation contract. + +## Design + +### 1. Keep Scenario-Based Headless Support + +Retain `agent_instructions_generate` as an explicit headless scenario. + +Reasoning: + +- It preserves the current capability model and avoids collapsing all future headless uses into one boolean. +- It remains the correct feature gate for the UI and server. +- It avoids broadening support to providers that happen to be headless but are not yet wired for generation. + +Change: + +- Add `agent_instructions_generate` to the built-in providers that already support stable headless execution for `supervisor_eval` and `session_analysis`: + - `claude` + - `gemini` + - `cursor` +- Keep `codex` support unchanged. + +`opencode` stays unchanged unless it first grows a stable built-in headless execution path compatible with the same contract. + +### 2. Introduce a Unified Generation Payload + +All providers should be prompted to return a strict JSON payload in their final text response: + +```json +{ + "ok": true, + "content": "# Agent Instructions\n..." +} +``` + +Optional failure payload: + +```json +{ + "ok": false, + "error": "..." +} +``` + +Rules: + +- The server treats the provider output as invalid unless it can recover a final text response and parse the JSON payload. +- `content` must still pass existing markdown normalization and heading validation. +- The server continues to own all file writes. + +This separates two concerns cleanly: + +- provider-specific envelope extraction +- provider-agnostic generation payload parsing + +### 3. Split Output Handling Into Two Layers + +Replace the Codex-only parser with a two-step model: + +1. Extract final response text from provider stdout. +2. Parse the unified generation JSON payload from that text. + +#### Provider-Specific Envelope Extraction + +Add a provider-aware extractor in `packages/server/src/agent-instructions/output.ts`. + +Expected handling: + +- `codex` + - Extract the final `agent_message` text from the `codex exec --json` JSONL stream. +- `claude` + - Extract the assistant text from the `--output-format json` envelope already used by headless evaluation. +- `gemini` + - Extract the assistant text from its `--output-format json` envelope. +- `cursor` + - Extract the assistant text from its `--output-format json` envelope. + +This layer should return one thing only: the final text reply generated by the provider. + +#### Unified Payload Parsing + +After text extraction, parse the JSON payload and validate: + +- payload is valid JSON +- `ok === true` +- `content` is a non-empty string +- normalized markdown starts with exact `# Agent Instructions` + +If `ok === false`, surface the provider error message as a typed generation failure. + +### 4. Reuse Existing Headless Command Shape + +No new request shape is needed for `agent_instructions_generate`. + +The current `ProviderHeadlessCommandRequest` is already sufficient: + +- `prompt` +- `sessionId` +- `workspacePath` +- `apiKey?` +- `model?` +- `outputFile?` + +The new scenario should reuse each provider's existing headless command builder pattern. This is already how Codex works today, and the other built-in headless providers already accept the same request shape for evaluation. + +### 5. Prompt Contract + +`packages/server/src/agent-instructions/prompt.ts` should require: + +- the provider must return strict JSON, not raw markdown +- `content` must contain the full markdown document +- no extra prose outside the JSON payload +- the markdown must begin with `# Agent Instructions` +- unknown facts must be omitted rather than invented + +The business contract becomes: + +- provider generates structured content +- server validates and persists it + +### 6. File Write Responsibility + +Keep file writes on the server. + +Reasons: + +- fixed write target +- stronger validation before persistence +- easier tests +- cleaner permission boundary +- simpler future support for preview or diff before write + +The provider should never be asked to mutate the workspace directly for this flow. + +## Data Flow + +1. User opens the generate dialog. +2. Frontend loads providers from `provider.list` and `provider.runtimeStatus`. +3. Frontend filters to `available && supportsAgentInstructionsGeneration`. +4. User chooses provider and optional model. +5. Server collects workspace intelligence and builds the generation prompt. +6. Server invokes the selected provider in headless `agent_instructions_generate` mode. +7. Server extracts the provider's final text reply from stdout. +8. Server parses the unified generation JSON payload. +9. Server normalizes and validates markdown. +10. Server writes `.coder-studio/agent.md`. +11. Existing downstream attach/view/publish flows continue unchanged. + +## Error Handling + +Introduce clearer typed failures for these cases: + +- provider does not support `agent_instructions_generate` +- provider stdout cannot be decoded for that provider's envelope format +- provider reply is not valid generation JSON +- provider reply reports `ok: false` +- markdown payload fails normalization or heading validation + +This should replace the current ambiguity where non-Codex providers are excluded early and Codex parsing failures are the dominant error mode. + +## Testing + +Add or update tests in four groups. + +### Provider Definition Tests + +- `claude`, `gemini`, and `cursor` declare `agent_instructions_generate` +- existing `codex` coverage remains valid + +### Server Generation Tests + +- successful generation for each supported built-in provider +- provider-specific envelope parse failures +- unified payload parse failures +- invalid markdown heading failures +- provider-reported `ok: false` failures + +### Provider Listing and Runtime Tests + +- provider list exposes `supportsAgentInstructionsGeneration` for the expanded set +- generate dialog filtering includes runtime-available providers that support generation + +### Regression Tests + +- existing Codex generation path still succeeds +- current write command behavior and saved-path semantics remain unchanged + +## Rollout + +Implement in one patch if practical, but keep the change internally layered: + +1. broaden provider scenario declarations +2. introduce unified output extraction and payload parsing +3. update generation tests +4. update UI-facing provider filtering expectations + +This allows the product behavior to become genuinely multi-provider without a UI redesign. + +## Open Decision + +No additional product-level decisions remain for this phase. The intended implementation target is: + +- built-in headless providers only +- explicit `agent_instructions_generate` scenario retained +- server-owned writes +- provider-specific envelope extraction plus unified generation payload parsing diff --git a/docs/superpowers/specs/2026-06-03-provider-work-log-analysis-design.md b/docs/superpowers/specs/2026-06-03-provider-work-log-analysis-design.md new file mode 100644 index 000000000..f89cab9ff --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-provider-work-log-analysis-design.md @@ -0,0 +1,630 @@ +# Provider Work Log Analysis Design + +Date: 2026-06-03 +Status: Draft +Owner: spencer + +## Problem + +`工作分析` 当前把 Coder Studio 自己管理的 session 当成分析数据源。这和用户期望不一致:工作分析应该分析选中 workspace 在各个 agent/provider 自己历史日志中的实际工作记录,而不是当前打开或曾由 Coder Studio 管理的会话。 + +当前实现直接依赖 Coder Studio session: + +- [`packages/server/src/work-analysis/session-selector.ts`](../../../packages/server/src/work-analysis/session-selector.ts) 调用 `sessionMgr.getAll()`。 +- [`packages/server/src/work-analysis/evidence-collector.ts`](../../../packages/server/src/work-analysis/evidence-collector.ts) 从 Coder Studio session 读取 terminal snapshot 和 latest user input。 +- [`packages/server/src/work-analysis/service.ts`](../../../packages/server/src/work-analysis/service.ts) 会复用已成功的分析结果,导致 preset 时间范围和新增 provider 日志可能不刷新。 + +这会导致两个明显错误: + +- 已关闭或不在 Coder Studio 中打开的 provider 历史不会被统计。 +- `最近 7 天` 等查询可能显示少量 session,实际 provider 日志中已有更多记录。 + +## Goals + +- 工作分析的数据源改为 provider 自己的本地日志、缓存或数据库。 +- 覆盖所有 5 个内置 provider:`claude`、`codex`、`gemini`、`cursor`、`opencode`。 +- 按用户选中的 workspace path 和时间范围筛选 provider 历史。 +- 将不同 provider 的记录归一化为统一的 `WorkLogSession`。 +- 基础分析只聚合归一化 summary,保持快速、稳定。 +- 深入分析只使用受限、抽样、脱敏倾向的 evidence,不把整份日志交给 headless agent。 +- UI 明确展示每个 provider 的数据源状态和数据质量。 +- 保留分析结果记录用于展示上次结果,但用户重新运行分析时必须重新扫描 provider 数据源。 + +## Non-Goals + +- 不让 Coder Studio 修改或迁移 provider 的原始日志。 +- 不把 provider 历史导入为 Coder Studio session。 +- 不在 v1 中实现长期索引服务或后台定时扫描。 +- 不保证不同 provider 的所有指标完全对称。 +- 不把全部对话正文作为默认分析输入。 +- 不扩展到自定义 provider;自定义 provider 可以后续通过同一接口接入。 + +## User Decisions Captured + +- 工作分析和 Coder Studio session 没有业务关系。 +- 应该去具体 agent 的日志缓存中找和选中 workspace 相关的日志。 +- 不同 agent 有不同的会话日志缓存,先找到日志,再做提取、聚合、呈现。 +- 内置 provider 是 5 个,方案不能只覆盖 Codex 和 Claude。 +- 缓存指分析结果记录,不是 provider 原始日志;分析结果可以保留,但不能阻止重新分析。 + +## Current State + +### Provider Registry + +内置 provider 在 [`packages/providers/src/registry.ts`](../../../packages/providers/src/registry.ts) 中固定声明: + +- `claude` +- `codex` +- `gemini` +- `cursor` +- `opencode` + +因此工作分析应从 registry 派生内置 provider 范围,不能硬编码只处理部分 provider。 + +### Work Analysis Service + +当前 `WorkAnalysisService` 依赖两类 session-based 输入: + +- `sessionSelector`: 选择 Coder Studio session summary。 +- `evidenceCollector`: 读取 Coder Studio session 的 terminal snapshot、latest input 和 workspace path。 + +这两个依赖都需要替换为 provider-log based 输入。 + +### Result Persistence + +`WorkAnalysisRepo` 保存的是 `WorkAnalysisRecord`,也就是分析结果记录。它不是 provider 日志缓存。 + +当前 `runBasic` 如果发现同 query digest 的记录已经 `succeeded`,会直接返回旧记录。这对手动重新分析不合适,因为 provider 日志可能已经变化,preset 时间范围也会随时间滑动。 + +## Provider Log Findings + +### Codex + +- Root: `~/.codex/sessions/YYYY/MM/DD/*.jsonl` +- Workspace match: first metadata record `payload.cwd` +- Session id: `payload.id` +- Time: `timestamp` or `payload.timestamp`, with file mtime as fallback +- Useful fields: + - `payload.model_provider` + - `payload.git.branch` + - `payload.git.commit_hash` + - `payload.git.repository_url` + - message/event records for turn and tool counts +- Risk: low. JSONL metadata has direct workspace path. + +### Claude + +- Root: `~/.claude/projects//*.jsonl` +- Workspace match: encoded project directory plus record `cwd` +- Session id: `sessionId` +- Time: record `timestamp` +- Useful fields: + - `cwd` + - `gitBranch` + - message roles + - tool/hook attachment metadata +- Risk: low. Workspace path and timestamps are present in records. + +### Gemini + +- Roots: + - `~/.gemini/tmp//chats/session-*.json` + - `~/.gemini/history/` for historical compatibility when available +- Workspace match: `~/.gemini/tmp//.project_root` and matching history `.project_root` +- Session id: chat JSON `sessionId` +- Time: `startTime`, `lastUpdated` +- Useful fields: + - `kind` + - `projectHash` + - `summary` + - `messages[].type` + - `messages[].timestamp` + - message content type +- Risk: medium. Project directory names are not enough; `.project_root` must be authoritative. + +### Cursor + +- Primary root: `~/.cursor/projects//agent-transcripts//*.jsonl` +- Secondary root for future enhancement: `~/.cursor/chats///store.db` +- Workspace match: + - primary: encoded workspace project directory + - secondary: md5 of absolute workspace path +- Session id: transcript uuid +- Time: + - v1: transcript file mtime fallback + - future: DB metadata if stable fields are confirmed +- Useful fields: + - JSONL `role` + - `message.content[].type` + - tool-like content entries + - `~/.cursor/ai-tracking/ai-code-tracking.db` can support future code contribution signals +- Risk: medium-high. Agent transcript JSONL may not include explicit timestamps, so v1 must report mtime fallback data quality. + +### OpenCode + +- Root: `~/.local/share/opencode/opencode.db` +- Workspace match: + - `project.worktree` + - `session.directory` +- Session id: `session.id` +- Time: + - `session.time_created` + - `session.time_updated` + - message and part timestamps for detail +- Useful tables: + - `project` + - `session` + - `message` + - `part` + - `todo` + - `session_diff` +- Useful fields: + - `session.title` + - `session.version` + - `summary_files` + - `summary_additions` + - `summary_deletions` + - message and part counts +- Risk: medium. SQLite schema is strong locally, but provider is marked experimental in Coder Studio. + +## Approaches Considered + +### Option A: Patch Current Session Selector + +Keep `WorkAnalysisService` mostly unchanged and teach `session-selector.ts` to merge Coder Studio session records with provider logs. + +Pros: + +- Smallest diff. +- Existing basic analyzer changes little. + +Cons: + +- Keeps the wrong mental model: provider logs are not Coder Studio sessions. +- Makes evidence collection ambiguous. +- UI would still imply current/open sessions. +- Hard to represent provider data quality. + +### Option B: Provider Log Adapters + Normalized Collector (Recommended) + +Replace session selection and evidence collection with provider-specific adapters that return normalized work log sessions. + +Pros: + +- Matches the required product semantics. +- Keeps provider-specific parsing isolated. +- Makes all 5 built-in providers first-class. +- Enables clear data quality status per provider. +- Allows basic and deep analysis to share one normalized source. + +Cons: + +- Requires new adapters and fixtures. +- Some providers have weaker timestamp guarantees. +- Existing tests around session selector/evidence collector must be replaced. + +### Option C: Persistent Work Log Index + +Build a background indexing service that continuously scans provider logs and stores a normalized local index. + +Pros: + +- Fast queries after indexing. +- Enables historical trends and freshness detection. + +Cons: + +- Too large for this correction. +- Adds background scanning and invalidation complexity. +- Raises more privacy and storage questions. + +## Final Choice + +Use Option B. + +Implement a provider-log based collector with one adapter per built-in provider. The collector scans on demand when the user runs analysis. Results are normalized into `WorkLogSession[]`, then passed to basic aggregation and deep evidence sampling. + +Persisted `WorkAnalysisRecord` remains useful for showing the last result, but it no longer short-circuits explicit `runBasic` or `runDeep`. + +## Architecture + +### New Source Interface + +Create a provider log source interface in the server work-analysis domain: + +```ts +export interface ProviderWorkLogSource { + providerId: BuiltInProviderId; + + discover(input: ProviderWorkLogDiscoverInput): Promise; +} + +export interface ProviderWorkLogDiscoverInput { + workspacePaths: string[]; + timeRange: ResolvedWorkAnalysisTimeRange; +} + +export interface ProviderWorkLogDiscovery { + providerId: BuiltInProviderId; + status: WorkLogProviderStatus; + sessions: WorkLogSession[]; + sourceRefs: WorkLogSourceRef[]; + parseErrorCount: number; + warnings: WorkLogWarning[]; +} +``` + +### Normalized Session + +```ts +export interface WorkLogSession { + providerId: BuiltInProviderId; + sessionId: string; + workspacePath: string; + startedAt: number; + lastActiveAt: number; + sourceRef: string; + title?: string; + modelId?: string; + gitBranch?: string; + gitCommit?: string; + userTurnCount: number; + assistantTurnCount: number; + toolUseCount: number; + parseErrorCount: number; + timestampQuality: "explicit" | "file_mtime" | "mixed"; + evidence?: WorkLogEvidence[]; +} +``` + +### Source References + +```ts +export interface WorkLogSourceRef { + providerId: BuiltInProviderId; + kind: "file" | "sqlite"; + path: string; + mtimeMs?: number; + sizeBytes?: number; + maxUpdatedAt?: number; +} +``` + +`WorkLogSourceRef` supports result freshness diagnostics and future automatic invalidation. + +### Provider Status + +```ts +export type WorkLogProviderStatus = + | "supported" + | "no_logs" + | "missing_root" + | "partial" + | "unsupported"; +``` + +Meaning: + +- `supported`: adapter ran and returned zero or more valid sessions without material parse failures. +- `no_logs`: log root exists, but no sessions matched selected workspace/time range. +- `missing_root`: provider log root is not present on disk. +- `partial`: some sessions were parsed, but some files/records failed. +- `unsupported`: built-in provider exists, but this adapter intentionally has no v1 reader. + +For this design, all 5 built-in providers should have v1 adapters. `unsupported` remains for forward compatibility and custom providers. + +### Collector + +Create `WorkLogCollector`: + +```ts +export interface WorkLogCollector { + collect(input: { + workspacePaths: string[]; + timeRange: ResolvedWorkAnalysisTimeRange; + }): Promise; +} + +export interface WorkLogCollection { + sessions: WorkLogSession[]; + providers: ProviderWorkLogDiscovery[]; + sourceDigest: string; +} +``` + +The collector: + +- Runs all built-in provider adapters. +- Sorts sessions by `lastActiveAt`, then `providerId`, then `sessionId`. +- Computes `sourceDigest` from provider id, source ref path, mtime, size, max updated time, and matched session ids. +- Keeps provider warnings for UI display. + +## Basic Analysis Design + +Basic analysis should consume `WorkLogSession[]` instead of Coder Studio session summaries. + +Existing fields can mostly remain: + +- `coverage.workspaceCount` +- `coverage.sessionCount` +- `coverage.providerCount` +- `activity.sessionCount` +- `activity.totalDurationMs` +- `activity.averageDurationMs` +- `workHabits.hourBuckets` +- `usage.totalSessions` +- `usage.sessionsByProvider` +- `agentModelMix.providers` +- `dataQuality.clampedDurationCount` +- `dataQuality.emptySessionCount` + +Add provider log data quality: + +```ts +dataSources: { + providers: Array<{ + providerId: BuiltInProviderId; + status: WorkLogProviderStatus; + sessionCount: number; + parseErrorCount: number; + warningCount: number; + }>; +}; +``` + +Add execution-like signals from logs: + +```ts +executionSignals: { + sessionsWithActivity: number; + userTurnCount: number; + assistantTurnCount: number; + toolUseCount: number; + fileMtimeTimestampCount: number; +}; +``` + +The previous `skillInventory` section can stay because it is Coder Studio skill inventory, not provider session data. Its label should make clear it is local Coder Studio skill inventory. + +## Deep Analysis Design + +Deep analysis should use sampled provider-log evidence, not terminal snapshots. + +### Evidence Shape + +```ts +export interface WorkLogEvidence { + providerId: BuiltInProviderId; + sessionId: string; + workspacePath: string; + title?: string; + startedAt: number; + lastActiveAt: number; + excerpts: Array<{ + role: "user" | "assistant" | "tool" | "system" | "unknown"; + at?: number; + text?: string; + toolName?: string; + commandKind?: string; + filePath?: string; + }>; +} +``` + +### Sampling Rules + +- Cap total sessions included in deep evidence. +- Cap sessions per provider so one provider cannot dominate. +- Cap excerpts per session. +- Cap excerpt text length. +- Prefer recent sessions, sessions with tool activity, and sessions with explicit timestamps. +- Include provider and session metadata even when textual evidence is sparse. +- Exclude binary blobs, raw DB blobs, OAuth/account files, and provider config secrets. + +### Headless Provider Selection + +The provider used to run deep analysis should not be selected by "most sessions found". That was only a side effect of the old session-based model. + +Use a dedicated selection policy: + +1. Prefer the user's configured default analysis provider if it supports `session_analysis` headless and is runtime-available. +2. Otherwise choose the first runtime-available built-in provider that supports `session_analysis`. +3. If no provider is available, basic analysis still succeeds and deep analysis fails with a typed provider-unavailable error. + +## Result Record And Cache Behavior + +`WorkAnalysisRecord` continues to store analysis results for display. It should also store source freshness metadata: + +```ts +export interface WorkAnalysisSourceSnapshot { + sourceDigest: string; + providerStatuses: Array<{ + providerId: BuiltInProviderId; + status: WorkLogProviderStatus; + sessionCount: number; + parseErrorCount: number; + }>; + collectedAt: number; +} +``` + +Behavior: + +- `work.analysis.get` returns the last saved result for the query. +- `work.analysis.runBasic` always resolves the time range and rescans provider logs. +- `work.analysis.runDeep` always runs against the latest collected basic result and sampled evidence. +- A previous `succeeded` record must not short-circuit explicit runs. +- `sourceDigest` is saved for display and future freshness checks, but v1 does not need automatic background invalidation. + +This keeps the useful "last result" behavior while preventing stale analysis from blocking new runs. + +## UI Design + +The current settings component can remain the entry surface, but labels and result sections need to shift from session wording to provider log wording. + +Required UI changes: + +- Describe the source as provider local logs/cache, not current sessions. +- Show one row per built-in provider: + - provider name + - status + - matched session count + - parse warning count +- Show when timestamps are based on file mtime fallback. +- For no data, say the selected provider has no matching local logs for the selected workspace/time range. +- For missing roots, say the provider log root was not found. +- For deep analysis, clarify that only sampled log evidence is used. + +The UI should avoid implying that a workspace must have an open Coder Studio session. + +## Privacy And Safety + +Provider logs may contain user prompts, assistant responses, command outputs, file paths, and sometimes tool results. The implementation must treat them as local sensitive data. + +Rules: + +- Do not print raw prompts/responses to server logs. +- Do not send whole provider logs to deep analysis. +- Limit evidence size. +- Prefer metadata and short excerpts. +- Never parse credential/config files as analysis evidence. +- Avoid reading provider account files such as OAuth tokens. +- Keep raw provider log content out of persisted `WorkAnalysisRecord` except bounded evidence summaries needed for deep analysis results. + +## Error Handling + +Adapter errors should not fail the whole basic analysis unless every provider fails due to a shared system problem. + +Per-provider outcomes: + +- Missing root: report `missing_root`, no sessions. +- Root exists but no matches: report `no_logs`, no sessions. +- Some files fail: report `partial`, include parsed sessions and parse error count. +- SQLite read failure: report `partial` if some data was read, otherwise `missing_root` or `partial` with warning depending on file existence. +- Unknown record shape: skip the record, increment parse error count, keep scanning. + +Deep analysis can fail independently without invalidating basic analysis. + +## File And Module Plan + +Expected new server modules: + +- `packages/server/src/work-analysis/log-sources/types.ts` +- `packages/server/src/work-analysis/log-sources/collector.ts` +- `packages/server/src/work-analysis/log-sources/codex.ts` +- `packages/server/src/work-analysis/log-sources/claude.ts` +- `packages/server/src/work-analysis/log-sources/gemini.ts` +- `packages/server/src/work-analysis/log-sources/cursor.ts` +- `packages/server/src/work-analysis/log-sources/opencode.ts` +- `packages/server/src/work-analysis/log-sources/path-encoding.ts` +- `packages/server/src/work-analysis/evidence-sampler.ts` + +Expected modified modules: + +- `packages/server/src/work-analysis/service.ts` +- `packages/server/src/work-analysis/basic-analyzer.ts` +- `packages/server/src/work-analysis/basic-schema.ts` +- `packages/server/src/work-analysis/types.ts` +- `packages/server/src/work-analysis/deep-prompt.ts` +- `packages/server/src/work-analysis/query.ts` +- `packages/server/src/storage/repositories/work-analysis-repo.ts` +- `packages/server/src/commands/work-analysis.ts` +- `packages/web/src/features/settings/components/session-analysis-settings.tsx` +- `packages/web/src/features/work-analysis/types.ts` +- `docs/help/work-analysis.md` + +Expected removed or retired modules: + +- `packages/server/src/work-analysis/session-selector.ts` +- `packages/server/src/work-analysis/evidence-collector.ts` + +They may remain temporarily during migration only if tests still cover old behavior, but the final behavior must not call Coder Studio session manager for work analysis data. + +## Testing + +### Adapter Tests + +Use fixtures rather than the developer's real home directory. + +Codex: + +- Select sessions by metadata `payload.cwd`. +- Exclude sessions outside time range. +- Count user, assistant, and tool records. +- Preserve model and git metadata when present. + +Claude: + +- Select sessions from encoded workspace directory. +- Confirm `cwd` mismatch is excluded or flagged. +- Count messages by role. +- Handle parse errors without failing the whole adapter. + +Gemini: + +- Match workspace via `.project_root`. +- Read `sessionId`, `startTime`, `lastUpdated`, and messages. +- Include `summary` as optional title/evidence metadata. +- Ignore projects whose `.project_root` does not match. + +Cursor: + +- Match encoded workspace project directory. +- Use transcript uuid as session id. +- Use file mtime when explicit timestamps are absent. +- Report timestamp quality as `file_mtime`. +- Count role and tool-like content entries. + +OpenCode: + +- Build a temporary SQLite fixture with `project`, `session`, `message`, and `part`. +- Match by `project.worktree` and `session.directory`. +- Count messages and parts by session. +- Report summary diff fields when present. + +### Collector Tests + +- Runs all 5 adapters. +- Aggregates provider statuses. +- Sorts sessions deterministically. +- Produces stable `sourceDigest` for unchanged source refs. +- Changes `sourceDigest` when matched source mtime, size, updated time, or session ids change. + +### Service Tests + +- `runBasic` rescans even when an existing record is `succeeded`. +- `get` returns the last saved record without scanning. +- Basic analysis succeeds when one provider is `partial`. +- Deep analysis uses sampled provider evidence, not terminal snapshots. +- Deep analysis provider selection uses headless availability, not largest session count. + +### UI Tests + +- Provider status rows render for all 5 built-in providers. +- No-log and missing-root states have distinct messages. +- Session count is labeled as provider local log matches. +- Deep analysis button remains disabled until basic result exists. + +### Docs Tests + +- Help text no longer says an open agent session is required. +- Help text explains provider local logs and data-source limitations. + +## Rollout + +1. Introduce types, collector, and fixtures. +2. Implement provider adapters behind tests. +3. Replace service dependencies from session selector/evidence collector to work log collector/evidence sampler. +4. Update schemas and frontend types. +5. Update UI copy and provider status display. +6. Update help docs. +7. Retire old session selector and evidence collector tests. + +## Acceptance Criteria + +- Running `工作分析` for a selected workspace scans provider local logs for all 5 built-in providers. +- Recently closed or never-opened-in-Coder-Studio provider sessions can appear in analysis if their provider logs match the workspace and time range. +- Explicitly running basic analysis does not return an old successful result without scanning. +- Basic analysis reports provider data-source status. +- Deep analysis uses bounded provider-log evidence. +- The UI no longer implies that Coder Studio sessions are the source of truth. diff --git a/docs/superpowers/specs/2026-06-04-agent-instructions-richer-generation-design.md b/docs/superpowers/specs/2026-06-04-agent-instructions-richer-generation-design.md new file mode 100644 index 000000000..4e6b0cfa7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-agent-instructions-richer-generation-design.md @@ -0,0 +1,295 @@ +# Richer Agent Instructions Generation Design + +## Goal + +Improve generated `.coder-studio/agent.md` so it works as a short project operating guide for coding agents, not just a safe repository summary. + +The output should stay compact enough to read in one to two screens, but it should provide better actionability in three areas: + +- project structure and architecture boundaries +- practical command and verification guidance +- file constraints and collaboration rules + +## Non-Goals + +- Do not turn `agent.md` into a long handbook or full architecture document. +- Do not require manual project-specific configuration before generation becomes useful. +- Do not replace the existing static generator path used by non-agent generation flows unless needed by shared summary code. +- Do not depend on Mermaid or other diagram formats. Output remains pure Markdown. + +## Recommended Approach + +Use a richer structured workspace summary as the main improvement point, then update the generation prompt to consume that summary. + +This keeps the system mostly automatic, improves output quality across repositories, and remains testable. It also avoids hardcoding full documents in the server while still allowing a small fixed set of high-value rules and constraints. + +## Alternatives Considered + +### 1. Prompt-only expansion + +Keep the current summary model and only ask the model to write more helpful content. + +Trade-off: +- Lowest engineering cost +- Weakest reliability because the model cannot infer high-value structure that is not present in the input + +### 2. Structured summary expansion + +Expand workspace intelligence with architecture, key directories, command coverage, and constraints, then update the prompt to use it. + +Trade-off: +- Moderate implementation cost +- Best balance of quality, determinism, and testability + +This is the chosen approach. + +### 3. Mostly server-rendered template + +Generate most of the final Markdown on the server and let the model fill only a few fields. + +Trade-off: +- Very stable output +- Too rigid and closer to hardcoded documentation than generation + +## Desired Output Shape + +Generated `agent.md` should stay pure Markdown and use these second-level sections in this order: + +- `Project Overview` +- `Architecture Map` +- `Key Directories` +- `Development Commands` +- `Workflow Expectations` +- `File Constraints` +- `Review Checklist` +- `Provider Notes` + +Content expectations: + +- `Project Overview` + - Short repository description + - Top-level stack/runtime summary + - Monorepo/single-package context if applicable +- `Architecture Map` + - Pure Markdown hierarchy + - Short role descriptions for major layers or packages +- `Key Directories` + - Only the most relevant 3-6 directories/packages + - One-line reason each matters to an agent +- `Development Commands` + - Real commands only + - Prefer root verification and CI-style commands when present +- `Workflow Expectations` + - Small fixed ruleset with optional project-specific additions when supported by facts +- `File Constraints` + - Boundaries and editing cautions + - Must not invent repo-specific rules without evidence + - May include small generic safety rules when no stronger repo facts exist +- `Review Checklist` + - Short, concrete pre-handoff checks +- `Provider Notes` + - Small fixed provider-specific notes carried forward from today + +## Data Model Changes + +Extend workspace intelligence so generation has richer structured facts. + +Current summary is too thin. The new summary should add the following fields or equivalent structure: + +- `workspaceKind` + - Examples: `monorepo`, `node_app`, `unknown` +- `topLevelDirectories` + - Sorted list of meaningful root directories, filtered to avoid noise +- `keyDirectories` + - Array of objects with: + - `path` + - `kind` + - `reason` +- `packages` + - Array of objects with: + - `path` + - `name` + - `role` + - `scripts` +- `documentationEntries` + - Important docs beyond just `README.md` and `docs/` +- `verificationCommands` + - Prioritized list of concrete commands useful before completion +- `fileConstraints` + - Structured constraints inferred from repository shape and known conventions + +These do not need to be exposed publicly outside generation if that increases churn, but they must be structured enough to test independently. + +## Inference Rules + +The summary builder should infer useful facts conservatively. + +### Workspace Kind + +Infer `monorepo` when workspace markers such as `pnpm-workspace.yaml` exist or multiple `packages/*/package.json` files are present. + +### Key Directories + +For this repository family and similar monorepos, prioritize directories like: + +- `packages/web` +- `packages/server` +- `packages/providers` +- `packages/core` +- `packages/cli` +- `docs` +- `e2e` +- `scripts` + +Selection rules: + +- only include directories that exist +- cap at 3-6 items +- prefer code-bearing directories over support directories +- produce short deterministic reasons, not vague summaries + +### Package Roles + +Infer role from path and package name with conservative heuristics: + +- `web` => frontend UI +- `server` => backend/server/runtime/WS commands +- `providers` => provider integrations/adapters +- `core` => shared protocol/types/runtime contracts +- `cli` => launcher or command-line entrypoint +- `utils` => shared utilities + +If role confidence is weak, fall back to a generic but truthful description such as "shared package" instead of guessing. + +### Documentation Entries + +Prefer documentation with operational value: + +- `README.md` +- `docs/help/*` +- `docs/wiki/*` +- root contribution or architecture docs if present + +Cap the list so the output remains compact. + +### Command Prioritization + +Current generation overemphasizes only `dev/build/lint`. The new prioritization should: + +- keep existing script-derived commands +- include stronger verification commands such as: + - `pnpm ci:test` + - `pnpm ci:typecheck` + - `pnpm ci:verify` + - `pnpm acceptance:phase1` +- include package-level test commands only if clearly available and useful +- rank commands by likely usefulness to an agent: + - verification first + - then build/typecheck/lint + - then local dev commands + +### File Constraints + +This section should be assembled from a mix of repository facts and a small safe template. + +Examples of allowed automatically generated constraints: + +- preserve existing package boundaries in a monorepo +- keep frontend changes in `packages/web` and backend runtime changes in `packages/server` unless cross-package edits are required +- prefer existing patterns and naming conventions in the touched package +- avoid unrelated refactors across packages while solving a targeted task +- use repository-level verification commands before claiming completion + +These are acceptable because they are grounded in repo shape and established collaboration expectations, not arbitrary invention. + +## Prompt Changes + +The prompt should be updated to: + +- request the new section order +- explicitly ask for a Markdown hierarchy under `Architecture Map` +- ask for 3-6 key directories only +- ask for practical commands, not placeholders +- ask for concise but specific file constraints +- forbid invented package roles, commands, or rules + +The prompt should still require a single JSON object result and preserve strict parsing expectations. + +## Error Handling + +No new user-visible error classes are required for this enhancement. + +If richer summary data cannot be derived: + +- generation should fall back to thinner facts rather than fail +- missing optional fields should simply produce shorter sections +- the system must still return valid Markdown when enough baseline facts exist + +## Testing Strategy + +### Workspace Intelligence Tests + +Add focused tests for the richer summary builder: + +- monorepo detection +- key directory selection and ordering +- package role inference +- command prioritization +- documentation entry selection +- file constraint generation + +### Prompt Tests + +Add tests that assert the prompt now requires: + +- the new section order +- Markdown hierarchy for `Architecture Map` +- a limited number of key directories +- concise constraints and review checklist behavior + +### Command-Level Tests + +Extend agent instructions command tests so mocked provider output can be validated against stronger expectations for the prompt input and expected section presence. + +### Real End-to-End Verification + +After implementation: + +- run the targeted server tests +- run real `Codex` generation through the existing WS command path +- inspect the resulting `.coder-studio/agent.md` +- confirm it includes: + - architecture map as pure Markdown hierarchy + - key directories with meaningful roles + - richer command guidance + - file constraints that are short and actionable + +## Implementation Outline + +1. Expand workspace intelligence data gathering and inference helpers. +2. Update the generation prompt to consume the richer facts and new section contract. +3. Update tests for summary inference and prompt expectations. +4. Run real generation and tune compactness if output becomes too verbose. + +## Risks + +- Over-inference can create inaccurate constraints or package roles. +- Too much data can make the prompt noisy and reduce output quality. +- Excessive command lists can reduce readability. + +Mitigations: + +- cap lists aggressively +- prefer deterministic heuristics +- fall back to omission instead of guessing +- verify with a real generation pass before considering the work done + +## Success Criteria + +The enhancement is successful when a real generated `agent.md`: + +- clearly explains the repository shape in pure Markdown +- gives an agent a useful shortlist of where to look first +- includes better verification guidance than only `dev/build/lint` +- includes short file constraints that reduce low-quality cross-cutting edits +- remains compact enough to read quickly diff --git a/docs/superpowers/specs/2026-06-04-tasks-verification-git-review-design.md b/docs/superpowers/specs/2026-06-04-tasks-verification-git-review-design.md new file mode 100644 index 000000000..3e28df72c --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-tasks-verification-git-review-design.md @@ -0,0 +1,584 @@ +# Tasks Verification And Git Review Design + +## Summary + +This design defines a staged upgrade for Coder Studio's post-agent development workflow. + +The first track adds a `Tasks` surface beside the existing terminal panel. It provides managed project commands such as `Verify`, `Test`, `Lint`, and `Build`, while still using terminal output as the source of truth. + +The second track upgrades Git review with hunk-level staging and stronger diff actions. Together, these features create a tighter loop: + +1. an agent changes code +2. the user runs a managed verification task +3. the result is visible next to terminal output +4. the user reviews and stages only the intended Git changes + +This design intentionally does not pursue VS Code extension compatibility, Debug Adapter Protocol support, or a full Problems panel in this phase. + +## Problem + +Coder Studio already combines agent sessions, terminals, files, and Git review in one browser workspace. The remaining workflow gap is not "more IDE surface area" in general. The sharper gap is that users still have to manually coordinate verification and fine-grained Git cleanup after an agent finishes work. + +Current friction points: + +- verification commands are run manually in shell terminals +- the UI does not track whether the current workspace last verified successfully +- terminal output is available, but task intent, duration, exit code, and rerun state are not modeled +- Git review supports file-level staging and diff viewing, but not hunk-level staging +- users must switch context to decide what should be staged, discarded, or kept for follow-up + +The product should optimize for the agent workflow before adding broader IDE features such as debugger support or extension-host compatibility. + +## Goals + +- Add a bottom-panel `Tasks` tab beside `Terminal`. +- Automatically discover common project verification commands. +- Provide a default `Run Verify` action when a likely verification command exists. +- Run tasks through managed terminal sessions so output remains live and inspectable. +- Track task status, duration, exit code, and last run metadata. +- Support stop and rerun for managed task runs. +- Show the latest verification result in lightweight agent and Git review contexts. +- Add hunk-level Git staging and unstaging. +- Add common review actions directly inside the diff surface. + +## Non-Goals + +- Full VS Code Tasks compatibility. +- Debugging, breakpoints, variables, call stack, or `launch.json`. +- A complete Problems panel. +- VS Code extension host or Marketplace compatibility. +- CI service integration. +- Cross-machine task history sync. +- Full Git graph, pull request, or issue integration. +- Three-way merge conflict editor in the first Git review pass. + +## Approaches Considered + +### 1. Put verification actions in the workspace top bar + +This gives high visibility, but it separates the command from its output. Users would still need to move to the terminal panel to understand what happened. + +This approach is rejected as the primary surface. A compact top-level status can be added later, but not as the main interaction model. + +### 2. Put verification in the Git panel + +This aligns with pre-commit review, but verification is not only a Git action. Users often run tests while an agent is still working or before they inspect the diff. + +This approach is useful as a secondary shortcut only. + +### 3. Put `Tasks` beside `Terminal` + +This keeps the feature close to its natural output surface. Tasks are managed commands, not debugger sessions. The existing terminal infrastructure can own process output, resizing, replay, snapshots, and mobile behavior. + +This is the recommended approach. + +## Recommended Scope + +### Phase 1: Managed Tasks MVP + +The first phase adds task discovery, task execution, status tracking, and the bottom-panel UI. + +#### Task Surface + +Add a new bottom-panel tab beside `Terminal`: + +```text +Bottom Panel +[Terminal] [Tasks] [Run Verify] [Run Test] [Run Lint] +``` + +The `Tasks` tab lists discovered tasks for the active workspace: + +```text +Tasks + Verify pnpm ci:verify Failed 42s [Rerun] + Test pnpm ci:test Passed 18s [Run] + Lint pnpm lint Not run [Run] + Build pnpm build Not run [Run] +``` + +Each row should show: + +- task label +- command preview +- status: `not_run`, `queued`, `running`, `passed`, `failed`, `stopped` +- duration for completed runs +- active action: `Run`, `Stop`, or `Rerun` + +#### Task Discovery + +The server should discover tasks from workspace files in this order: + +1. explicit Coder Studio task config +2. package scripts +3. repository conventions +4. language ecosystem files + +Supported first-pass sources: + +- `.coder-studio/tasks.json` +- `package.json` +- `pnpm-workspace.yaml` +- `Cargo.toml` +- `go.mod` +- `pyproject.toml` +- `Makefile` + +Default inferred task kinds: + +- `verify` +- `test` +- `lint` +- `build` +- `dev` +- `custom` + +For this repository, discovery should prefer `pnpm ci:verify` as the default `Verify` task because it is the documented repository validation command. + +#### Task Execution + +Task runs should be managed server-side. A run creates or reuses a terminal with a task identity. + +Task terminals should be distinct from shell terminals and agent terminals: + +```ts +type TerminalKind = "shell" | "agent" | "task"; +``` + +The terminal remains responsible for: + +- live output +- replay +- snapshot hydration +- resize +- close behavior +- mobile terminal rendering + +The task layer is responsible for: + +- task definition +- run id +- status +- start and finish timestamps +- exit code +- command preview +- terminal id +- last output summary + +#### Task Commands + +Add command-level operations: + +- `task.discover` +- `task.list` +- `task.run` +- `task.stop` +- `task.rerun` +- `task.history` + +`task.run` should return a `TaskRun` immediately after the managed terminal has been created. The run then updates over workspace events. + +#### Task Events + +Add workspace-scoped task events: + +- `task.discovered` +- `task.run.started` +- `task.run.updated` +- `task.run.finished` +- `task.run.stopped` + +The web client should subscribe to active workspace task events in the same way it subscribes to terminal and Git events. + +#### Failure Summary + +The MVP should not parse every compiler and test output. It should still record enough failure context to be useful: + +- command +- cwd +- exit code +- duration +- last non-empty output lines +- linked terminal id + +The last output summary should be capped to avoid storing full logs in task state. The terminal replay remains the source for full output. + +### Phase 2: Agent And Git Context Integration + +Once task execution is reliable, surface the latest verification result in the workflows where it matters. + +#### Agent Context + +Agent panes should show a compact latest verification state for the workspace: + +```text +Last verify: Failed +pnpm ci:verify · exit 1 · 42s +[View output] [Rerun] +``` + +This should be read-only context plus navigation. Agent panes should not become a second task manager. + +#### Git Review Context + +The Git panel or diff review header should show: + +```text +Verification: Failed [View Tasks] [Rerun Verify] +``` + +This helps users avoid committing unverified agent output. + +#### Supervisor Context + +Supervisor can consume task result metadata later, but automatic repair loops are not part of the MVP. The first integration should only expose enough state for a human to rerun and inspect verification. + +### Phase 3: Git Review Upgrade + +After managed tasks are usable, Git review should gain fine-grained staging. + +#### Hunk-Level Staging + +The diff viewer should allow: + +- stage hunk +- unstage hunk +- discard hunk + +For staged diffs: + +- `unstage hunk` applies the reverse patch from index to working tree staging state + +For unstaged diffs: + +- `stage hunk` applies the selected patch to the index +- `discard hunk` applies the reverse patch to the working tree + +The server should execute these operations with `git apply`-based patch application rather than ad hoc text editing. If patch application fails because the file changed, the UI should show a stale diff error and offer refresh. + +#### Line-Level Staging + +Line-level staging is valuable, but it is more complex than hunk staging because it requires constructing valid partial patches. + +Recommended sequence: + +1. implement hunk staging first +2. add selected-line staging once the diff model can generate stable patch slices + +#### Diff Review Actions + +Add direct actions to the diff surface: + +- stage file +- unstage file +- discard file +- stage hunk +- unstage hunk +- discard hunk +- open file +- copy path +- refresh diff + +This reduces movement between the Git tree and the central editor surface. + +#### Commit Assistance + +Add commit message assistance after hunk staging exists. + +Recommended first version: + +- generate a commit message from staged diff +- keep the result editable +- do not auto-commit +- do not require an AI provider if no provider is configured + +If no provider is available, the action can be hidden or disabled with a clear message. + +## Data Model + +### TaskDefinition + +```ts +interface TaskDefinition { + id: string; + workspaceId: string; + kind: "verify" | "test" | "lint" | "build" | "dev" | "custom"; + label: string; + command: string; + args: string[]; + cwdPath?: string; + source: + | "coder-studio" + | "package-json" + | "pnpm-workspace" + | "cargo" + | "go" + | "python" + | "makefile" + | "inferred"; + priority: number; +} +``` + +### TaskRun + +```ts +interface TaskRun { + id: string; + workspaceId: string; + taskId: string; + terminalId: string; + status: "queued" | "running" | "passed" | "failed" | "stopped"; + command: string; + args: string[]; + cwdPath?: string; + startedAt: number; + finishedAt?: number; + exitCode?: number; + summary?: { + tailLines: string[]; + }; +} +``` + +### Git Hunk Operation + +```ts +interface GitHunkOperation { + workspaceId: string; + path: string; + staged: boolean; + hunkId: string; + operation: "stage" | "unstage" | "discard"; +} +``` + +The client should not send arbitrary patch text as the primary API contract. Prefer sending a stable hunk id derived from the current diff payload, then let the server validate it against a fresh or cached diff. This reduces the risk of applying stale or tampered patches. + +## UI Placement + +### Bottom Panel + +The bottom panel becomes the primary home for command execution: + +- `Terminal` remains manual shell terminal management +- `Tasks` manages discovered and recent task commands + +The active task output should still open in an xterm surface. The `Tasks` tab can either show output below the task list or jump to the terminal tab with the task terminal selected. The MVP should prefer jumping to the terminal tab to avoid duplicating terminal rendering. + +### Agent Pane + +Agent panes get compact task status only: + +- latest verify state +- view output +- rerun verify + +### Git Panel And Diff Surface + +Git panel gets verification status as a compact banner. The diff surface gets review actions local to the current file or hunk. + +## Error Handling + +### Task Discovery + +If discovery fails for one source, it should not fail all discovery. + +Example: + +- invalid `package.json` should report a source warning +- valid `Makefile` and inferred commands should still appear + +### Task Run + +Task run errors should map to clear states: + +- failed to create terminal: `failed` +- command exits non-zero: `failed` +- user stops run: `stopped` +- process exits zero: `passed` + +### Stale Git Diff + +If hunk staging fails because the diff changed: + +- show `Diff changed. Refresh and try again.` +- keep the file selected +- refresh Git status after the user confirms or clicks refresh + +## File Boundaries + +Expected primary areas: + +- `packages/core/src/domain/types.ts` +- `packages/server/src/commands/terminal.ts` +- `packages/server/src/commands/git.ts` +- `packages/server/src/git/diff.ts` +- `packages/server/src/terminal/manager.ts` +- `packages/web/src/features/terminal-panel/*` +- `packages/web/src/features/workspace/actions/use-git-actions.ts` +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` + +Likely new server files: + +- `packages/server/src/tasks/discovery.ts` +- `packages/server/src/tasks/manager.ts` +- `packages/server/src/commands/task.ts` +- `packages/server/src/git/hunk-operations.ts` + +Likely new web files: + +- `packages/web/src/features/tasks/actions/use-task-actions.ts` +- `packages/web/src/features/tasks/atoms.ts` +- `packages/web/src/features/tasks/views/shared/tasks-panel.tsx` + +## Testing Strategy + +### Task Tests + +Server tests: + +- discovers `pnpm ci:verify` as the preferred verify task in this repository +- discovers `package.json` scripts +- handles invalid project files without failing all discovery +- creates a task terminal on `task.run` +- marks run passed on exit code `0` +- marks run failed on non-zero exit +- marks run stopped when stopped by user + +Web tests: + +- renders `Tasks` tab beside `Terminal` +- lists discovered tasks +- starts a task from the task row +- shows running and completed state +- exposes rerun after completion + +### Git Tests + +Server tests: + +- stages one hunk from an unstaged text diff +- unstages one hunk from a staged text diff +- discards one hunk from an unstaged text diff +- rejects stale hunk operations +- refreshes Git state after successful hunk operation + +Web tests: + +- renders hunk actions in diff view +- calls the expected Git hunk operation +- shows stale diff error +- keeps file selection stable after refresh + +## Rollout Plan + +### Milestone 1 + +Deliver: + +- task type definitions +- task discovery +- `Tasks` tab +- `task.run` +- task terminal output +- basic status tracking + +Success criterion: + +- a user can open the bottom `Tasks` tab and run `Verify` for the active workspace. + +### Milestone 2 + +Deliver: + +- stop and rerun +- run history for the latest run per task +- failure summary +- compact latest verify state in Agent and Git review contexts + +Success criterion: + +- a user can tell whether the latest verification passed without manually scanning terminal tabs. + +### Milestone 3 + +Deliver: + +- hunk-level stage, unstage, and discard +- diff-local review actions + +Success criterion: + +- a user can stage only part of an agent-generated file change from the diff surface. + +### Milestone 4 + +Deliver: + +- optional line-level staging +- commit message assistance from staged diff + +Success criterion: + +- a user can prepare a clean commit from partial agent output without leaving Code Studio. + +## Implementation Defaults + +The first implementation should use these defaults. + +### Task Terminals + +Task terminals are visible in the existing terminal selector and visually marked as managed. + +The `Tasks` tab does not embed a second xterm instance. Selecting `View output` or starting a task switches to the existing terminal tab with the managed task terminal selected. + +### Task Run Retention + +Keep the latest run per task in memory for the MVP. + +Do not persist task history to SQLite in the first pass. If users need longer history after the MVP, add persistence as a separate follow-up. + +### Task Config Schema + +The first `.coder-studio/tasks.json` schema is: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "verify", + "label": "Verify", + "kind": "verify", + "command": "pnpm", + "args": ["ci:verify"], + "cwdPath": "." + } + ] +} +``` + +Schema rules: + +- `version` must be `1`. +- `tasks` must be an array. +- `id`, `label`, `kind`, and `command` are required. +- `args` defaults to an empty array. +- `cwdPath` defaults to the workspace root. +- config-defined tasks override discovered tasks with the same `kind` and `id`. + +### Hunk Identity + +Hunk ids are generated server-side when building Git diff payloads. + +The client sends only: + +- workspace id +- file path +- staged flag +- hunk id +- operation + +The server validates the hunk id against the current diff before applying any patch. If validation fails, the operation returns a stale-diff error. diff --git a/docs/superpowers/specs/2026-06-04-work-analysis-workspace-path-filter-design.md b/docs/superpowers/specs/2026-06-04-work-analysis-workspace-path-filter-design.md new file mode 100644 index 000000000..ac721b69a --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-work-analysis-workspace-path-filter-design.md @@ -0,0 +1,314 @@ +# Work Analysis Workspace Path Filter Redesign + +Date: 2026-06-04 +Status: Draft +Owner: spencer + +## Problem + +当前 `工作分析` 把“已打开的 workspace”当成分析前置筛选条件: + +- 前端设置页只允许从当前 `orderedWorkspaces` 里选 workspace。 +- 服务端 `work.analysis.*` 查询模型要求 `workspaceIds`。 +- `WorkAnalysisService` 先把 `workspaceIds` 解析成 workspace path,再把这些 path 传给 provider log source 做发现。 + +这和真实需求不一致。 + +用户希望: + +- 工作分析应先扫描 agent/provider 日志里在所选时间范围内命中的全部 workspace 目录。 +- 即使某个目录从未在 Coder Studio 里 `open workspace`,只要它在 provider 日志中出现,也必须进入分析域。 +- workspace 不应该作为“事前屏蔽不相关目录”的条件,而应该作为“分析结果出来后的筛选项”。 +- 用户可以先看全部目录的汇总,再缩小到一个或多个具体路径。 + +因此,当前“只看已打开 workspace”的设计在产品语义上是错误的。 + +## Goals + +- 工作分析不再依赖 Coder Studio 已打开 workspace 列表作为前置输入。 +- provider log scan 在给定时间范围内收集所有命中的 `workspacePath`。 +- `workspacePath` 成为工作分析域内的一级筛选维度。 +- 前端路径筛选项来自分析结果中的 `availableWorkspacePaths`,而不是 `orderedWorkspacesAtom`。 +- 初次分析默认汇总全部命中路径;用户可再按路径多选缩小结果。 +- 保持 5 个内置 provider 都遵循同一语义。 + +## Non-Goals + +- 不保留旧 `workspaceIds` 查询协议的兼容层。 +- 不尝试把未打开目录映射成伪 workspace id。 +- 不在本轮引入“已打开 workspace 标记”或路径分组 UI。 +- 不把路径筛选扩展成更复杂的 tag、搜索或树形浏览器。 +- 不修改 provider 原始日志结构。 + +## User Decisions Captured + +- 不考虑兼容旧方案,应按正确方案重做。 +- workspace 只作为分析结果筛选项,不再作为分析前置筛选项。 +- UI 采用最简单的“纯路径多选”形式。 +- 不要求路径选项和已打开 workspace 产生任何绑定关系。 + +## Current State + +### Query Model + +当前 `work.analysis.get`、`work.analysis.runBasic`、`work.analysis.runDeep` 都要求: + +- `workspaceIds: string[]` +- `timeRange` + +这使“分析域”在请求发出前就被限制为已打开 workspace。 + +### Service Data Flow + +当前 [`packages/server/src/work-analysis/service.ts`](../../../packages/server/src/work-analysis/service.ts) 的关键流程是: + +1. 规范化 `workspaceIds` +2. 通过 `workspaceResolver` 解析成 `workspacePaths` +3. 把这些 `workspacePaths` 传给 `workLogCollector` +4. provider adapter 只在这些 path 范围内找日志 +5. analyzer 基于这个预筛选后的 session 集合做聚合 + +这意味着: + +- 未打开 workspace 的日志永远不会进入分析域。 +- `available workspaces` 的来源是 app state,而不是 provider 历史。 + +### Frontend State + +当前 [`packages/web/src/features/settings/components/session-analysis-settings.tsx`](../../../packages/web/src/features/settings/components/session-analysis-settings.tsx): + +- 用 `orderedWorkspacesAtom` 渲染复选框 +- 用 `activeWorkspaceId` 做默认值 +- 把 `selectedWorkspaceIds` 直接发给 `work.analysis.*` + +这使设置页本质上变成了“已打开 workspace 过滤器”,不是“分析结果路径过滤器”。 + +## Desired Model + +### Core Principle + +工作分析的输入只有两类: + +- 时间范围 +- 可选的 `workspacePaths` 结果过滤器 + +其中: + +- `workspacePaths` 为空或省略,表示“扫描并汇总时间范围内全部命中的路径” +- `workspacePaths` 非空,表示“只在已发现路径中保留这些路径对应的数据” + +### Two-Phase Mental Model + +正确语义应是: + +1. **发现阶段**:provider 日志扫描命中的全部 session,并记录它们的 `workspacePath` +2. **分析阶段**:根据用户选择的 `workspacePaths` 对 discovered sessions 进行过滤和聚合 + +注意这里的“发现”不是单独暴露为用户操作,而是 `runBasic/get` 的内部流程。 + +### UI Semantics + +设置页进入工作分析后: + +1. 用户先只指定时间范围 +2. 页面请求“全路径汇总”分析 +3. 返回结果中包含 `availableWorkspacePaths` +4. 页面用这些路径渲染多选框,默认全选 +5. 用户取消部分路径后,再次请求分析,传所选 `workspacePaths` + +因此 UI 中的路径列表是“分析结果的一部分”,不是“app shell 里的 workspace 列表”。 + +## Proposed Architecture + +### 1. Query Schema Rewrite + +将工作分析查询模型改为: + +- `workspacePaths?: string[]` +- `timeRange` + +规则: + +- 未提供 `workspacePaths` 或传空数组:不过滤路径 +- 提供非空数组:按绝对路径字符串精确匹配过滤 + +不再接受 `workspaceIds`。 + +### 2. Provider Log Source Contract Rewrite + +当前 provider source discover 输入依赖: + +- `workspacePaths` +- `timeRange` + +这需要改成只依赖: + +- `timeRange` + +provider adapter 的职责变为: + +- 扫描 provider 自身日志根目录或数据库 +- 找出时间范围内命中的 session +- 从记录中提取 `workspacePath` +- 返回归一化 `WorkLogSession[]` + +是否保留某个 session,不再由外部 path 白名单决定,而由 provider adapter 根据时间范围和日志完整性决定。 + +### 3. Service-Level Filtering + +`WorkAnalysisService` 的新流程: + +1. 规范化查询 +2. 调用 collector,按时间范围收集全部 provider sessions +3. 从 collection 中提取唯一 `availableWorkspacePaths` +4. 若查询指定了 `workspacePaths`,则按路径过滤 session +5. 用过滤后的 session 跑 basic analyzer +6. 在 record/basic result 中写入 `availableWorkspacePaths` +7. deep analysis 也只基于过滤后的 session/evidence + +这里的关键变化是: + +- provider collector 负责“收集全部” +- service 负责“按用户路径筛选” + +### 4. Result Shape Rewrite + +基础分析结果新增: + +- `availableWorkspacePaths: string[]` + +它表示: + +- 在当前时间范围内,从 provider 日志实际发现过的全部 workspace 路径 +- 已按 provider parse/时间范围规则归一化后的可选路径全集 + +同时保留现有汇总字段,但 `workSurface.workspaceIds` 应改为路径语义,例如: + +- `workSurface.workspacePaths: string[]` + +避免继续使用错误的 id 概念。 + +### 5. Frontend Filter Rewrite + +设置页状态改为: + +- `selectedWorkspacePaths: string[]` +- 初始为空,表示尚未拿到分析结果路径全集 + +流程: + +1. 首次请求只带 `timeRange` +2. 收到结果后读取 `availableWorkspacePaths` +3. 如果用户尚未手动改过筛选,则把 `selectedWorkspacePaths` 设为全部路径 +4. 当用户调整路径多选时,重新请求分析,传 `{ workspacePaths: selectedWorkspacePaths, timeRange }` + +显示规则: + +- 没有结果前,不渲染路径多选列表 +- 有结果但 `availableWorkspacePaths` 为空,显示“该时间范围内没有发现 provider 日志目录” +- 路径直接显示绝对路径字符串 + +## Data Flow + +### Basic Analysis + +1. UI 请求 `work.analysis.get({ timeRange })` +2. service 扫描 provider logs,得到所有命中 session +3. service 产出: + - `availableWorkspacePaths` + - 基于全部路径聚合的 basic result +4. UI 渲染结果和路径多选,默认全选 +5. 用户选择子集路径 +6. UI 请求 `work.analysis.get({ timeRange, workspacePaths })` +7. service 复扫 provider logs 并在 service 层过滤路径 +8. UI 渲染所选路径对应分析结果 + +### Deep Analysis + +1. UI 在已有路径筛选状态下触发 `runDeep` +2. 请求参数沿用当前 `workspacePaths + timeRange` +3. service 复扫并按路径过滤 +4. evidence sampler 只从过滤后的 session 中抽样 +5. deep runner 基于过滤后的 basic/evidence 运行 + +## Testing Strategy + +### Server Tests + +新增或改写以下测试: + +- `WorkAnalysisService` 在未提供 `workspacePaths` 时,会聚合多个未打开目录的 session +- `WorkAnalysisService` 在提供 `workspacePaths` 时,只保留匹配路径的 session +- `basic-analyzer` 的 `workSurface` 和新 `availableWorkspacePaths` 使用路径语义 +- `work-analysis` commands schema 改为接受 `workspacePaths` +- provider log collector 相关测试不再依赖 discover 输入里的 `workspacePaths` + +### Frontend Tests + +新增或改写以下测试: + +- settings page 首次进入分析页时,初次请求不发送 `workspaceIds` +- 收到分析结果后,路径多选项来自 `availableWorkspacePaths` +- 调整路径多选后,请求发送 `workspacePaths` +- 页面不再依赖 `orderedWorkspacesAtom` 渲染分析筛选项 + +### E2E + +新增一条真实路径语义的 e2e: + +- provider 日志中存在两个未打开目录 +- 设置页工作分析首次展示汇总结果 +- 路径筛选项展示这两个目录 +- 取消一个目录后,结果只剩另一个目录对应数据 + +## Risks + +### Provider Scan Cost + +去掉前置 workspace path 白名单后,provider scan 范围会扩大。 + +缓解: + +- 仍严格按时间范围裁剪 +- adapter 先做轻量 metadata 过滤,再读重内容 +- 保持 basic analysis summary-first,不加载全文 + +### Path Normalization Drift + +不同 provider 记录的路径格式可能不完全一致,例如大小写、symlink、Windows 分隔符。 + +本轮先采用现有绝对路径字符串语义,不引入复杂 canonicalization;如果后续出现实测问题,再单独设计路径归一化策略。 + +### Empty First Result UX + +首次进入时路径筛选项要等第一次分析结果返回后才知道,这比“直接用已打开 workspace 列表”多一步。 + +这是正确代价,因为路径全集本来就应该来自 provider 历史,而不是 app state。 + +## Migration Impact + +这是一次显式语义重写,不做向后兼容。 + +影响: + +- 前后端 `work analysis` 查询类型全部改成 `workspacePaths` +- 基础分析结果中的 workspace 维度改成 path 语义 +- 旧测试和旧 seed 数据凡是使用 `workspaceIds` 作为分析筛选输入的,都要更新 + +不要求保留旧 query record 的读取兼容语义;按新方案统一即可。 + +## Recommended Implementation Order + +1. 改 query types / commands schema 到 `workspacePaths` +2. 改 service 与 collector contract,去掉 discover-time path 白名单 +3. 改 analyzer/result types,加入 `availableWorkspacePaths` +4. 改设置页状态机,路径筛选来源切到分析结果 +5. 补齐 server/web/e2e 测试 + +## Acceptance Criteria + +- 用户未打开某个 workspace,但它出现在 provider 日志中时,工作分析仍能发现并展示该路径。 +- 工作分析首次运行时默认汇总全部命中路径,而不是只看当前打开 workspace。 +- 用户可以从结果里的路径列表中多选一个或多个路径重新查看分析结果。 +- 前端和服务端都不再把“已打开 workspace”当成工作分析前置筛选条件。 +- 5 个内置 provider 的工作分析语义保持一致。 diff --git a/docs/superpowers/specs/2026-06-04-workspace-history-design.md b/docs/superpowers/specs/2026-06-04-workspace-history-design.md new file mode 100644 index 000000000..d87267a33 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-workspace-history-design.md @@ -0,0 +1,264 @@ +# Workspace History Design + +## Summary + +This design adds a persistent `Recent Workspaces` list to the shared workspace launch panel so users can reopen previously used project folders directly after the app or a workspace has been closed. The history is stored separately from the current open-workspace set so bootstrap routing, active workspace state, and close semantics remain unchanged. + +## Problem + +The current workspace model only persists workspaces that are still open. + +- `workspace.open` creates or returns a live workspace entry. +- `workspace.close` deletes that workspace entry. +- `workspace.list` is used during bootstrap to decide whether the app should route to `/workspace` or stay on `/`. + +That behavior is correct for active workspaces, but it means a previously opened project disappears completely once it is closed. Users then have to browse the filesystem again from the launch panel to find the same folder. + +The missing piece is a separate concept for "recently opened workspaces" that survives workspace closure and server restart without changing the meaning of the existing open-workspace APIs. + +## Goals + +- Persist a recent workspace list across workspace closure and server restart. +- Show that recent list inside the existing shared workspace launch panel. +- Allow clicking a recent entry to immediately reopen that workspace. +- Keep `workspace.list` scoped to currently open workspaces only. +- Reuse existing `workspace.open` success and error flows instead of creating a second open path. + +## Non-Goals + +- Pinning, starring, or manually reordering history entries. +- Removing invalid history entries before the user clicks them. +- Adding a separate global workspace manager page. +- Changing multi-workspace bootstrap or routing behavior. +- Reusing historical workspace ids across reopen events. + +## Approaches Considered + +### 1. Browser-local history in `localStorage` + +This is the lightest implementation, but it does not fit the product direction. + +- history would be tied to one browser profile +- mobile or second-browser access would not see the same recent list +- the app already uses server-owned persistence for adjacent workspace activity data such as `workspace.lastViewedTarget` + +This approach is rejected. + +### 2. Separate recent-workspace history backed by `settingsRepo` + +This keeps history independent from live workspace state while using an existing persistence mechanism already meant for global app state. + +- survives restart +- available to all browser clients connected to the same local server +- does not interfere with `workspace.list` +- matches the existing `workspace.lastViewedTarget` storage pattern + +This is the recommended approach. + +### 3. Expand `workspaceRepo` to keep closed workspaces + +This approach would mix active and historical concepts in one store. + +- closed workspaces would still appear in `workspace.list` +- bootstrap route decisions would become wrong +- closing a workspace would no longer mean "remove from active set" + +This approach is rejected. + +## Recommended Design + +### Data Model + +Add a shared type in `packages/core`: + +```ts +export interface WorkspaceHistoryEntry { + path: string; + name: string; + lastOpenedAt: number; +} +``` + +Design rules: + +- `path` is the stable identity and dedupe key. +- `workspaceId` is not stored because reopened workspaces may receive a new id after being closed. +- `name` is derived from `basename(path)` and falls back to `path` if needed. +- the stored list is sorted by `lastOpenedAt` descending. +- the list is capped at 20 entries. + +### Server Persistence + +Recent-workspace history should be stored under a new settings key: + +```ts +workspace.history +``` + +The persistence implementation should live in a small server-side helper dedicated to: + +- reading the stored history list +- validating and normalizing entries +- recording a successful workspace open + +The helper should not live inside `WorkspaceRepo`, because `WorkspaceRepo` currently models the active workspace set only. + +### Server Behavior + +#### Read path + +Add a new command: + +- `workspace.history.list` + +It returns normalized `WorkspaceHistoryEntry[]`. + +#### Write path + +Do not add a public `workspace.history.record` command. + +Instead, record history as part of the existing `workspace.open` server command after `ctx.workspaceMgr.open(...)` succeeds. That keeps all open entry points aligned: + +- launch modal open +- diagnostics retry flows +- any future caller that uses `workspace.open` + +Recording rules: + +1. normalize the opened path +2. remove any existing history entry with the same path +3. prepend the new entry with current timestamp +4. trim to the maximum length +5. write the final list back to `settingsRepo` + +### Client Behavior + +The shared launch modal should load recent history alongside the current directory browser data. + +The browse request and history request should be allowed to resolve independently. A history load failure should degrade the launch panel to its existing directory-browser-only behavior instead of blocking workspace launch entirely. + +The action layer in `use-workspace-launch-actions.ts` should gain a reusable helper: + +- `openWorkspaceByPath(path: string)` + +That helper should contain the existing post-open behavior that is currently coupled to the selected-directory flow: + +- dispatch `workspace.open` +- persist `workspace.lastViewedTarget` +- update `activeWorkspaceIdAtom` +- write the workspace into `workspacesAtom` +- hydrate editor UI state +- update `workspaceOrderAtom` +- navigate to `/workspace` when launched from outside the workspace route +- close the modal on success +- preserve the existing diagnostics redirect on failure + +Both of these launch paths should call the same helper: + +- directory selection + `Start Workspace` +- direct click on a recent history entry + +### Launch Panel UI + +The feature should be added to the existing shared modal in `workspace-launch-modal.tsx`, because that surface is already reused by: + +- welcome page +- top bar +- command palette +- mobile workspace flows + +#### Desktop layout + +Show a `Recent Workspaces` section in the launch modal before or beside the directory browser content. + +Each history row should show: + +- workspace name +- full path +- optional recency metadata if the existing layout has room + +Clicking a history row should immediately open that workspace. It should not require selecting the row and then clicking `Start Workspace`. + +#### Mobile layout + +Render the recent history block above the directory list inside the same sheet body. + +Clicking a history row should also immediately open the workspace on mobile. + +### Failure Handling + +The first version should reuse the current `workspace.open` failure behavior. + +If a history path no longer exists or is no longer accessible: + +- `workspace.open` fails +- the client follows the current diagnostics redirect path with the selected workspace path preserved + +This keeps the implementation small and consistent. Invalid-history pruning can be added later if needed. + +### Architecture Notes + +The design depends on preserving one hard boundary: + +- `workspace.list` means "currently open workspaces" +- `workspace.history.list` means "recently opened workspaces" + +Bootstrap code in `useBootstrap.ts` must continue to use only `workspace.list` when deciding whether the app should route to `/workspace` or remain on `/`. + +## File Boundaries + +Primary files expected to change: + +- `packages/core/src/domain/types.ts` +- `packages/server/src/commands/workspace.ts` +- `packages/server/src/commands/workspace-activity.ts` or a sibling workspace-history command module +- `packages/web/src/features/workspace/actions/use-workspace-launch-actions.ts` +- `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` + +Likely new files: + +- `packages/server/src/workspace/history-store.ts` + +Tests expected to change: + +- `packages/server/src/__tests__/workspace-commands.test.ts` +- `packages/web/src/features/workspace/views/shared/workspace-launch-modal.test.tsx` + +Likely locale updates: + +- `packages/web/src/locales/en.json` +- `packages/web/src/locales/zh.json` + +## Testing Strategy + +### Server tests + +Add coverage for: + +- `workspace.history.list` returning an empty list by default +- recording history after a successful `workspace.open` +- deduping repeated opens of the same path +- ordering by most recent open time +- trimming the list to the configured maximum length +- returning normalized data when malformed entries are present in settings storage + +### Client tests + +Add coverage for: + +- launch modal requesting recent history during mount +- rendering recent history entries in desktop and mobile launch surfaces +- clicking a recent history entry dispatching `workspace.open` directly +- recent-history opens reusing the same post-open state hydration as directory-based opens +- failed recent-history opens preserving the current diagnostics redirect behavior + +## Acceptance Criteria + +- opening a workspace records it in recent history +- closing a workspace does not remove it from recent history +- reopening the app still shows recent history +- clicking a recent history row immediately reopens that workspace +- repeated opens of the same path do not create duplicate history rows +- `workspace.list` semantics remain unchanged and continue to represent only currently open workspaces +- bootstrap routing behavior remains unchanged +- the recent-history UI is available anywhere the shared launch modal is used diff --git a/docs/superpowers/specs/2026-06-05-workspace-launch-history-timestamp-design.md b/docs/superpowers/specs/2026-06-05-workspace-launch-history-timestamp-design.md new file mode 100644 index 000000000..e1a992a06 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-workspace-launch-history-timestamp-design.md @@ -0,0 +1,107 @@ +# Workspace Launch History Timestamp Design + +Date: 2026-06-05 +Status: Drafted for review + +## Summary + +Add a visible "last accessed" timestamp to each row in the workspace launch modal's "Recent Workspaces" list. + +The server already persists recent workspace history with `WorkspaceHistoryEntry.lastOpenedAt`, so this change is frontend-only. The modal will render an absolute timestamp using the current locale, with the workspace name on the left and the formatted timestamp on the right. The path remains on the second line. + +## Goals + +- Make recency visible without requiring hover or an extra click. +- Reuse the existing workspace history payload without protocol changes. +- Preserve the current compact list density on desktop and mobile. + +## Non-Goals + +- Renaming history fields or changing persistence semantics. +- Introducing relative time labels such as "3 minutes ago". +- Adding sorting or filtering controls to recent workspaces. + +## Existing State + +- `packages/server/src/workspace/history-store.ts` already stores `lastOpenedAt` for each recent workspace entry. +- `packages/core/src/domain/types.ts` already exposes `WorkspaceHistoryEntry.lastOpenedAt`. +- `packages/web/src/features/workspace/views/shared/workspace-launch-modal.tsx` currently renders only the workspace name and path. + +## Proposed Design + +### Data + +Continue using `WorkspaceHistoryEntry.lastOpenedAt` from `workspace.history.list`. No backend or shared type changes are required. + +### UI Layout + +Each recent workspace row keeps a two-line layout: + +1. Top row: workspace name on the left, absolute last-access timestamp on the right. +2. Bottom row: workspace path in monospace, unchanged from the current design. + +The timestamp should visually read as secondary metadata: + +- Smaller than the workspace name. +- Right-aligned within the header row. +- Styled with secondary or tertiary foreground emphasis. + +On narrow widths, the header row may wrap if needed, but the preferred layout remains name-left / time-right. + +### Formatting + +Use the existing `formatDate(timestamp, locale)` helper from `packages/web/src/lib/i18n.ts`. + +Expected output follows the active UI locale: + +- `zh` uses `zh-CN` +- `en` uses `en-US` + +No custom timestamp formatting is added in this change. + +### Accessibility + +- The visible timestamp is informative only and does not change button semantics. +- The row button's existing accessible name remains the workspace-open action. +- No new interactive element is introduced. + +## Implementation Notes + +### Frontend + +- Update `workspace-launch-modal.tsx` to render a new row header wrapper for name + timestamp. +- Read the active locale and format `entry.lastOpenedAt` with `formatDate`. +- Add small CSS adjustments in `packages/web/src/styles/components.css` for the header row and timestamp styling. + +### Tests + +Add or update modal tests to verify: + +- Recent workspace rows render the formatted timestamp. +- Existing direct-open behavior from recent workspaces remains unchanged. + +No backend tests are required because data persistence is unchanged. + +## Alternatives Considered + +### Show timestamp on a third line + +Rejected because it increases row height and reduces visible recent-workspace density. + +### Show relative time instead of absolute time + +Rejected because the requested behavior is explicit timestamp visibility and the product already has a locale-aware absolute date formatter. + +### Add both relative and absolute time + +Rejected for now because it adds noise to a compact picker without a clear decision need. + +## Risks + +- Locale-formatted timestamps can be wider than expected in some environments, so the row header needs flexible spacing. +- Very long workspace names and long timestamps may compete for horizontal space; CSS needs to allow graceful shrinking or wrapping. + +## Validation + +- Unit test coverage for rendered timestamp text in the workspace launch modal. +- Manual verification on desktop and mobile launch surfaces for spacing and overflow behavior. diff --git a/docs/superpowers/specs/2026-06-06-system-agent-instructions-design.md b/docs/superpowers/specs/2026-06-06-system-agent-instructions-design.md new file mode 100644 index 000000000..c008a744d --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-system-agent-instructions-design.md @@ -0,0 +1,324 @@ +# System Agent Instructions Management Design + +Date: 2026-06-06 +Status: Draft +Owner: Codex + +## Problem + +当前 Agent 面板已经能管理项目级 `.coder-studio/agent.md`。这份文件是 Coder Studio 的 workspace-local 项目说明,保存后会同步到各 provider 在当前项目里实际读取的文件,例如 `AGENTS.md`、`GEMINI.md`、`.claude/CLAUDE.md`。 + +用户还希望在同一个 Agent 面板里管理每个 Agent 工具自己的用户级全局说明文件。这个文件不属于当前 workspace,通常位于用户 home 目录下,例如 Codex 的 `~/.codex/AGENTS.md` 和 Claude Code 的 `~/.claude/CLAUDE.md`。现有 `file.read` / `file.write` 明确限制在 workspace root 内,不能直接用于这些系统文件。 + +因此本功能需要在保留 workspace 文件安全边界的前提下,为少量已知 Agent 全局说明文件提供受控编辑入口。 + +## Goals + +- 将现有 Agent 面板中的 `agent.md` 命名调整为 `项目 Agent.md`。 +- 新增 `系统 Agent.md` 分组,列出当前内置 provider 中支持用户级全局说明文件的 Agent。 +- 点击系统 Agent 条目的编辑按钮后,在现有主编辑器中打开对应文件。 +- 文件不存在时创建简短 scaffold 后打开。 +- 保存时支持 baseHash 冲突检测。 +- 后端只允许编辑服务端定义的 provider allowlist 路径,不接受前端传任意绝对路径。 +- 对没有稳定 Markdown 全局说明文件的 provider,展示不可编辑状态,而不是伪造路径。 +- 桌面端和移动端都能看到同一组项目/系统 Agent.md 管理入口。 + +## Non-Goals + +- 不把 `$HOME` 目录挂进文件树。 +- 不支持任意系统文件读写。 +- 不把系统 Agent 文件加入 Git diff、文件搜索、文件树、LSP 或图片预览。 +- 不修改各 Agent CLI 的真实加载规则。 +- 不为 Cursor User Rules 逆向或猜测本地存储路径。 +- 不在一期支持自定义 provider 的全局说明文件配置。 +- 不迁移或合并现有 `.coder-studio/agent.md` 内容到系统文件。 + +## User Decisions Captured + +- “系统 agent.md” 指每个 Agent 工具自己的用户级全局说明文件。 +- 现有项目级 agent.md 继续存在,但 UI 名称改为 `项目 Agent.md`。 +- 新增系统级分组,用户可以直接编辑保存支持的 Agent 全局说明文件。 +- Cursor Agent 如果没有稳定文件入口,可以先显示为不支持直接文件编辑。 + +## Research Notes + +- Codex 官方文档说明全局说明文件位于 Codex home 目录,默认读取 `~/.codex/AGENTS.override.md`,否则读取 `~/.codex/AGENTS.md`。本功能一期编辑稳定基础文件 `~/.codex/AGENTS.md`,不主动创建 override 文件。 +- Claude Code 官方 memory 文档列出用户级 memory 文件为 `~/.claude/CLAUDE.md`。 +- Gemini CLI 文档说明 `/memory add` 会追加到全局 `~/.gemini/GEMINI.md`。 +- OpenCode 官方 rules 文档说明全局规则文件为 `~/.config/opencode/AGENTS.md`。 +- Cursor 官方 rules 文档说明 User Rules 是全局规则,但定义在 Cursor Settings > Rules 中,不是公开稳定的 Markdown 文件路径。 + +Sources: + +- OpenAI Codex AGENTS.md guide: https://developers.openai.com/codex/guides/agents-md +- Claude Code memory docs: https://docs.claude.com/en/docs/claude-code/memory +- Gemini CLI context files docs: https://google-gemini.github.io/gemini-cli/docs/cli/gemini-md.html +- OpenCode rules docs: https://dev.opencode.ai/docs/rules/ +- Cursor rules docs: https://docs.cursor.com/context/rules + +## Approaches Considered + +### Option A: Reuse workspace file APIs with relative paths + +Treat each system file as if it were a workspace file and pass a path like `../../.codex/AGENTS.md`. + +Pros: + +- Very small frontend change. + +Cons: + +- Breaks the existing path safety model. +- Encourages future features to bypass workspace root. +- Would make a security-sensitive exception inside a general-purpose file API. + +Decision: reject. + +### Option B: Add a general external file manager + +Create a general API that can read/write files outside the workspace, then use it for Agent system files. + +Pros: + +- Flexible for future system-level config editing. + +Cons: + +- Much larger product and security surface. +- Needs permissions, browsing, path validation, audit UI, and likely OS-specific handling. +- Overbuilds this request. + +Decision: reject for this feature. + +### Option C: Add provider allowlist system Agent instructions APIs + +Create dedicated commands for system Agent instruction files. The frontend passes only `providerId`; the backend maps that provider to a fixed path. + +Pros: + +- Meets direct edit/save requirement. +- Keeps existing workspace file API strict. +- Simple to test because the path matrix is finite. +- Handles unsupported providers honestly. + +Cons: + +- Requires editor load/save to understand a small virtual path scheme. +- Custom providers need a later extension point. + +Decision: accept. + +## Final Design + +### 1. Provider Matrix + +一期系统 Agent 文件 allowlist: + +| Provider | Display | File | Editable | +| --- | --- | --- | --- | +| `codex` | Codex | `~/.codex/AGENTS.md` | yes | +| `claude` | Claude Code | `~/.claude/CLAUDE.md` | yes | +| `gemini` | Gemini CLI | `~/.gemini/GEMINI.md` | yes | +| `opencode` | OpenCode | `~/.config/opencode/AGENTS.md` | yes | +| `cursor` | Cursor Agent | Cursor Settings > Rules | no | + +The backend must derive paths with `os.homedir()` and path joining. The frontend never sends these absolute paths for read/write. + +### 2. UI Structure + +The current `AgentInstructionsSection` becomes a higher-level Agent instructions panel with two groups. + +Group 1: `项目 Agent.md` + +- Uses the existing `.coder-studio/agent.md` status, generate, regenerate, and edit flow. +- Existing behavior stays intact. +- Copy changes from generic `agent.md` to project-specific wording. + +Group 2: `系统 Agent.md` + +- Loads provider list/status from a new system status command. +- Renders one row per built-in provider. +- Editable rows show provider display name, resolved user-facing path, existence state, and an edit action. +- Unsupported rows show provider display name, reason, and no edit action. +- Cursor row copy should say it is managed through Cursor Settings > Rules. + +Suggested row states: + +- `Ready`: file exists and can be edited. +- `Missing`: file does not exist; clicking edit will create scaffold. +- `Unsupported`: provider has no stable file path. +- `Error`: status/read/write failed. + +### 3. Editor Integration + +System files open in the existing editor with virtual paths: + +- `agent-system:codex` +- `agent-system:claude` +- `agent-system:gemini` +- `agent-system:opencode` + +The display label should show the real user-facing path, for example `~/.codex/AGENTS.md`, while internal editor state keeps the virtual path as the stable key. + +Editor read/write routing: + +- If active path starts with `agent-system:`, `useCodeEditorActions.loadFile` calls `agentInstructions.system.read`. +- `handleSave` calls `agentInstructions.system.write`. +- Refresh reconciliation for these paths calls the same system read command. +- Monaco model path can use the virtual path. No LSP should attach to these files. + +The returned payload should stay compatible with text file handling: + +```ts +type SystemAgentInstructionsReadResult = { + kind: "text"; + providerId: string; + path: string; + displayPath: string; + exists: boolean; + content: string; + baseHash: string; + encoding: "utf-8"; +}; +``` + +The write command returns: + +```ts +type SystemAgentInstructionsWriteResult = { + providerId: string; + path: string; + displayPath: string; + newHash: string; +}; +``` + +### 4. Backend Commands + +Add commands under the existing `agentInstructions` namespace: + +- `agentInstructions.system.status` +- `agentInstructions.system.read` +- `agentInstructions.system.write` + +`status` input: + +```ts +{ + workspaceId: string; +} +``` + +`read` input: + +```ts +{ + workspaceId: string; + providerId: string; +} +``` + +`write` input: + +```ts +{ + workspaceId: string; + providerId: string; + content: string; + baseHash?: string; +} +``` + +`workspaceId` is kept for command scoping and client consistency, but the file path does not depend on workspace root. + +Implementation details: + +- Create a small resolver that returns system instruction metadata by provider id. +- Only built-in provider ids in the allowlist can resolve to editable files. +- Unsupported providers return structured metadata from status and throw `agent_system_instructions_unsupported` on read/write. +- `read` returns empty content and `exists: false` when the file is missing. +- `write` creates parent directories and writes the file. +- `write` checks `baseHash` against current file content when provided and throws `conflict` on mismatch. +- Commands should not emit workspace `fs.dirty`, because these files are outside the workspace tree. + +### 5. Scaffold Content + +When a supported system file is missing and the user clicks edit, the frontend can either call write first with scaffold content or let read return a scaffold candidate. Keep the implementation consistent with the existing project Agent edit flow by writing scaffold first. + +Suggested scaffold: + +```md +# Agent Instructions + +## Personal Defaults +- Add preferences this agent should follow across your projects. + +## Working Style +- Add communication, testing, review, or safety expectations. +``` + +Provider-specific heading can be added later.一期不需要不同 provider 生成不同 scaffold。 + +### 6. State And Persistence + +Reuse existing workspace UI state for the project group expansion. + +Add one optional UI state field for system group expansion: + +```ts +agentSystemInstructionsExpanded?: boolean; +``` + +If this feels too wide for一期, keep the system group always expanded. The preferred implementation adds the field because the current panel already persists expansion for project Agent.md. + +### 7. Error Handling + +Expected errors: + +- `workspace_not_found`: command called without an active workspace. +- `agent_system_instructions_unsupported`: provider has no editable global file. +- `agent_system_instructions_unknown_provider`: provider is not in the built-in matrix. +- `conflict`: file changed since it was opened. +- filesystem permission errors: show the OS error message in the panel/editor save error. + +The UI should not hide unsupported providers. Listing them makes the support boundary clear. + +### 8. Testing + +Server tests: + +- Status lists editable Codex, Claude, Gemini, OpenCode and unsupported Cursor. +- Read returns `exists: false` for missing editable file. +- Write creates parent directories and file. +- Write returns conflict when baseHash is stale. +- Unsupported provider read/write returns typed error. +- Unknown provider read/write returns typed error. +- Commands do not touch workspace file API or emit workspace dirty events. + +Web tests: + +- Agent panel title changes to `项目 Agent.md`. +- System group renders provider rows and unsupported Cursor row. +- Clicking edit on a missing system file writes scaffold then opens virtual path. +- Clicking edit on an existing system file opens virtual path without scaffold write. +- Editor loads `agent-system:codex` through system read. +- Saving `agent-system:codex` calls system write with provider id and baseHash. +- Existing project Agent generation/regeneration tests continue passing after copy changes. + +Manual verification: + +- Open Agent panel in desktop workspace and edit Codex global instructions. +- Save and confirm `~/.codex/AGENTS.md` changes on disk. +- Repeat for missing-file creation using a temporary HOME in tests where possible. +- Check mobile file/explorer surface still renders without layout overflow. + +## Rollout Notes + +This feature changes a personal global Agent configuration file. It should be clear in UI copy that system Agent.md applies across projects for that local user, unlike project Agent.md. + +If a user edits Codex `~/.codex/AGENTS.md`, active Codex sessions may not reload it until a new session starts. The UI should avoid promising live reload behavior. + +## Open Questions + +- Should custom providers later be able to declare a global instructions file path?一期 leaves this out for safety. +- Should Codex also expose `~/.codex/AGENTS.override.md`?一期 edits only `AGENTS.md` to avoid accidentally creating a higher-precedence override. diff --git a/docs/superpowers/specs/2026-06-06-work-analysis-hourly-dashboard-redesign-design.md b/docs/superpowers/specs/2026-06-06-work-analysis-hourly-dashboard-redesign-design.md new file mode 100644 index 000000000..1434e9b8b --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-work-analysis-hourly-dashboard-redesign-design.md @@ -0,0 +1,163 @@ +# Work Analysis Hourly Dashboard Redesign + +Date: 2026-06-06 +Status: Approved for first implementation +Owner: Codex + +## Problem + +当前工作分析仍然像一个需要手动触发的报告页: + +- 用户进入页面时经常没有现成结果,需要先点击运行基础分析。 +- `WorkAnalysisRecord` 以 `queryDigest` 缓存一次性快照,不适合作为长期趋势和自动刷新基础。 +- 页面直接消费复杂 `basicResult/snapshotV2`,前端需要大量兼容映射,信息层级混乱。 +- token 趋势、项目贡献、模型贡献、agent 贡献不够直观。 + +## Goals + +- 工作分析默认展示已有缓存数据,用户不需要先手动运行基础分析。 +- 支持手动刷新和自动刷新两种扫描方式。 +- 自动扫描每小时运行一次,刷新 provider 本地日志索引。 +- 基础扫描以小时维度聚合 token、会话数、活跃时间、项目、模型、agent、provider 等数据。 +- 概览页采用扁平化专业仪表盘布局: + - KPI 状态条 + - Token 趋势独占一整行 + - 下一行三列展示项目、模型、agent token 贡献排行 + - 继续展示任务/工具大头、小时热力图、数据质量和扫描状态 +- 不要求兼容旧页面结构;可以推翻当前工作分析 UI。 + +## Non-Goals + +- 不在第一版引入远程服务或云同步。 +- 不在第一版做真实费用金额估算。 +- 不要求所有 provider 立即达到完整 usage coverage。 +- 不把深入分析作为概览页核心依赖。 +- 不在第一版实现无限 drill-down;先保证 dashboard 可用。 + +## Product Model + +`基础分析` 的产品语义改为 `刷新索引`: + +- 自动刷新:服务启动后后台调度,每小时运行一次。 +- 手动刷新:用户点击 `立即刷新索引`,强制扫描当前时间范围并更新缓存。 +- 页面读取:`work.analysis.dashboard` 直接返回 dashboard projection。 +- 深入分析:后续基于缓存中的 sessions/events 抽样生成洞察,作为可选二级能力。 + +## Data Model + +第一版采用本地 JSON repo,结构按索引库设计,后续可迁移 SQLite。 + +```ts +interface WorkAnalysisDashboardCache { + version: 1; + scanState: WorkAnalysisScanState; + dashboard: WorkAnalysisDashboardProjection; +} + +interface WorkAnalysisScanState { + mode: "manual" | "auto"; + status: "idle" | "running" | "succeeded" | "failed"; + lastStartedAt?: number; + lastCompletedAt?: number; + nextScheduledAt?: number; + errorMessage?: string; + sourceDigest?: string; + providerStatuses: WorkAnalysisProviderStatus[]; +} + +interface WorkAnalysisDashboardProjection { + generatedAt: number; + timeRange: ResolvedWorkAnalysisTimeRange; + filters: WorkAnalysisDashboardQuery; + kpis: WorkAnalysisDashboardKpi[]; + trends: { + tokenHourly: WorkAnalysisTokenTrendPoint[]; + tokenDaily: WorkAnalysisTokenTrendPoint[]; + hourHeatmap: WorkAnalysisHourHeatPoint[]; + }; + rankings: { + projects: WorkAnalysisContributionRank[]; + models: WorkAnalysisContributionRank[]; + agents: WorkAnalysisContributionRank[]; + }; + breakdowns: { + tasks: WorkAnalysisContributionRank[]; + tools: WorkAnalysisContributionRank[]; + }; + quality: WorkAnalysisDataQualitySummary; +} +``` + +Hourly aggregation uses absolute `hourStart` timestamps rather than `0-23` buckets. This lets the UI render real trends across days and still derive the hour heatmap. + +## Backend Design + +Add a dashboard-oriented service path beside the existing basic/deep commands: + +- `work.analysis.dashboard.get` + - returns cached dashboard projection when available + - if no cache exists, returns an empty projection with scan state +- `work.analysis.dashboard.refresh` + - runs a manual scan for the requested range/filter + - updates the dashboard cache + - returns the new projection + +The first implementation may reuse the existing `workLogCollector.collect()` and `analyzeWorkBasic()` instead of introducing a full normalized fact store immediately. The important contract change is that the service materializes dashboard projection and scan state separately from query snapshot records. + +Automatic scanning is owned by `WorkAnalysisService`: + +- `startAutoScan()` schedules a scan every hour. +- The scheduler avoids overlapping scans. +- The default query is `90d` with all known workspaces. +- Manual refresh can run independently but should share the same scan lock. + +## Frontend Design + +Replace the current tab-heavy work analysis page with an overview-first dashboard. + +Primary layout: + +1. Top status strip: auto scan enabled, last scan, next scan, coverage warnings. +2. Filter bar: time range, projects, provider, model, agent, metric. +3. KPI row: total tokens, input/output, sessions, active time, top project share. +4. Token trend row: full-width chart. +5. Contribution row: three columns: + - project token contribution ranking + - model token contribution ranking + - agent token contribution ranking +6. Secondary row: model/agent/task/tool highlights and hour heatmap. +7. Operational row: scan pipeline and data quality. + +The visual direction is flat and professional: + +- light background +- white panels +- fine borders +- low shadow +- table-based rankings +- restrained blue accent + +## Error Handling + +- If dashboard cache is absent, show empty state with `立即刷新索引`. +- If refresh fails, keep the last successful dashboard and show scan error in the status strip. +- If some providers are partial, show data quality warning without blocking the dashboard. +- If a selected filter yields no data, show zeroed KPI cards and empty ranking panels. + +## Testing + +Backend tests: + +- dashboard refresh builds token trend and three contribution rankings +- cached dashboard can be read without rescanning +- refresh failure updates scan state but does not erase prior dashboard + +Frontend tests: + +- page renders token trend before contribution rankings +- contribution section renders project/model/agent rankings as three separate groups +- refresh button dispatches dashboard refresh command + +## First Slice + +The first implementation should land a working dashboard path using the existing collector/analyzer data rather than building the full future fact store in one step. This gives users the visible product improvement now while keeping the protocol shaped for hourly indexing. diff --git a/docs/superpowers/specs/2026-06-06-work-analysis-remove-budget-module-design.md b/docs/superpowers/specs/2026-06-06-work-analysis-remove-budget-module-design.md new file mode 100644 index 000000000..2f73db881 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-work-analysis-remove-budget-module-design.md @@ -0,0 +1,126 @@ +# Work Analysis Remove Budget Module Design + +Date: 2026-06-06 +Status: Draft +Owner: Codex + +## Problem + +当前 `工作分析` 的预算能力已经形成一条完整链路: + +- 前端存在独立的 `budgets` 页签和预算展示区 +- 前端类型包含预算专用结构 +- 后端协议同时在 `basicResult.budgets` 和 `snapshotV2.delivery.budgets` 暴露预算数据 +- 后端 analyzer 和 metrics 层持续计算预算预测、阈值和目录预算 +- server/web 测试持续维护预算 fixture 和断言 + +这和当前目标冲突。用户要求把 `工作分析总览额度预算模块功能整个去掉`,因此不能只隐藏页面,也不能保留后端空壳字段。 + +## Goal + +把 `工作分析` 里的预算模块从前后端一起彻底移除,保证: + +- UI 不再出现预算页签和预算区块 +- 协议不再输出预算字段 +- 后端不再进行预算计算 +- 测试不再维护预算相关断言 +- 仓库中不再保留工作分析预算模块的活跃实现链路 + +## Non-Goals + +- 不重构 `yield`、`overview`、`compare` 等非预算分析能力 +- 不调整深度分析产品形态 +- 不保留“兼容旧字段但恒为空”的过渡层 +- 不处理与工作分析无关的其他 `budget` 文案或系统预算概念 + +## Decision + +采用硬删除方案,前后端同步收口。 + +不采用以下替代方案: + +1. 只删前端展示 +原因:后端仍会保留无用字段、计算和测试,功能没有真正移除。 + +2. 保留字段但返回空值 +原因:协议语义变差,后续维护者仍要处理预算分支。 + +## Scope + +本次删除范围包括以下层级。 + +### Frontend + +- 删除 `packages/web/src/features/work-analysis/navigation.ts` 中的 `budgets` tab +- 删除 `packages/web/src/features/work-analysis/page.tsx` 中预算读取和预算 `TabPanel` +- 删除 `packages/web/src/features/work-analysis/types.ts` 中预算相关类型 +- 删除中英文 locale 中工作分析预算文案 + +### Backend + +- 删除 `packages/server/src/work-analysis/types.ts` 中预算相关类型和结果字段 +- 删除 `packages/server/src/work-analysis/basic-schema.ts` 中预算 schema +- 删除 `packages/server/src/work-analysis/basic-analyzer.ts` 中预算汇总、预算投射和 `delivery.budgets` / `basicResult.budgets` 赋值 +- 删除不再使用的 `packages/server/src/work-analysis/metrics/token-budgets.ts` 及其引用 + +### Tests + +- 删除 server analyzer/service tests 中预算 fixture 和预算断言 +- 删除 web page tests 中预算 fixture、tab 断言和预算渲染断言 + +## Post-Removal Shape + +删除后,工作分析页面和协议继续保留这些主域: + +- `overview` +- `tasks` +- `models` +- `optimize` +- `compare` +- `yield` +- `capabilities` +- `dataSources` + +以下内容会彻底消失: + +- `WORK_ANALYTICS_TABS` 中的 `budgets` +- 页面里的 `30 天预算预测`、`目标阈值`、`目录预算` +- `snapshotV2.delivery.budgets` +- `basicResult.budgets` +- `WorkAnalysisBudgetSummary` +- `WorkAnalysisBudgetTarget` +- `WorkAnalysisBudgetThreshold` +- 预算计算和预算专用测试数据 + +## Implementation Order + +按以下顺序执行,优先删契约,再删消费方,避免留下半兼容状态。 + +1. 删除后端类型与 schema 中的预算结构和预算字段 +2. 删除后端 analyzer 中预算计算与装配逻辑 +3. 删除预算 metrics 文件及所有引用 +4. 删除前端导航、页面和类型中的预算消费逻辑 +5. 删除 locale 中预算文案 +6. 更新 server/web tests,去除所有预算相关断言和 fixture + +## Validation + +完成后需要满足以下结果: + +- `/settings?section=analysis` 正常加载 +- 剩余 tab 可以正常切换和渲染 +- 不再存在预算 tab +- server work-analysis tests 通过 +- web work-analysis page tests 通过 +- 搜索仓库后,不再有工作分析预算模块的实现引用 + +## Risks + +主要风险只有一个: + +- 预算字段同时存在于 legacy `basicResult` 与 `snapshotV2.delivery` 两套结构中,如果只删一侧,会造成类型、schema、fixture 与页面消费不同步 + +应对方式: + +- 这次按前后端同步硬删除执行,不保留兼容层 +- 修改后通过类型检查和定向测试验证剩余域仍能正常工作 diff --git a/docs/superpowers/specs/2026-06-07-agent-token-trend-design.md b/docs/superpowers/specs/2026-06-07-agent-token-trend-design.md new file mode 100644 index 000000000..283e93844 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-agent-token-trend-design.md @@ -0,0 +1,76 @@ +# Agent Panel Token Trend Design + +## Goal + +Add a compact token consumption trend chart to the AGENT.MD sidebar panel. The chart shows the current project's token consumption over the most recent 24 hours and appears at the top of the expanded panel content. + +## Placement + +Render the chart as the first content block inside `AgentInstructionsSection` when the AGENT.MD panel is expanded. It appears above the existing "项目 AGENT.MD" status group and above the "系统 AGENT.MD" group. + +The outer AGENT.MD header remains unchanged. Collapsing the panel hides the chart together with the rest of the panel body. + +## Data Flow + +Use the existing work-analysis dashboard command from the web client: + +```ts +dispatch("work.analysis.dashboard.get", { + workspacePaths: [workspace.path], + timeRange: { preset: "24h" }, +}); +``` + +Read token trend data from `result.data.dashboard.trends.tokenHourly`. This avoids adding a new server command because the required 24-hour token data already exists. + +The chart only runs when `workspace.path` is available. If the workspace cannot be resolved, the chart does not render and does not dispatch. + +## UI States + +The chart block has four states: + +- Loading: show a compact muted skeleton sized like the final chart. +- Ready with data: render an ECharts line/area chart using hourly token totals. +- Empty: show "最近 24 小时暂无 token 数据". +- Error: show a low-emphasis inline message and keep the existing Agent.md controls usable. + +The chart header shows: + +- Title: `Token 消耗趋势` +- Subtitle: `当前项目 · 最近 24 小时` +- Summary metric: total tokens across the 24-hour points + +The chart footer shows peak hourly token usage and total session count when data exists. + +## Visual Direction + +Keep the visual language aligned with the existing sidebar: + +- Use `workspace-agent-instructions__*` CSS classes. +- Use existing theme tokens for text, borders, surfaces, and status colors. +- Keep the chart compact so it does not dominate the sidebar. +- Avoid introducing new global visual patterns or new chart dependencies. + +## Component Boundaries + +Add `agent-instructions-token-trend.tsx` beside `agent-instructions-section.tsx` to keep the existing Agent.md actions readable. + +The child component is responsible for: + +- Dispatching the 24-hour work-analysis query for the current workspace path. +- Normalizing hourly points for display. +- Rendering loading, ready, empty, and error states. +- Disposing the ECharts instance on unmount. + +`AgentInstructionsSection` remains responsible for panel state, Agent.md status, generation, and edit actions. + +## Testing + +Add focused tests for `agent-instructions-token-trend.tsx` plus one placement assertion in `AgentInstructionsSection` tests. Verify: + +- It dispatches `work.analysis.dashboard.get` with the current workspace path and `24h` time range. +- It renders the chart block before the existing project Agent.md group. +- It renders the empty state when `tokenHourly` has no token/session data. +- It renders an error state without hiding existing Agent.md controls. + +Run focused web tests for the touched component and a typecheck or broader verification command before handoff. diff --git a/docs/work-analysis-prototype.html b/docs/work-analysis-prototype.html new file mode 100644 index 000000000..b882b4f35 --- /dev/null +++ b/docs/work-analysis-prototype.html @@ -0,0 +1,1926 @@ + + + + + + 工作分析 - 单页原型 + + + + +
+
+
+
WA
+
+ 工作分析 + 单页原型 · 现有能力全量合并 +
+
+ +
+ + +
+
+ +
+
+
+
work analysis / unified view
+

把会话、事件、工具、任务、证据放在同一页里看

+

+ 这是一版基于现有功能的完整原型。当前日志可以稳定做到会话级、事件级、工具级、任务级统计; + Skill 调用次数可以单独归因到 provider 级别,但 skill 内部 prompt 的 token 切分仍然不可见。 +

+
+
+
+ 已更新 + 自动扫描 + 近 30 天 +
+
+ Codex 188 + Claude 142 + OpenCode 48 + Cursor 0 +
+
+
+ +
+
+ 时间范围 + 24h + 7d + 30d + 90d + 自定义 +
+
+ workspace +
+ /home/spencer/workspace/coder-studio + /repo/agent-studio + /repo/sdk-integration + /repo/infra-tools +
+
+
+ 数据来源 + Codex 可解析 + Claude 部分解析 + OpenCode 可解析 + Cursor 未挂载 +
+
+ +
+ +
+
+
+
+
+

Token 趋势

+

按照当前筛选范围自动切换展示粒度。这里保留小时 / 6 小时 / 日的自适应表现。

+
+ 自适应粒度 +
+
+ + + + + + + + + + + + + +
+
+
+ +
+
+
+

24 小时消耗分布

+

按本地时区汇总同一小时段的 token 消耗,快速看峰值时段和低谷时段。

+
+ hour heatmap +
+
+
+
+
+ 低消耗 + ▁ ▁ ▂ ▃ ▄ ▅ ▆ ▇ + 高消耗 + 颜色越深代表该小时段消耗越高 +
+
+
+ +
+
+
+
+

项目 token 贡献

+

按 workspace 归因后的总量排行。

+
+
+
+
+
+
+
+
+
+

模型 token 贡献

+

不同 provider / model 的消耗分布。

+
+
+
+
+
+
+
+
+
+

Agent token 贡献

+

按 provider / agent 维度查看主消耗来源。

+
+
+
+
+
+
+
+ +
+
+
+
+

任务类型归因

+

coding / debugging / planning 等任务的 token 和会话占比。

+
+
+
+
+
+
+
+
+
+

工具调用归因

+

按 toolName 汇总,Skill 已单独拆出。

+
+
+
+
+
+
+
+
+
+

Skill 调用归因

+

按 provider 统计 Skill 调用次数,token 仍然不能拆到 skill 内部。

+
+
+
+
+
+
+
+
+
+

命令归因

+

按 commandLabel 聚合,适合看 test / build / git / search。

+
+
+
+
+
+
+
+ +
+
+
+

会话列表

+

选中一条会话后,右侧会显示该会话的证据、事件、token 构成和信号。

+
+ click row +
+
+
+
+ 会话 + provider + workspace + task + tokens + duration + signals +
+
+
+
+
+ + +
+ + +
+
+
+ + + + diff --git a/e2e-ui/fixtures/scene-runner.ts b/e2e-ui/fixtures/scene-runner.ts index eca030a27..53380cd0d 100644 --- a/e2e-ui/fixtures/scene-runner.ts +++ b/e2e-ui/fixtures/scene-runner.ts @@ -33,9 +33,12 @@ async function openSettingsSection( ) { const sectionOrder = { general: 0, - providers: 1, - appearance: 2, - shortcuts: 3, + monitoring: 1, + analysis: 2, + providers: 3, + appearance: 4, + shortcuts: 5, + about: 6, } as const; const index = sectionOrder[section]; diff --git a/e2e/fixtures/phase2-i18n.ts b/e2e/fixtures/phase2-i18n.ts index 9b46e04a1..f6dbd2d87 100644 --- a/e2e/fixtures/phase2-i18n.ts +++ b/e2e/fixtures/phase2-i18n.ts @@ -1,7 +1,7 @@ import { type Page } from "@playwright/test"; import { type E2ELocaleCode, translateForE2E } from "./i18n.js"; -type SettingsSection = "general" | "appearance" | "providers" | "shortcuts"; +type SettingsSection = "general" | "appearance" | "providers" | "shortcuts" | "analysis"; type ProviderSettingLabel = | "base" | "config_file" @@ -15,6 +15,7 @@ const SETTINGS_SECTION_KEYS: Record "); +} + +mkdirSync(stateDir, { recursive: true }); +rmSync(join(stateDir, "state"), { recursive: true, force: true }); + +const now = Date.now(); +const siblingWorkspacePath = join(join(workspacePath, ".."), "workspace-b"); +const externalWorkspacePath = join(join(workspacePath, ".."), "workspace-c"); +const availableWorkspacePaths = [workspacePath, siblingWorkspacePath, externalWorkspacePath].sort( + (left, right) => left.localeCompare(right) +); + +const workspaceRepo = new WorkspaceRepo({ + filePath: join(stateDir, "state", "workspaces.json"), +}); +const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "state", "settings.json"), +}); +const workAnalysisRepo = new WorkAnalysisRepo({ + filePath: join(stateDir, "state", "work-analysis.json"), +}); + +workspaceRepo.create({ + id: WORKSPACE_ID, + path: workspacePath, + targetRuntime: "native", + openedAt: now, + lastActiveAt: now, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, +}); + +const query = { + timeRange: { preset: "7d" as const }, +}; + +workAnalysisRepo.upsert({ + id: "analysis-legacy-e2e", + queryDigest: buildWorkAnalysisQueryDigest(query), + timeRange: query.timeRange, + requestedAt: now, + basicCompletedAt: now, + basicStatus: "succeeded", + deepStatus: "idle", + basicResult: { + availableWorkspacePaths, + capabilityMatrix: { + providers: [], + }, + coverage: { + workspaceCount: 3, + sessionCount: 4, + providerCount: 1, + timeRangeLabel: "7d", + }, + activity: { + sessionCount: 4, + totalDurationMs: 3 * 60 * 60 * 1000, + averageDurationMs: 45 * 60 * 1000, + daily: [ + { day: "2026-06-01", totalTokens: 420, sessionCount: 1 }, + { day: "2026-06-02", totalTokens: 620, sessionCount: 1 }, + { day: "2026-06-03", totalTokens: 310, sessionCount: 1 }, + { day: "2026-06-04", totalTokens: 850, sessionCount: 1 }, + ], + }, + workHabits: { + hourBuckets: [ + { hour: 10, sessionCount: 1 }, + { hour: 14, sessionCount: 2 }, + { hour: 18, sessionCount: 1 }, + ], + }, + skillInventory: { + installedCount: 4, + mountedCount: 2, + unmountedCount: 2, + }, + usage: { + totalSessions: 4, + sessionsByProvider: { codex: 4 }, + totals: { + inputTokens: 1320, + outputTokens: 880, + cachedInputTokens: 140, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 80, + reasoningOutputTokens: 0, + totalTokens: 2200, + }, + byDay: [ + { + day: "2026-06-01", + sessionCount: 1, + totals: { + inputTokens: 220, + outputTokens: 200, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 10, + reasoningOutputTokens: 0, + totalTokens: 420, + }, + }, + { + day: "2026-06-02", + sessionCount: 1, + totals: { + inputTokens: 360, + outputTokens: 260, + cachedInputTokens: 40, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20, + reasoningOutputTokens: 0, + totalTokens: 620, + }, + }, + { + day: "2026-06-03", + sessionCount: 1, + totals: { + inputTokens: 210, + outputTokens: 100, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 10, + reasoningOutputTokens: 0, + totalTokens: 310, + }, + }, + { + day: "2026-06-04", + sessionCount: 1, + totals: { + inputTokens: 530, + outputTokens: 320, + cachedInputTokens: 60, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 850, + }, + }, + ], + byHour: [], + byProvider: [ + { + providerId: "codex", + sessionCount: 4, + totals: { + inputTokens: 1320, + outputTokens: 880, + cachedInputTokens: 140, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 80, + reasoningOutputTokens: 0, + totalTokens: 2200, + }, + }, + ], + byWorkspace: [ + { + workspacePath, + sessionCount: 2, + totals: { + inputTokens: 580, + outputTokens: 460, + cachedInputTokens: 60, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 30, + reasoningOutputTokens: 0, + totalTokens: 1040, + }, + }, + { + workspacePath: siblingWorkspacePath, + sessionCount: 1, + totals: { + inputTokens: 210, + outputTokens: 100, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 10, + reasoningOutputTokens: 0, + totalTokens: 310, + }, + }, + { + workspacePath: externalWorkspacePath, + sessionCount: 1, + totals: { + inputTokens: 530, + outputTokens: 320, + cachedInputTokens: 60, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 850, + }, + }, + ], + byModel: [ + { + modelId: "gpt-5-codex", + providerId: "codex", + sessionCount: 4, + totals: { + inputTokens: 1320, + outputTokens: 880, + cachedInputTokens: 140, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 80, + reasoningOutputTokens: 0, + totalTokens: 2200, + }, + }, + ], + byTool: [], + byCommand: [], + topSessionsByTotalTokens: [ + { + sessionId: "sess-4", + providerId: "codex", + workspacePath: externalWorkspacePath, + modelId: "gpt-5-codex", + totalTokens: 850, + }, + { + sessionId: "sess-2", + providerId: "codex", + workspacePath, + modelId: "gpt-5-codex", + totalTokens: 620, + }, + ], + }, + tasks: { + byType: [ + { + taskType: "coding", + sessionCount: 2, + totals: { + inputTokens: 580, + outputTokens: 460, + cachedInputTokens: 60, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 30, + reasoningOutputTokens: 0, + totalTokens: 1040, + }, + providerIds: ["codex"], + modelIds: ["gpt-5-codex"], + workspacePaths: [workspacePath], + }, + { + taskType: "exploration", + sessionCount: 2, + totals: { + inputTokens: 740, + outputTokens: 420, + cachedInputTokens: 80, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + reasoningOutputTokens: 0, + totalTokens: 1160, + }, + providerIds: ["codex"], + modelIds: ["gpt-5-codex"], + workspacePaths: [siblingWorkspacePath, externalWorkspacePath], + }, + ], + sessions: [], + }, + compare: { + topDimension: "workspace", + workspaces: [ + { + key: externalWorkspacePath, + label: externalWorkspacePath, + workspacePath: externalWorkspacePath, + sessionCount: 1, + totalTokens: 850, + shareOfTokens: 850 / 2200, + sharePercent: (850 / 2200) * 100, + averageTokensPerSession: 850, + averageOutputShare: 320 / 530, + }, + { + key: workspacePath, + label: workspacePath, + workspacePath, + sessionCount: 2, + totalTokens: 1040, + shareOfTokens: 1040 / 2200, + sharePercent: (1040 / 2200) * 100, + averageTokensPerSession: 520, + averageOutputShare: 460 / 580, + }, + ], + providers: [ + { + key: "codex", + label: "codex", + providerId: "codex", + sessionCount: 4, + totalTokens: 2200, + shareOfTokens: 1, + sharePercent: 100, + averageTokensPerSession: 550, + averageOutputShare: 880 / 1320, + }, + ], + models: [ + { + key: "codex\u0000gpt-5-codex", + label: "codex / gpt-5-codex", + providerId: "codex", + modelId: "gpt-5-codex", + sessionCount: 4, + totalTokens: 2200, + shareOfTokens: 1, + sharePercent: 100, + averageTokensPerSession: 550, + averageOutputShare: 880 / 1320, + }, + ], + tasks: [ + { + key: "coding", + label: "coding", + taskType: "coding", + sessionCount: 2, + totalTokens: 1040, + shareOfTokens: 1040 / 2200, + sharePercent: (1040 / 2200) * 100, + averageTokensPerSession: 520, + averageOutputShare: 460 / 580, + }, + { + key: "exploration", + label: "exploration", + taskType: "exploration", + sessionCount: 2, + totalTokens: 1160, + shareOfTokens: 1160 / 2200, + sharePercent: (1160 / 2200) * 100, + averageTokensPerSession: 580, + averageOutputShare: 420 / 740, + }, + ], + dimensions: { + workspace: [], + provider: [], + model: [], + task: [], + }, + }, + yield: { + overall: { + sessionCount: 4, + shippedSessionCount: 2, + shippedSessionRate: 0.5, + editSessionCount: 2, + commandSessionCount: 4, + gitSessionCount: 2, + artifactSessionCount: 2, + shippedTokens: 1470, + shippedTokenShare: 1470 / 2200, + averageTokensPerShippedSession: 735, + averageTokensPerNonShippedSession: 365, + outputToInputRatio: 880 / 1320, + artifactSignalPerThousandTokens: 0.909, + gitAwareSessionRate: 0.5, + }, + byWorkspace: [], + byTask: [], + topShippedSessions: [ + { + sessionId: "sess-4", + providerId: "codex", + workspacePath: externalWorkspacePath, + taskType: "exploration", + totalTokens: 850, + shippedSignals: ["edit", "git"], + }, + ], + lowYieldSessions: [ + { + sessionId: "sess-3", + providerId: "codex", + workspacePath: siblingWorkspacePath, + taskType: "exploration", + totalTokens: 310, + missedSignals: ["no_git", "no_artifact"], + }, + ], + limitations: [], + }, + workSurface: { + workspacePaths: availableWorkspacePaths, + }, + executionSignals: { + sessionsWithActivity: 4, + userTurnCount: 12, + assistantTurnCount: 15, + toolUseCount: 8, + fileMtimeTimestampCount: 0, + }, + dataQuality: { + clampedDurationCount: 0, + emptySessionCount: 0, + }, + }, +}); + +settingsRepo.set("workspace.lastViewedTarget", { + workspaceId: WORKSPACE_ID, + updatedAt: now, +}); + +console.log( + JSON.stringify({ + stateDir, + workspaceId: WORKSPACE_ID, + query, + }) +); diff --git a/e2e/specs/settings/analysis-real.spec.ts b/e2e/specs/settings/analysis-real.spec.ts new file mode 100644 index 000000000..97b92c098 --- /dev/null +++ b/e2e/specs/settings/analysis-real.spec.ts @@ -0,0 +1,47 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { translatePatternForE2E } from "../../fixtures/i18n"; + +const SPEC_DIR = fileURLToPath(new URL(".", import.meta.url)); + +test.describe("settings analysis real logs capture", () => { + test("captures real basic analysis with discovered workspaces", async ({ page }) => { + test.setTimeout(180000); + + await page.goto("/analytics"); + await expect(page.getByTestId("work-analytics-page")).toBeVisible({ timeout: 20000 }); + + const runBasicButton = page.getByRole("button", { + name: translatePatternForE2E("settings.analysis.run_basic"), + }); + await runBasicButton.click(); + + await expect(page.getByText(/^(?:基础分析: 已完成|Basic Analysis: Completed)$/)).toBeVisible({ + timeout: 120000, + }); + + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-real-overview-full.png"), + fullPage: true, + }); + + await page + .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_compare") }) + .click(); + await expect(page).toHaveURL(/tab=compare/); + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-real-compare-full.png"), + fullPage: true, + }); + + await page + .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_yield") }) + .click(); + await expect(page).toHaveURL(/tab=yield/); + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-real-yield-full.png"), + fullPage: true, + }); + }); +}); diff --git a/e2e/specs/settings/analysis.spec.ts b/e2e/specs/settings/analysis.spec.ts new file mode 100644 index 000000000..c68472377 --- /dev/null +++ b/e2e/specs/settings/analysis.spec.ts @@ -0,0 +1,122 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { expectSettingsEntryPoint } from "../../fixtures/app-entry"; +import { translatePatternForE2E } from "../../fixtures/i18n"; + +const SPEC_DIR = fileURLToPath(new URL(".", import.meta.url)); +const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); + +test.describe("@phase2 settings analysis acceptance", () => { + let sandboxDir: string; + let workspaceDir: string; + + test.beforeEach(() => { + sandboxDir = mkdtempSync(join(tmpdir(), "coder-studio-analysis-settings-e2e-")); + workspaceDir = join(sandboxDir, "workspace"); + mkdirSync(workspaceDir, { recursive: true }); + + const stateDir = process.env.CODER_STUDIO_PHASE1_STATE_DIR; + if (!stateDir) { + throw new Error("CODER_STUDIO_PHASE1_STATE_DIR must be set for settings analysis e2e"); + } + + const result = spawnSync( + "pnpm", + ["exec", "tsx", "e2e/fixtures/seed-work-analysis-settings-db.ts", stateDir, workspaceDir], + { + cwd: REPO_ROOT, + stdio: "inherit", + } + ); + + if (result.status !== 0) { + throw new Error(`Failed to seed work analysis settings state: ${result.status ?? "unknown"}`); + } + }); + + test.afterEach(() => { + rmSync(sandboxDir, { recursive: true, force: true }); + }); + + test("P2S-09 analysis settings renders all discovered workspace paths", async ({ page }) => { + await page.goto("/workspace"); + await expect(page.getByTestId("workspace-resolving-shell")).toHaveCount(0, { timeout: 20000 }); + + const settingsEntry = await expectSettingsEntryPoint(page); + await settingsEntry.click(); + await expect(page).toHaveURL(/\/settings$/); + + await page + .getByRole("button", { name: translatePatternForE2E("settings.analysis.title") }) + .click(); + + await expect(page.locator('[data-testid="session-analysis-settings"]')).toBeVisible(); + await expect(page.locator("body")).not.toContainText( + "An error occurred in the component." + ); + await expect( + page.getByText(translatePatternForE2E("settings.analysis.provider_sources")) + ).toBeVisible(); + await expect( + page.getByText( + translatePatternForE2E("settings.analysis.log_coverage_summary", { + workspaceCount: "3", + sessionCount: "4", + providerCount: "1", + }) + ) + ).toBeVisible(); + await expect(page.getByText(/workspace$/)).toBeVisible(); + await expect(page.getByText(/workspace-b$/)).toBeVisible(); + await expect(page.getByText(/workspace-c$/)).toBeVisible(); + await expect( + page.getByText(translatePatternForE2E("settings.analysis.empty_workspace")) + ).toHaveCount(0); + await page + .getByRole("button", { name: translatePatternForE2E("settings.analysis.open_analytics") }) + .click(); + await expect(page).toHaveURL(/\/analytics/); + await expect(page.getByTestId("work-analytics-page")).toBeVisible(); + await expect( + page.getByRole("tablist", { + name: translatePatternForE2E("settings.analysis.analytics_sections"), + }) + ).toBeVisible(); + await expect( + page.getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_overview") }) + ).toHaveAttribute("aria-selected", "true"); + + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-overview.png"), + fullPage: true, + }); + + await page + .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_compare") }) + .click(); + await expect(page).toHaveURL(/tab=compare/); + await expect( + page.getByText(translatePatternForE2E("settings.analysis.workspace_breakdown")) + ).toBeVisible(); + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-compare.png"), + fullPage: true, + }); + + await page + .getByRole("tab", { name: translatePatternForE2E("settings.analysis.tab_yield") }) + .click(); + await expect(page).toHaveURL(/tab=yield/); + await expect( + page.getByText(translatePatternForE2E("settings.analysis.top_sessions")) + ).toBeVisible(); + await page.screenshot({ + path: join(SPEC_DIR, "../../test-results/settings-analysis-yield.png"), + fullPage: true, + }); + }); +}); diff --git a/packages/core/src/domain/diagnostics.ts b/packages/core/src/domain/diagnostics.ts index 7a37a17e9..df23b296c 100644 --- a/packages/core/src/domain/diagnostics.ts +++ b/packages/core/src/domain/diagnostics.ts @@ -1,3 +1,4 @@ +import type { LspServerKind } from "./lsp"; import type { SystemDependencyId } from "./system-dependency-install"; export type DiagnosticsContext = @@ -60,14 +61,34 @@ export interface DiagnosticsCheck { export interface DiagnosticsMetadata { authEnabled?: boolean; host?: string; + lspRuntimeContext?: { + targetRuntime: "native" | "wsl"; + managedInstallSupported: boolean; + }; providerId?: string; workspaceId?: string; workspacePath?: string; } +export type DiagnosticsLspServiceStatus = + | "installed" + | "not_installed" + | "install_failed" + | "prerequisite_missing" + | "runtime_off"; + +export interface DiagnosticsLspServiceEntry { + serverKind: LspServerKind; + displayName: string; + status: DiagnosticsLspServiceStatus; + missingCommands?: string[]; + missingPrerequisites?: string[]; +} + export interface DiagnosticsResponse { context: DiagnosticsContext; canContinue: boolean; checks: DiagnosticsCheck[]; + lspServices: DiagnosticsLspServiceEntry[]; metadata: DiagnosticsMetadata; } diff --git a/packages/core/src/domain/provider-install.ts b/packages/core/src/domain/provider-install.ts index 2436859f7..83269f5d9 100644 --- a/packages/core/src/domain/provider-install.ts +++ b/packages/core/src/domain/provider-install.ts @@ -1,3 +1,9 @@ +import type { + ProviderCapabilityDescriptor, + ProviderKind, + ProviderStability, +} from "../provider/definition"; + export interface ProviderInstallDocUrls { provider: string; prerequisites: Partial>; @@ -5,6 +11,16 @@ export interface ProviderInstallDocUrls { export interface ProviderRuntimeStatusEntry { providerId: string; + displayName: string; + badge: string; + kind: ProviderKind; + stability?: ProviderStability; + supportsAgentInstructions?: boolean; + supportsAgentInstructionsGeneration?: boolean; + supportsSkillsMount?: boolean; + capability: "full" | "limited" | "unsupported"; + capabilities: ProviderCapabilityDescriptor[]; + requiredCommands: string[]; available: boolean; missingCommands: string[]; missingPrerequisites: string[]; diff --git a/packages/core/src/domain/skill-management.test.ts b/packages/core/src/domain/skill-management.test.ts new file mode 100644 index 000000000..e173986a7 --- /dev/null +++ b/packages/core/src/domain/skill-management.test.ts @@ -0,0 +1,42 @@ +import { + isSkillMountStatus, + SKILL_INSTALL_STATES, + SKILL_LIBRARY_ITEM_STATES, + SKILL_MOUNT_STATUSES, + SKILL_TARGET_HEALTH_STATES, +} from "./skill-management.js"; + +describe("skill management domain", () => { + it("exports stable install states", () => { + expect(SKILL_INSTALL_STATES).toEqual(["installed", "installing", "failed"]); + }); + + it("exports stable library summary states", () => { + expect(SKILL_LIBRARY_ITEM_STATES).toEqual([ + "unmounted", + "partially_mounted", + "fully_mounted", + "error", + ]); + }); + + it("exports stable mount statuses", () => { + expect(SKILL_MOUNT_STATUSES).toEqual([ + "mounted", + "stale", + "missing_target", + "missing_source", + "failed", + ]); + }); + + it("exports stable target health states", () => { + expect(SKILL_TARGET_HEALTH_STATES).toEqual(["healthy", "warning", "error", "unconfigured"]); + }); + + it("recognizes supported mount statuses only", () => { + expect(isSkillMountStatus("mounted")).toBe(true); + expect(isSkillMountStatus("failed")).toBe(true); + expect(isSkillMountStatus("unknown")).toBe(false); + }); +}); diff --git a/packages/core/src/domain/skill-management.ts b/packages/core/src/domain/skill-management.ts new file mode 100644 index 000000000..f88aa0a85 --- /dev/null +++ b/packages/core/src/domain/skill-management.ts @@ -0,0 +1,93 @@ +export const SKILL_INSTALL_STATES = ["installed", "installing", "failed"] as const; +export const SKILL_LIBRARY_ITEM_STATES = [ + "unmounted", + "partially_mounted", + "fully_mounted", + "error", +] as const; +export const SKILL_MOUNT_STATUSES = [ + "mounted", + "stale", + "missing_target", + "missing_source", + "failed", +] as const; +export const SKILL_TARGET_HEALTH_STATES = ["healthy", "warning", "error", "unconfigured"] as const; + +type SkillInstallState = (typeof SKILL_INSTALL_STATES)[number]; +type SkillMountStatus = (typeof SKILL_MOUNT_STATUSES)[number]; +type SkillTargetHealthState = (typeof SKILL_TARGET_HEALTH_STATES)[number]; + +export function isSkillMountStatus(value: string): value is SkillMountStatus { + return (SKILL_MOUNT_STATUSES as readonly string[]).includes(value); +} + +export interface SkillLibraryEntry { + slug: string; + displayName: string; + description?: string; + version: string; + source: "skillhub" | "local"; + libraryPath: string; + installState: SkillInstallState; + installedAt: number; + updatedAt: number; + lastError?: string; +} + +export interface AgentSkillTargetEntry { + providerId: string; + displayName: string; + kind: "built_in" | "preset" | "custom"; + skillDir?: string; + mountPreference: "auto"; + lastHealthState: SkillTargetHealthState; + lastHealthError?: string; +} + +export interface SkillMountRelation { + providerId: string; + skillSlug: string; + enabled: boolean; + sourcePath: string; + targetPath: string; + mountModeResolved: "symlink" | "copy"; + status: SkillMountStatus; + lastSyncedAt?: number; + lastError?: string; +} + +export interface SkillInstallStepSnapshot { + id: string; + titleKey: string; + kind: "prepare" | "download" | "extract" | "verify"; + status: "pending" | "running" | "succeeded" | "failed"; + detail?: string; + startedAt?: number; + finishedAt?: number; +} + +export interface SkillInstallFailure { + code: + | "cli_unavailable" + | "search_parse_failed" + | "install_failed" + | "sync_failed" + | "invalid_skill_payload" + | "write_failed" + | "unknown_failure"; + slug: string; + failedStepId: string; + message: string; + detail?: string; +} + +export interface SkillInstallJobSnapshot { + jobId: string; + slug: string; + version?: string; + status: "queued" | "running" | "succeeded" | "failed"; + currentStepId?: string; + steps: SkillInstallStepSnapshot[]; + failure?: SkillInstallFailure; +} diff --git a/packages/core/src/domain/types.test.ts b/packages/core/src/domain/types.test.ts index 05b3fce3a..7101242f7 100644 --- a/packages/core/src/domain/types.test.ts +++ b/packages/core/src/domain/types.test.ts @@ -8,8 +8,9 @@ import type { GitFileDiffPayload, GitRevisionSource, SessionState, + WorkspaceHistoryEntry, } from "./types"; -import { deriveSessionTitle, SESSION_TITLE_MAX_LENGTH } from "./types"; +import { deriveSessionTitle, normalizeSessionTitleInput, SESSION_TITLE_MAX_LENGTH } from "./types"; describe("deriveSessionTitle", () => { it("returns undefined for empty/whitespace-only input", () => { @@ -44,6 +45,18 @@ describe("deriveSessionTitle", () => { }); }); +describe("normalizeSessionTitleInput", () => { + it("returns the full normalized submitted input without truncating it", () => { + expect(normalizeSessionTitleInput(" hello world this is a test\n")).toBe( + "hello world this is a test" + ); + }); + + it("returns undefined for whitespace-only input", () => { + expect(normalizeSessionTitleInput("\n\t ")).toBeUndefined(); + }); +}); + describe("SessionState", () => { it("only allows the PTY-driven lifecycle states", () => { expectTypeOf().toEqualTypeOf< @@ -58,6 +71,16 @@ describe("CustomProviderSessionMode", () => { }); }); +describe("WorkspaceHistoryEntry", () => { + it("captures path-based recent workspace metadata", () => { + expectTypeOf().toEqualTypeOf<{ + path: string; + name: string; + lastOpenedAt: number; + }>(); + }); +}); + describe("AgentContextKind", () => { it("covers the backend context package variants", () => { expectTypeOf().toEqualTypeOf< diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index bfa0e7e94..4c8989ff5 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -74,6 +74,7 @@ export interface UiState { fileTreeExpandedDirs?: string[]; openEditorPaths?: string[]; activeEditorPath?: string | null; + agentInstructionsExpanded?: boolean; } export interface WorkspaceLastViewedTarget { @@ -82,10 +83,68 @@ export interface WorkspaceLastViewedTarget { updatedAt: number; } -export interface WorkspaceIntelligenceRecommendedCommand { - key: "dev" | "test" | "build" | "lint"; +export interface WorkspaceHistoryEntry { + path: string; + name: string; + lastOpenedAt: number; +} + +export interface WorkspaceIntelligenceCommandReference { command: string; +} + +export interface WorkspaceIntelligenceRecommendedCommand + extends WorkspaceIntelligenceCommandReference { + key: "dev" | "test" | "build" | "lint"; source: "package_json" | "makefile" | "detected"; + intent?: "recommended_entrypoint"; +} + +export interface WorkspaceIntelligenceKeyDirectory { + path: string; + kind: + | "frontend" + | "backend" + | "providers" + | "shared" + | "cli" + | "docs" + | "tests" + | "scripts" + | "other"; + reason: string; +} + +export interface WorkspaceIntelligencePackageSummary { + path: string; + name?: string; + role: + | "frontend_ui" + | "backend_runtime" + | "provider_integrations" + | "shared_contracts" + | "cli_entrypoint" + | "shared_utilities" + | "shared_package"; + scripts: string[]; +} + +export interface WorkspaceIntelligenceVerificationCommand + extends WorkspaceIntelligenceCommandReference { + reason: string; + priority: "verification" | "quality" | "dev"; + intent?: "verification_workflow"; +} + +export type WorkspaceIntelligenceDocumentationKind = "readme" | "docs" | "guide" | "wiki"; + +export interface WorkspaceIntelligenceDocumentationEntry { + path: string; + kind: WorkspaceIntelligenceDocumentationKind; +} + +export interface WorkspaceIntelligenceDocEntry extends WorkspaceIntelligenceDocumentationEntry { + kind: "readme" | "docs"; } export interface WorkspaceIntelligenceSummary { @@ -104,23 +163,104 @@ export interface WorkspaceIntelligenceSummary { lint?: string; }; recommendedCommands: WorkspaceIntelligenceRecommendedCommand[]; - docs: Array<{ - path: string; - kind: "readme" | "docs"; - }>; + docs: WorkspaceIntelligenceDocEntry[]; + workspaceKind?: "monorepo" | "node_app" | "unknown"; + topLevelDirectories?: string[]; + keyDirectories?: WorkspaceIntelligenceKeyDirectory[]; + packages?: WorkspaceIntelligencePackageSummary[]; + documentationEntries?: WorkspaceIntelligenceDocumentationEntry[]; + verificationCommands?: WorkspaceIntelligenceVerificationCommand[]; + fileConstraints?: string[]; agentInstructions: { exists: boolean; - path: ".coder-studio/AGENTS.md"; + path: ".coder-studio/agent.md"; }; } +export const SYSTEM_AGENT_INSTRUCTIONS_PROVIDER_IDS = [ + "codex", + "claude", + "gemini", + "opencode", +] as const; + +export type SystemAgentInstructionsProviderId = + (typeof SYSTEM_AGENT_INSTRUCTIONS_PROVIDER_IDS)[number]; + +export const SYSTEM_AGENT_INSTRUCTIONS_PATHS = { + codex: { + displayName: "Codex", + relPath: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + editable: true, + }, + claude: { + displayName: "Claude Code", + relPath: ".claude/CLAUDE.md", + displayPath: "~/.claude/CLAUDE.md", + editable: true, + }, + gemini: { + displayName: "Gemini CLI", + relPath: ".gemini/GEMINI.md", + displayPath: "~/.gemini/GEMINI.md", + editable: true, + }, + opencode: { + displayName: "OpenCode", + relPath: ".config/opencode/AGENTS.md", + displayPath: "~/.config/opencode/AGENTS.md", + editable: true, + }, +} as const satisfies Record< + SystemAgentInstructionsProviderId, + { + displayName: string; + relPath?: string; + displayPath: string; + editable: boolean; + } +>; + export interface AgentInstructionsDocument { - path: ".coder-studio/AGENTS.md"; + path: string; + displayPath?: string; exists: boolean; content: string; baseHash?: string; } +export type AgentInstructionsDocumentKind = "custom" | "system"; + +export interface AgentInstructionsPanelProjectStatus { + path: ".coder-studio/agent.md"; + displayPath: "项目 Agent.md"; + exists: boolean; + stale: boolean; +} + +export interface AgentInstructionsSystemStatusEntry { + providerId: SystemAgentInstructionsProviderId; + displayName: string; + path?: string; + displayPath: string; + exists: boolean; + editable: boolean; + status: "ready" | "missing" | "unsupported" | "error"; + reason?: string; +} + +export interface AgentInstructionsPanelStatus { + project: AgentInstructionsPanelProjectStatus; + system: AgentInstructionsSystemStatusEntry[]; + document: AgentInstructionsPanelProjectStatus; +} + +export interface AgentInstructionsSystemDocument extends AgentInstructionsDocument { + providerId: SystemAgentInstructionsProviderId; + displayPath: string; +} + export interface AgentInstructionsHealthIssue { code: | "missing_document" @@ -143,7 +283,7 @@ export interface AgentInstructionsHealthChecks { } export interface AgentInstructionsHealth { - path: ".coder-studio/AGENTS.md"; + path: ".coder-studio/agent.md"; exists: boolean; status: "healthy" | "warning" | "missing"; checks: AgentInstructionsHealthChecks; @@ -183,6 +323,11 @@ export interface AgentSessionMetadata { baselineGitHead?: string; baselineCapturedAt?: number; verificationRuns: AgentSessionVerificationRun[]; + attachedAgentInstructions?: { + effectiveHash: string; + mode: "auto" | "manual"; + attachedAt: number; + }; } export interface SessionReviewWarning { @@ -257,6 +402,11 @@ export interface Session { * their first message. */ title?: string; + /** + * Full normalized first submitted instruction used for title hover details. + * Assigned together with `title` and never overwritten afterwards. + */ + firstSubmittedUserInput?: string; } /** @@ -285,9 +435,17 @@ export interface GitStatus { untracked: GitFileChange[]; /** Worktree-only deletions (index unchanged, file removed in working tree). */ deleted: GitFileChange[]; + /** Unmerged files reported by porcelain v2 `u` records during merge conflicts. */ + conflicted?: GitFileChange[]; } -export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "untracked"; +export type GitChangeStatus = + | "added" + | "modified" + | "deleted" + | "renamed" + | "untracked" + | "conflicted"; export type GitDiffRenderMode = "text" | "image"; @@ -310,7 +468,7 @@ export interface GitCommitSummary { export interface GitCommitFileEntry { path: string; oldPath?: string; - status: Exclude; + status: Exclude; renderAs: GitDiffRenderMode; } @@ -358,6 +516,7 @@ export interface FileNode { size?: number; mtime?: number; isGitIgnored?: boolean; + isSymlink?: boolean; } export interface SearchContentMatch { @@ -481,8 +640,13 @@ export interface ProviderConfig { * replaced with an ellipsis ("…") so the total length is still at most * SESSION_TITLE_MAX_LENGTH. */ -export function deriveSessionTitle(raw: string): string | undefined { +export function normalizeSessionTitleInput(raw: string): string | undefined { const normalized = raw.replace(/\s+/g, " ").trim(); + return normalized || undefined; +} + +export function deriveSessionTitle(raw: string): string | undefined { + const normalized = normalizeSessionTitleInput(raw); if (!normalized) return undefined; if (normalized.length <= SESSION_TITLE_MAX_LENGTH) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a9eab5f2e..36b5c64e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from "./domain/lsp"; export * from "./domain/mcp"; export * from "./domain/monitoring"; export * from "./domain/provider-install"; +export * from "./domain/skill-management"; export * from "./domain/supervisor"; export * from "./domain/system-dependency-install"; // Domain diff --git a/packages/core/src/provider/definition.ts b/packages/core/src/provider/definition.ts index 5f4cd331c..6eef3a962 100644 --- a/packages/core/src/provider/definition.ts +++ b/packages/core/src/provider/definition.ts @@ -18,7 +18,12 @@ export interface ProviderInstallMetadata { strategies: Partial>; } -export interface SupervisorEvalCommandRequest { +export type ProviderHeadlessScenario = + | "supervisor_eval" + | "agent_instructions_generate" + | "session_analysis"; + +export interface ProviderHeadlessCommandRequest { prompt: string; sessionId: string; workspacePath: string; @@ -27,8 +32,12 @@ export interface SupervisorEvalCommandRequest { outputFile?: string; } +export type SupervisorEvalCommandRequest = ProviderHeadlessCommandRequest; + export type ProviderKind = "built_in" | "preset" | "custom"; +export type ProviderStability = "stable" | "experimental"; + export type ProviderCapabilityKey = | "interactive_session" | "supervisor_eval" @@ -42,11 +51,31 @@ export interface ProviderCapabilityDescriptor { label: string; } +export interface ProviderHeadlessCommand { + argv: string[]; + outputFile?: string; + cwd?: string; + env?: Record; +} + +export interface ProviderHeadlessDefinition { + supportedScenarios: ProviderHeadlessScenario[]; + buildCommand: ( + config: ProviderConfig, + scenario: ProviderHeadlessScenario, + req: ProviderHeadlessCommandRequest + ) => ProviderHeadlessCommand | null; +} + export interface ProviderListItem { id: string; displayName: string; badge: string; kind: ProviderKind; + stability?: ProviderStability; + supportsAgentInstructions?: boolean; + supportsAgentInstructionsGeneration?: boolean; + supportsSkillsMount?: boolean; capability: "full" | "limited" | "unsupported"; capabilities: ProviderCapabilityDescriptor[]; requiredCommands: string[]; @@ -58,6 +87,10 @@ export interface ProviderDefinition { displayName: string; badge: string; kind: ProviderKind; + stability?: ProviderStability; + supportsAgentInstructions?: boolean; + supportsAgentInstructionsGeneration?: boolean; + supportsSkillsMount?: boolean; /** * Declarative label for UI badges and docs only. * Runtime behavior must read hooks/events directly. @@ -65,6 +98,11 @@ export interface ProviderDefinition { capability: "full" | "limited" | "unsupported"; capabilities: ProviderCapabilityDescriptor[]; install: ProviderInstallMetadata; + /** + * Directories the provider actually loads skills from. The first entry is the + * canonical write/display target; later entries are discovery aliases. + */ + skillMountDirectories?: string[]; // Command construction buildCommand( @@ -76,16 +114,6 @@ export interface ProviderDefinition { cwd: string; }; - buildSupervisorEvalCommand?( - config: ProviderConfig, - req: SupervisorEvalCommandRequest - ): { - argv: string[]; - outputFile?: string; - cwd?: string; - env?: Record; - } | null; - // Configuration configSchema: ZodSchema; defaultConfig: ProviderConfig; @@ -93,6 +121,16 @@ export interface ProviderDefinition { // Runtime requirements requiredCommands: string[]; + // Optional agent instructions publishing target for providers that read + // workspace-local instruction files from a fixed path. + agentInstructions?: { + publishTarget?: { + path: string; + }; + }; + + headless?: ProviderHeadlessDefinition; + /** PTY-output-based idle detection used by the session manager. */ idleHeuristics?: IdleHeuristics; } @@ -101,3 +139,16 @@ export interface LaunchContext { sessionId: string; workspacePath: string; } + +export function providerSupportsHeadlessScenario( + provider: Pick, + scenario: ProviderHeadlessScenario +): boolean { + return provider.headless?.supportedScenarios.includes(scenario) ?? false; +} + +export function providerSupportsAgentInstructionsGeneration( + provider: Pick +): boolean { + return providerSupportsHeadlessScenario(provider, "agent_instructions_generate"); +} diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index dcc048d02..75b46d862 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -72,7 +72,16 @@ export function writeRuntimeConfig(config: RuntimeConfig): void { export function deleteRuntimeConfig(): void { const runtimePath = getRuntimePath(); - if (existsSync(runtimePath)) { + if (!existsSync(runtimePath)) { + return; + } + + try { unlinkSync(runtimePath); + } catch (error) { + const candidate = error as { code?: string }; + if (candidate.code !== "ENOENT") { + throw error; + } } } diff --git a/packages/providers/src/claude/definition.test.ts b/packages/providers/src/claude/definition.test.ts index 1f706b2f6..44bebae69 100644 --- a/packages/providers/src/claude/definition.test.ts +++ b/packages/providers/src/claude/definition.test.ts @@ -1,3 +1,5 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; import type { ProviderConfig } from "@coder-studio/core"; import { describe, expect, it } from "vitest"; import { claudeDefinition, claudeInstallMetadata } from "./definition.js"; @@ -18,6 +20,17 @@ describe("Claude Provider Definition", () => { expect(claudeDefinition.requiredCommands).toEqual(["claude"]); }); + it("uses the Claude skill directory as the default mount target", () => { + expect(claudeDefinition.supportsSkillsMount).toBe(true); + expect(claudeDefinition.skillMountDirectories).toEqual([ + join(homedir(), ".claude", "skills"), + ]); + }); + + it("publishes agent instructions to the project-scoped Claude memory file", () => { + expect(claudeDefinition.agentInstructions?.publishTarget?.path).toBe(".claude/CLAUDE.md"); + }); + it("should expose install metadata", () => { expect(claudeDefinition.install).toBe(claudeInstallMetadata); expect(claudeInstallMetadata.prerequisites).toEqual(["npm"]); @@ -123,15 +136,16 @@ describe("Claude Provider Definition", () => { }); }); - describe("buildSupervisorEvalCommand", () => { + describe("headless", () => { it("builds a supervisor eval command with claude -p --output-format json", () => { - const result = claudeDefinition.buildSupervisorEvalCommand?.( + const result = claudeDefinition.headless?.buildCommand( { model: "claude-sonnet-4-6", maxTurns: null, additionalArgs: [], envVars: { ANTHROPIC_API_KEY: "sk-test" }, }, + "supervisor_eval", { prompt: "Return strict JSON", sessionId: "sess-1", @@ -149,18 +163,30 @@ describe("Claude Provider Definition", () => { }); it("omits the model flag for supervisor eval when no model is configured", () => { - const result = claudeDefinition.buildSupervisorEvalCommand?.( - {}, - { - prompt: "Return strict JSON", - sessionId: "sess-1", - workspacePath: "/workspace", - } - ); + const result = claudeDefinition.headless?.buildCommand({}, "supervisor_eval", { + prompt: "Return strict JSON", + sessionId: "sess-1", + workspacePath: "/workspace", + }); expect(result?.argv[0]).toBe("claude"); expect(result?.argv).not.toContain("--model"); }); + + it("exposes supervisor_eval, session_analysis, and agent_instructions_generate as headless scenarios", () => { + expect(claudeDefinition.headless?.supportedScenarios).toEqual([ + "supervisor_eval", + "session_analysis", + "agent_instructions_generate", + ]); + expect( + claudeDefinition.headless?.buildCommand({}, "agent_instructions_generate", { + prompt: "Return strict JSON", + sessionId: "sess-1", + workspacePath: "/workspace", + }) + ).not.toBeNull(); + }); }); describe("defaultConfig", () => { diff --git a/packages/providers/src/claude/definition.ts b/packages/providers/src/claude/definition.ts index 3a9e60ed4..8be3a2e55 100644 --- a/packages/providers/src/claude/definition.ts +++ b/packages/providers/src/claude/definition.ts @@ -1,5 +1,6 @@ import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core"; +import { providerSkillMountDirectories } from "../skills/directories.js"; import { claudeConfigSchema } from "./config-schema.js"; import { claudeIdleHeuristics } from "./idle-heuristics.js"; import { buildClaudeSupervisorEvalCommand } from "./supervisor-eval.js"; @@ -81,6 +82,8 @@ export const claudeDefinition: ProviderDefinition = { { key: "review", supported: false, label: "Review" }, ], install: claudeInstallMetadata, + supportsSkillsMount: true, + skillMountDirectories: providerSkillMountDirectories(".claude"), // ===== Command construction ===== buildCommand(config: ProviderConfig, ctx) { @@ -97,13 +100,30 @@ export const claudeDefinition: ProviderDefinition = { }; }, - buildSupervisorEvalCommand: buildClaudeSupervisorEvalCommand, - // ===== Configuration ===== configSchema: claudeConfigSchema, defaultConfig: {}, // ===== Runtime requirements ===== requiredCommands: ["claude"], + agentInstructions: { + publishTarget: { + path: ".claude/CLAUDE.md", + }, + }, + headless: { + supportedScenarios: ["supervisor_eval", "session_analysis", "agent_instructions_generate"], + buildCommand(config, scenario, req) { + if ( + scenario !== "supervisor_eval" && + scenario !== "session_analysis" && + scenario !== "agent_instructions_generate" + ) { + return null; + } + + return buildClaudeSupervisorEvalCommand(config, req); + }, + }, idleHeuristics: claudeIdleHeuristics, }; diff --git a/packages/providers/src/codex/definition.test.ts b/packages/providers/src/codex/definition.test.ts index 3ed53b424..409950004 100644 --- a/packages/providers/src/codex/definition.test.ts +++ b/packages/providers/src/codex/definition.test.ts @@ -1,3 +1,5 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; import type { ProviderConfig } from "@coder-studio/core"; import { describe, expect, it } from "vitest"; import { codexDefinition, codexInstallMetadata } from "./definition.js"; @@ -18,6 +20,18 @@ describe("Codex Provider Definition", () => { expect(codexDefinition.requiredCommands).toEqual(["codex"]); }); + it("uses the shared skill directory as the default mount target", () => { + expect(codexDefinition.supportsSkillsMount).toBe(true); + expect(codexDefinition.skillMountDirectories).toEqual([ + join(homedir(), ".agents", "skills"), + join(homedir(), ".codex", "skills"), + ]); + }); + + it("publishes agent instructions to the official AGENTS.md file", () => { + expect(codexDefinition.agentInstructions?.publishTarget?.path).toBe("AGENTS.md"); + }); + it("should expose install metadata", () => { expect(codexDefinition.install).toBe(codexInstallMetadata); expect(codexInstallMetadata.prerequisites).toEqual(["npm"]); @@ -132,13 +146,14 @@ describe("Codex Provider Definition", () => { }); }); - describe("buildSupervisorEvalCommand", () => { + describe("headless", () => { it("builds a supervisor eval command with codex exec --json", () => { - const result = codexDefinition.buildSupervisorEvalCommand?.( + const result = codexDefinition.headless?.buildCommand( { additionalArgs: [], envVars: { OPENAI_API_KEY: "sk-openai" }, }, + "supervisor_eval", { prompt: "Return strict JSON", sessionId: "sess-1", @@ -160,11 +175,12 @@ describe("Codex Provider Definition", () => { }); it("places additionalArgs before the prompt positional", () => { - const result = codexDefinition.buildSupervisorEvalCommand?.( + const result = codexDefinition.headless?.buildCommand( { additionalArgs: ["-c", 'model_reasoning_effort="low"'], envVars: {}, }, + "supervisor_eval", { prompt: "Return strict JSON", sessionId: "sess-1", @@ -181,12 +197,13 @@ describe("Codex Provider Definition", () => { }); it("passes the model override through to codex exec", () => { - const result = codexDefinition.buildSupervisorEvalCommand?.( + const result = codexDefinition.headless?.buildCommand( { model: "gpt-4.1", additionalArgs: [], envVars: {}, }, + "supervisor_eval", { prompt: "Return strict JSON", sessionId: "sess-1", @@ -197,6 +214,111 @@ describe("Codex Provider Definition", () => { expect(result?.argv).toEqual(expect.arrayContaining(["-m", "o3"])); }); + + it("supports supervisor and agent-instructions headless scenarios", () => { + expect(codexDefinition.headless?.supportedScenarios).toEqual([ + "supervisor_eval", + "agent_instructions_generate", + "session_analysis", + ]); + }); + + it("builds a headless codex exec command for agent instructions generation", () => { + const result = codexDefinition.headless!.buildCommand( + { + additionalArgs: ["-c", 'model_reasoning_effort="low"'], + envVars: { CODEX_ENV: "1" }, + }, + "agent_instructions_generate", + { + prompt: "Generate agent instructions", + sessionId: "sess-1", + workspacePath: "/workspace", + } + ); + + expect(result.argv).toEqual([ + "codex", + "exec", + "--json", + "-s", + "read-only", + "--skip-git-repo-check", + "-c", + 'model_reasoning_effort="low"', + "Generate agent instructions", + ]); + expect(result.cwd).toBe("/workspace"); + expect(result.env).toEqual({ + CODEX_ENV: "1", + CODER_STUDIO_SESSION_ID: "sess-1", + }); + }); + + it("passes through optional model and api key", () => { + const result = codexDefinition.headless!.buildCommand( + { + additionalArgs: [], + envVars: {}, + }, + "agent_instructions_generate", + { + prompt: "Generate agent instructions", + sessionId: "sess-1", + workspacePath: "/workspace", + model: "o3", + apiKey: "sk-openai", + } + ); + + expect(result.argv).toEqual([ + "codex", + "exec", + "--json", + "-s", + "read-only", + "--skip-git-repo-check", + "-m", + "o3", + "Generate agent instructions", + ]); + expect(result.env).toEqual({ + OPENAI_API_KEY: "sk-openai", + CODER_STUDIO_SESSION_ID: "sess-1", + }); + }); + + it("reuses the same transport assembly as supervisor eval", () => { + const config: ProviderConfig = { + additionalArgs: ["-c", 'model_reasoning_effort="low"'], + envVars: { CODEX_ENV: "1" }, + }; + const req = { + prompt: "Generate agent instructions", + sessionId: "sess-1", + workspacePath: "/workspace", + model: "o3", + apiKey: "sk-openai", + }; + + expect( + codexDefinition.headless!.buildCommand(config, "agent_instructions_generate", req) + ).toEqual(codexDefinition.headless?.buildCommand(config, "supervisor_eval", req)); + }); + + it("returns null for unsupported headless scenarios", () => { + expect( + codexDefinition.headless?.buildCommand( + { additionalArgs: [], envVars: {} }, + "unsupported_headless_scenario" as never, + { + prompt: "ignored", + sessionId: "sess-1", + workspacePath: "/workspace", + } + ) + ).toBeNull(); + }); }); describe("defaultConfig", () => { diff --git a/packages/providers/src/codex/definition.ts b/packages/providers/src/codex/definition.ts index 54e12f2d4..310e33465 100644 --- a/packages/providers/src/codex/definition.ts +++ b/packages/providers/src/codex/definition.ts @@ -1,8 +1,9 @@ import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core"; +import { sharedFirstSkillMountDirectories } from "../skills/directories.js"; import { type CodexConfig, codexConfigSchema } from "./config-schema.js"; +import { codexHeadlessDefinition } from "./headless.js"; import { idleDebounceMs, idlePromptPatterns, sessionIdPatterns } from "./stdout-heuristics.js"; -import { buildCodexSupervisorEvalCommand } from "./supervisor-eval.js"; export const codexInstallMetadata = { prerequisites: ["npm"], @@ -81,6 +82,8 @@ export const codexDefinition: ProviderDefinition = { { key: "review", supported: false, label: "Review" }, ], install: codexInstallMetadata, + supportsSkillsMount: true, + skillMountDirectories: sharedFirstSkillMountDirectories(".codex"), // ===== Command construction ===== buildCommand(config: ProviderConfig, ctx) { @@ -96,9 +99,6 @@ export const codexDefinition: ProviderDefinition = { }; }, - // Full mode: no resume support yet (Codex CLI may support it later) - buildSupervisorEvalCommand: buildCodexSupervisorEvalCommand, - // ===== Configuration ===== configSchema: codexConfigSchema, defaultConfig: { @@ -108,6 +108,12 @@ export const codexDefinition: ProviderDefinition = { // ===== Runtime requirements ===== requiredCommands: ["codex"], + agentInstructions: { + publishTarget: { + path: "AGENTS.md", + }, + }, + headless: codexHeadlessDefinition, idleHeuristics: { sessionIdPatterns, idlePromptPatterns, diff --git a/packages/providers/src/codex/headless.ts b/packages/providers/src/codex/headless.ts new file mode 100644 index 000000000..3fec09acf --- /dev/null +++ b/packages/providers/src/codex/headless.ts @@ -0,0 +1,27 @@ +import type { + ProviderConfig, + ProviderHeadlessCommandRequest, + ProviderHeadlessDefinition, + ProviderHeadlessScenario, +} from "@coder-studio/core"; +import { buildCodexSupervisorEvalCommand } from "./supervisor-eval.js"; + +function buildCodexHeadlessCommand( + config: ProviderConfig, + scenario: ProviderHeadlessScenario, + req: ProviderHeadlessCommandRequest +) { + switch (scenario) { + case "supervisor_eval": + case "agent_instructions_generate": + case "session_analysis": + return buildCodexSupervisorEvalCommand(config, req); + default: + return null; + } +} + +export const codexHeadlessDefinition: ProviderHeadlessDefinition = { + supportedScenarios: ["supervisor_eval", "agent_instructions_generate", "session_analysis"], + buildCommand: buildCodexHeadlessCommand, +}; diff --git a/packages/providers/src/cursor/config-schema.ts b/packages/providers/src/cursor/config-schema.ts new file mode 100644 index 000000000..4f64dbde6 --- /dev/null +++ b/packages/providers/src/cursor/config-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const cursorConfigSchema = z.object({ + model: z.string().trim().min(1).optional(), + additionalArgs: z.array(z.string()).default([]), + envVars: z.record(z.string(), z.string()).default({}), +}); + +export type CursorConfig = z.infer; diff --git a/packages/providers/src/cursor/definition.test.ts b/packages/providers/src/cursor/definition.test.ts new file mode 100644 index 000000000..598ff9569 --- /dev/null +++ b/packages/providers/src/cursor/definition.test.ts @@ -0,0 +1,83 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cursorDefinition } from "./definition.js"; + +describe("cursorDefinition", () => { + it("builds a workspace-scoped CLI command", () => { + const command = cursorDefinition.buildCommand( + { + model: "gpt-5", + additionalArgs: ["--force"], + envVars: { CURSOR_API_KEY: "x" }, + }, + { sessionId: "sess-1", workspacePath: "/tmp/ws" } + ); + + expect(command).toEqual({ + argv: ["agent", "--model", "gpt-5", "--force"], + env: { CURSOR_API_KEY: "x", CODER_STUDIO_SESSION_ID: "sess-1" }, + cwd: "/tmp/ws", + }); + }); + + it("builds supervisor eval with the official agent command", () => { + const command = cursorDefinition.headless?.buildCommand( + { + model: "gpt-5", + additionalArgs: [], + envVars: { CURSOR_API_KEY: "x" }, + }, + "supervisor_eval", + { + prompt: "Return JSON", + sessionId: "sess-1", + workspacePath: "/tmp/ws", + } + ); + + expect(command).toMatchObject({ + argv: ["agent", "--print", "Return JSON", "--output-format", "json", "--model", "gpt-5"], + env: { CURSOR_API_KEY: "x", CODER_STUDIO_SESSION_ID: "sess-1" }, + cwd: "/tmp/ws", + }); + }); + + it("exposes debounce idle heuristics for PTY-driven state detection", () => { + expect(cursorDefinition.idleHeuristics).toBeDefined(); + expect(cursorDefinition.idleHeuristics?.idlePromptPatterns).toEqual([]); + expect(cursorDefinition.idleHeuristics?.idleDebounceMs).toBe(4000); + }); + + it("supports skills mount through Cursor and shared skill directories", () => { + expect(cursorDefinition.supportsSkillsMount).toBe(true); + expect(cursorDefinition.skillMountDirectories).toEqual([ + join(homedir(), ".agents", "skills"), + join(homedir(), ".cursor", "skills"), + ]); + }); + + it("supports agent_instructions_generate in headless mode", () => { + expect(cursorDefinition.headless?.supportedScenarios).toEqual([ + "supervisor_eval", + "session_analysis", + "agent_instructions_generate", + ]); + + expect( + cursorDefinition.headless?.buildCommand( + { + model: "gpt-5", + additionalArgs: [], + envVars: { CURSOR_API_KEY: "x" }, + }, + "agent_instructions_generate", + { + prompt: "Return JSON", + sessionId: "sess-1", + workspacePath: "/tmp/ws", + } + ) + ).not.toBeNull(); + }); +}); diff --git a/packages/providers/src/cursor/definition.ts b/packages/providers/src/cursor/definition.ts new file mode 100644 index 000000000..efea3f317 --- /dev/null +++ b/packages/providers/src/cursor/definition.ts @@ -0,0 +1,95 @@ +import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core"; +import { debounceIdleHeuristics } from "../debounce-idle-heuristics.js"; +import { sharedFirstSkillMountDirectories } from "../skills/directories.js"; +import { type CursorConfig, cursorConfigSchema } from "./config-schema.js"; +import { buildCursorSupervisorEvalCommand } from "./supervisor-eval.js"; + +const cursorInstallMetadata = { + prerequisites: [], + manualGuideKeys: ["provider.install.cursor.manual"], + docUrls: { + provider: "https://cursor.com/docs/cli/installation", + prerequisites: {}, + }, + strategies: { + darwin: [ + { + id: "cursor-install-script", + kind: "provider", + targetCommand: "agent", + requiresCommands: ["bash"], + command: "bash", + args: ["-lc", "curl https://cursor.com/install -fsS | bash"], + }, + ], + linux: [ + { + id: "cursor-install-script", + kind: "provider", + targetCommand: "agent", + requiresCommands: ["bash"], + command: "bash", + args: ["-lc", "curl https://cursor.com/install -fsS | bash"], + }, + ], + }, +} satisfies ProviderDefinition["install"]; + +export const cursorDefinition: ProviderDefinition = { + id: "cursor", + displayName: "Cursor Agent", + badge: "Cursor", + kind: "built_in", + stability: "stable", + supportsAgentInstructions: true, + supportsSkillsMount: true, + skillMountDirectories: sharedFirstSkillMountDirectories(".cursor"), + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + install: cursorInstallMetadata, + buildCommand(config: ProviderConfig, ctx) { + const cfg = cursorConfigSchema.parse(config); + const modelArg = cfg.model ? ["--model", cfg.model] : []; + + return { + argv: ["agent", ...modelArg, ...cfg.additionalArgs], + env: { + ...cfg.envVars, + CODER_STUDIO_SESSION_ID: ctx.sessionId, + }, + cwd: ctx.workspacePath, + }; + }, + configSchema: cursorConfigSchema, + defaultConfig: { + additionalArgs: [], + envVars: {}, + } satisfies CursorConfig, + requiredCommands: ["agent"], + agentInstructions: { + publishTarget: { + path: "AGENTS.md", + }, + }, + headless: { + supportedScenarios: ["supervisor_eval", "session_analysis", "agent_instructions_generate"], + buildCommand(config, scenario, req) { + if ( + scenario !== "supervisor_eval" && + scenario !== "session_analysis" && + scenario !== "agent_instructions_generate" + ) { + return null; + } + + return buildCursorSupervisorEvalCommand(config, req); + }, + }, + idleHeuristics: debounceIdleHeuristics, +}; diff --git a/packages/providers/src/cursor/supervisor-eval.ts b/packages/providers/src/cursor/supervisor-eval.ts new file mode 100644 index 000000000..d41c62c2a --- /dev/null +++ b/packages/providers/src/cursor/supervisor-eval.ts @@ -0,0 +1,27 @@ +import type { ProviderConfig, SupervisorEvalCommandRequest } from "@coder-studio/core"; +import { cursorConfigSchema } from "./config-schema.js"; + +export function buildCursorSupervisorEvalCommand( + config: ProviderConfig, + req: SupervisorEvalCommandRequest +) { + const cfg = cursorConfigSchema.parse(config); + const model = req.model ?? cfg.model; + + return { + argv: [ + "agent", + "--print", + req.prompt, + "--output-format", + "json", + ...(model ? ["--model", model] : []), + ], + cwd: req.workspacePath, + env: { + ...cfg.envVars, + CODER_STUDIO_SESSION_ID: req.sessionId, + }, + outputFile: req.outputFile, + }; +} diff --git a/packages/providers/src/debounce-idle-heuristics.ts b/packages/providers/src/debounce-idle-heuristics.ts new file mode 100644 index 000000000..706b0f84c --- /dev/null +++ b/packages/providers/src/debounce-idle-heuristics.ts @@ -0,0 +1,11 @@ +import type { IdleHeuristics } from "@coder-studio/core"; + +/** + * Debounce-only idle detection for CLIs whose prompt format is not yet modeled. + * After the last PTY output, wait {@link debounceIdleHeuristics.idleDebounceMs} + * with no further output before declaring the session idle. + */ +export const debounceIdleHeuristics: IdleHeuristics = { + idlePromptPatterns: [], + idleDebounceMs: 4000, +}; diff --git a/packages/providers/src/gemini/config-schema.ts b/packages/providers/src/gemini/config-schema.ts new file mode 100644 index 000000000..0dccf96ab --- /dev/null +++ b/packages/providers/src/gemini/config-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const geminiConfigSchema = z.object({ + model: z.string().trim().min(1).optional(), + additionalArgs: z.array(z.string()).default([]), + envVars: z.record(z.string(), z.string()).default({}), +}); + +export type GeminiConfig = z.infer; diff --git a/packages/providers/src/gemini/definition.test.ts b/packages/providers/src/gemini/definition.test.ts new file mode 100644 index 000000000..ce1fb4d81 --- /dev/null +++ b/packages/providers/src/gemini/definition.test.ts @@ -0,0 +1,61 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { geminiDefinition } from "./definition.js"; + +describe("geminiDefinition", () => { + it("builds a workspace-scoped CLI command", () => { + const command = geminiDefinition.buildCommand( + { + model: "gemini-2.5-pro", + additionalArgs: ["--yolo"], + envVars: { GEMINI_API_KEY: "x" }, + }, + { sessionId: "sess-1", workspacePath: "/tmp/ws" } + ); + + expect(command).toEqual({ + argv: ["gemini", "--model", "gemini-2.5-pro", "--yolo"], + env: { GEMINI_API_KEY: "x", CODER_STUDIO_SESSION_ID: "sess-1" }, + cwd: "/tmp/ws", + }); + }); + + it("exposes debounce idle heuristics for PTY-driven state detection", () => { + expect(geminiDefinition.idleHeuristics).toBeDefined(); + expect(geminiDefinition.idleHeuristics?.idlePromptPatterns).toEqual([]); + expect(geminiDefinition.idleHeuristics?.idleDebounceMs).toBe(4000); + }); + + it("supports skills mount through Gemini and shared skill directories", () => { + expect(geminiDefinition.supportsSkillsMount).toBe(true); + expect(geminiDefinition.skillMountDirectories).toEqual([ + join(homedir(), ".agents", "skills"), + join(homedir(), ".gemini", "skills"), + ]); + }); + + it("supports agent_instructions_generate in headless mode", () => { + expect(geminiDefinition.headless?.supportedScenarios).toEqual([ + "supervisor_eval", + "session_analysis", + "agent_instructions_generate", + ]); + + expect( + geminiDefinition.headless?.buildCommand( + { + model: "gemini-2.5-pro", + additionalArgs: [], + envVars: { GEMINI_API_KEY: "x" }, + }, + "agent_instructions_generate", + { + prompt: "Return JSON", + sessionId: "sess-1", + workspacePath: "/tmp/ws", + } + ) + ).not.toBeNull(); + }); +}); diff --git a/packages/providers/src/gemini/definition.ts b/packages/providers/src/gemini/definition.ts new file mode 100644 index 000000000..355ad1cdf --- /dev/null +++ b/packages/providers/src/gemini/definition.ts @@ -0,0 +1,123 @@ +import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core"; +import { debounceIdleHeuristics } from "../debounce-idle-heuristics.js"; +import { sharedFirstSkillMountDirectories } from "../skills/directories.js"; +import { type GeminiConfig, geminiConfigSchema } from "./config-schema.js"; +import { buildGeminiSupervisorEvalCommand } from "./supervisor-eval.js"; + +const geminiInstallMetadata = { + prerequisites: ["npm"], + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.gemini.manual"], + docUrls: { + provider: "https://google-gemini.github.io/gemini-cli/docs/get-started/", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + strategies: { + win32: [ + { + id: "winget-nodejs-lts", + kind: "prerequisite", + targetCommand: "npm", + requiresCommands: ["winget"], + command: "winget", + args: ["install", "--id", "OpenJS.NodeJS.LTS", "--exact", "--silent"], + }, + { + id: "npm-install-gemini", + kind: "provider", + targetCommand: "gemini", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "@google/gemini-cli"], + }, + ], + darwin: [ + { + id: "brew-node", + kind: "prerequisite", + targetCommand: "npm", + requiresCommands: ["brew"], + command: "brew", + args: ["install", "node"], + }, + { + id: "npm-install-gemini", + kind: "provider", + targetCommand: "gemini", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "@google/gemini-cli"], + }, + ], + linux: [ + { + id: "npm-install-gemini", + kind: "provider", + targetCommand: "gemini", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "@google/gemini-cli"], + }, + ], + }, +} satisfies ProviderDefinition["install"]; + +export const geminiDefinition: ProviderDefinition = { + id: "gemini", + displayName: "Gemini CLI", + badge: "Gemini", + kind: "built_in", + stability: "stable", + supportsAgentInstructions: true, + supportsSkillsMount: true, + skillMountDirectories: sharedFirstSkillMountDirectories(".gemini"), + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + install: geminiInstallMetadata, + buildCommand(config: ProviderConfig, ctx) { + const cfg = geminiConfigSchema.parse(config); + const modelArg = cfg.model ? ["--model", cfg.model] : []; + + return { + argv: ["gemini", ...modelArg, ...cfg.additionalArgs], + env: { + ...cfg.envVars, + CODER_STUDIO_SESSION_ID: ctx.sessionId, + }, + cwd: ctx.workspacePath, + }; + }, + configSchema: geminiConfigSchema, + defaultConfig: { + additionalArgs: [], + envVars: {}, + } satisfies GeminiConfig, + requiredCommands: ["gemini"], + agentInstructions: { + publishTarget: { + path: "GEMINI.md", + }, + }, + headless: { + supportedScenarios: ["supervisor_eval", "session_analysis", "agent_instructions_generate"], + buildCommand(config, scenario, req) { + if ( + scenario !== "supervisor_eval" && + scenario !== "session_analysis" && + scenario !== "agent_instructions_generate" + ) { + return null; + } + + return buildGeminiSupervisorEvalCommand(config, req); + }, + }, + idleHeuristics: debounceIdleHeuristics, +}; diff --git a/packages/providers/src/gemini/supervisor-eval.ts b/packages/providers/src/gemini/supervisor-eval.ts new file mode 100644 index 000000000..981e34c8d --- /dev/null +++ b/packages/providers/src/gemini/supervisor-eval.ts @@ -0,0 +1,27 @@ +import type { ProviderConfig, SupervisorEvalCommandRequest } from "@coder-studio/core"; +import { geminiConfigSchema } from "./config-schema.js"; + +export function buildGeminiSupervisorEvalCommand( + config: ProviderConfig, + req: SupervisorEvalCommandRequest +) { + const cfg = geminiConfigSchema.parse(config); + const model = req.model ?? cfg.model; + + return { + argv: [ + "gemini", + "--prompt", + req.prompt, + "--output-format", + "json", + ...(model ? ["--model", model] : []), + ], + cwd: req.workspacePath, + env: { + ...cfg.envVars, + CODER_STUDIO_SESSION_ID: req.sessionId, + }, + outputFile: req.outputFile, + }; +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index df2bd9c94..5b2388eae 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -18,6 +18,7 @@ export { export { getProviderPresets, type ProviderPresetMetadata, providerPresets } from "./presets.js"; // Provider registry export { + builtInProviderIds, getAllProviderIds, getProviderById, getProvidersByCapability, diff --git a/packages/providers/src/opencode/config-schema.ts b/packages/providers/src/opencode/config-schema.ts new file mode 100644 index 000000000..dbda0d36a --- /dev/null +++ b/packages/providers/src/opencode/config-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const opencodeConfigSchema = z.object({ + model: z.string().trim().min(1).optional(), + additionalArgs: z.array(z.string()).default([]), + envVars: z.record(z.string(), z.string()).default({}), +}); + +export type OpenCodeConfig = z.infer; diff --git a/packages/providers/src/opencode/definition.test.ts b/packages/providers/src/opencode/definition.test.ts new file mode 100644 index 000000000..08a38fe28 --- /dev/null +++ b/packages/providers/src/opencode/definition.test.ts @@ -0,0 +1,43 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { opencodeDefinition } from "./definition.js"; + +describe("opencodeDefinition", () => { + it("publishes agent instructions to the official AGENTS.md file", () => { + expect(opencodeDefinition.supportsAgentInstructions).toBe(true); + expect(opencodeDefinition.agentInstructions?.publishTarget?.path).toBe("AGENTS.md"); + }); + + it("builds a workspace-scoped CLI command", () => { + const command = opencodeDefinition.buildCommand( + { + model: "gpt-oss", + additionalArgs: ["--local"], + envVars: { OPENCODE_TOKEN: "x" }, + }, + { sessionId: "sess-1", workspacePath: "/tmp/ws" } + ); + + expect(command).toEqual({ + argv: ["opencode", "--model", "gpt-oss", "--local"], + env: { OPENCODE_TOKEN: "x", CODER_STUDIO_SESSION_ID: "sess-1" }, + cwd: "/tmp/ws", + }); + }); + + it("exposes debounce idle heuristics for PTY-driven state detection", () => { + expect(opencodeDefinition.idleHeuristics).toBeDefined(); + expect(opencodeDefinition.idleHeuristics?.idlePromptPatterns).toEqual([]); + expect(opencodeDefinition.idleHeuristics?.idleDebounceMs).toBe(4000); + }); + + it("supports skills mount through OpenCode, shared, and Claude-compatible directories", () => { + expect(opencodeDefinition.supportsSkillsMount).toBe(true); + expect(opencodeDefinition.skillMountDirectories).toEqual([ + join(homedir(), ".agents", "skills"), + join(homedir(), ".config", "opencode", "skills"), + join(homedir(), ".claude", "skills"), + ]); + }); +}); diff --git a/packages/providers/src/opencode/definition.ts b/packages/providers/src/opencode/definition.ts new file mode 100644 index 000000000..c3415901a --- /dev/null +++ b/packages/providers/src/opencode/definition.ts @@ -0,0 +1,108 @@ +import type { ProviderConfig, ProviderDefinition } from "@coder-studio/core"; +import { debounceIdleHeuristics } from "../debounce-idle-heuristics.js"; +import { opencodeSkillMountDirectories } from "../skills/directories.js"; +import { type OpenCodeConfig, opencodeConfigSchema } from "./config-schema.js"; + +const opencodeInstallMetadata = { + prerequisites: ["npm"], + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.opencode.manual"], + docUrls: { + provider: "https://github.com/anomalyco/opencode#installation", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + strategies: { + win32: [ + { + id: "winget-nodejs-lts", + kind: "prerequisite", + targetCommand: "npm", + requiresCommands: ["winget"], + command: "winget", + args: ["install", "--id", "OpenJS.NodeJS.LTS", "--exact", "--silent"], + }, + { + id: "npm-install-opencode", + kind: "provider", + targetCommand: "opencode", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "opencode-ai"], + }, + ], + darwin: [ + { + id: "brew-node", + kind: "prerequisite", + targetCommand: "npm", + requiresCommands: ["brew"], + command: "brew", + args: ["install", "node"], + }, + { + id: "npm-install-opencode", + kind: "provider", + targetCommand: "opencode", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "opencode-ai"], + }, + ], + linux: [ + { + id: "npm-install-opencode", + kind: "provider", + targetCommand: "opencode", + requiresCommands: ["npm"], + command: "npm", + args: ["install", "-g", "opencode-ai"], + }, + ], + }, +} satisfies ProviderDefinition["install"]; + +export const opencodeDefinition: ProviderDefinition = { + id: "opencode", + displayName: "OpenCode", + badge: "OpenCode", + kind: "built_in", + stability: "experimental", + supportsAgentInstructions: true, + supportsSkillsMount: true, + skillMountDirectories: opencodeSkillMountDirectories(), + capability: "limited", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + install: opencodeInstallMetadata, + buildCommand(config: ProviderConfig, ctx) { + const cfg = opencodeConfigSchema.parse(config); + const modelArg = cfg.model ? ["--model", cfg.model] : []; + + return { + argv: ["opencode", ...modelArg, ...cfg.additionalArgs], + env: { + ...cfg.envVars, + CODER_STUDIO_SESSION_ID: ctx.sessionId, + }, + cwd: ctx.workspacePath, + }; + }, + configSchema: opencodeConfigSchema, + defaultConfig: { + additionalArgs: [], + envVars: {}, + } satisfies OpenCodeConfig, + requiredCommands: ["opencode"], + agentInstructions: { + publishTarget: { + path: "AGENTS.md", + }, + }, + idleHeuristics: debounceIdleHeuristics, +}; diff --git a/packages/providers/src/presets.test.ts b/packages/providers/src/presets.test.ts index 1acbf406b..23820ea28 100644 --- a/packages/providers/src/presets.test.ts +++ b/packages/providers/src/presets.test.ts @@ -38,8 +38,8 @@ describe("provider presets", () => { expect(first[0]).not.toBe(second[0]); const activeProviderIds = new Set(providerRegistry.map((provider) => provider.id)); - for (const preset of first) { - expect(activeProviderIds.has(preset.id)).toBe(false); - } + expect(activeProviderIds.has("gemini-cli")).toBe(false); + expect(activeProviderIds.has("aider")).toBe(false); + expect(activeProviderIds.has("opencode")).toBe(true); }); }); diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 14eb39a64..7065c2422 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -10,12 +10,15 @@ import { describe("Provider Registry", () => { describe("providerRegistry", () => { - it("should contain Claude and Codex providers", () => { - expect(providerRegistry.length).toBe(2); + it("should contain the built-in provider set", () => { + expect(providerRegistry.length).toBe(5); const ids = providerRegistry.map((p) => p.id); expect(ids).toContain("claude"); expect(ids).toContain("codex"); + expect(ids).toContain("gemini"); + expect(ids).toContain("cursor"); + expect(ids).toContain("opencode"); }); it("should have valid definitions for all providers", () => { @@ -47,6 +50,12 @@ describe("Provider Registry", () => { expect(result?.capability).toBe("full"); }); + it("should return Gemini, Cursor, and OpenCode providers", () => { + expect(getProviderById("gemini")?.requiredCommands).toEqual(["gemini"]); + expect(getProviderById("cursor")?.requiredCommands).toEqual(["agent"]); + expect(getProviderById("opencode")?.capability).toBe("limited"); + }); + it("should return undefined for unknown provider", () => { const result = getProviderById("unknown"); expect(result).toBeUndefined(); @@ -57,6 +66,9 @@ describe("Provider Registry", () => { it("should return true for valid IDs", () => { expect(isValidProviderId("claude")).toBe(true); expect(isValidProviderId("codex")).toBe(true); + expect(isValidProviderId("gemini")).toBe(true); + expect(isValidProviderId("cursor")).toBe(true); + expect(isValidProviderId("opencode")).toBe(true); }); it("should return false for invalid IDs", () => { @@ -68,23 +80,27 @@ describe("Provider Registry", () => { describe("getAllProviderIds", () => { it("should return all provider IDs", () => { const ids = getAllProviderIds(); - expect(ids.length).toBe(2); + expect(ids.length).toBe(5); expect(ids).toContain("claude"); expect(ids).toContain("codex"); + expect(ids).toContain("gemini"); + expect(ids).toContain("cursor"); + expect(ids).toContain("opencode"); }); }); describe("getProvidersByCapability", () => { it("should return full capability providers", () => { const fullProviders = getProvidersByCapability("full"); - expect(fullProviders.length).toBe(2); + expect(fullProviders.length).toBe(4); const ids = fullProviders.map((p) => p.id).sort(); - expect(ids).toEqual(["claude", "codex"]); + expect(ids).toEqual(["claude", "codex", "cursor", "gemini"]); }); - it("should return no limited capability providers (codex upgraded to full)", () => { + it("should return limited capability providers", () => { const limitedProviders = getProvidersByCapability("limited"); - expect(limitedProviders.length).toBe(0); + expect(limitedProviders.length).toBe(1); + expect(limitedProviders[0]?.id).toBe("opencode"); }); it("should return empty array for unsupported capability", () => { @@ -105,6 +121,10 @@ describe("Provider Registry", () => { displayName: "Claude Code", badge: "Claude", kind: "built_in", + stability: undefined, + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, capability: "full", capabilities: [ { key: "interactive_session", supported: true, label: "Interactive session" }, @@ -128,6 +148,10 @@ describe("Provider Registry", () => { displayName: "Codex", badge: "Codex", kind: "built_in", + stability: undefined, + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, capability: "full", capabilities: [ { key: "interactive_session", supported: true, label: "Interactive session" }, @@ -143,5 +167,101 @@ describe("Provider Registry", () => { expect("configSchema" in item).toBe(false); expect("defaultConfig" in item).toBe(false); }); + + it("maps provider metadata for Gemini and OpenCode", () => { + const gemini = toProviderListItem(getProviderById("gemini")!); + const opencode = toProviderListItem(getProviderById("opencode")!); + + expect(gemini).toMatchObject({ + id: "gemini", + stability: "stable", + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, + capability: "full", + }); + + expect(opencode).toMatchObject({ + id: "opencode", + stability: "experimental", + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: false, + supportsSkillsMount: true, + capability: "limited", + }); + }); + }); + + describe("install metadata", () => { + it("declares auto-install strategies for Gemini and OpenCode", () => { + const gemini = getProviderById("gemini"); + const opencode = getProviderById("opencode"); + + expect(gemini?.install).toMatchObject({ + prerequisites: ["npm"], + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.gemini.manual"], + docUrls: { + provider: "https://google-gemini.github.io/gemini-cli/docs/get-started/", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + }); + expect(gemini?.install.strategies.linux).toContainEqual( + expect.objectContaining({ + id: "npm-install-gemini", + kind: "provider", + targetCommand: "gemini", + command: "npm", + args: ["install", "-g", "@google/gemini-cli"], + }) + ); + + expect(opencode?.install).toMatchObject({ + prerequisites: ["npm"], + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.opencode.manual"], + docUrls: { + provider: "https://github.com/anomalyco/opencode#installation", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + }); + expect(opencode?.install.strategies.linux).toContainEqual( + expect.objectContaining({ + id: "npm-install-opencode", + kind: "provider", + targetCommand: "opencode", + command: "npm", + args: ["install", "-g", "opencode-ai"], + }) + ); + }); + + it("declares Cursor Agent install support through the official agent command", () => { + const cursor = getProviderById("cursor"); + + expect(cursor?.requiredCommands).toEqual(["agent"]); + expect(cursor?.install).toMatchObject({ + prerequisites: [], + manualGuideKeys: ["provider.install.cursor.manual"], + docUrls: { + provider: "https://cursor.com/docs/cli/installation", + prerequisites: {}, + }, + }); + expect(cursor?.install.strategies.linux).toEqual([ + expect.objectContaining({ + id: "cursor-install-script", + kind: "provider", + targetCommand: "agent", + requiresCommands: ["bash"], + command: "bash", + args: ["-lc", "curl https://cursor.com/install -fsS | bash"], + }), + ]); + expect(cursor?.install.strategies.darwin).toEqual(cursor?.install.strategies.linux); + expect(cursor?.install.strategies.win32).toBeUndefined(); + }); }); }); diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index ad9550c2e..babe38027 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -2,6 +2,11 @@ import type { ProviderDefinition, ProviderListItem } from "@coder-studio/core"; import { claudeDefinition } from "./claude/definition.js"; import { codexDefinition } from "./codex/definition.js"; +import { cursorDefinition } from "./cursor/definition.js"; +import { geminiDefinition } from "./gemini/definition.js"; +import { opencodeDefinition } from "./opencode/definition.js"; + +export const builtInProviderIds = ["claude", "codex", "gemini", "cursor", "opencode"] as const; /** * Static registry of all available providers @@ -13,7 +18,13 @@ import { codexDefinition } from "./codex/definition.js"; * 3. Import and add to this array * 4. Frontend automatically receives updated list via provider.list command */ -export const providerRegistry: ProviderDefinition[] = [claudeDefinition, codexDefinition]; +export const providerRegistry: ProviderDefinition[] = [ + claudeDefinition, + codexDefinition, + geminiDefinition, + cursorDefinition, + opencodeDefinition, +]; /** * Convert an internal provider definition into a frontend-safe list item. @@ -24,6 +35,14 @@ export function toProviderListItem(provider: ProviderDefinition): ProviderListIt displayName: provider.displayName, badge: provider.badge, kind: provider.kind, + stability: provider.stability, + supportsAgentInstructions: + provider.supportsAgentInstructions ?? + Boolean(provider.agentInstructions?.publishTarget?.path), + supportsAgentInstructionsGeneration: + provider.supportsAgentInstructionsGeneration ?? + Boolean(provider.headless?.supportedScenarios.includes("agent_instructions_generate")), + supportsSkillsMount: provider.supportsSkillsMount ?? false, capability: provider.capability, capabilities: provider.capabilities.map((capability) => ({ ...capability })), requiredCommands: [...provider.requiredCommands], diff --git a/packages/providers/src/skills/directories.ts b/packages/providers/src/skills/directories.ts new file mode 100644 index 000000000..48d5daa7e --- /dev/null +++ b/packages/providers/src/skills/directories.ts @@ -0,0 +1,20 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +const sharedSkillsDir = join(homedir(), ".agents", "skills"); + +export function providerSkillMountDirectories(providerHomeDirName: string): string[] { + return [join(homedir(), providerHomeDirName, "skills")]; +} + +export function sharedFirstSkillMountDirectories(providerHomeDirName: string): string[] { + return [sharedSkillsDir, ...providerSkillMountDirectories(providerHomeDirName)]; +} + +export function opencodeSkillMountDirectories(): string[] { + return [ + sharedSkillsDir, + join(homedir(), ".config", "opencode", "skills"), + join(homedir(), ".claude", "skills"), + ]; +} diff --git a/packages/server/src/__tests__/agent-instructions-command.test.ts b/packages/server/src/__tests__/agent-instructions-command.test.ts index 340235b2f..61cecc7cf 100644 --- a/packages/server/src/__tests__/agent-instructions-command.test.ts +++ b/packages/server/src/__tests__/agent-instructions-command.test.ts @@ -1,9 +1,15 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import type { ProviderDefinition } from "@coder-studio/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import { EventBus } from "../bus/event-bus.js"; +import { runCommandAsString } from "../provider-runtime/command-runner.js"; +import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import { + AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, AGENT_INSTRUCTIONS_RELATIVE_PATH, WORKSPACE_STATE_DIR, } from "../workspace/workspace-state.js"; @@ -12,10 +18,22 @@ import { dispatch } from "../ws/dispatch.js"; import "../commands/workspace.js"; import "../commands/agent-instructions.js"; +vi.mock("../provider-runtime/command-runner.js", () => ({ + runCommandAsString: vi.fn(), +})); + describe("agentInstructions commands", () => { const tempDirs: string[] = []; + const runCommandAsStringMock = vi.mocked(runCommandAsString); + const originalHome = process.env.HOME; afterEach(async () => { + vi.resetAllMocks(); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } await Promise.all( tempDirs.map(async (dir) => { try { @@ -29,7 +47,10 @@ describe("agentInstructions commands", () => { ); }); - function createContext(rootPath: string | null): CommandContext { + function createContext( + rootPath: string | null, + overrides: Partial = {} + ): CommandContext { return { workspaceMgr: { get(id: string) { @@ -47,11 +68,15 @@ describe("agentInstructions commands", () => { leftPanelWidth: 320, bottomPanelHeight: 240, focusMode: false, + activeSessionId: "sess-1", }, }; }, }, - sessionMgr: {} as never, + sessionMgr: { + get: vi.fn(() => undefined), + sendInput: vi.fn(), + } as never, terminalMgr: {} as never, eventBus: new EventBus(), broadcaster: {} as never, @@ -63,9 +88,115 @@ describe("agentInstructions commands", () => { activationMgr: { getLease: () => ({ wsClientId: "test-client" }), }, + ...overrides, } as unknown as CommandContext; } + function createSessionMetadataRepo(rootPath: string): SessionMetadataRepo { + const workspaceRepo = new WorkspaceRepo({ + filePath: join(rootPath, ".test-workspaces.json"), + }); + workspaceRepo.create({ + id: "ws-1", + path: rootPath, + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + activeSessionId: "sess-1", + }, + }); + + return new SessionMetadataRepo({ + workspaceRepo, + }); + } + + function createAgentGenerationProvider(options?: { + id?: string; + commandBuilder?: NonNullable["buildCommand"]; + }): ProviderDefinition { + const providerId = options?.id ?? "codex"; + + return { + id: providerId, + displayName: providerId, + badge: providerId, + kind: "built_in", + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: {}, + strategies: {}, + }, + buildCommand() { + return { + argv: [], + env: {}, + cwd: "/workspace", + }; + }, + configSchema: z.object({}).passthrough(), + defaultConfig: {}, + requiredCommands: [], + headless: { + supportedScenarios: ["agent_instructions_generate"], + buildCommand: + options?.commandBuilder ?? + ((_config, _scenario, _req) => ({ + argv: [providerId, "exec"], + })), + }, + } as ProviderDefinition; + } + + function codexJsonlPayload(text: string): string { + return [ + JSON.stringify({ type: "thread.started", thread_id: "t1" }), + JSON.stringify({ type: "turn.started" }), + JSON.stringify({ + type: "item.completed", + item: { id: "i1", type: "agent_message", text }, + }), + JSON.stringify({ type: "turn.completed", usage: { output_tokens: 20 } }), + ].join("\n"); + } + + function generationPayload(markdown: string): string { + return JSON.stringify({ + ok: true, + content: markdown, + }); + } + + function generationFailurePayload(message: string): string { + return JSON.stringify({ + ok: false, + error: message, + }); + } + + async function createTestHome(prefix: string): Promise { + const homePath = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(homePath); + process.env.HOME = homePath; + return homePath; + } + + function resultEnvelope(text: string): string { + return JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: text, + }); + } + it("returns workspace_not_found for missing workspaces", async () => { const result = await dispatch( { @@ -83,7 +214,7 @@ describe("agentInstructions commands", () => { expect(result.error?.code).toBe("workspace_not_found"); }); - it("reads a missing AGENTS.md without inventing content", async () => { + it("reads a missing agent.md without inventing content", async () => { const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-read-")); tempDirs.push(rootPath); @@ -107,6 +238,220 @@ describe("agentInstructions commands", () => { }); }); + it("reports system instruction status for supported providers only", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-system-status-")); + tempDirs.push(rootPath); + const homePath = await createTestHome("agent-instructions-home-status-"); + await mkdir(join(homePath, ".codex"), { recursive: true }); + await writeFile(join(homePath, ".codex", "AGENTS.md"), "# Codex\n"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-status", + op: "agentInstructions.system.status", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + providerId: "codex", + displayName: "Codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + editable: true, + status: "ready", + }, + { + providerId: "claude", + displayName: "Claude Code", + path: ".claude/CLAUDE.md", + displayPath: "~/.claude/CLAUDE.md", + exists: false, + editable: true, + status: "missing", + }, + { + providerId: "gemini", + displayName: "Gemini CLI", + path: ".gemini/GEMINI.md", + displayPath: "~/.gemini/GEMINI.md", + exists: false, + editable: true, + status: "missing", + }, + { + providerId: "opencode", + displayName: "OpenCode", + path: ".config/opencode/AGENTS.md", + displayPath: "~/.config/opencode/AGENTS.md", + exists: false, + editable: true, + status: "missing", + }, + ]); + }); + + it("reads a missing system agent file as empty content with a display path", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-system-read-")); + tempDirs.push(rootPath); + await createTestHome("agent-instructions-home-read-"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-read", + op: "agentInstructions.system.read", + args: { + workspaceId: "ws-1", + providerId: "codex", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: false, + content: "", + }); + }); + + it("creates a missing system agent file through the write command", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-system-write-")); + tempDirs.push(rootPath); + const homePath = await createTestHome("agent-instructions-home-write-"); + const eventBus = new EventBus(); + const emitSpy = vi.spyOn(eventBus, "emit"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-write", + op: "agentInstructions.system.write", + args: { + workspaceId: "ws-1", + providerId: "claude", + content: "# Agent Instructions\n\n## Personal Defaults\n- Be concise.\n", + }, + }, + createContext(rootPath, { eventBus }) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId: "claude", + path: ".claude/CLAUDE.md", + displayPath: "~/.claude/CLAUDE.md", + exists: true, + content: "# Agent Instructions\n\n## Personal Defaults\n- Be concise.\n", + }); + expect((result.data as { baseHash?: string }).baseHash).toEqual(expect.any(String)); + await expect(readFile(join(homePath, ".claude", "CLAUDE.md"), "utf8")).resolves.toBe( + "# Agent Instructions\n\n## Personal Defaults\n- Be concise.\n" + ); + expect(emitSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "fs.dirty", + }) + ); + }); + + it("rejects stale baseHash writes for system agent files", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-system-conflict-")); + tempDirs.push(rootPath); + const homePath = await createTestHome("agent-instructions-home-conflict-"); + const filePath = join(homePath, ".codex", "AGENTS.md"); + + const writeResult = await dispatch( + { + kind: "command", + id: "agent-instructions-system-write-initial", + op: "agentInstructions.system.write", + args: { + workspaceId: "ws-1", + providerId: "codex", + content: "initial\n", + }, + }, + createContext(rootPath) + ); + expect(writeResult.ok).toBe(true); + + const baseHash = (writeResult.data as { baseHash: string }).baseHash; + await writeFile(filePath, "external edit\n"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-system-write-conflict", + op: "agentInstructions.system.write", + args: { + workspaceId: "ws-1", + providerId: "codex", + content: "next\n", + baseHash, + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "conflict", + }); + }); + + it("rejects unsupported system providers instead of inventing a path", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-system-unsupported-")); + tempDirs.push(rootPath); + await createTestHome("agent-instructions-home-unsupported-"); + + const readResult = await dispatch( + { + kind: "command", + id: "agent-instructions-system-read-unsupported", + op: "agentInstructions.system.read", + args: { + workspaceId: "ws-1", + providerId: "cursor", + }, + }, + createContext(rootPath) + ); + const writeResult = await dispatch( + { + kind: "command", + id: "agent-instructions-system-write-unsupported", + op: "agentInstructions.system.write", + args: { + workspaceId: "ws-1", + providerId: "cursor", + content: "# Cursor\n", + }, + }, + createContext(rootPath) + ); + + expect(readResult.ok).toBe(false); + expect(readResult.error).toMatchObject({ + code: "agent_system_instructions_unsupported", + }); + expect(writeResult.ok).toBe(false); + expect(writeResult.error).toMatchObject({ + code: "agent_system_instructions_unsupported", + }); + }); + it("generates content from workspace intelligence and omits absent commands", async () => { const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-")); tempDirs.push(rootPath); @@ -147,110 +492,134 @@ describe("agentInstructions commands", () => { expect((result.data as { content: string }).content).not.toContain("- Lint:"); }); - it("writes and reads AGENTS.md roundtrip", async () => { - const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-write-")); + it("generates agent instructions through the agent provider command", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-")); tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: codexJsonlPayload(generationPayload("# Agent Instructions\n\nGenerated for tests\n")), + stderr: "", + }); - const content = [ - "# Agent Instructions", - "", - "## Project Overview", - "", - "- Git branch: main", - "", - "## Development Commands", - "", - "- Dev: `pnpm dev`", - "", - "## Working Rules", - "", - "- Keep changes focused on the requested task.", - "- Do not revert user changes unless explicitly asked.", - "- Prefer the project's existing patterns.", - "- Run the relevant verification command before reporting completion.", - "", - "## Review Expectations", - "", - "- Summarize changed files.", - "- Report verification commands and results.", - "- Call out risks, skipped tests, and assumptions.", - "", - "## Provider Notes", - "", - "- Claude Code: use the project rules above.", - "- Codex: use the project rules above.", - "", - ].join("\n"); - - const writeResult = await dispatch( + const result = await dispatch( { kind: "command", - id: "agent-instructions-write-1", - op: "agentInstructions.write", + id: "agent-instructions-generate-by-agent", + op: "agentInstructions.generateByAgent", args: { workspaceId: "ws-1", - content, + providerId: "codex", + model: "o3", }, }, - createContext(rootPath) + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider()], + }) ); - expect(writeResult.ok).toBe(true); + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + content: "# Agent Instructions\n\nGenerated for tests\n", + meta: { + providerId: "codex", + model: "o3", + }, + }); + }); - const readResult = await dispatch( + it("supports non-codex providers when they expose agent-instructions generation headless mode", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-gemini-")); + tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: resultEnvelope(generationPayload("# Agent Instructions\n\nGenerated by gemini\n")), + stderr: "", + }); + const commandBuilder = vi.fn(() => ({ + argv: ["gemini", "exec"], + })); + + const result = await dispatch( { kind: "command", - id: "agent-instructions-read-1", - op: "agentInstructions.read", + id: "agent-instructions-generate-by-agent-gemini", + op: "agentInstructions.generateByAgent", args: { workspaceId: "ws-1", + providerId: "gemini", + model: "", }, }, - createContext(rootPath) + createContext(rootPath, { + providerRegistry: [ + createAgentGenerationProvider({ id: "codex" }), + createAgentGenerationProvider({ + id: "gemini", + commandBuilder, + }), + ], + }) ); - expect(readResult.ok).toBe(true); - expect(readResult.data).toMatchObject({ - path: AGENT_INSTRUCTIONS_RELATIVE_PATH, - exists: true, - content, + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + content: "# Agent Instructions\n\nGenerated by gemini\n", + meta: { + providerId: "gemini", + }, }); + expect(commandBuilder).toHaveBeenCalledWith( + {}, + "agent_instructions_generate", + expect.objectContaining({ + model: undefined, + }) + ); }); - it("reports health for incomplete AGENTS.md content", async () => { - const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-health-")); + it("generates and writes agent instructions through the existing write flow", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-write-agent-")); tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: codexJsonlPayload(generationPayload("# Agent Instructions\n\nGenerated for write\n")), + stderr: "", + }); - await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); - await writeFile( - join(rootPath, AGENT_INSTRUCTIONS_RELATIVE_PATH), - [ - "# Agent Instructions", - "", - "## Project Overview", - "", - "- Git branch: main", - "", - "## Development Commands", - "", - "- Dev: `pnpm dev`", - "", - "## Working Rules", - "", - "- Keep changes focused on the requested task.", - "", - "## Provider Notes", - "", - "- Claude Code: use the project rules above.", - "", - ].join("\n") + const eventBus = new EventBus(); + const emitSpy = vi.spyOn(eventBus, "emit"); + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-write-by-agent", + op: "agentInstructions.generateAndWriteByAgent", + args: { + workspaceId: "ws-1", + providerId: "codex", + model: "o3-mini", + }, + }, + createContext(rootPath, { + eventBus, + providerRegistry: [createAgentGenerationProvider()], + }) ); - const result = await dispatch( + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + document: { + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + exists: true, + content: "# Agent Instructions\n\nGenerated for write\n", + }, + meta: { + providerId: "codex", + model: "o3-mini", + }, + }); + + const readResult = await dispatch( { kind: "command", - id: "agent-instructions-health-1", - op: "agentInstructions.health", + id: "agent-instructions-read-after-generate-write-by-agent", + op: "agentInstructions.read", args: { workspaceId: "ws-1", }, @@ -258,7 +627,688 @@ describe("agentInstructions commands", () => { createContext(rootPath) ); - expect(result.ok).toBe(true); - expect((result.data as { status: string }).status).toBe("warning"); + expect(readResult.ok).toBe(true); + expect(readResult.data).toMatchObject({ + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: true, + content: "# Agent Instructions\n\nGenerated for write\n", + }); + expect(emitSpy).toHaveBeenCalledWith({ + type: "fs.dirty", + workspaceId: "ws-1", + reason: "file_content", + }); + }); + + it("returns an unsupported-provider error when no provider can generate agent instructions", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "agent-instructions-generate-agent-unsupported-") + ); + tempDirs.push(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-unsupported", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_provider_unsupported", + }); + }); + + it("returns a clear unsupported-provider error when the selected provider cannot generate agent instructions", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "agent-instructions-generate-agent-selected-unsupported-") + ); + tempDirs.push(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-selected-unsupported", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + providerId: "claude", + }, + }, + createContext(rootPath, { + providerRegistry: [ + createAgentGenerationProvider({ id: "codex" }), + { + ...createAgentGenerationProvider({ id: "claude" }), + headless: { + supportedScenarios: ["supervisor_eval"], + buildCommand: vi.fn(() => null), + }, + }, + ], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_provider_unsupported", + message: "Provider does not support agent-instructions generation: claude", + }); + }); + + it("normalizes subprocess failures from agent-backed generation", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-failure-")); + tempDirs.push(rootPath); + runCommandAsStringMock.mockRejectedValue( + Object.assign(new Error("Command failed with exit code 1"), { + exitCode: 1, + stderr: "provider failed", + }) + ); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-failure", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider()], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_generation_failed", + }); + }); + + it("returns a typed timeout error when agent-backed generation exceeds the subprocess timeout", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-timeout-")); + tempDirs.push(rootPath); + runCommandAsStringMock.mockRejectedValue({ + code: "command_timeout", + message: "Command timed out after 120000ms", + timeoutMs: 120000, + stdout: "", + stderr: "", + }); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-timeout", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider()], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_generation_timeout", + }); + }); + + it("returns a typed no-output error when agent-backed generation exits without usable output", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-empty-")); + tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: " \n", + stderr: "", + }); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-empty", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider()], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_generation_no_output", + }); + }); + + it("propagates typed parse failures from agent-backed generation", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-generate-agent-parse-")); + tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: codexJsonlPayload(generationPayload("no heading")), + stderr: "", + }); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-parse", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider()], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + }); + + it("propagates typed generation payload failures from agent-backed generation", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "agent-instructions-generate-agent-payload-failure-") + ); + tempDirs.push(rootPath); + runCommandAsStringMock.mockResolvedValue({ + stdout: resultEnvelope(generationFailurePayload("provider refused")), + stderr: "", + }); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-generate-by-agent-payload-failure", + op: "agentInstructions.generateByAgent", + args: { + workspaceId: "ws-1", + providerId: "claude", + }, + }, + createContext(rootPath, { + providerRegistry: [createAgentGenerationProvider({ id: "claude" })], + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_parse_failed", + message: "provider refused", + }); + }); + + it("writes and reads agent.md roundtrip", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-write-")); + tempDirs.push(rootPath); + + const content = [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Workflow Expectations", + "", + "- Keep changes focused on the requested task.", + "- Do not revert user changes unless explicitly asked.", + "- Prefer the project's existing patterns.", + "- Run the relevant verification command before reporting completion.", + "", + "## Review Checklist", + "", + "- Summarize changed files.", + "- Report verification commands and results.", + "- Call out risks, skipped tests, and assumptions.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "- Codex: use the project rules above.", + "", + ].join("\n"); + + const writeResult = await dispatch( + { + kind: "command", + id: "agent-instructions-write-1", + op: "agentInstructions.write", + args: { + workspaceId: "ws-1", + content, + }, + }, + createContext(rootPath) + ); + + expect(writeResult.ok).toBe(true); + + const readResult = await dispatch( + { + kind: "command", + id: "agent-instructions-read-1", + op: "agentInstructions.read", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(readResult.ok).toBe(true); + expect(readResult.data).toMatchObject({ + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + exists: true, + content, + }); + }); + + it("reports health for incomplete AGENTS.md content", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-health-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile( + join(rootPath, AGENT_INSTRUCTIONS_RELATIVE_PATH), + [ + "# Agent Instructions", + "", + "## Project Overview", + "", + "- Git branch: main", + "", + "## Development Commands", + "", + "- Dev: `pnpm dev`", + "", + "## Workflow Expectations", + "", + "- Keep changes focused on the requested task.", + "", + "## Provider Notes", + "", + "- Claude Code: use the project rules above.", + "", + ].join("\n") + ); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-health-1", + op: "agentInstructions.health", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect((result.data as { status: string }).status).toBe("warning"); + }); + + it("reports status for empty workspaces", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-status-empty-")); + tempDirs.push(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-status-empty", + op: "agentInstructions.status", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + project: { + exists: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + stale: false, + }, + system: expect.any(Array), + document: { + exists: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + stale: false, + }, + }); + }); + + it("regenerates agent.md by overwriting the single source file", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-regenerate-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".git"), { recursive: true }); + await writeFile(join(rootPath, ".git", "HEAD"), "ref: refs/heads/main\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify({ + scripts: { + dev: "vite", + test: "vitest run", + }, + devDependencies: { + vite: "^7.0.0", + }, + }) + ); + await writeFile(join(rootPath, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n"); + await writeFile(join(rootPath, "README.md"), "# Repo\n"); + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile(join(rootPath, AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH), "# Manual\n"); + + const generateResult = await dispatch( + { + kind: "command", + id: "agent-instructions-regenerate", + op: "agentInstructions.regenerate", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(generateResult.ok).toBe(true); + expect(generateResult.data).toMatchObject({ + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + exists: true, + }); + + const statusResult = await dispatch( + { + kind: "command", + id: "agent-instructions-status-after-regenerate", + op: "agentInstructions.status", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(statusResult.ok).toBe(true); + expect(statusResult.data).toEqual({ + project: { + exists: true, + stale: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + }, + system: expect.any(Array), + document: { + exists: true, + stale: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + }, + }); + + const readResult = await dispatch( + { + kind: "command", + id: "agent-instructions-read-after-regenerate", + op: "agentInstructions.read", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(readResult.ok).toBe(true); + expect((readResult.data as { content: string }).content).toContain("## Project Overview"); + expect((readResult.data as { content: string }).content).not.toContain("# Manual"); + }); + + it("reports saved instructions as not stale even when they differ from generation", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-status-stale-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".git"), { recursive: true }); + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile(join(rootPath, ".git", "HEAD"), "ref: refs/heads/main\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify({ + scripts: { + dev: "vite", + }, + devDependencies: { + vite: "^7.0.0", + }, + }) + ); + await writeFile(join(rootPath, "pnpm-lock.yaml"), "lockfileVersion: 9.0\n"); + await writeFile(join(rootPath, "README.md"), "# Repo\n"); + await writeFile(join(rootPath, AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH), "stale effective\n"); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-status-stale", + op: "agentInstructions.status", + args: { + workspaceId: "ws-1", + }, + }, + createContext(rootPath) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ + project: { + exists: true, + stale: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + }, + system: expect.any(Array), + document: { + exists: true, + stale: false, + path: AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH, + displayPath: "项目 Agent.md", + }, + }); + }); + + it("returns session_not_found when attach target session is unavailable", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-attach-missing-session-")); + tempDirs.push(rootPath); + const sessionMetadataRepo = createSessionMetadataRepo(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-attach-missing-session", + op: "agentInstructions.attachToSession", + args: { + workspaceId: "ws-1", + sessionId: "sess-1", + }, + }, + createContext(rootPath, { + sessionMetadataRepo, + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "session_not_found", + }); + }); + + it("returns inject_target_unavailable when attach target session is not injectable", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-attach-noninjectable-")); + tempDirs.push(rootPath); + const sessionMetadataRepo = createSessionMetadataRepo(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-attach-noninjectable", + op: "agentInstructions.attachToSession", + args: { + workspaceId: "ws-1", + sessionId: "sess-1", + }, + }, + createContext(rootPath, { + sessionMetadataRepo, + sessionMgr: { + get: vi.fn(() => ({ + id: "sess-1", + terminalId: "term-1", + state: "starting", + workspaceId: "ws-1", + providerId: "codex", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + })), + sendInput: vi.fn(), + } as never, + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "inject_target_unavailable", + }); + }); + + it("returns agent_instructions_missing when attach target has no agent instructions", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-attach-missing-effective-")); + tempDirs.push(rootPath); + const sessionMetadataRepo = createSessionMetadataRepo(rootPath); + + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-attach-missing-effective", + op: "agentInstructions.attachToSession", + args: { + workspaceId: "ws-1", + sessionId: "sess-1", + }, + }, + createContext(rootPath, { + sessionMetadataRepo, + sessionMgr: { + get: vi.fn(() => ({ + id: "sess-1", + terminalId: "term-1", + state: "idle", + workspaceId: "ws-1", + providerId: "codex", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + })), + sendInput: vi.fn(), + } as never, + }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "agent_instructions_missing", + }); + }); + + it("injects agent instructions into the target session and records metadata", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-attach-success-")); + tempDirs.push(rootPath); + const sessionMetadataRepo = createSessionMetadataRepo(rootPath); + await mkdir(join(rootPath, WORKSPACE_STATE_DIR), { recursive: true }); + await writeFile( + join(rootPath, AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH), + ["# Agent Instructions", "", "- Custom rule.", ""].join("\n") + ); + + sessionMetadataRepo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + verificationRuns: [], + }); + + const sendInput = vi.fn(); + const result = await dispatch( + { + kind: "command", + id: "agent-instructions-attach-success", + op: "agentInstructions.attachToSession", + args: { + workspaceId: "ws-1", + sessionId: "sess-1", + }, + }, + createContext(rootPath, { + sessionMetadataRepo, + sessionMgr: { + get: vi.fn(() => ({ + id: "sess-1", + terminalId: "term-1", + state: "idle", + workspaceId: "ws-1", + providerId: "codex", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + })), + sendInput, + } as never, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + injected: true, + sessionId: "sess-1", + mode: "manual", + effectiveHash: expect.any(String), + }); + expect(sendInput).toHaveBeenCalledWith("sess-1", expect.any(Buffer), "internal_submit"); + + const payload = (sendInput.mock.calls[0]?.[1] as Buffer).toString("utf8"); + expect(payload.startsWith("\x1b[200~")).toBe(true); + expect(payload.endsWith("\x1b[201~\r")).toBe(true); + expect(payload).toContain("# Agent Instructions"); + expect(payload).toContain("- Custom rule."); + + expect(sessionMetadataRepo.get("sess-1")).toMatchObject({ + attachedAgentInstructions: { + effectiveHash: (result.data as { effectiveHash: string }).effectiveHash, + mode: "manual", + attachedAt: expect.any(Number), + }, + }); }); }); diff --git a/packages/server/src/__tests__/agent-instructions-publisher.test.ts b/packages/server/src/__tests__/agent-instructions-publisher.test.ts new file mode 100644 index 000000000..12b6e9aa8 --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions-publisher.test.ts @@ -0,0 +1,351 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderDefinition } from "@coder-studio/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { AgentInstructionsPublisher } from "../agent-instructions/publisher.js"; +import type { CommandAvailabilityCheck } from "../provider-runtime/command-check.js"; + +describe("AgentInstructionsPublisher", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.map(async (dir) => { + try { + await rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures in tests. + } + }) + ); + }); + + function createProvider( + id: string, + path?: string, + requiredCommands: string[] = [] + ): ProviderDefinition { + return { + id, + displayName: id, + badge: id, + kind: "built_in", + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + strategies: {}, + }, + buildCommand() { + return { argv: [], env: {}, cwd: "/" }; + }, + configSchema: {} as ProviderDefinition["configSchema"], + defaultConfig: {}, + requiredCommands, + agentInstructions: path + ? { + publishTarget: { + path, + }, + } + : undefined, + }; + } + + function createPublisher( + rootPath: string, + providers: ProviderDefinition[], + commandExists?: CommandAvailabilityCheck + ) { + return new AgentInstructionsPublisher({ + workspaceMgr: { + get(workspaceId: string) { + if (workspaceId !== "ws-1") { + return undefined; + } + + return { + id: workspaceId, + path: rootPath, + targetRuntime: "native" as const, + openedAt: Date.now(), + lastActiveAt: Date.now(), + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + }, + }; + }, + list() { + return [ + { + id: "ws-1", + path: rootPath, + targetRuntime: "native" as const, + openedAt: Date.now(), + lastActiveAt: Date.now(), + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + }, + }, + ]; + }, + }, + getProviderRegistry: () => providers, + commandExists, + }); + } + + it("publishes only the targets declared by the active provider registry", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher(rootPath, [createProvider("codex", "AGENTS.md")]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual([ + expect.objectContaining({ + providerIds: ["codex"], + path: "AGENTS.md", + action: "written", + }), + ]); + + const codex = await readFile(join(rootPath, "AGENTS.md"), "utf8"); + expect(codex).toContain("# Agent Instructions"); + await expect(stat(join(rootPath, ".claude", "CLAUDE.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("ignores providers without an agent instructions publish target", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-ignore-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher(rootPath, [ + createProvider("codex", "AGENTS.md"), + createProvider("local-custom"), + ]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual([ + expect.objectContaining({ + providerIds: ["codex"], + path: "AGENTS.md", + action: "written", + }), + ]); + }); + + it("publishes multiple provider-specific targets when multiple providers declare them", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-multi-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher(rootPath, [ + createProvider("codex", "AGENTS.md"), + createProvider("claude", ".claude/CLAUDE.md"), + ]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerIds: ["codex"], + path: "AGENTS.md", + action: "written", + }), + expect.objectContaining({ + providerIds: ["claude"], + path: ".claude/CLAUDE.md", + action: "written", + }), + ]) + ); + }); + + it("publishes one shared AGENTS.md target for providers that use the same official file", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-shared-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher(rootPath, [ + createProvider("codex", "AGENTS.md"), + createProvider("cursor", "AGENTS.md"), + createProvider("opencode", "AGENTS.md"), + ]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual([ + expect.objectContaining({ + providerIds: ["codex", "cursor", "opencode"], + path: "AGENTS.md", + action: "written", + }), + ]); + }); + + it("defaults to publishing declared targets when install status is unavailable", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-default-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher(rootPath, [createProvider("codex", "AGENTS.md", ["codex"])]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual([ + expect.objectContaining({ + providerIds: ["codex"], + path: "AGENTS.md", + action: "written", + }), + ]); + }); + + it("publishes only targets for installed providers", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-installed-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + const publisher = createPublisher( + rootPath, + [ + createProvider("codex", "AGENTS.md", ["codex"]), + createProvider("claude", ".claude/CLAUDE.md", ["claude"]), + ], + async (command) => command === "codex" + ); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerIds: ["codex"], + path: "AGENTS.md", + action: "written", + }), + expect.objectContaining({ + providerIds: ["claude"], + path: ".claude/CLAUDE.md", + action: "unchanged", + }), + ]) + ); + + const codex = await readFile(join(rootPath, "AGENTS.md"), "utf8"); + expect(codex).toContain("# Agent Instructions"); + await expect(stat(join(rootPath, ".claude", "CLAUDE.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("deletes stale target files when the provider is no longer installed", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-stale-")); + tempDirs.push(rootPath); + + await mkdir(join(rootPath, ".coder-studio"), { recursive: true }); + await writeFile( + join(rootPath, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + await mkdir(join(rootPath, ".claude"), { recursive: true }); + await writeFile(join(rootPath, ".claude", "CLAUDE.md"), "stale claude content\n"); + + const publisher = createPublisher( + rootPath, + [createProvider("claude", ".claude/CLAUDE.md", ["claude"])], + async () => false + ); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual([ + expect.objectContaining({ + providerIds: ["claude"], + path: ".claude/CLAUDE.md", + action: "deleted", + }), + ]); + await expect(stat(join(rootPath, ".claude", "CLAUDE.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("deletes managed targets when no agent instructions exist", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "agent-instructions-publisher-empty-")); + tempDirs.push(rootPath); + + await writeFile(join(rootPath, "AGENTS.md"), "stale shared content\n"); + await mkdir(join(rootPath, ".claude"), { recursive: true }); + await writeFile(join(rootPath, ".claude", "CLAUDE.md"), "stale claude content\n"); + + const publisher = createPublisher(rootPath, [ + createProvider("codex", "AGENTS.md"), + createProvider("cursor", "AGENTS.md"), + createProvider("opencode", "AGENTS.md"), + createProvider("claude", ".claude/CLAUDE.md"), + ]); + const result = await publisher.syncWorkspace("ws-1"); + + expect(result.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerIds: ["codex", "cursor", "opencode"], + path: "AGENTS.md", + action: "deleted", + }), + expect.objectContaining({ + providerIds: ["claude"], + path: ".claude/CLAUDE.md", + action: "deleted", + }), + ]) + ); + await expect(stat(join(rootPath, "AGENTS.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(stat(join(rootPath, ".claude", "CLAUDE.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/packages/server/src/__tests__/agent-instructions/effective.test.ts b/packages/server/src/__tests__/agent-instructions/effective.test.ts new file mode 100644 index 000000000..a7b8156e6 --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions/effective.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + hashAgentInstructionsContent, + normalizeAgentInstructionsContent, +} from "../../agent-instructions/effective.js"; + +describe("agent instruction helpers", () => { + it("normalizes trailing whitespace consistently", () => { + expect(normalizeAgentInstructionsContent("# Agent Instructions\n\n")).toBe( + "# Agent Instructions\n" + ); + }); + + it("hashes normalized content consistently", () => { + expect(hashAgentInstructionsContent("# Agent Instructions")).toBe( + hashAgentInstructionsContent("# Agent Instructions\n") + ); + }); +}); diff --git a/packages/server/src/__tests__/agent-instructions/generator.test.ts b/packages/server/src/__tests__/agent-instructions/generator.test.ts index 56f646a00..384d9e9f4 100644 --- a/packages/server/src/__tests__/agent-instructions/generator.test.ts +++ b/packages/server/src/__tests__/agent-instructions/generator.test.ts @@ -25,6 +25,44 @@ describe("buildAgentInstructionsMarkdown", () => { { key: "test", command: "pnpm test", source: "package_json" }, ], docs: [{ path: "README.md", kind: "readme" }], + workspaceKind: "monorepo", + topLevelDirectories: ["docs", "packages"], + keyDirectories: [ + { + path: "packages/web", + kind: "frontend", + reason: "Primary frontend UI package for user-facing behavior.", + }, + { + path: "packages/server", + kind: "backend", + reason: "Backend runtime package that owns server-side behavior.", + }, + ], + packages: [ + { + path: "packages/web", + name: "@repo/web", + role: "frontend_ui", + scripts: ["test"], + }, + { + path: "packages/server", + name: "@repo/server", + role: "backend_runtime", + scripts: [], + }, + ], + verificationCommands: [ + { + command: "pnpm ci:verify", + reason: "Repository-level validation workflow to run before handoff.", + priority: "verification", + }, + ], + fileConstraints: [ + "Respect package boundaries and keep changes scoped to the package you are touching unless cross-package edits are required.", + ], agentInstructions: { exists: false, path: AGENT_INSTRUCTIONS_RELATIVE_PATH, @@ -39,23 +77,46 @@ describe("buildAgentInstructionsMarkdown", () => { "", "- Git branch: main", "- Package manager: pnpm", + "- Workspace kind: monorepo", "- Frameworks: React", "- Docs: README.md", `- ${AGENT_INSTRUCTIONS_RELATIVE_PATH}: missing`, "", + "## Architecture Map", + "", + "- User-facing change routing:", + " - UI and interaction changes usually start in `packages/web`, then cross into `packages/server` when they need commands, persistence, or runtime side effects.", + "- Runtime and integration flow:", + " - `packages/server` is the orchestration layer for commands, runtime workflows, and workspace behavior.", + "- Package responsibilities:", + " - `packages/web`: Owns UI, interaction flows, and client-side state orchestration.", + " - `packages/server`: Owns commands, runtime behavior, workspace logic, and server-side orchestration.", + "- Documentation entrypoints:", + " - `README.md`: general repository documentation.", + "", + "## Key Directories", + "", + "- `packages/web`: Primary frontend UI package for user-facing behavior.", + "- `packages/server`: Backend runtime package that owns server-side behavior.", + "", "## Development Commands", "", - "- Dev: `pnpm dev`", - "- Test: `pnpm test`", + "- Verify: `pnpm ci:verify` - full repository verification before handoff", + "- Dev: `pnpm dev` - local development entrypoint", + "- Test: `pnpm test` - package-level test entrypoint", "", - "## Working Rules", + "## Workflow Expectations", "", "- Keep changes focused on the requested task.", "- Do not revert user changes unless explicitly asked.", "- Prefer the project's existing patterns.", "- Run the relevant verification command before reporting completion.", "", - "## Review Expectations", + "## File Constraints", + "", + "- Respect package boundaries and keep changes scoped to the package you are touching unless cross-package edits are required.", + "", + "## Review Checklist", "", "- Summarize changed files.", "- Report verification commands and results.", @@ -69,4 +130,68 @@ describe("buildAgentInstructionsMarkdown", () => { ].join("\n") ); }); + + it("adds concrete source entrypoints for coder-studio style workspaces", () => { + const summary: WorkspaceIntelligenceSummary = { + workspaceId: "ws-2", + rootPath: "/repo", + git: { + isRepo: true, + }, + packageManager: "pnpm", + frameworks: ["Node", "Monorepo"], + scripts: { + dev: undefined, + test: undefined, + build: undefined, + lint: undefined, + }, + recommendedCommands: [], + docs: [], + workspaceKind: "monorepo", + keyDirectories: [], + packages: [ + { + path: "packages/web", + name: "@coder-studio/web", + role: "frontend_ui", + scripts: [], + }, + { + path: "packages/server", + name: "@coder-studio/server", + role: "backend_runtime", + scripts: [], + }, + { + path: "packages/providers", + name: "@coder-studio/providers", + role: "provider_integrations", + scripts: [], + }, + { + path: "packages/core", + name: "@coder-studio/core", + role: "shared_contracts", + scripts: [], + }, + ], + agentInstructions: { + exists: false, + path: AGENT_INSTRUCTIONS_RELATIVE_PATH, + }, + }; + + const content = buildAgentInstructionsMarkdown(summary); + + expect(content).toContain( + "`packages/web/src/features/workspace/actions/`, then cross into `packages/server/src/ws/dispatch.ts` and `packages/server/src/commands/*.ts`" + ); + expect(content).toContain( + "`packages/server/src/commands/agent-instructions.ts`, `packages/server/src/agent-instructions/agent-generator.ts`, `prompt.ts`, and `workspace/intelligence.ts`" + ); + expect(content).toContain( + "`packages/core/src/domain/types.ts` and `packages/core/src/provider/definition.ts`" + ); + }); }); diff --git a/packages/server/src/__tests__/agent-instructions/health.test.ts b/packages/server/src/__tests__/agent-instructions/health.test.ts index b5fd58238..d5cea0019 100644 --- a/packages/server/src/__tests__/agent-instructions/health.test.ts +++ b/packages/server/src/__tests__/agent-instructions/health.test.ts @@ -15,7 +15,7 @@ describe("evaluateAgentInstructionsMarkdown", () => { "", "- Dev: `pnpm dev`", "", - "## Working Rules", + "## Workflow Expectations", "", "- Keep changes focused on the requested task.", "", @@ -55,14 +55,14 @@ describe("evaluateAgentInstructionsMarkdown", () => { "", "- Dev: `pnpm dev`", "", - "## Working Rules", + "## Workflow Expectations", "", "- Keep changes focused on the requested task.", "- Do not revert user changes unless explicitly asked.", "- Prefer the project's existing patterns.", "- Run the relevant verification command before reporting completion.", "", - "## Review Expectations", + "## Review Checklist", "", "- Summarize changed files.", "- Report verification commands and results.", diff --git a/packages/server/src/__tests__/agent-instructions/output.test.ts b/packages/server/src/__tests__/agent-instructions/output.test.ts new file mode 100644 index 000000000..ee3a9b16e --- /dev/null +++ b/packages/server/src/__tests__/agent-instructions/output.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { + extractAgentInstructionsMarkdownFromCodexJsonl, + extractAgentInstructionsReplyText, + normalizeGeneratedAgentInstructionsMarkdown, + parseGeneratedAgentInstructionsPayload, +} from "../../agent-instructions/output.js"; + +describe("agent instructions output extraction", () => { + it("extracts the final completed agent_message text from Codex JSONL output", () => { + const jsonl = [ + JSON.stringify({ + type: "item.completed", + item: { id: "1", type: "agent_message", text: "" }, + }), + JSON.stringify({ type: "item.started", item: { id: "2", type: "reasoning" } }), + JSON.stringify({ + type: "item.completed", + item: { + id: "3", + type: "agent_message", + text: '```json\n{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview"}\n```', + }, + }), + ].join("\n"); + + expect(extractAgentInstructionsReplyText("codex", jsonl)).toBe( + '```json\n{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview"}\n```' + ); + }); + + it.each([ + "claude", + "gemini", + "cursor", + ] as const)("extracts the final reply text from %s JSON envelopes", (providerId) => { + const envelope = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: '{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview"}', + }); + + expect(extractAgentInstructionsReplyText(providerId, envelope)).toBe( + '{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview"}' + ); + }); + + it("parses a successful generation payload and normalizes markdown", () => { + const replyText = + '```json\n{"ok":true,"content":"```markdown\\n# Agent Instructions\\n\\n## Project Overview\\n```"}\n```'; + + expect(parseGeneratedAgentInstructionsPayload(replyText)).toBe( + "# Agent Instructions\n\n## Project Overview\n" + ); + }); + + it("strips a wrapping markdown fence and normalizes a trailing newline", () => { + const wrapped = "```markdown\n# Agent Instructions\n\n## Project Overview\n```\n"; + + expect(normalizeGeneratedAgentInstructionsMarkdown(wrapped)).toBe( + "# Agent Instructions\n\n## Project Overview\n" + ); + }); + + it("throws a typed parse error when no agent instructions heading is present", () => { + expect.assertions(2); + + try { + normalizeGeneratedAgentInstructionsMarkdown("## Project Overview\n"); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + expect(error).toHaveProperty("message"); + } + }); + + it("throws a typed parse error for near-miss agent instructions headings", () => { + for (const content of [ + "# Agent InstructionsX\n\n## Project Overview\n", + "# Agent Instructions foo\n\n## Project Overview\n", + ]) { + expect(() => normalizeGeneratedAgentInstructionsMarkdown(content)).toThrowError( + /agent instructions/i + ); + + try { + normalizeGeneratedAgentInstructionsMarkdown(content); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + } + } + }); + + it("throws a typed parse error when the generation payload reports failure", () => { + expect.assertions(2); + + try { + parseGeneratedAgentInstructionsPayload('{"ok":false,"message":"provider refused"}'); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + expect(error).toMatchObject({ + message: expect.stringContaining("provider refused"), + }); + } + }); + + it("throws a typed parse error when no usable completed agent_message exists", () => { + expect.assertions(2); + + try { + extractAgentInstructionsReplyText( + "codex", + [ + JSON.stringify({ type: "item.completed", item: { id: "1", type: "reasoning" } }), + JSON.stringify({ + type: "item.completed", + item: { id: "2", type: "agent_message", text: "" }, + }), + ].join("\n") + ); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + expect(error).toHaveProperty("message"); + } + }); + + it("throws a typed parse error for malformed Codex JSONL input", () => { + expect.assertions(2); + + try { + extractAgentInstructionsReplyText("codex", "{not-json"); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + expect(error).toHaveProperty("message"); + } + }); + + it("throws a typed parse error for malformed generation payload JSON", () => { + expect.assertions(2); + + try { + parseGeneratedAgentInstructionsPayload("{not-json"); + } catch (error) { + expect(error).toMatchObject({ + code: "agent_instructions_parse_failed", + }); + expect(error).toHaveProperty("message"); + } + }); + + it("keeps the legacy Codex helper working with JSON payload output", () => { + const jsonl = [ + JSON.stringify({ + type: "item.completed", + item: { + id: "3", + type: "agent_message", + text: '{"ok":true,"content":"# Agent Instructions\\n\\n## Project Overview"}', + }, + }), + ].join("\n"); + + expect(extractAgentInstructionsMarkdownFromCodexJsonl(jsonl)).toBe( + "# Agent Instructions\n\n## Project Overview\n" + ); + }); +}); diff --git a/packages/server/src/__tests__/diagnostics-commands.test.ts b/packages/server/src/__tests__/diagnostics-commands.test.ts index 108e03e03..3e5542437 100644 --- a/packages/server/src/__tests__/diagnostics-commands.test.ts +++ b/packages/server/src/__tests__/diagnostics-commands.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ProviderDefinition } from "@coder-studio/core"; +import type { LspToolRuntimeStatusEntry, ProviderDefinition } from "@coder-studio/core"; import { providerRegistry } from "@coder-studio/providers"; import { describe, expect, it } from "vitest"; import type { EventBus } from "../bus/event-bus.js"; @@ -57,6 +57,36 @@ function createContext(overrides: Partial = {}): CommandContext }, ...providerRuntimeDeps, }, + lspMgr: { + getRuntimeMode: () => "auto", + } as never, + lspToolMgr: { + runtimeStatus: async ({ serverKind }: { serverKind: string }) => + ({ + serverKind, + displayName: `${serverKind} language server`, + available: serverKind === "typescript", + autoInstallSupported: serverKind !== "typescript", + installReadiness: + serverKind === "python" + ? "missing_prerequisite" + : serverKind === "rust" + ? "unsupported_platform" + : "ready", + missingCommands: + serverKind === "python" + ? ["pylsp"] + : serverKind === "go" + ? ["gopls"] + : serverKind === "vue" + ? ["vue-language-server"] + : [], + missingPrerequisites: serverKind === "python" ? ["python3"] : [], + }) satisfies LspToolRuntimeStatusEntry, + } as never, + lspToolInstallMgr: { + getLatestFailure: () => undefined, + } as never, ...restOverrides, }; } @@ -103,6 +133,37 @@ describe("diagnostics commands", () => { }), ]) ); + expect( + ( + result.data as { + lspServices: Array<{ serverKind: string; status: string }>; + metadata: { + lspRuntimeContext?: { + targetRuntime: "native" | "wsl"; + managedInstallSupported: boolean; + }; + }; + } + ).lspServices + ).toEqual([ + expect.objectContaining({ serverKind: "typescript", status: "installed" }), + expect.objectContaining({ serverKind: "python", status: "prerequisite_missing" }), + expect.objectContaining({ serverKind: "go", status: "not_installed" }), + expect.objectContaining({ serverKind: "rust", status: "not_installed" }), + expect.objectContaining({ serverKind: "vue", status: "not_installed" }), + ]); + expect( + ( + result.data as { + metadata: { + lspRuntimeContext?: { + targetRuntime: "native" | "wsl"; + managedInstallSupported: boolean; + }; + }; + } + ).metadata.lspRuntimeContext + ).toBeUndefined(); }); it("surfaces missing provider CLI checks for session start diagnostics", async () => { @@ -146,6 +207,119 @@ describe("diagnostics commands", () => { ); }); + it("surfaces latest failed LSP install state without affecting canContinue", async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), "diagnostics-lsp-runtime-")); + const result = await dispatch( + { + kind: "command", + id: "diag-session-lsp-install-failed", + op: "diagnostics.get", + args: { + context: "session_start", + workspaceId: "ws-1", + providerId: "claude", + }, + }, + createContext({ + workspaceMgr: { + get: (workspaceId: string) => + workspaceId === "ws-1" ? { id: "ws-1", path: workspaceDir } : undefined, + list: () => [], + } as unknown as WorkspaceManager, + lspToolMgr: { + runtimeStatus: async ({ serverKind }: { serverKind: string }) => + ({ + serverKind, + displayName: `${serverKind} language server`, + available: false, + autoInstallSupported: true, + installReadiness: "ready", + missingCommands: [serverKind], + missingPrerequisites: [], + }) satisfies LspToolRuntimeStatusEntry, + } as never, + lspToolInstallMgr: { + getLatestFailure: (serverKind: string) => + serverKind === "go" + ? { + jobId: "job-go-failed", + serverKind: "go", + status: "failed", + steps: [], + failure: { + code: "command_failed", + serverKind: "go", + message: "install failed", + failedStepId: "install-go-lsp", + command: "go", + args: ["install"], + missingCommands: [], + }, + } + : undefined, + } as never, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + context: "session_start", + canContinue: true, + metadata: { + workspaceId: "ws-1", + workspacePath: workspaceDir, + providerId: "claude", + lspRuntimeContext: { + targetRuntime: "native", + managedInstallSupported: true, + }, + }, + }); + expect( + (result.data as { lspServices: Array<{ serverKind: string; status: string }> }).lspServices + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ serverKind: "go", status: "install_failed" }), + ]) + ); + expect((result.data as { checks: Array<{ code: string }> }).checks).not.toEqual( + expect.arrayContaining([expect.objectContaining({ code: "lsp_install_failed" })]) + ); + }); + + it("reports runtime_off only when the global LSP runtime mode is off", async () => { + const result = await dispatch( + { + kind: "command", + id: "diag-session-lsp-runtime-off", + op: "diagnostics.get", + args: { + context: "session_start", + workspaceId: "ws-1", + providerId: "claude", + }, + }, + createContext({ + lspMgr: { + getRuntimeMode: () => "off", + } as never, + }) + ); + + expect(result.ok).toBe(true); + expect( + (result.data as { lspServices: Array<{ serverKind: string; status: string }> }).lspServices + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ serverKind: "typescript", status: "runtime_off" }), + expect.objectContaining({ serverKind: "python", status: "runtime_off" }), + expect.objectContaining({ serverKind: "go", status: "runtime_off" }), + expect.objectContaining({ serverKind: "rust", status: "runtime_off" }), + expect.objectContaining({ serverKind: "vue", status: "runtime_off" }), + ]) + ); + }); + it("blocks session start when node is missing but keeps workspace-open non-blocking", async () => { const workspaceDir = await mkdtemp(join(tmpdir(), "diagnostics-base-runtime-")); const nodeMissingContext = createContext({ diff --git a/packages/server/src/__tests__/fs/file-io.test.ts b/packages/server/src/__tests__/fs/file-io.test.ts index 1f51d2891..ca6d9eacc 100644 --- a/packages/server/src/__tests__/fs/file-io.test.ts +++ b/packages/server/src/__tests__/fs/file-io.test.ts @@ -2,7 +2,7 @@ * Tests for file-io operations. */ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join, win32 } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -82,6 +82,19 @@ describe("readFile", () => { await expect(readWorkspaceFile("ws-1", testDir, "nonexistent.txt")).rejects.toThrow(); }); + it("rejects symlinked files that resolve outside the workspace root", async () => { + const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-")); + const outsideFile = join(outsideDir, "secret.txt"); + await writeFile(outsideFile, "secret"); + await symlink(outsideFile, join(testDir, "escape.txt")); + + await expect(readWorkspaceFile("ws-1", testDir, "escape.txt")).rejects.toMatchObject({ + code: "path_escape", + }); + + await rm(outsideDir, { recursive: true, force: true }); + }); + it("should return an image descriptor with a signed-in asset URL for png files", async () => { // Minimal 1x1 PNG so we exercise the binary branch without depending on // fixture files; exact bytes don't matter since the endpoint just streams @@ -169,6 +182,19 @@ describe("writeFile", () => { // Try to write with outdated baseHash await expect(writeWorkspaceFile(testDir, "test.txt", "Updated", "wronghash")).rejects.toThrow(); }); + + it("rejects writing through symlinked files outside the workspace root", async () => { + const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-")); + const outsideFile = join(outsideDir, "secret.txt"); + await writeFile(outsideFile, "secret"); + await symlink(outsideFile, join(testDir, "escape.txt")); + + await expect(writeWorkspaceFile(testDir, "escape.txt", "updated")).rejects.toMatchObject({ + code: "path_escape", + }); + + await rm(outsideDir, { recursive: true, force: true }); + }); }); describe("createFile", () => { @@ -283,4 +309,17 @@ describe("deleteEntry", () => { code: "path_escape", }); }); + + it("rejects deleting symlinked files outside the workspace root", async () => { + const outsideDir = await mkdtemp(join(tmpdir(), "fileio-outside-")); + const outsideFile = join(outsideDir, "secret.txt"); + await writeFile(outsideFile, "secret"); + await symlink(outsideFile, join(testDir, "escape.txt")); + + await expect(deleteEntry(testDir, "escape.txt")).rejects.toMatchObject({ + code: "path_escape", + }); + + await rm(outsideDir, { recursive: true, force: true }); + }); }); diff --git a/packages/server/src/__tests__/fs/tree.test.ts b/packages/server/src/__tests__/fs/tree.test.ts index ccae46a05..151763138 100644 --- a/packages/server/src/__tests__/fs/tree.test.ts +++ b/packages/server/src/__tests__/fs/tree.test.ts @@ -2,7 +2,7 @@ * Tests for file tree builder (lazy loading version). */ -import { mkdir, mkdir as mkdirAsync, rmdir, writeFile } from "fs/promises"; +import { mkdir, mkdir as mkdirAsync, rmdir, symlink, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -162,4 +162,53 @@ describe("readTree", () => { isGitIgnored: false, }); }); + + it("shows symlinked files and directories using the target kind", async () => { + await mkdirAsync(join(testDir, "real-dir")); + await writeFile(join(testDir, "real-file.txt"), "content"); + await symlink(join(testDir, "real-dir"), join(testDir, "linked-dir"), "dir"); + await symlink(join(testDir, "real-file.txt"), join(testDir, "linked-file.txt")); + + const result = await readTree(testDir); + + expect(result.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "linked-dir", + path: "linked-dir", + kind: "dir", + isSymlink: true, + }), + expect.objectContaining({ + name: "linked-file.txt", + path: "linked-file.txt", + kind: "file", + isSymlink: true, + }), + ]) + ); + }); + + it("lists external symlink directories but rejects expanding them", async () => { + const outsideDir = join(tmpdir(), `tree-test-outside-${Date.now()}`); + await mkdirAsync(outsideDir); + await symlink(outsideDir, join(testDir, "external-dir"), "dir"); + + const rootResult = await readTree(testDir); + expect(rootResult.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "external-dir", + kind: "dir", + isSymlink: true, + }), + ]) + ); + + await expect(readTree(testDir, "external-dir")).rejects.toMatchObject({ + code: "path_escape", + }); + + await rmdir(outsideDir); + }); }); diff --git a/packages/server/src/__tests__/fs/watcher.test.ts b/packages/server/src/__tests__/fs/watcher.test.ts index 0fbbb820c..f8d58fa4d 100644 --- a/packages/server/src/__tests__/fs/watcher.test.ts +++ b/packages/server/src/__tests__/fs/watcher.test.ts @@ -93,6 +93,19 @@ describe("WorkspaceWatcher", () => { expect(ignored?.(join(testDir, "src/index.ts"))).toBe(false); }); + it("ignores managed target files used for agent instruction publishing", () => { + new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); + + const options = watchSpy.mock.calls[0]?.[1]; + const ignored = options?.ignored; + + expect(typeof ignored).toBe("function"); + expect(ignored?.(join(testDir, "AGENTS.md"))).toBe(true); + expect(ignored?.(join(testDir, "GEMINI.md"))).toBe(true); + expect(ignored?.(join(testDir, ".claude", "CLAUDE.md"))).toBe(true); + expect(ignored?.(join(testDir, ".coder-studio", "agent.md"))).toBe(false); + }); + it("ignores transient git lock files and write-heavy git internals", () => { new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); @@ -241,6 +254,20 @@ describe("WorkspaceWatcher", () => { ); }); + it("invokes the dirty callback after a single file event settles", async () => { + vi.useFakeTimers(); + const onDirty = vi.fn(); + + new WorkspaceWatcher("test-workspace-id", testDir, broadcaster, undefined, onDirty); + + watcherEvents.all?.("change", join(testDir, ".coder-studio", "agent.md")); + await vi.advanceTimersByTimeAsync(199); + expect(onDirty).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(onDirty).toHaveBeenCalledWith("test-workspace-id", "fs_change"); + }); + it("broadcasts fs.dirty after consecutive file events settle", async () => { vi.useFakeTimers(); new WorkspaceWatcher("test-workspace-id", testDir, broadcaster); diff --git a/packages/server/src/__tests__/git/status-parser.test.ts b/packages/server/src/__tests__/git/status-parser.test.ts index 7a8b990ad..12a851e40 100644 --- a/packages/server/src/__tests__/git/status-parser.test.ts +++ b/packages/server/src/__tests__/git/status-parser.test.ts @@ -16,6 +16,7 @@ describe("parseStatus", () => { expect(status.modified).toHaveLength(0); expect(status.untracked).toHaveLength(0); expect(status.deleted).toHaveLength(0); + expect(status.conflicted).toHaveLength(0); }); it("should parse branch name", () => { @@ -144,6 +145,21 @@ describe("parseStatus", () => { ]); }); + it("parses unmerged conflict entries as merge changes", () => { + const porcelain = `# branch.head main +u UU N... 100644 100644 100644 100644 base123 ours123 theirs123 conflicted.ts`; + const status = parseStatus(porcelain); + + expect(status.conflicted).toEqual([ + { + path: "conflicted.ts", + status: "conflicted", + }, + ]); + expect(status.modified).toHaveLength(0); + expect(status.staged).toHaveLength(0); + }); + it("should handle complex status", () => { const porcelain = `# branch.oid abc123 # branch.head main diff --git a/packages/server/src/__tests__/lsp-commands.test.ts b/packages/server/src/__tests__/lsp-commands.test.ts index 93e2afd7b..51f63a185 100644 --- a/packages/server/src/__tests__/lsp-commands.test.ts +++ b/packages/server/src/__tests__/lsp-commands.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventBus } from "../bus/event-bus.js"; +import { SettingsRepo } from "../storage/repositories/settings-repo.js"; import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import { WorkspaceManager } from "../workspace/manager.js"; import type { CommandContext } from "../ws/dispatch.js"; @@ -118,6 +119,9 @@ describe("LSP commands", () => { beforeEach(() => { stateDir = mkdtempSync(join(tmpdir(), "lsp-command-state-")); const eventBus = new EventBus(); + const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "settings.json"), + }); const workspaceMgr = new WorkspaceManager({ workspaceRepo: new WorkspaceRepo({ filePath: join(stateDir, "workspaces.json"), @@ -131,6 +135,7 @@ describe("LSP commands", () => { sessionMgr: {} as never, terminalMgr: {} as never, broadcaster: { broadcast: vi.fn(), sendToClient: vi.fn(), sendBinaryToClient: vi.fn() }, + settingsRepo, providerRegistry: [], autoFetch: {} as never, fencingMgr: {} as never, diff --git a/packages/server/src/__tests__/monitoring/aggregation.test.ts b/packages/server/src/__tests__/monitoring/aggregation.test.ts index 5993ae181..0b8752735 100644 --- a/packages/server/src/__tests__/monitoring/aggregation.test.ts +++ b/packages/server/src/__tests__/monitoring/aggregation.test.ts @@ -129,6 +129,64 @@ describe("buildMonitoringSnapshot", () => { expect(response.snapshot.workspaces[0]?.label).toBe("ws_1779980247607_u2lfvdjf"); }); + it("keeps workspace-scoped background roots under the workspace attribution tree", () => { + const response = buildMonitoringSnapshot({ + settings: { + ...createDefaultMonitoringSettings(), + enabled: true, + }, + sampledAt: 100, + host: null, + roots: [ + { + ownerId: "lsp:ws-1:typescript", + rootPid: 200, + kind: "lsp", + label: "TypeScript language server", + workspaceId: "ws-1", + startedAt: 2, + }, + ], + workspaceLabels: { + "ws-1": "coder-studio", + }, + processRows: [ + { + pid: 200, + ppid: 1, + cpuPercent: 7, + rssBytes: 150, + elapsedSec: 45, + command: "typescript-language-server", + }, + ], + previousSnapshot: null, + }); + + expect(response.snapshot.workspaces).toEqual([ + expect.objectContaining({ + id: "workspace:ws-1", + kind: "workspace", + label: "coder-studio", + cpuPercent: 7, + memoryBytes: 150, + processCount: 1, + }), + ]); + expect(response.snapshot.backgroundGroups).toEqual([ + expect.objectContaining({ + id: "background:lsp:ws-1:typescript", + kind: "background_group", + parentId: "workspace:ws-1", + workspaceId: "ws-1", + label: "TypeScript language server", + cpuPercent: 7, + memoryBytes: 150, + processCount: 1, + }), + ]); + }); + it("keeps host data when process collection fails", () => { const response = buildMonitoringSnapshot({ settings: { diff --git a/packages/server/src/__tests__/monitoring/service.test.ts b/packages/server/src/__tests__/monitoring/service.test.ts index 0923a02af..03235e12a 100644 --- a/packages/server/src/__tests__/monitoring/service.test.ts +++ b/packages/server/src/__tests__/monitoring/service.test.ts @@ -520,4 +520,77 @@ describe("MonitoringService", () => { expect(registry.listRoots().map((root) => root.ownerId)).not.toContain("terminal:term-1"); }); + + it("includes registered lsp roots in background groups", async () => { + const registry = new ManagedProcessRegistry({ now: () => 10 }); + registry.registerBackgroundRoot({ + ownerId: "lsp:ws-1:typescript", + rootPid: 200, + kind: "lsp", + label: "TypeScript language server", + startedAt: 10, + }); + + const service = new MonitoringService({ + broadcaster: { broadcast: vi.fn() }, + settingsRepo: { + get: (key: string) => { + const settings = { + "monitoring.enabled": true, + "monitoring.hostMetricsEnabled": true, + "monitoring.runtimeSummaryEnabled": true, + "monitoring.workspaceAttributionEnabled": true, + "monitoring.subprocessDrilldownEnabled": false, + "monitoring.sampleIntervalMs": 2000, + } as Record; + return settings[key]; + }, + }, + registry, + sessionMgr: { getAll: () => [], findSessionIdByTerminal: () => undefined }, + terminalMgr: { getAll: () => [] }, + hostCollector: { + collect: () => ({ + cpuPercent: 30, + memoryUsedBytes: 300, + memoryTotalBytes: 1000, + memoryAvailableBytes: 700, + loadAverage: [0.3, 0.2, 0.1], + uptimeSec: 10, + pressure: "normal", + }), + }, + processCollector: { + collect: async () => [ + { + pid: 200, + ppid: 1, + cpuPercent: 7, + rssBytes: 150, + elapsedSec: 12, + command: "typescript-language-server --stdio", + }, + ], + }, + setInterval: vi.fn(() => ({ unref: vi.fn() })), + clearInterval: vi.fn(), + now: () => 10, + }); + + service.start(); + const response = await service.recheck(); + + expect(response.snapshot.backgroundGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "background:lsp:ws-1:typescript", + kind: "background_group", + label: "TypeScript language server", + cpuPercent: 7, + memoryBytes: 150, + processCount: 1, + }), + ]) + ); + }); }); diff --git a/packages/server/src/__tests__/provider-list.test.ts b/packages/server/src/__tests__/provider-list.test.ts index 64096ab62..47b17142d 100644 --- a/packages/server/src/__tests__/provider-list.test.ts +++ b/packages/server/src/__tests__/provider-list.test.ts @@ -35,38 +35,82 @@ describe("provider.list command", () => { ); expect(result.ok).toBe(true); - expect(result.data).toEqual([ - { - id: "claude", - displayName: "Claude Code", - badge: "Claude", - kind: "built_in", - capability: "full", - capabilities: [ - { key: "interactive_session", supported: true, label: "Interactive session" }, - { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, - { key: "idle_detection", supported: true, label: "Idle detection" }, - { key: "context_attach", supported: false, label: "Context attach" }, - { key: "review", supported: false, label: "Review" }, - ], - requiredCommands: ["claude"], - }, - { - id: "codex", - displayName: "Codex", - badge: "Codex", - kind: "built_in", - capability: "full", - capabilities: [ - { key: "interactive_session", supported: true, label: "Interactive session" }, - { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, - { key: "idle_detection", supported: true, label: "Idle detection" }, - { key: "context_attach", supported: false, label: "Context attach" }, - { key: "review", supported: false, label: "Review" }, - ], - requiredCommands: ["codex"], - }, - ]); + expect(result.data).toEqual( + expect.arrayContaining([ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + stability: undefined, + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["claude"], + }, + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + stability: undefined, + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["codex"], + }, + expect.objectContaining({ + id: "gemini", + displayName: "Gemini CLI", + kind: "built_in", + stability: "stable", + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, + capability: "full", + requiredCommands: ["gemini"], + }), + expect.objectContaining({ + id: "cursor", + displayName: "Cursor Agent", + kind: "built_in", + stability: "stable", + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: true, + supportsSkillsMount: true, + capability: "full", + requiredCommands: ["agent"], + }), + expect.objectContaining({ + id: "opencode", + displayName: "OpenCode", + kind: "built_in", + stability: "experimental", + supportsAgentInstructions: true, + supportsAgentInstructionsGeneration: false, + supportsSkillsMount: true, + capability: "limited", + requiredCommands: ["opencode"], + }), + ]) + ); + expect(result.data).toHaveLength(providerRegistry.length); }); it("includes custom providers already present in the command context registry", async () => { @@ -111,6 +155,7 @@ describe("provider.list command", () => { expect.objectContaining({ id: "review-bot", kind: "custom", + supportsAgentInstructionsGeneration: false, requiredCommands: ["review-bot"], }), ]) diff --git a/packages/server/src/__tests__/provider-runtime/command-runner.test.ts b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts index 9ca7f0498..89c03578e 100644 --- a/packages/server/src/__tests__/provider-runtime/command-runner.test.ts +++ b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts @@ -22,9 +22,11 @@ function createChildProcessMock() { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; + kill: ReturnType; }; child.stdout = stdout; child.stderr = stderr; + child.kill = vi.fn(); return child; } @@ -57,6 +59,7 @@ describe("runCommandAsString", () => { cwd: undefined, env: undefined, shell: false, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); }); @@ -74,6 +77,7 @@ describe("runCommandAsString", () => { cwd: undefined, env: undefined, shell: false, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); }); @@ -95,6 +99,7 @@ describe("runCommandAsString", () => { cwd: "/tmp/demo", env: { PATH: "/tmp/bin" }, shell: false, + stdio: ["ignore", "pipe", "pipe"], windowsHide: false, }); }); @@ -112,7 +117,13 @@ describe("runCommandAsString", () => { expect(spawnMock).toHaveBeenCalledWith( "npm", ["install", "-g", "@openai/codex"], - expect.objectContaining({ cwd: undefined, env: undefined, shell: true, windowsHide: true }) + expect.objectContaining({ + cwd: undefined, + env: undefined, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) ); }); @@ -129,7 +140,13 @@ describe("runCommandAsString", () => { expect(spawnMock).toHaveBeenCalledWith( "C:\\tools\\vue-language-server.cmd", ["--version"], - expect.objectContaining({ cwd: undefined, env: undefined, shell: true, windowsHide: true }) + expect.objectContaining({ + cwd: undefined, + env: undefined, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) ); }); @@ -146,7 +163,33 @@ describe("runCommandAsString", () => { expect(spawnMock).toHaveBeenCalledWith( "git", ["--version"], - expect.objectContaining({ cwd: undefined, env: undefined, shell: false, windowsHide: true }) + expect.objectContaining({ + cwd: undefined, + env: undefined, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) ); }); + + it("rejects with a typed timeout error when the subprocess exceeds timeoutMs", async () => { + vi.useFakeTimers(); + const child = createChildProcessMock(); + spawnMock.mockReturnValue(child); + + const resultPromise = runCommandAsString("demo", ["generate"], { + timeoutMs: 25, + }); + const expectation = expect(resultPromise).rejects.toMatchObject({ + code: "command_timeout", + timeoutMs: 25, + }); + + await vi.advanceTimersByTimeAsync(25); + + await expectation; + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + vi.useRealTimers(); + }); }); diff --git a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts index b0b98c6bc..b825c3ba6 100644 --- a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts +++ b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts @@ -1,4 +1,4 @@ -import { codexDefinition } from "@coder-studio/providers"; +import { codexDefinition, providerRegistry } from "@coder-studio/providers"; import { describe, expect, it, vi } from "vitest"; import { ProviderInstallManager } from "../../provider-runtime/install-manager.js"; @@ -253,4 +253,165 @@ describe("ProviderInstallManager", () => { windowsHide: true, }); }); + + it("executes Gemini's npm install strategy on Linux", async () => { + let geminiInstalled = false; + const commandExists = vi.fn(async (command: string) => { + if (command === "npm") { + return true; + } + if (command === "gemini") { + return geminiInstalled; + } + return false; + }); + const execFile = vi.fn(async (file: string, args: string[]) => { + if (file === "npm" && args.join(" ") === "install -g @google/gemini-cli") { + geminiInstalled = true; + return { stdout: "installed gemini", stderr: "" }; + } + + throw new Error(`unexpected execFile call: ${file} ${args.join(" ")}`); + }); + const manager = new ProviderInstallManager(providerRegistry, { + platform: "linux", + commandExists, + runCommand: execFile, + }); + + const job = await manager.start("gemini"); + + expect(job.strategyIds).toEqual(["npm-install-gemini"]); + expect(job.steps.map((step) => step.id)).toEqual([ + "install-provider-gemini", + "verify-provider-gemini", + ]); + + await vi.waitFor(() => { + expect(manager.get(job.jobId)?.status).toBe("succeeded"); + }); + + expect(execFile).toHaveBeenCalledWith("npm", ["install", "-g", "@google/gemini-cli"], { + windowsHide: true, + }); + }); + + it("executes OpenCode's npm install strategy on Linux", async () => { + let opencodeInstalled = false; + const commandExists = vi.fn(async (command: string) => { + if (command === "npm") { + return true; + } + if (command === "opencode") { + return opencodeInstalled; + } + return false; + }); + const execFile = vi.fn(async (file: string, args: string[]) => { + if (file === "npm" && args.join(" ") === "install -g opencode-ai") { + opencodeInstalled = true; + return { stdout: "installed opencode", stderr: "" }; + } + + throw new Error(`unexpected execFile call: ${file} ${args.join(" ")}`); + }); + const manager = new ProviderInstallManager(providerRegistry, { + platform: "linux", + commandExists, + runCommand: execFile, + }); + + const job = await manager.start("opencode"); + + expect(job.strategyIds).toEqual(["npm-install-opencode"]); + expect(job.steps.map((step) => step.id)).toEqual([ + "install-provider-opencode", + "verify-provider-opencode", + ]); + + await vi.waitFor(() => { + expect(manager.get(job.jobId)?.status).toBe("succeeded"); + }); + + expect(execFile).toHaveBeenCalledWith("npm", ["install", "-g", "opencode-ai"], { + windowsHide: true, + }); + }); + + it("executes Cursor Agent's official install script on Linux", async () => { + let agentInstalled = false; + const commandExists = vi.fn(async (command: string) => { + if (command === "bash") { + return true; + } + if (command === "agent") { + return agentInstalled; + } + return false; + }); + const execFile = vi.fn(async (file: string, args: string[]) => { + if ( + file === "bash" && + args[0] === "-lc" && + args[1] === "curl https://cursor.com/install -fsS | bash" + ) { + agentInstalled = true; + return { stdout: "installed cursor agent", stderr: "" }; + } + + throw new Error(`unexpected execFile call: ${file} ${args.join(" ")}`); + }); + const manager = new ProviderInstallManager(providerRegistry, { + platform: "linux", + commandExists, + runCommand: execFile, + }); + + const job = await manager.start("cursor"); + + expect(job.strategyIds).toEqual(["cursor-install-script"]); + expect(job.steps.map((step) => step.id)).toEqual([ + "install-provider-agent", + "verify-provider-cursor", + ]); + + await vi.waitFor(() => { + expect(manager.get(job.jobId)?.status).toBe("succeeded"); + }); + + expect(execFile).toHaveBeenCalledWith( + "bash", + ["-lc", "curl https://cursor.com/install -fsS | bash"], + { windowsHide: true } + ); + }); + + it("keeps Cursor Agent manual-only on native Windows", async () => { + const manager = new ProviderInstallManager(providerRegistry, { + platform: "win32", + commandExists: vi.fn(async () => false), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), + }); + + const job = await manager.start("cursor"); + + expect(job).toMatchObject({ + providerId: "cursor", + status: "failed", + strategyIds: [], + failure: { + code: "unsupported_platform", + providerId: "cursor", + missingCommands: ["agent"], + }, + }); + expect(job.steps).toEqual([ + expect.objectContaining({ + id: "install-provider-agent", + kind: "check", + command: "agent", + status: "failed", + }), + ]); + }); }); diff --git a/packages/server/src/__tests__/provider-runtime/runtime-status.test.ts b/packages/server/src/__tests__/provider-runtime/runtime-status.test.ts index 2d67741eb..94c9f8eac 100644 --- a/packages/server/src/__tests__/provider-runtime/runtime-status.test.ts +++ b/packages/server/src/__tests__/provider-runtime/runtime-status.test.ts @@ -1,4 +1,5 @@ import type { ProviderDefinition } from "@coder-studio/core"; +import { providerRegistry } from "@coder-studio/providers"; import { expect, it, vi } from "vitest"; import { z } from "zod"; import { buildProviderRuntimeStatus } from "../../provider-runtime/runtime-status.js"; @@ -126,3 +127,168 @@ it("reports unsupported platform when the provider command is missing and no ins installReadiness: "unsupported_platform", }); }); + +it("reports runtime entries for expanded built-in providers with provider metadata", async () => { + const commandExists = vi.fn(async (command: string) => + ["claude", "codex", "gemini", "agent"].includes(command) + ); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "linux", + commandExists, + }); + + expect(result.providers.gemini).toMatchObject({ + providerId: "gemini", + displayName: "Gemini CLI", + kind: "built_in", + stability: "stable", + supportsAgentInstructionsGeneration: true, + available: true, + missingCommands: [], + }); + expect(result.providers.cursor).toMatchObject({ + providerId: "cursor", + displayName: "Cursor Agent", + kind: "built_in", + stability: "stable", + supportsAgentInstructionsGeneration: true, + available: true, + missingCommands: [], + }); + expect(result.providers.claude).toMatchObject({ + providerId: "claude", + displayName: "Claude Code", + kind: "built_in", + supportsAgentInstructionsGeneration: true, + available: true, + missingCommands: [], + }); + expect(result.providers.codex).toMatchObject({ + providerId: "codex", + supportsAgentInstructionsGeneration: true, + available: true, + }); + expect(result.providers.opencode).toMatchObject({ + providerId: "opencode", + displayName: "OpenCode", + kind: "built_in", + stability: "experimental", + supportsAgentInstructionsGeneration: false, + available: false, + missingCommands: ["opencode"], + autoInstallSupported: false, + }); +}); + +it("reports Gemini as ready for npm-backed auto-install on Linux", async () => { + const commandExists = vi.fn(async (command: string) => command === "npm"); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "linux", + commandExists, + }); + + expect(result.providers.gemini).toMatchObject({ + available: false, + missingCommands: ["gemini"], + missingPrerequisites: [], + autoInstallSupported: true, + installReadiness: "ready", + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.gemini.manual"], + docUrls: { + provider: "https://google-gemini.github.io/gemini-cli/docs/get-started/", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + }); +}); + +it("reports OpenCode as ready for npm-backed auto-install on Linux", async () => { + const commandExists = vi.fn(async (command: string) => command === "npm"); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "linux", + commandExists, + }); + + expect(result.providers.opencode).toMatchObject({ + available: false, + missingCommands: ["opencode"], + missingPrerequisites: [], + autoInstallSupported: true, + installReadiness: "ready", + manualGuideKeys: ["provider.install.nodejs.manual", "provider.install.opencode.manual"], + docUrls: { + provider: "https://github.com/anomalyco/opencode#installation", + prerequisites: { + npm: "https://nodejs.org/en/download", + }, + }, + }); +}); + +it("reports Cursor Agent as ready for script-backed auto-install on Linux", async () => { + const commandExists = vi.fn(async (command: string) => command === "bash"); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "linux", + commandExists, + }); + + expect(result.providers.cursor).toMatchObject({ + available: false, + requiredCommands: ["agent"], + missingCommands: ["agent"], + missingPrerequisites: [], + autoInstallSupported: true, + installReadiness: "ready", + manualGuideKeys: ["provider.install.cursor.manual"], + docUrls: { + provider: "https://cursor.com/docs/cli/installation", + prerequisites: {}, + }, + }); +}); + +it("reports Cursor Agent as ready for script-backed auto-install on macOS", async () => { + const commandExists = vi.fn(async (command: string) => command === "bash"); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "darwin", + commandExists, + }); + + expect(result.providers.cursor).toMatchObject({ + available: false, + requiredCommands: ["agent"], + missingCommands: ["agent"], + missingPrerequisites: [], + autoInstallSupported: true, + installReadiness: "ready", + manualGuideKeys: ["provider.install.cursor.manual"], + docUrls: { + provider: "https://cursor.com/docs/cli/installation", + prerequisites: {}, + }, + }); +}); + +it("keeps Cursor Agent manual-only on native Windows", async () => { + const commandExists = vi.fn(async () => false); + + const result = await buildProviderRuntimeStatus(providerRegistry, { + platform: "win32", + commandExists, + }); + + expect(result.providers.cursor).toMatchObject({ + available: false, + requiredCommands: ["agent"], + missingCommands: ["agent"], + autoInstallSupported: false, + installReadiness: "unsupported_platform", + manualGuideKeys: ["provider.install.cursor.manual"], + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-commands.test.ts b/packages/server/src/__tests__/session-analysis-commands.test.ts new file mode 100644 index 000000000..85a8a575f --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-commands.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; + +import "../commands/session.js"; + +describe("session analysis commands", () => { + function createContext(): CommandContext { + return { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: {} as never, + settingsRepo: {} as never, + providerConfigRepo: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: { getLease: () => null } as never, + sessionAnalysisService: { + get: vi.fn(() => ({ sessionId: "sess-1", status: "succeeded" })), + run: vi.fn(async () => ({ sessionId: "sess-1", status: "running" })), + } as never, + }; + } + + it("dispatches session.analysis.get", async () => { + const ctx = createContext(); + const result = await dispatch( + { + kind: "command", + id: "1", + op: "session.analysis.get", + args: { sessionId: "sess-1" }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.sessionAnalysisService?.get).toHaveBeenCalledWith("sess-1"); + }); + + it("dispatches session.analysis.run", async () => { + const ctx = createContext(); + const result = await dispatch( + { + kind: "command", + id: "2", + op: "session.analysis.run", + args: { sessionId: "sess-1", force: true }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.sessionAnalysisService?.run).toHaveBeenCalledWith({ + sessionId: "sess-1", + force: true, + }); + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-context.test.ts b/packages/server/src/__tests__/session-analysis-context.test.ts new file mode 100644 index 000000000..2e0d79660 --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-context.test.ts @@ -0,0 +1,168 @@ +import type { Session, Workspace } from "@coder-studio/core"; +import { describe, expect, it, vi } from "vitest"; +import * as gitCli from "../git/cli.js"; +import { createSessionAnalysisContextCollector } from "../session-analysis/context.js"; +import type { SessionAnalysisContext } from "../session-analysis/types.js"; + +type SessionManagerStub = Pick< + import("../session/manager.js").SessionManager, + "get" | "getRenderedSnapshot" | "getLatestSubmittedUserInput" +>; + +type WorkspaceManagerStub = Pick; + +function createSession(overrides?: Partial): Session { + return { + id: "sess-1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "codex", + capability: "full", + state: "idle", + startedAt: 100, + lastActiveAt: 200, + ...overrides, + }; +} + +function createWorkspace(overrides?: Partial): Workspace { + return { + id: "ws-1", + path: "/repo/project", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 2, + uiState: { leftPanelWidth: 320, bottomPanelHeight: 240, focusMode: false }, + ...overrides, + }; +} + +function createSessionMgr(session?: Session): SessionManagerStub { + return { + get: vi.fn((sessionId: string) => (sessionId === session?.id ? session : undefined)), + getRenderedSnapshot: vi.fn(async () => "build failed\nfix tests\n"), + getLatestSubmittedUserInput: vi.fn(() => "fix the failing test"), + }; +} + +function createWorkspaceMgr(workspace?: Workspace): WorkspaceManagerStub { + return { + get: vi.fn((workspaceId: string) => (workspaceId === workspace?.id ? workspace : undefined)), + }; +} + +describe("createSessionAnalysisContextCollector", () => { + it("collects the current session and workspace context without invoking any runner", async () => { + vi.spyOn(gitCli, "getGitStatusSummary").mockResolvedValue(" M package.json"); + vi.spyOn(gitCli, "getGitDiffStatSummary").mockResolvedValue("1 file changed, 2 insertions(+)"); + vi.spyOn(gitCli, "getGitStatus").mockResolvedValue({ + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [{ path: "package.json", status: "modified" }], + untracked: [], + deleted: [], + }); + + const session = createSession(); + const workspace = createWorkspace(); + const sessionMgr = createSessionMgr(session); + const workspaceMgr = createWorkspaceMgr(workspace); + + const collect = createSessionAnalysisContextCollector({ + sessionMgr: sessionMgr as import("../session/manager.js").SessionManager, + workspaceMgr: workspaceMgr as import("../workspace/manager.js").WorkspaceManager, + }); + + await expect(collect({ sessionId: "sess-1" })).resolves.toEqual({ + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/repo/project", + providerId: "codex", + sessionState: "idle", + startedAt: 100, + lastActiveAt: 200, + gitStatus: " M package.json", + changedFiles: ["package.json"], + diffSummary: "1 file changed, 2 insertions(+)", + latestUserInput: "fix the failing test", + terminalSnapshot: "build failed\nfix tests\n", + }); + + expect(sessionMgr.get).toHaveBeenCalledWith("sess-1"); + expect(workspaceMgr.get).toHaveBeenCalledWith("ws-1"); + expect(sessionMgr.getRenderedSnapshot).toHaveBeenCalledWith( + "sess-1", + expect.objectContaining({ + maxChars: expect.any(Number), + maxLines: expect.any(Number), + }) + ); + expect(sessionMgr.getLatestSubmittedUserInput).toHaveBeenCalledWith("sess-1"); + }); + + it("omits optional context fields when no terminal snapshot or latest input exists", async () => { + vi.spyOn(gitCli, "getGitStatusSummary").mockResolvedValue(""); + vi.spyOn(gitCli, "getGitDiffStatSummary").mockResolvedValue(""); + vi.spyOn(gitCli, "getGitStatus").mockResolvedValue({ + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + untracked: [], + deleted: [], + }); + + const session = createSession({ state: "running" }); + const workspace = createWorkspace({ path: "/repo/alt" }); + const sessionMgr = { + ...createSessionMgr(session), + getRenderedSnapshot: vi.fn(async () => ""), + getLatestSubmittedUserInput: vi.fn(() => undefined), + }; + const workspaceMgr = createWorkspaceMgr(workspace); + + const collect = createSessionAnalysisContextCollector({ + sessionMgr: sessionMgr as import("../session/manager.js").SessionManager, + workspaceMgr: workspaceMgr as import("../workspace/manager.js").WorkspaceManager, + }); + + await expect(collect({ sessionId: "sess-1" })).resolves.toEqual({ + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/repo/alt", + providerId: "codex", + sessionState: "running", + startedAt: 100, + lastActiveAt: 200, + changedFiles: [], + }); + }); + + it("throws a stable not found error when the session or workspace is unavailable", async () => { + const collectMissingSession = createSessionAnalysisContextCollector({ + sessionMgr: createSessionMgr() as import("../session/manager.js").SessionManager, + workspaceMgr: createWorkspaceMgr( + createWorkspace() + ) as import("../workspace/manager.js").WorkspaceManager, + }); + + await expect(collectMissingSession({ sessionId: "missing" })).rejects.toMatchObject({ + code: "session_analysis_context_unavailable", + message: "Session analysis context is unavailable", + }); + + const session = createSession(); + const collectMissingWorkspace = createSessionAnalysisContextCollector({ + sessionMgr: createSessionMgr(session) as import("../session/manager.js").SessionManager, + workspaceMgr: createWorkspaceMgr() as import("../workspace/manager.js").WorkspaceManager, + }); + + await expect(collectMissingWorkspace({ sessionId: "sess-1" })).rejects.toMatchObject({ + code: "session_analysis_context_unavailable", + message: "Session analysis context is unavailable", + }); + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-repo.test.ts b/packages/server/src/__tests__/session-analysis-repo.test.ts new file mode 100644 index 000000000..08820c38d --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-repo.test.ts @@ -0,0 +1,81 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + type SessionAnalysisRecord, + SessionAnalysisRepo, +} from "../storage/repositories/session-analysis-repo.js"; + +describe("SessionAnalysisRepo", () => { + let repo: SessionAnalysisRepo; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "session-analysis-repo-test-")); + repo = new SessionAnalysisRepo({ + filePath: join(tempDir, "session-analysis.json"), + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns undefined for a missing session", () => { + expect(repo.findBySessionId("missing-session")).toBeUndefined(); + }); + + it("persists and reloads a session analysis record by session id", () => { + const record: SessionAnalysisRecord = { + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + status: "succeeded", + requestedAt: 1000, + completedAt: 1200, + inputDigest: "digest-123", + result: { + summary: "The session made progress after clarifying repo boundaries.", + recentWork: ["Reviewed adjacent repos", "Added focused tests"], + repeatedTopics: [ + { + topic: "TDD ordering", + whyItRepeated: "The plan explicitly required test-first execution.", + evidence: ["Wrote the repo test before implementation", "Ran the focused test twice"], + }, + ], + bottlenecks: [ + { + title: "Plan visibility", + impact: "The first cut inferred fields that later tasks do not use.", + evidence: [ + "Initial record shape used createdAt/updatedAt", + "Spec alignment required a rewrite", + ], + suggestion: "Keep the persisted contract limited to the confirmed plan.", + }, + ], + skillCandidates: [ + { + title: "Plan audit", + why: "Future tasks depend on this schema staying exact.", + suggestedScope: "Validate record shape before wiring commands.", + evidence: ["Downstream tasks read SessionAnalysisResult directly"], + }, + ], + openLoops: ["Need command-layer wiring in a later task"], + wrapUpSuggestions: ["Add command tests once the repo is consumed by the manager"], + confidence: "medium", + }, + }; + + repo.upsert(record); + + const reloadedRepo = new SessionAnalysisRepo({ + filePath: join(tempDir, "session-analysis.json"), + }); + + expect(reloadedRepo.findBySessionId("sess-1")).toEqual(record); + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-runner.test.ts b/packages/server/src/__tests__/session-analysis-runner.test.ts new file mode 100644 index 000000000..05c1d4e26 --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-runner.test.ts @@ -0,0 +1,218 @@ +import type { ProviderDefinition } from "@coder-studio/core"; +import { describe, expect, it } from "vitest"; +import { buildSessionAnalysisPrompt } from "../session-analysis/prompt.js"; +import { SessionAnalysisRunner } from "../session-analysis/runner.js"; +import { sessionAnalysisResultSchema } from "../session-analysis/schema.js"; +import type { SessionAnalysisResult } from "../session-analysis/types.js"; + +describe("session analysis prompt", () => { + it("builds a prompt that requires JSON-only output and lists the result fields", () => { + const prompt = buildSessionAnalysisPrompt({ + transcript: "user: investigate flaky test\nassistant: narrowed it to prompt formatting", + context: { + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/repo/coder-studio", + providerId: "codex", + sessionState: "idle", + sessionTitle: "fix flaky test", + startedAt: 100, + lastActiveAt: 200, + gitStatus: " M packages/server/src/session-analysis/prompt.ts", + changedFiles: ["packages/server/src/session-analysis/prompt.ts"], + diffSummary: "1 file changed, 3 insertions(+)", + latestUserInput: "investigate flaky test", + }, + }); + + expect(prompt).toContain("Return JSON only."); + expect(prompt).toContain("No prose before or after the JSON."); + expect(prompt).toContain('"summary": string'); + expect(prompt).toContain('"recentWork": string[]'); + expect(prompt).toContain('"repeatedTopics"'); + expect(prompt).toContain('"bottlenecks"'); + expect(prompt).toContain('"skillCandidates"'); + expect(prompt).toContain('"openLoops": string[]'); + expect(prompt).toContain('"wrapUpSuggestions": string[]'); + expect(prompt).toContain('"confidence": "low" | "medium" | "high"'); + expect(prompt).toContain("Transcript:"); + expect(prompt).toContain("Context:"); + expect(prompt).toContain("investigate flaky test"); + expect(prompt).toContain('"workspacePath": "/repo/coder-studio"'); + expect(prompt).toContain('"changedFiles"'); + }); +}); + +describe("sessionAnalysisResultSchema", () => { + it("accepts results that match SessionAnalysisResult", () => { + const result = { + summary: "The session converged after narrowing scope.", + recentWork: ["Reviewed failing test", "Outlined prompt contract"], + repeatedTopics: [ + { + topic: "Prompt formatting", + whyItRepeated: "The task requires an explicit JSON-only contract.", + evidence: ["The prompt must list required fields"], + }, + ], + bottlenecks: [ + { + title: "Missing prompt helper", + impact: "Task 2 cannot wire the runner contract yet.", + evidence: ["prompt.ts does not exist"], + suggestion: "Add a dedicated prompt builder with a fixed schema section.", + }, + ], + skillCandidates: [ + { + title: "TDD enforcement", + why: "The task explicitly requires test-first implementation.", + suggestedScope: + "Keep the session-analysis contract stable while later tasks add the runner.", + evidence: ["The plan calls for a failing test before implementation"], + }, + ], + openLoops: ["Runner integration is deferred to a later task."], + wrapUpSuggestions: ["Keep the prompt and schema exports isolated from runner logic."], + confidence: "medium", + } satisfies SessionAnalysisResult; + + expect(sessionAnalysisResultSchema.parse(result)).toEqual(result); + }); + + it("rejects results with an invalid confidence value", () => { + const parse = () => + sessionAnalysisResultSchema.parse({ + summary: "Invalid confidence should fail validation.", + recentWork: [], + repeatedTopics: [], + bottlenecks: [], + skillCandidates: [], + openLoops: [], + wrapUpSuggestions: [], + confidence: "certain", + }); + + expect(parse).toThrow(); + }); +}); + +describe("SessionAnalysisRunner", () => { + const provider = { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/workspace" }), + configSchema: { parse: (value: unknown) => value } as ProviderDefinition["configSchema"], + defaultConfig: {}, + requiredCommands: ["codex"], + headless: { + supportedScenarios: ["session_analysis"], + buildCommand: () => ({ + argv: ["codex", "exec"], + cwd: "/workspace", + env: {}, + }), + }, + } satisfies ProviderDefinition; + + it("parses a plain JSON response", async () => { + const runner = new SessionAnalysisRunner({ + providerRegistry: [provider], + commandRunner: async () => ({ + stdout: JSON.stringify({ + summary: "done", + recentWork: [], + repeatedTopics: [], + bottlenecks: [], + skillCandidates: [], + openLoops: [], + wrapUpSuggestions: [], + confidence: "high", + }), + stderr: "", + }), + }); + + await expect( + runner.run({ + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/workspace", + transcript: "user: hi", + context: { + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/workspace", + providerId: "codex", + sessionState: "ended", + startedAt: 1, + lastActiveAt: 2, + changedFiles: [], + }, + }) + ).resolves.toMatchObject({ + summary: "done", + confidence: "high", + }); + }); + + it("parses Codex JSONL output by extracting the completed agent message", async () => { + const runner = new SessionAnalysisRunner({ + providerRegistry: [provider], + commandRunner: async () => ({ + stdout: [ + '{"type":"item.started","item":{"type":"agent_message"}}', + JSON.stringify({ + type: "item.completed", + item: { + type: "agent_message", + text: JSON.stringify({ + summary: "done", + recentWork: [], + repeatedTopics: [], + bottlenecks: [], + skillCandidates: [], + openLoops: [], + wrapUpSuggestions: [], + confidence: "medium", + }), + }, + }), + ].join("\n"), + stderr: "", + }), + }); + + await expect( + runner.run({ + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/workspace", + transcript: "user: hi", + context: { + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/workspace", + providerId: "codex", + sessionState: "ended", + startedAt: 1, + lastActiveAt: 2, + changedFiles: [], + }, + }) + ).resolves.toMatchObject({ + summary: "done", + confidence: "medium", + }); + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-service.test.ts b/packages/server/src/__tests__/session-analysis-service.test.ts new file mode 100644 index 000000000..649228469 --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-service.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildSessionAnalysisDigest } from "../session-analysis/runner.js"; +import { SessionAnalysisService } from "../session-analysis/service.js"; +import type { SessionAnalysisContext, SessionAnalysisResult } from "../session-analysis/types.js"; + +function createContext(): SessionAnalysisContext { + return { + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: "/repo/project", + providerId: "codex", + sessionState: "ended", + sessionTitle: "fix tests", + startedAt: 100, + lastActiveAt: 200, + changedFiles: ["a.ts"], + gitStatus: " M a.ts", + diffSummary: "1 file changed", + latestUserInput: "fix tests", + }; +} + +function createResult(): SessionAnalysisResult { + return { + summary: "summary", + recentWork: ["did work"], + repeatedTopics: [], + bottlenecks: [], + skillCandidates: [], + openLoops: [], + wrapUpSuggestions: [], + confidence: "medium", + }; +} + +describe("SessionAnalysisService", () => { + it("returns a cached succeeded record when the digest is unchanged", async () => { + const context = createContext(); + const transcript = "same transcript"; + const repo = { + findBySessionId: vi.fn(() => ({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + status: "succeeded" as const, + inputDigest: buildSessionAnalysisDigest({ + context, + transcript, + }), + result: createResult(), + })), + upsert: vi.fn(), + }; + + const service = new SessionAnalysisService({ + repo, + sessionMgr: {} as never, + workspaceMgr: {} as never, + runner: { run: vi.fn() }, + collectContext: vi.fn(async () => context), + readTranscript: vi.fn(async () => ({ + providerId: "codex", + sessionId: "sess-1", + path: "/tmp/sess-1.jsonl", + content: transcript, + })), + }); + + const result = await service.run({ sessionId: "sess-1" }); + expect(result.status).toBe("succeeded"); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + + it("persists running and succeeded states around the headless analysis", async () => { + const upsert = vi.fn((record) => record); + const runner = { run: vi.fn(async () => createResult()) }; + const service = new SessionAnalysisService({ + repo: { + findBySessionId: vi.fn(() => undefined), + upsert, + }, + sessionMgr: {} as never, + workspaceMgr: {} as never, + runner, + now: vi.fn(() => 1234), + collectContext: vi.fn(async () => createContext()), + readTranscript: vi.fn(async () => ({ + providerId: "codex", + sessionId: "sess-1", + path: "/tmp/sess-1.jsonl", + content: "transcript", + })), + }); + + const result = await service.run({ sessionId: "sess-1" }); + expect(upsert).toHaveBeenCalledTimes(2); + expect(result.status).toBe("succeeded"); + expect(result.result).toEqual(createResult()); + expect(runner.run).toHaveBeenCalledOnce(); + }); + + it("persists a failed record when the runner throws", async () => { + const upsert = vi.fn((record) => record); + const service = new SessionAnalysisService({ + repo: { + findBySessionId: vi.fn(() => undefined), + upsert, + }, + sessionMgr: {} as never, + workspaceMgr: {} as never, + runner: { + run: vi.fn(async () => { + throw new Error("boom"); + }), + }, + now: vi.fn(() => 1234), + collectContext: vi.fn(async () => createContext()), + readTranscript: vi.fn(async () => ({ + providerId: "codex", + sessionId: "sess-1", + path: "/tmp/sess-1.jsonl", + content: "transcript", + })), + }); + + const result = await service.run({ sessionId: "sess-1" }); + expect(result.status).toBe("failed"); + expect(result.errorMessage).toBe("boom"); + }); +}); diff --git a/packages/server/src/__tests__/session-analysis-transcript-reader.test.ts b/packages/server/src/__tests__/session-analysis-transcript-reader.test.ts new file mode 100644 index 000000000..7e4227404 --- /dev/null +++ b/packages/server/src/__tests__/session-analysis-transcript-reader.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createSessionTranscriptReader } from "../session-analysis/transcript-reader.js"; + +describe("createSessionTranscriptReader", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reads a codex transcript by session id", async () => { + const root = join(tmpdir(), `session-analysis-codex-${Date.now()}`); + tempDirs.push(root); + mkdirSync(join(root, "2026", "06", "02"), { recursive: true }); + const filePath = join(root, "2026", "06", "02", "rollout-2026-06-02T10-00-00-sess-123.jsonl"); + writeFileSync(filePath, '{"type":"session_meta"}\n'); + + const readTranscript = createSessionTranscriptReader({ codexRoot: root }); + await expect(readTranscript({ providerId: "codex", sessionId: "sess-123" })).resolves.toEqual({ + providerId: "codex", + sessionId: "sess-123", + path: filePath, + content: '{"type":"session_meta"}\n', + }); + }); + + it("reads a claude transcript by session id", async () => { + const root = join(tmpdir(), `session-analysis-claude-${Date.now()}`); + tempDirs.push(root); + mkdirSync(join(root, "project-a"), { recursive: true }); + const filePath = join(root, "project-a", "sess-456.jsonl"); + writeFileSync(filePath, '{"type":"user"}\n'); + + const readTranscript = createSessionTranscriptReader({ claudeRoot: root }); + await expect(readTranscript({ providerId: "claude", sessionId: "sess-456" })).resolves.toEqual({ + providerId: "claude", + sessionId: "sess-456", + path: filePath, + content: '{"type":"user"}\n', + }); + }); + + it("returns a typed error when the provider is unsupported or the transcript is missing", async () => { + const root = join(tmpdir(), `session-analysis-missing-${Date.now()}`); + tempDirs.push(root); + mkdirSync(root, { recursive: true }); + + const readTranscript = createSessionTranscriptReader({ + codexRoot: root, + claudeRoot: root, + }); + + await expect( + readTranscript({ providerId: "cursor", sessionId: "sess-1" }) + ).rejects.toMatchObject({ + code: "session_analysis_transcript_unsupported", + }); + await expect( + readTranscript({ providerId: "codex", sessionId: "missing" }) + ).rejects.toMatchObject({ + code: "session_analysis_transcript_missing", + }); + }); +}); diff --git a/packages/server/src/__tests__/session-commands.test.ts b/packages/server/src/__tests__/session-commands.test.ts index 2ec3a93fe..03b07280e 100644 --- a/packages/server/src/__tests__/session-commands.test.ts +++ b/packages/server/src/__tests__/session-commands.test.ts @@ -10,6 +10,7 @@ import { SessionManager } from "../session/manager.js"; import type { SessionDatabase } from "../session/types.js"; import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; import { SessionMetadataRepo } from "../storage/repositories/session-metadata-repo.js"; +import { SettingsRepo } from "../storage/repositories/settings-repo.js"; import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import type { TerminalManager } from "../terminal/manager.js"; import { WorkspaceManager } from "../workspace/manager.js"; @@ -40,6 +41,9 @@ describe("Session Commands", () => { eventBus = new EventBus(); stateDir = mkdtempSync(join(tmpdir(), "session-command-state-")); const providerConfigRepo = createProviderConfigRepo(join(stateDir, "provider-configs.json")); + const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "settings.json"), + }); workspaceRepo = new WorkspaceRepo({ filePath: join(stateDir, "workspaces.json"), }); @@ -79,6 +83,7 @@ describe("Session Commands", () => { terminalMgr: {} as never, eventBus, broadcaster, + settingsRepo, providerRegistry: [], fencingMgr: {} as never, supervisorMgr: {} as never, @@ -163,6 +168,73 @@ describe("Session Commands", () => { } }); + it("publishes agent instructions before session.create starts the agent", async () => { + const testDir = join(tmpdir(), `coder-studio-session-publish-${Date.now()}`); + mkdirSync(join(testDir, ".git"), { recursive: true }); + writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + + const calls: string[] = []; + ctx.providerRegistry = providerRegistry as ProviderDefinition[]; + ctx.providerRuntimeDeps = { + commandExists: async (command: string) => command === "claude", + }; + ctx.agentInstructionPublisher = { + syncWorkspace: vi.fn(async () => { + calls.push("publish"); + }), + scheduleWorkspaceSync: vi.fn(), + syncAllOpenWorkspaces: vi.fn(), + } as never; + + const createSpy = vi.spyOn(sessionMgr, "create").mockImplementation(async () => { + calls.push("create"); + return { + id: "sess-1", + workspaceId: "ws-1", + providerId: "claude", + terminalId: "term-1", + capability: "full", + state: "starting", + startedAt: Date.now(), + lastActiveAt: Date.now(), + }; + }); + + try { + const openResult = await dispatch( + { + kind: "command", + id: "workspace-publish-order", + op: "workspace.open", + args: { path: testDir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + calls.length = 0; + + const result = await dispatch( + { + kind: "command", + id: "session-publish-order", + op: "session.create", + args: { + workspaceId: openResult.data!.id, + providerId: "claude", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(calls).toEqual(["publish", "create"]); + } finally { + createSpy.mockRestore(); + rmSync(testDir, { recursive: true, force: true }); + } + }); + it("launches a custom provider through the existing session.create flow", async () => { const testDir = join(tmpdir(), `coder-studio-custom-provider-session-${Date.now()}`); mkdirSync(join(testDir, ".git"), { recursive: true }); @@ -235,6 +307,61 @@ describe("Session Commands", () => { } }); + it.each([ + { providerId: "gemini", command: "gemini", expectedCapability: "full" }, + { providerId: "cursor", command: "agent", expectedCapability: "full" }, + { providerId: "opencode", command: "opencode", expectedCapability: "limited" }, + ])("launches $providerId through the shared session.create flow", async ({ + providerId, + command, + expectedCapability, + }) => { + const testDir = join(tmpdir(), `coder-studio-${providerId}-session-${Date.now()}`); + mkdirSync(join(testDir, ".git"), { recursive: true }); + writeFileSync(join(testDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + + ctx.providerRegistry = providerRegistry as ProviderDefinition[]; + ctx.providerRuntimeDeps = { + commandExists: async (candidate: string) => candidate === command, + }; + + try { + const openResult = await dispatch( + { + kind: "command", + id: `workspace-${providerId}`, + op: "workspace.open", + args: { path: testDir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + + const result = await dispatch( + { + kind: "command", + id: `session-${providerId}`, + op: "session.create", + args: { + workspaceId: openResult.data!.id, + providerId, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + providerId, + capability: expectedCapability, + state: "starting", + }); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); + it("captures session objective and git baseline metadata when available", async () => { const testDir = join(tmpdir(), `coder-studio-session-metadata-${Date.now()}`); mkdirSync(testDir, { recursive: true }); diff --git a/packages/server/src/__tests__/session-integration.test.ts b/packages/server/src/__tests__/session-integration.test.ts index 1e9fe1303..9a85b1cee 100644 --- a/packages/server/src/__tests__/session-integration.test.ts +++ b/packages/server/src/__tests__/session-integration.test.ts @@ -20,6 +20,7 @@ import { ProviderInstallManager } from "../provider-runtime/install-manager.js"; import { SessionManager } from "../session/manager.js"; import type { SessionDatabase } from "../session/types.js"; import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; +import { SettingsRepo } from "../storage/repositories/settings-repo.js"; import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; import { TerminalManager } from "../terminal/manager.js"; import type { Broadcaster, PtyHost, PtyProcess } from "../terminal/types.js"; @@ -194,6 +195,9 @@ describe("Session Integration", () => { providerConfigRepo = new ProviderConfigRepo({ filePath: join(stateDir, "provider-configs.json"), }); + const settingsRepo = new SettingsRepo({ + filePath: join(stateDir, "settings.json"), + }); sessionMgr = new SessionManager({ terminalMgr, eventBus, @@ -210,6 +214,7 @@ describe("Session Integration", () => { terminalMgr, eventBus, broadcaster: mockBroadcaster, + settingsRepo, providerRegistry, fencingMgr: {} as CommandContext["fencingMgr"], supervisorMgr: {} as CommandContext["supervisorMgr"], @@ -847,7 +852,7 @@ describe("Session Integration", () => { op: "terminal.input", args: { terminalId, - bytes: btoa("next turn\n"), + bytes: btoa("next turn"), activity: "typing", }, }, diff --git a/packages/server/src/__tests__/session-manager-api.test.ts b/packages/server/src/__tests__/session-manager-api.test.ts index 4d3be10d8..0a04bcd3f 100644 --- a/packages/server/src/__tests__/session-manager-api.test.ts +++ b/packages/server/src/__tests__/session-manager-api.test.ts @@ -813,6 +813,79 @@ describe("SessionManager session-level API", () => { expect(lifecycleEvents).toEqual(["turn_completed"]); }); + it("promotes idle session to running when submit bytes use kitty enter encoding", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("fix the build\x1b[13;5u"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + }); + + it("resumes to running from PTY output after submit while still idle", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("\r"), "submit", "fix the build"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + + onData?.("working...\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + }); + it("completes a second submit issued while the detector is already running", async () => { vi.useFakeTimers(); provider = { diff --git a/packages/server/src/__tests__/session-manager-title.test.ts b/packages/server/src/__tests__/session-manager-title.test.ts index 157ca5bf2..09a617c87 100644 --- a/packages/server/src/__tests__/session-manager-title.test.ts +++ b/packages/server/src/__tests__/session-manager-title.test.ts @@ -108,8 +108,27 @@ describe("SessionManager title derivation", () => { sessionMgr.onTerminalInput("terminal-1", "submit", "fix the build\n"); - expect(mockDb.update).toHaveBeenCalledWith(session.id, { title: "fix the b…" }); + expect(mockDb.update).toHaveBeenCalledWith(session.id, { + title: "fix the b…", + firstSubmittedUserInput: "fix the build", + }); expect(sessionMgr.get(session.id)?.title).toBe("fix the b…"); + expect(sessionMgr.get(session.id)?.firstSubmittedUserInput).toBe("fix the build"); + }); + + it("stores the full normalized first submitted input alongside the compact title", async () => { + const session = await createSession(); + + sessionMgr.onTerminalInput("terminal-1", "submit", " hello world this is a test\n"); + + expect(mockDb.update).toHaveBeenCalledWith(session.id, { + title: "hello wor…", + firstSubmittedUserInput: "hello world this is a test", + }); + expect(sessionMgr.get(session.id)).toMatchObject({ + title: "hello wor…", + firstSubmittedUserInput: "hello world this is a test", + }); }); it("uses the raw text when it already fits in 10 chars", async () => { @@ -143,8 +162,10 @@ describe("SessionManager title derivation", () => { const updateCalls = mockDb.update.mock.calls; for (const [, patch] of updateCalls) { expect(patch).not.toHaveProperty("title"); + expect(patch).not.toHaveProperty("firstSubmittedUserInput"); } expect(sessionMgr.get(session.id)?.title).toBe(firstTitle); + expect(sessionMgr.get(session.id)?.firstSubmittedUserInput).toBe("first message"); }); it("ignores non-submit activity for title derivation", async () => { @@ -179,7 +200,10 @@ describe("SessionManager title derivation", () => { sessionMgr.onTerminalInput("terminal-1", "submit", "new prompt\n"); expect(sessionMgr.get(session.id)?.title).toBe("new prompt"); - expect(mockDb.update).toHaveBeenCalledWith(session.id, { title: "new prompt" }); + expect(mockDb.update).toHaveBeenCalledWith(session.id, { + title: "new prompt", + firstSubmittedUserInput: "new prompt", + }); }); it("broadcasts state.changed so clients pick up the new title", async () => { @@ -200,7 +224,10 @@ describe("SessionManager title derivation", () => { sessionId: session.id, from: "running", to: "running", - session: expect.objectContaining({ title: "hello wor…" }), + session: expect.objectContaining({ + title: "hello wor…", + firstSubmittedUserInput: "hello world hi", + }), }); }); diff --git a/packages/server/src/__tests__/session-metadata-repo.test.ts b/packages/server/src/__tests__/session-metadata-repo.test.ts index 597809c82..c346b49e8 100644 --- a/packages/server/src/__tests__/session-metadata-repo.test.ts +++ b/packages/server/src/__tests__/session-metadata-repo.test.ts @@ -128,6 +128,34 @@ describe("SessionMetadataRepo", () => { ]); }); + it("rehydrates attached agent instructions in a fresh repo instance", () => { + repo.upsert({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + verificationRuns: [], + attachedAgentInstructions: { + effectiveHash: "hash-123", + mode: "manual", + attachedAt: 1234, + }, + }); + + const reloadedRepo = new SessionMetadataRepo({ workspaceRepo }); + + expect(reloadedRepo.get("sess-1")).toEqual({ + sessionId: "sess-1", + workspaceId: "ws-1", + providerId: "codex", + verificationRuns: [], + attachedAgentInstructions: { + effectiveHash: "hash-123", + mode: "manual", + attachedAt: 1234, + }, + }); + }); + it("finds metadata across registered workspaces by session id", async () => { const otherWorkspacePath = join(tempDir, "workspace-2"); await mkdir(otherWorkspacePath, { recursive: true }); diff --git a/packages/server/src/__tests__/session-repo.test.ts b/packages/server/src/__tests__/session-repo.test.ts index 812aecb90..d54629015 100644 --- a/packages/server/src/__tests__/session-repo.test.ts +++ b/packages/server/src/__tests__/session-repo.test.ts @@ -173,6 +173,18 @@ describe("SessionRepo", () => { expect(repo.findById("s-1")?.errorReason).toBe("API rate limit exceeded"); }); + it("persists the full first submitted input when updated", () => { + repo.update("s-1", { + title: "hello wor…", + firstSubmittedUserInput: "hello world this is a test", + }); + + expect(repo.findById("s-1")).toMatchObject({ + title: "hello wor…", + firstSubmittedUserInput: "hello world this is a test", + }); + }); + it("archives a session", () => { repo.archive("s-1"); expect(repo.listHydratable()).toEqual([]); @@ -195,6 +207,7 @@ describe("SessionRepo", () => { error_reason: null, archived: 0, title: "resume me", + first_submitted_user_input: "resume me with more context", draft: "draft text", }); @@ -207,6 +220,7 @@ describe("SessionRepo", () => { workspaceId: "ws-1", terminalId: "t-1", title: "resume me", + firstSubmittedUserInput: "resume me with more context", draft: "draft text", }); }); diff --git a/packages/server/src/__tests__/skills-command.test.ts b/packages/server/src/__tests__/skills-command.test.ts new file mode 100644 index 000000000..7040f0eb9 --- /dev/null +++ b/packages/server/src/__tests__/skills-command.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "../commands/skills.js"; +import { SkillLibraryRepo } from "../storage/repositories/skill-library-repo.js"; +import type { CommandContext } from "../ws/dispatch.js"; +import { dispatch } from "../ws/dispatch.js"; + +describe("skills commands", () => { + let tempDir: string; + let skillLibraryRepo: SkillLibraryRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "skills-command-")); + skillLibraryRepo = new SkillLibraryRepo({ filePath: join(tempDir, "skill-library.json") }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("returns skill library entries through dispatch", async () => { + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + source: "skillhub", + libraryPath: "/skills/library/code-review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-library-list-1", + op: "skills.library.list", + args: {}, + }, + { + skillLibraryRepo, + skillMountRepo: { + listBySkillSlug: () => [], + } as never, + skillsHubClient: {} as never, + } as CommandContext + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + expect.objectContaining({ + slug: "code-review", + displayName: "Code Review", + version: "1.2.3", + }), + ]); + }); +}); diff --git a/packages/server/src/__tests__/skills/commands.test.ts b/packages/server/src/__tests__/skills/commands.test.ts new file mode 100644 index 000000000..a35e8faf7 --- /dev/null +++ b/packages/server/src/__tests__/skills/commands.test.ts @@ -0,0 +1,987 @@ +import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { SkillHealthManager } from "../../skills/health-manager.js"; +import { SkillMountManager } from "../../skills/mount-manager.js"; +import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js"; +import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { dispatch } from "../../ws/dispatch.js"; +import "../../commands/skills.js"; + +function createBaseContext(overrides: Partial = {}): CommandContext { + return { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: {} as never, + settingsRepo: {} as never, + providerConfigRepo: {} as never, + providerRegistry: [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["codex"], + skillMountDirectories: ["/Users/test/.agents/skills", "/Users/test/.codex/skills"], + }, + ] as never, + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: { getLease: () => undefined } as never, + lspMgr: {} as never, + ...overrides, + } as CommandContext; +} + +describe("skills commands", () => { + it("returns remote search results decorated with local install state", async () => { + const ctx = createBaseContext({ + skillLibraryRepo: { + get: vi.fn(() => ({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + source: "skillhub", + libraryPath: "/library/code-review", + installState: "installed", + installedAt: 1, + updatedAt: 1, + })), + } as never, + skillMountRepo: { + listBySkillSlug: vi.fn(() => [ + { + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + }, + ]), + countsByProviderId: vi.fn(() => ({ codex: 1 })), + } as never, + skillsHubClient: { + search: vi.fn(async () => [ + { + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + }, + ]), + } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-search-1", + op: "skills.search", + args: { query: "review" }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + installed: true, + installedVersion: "1.2.3", + mountedProviderIds: ["codex"], + }, + ]); + }); + + it("returns merged remote and local info for a skill", async () => { + const ctx = createBaseContext({ + skillLibraryRepo: { + get: vi.fn(() => ({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + source: "skillhub", + libraryPath: "/library/code-review", + installState: "installed", + installedAt: 1, + updatedAt: 1, + })), + } as never, + skillMountRepo: { + listBySkillSlug: vi.fn(() => [ + { + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + }, + ]), + } as never, + skillsHubClient: { + info: vi.fn(async () => ({ + slug: "code-review", + name: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + })), + } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-info-1", + op: "skills.info", + args: { slug: "code-review" }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + installed: true, + libraryEntry: expect.objectContaining({ slug: "code-review" }), + mounts: [expect.objectContaining({ providerId: "codex" })], + }); + }); + + it("returns library entries with derived mount state", async () => { + const ctx = createBaseContext({ + skillLibraryRepo: { + list: vi.fn(() => [ + { + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + source: "skillhub", + libraryPath: "/library/code-review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }, + ]), + } as never, + skillMountRepo: { + listBySkillSlug: vi.fn(() => [ + { + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + }, + ]), + } as never, + skillsHubClient: {} as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-library-list-1", + op: "skills.library.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + expect.objectContaining({ + slug: "code-review", + mountedProviderIds: ["codex"], + mountStatus: "partially_mounted", + errorCount: 0, + }), + ]); + }); + + it("starts and fetches install jobs", async () => { + const start = vi.fn(async () => ({ + jobId: "job-1", + slug: "code-review", + status: "queued", + steps: [], + })); + const get = vi.fn(() => ({ + jobId: "job-1", + slug: "code-review", + status: "running", + steps: [], + })); + const ctx = createBaseContext({ + skillInstallMgr: { start, get } as never, + }); + + const started = await dispatch( + { + kind: "command", + id: "skills-install-start-1", + op: "skills.install.start", + args: { slug: "code-review" }, + }, + ctx + ); + const fetched = await dispatch( + { + kind: "command", + id: "skills-install-get-1", + op: "skills.install.get", + args: { jobId: "job-1" }, + }, + ctx + ); + + expect(started.ok).toBe(true); + expect(fetched.ok).toBe(true); + expect(start).toHaveBeenCalledWith("code-review"); + expect(get).toHaveBeenCalledWith("job-1"); + }); + + it("mounts a skill and persists scanned relation", async () => { + const mount = vi.fn(async () => ({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + })); + const scanMount = vi.fn(async (relation) => relation); + const upsert = vi.fn(); + const ctx = createBaseContext({ + skillMountMgr: { mount } as never, + skillHealthMgr: { scanMount } as never, + skillTargetRepo: {} as never, + skillMountRepo: { upsert } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-mount-1", + op: "skills.mount", + args: { providerId: "codex", skillSlug: "code-review", enabled: true }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(mount).toHaveBeenCalledWith({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + }); + expect(upsert).toHaveBeenCalled(); + }); + + it("mounts Codex skills into the shared directory before provider-specific fallbacks", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-codex-mount-target-")); + try { + const libraryPath = join(tempDir, "library", "code-review"); + const codexSkillDir = join(tempDir, ".codex", "skills"); + const sharedSkillDir = join(tempDir, ".agents", "skills"); + await mkdir(libraryPath, { recursive: true }); + await writeFile(join(libraryPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.0.0", + source: "skillhub", + libraryPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const providers = [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["codex"], + skillMountDirectories: [sharedSkillDir, codexSkillDir], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-mount-codex-primary-target-1", + op: "skills.mount", + args: { providerId: "codex", skillSlug: "code-review", enabled: true }, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillMountMgr: new SkillMountManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + skillMountRepo, + }), + skillHealthMgr: new SkillHealthManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + }), + }) + ); + + const targetPath = join(sharedSkillDir, "code-review"); + expect(result.ok).toBe(true); + expect(result.data).toEqual(expect.objectContaining({ targetPath, status: "mounted" })); + await expect(lstat(targetPath)).resolves.toBeTruthy(); + await expect(lstat(join(codexSkillDir, "code-review"))).rejects.toBeTruthy(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("mounts local shared Codex skills without rewriting their source directory", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-codex-local-shared-mount-")); + try { + const sharedSkillDir = join(tempDir, ".agents", "skills"); + const codexSkillDir = join(tempDir, ".codex", "skills"); + const localSkillPath = join(sharedSkillDir, "code-review"); + await mkdir(localSkillPath, { recursive: true }); + await writeFile(join(localSkillPath, "SKILL.md"), "---\nversion: local\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "local", + source: "local", + libraryPath: localSkillPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const providers = [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["codex"], + skillMountDirectories: [sharedSkillDir, codexSkillDir], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-mount-codex-local-shared-1", + op: "skills.mount", + args: { providerId: "codex", skillSlug: "code-review", enabled: true }, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillMountMgr: new SkillMountManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + skillMountRepo, + }), + skillHealthMgr: new SkillHealthManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + }), + }) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + sourcePath: localSkillPath, + targetPath: localSkillPath, + status: "mounted", + }) + ); + await expect(lstat(join(localSkillPath, "SKILL.md"))).resolves.toBeTruthy(); + expect((await lstat(localSkillPath)).isSymbolicLink()).toBe(false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not delete local shared skills when unmounting a discovered native mount", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-native-unmount-")); + try { + const localSkillPath = join(tempDir, ".agents", "skills", "code-review"); + await mkdir(localSkillPath, { recursive: true }); + await writeFile(join(localSkillPath, "SKILL.md"), "---\nversion: local\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "local", + source: "local", + libraryPath: localSkillPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + skillMountRepo.upsert({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: localSkillPath, + targetPath: localSkillPath, + mountModeResolved: "copy", + status: "mounted", + lastSyncedAt: 3, + }); + + const providers = [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["codex"], + skillMountDirectories: [ + join(tempDir, ".agents", "skills"), + join(tempDir, ".codex", "skills"), + ], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-unmount-native-shared-1", + op: "skills.unmount", + args: { providerId: "codex", skillSlug: "code-review" }, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillMountMgr: new SkillMountManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + skillMountRepo, + }), + }) + ); + + expect(result.ok).toBe(true); + await expect(lstat(join(localSkillPath, "SKILL.md"))).resolves.toBeTruthy(); + expect(skillMountRepo.get("codex", "code-review")).toBeUndefined(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("prefers shared directory discovery over existing provider-specific Codex relations", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-codex-shared-preferred-")); + try { + const libraryPath = join(tempDir, "library", "code-review"); + const sharedTargetPath = join(tempDir, ".agents", "skills", "code-review"); + const codexTargetPath = join(tempDir, ".codex", "skills", "code-review"); + await mkdir(libraryPath, { recursive: true }); + await mkdir(sharedTargetPath, { recursive: true }); + await mkdir(codexTargetPath, { recursive: true }); + await writeFile(join(libraryPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + await writeFile(join(sharedTargetPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + await writeFile(join(codexTargetPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.0.0", + source: "skillhub", + libraryPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + skillMountRepo.upsert({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: libraryPath, + targetPath: codexTargetPath, + mountModeResolved: "copy", + status: "mounted", + lastSyncedAt: 3, + }); + + const providers = [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["codex"], + skillMountDirectories: [ + join(tempDir, ".agents", "skills"), + join(tempDir, ".codex", "skills"), + ], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-health-scan-codex-shared-preferred-1", + op: "skills.health.scan", + args: {}, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillHealthMgr: new SkillHealthManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + }), + }) + ); + + expect(result.ok).toBe(true); + expect(skillMountRepo.get("codex", "code-review")).toEqual( + expect.objectContaining({ + targetPath: sharedTargetPath, + status: "mounted", + }) + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("returns aggregated target settings and health", async () => { + const ctx = createBaseContext({ + skillMountRepo: { + countsByProviderId: vi.fn(() => ({ codex: 2 })), + } as never, + skillHealthMgr: { + listTargetHealth: vi.fn(async () => ({ codex: { state: "healthy" } })), + } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-targets-list-1", + op: "skills.targets.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + expect.objectContaining({ + providerId: "codex", + skillDir: "/Users/test/.agents/skills", + mountedSkillCount: 2, + lastHealthState: "healthy", + }), + ]); + }); + + it("uninstalls a skill by removing repo state and library directory", async () => { + const tempDir = join(tmpdir(), `skill-uninstall-${Date.now()}`); + const libraryPath = join(tempDir, "code-review"); + await mkdir(libraryPath, { recursive: true }); + const deleteBySkillSlug = vi.fn(); + const deleteEntry = vi.fn(); + const ctx = createBaseContext({ + skillsHubClient: {} as never, + skillLibraryRepo: { + get: vi.fn(() => ({ + slug: "code-review", + displayName: "Code Review", + version: "1.0.0", + source: "skillhub", + libraryPath, + installState: "installed", + installedAt: 1, + updatedAt: 1, + })), + delete: deleteEntry, + } as never, + skillMountRepo: { + listBySkillSlug: vi.fn(() => []), + deleteBySkillSlug, + } as never, + skillMountMgr: {} as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-uninstall-1", + op: "skills.uninstall", + args: { slug: "code-review" }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(deleteBySkillSlug).toHaveBeenCalledWith("code-review"); + expect(deleteEntry).toHaveBeenCalledWith("code-review"); + await expect(rm(libraryPath, { recursive: true, force: false })).rejects.toBeTruthy(); + }); + + it("scans and persists mount health state", async () => { + const upsert = vi.fn(); + const ctx = createBaseContext({ + skillMountRepo: { + list: vi.fn(() => [ + { + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + }, + ]), + countsByProviderId: vi.fn(() => ({ codex: 1 })), + upsert, + } as never, + skillHealthMgr: { + discoverMounts: vi.fn(async () => []), + scanMount: vi.fn(async (relation) => ({ ...relation, status: "stale" })), + listTargetHealth: vi.fn(async () => ({ codex: { state: "warning" } })), + } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-health-scan-1", + op: "skills.health.scan", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(upsert).toHaveBeenCalledWith(expect.objectContaining({ status: "stale" })); + }); + + it("discovers mounted Claude skills from provider-specific directories during health scan", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-claude-mount-discovery-")); + try { + const libraryPath = join(tempDir, "library", "code-review"); + const claudeSkillDir = join(tempDir, ".claude", "skills"); + const targetPath = join(claudeSkillDir, "code-review"); + await mkdir(libraryPath, { recursive: true }); + await mkdir(targetPath, { recursive: true }); + await writeFile(join(libraryPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + await writeFile(join(targetPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.0.0", + source: "skillhub", + libraryPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const providers = [ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["claude"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["claude"], + skillMountDirectories: [claudeSkillDir], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-health-scan-claude-discovery-1", + op: "skills.health.scan", + args: {}, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillHealthMgr: new SkillHealthManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + }), + }) + ); + + expect(result.ok).toBe(true); + expect(skillMountRepo.get("claude", "code-review")).toEqual( + expect.objectContaining({ + enabled: true, + mountModeResolved: "copy", + sourcePath: libraryPath, + status: "mounted", + targetPath, + }) + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not mark shared local skills as mounted for Claude", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "skill-claude-shared-not-mounted-")); + try { + const libraryPath = join(tempDir, "library", "code-review"); + const sharedTargetPath = join(tempDir, ".agents", "skills", "code-review"); + const claudeSkillDir = join(tempDir, ".claude", "skills"); + await mkdir(libraryPath, { recursive: true }); + await mkdir(sharedTargetPath, { recursive: true }); + await writeFile(join(libraryPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + await writeFile(join(sharedTargetPath, "SKILL.md"), "---\nversion: 1.0.0\n---\n"); + + const skillLibraryRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library-index.json"), + }); + const skillMountRepo = new SkillMountRepo({ + filePath: join(tempDir, "mounts.json"), + }); + skillLibraryRepo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.0.0", + source: "skillhub", + libraryPath, + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const providers = [ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["claude"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["claude"], + skillMountDirectories: [claudeSkillDir], + }, + ] as CommandContext["providerRegistry"]; + + const result = await dispatch( + { + kind: "command", + id: "skills-health-scan-claude-shared-not-mounted-1", + op: "skills.health.scan", + args: {}, + }, + createBaseContext({ + providerRegistry: providers, + skillLibraryRepo, + skillMountRepo, + skillHealthMgr: new SkillHealthManager({ + getProviderRegistry: () => providers, + skillLibraryRepo, + }), + }) + ); + + expect(result.ok).toBe(true); + expect(skillMountRepo.get("claude", "code-review")).toBeUndefined(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("repairs an existing mount by remounting and rescanning it", async () => { + const mount = vi.fn(async () => ({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/library/code-review", + targetPath: "/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + })); + const scanMount = vi.fn(async (relation) => relation); + const upsert = vi.fn(); + const ctx = createBaseContext({ + skillTargetRepo: {} as never, + skillMountRepo: { + get: vi.fn(() => ({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + })), + upsert, + } as never, + skillMountMgr: { mount } as never, + skillHealthMgr: { scanMount } as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "skills-repair-1", + op: "skills.repair", + args: { providerId: "codex", skillSlug: "code-review" }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(mount).toHaveBeenCalledWith({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + }); + expect(upsert).toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/__tests__/skills/search-parser.test.ts b/packages/server/src/__tests__/skills/search-parser.test.ts new file mode 100644 index 000000000..2fe3641de --- /dev/null +++ b/packages/server/src/__tests__/skills/search-parser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillsHubSearchOutput } from "../../skills/search-parser.js"; + +describe("parseSkillsHubSearchOutput", () => { + it("extracts search rows from CLI text output", () => { + const output = [ + "1. code-review", + " Name: Code Review", + " Description: Review code changes before merge", + "2. security-audit", + " Name: Security Audit", + " Description: Find high-risk vulnerabilities", + ].join("\n"); + + expect(parseSkillsHubSearchOutput(output)).toEqual([ + { + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + }, + { + slug: "security-audit", + displayName: "Security Audit", + description: "Find high-risk vulnerabilities", + }, + ]); + }); + + it("extracts rows from the current Skills Hub CLI output", () => { + const output = [ + "\u001b[1mFound 2 skills:\u001b[22m", + "", + " \u001b[90m[--]\u001b[39m \u001b[1mcode-review\u001b[22m \u001b[90mv1.0.0\u001b[39m", + " \u001b[90mThorough code review - checks correctness, security, performance\u001b[39m", + " \u001b[36mnpx @skills-hub-ai/cli install code-review\u001b[39m \u001b[90m40 installs\u001b[39m", + "", + " \u001b[90m[--]\u001b[39m \u001b[1msecurity-review\u001b[22m \u001b[90mv2.0.0\u001b[39m", + " \u001b[90mSecurity audit and vulnerability assessment for any codebase.\u001b[39m", + " \u001b[36mnpx @skills-hub-ai/cli install security-review\u001b[39m \u001b[90m14 installs\u001b[39m", + ].join("\n"); + + expect(parseSkillsHubSearchOutput(output)).toEqual([ + { + slug: "code-review", + displayName: "code-review", + description: "Thorough code review - checks correctness, security, performance", + }, + { + slug: "security-review", + displayName: "security-review", + description: "Security audit and vulnerability assessment for any codebase.", + }, + ]); + }); +}); diff --git a/packages/server/src/__tests__/skills/skills-hub-client.test.ts b/packages/server/src/__tests__/skills/skills-hub-client.test.ts new file mode 100644 index 000000000..98bee8cbe --- /dev/null +++ b/packages/server/src/__tests__/skills/skills-hub-client.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { SkillsHubClient } from "../../skills/skills-hub-client.js"; + +describe("SkillsHubClient", () => { + it("runs search with the expected CLI arguments", async () => { + const runCommand = vi.fn(async () => ({ + stdout: "1. code-review\n Name: Code Review\n", + stderr: "", + })); + const client = new SkillsHubClient({ runCommand }); + + const results = await client.search("review"); + + expect(runCommand).toHaveBeenCalledWith( + "npx", + ["-y", "@skills-hub-ai/cli", "search", "review", "--limit", "20"], + undefined + ); + expect(results[0]).toMatchObject({ slug: "code-review", displayName: "Code Review" }); + }); + + it("stages install through a temp HOME and then syncs to an export dir", async () => { + const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" })); + const client = new SkillsHubClient({ runCommand }); + + const staged = await client.stageInstall("code-review"); + + expect(runCommand).toHaveBeenNthCalledWith( + 1, + "npx", + ["-y", "@skills-hub-ai/cli", "install", "code-review", "--target", "codex", "--no-save"], + expect.objectContaining({ + env: expect.objectContaining({ HOME: staged.tempHome }), + }) + ); + expect(runCommand).toHaveBeenNthCalledWith( + 2, + "npx", + ["-y", "@skills-hub-ai/cli", "sync", "codex", "--output", staged.exportDir], + expect.objectContaining({ + env: expect.objectContaining({ HOME: staged.tempHome }), + }) + ); + }); +}); diff --git a/packages/server/src/__tests__/skills/target-registry.test.ts b/packages/server/src/__tests__/skills/target-registry.test.ts new file mode 100644 index 000000000..699f467f3 --- /dev/null +++ b/packages/server/src/__tests__/skills/target-registry.test.ts @@ -0,0 +1,71 @@ +import type { ProviderDefinition } from "@coder-studio/core"; +import { describe, expect, it } from "vitest"; +import { buildAgentSkillTargets } from "../../skills/target-registry.js"; + +const providers = [ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + supportsSkillsMount: true, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["claude"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["claude"], + }, + { + id: "review-bot", + displayName: "Review Bot", + badge: "Custom", + kind: "custom", + supportsSkillsMount: false, + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["review-bot"], env: {}, cwd: "/tmp" }), + configSchema: { parse: (value: unknown) => value } as never, + defaultConfig: {}, + requiredCommands: ["review-bot"], + }, +] satisfies ProviderDefinition[]; + +describe("buildAgentSkillTargets", () => { + it("uses provider-configured skill directories and excludes unsupported providers", () => { + const targets = buildAgentSkillTargets({ + providers, + resolvedSkillDirByProviderId: { + claude: "/Users/test/.claude/skills", + }, + mountCountsByProviderId: { + claude: 2, + }, + targetHealthByProviderId: { + claude: { state: "healthy" }, + "review-bot": { state: "unconfigured", error: "No skill directory configured" }, + }, + }); + + expect(targets).toEqual([ + expect.objectContaining({ + providerId: "claude", + skillDir: "/Users/test/.claude/skills", + mountedSkillCount: 2, + lastHealthState: "healthy", + }), + ]); + }); +}); diff --git a/packages/server/src/__tests__/storage/skill-library-repo.test.ts b/packages/server/src/__tests__/storage/skill-library-repo.test.ts new file mode 100644 index 000000000..81a5aaa08 --- /dev/null +++ b/packages/server/src/__tests__/storage/skill-library-repo.test.ts @@ -0,0 +1,83 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SkillLibraryRepo } from "../../storage/repositories/skill-library-repo.js"; + +describe("SkillLibraryRepo", () => { + let tempDir: string; + let repo: SkillLibraryRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "skill-library-repo-")); + repo = new SkillLibraryRepo({ filePath: join(tempDir, "library.json") }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("persists library entries across repo instances", () => { + repo.set({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + source: "skillhub", + libraryPath: "/skills/library/code-review", + installState: "installed", + installedAt: 1, + updatedAt: 2, + }); + + const reloaded = new SkillLibraryRepo({ filePath: join(tempDir, "library.json") }); + expect(reloaded.get("code-review")).toMatchObject({ + slug: "code-review", + displayName: "Code Review", + version: "1.2.3", + }); + }); + + it("includes skills discovered in local skill roots", async () => { + const skillsRoot = join(tempDir, "agents-skills"); + const localSkillDir = join(skillsRoot, "code-review"); + await mkdir(localSkillDir, { recursive: true }); + await mkdir(join(skillsRoot, "ignored"), { recursive: true }); + await writeFile( + join(localSkillDir, "SKILL.md"), + [ + "---", + "name: code-review", + "description: Review code changes before merge", + "---", + "", + "# Code Review", + "", + ].join("\n"), + "utf8" + ); + + const scannedRepo = new SkillLibraryRepo({ + filePath: join(tempDir, "library.json"), + localSkillRoots: [skillsRoot], + }); + + expect(scannedRepo.get("code-review")).toMatchObject({ + slug: "code-review", + displayName: "Code Review", + description: "Review code changes before merge", + source: "local", + version: "local", + installState: "installed", + libraryPath: localSkillDir, + }); + expect(scannedRepo.get("ignored")).toBeUndefined(); + expect(scannedRepo.list()).toEqual([ + expect.objectContaining({ + slug: "code-review", + displayName: "Code Review", + source: "local", + }), + ]); + }); +}); diff --git a/packages/server/src/__tests__/storage/skill-mount-repo.test.ts b/packages/server/src/__tests__/storage/skill-mount-repo.test.ts new file mode 100644 index 000000000..4f9cbbb0e --- /dev/null +++ b/packages/server/src/__tests__/storage/skill-mount-repo.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SkillMountRepo } from "../../storage/repositories/skill-mount-repo.js"; + +describe("SkillMountRepo", () => { + let tempDir: string; + let repo: SkillMountRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "skill-mount-repo-")); + repo = new SkillMountRepo({ filePath: join(tempDir, "mounts.json") }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("indexes mount relations by provider and slug", () => { + repo.upsert({ + providerId: "codex", + skillSlug: "code-review", + enabled: true, + sourcePath: "/skills/library/code-review", + targetPath: "/agents/codex/code-review", + mountModeResolved: "symlink", + status: "mounted", + lastSyncedAt: 100, + }); + + expect(repo.listByProviderId("codex")).toHaveLength(1); + expect(repo.listBySkillSlug("code-review")).toHaveLength(1); + expect(repo.countsByProviderId()).toEqual({ codex: 1 }); + }); +}); diff --git a/packages/server/src/__tests__/storage/skill-target-repo.test.ts b/packages/server/src/__tests__/storage/skill-target-repo.test.ts new file mode 100644 index 000000000..90974af43 --- /dev/null +++ b/packages/server/src/__tests__/storage/skill-target-repo.test.ts @@ -0,0 +1,30 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SkillTargetRepo } from "../../storage/repositories/skill-target-repo.js"; + +describe("SkillTargetRepo", () => { + let tempDir: string; + let repo: SkillTargetRepo; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "skill-target-repo-")); + repo = new SkillTargetRepo({ filePath: join(tempDir, "targets.json") }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("persists provider skill directories across repo instances", () => { + repo.set({ providerId: "codex", skillDir: "/skills/codex", updatedAt: 10 }); + + const reloaded = new SkillTargetRepo({ filePath: join(tempDir, "targets.json") }); + expect(reloaded.get("codex")).toEqual({ + providerId: "codex", + skillDir: "/skills/codex", + updatedAt: 10, + }); + }); +}); diff --git a/packages/server/src/__tests__/supervisor-manager.test.ts b/packages/server/src/__tests__/supervisor-manager.test.ts index 654c9c198..38893f52c 100644 --- a/packages/server/src/__tests__/supervisor-manager.test.ts +++ b/packages/server/src/__tests__/supervisor-manager.test.ts @@ -51,11 +51,27 @@ const PROVIDER_INSTALL = { function createProvider( overrides: Partial & Pick ): ProviderDefinition { + const supportsInteractive = overrides.capability !== "unsupported"; + const declaredSupervisorEval = overrides.capabilities?.find( + (capability) => capability.key === "supervisor_eval" + ); + const supportsSupervisorEval = + declaredSupervisorEval?.supported ?? + Boolean(overrides.headless?.supportedScenarios.includes("supervisor_eval")); + return { id: overrides.id, displayName: overrides.id, badge: overrides.id, capability: overrides.capability, + capabilities: overrides.capabilities ?? [ + { key: "interactive_session", supported: supportsInteractive, label: "Interactive session" }, + { + key: "supervisor_eval", + supported: supportsSupervisorEval, + label: "Supervisor evaluation", + }, + ], install: PROVIDER_INSTALL, buildCommand: () => ({ argv: ["node", "-e", 'process.stdout.write("noop")'], @@ -122,28 +138,6 @@ function createManagerDeps() { error: vi.fn(), }; - const defaultTargetMemory: SupervisorTargetMemory = { - targetId: "tgt-1", - decompositionGenerated: true, - decompositionMode: "stage", - items: [ - { - id: "stage-1", - kind: "stage", - title: "Verify the fix", - objective: "Confirm the fix works", - deliverable: "A passing focused verification run", - acceptanceCriteria: ["Focused verification passes"], - status: "in_progress", - }, - ], - activeItemId: "stage-1", - progressSummary: "Verification in progress", - lastGuidance: "Run the focused parser test.", - stalledCount: 0, - updatedAt: 1, - }; - const codexBuildSupervisorEvalCommand = vi.fn(() => ({ argv: [ "node", @@ -397,7 +391,10 @@ function createManagerDeps() { createProvider({ id: "codex", capability: "full", - buildSupervisorEvalCommand: codexBuildSupervisorEvalCommand, + headless: { + supportedScenarios: ["supervisor_eval"], + buildCommand: codexBuildSupervisorEvalCommand, + }, }), ], providerConfigRepo, @@ -872,7 +869,7 @@ describe("SupervisorManager cycle triggers", () => { }) ); - const cycle = await manager.triggerEvaluation(supervisor.id); + await manager.triggerEvaluation(supervisor.id); await waitFor(() => { expect(observedSignal).toBeDefined(); @@ -933,7 +930,7 @@ describe("SupervisorManager cycle triggers", () => { }) ); - const cycle = await manager.triggerEvaluation(supervisor.id); + await manager.triggerEvaluation(supervisor.id); await waitFor(() => { expect(observedSignal).toBeDefined(); @@ -1001,7 +998,7 @@ describe("SupervisorManager cycle triggers", () => { }) ); - const cycle = await manager.triggerEvaluation(supervisor.id); + await manager.triggerEvaluation(supervisor.id); await waitFor(() => { expect(rejectEvaluation).not.toBeNull(); @@ -1373,7 +1370,7 @@ describe("SupervisorManager cycle triggers", () => { }) ); - const cycle = await manager.triggerEvaluation(supervisor.id); + await manager.triggerEvaluation(supervisor.id); await waitFor(() => { expect(evaluate).toHaveBeenCalledTimes(1); }); @@ -1569,6 +1566,75 @@ describe("SupervisorManager cycle triggers", () => { }); }); + it("rejects evaluator providers that do not declare supervisor_eval support", async () => { + deps.providerRegistry.push( + createProvider({ + id: "hook-only", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor evaluation" }, + ], + headless: { + supportedScenarios: ["supervisor_eval"], + buildCommand: vi.fn(() => ({ + argv: ["node", "-e", 'process.stdout.write("{}")'], + cwd: process.cwd(), + env: {}, + })), + }, + }) + ); + + await expect( + manager.create({ + sessionId: "sess-hook-only", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "hook-only", + }) + ).rejects.toMatchObject({ + code: "supervisor_invalid_evaluator_provider", + message: "Provider hook-only cannot evaluate supervisors", + }); + }); + + it("selects evaluator providers from capability and hook support without config lookup", async () => { + const buildSupervisorEvalCommand = vi.fn(() => ({ + argv: ["node", "-e", 'process.stdout.write("{}")'], + cwd: process.cwd(), + env: {}, + })); + deps.providerRegistry.push( + createProvider({ + id: "cursor-like", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + headless: { + supportedScenarios: ["supervisor_eval"], + buildCommand: buildSupervisorEvalCommand, + }, + }) + ); + + await expect( + manager.create({ + sessionId: "sess-cursor-like", + workspaceId: "ws-1", + objective: "Ship the fix", + evaluatorProviderId: "cursor-like", + }) + ).resolves.toMatchObject({ + evaluatorProviderId: "cursor-like", + }); + + expect(deps.providerConfigRepo.get).not.toHaveBeenCalledWith("cursor-like"); + expect(buildSupervisorEvalCommand).not.toHaveBeenCalled(); + }); + it("logs evaluation failures with the original error and keeps the persisted reason concise", async () => { const supervisor = await manager.create({ sessionId: "sess-eval-error", diff --git a/packages/server/src/__tests__/terminal-commands.test.ts b/packages/server/src/__tests__/terminal-commands.test.ts index 894dc3448..b343c9daa 100644 --- a/packages/server/src/__tests__/terminal-commands.test.ts +++ b/packages/server/src/__tests__/terminal-commands.test.ts @@ -51,6 +51,7 @@ function createContext(overrides: Partial = {}): CommandContext close: vi.fn().mockResolvedValue(undefined), write: vi.fn(), resize: vi.fn(), + syncThemeBackgroundForWorkspace: vi.fn(), } as never, eventBus: {} as never, broadcaster: { @@ -838,6 +839,63 @@ describe("terminal commands", () => { expect(ctx.terminalMgr.write).not.toHaveBeenCalled(); }); + it("leaves submit payload untouched", async () => { + const sessionMetadataRepo = { + get: vi.fn(), + upsert: vi.fn(), + }; + const sendInput = vi.fn(); + const ctx = createContext({ + workspaceMgr: { + get: vi.fn().mockReturnValue({ + id: "ws-1", + path: "/workspace", + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 240, + focusMode: false, + }, + }), + } as never, + sessionMgr: { + findSessionIdByTerminal: vi.fn().mockReturnValue("sess-1"), + get: vi.fn().mockReturnValue({ + id: "sess-1", + terminalId: "term-1", + state: "idle", + workspaceId: "ws-1", + providerId: "codex", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + }), + sendInput, + resize: vi.fn(), + } as never, + sessionMetadataRepo: sessionMetadataRepo as never, + }); + + const result = await dispatch( + { + kind: "command", + id: "terminal-input-submit-1", + op: "terminal.input", + args: { + terminalId: "term-1", + bytes: Buffer.from("ship it\r").toString("base64"), + activity: "submit", + submittedText: "ship it", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(sendInput).toHaveBeenCalledWith("sess-1", Buffer.from("ship it\r"), "submit", "ship it"); + expect(sessionMetadataRepo.get).not.toHaveBeenCalled(); + expect(sessionMetadataRepo.upsert).not.toHaveBeenCalled(); + }); + it("delegates ctrl-modified terminal.input to sessionMgr.sendInput as control activity", async () => { const ctx = createContext({ sessionMgr: { @@ -1053,4 +1111,24 @@ describe("terminal commands", () => { expect(ctx.terminalMgr.resize).toHaveBeenCalledWith("term-shell", 80, 24); expect(ctx.sessionMgr.resize).not.toHaveBeenCalled(); }); + + it("delegates terminal.syncThemeBackground to terminalMgr.syncThemeBackgroundForWorkspace", async () => { + const ctx = createContext(); + + const result = await dispatch( + { + kind: "command", + id: "terminal-sync-theme-1", + op: "terminal.syncThemeBackground", + args: { + workspaceId: "ws-1", + themeBackground: "#0b1218", + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.terminalMgr.syncThemeBackgroundForWorkspace).toHaveBeenCalledWith("ws-1", "#0b1218"); + }); }); diff --git a/packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts b/packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts new file mode 100644 index 000000000..25f78a283 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-basic-analyzer.test.ts @@ -0,0 +1,2133 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { analyzeWorkBasic } from "../work-analysis/basic-analyzer.js"; +import { createClaudeWorkLogSource } from "../work-analysis/log-sources/claude.js"; +import { createCodexWorkLogSource } from "../work-analysis/log-sources/codex.js"; + +describe("analyzeWorkBasic", () => { + it("builds snapshotV2 domains from discovered workspaces and session usage", () => { + const result = analyzeWorkBasic({ + query: { workspacePaths: ["/repo/app"], timeRange: { preset: "7d" as const } }, + timeRange: { startAt: 0, endAt: 10_000, label: "Last 7 days" }, + availableWorkspacePaths: ["/repo/app", "/repo/lib"], + sessions: [ + { + sessionId: "session-1", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 1, 12, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 1, 12, 30, 0), + usage: { + inputTokens: 120, + outputTokens: 55, + totalTokens: 175, + }, + usageCoverage: { + hasUsage: true, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "event-1", + providerId: "codex", + sessionId: "session-1", + workspacePath: "/repo/app", + eventType: "command", + occurredAt: Date.UTC(2026, 5, 1, 12, 5, 0), + toolName: "shell", + commandText: "pnpm test", + }, + ], + }, + ], + dataSources: { + providers: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(result.snapshotV2).toMatchObject({ + version: 2, + query: { + timeRangeLabel: "Last 7 days", + selectedWorkspacePaths: ["/repo/app"], + availableWorkspacePaths: ["/repo/app", "/repo/lib"], + }, + overview: { + totals: { + totalTokens: 175, + sessionCount: 1, + workspaceCount: 1, + providerCount: 1, + taskTypeCount: 1, + }, + coverage: { + usage: { + sessionCount: 1, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + sessionCoverageRate: 1, + }, + }, + }, + dataSources: { + providers: [ + expect.objectContaining({ + providerId: "codex", + status: "supported", + sessionCount: 1, + }), + ], + }, + }); + expect(result.snapshotV2?.breakdowns.byWorkspace[0]).toMatchObject({ + key: "/repo/app", + label: "/repo/app", + sessionCount: 1, + }); + expect(result.snapshotV2?.sessions.featured.topByTotalTokens[0]).toMatchObject({ + sessionId: "session-1", + providerId: "codex", + workspacePath: "/repo/app", + totalTokens: 175, + }); + expect(result.tasks.turns?.[0]).toMatchObject({ + turnId: "session-1:turn:0", + primaryTask: "testing", + hasEdits: false, + retries: 0, + evidence: expect.arrayContaining(["tool_pattern:test_command"]), + }); + expect(result.snapshotV2?.tasks).toMatchObject({ + turns: expect.any(Array), + byTypeAndModel: expect.any(Array), + byTypeAndWorkspace: expect.any(Array), + }); + expect(result).not.toHaveProperty("budgets"); + expect(result.snapshotV2?.delivery).not.toHaveProperty("budgets"); + expect(result.coverage.usage).toEqual({ + sessionCount: 1, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + sessionCoverageRate: 1, + }); + }); + + it("computes coverage, durations, ordered hour buckets, and installed-skill mount summaries", () => { + const result = analyzeWorkBasic({ + query: { workspacePaths: ["/repo/app", "/repo/lib"], timeRange: { preset: "7d" as const } }, + timeRange: { startAt: 0, endAt: 10_000, label: "7d" }, + availableWorkspacePaths: ["/repo/app", "/repo/lib"], + sessions: [ + { + sessionId: "sess-1", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 0, 1, 18, 0, 0), + lastActiveAt: Date.UTC(2026, 0, 1, 18, 30, 0), + usage: { + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 60, + reasoningOutputTokens: 10, + totalTokens: 190, + }, + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "e1", + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/repo/app", + eventType: "message", + occurredAt: Date.UTC(2026, 0, 1, 18, 0, 0), + role: "user", + text: "fix failing tests", + }, + { + eventId: "e2", + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/repo/app", + eventType: "command", + occurredAt: Date.UTC(2026, 0, 1, 18, 5, 0), + toolName: "shell", + commandText: "pnpm test", + }, + ], + }, + { + sessionId: "sess-2", + workspacePath: "/repo/lib", + providerId: "claude", + modelId: "claude-sonnet-4-5", + startedAt: Date.UTC(2026, 0, 1, 3, 15, 0), + lastActiveAt: Date.UTC(2026, 0, 1, 4, 15, 0), + usage: { + inputTokens: 80, + outputTokens: 50, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + }, + userTurnCount: 2, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "file_mtime" as const, + events: [ + { + eventId: "e3", + providerId: "claude", + sessionId: "sess-2", + workspacePath: "/repo/lib", + eventType: "plan", + occurredAt: Date.UTC(2026, 0, 1, 3, 15, 0), + role: "assistant", + text: "plan the refactor and investigate errors", + }, + { + eventId: "e4", + providerId: "claude", + sessionId: "sess-2", + workspacePath: "/repo/lib", + eventType: "tool", + occurredAt: Date.UTC(2026, 0, 1, 3, 20, 0), + toolName: "grep", + }, + ], + }, + { + sessionId: "sess-3", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 0, 1, 18, 45, 0), + lastActiveAt: Date.UTC(2026, 0, 1, 19, 0, 0), + usage: { + inputTokens: 40, + outputTokens: 25, + totalTokens: 65, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "e5", + providerId: "codex", + sessionId: "sess-3", + workspacePath: "/repo/app", + eventType: "message", + occurredAt: Date.UTC(2026, 0, 1, 18, 45, 0), + role: "user", + text: "implement new feature", + }, + ], + }, + ], + dataSources: { + providers: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 2, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "cursor", + status: "no_logs" as const, + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "opencode", + status: "supported" as const, + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [{ slug: "review" }, { slug: "build" }, { slug: "ship" }], + mounts: [ + { skillSlug: "review", enabled: true }, + { skillSlug: "review", enabled: true }, + { skillSlug: "build", enabled: false }, + { skillSlug: "ghost", enabled: true }, + ], + }, + }); + + expect(result.coverage.workspaceCount).toBe(2); + expect(result.coverage.sessionCount).toBe(3); + expect(result.coverage.providerCount).toBe(2); + expect(result.activity.sessionCount).toBe(3); + expect(result.activity.totalDurationMs).toBe(6_300_000); + expect(result.activity.averageDurationMs).toBe(2_100_000); + expect(result.workHabits.hourBuckets).toEqual([ + { hour: 3, sessionCount: 1 }, + { hour: 18, sessionCount: 2 }, + ]); + expect(result.skillInventory.installedCount).toBe(3); + expect(result.skillInventory.mountedCount).toBe(1); + expect(result.skillInventory.unmountedCount).toBe(2); + expect(result.capabilityMatrix.providers).toEqual([ + { + providerId: "codex", + workspacePath: "full", + timestamps: "full", + sessionCounts: "full", + toolCounts: "full", + modelIdentity: "full", + tokenUsage: "partial", + cacheUsage: "partial", + reasoningUsage: "partial", + costEstimation: "none", + }, + { + providerId: "cursor", + workspacePath: "full", + timestamps: "partial", + sessionCounts: "full", + toolCounts: "full", + modelIdentity: "none", + tokenUsage: "none", + cacheUsage: "none", + reasoningUsage: "none", + costEstimation: "none", + }, + { + providerId: "opencode", + workspacePath: "full", + timestamps: "full", + sessionCounts: "full", + toolCounts: "full", + modelIdentity: "full", + tokenUsage: "partial", + cacheUsage: "partial", + reasoningUsage: "partial", + costEstimation: "partial", + }, + ]); + expect(result.usage).toEqual({ + totalSessions: 3, + sessionsByProvider: { + claude: 1, + codex: 2, + }, + totals: { + inputTokens: 220, + outputTokens: 135, + cachedInputTokens: 20, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 10, + totalTokens: 455, + }, + byDay: [ + { + day: "2026-01-01", + sessionCount: 3, + totals: { + inputTokens: 220, + outputTokens: 135, + cachedInputTokens: 20, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 10, + totalTokens: 455, + }, + }, + ], + byHour: [ + { + hour: 3, + sessionCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + }, + { + hour: 18, + sessionCount: 2, + totals: { + inputTokens: 140, + outputTokens: 85, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 255, + }, + }, + ], + byProvider: [ + { + providerId: "codex", + sessionCount: 2, + totals: { + inputTokens: 140, + outputTokens: 85, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 255, + }, + }, + { + providerId: "claude", + sessionCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + }, + ], + byWorkspace: [ + { + workspacePath: "/repo/app", + sessionCount: 2, + totals: { + inputTokens: 140, + outputTokens: 85, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 255, + }, + }, + { + workspacePath: "/repo/lib", + sessionCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + }, + ], + byModel: [ + { + modelId: "gpt-5-codex", + providerId: "codex", + sessionCount: 2, + totals: { + inputTokens: 140, + outputTokens: 85, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 255, + }, + }, + { + modelId: "claude-sonnet-4-5", + providerId: "claude", + sessionCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + }, + ], + byTool: [ + { + toolName: "grep", + sessionCount: 1, + useCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + }, + { + toolName: "shell", + sessionCount: 1, + useCount: 1, + totals: { + inputTokens: 100, + outputTokens: 60, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 190, + }, + }, + ], + byCommand: [ + { + commandLabel: "pnpm test", + sessionCount: 1, + useCount: 1, + totals: { + inputTokens: 100, + outputTokens: 60, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 190, + }, + }, + ], + topSessionsByTotalTokens: [ + { + sessionId: "sess-2", + providerId: "claude", + workspacePath: "/repo/lib", + modelId: "claude-sonnet-4-5", + totalTokens: 200, + }, + { + sessionId: "sess-1", + providerId: "codex", + workspacePath: "/repo/app", + modelId: "gpt-5-codex", + totalTokens: 190, + }, + { + sessionId: "sess-3", + providerId: "codex", + workspacePath: "/repo/app", + modelId: "gpt-5-codex", + totalTokens: 65, + }, + ], + }); + expect(result.tasks).toMatchObject({ + byType: [ + { + taskType: "planning", + turnCount: 1, + sessionCount: 1, + totals: { + inputTokens: 80, + outputTokens: 50, + cachedInputTokens: 0, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + reasoningOutputTokens: 0, + totalTokens: 200, + }, + providerIds: ["claude"], + modelIds: ["claude-sonnet-4-5"], + workspacePaths: ["/repo/lib"], + }, + { + taskType: "testing", + turnCount: 1, + sessionCount: 1, + totals: { + inputTokens: 100, + outputTokens: 60, + cachedInputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 10, + totalTokens: 190, + }, + providerIds: ["codex"], + modelIds: ["gpt-5-codex"], + workspacePaths: ["/repo/app"], + }, + { + taskType: "feature_dev", + turnCount: 1, + sessionCount: 1, + totals: { + inputTokens: 40, + outputTokens: 25, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 65, + }, + providerIds: ["codex"], + modelIds: ["gpt-5-codex"], + workspacePaths: ["/repo/app"], + }, + ], + turns: expect.any(Array), + byTypeAndModel: expect.any(Array), + byTypeAndWorkspace: expect.any(Array), + sessions: [ + { + sessionId: "sess-2", + providerId: "claude", + workspacePath: "/repo/lib", + modelId: "claude-sonnet-4-5", + primaryTask: "planning", + signals: [ + "plan_event", + "refactor_language", + "planning_language", + "exploration_language", + "tool_activity", + ], + totalTokens: 200, + }, + { + sessionId: "sess-1", + providerId: "codex", + workspacePath: "/repo/app", + modelId: "gpt-5-codex", + primaryTask: "testing", + signals: ["test_command", "debug_language"], + totalTokens: 190, + }, + { + sessionId: "sess-3", + providerId: "codex", + workspacePath: "/repo/app", + modelId: "gpt-5-codex", + primaryTask: "feature_dev", + signals: ["feature_language"], + totalTokens: 65, + }, + ], + }); + expect(result.efficiency).toEqual({ + overall: { + sessionCount: 3, + averageTokensPerSession: 152, + averageInputTokensPerSession: 73, + averageOutputTokensPerSession: 45, + averageTokensPerToolUse: 228, + commandSessionRate: 0.333, + cacheParticipationRate: 0.667, + editSignalCoverageRate: 0, + highTokenSessionRate: 0.667, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0.5, + }, + byProvider: [ + { + providerId: "claude", + summary: { + sessionCount: 1, + averageTokensPerSession: 200, + averageInputTokensPerSession: 80, + averageOutputTokensPerSession: 50, + averageTokensPerToolUse: 200, + commandSessionRate: 0, + cacheParticipationRate: 1, + editSignalCoverageRate: 0, + highTokenSessionRate: 1, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0, + }, + }, + { + providerId: "codex", + summary: { + sessionCount: 2, + averageTokensPerSession: 128, + averageInputTokensPerSession: 70, + averageOutputTokensPerSession: 43, + averageTokensPerToolUse: 255, + commandSessionRate: 0.5, + cacheParticipationRate: 0.5, + editSignalCoverageRate: 0, + highTokenSessionRate: 0.5, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0.5, + }, + }, + ], + byTask: [ + { + taskType: "feature_dev", + summary: { + sessionCount: 1, + averageTokensPerSession: 65, + averageInputTokensPerSession: 40, + averageOutputTokensPerSession: 25, + averageTokensPerToolUse: 65, + commandSessionRate: 0, + cacheParticipationRate: 0, + editSignalCoverageRate: 0, + highTokenSessionRate: 1, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0, + }, + }, + { + taskType: "planning", + summary: { + sessionCount: 1, + averageTokensPerSession: 200, + averageInputTokensPerSession: 80, + averageOutputTokensPerSession: 50, + averageTokensPerToolUse: 200, + commandSessionRate: 0, + cacheParticipationRate: 1, + editSignalCoverageRate: 0, + highTokenSessionRate: 1, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0, + }, + }, + { + taskType: "testing", + summary: { + sessionCount: 1, + averageTokensPerSession: 190, + averageInputTokensPerSession: 100, + averageOutputTokensPerSession: 60, + averageTokensPerToolUse: 190, + commandSessionRate: 1, + cacheParticipationRate: 1, + editSignalCoverageRate: 0, + highTokenSessionRate: 1, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 1, + }, + }, + ], + }); + expect(result.optimize).toEqual({ + totalFindings: 0, + totalEstimatedWastedTokens: 0, + findings: [], + }); + expect(result.compare).toMatchObject({ + topDimension: "workspace", + dimensions: { + workspace: [ + { key: "/repo/app", shareOfTokens: 0.56, averageTokensPerSession: 128 }, + { key: "/repo/lib", shareOfTokens: 0.44, averageTokensPerSession: 200 }, + ], + provider: [ + { key: "codex", shareOfTokens: 0.56 }, + { key: "claude", shareOfTokens: 0.44 }, + ], + task: [ + { key: "planning", shareOfTokens: 0.44 }, + { key: "testing", shareOfTokens: 0.418 }, + { key: "feature_dev", shareOfTokens: 0.143 }, + ], + }, + }); + expect(result.yield?.overall).toMatchObject({ + sessionCount: 3, + shippedSessionCount: 0, + shippedSessionRate: 0, + commandSessionCount: 1, + averageTokensPerNonShippedSession: 152, + artifactSessionCount: 1, + artifactSignalPerThousandTokens: 8.791, + outputToInputRatio: 0.614, + gitAwareSessionRate: 0.333, + }); + expect(result.yield?.topShippedSessions).toEqual([]); + expect(result.yield?.lowYieldSessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerId: "claude", + sessionId: "sess-2", + workspacePath: "/repo/lib", + taskType: "planning", + totalTokens: 200, + missedSignals: ["no_edit", "no_command", "no_git"], + }), + expect.objectContaining({ + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/repo/app", + taskType: "testing", + totalTokens: 190, + missedSignals: ["no_edit", "no_git"], + }), + expect.objectContaining({ + providerId: "codex", + sessionId: "sess-3", + workspacePath: "/repo/app", + taskType: "feature_dev", + totalTokens: 65, + missedSignals: ["no_edit", "no_command", "no_git"], + }), + ]) + ); + expect(result).not.toHaveProperty("budgets"); + expect(result.agentModelMix.providers).toEqual([ + { providerId: "claude", sessionCount: 1 }, + { providerId: "codex", sessionCount: 2 }, + ]); + expect(result.availableWorkspacePaths).toEqual(["/repo/app", "/repo/lib"]); + expect(result.workSurface.workspacePaths).toEqual(["/repo/app", "/repo/lib"]); + expect(result.executionSignals).toEqual({ + sessionsWithActivity: 3, + userTurnCount: 5, + assistantTurnCount: 4, + toolUseCount: 2, + fileMtimeTimestampCount: 1, + }); + expect(result.dataSources.providers).toEqual([ + { + providerId: "codex", + status: "supported", + sessionCount: 2, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "cursor", + status: "no_logs", + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "opencode", + status: "supported", + sessionCount: 0, + parseErrorCount: 0, + warningCount: 0, + }, + ]); + expect(result.dataQuality).toEqual({ + clampedDurationCount: 0, + emptySessionCount: 0, + }); + }); + + it("attributes session tokens across tool calls without duplicating totals", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { startAt: 0, endAt: 10_000, label: "7d" }, + availableWorkspacePaths: ["/repo/app"], + sessions: [ + { + sessionId: "tool-heavy-session", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 1, 10, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 1, 10, 30, 0), + usage: { + inputTokens: 600, + outputTokens: 400, + totalTokens: 1_000, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 3, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "tool-1", + providerId: "codex", + sessionId: "tool-heavy-session", + workspacePath: "/repo/app", + eventType: "tool", + occurredAt: Date.UTC(2026, 5, 1, 10, 1, 0), + toolName: "Edit", + }, + { + eventId: "tool-2", + providerId: "codex", + sessionId: "tool-heavy-session", + workspacePath: "/repo/app", + eventType: "command", + occurredAt: Date.UTC(2026, 5, 1, 10, 2, 0), + toolName: "Bash", + commandText: "pnpm test", + }, + { + eventId: "tool-3", + providerId: "codex", + sessionId: "tool-heavy-session", + workspacePath: "/repo/app", + eventType: "tool", + occurredAt: Date.UTC(2026, 5, 1, 10, 3, 0), + toolName: "Edit", + }, + ], + }, + ], + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + const toolTotals = Object.fromEntries( + result.usage.byTool.map((tool) => [tool.toolName, tool.totals.totalTokens]) + ); + expect(toolTotals).toEqual({ + Edit: 667, + Bash: 333, + }); + expect(result.usage.byTool.reduce((sum, tool) => sum + tool.totals.totalTokens, 0)).toBe( + result.usage.totals.totalTokens + ); + + expect(result.usage.byCommand).toMatchObject([ + { + commandLabel: "pnpm test", + totals: { + totalTokens: 333, + }, + }, + ]); + }); + + it("surfaces task-3 efficiency metrics in analyzer output", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { + startAt: Date.UTC(2026, 5, 1, 0, 0, 0), + endAt: Date.UTC(2026, 5, 7, 0, 0, 0), + label: "7d", + }, + availableWorkspacePaths: ["/repo/app"], + sessions: [ + { + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 2, 10, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 10, 15, 0), + usage: { + inputTokens: 100, + outputTokens: 900, + cachedInputTokens: 25, + totalTokens: 1_000, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "eff-1", + providerId: "codex", + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 10, 0, 0), + role: "user", + rawRefs: [], + }, + { + eventId: "eff-2", + providerId: "codex", + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + eventType: "command", + canonicalEventType: "command", + occurredAt: Date.UTC(2026, 5, 2, 10, 5, 0), + commandText: "pnpm test", + rawRefs: [], + }, + { + eventId: "eff-3", + providerId: "codex", + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + eventType: "edit", + canonicalEventType: "edit", + occurredAt: Date.UTC(2026, 5, 2, 10, 10, 0), + rawRefs: [], + }, + { + eventId: "eff-4", + providerId: "codex", + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + eventType: "git", + canonicalEventType: "git_signal", + occurredAt: Date.UTC(2026, 5, 2, 10, 12, 0), + rawRefs: [], + }, + { + eventId: "eff-usage-1", + providerId: "codex", + sessionId: "sess-one-shot", + workspacePath: "/repo/app", + eventType: "usage", + canonicalEventType: "usage", + occurredAt: Date.UTC(2026, 5, 2, 10, 14, 0), + tokenUsage: { + inputTokens: 100, + outputTokens: 900, + totalTokens: 1_000, + cachedInputTokens: 25, + }, + rawRefs: [], + }, + ], + }, + { + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + providerId: "claude", + modelId: "claude-sonnet-4-5", + startedAt: Date.UTC(2026, 5, 2, 11, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 11, 20, 0), + usage: { + inputTokens: 50, + outputTokens: 900, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + totalTokens: 1_000, + }, + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "eff-5", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 11, 0, 0), + role: "user", + rawRefs: [], + }, + { + eventId: "eff-6", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 11, 5, 0), + role: "assistant", + rawRefs: [], + }, + { + eventId: "eff-6b", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 11, 7, 0), + role: "user", + rawRefs: [], + }, + { + eventId: "eff-6c", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 11, 8, 0), + role: "assistant", + rawRefs: [], + }, + { + eventId: "eff-7", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "command", + canonicalEventType: "command", + occurredAt: Date.UTC(2026, 5, 2, 11, 10, 0), + commandText: "pnpm lint", + rawRefs: [], + }, + { + eventId: "eff-8", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "edit", + canonicalEventType: "edit", + occurredAt: Date.UTC(2026, 5, 2, 11, 12, 0), + rawRefs: [], + }, + { + eventId: "eff-usage-2", + providerId: "claude", + sessionId: "sess-retry-fix", + workspacePath: "/repo/app", + eventType: "usage", + canonicalEventType: "usage", + occurredAt: Date.UTC(2026, 5, 2, 11, 14, 0), + tokenUsage: { + inputTokens: 50, + outputTokens: 900, + totalTokens: 1_000, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + }, + rawRefs: [], + }, + ], + }, + { + sessionId: "sess-retry-readonly", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 2, 12, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 12, 10, 0), + usage: { + inputTokens: 100, + outputTokens: 900, + cachedInputTokens: 0, + totalTokens: 1_000, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "eff-9", + providerId: "codex", + sessionId: "sess-retry-readonly", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 12, 0, 0), + role: "user", + rawRefs: [], + }, + { + eventId: "eff-10", + providerId: "codex", + sessionId: "sess-retry-readonly", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 12, 5, 0), + role: "assistant", + rawRefs: [], + }, + { + eventId: "eff-11", + providerId: "codex", + sessionId: "sess-retry-readonly", + workspacePath: "/repo/app", + eventType: "command", + canonicalEventType: "command", + occurredAt: Date.UTC(2026, 5, 2, 12, 7, 0), + commandText: "rg TODO src", + rawRefs: [], + }, + { + eventId: "eff-usage-3", + providerId: "codex", + sessionId: "sess-retry-readonly", + workspacePath: "/repo/app", + eventType: "usage", + canonicalEventType: "usage", + occurredAt: Date.UTC(2026, 5, 2, 12, 8, 0), + tokenUsage: { inputTokens: 100, outputTokens: 900, totalTokens: 1_000 }, + rawRefs: [], + }, + ], + }, + { + sessionId: "sess-no-events", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 2, 13, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 13, 10, 0), + usage: { + inputTokens: 80, + outputTokens: 20, + totalTokens: 100, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: undefined, + }, + ], + dataSources: { + providers: [ + { + providerId: "claude", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + { + providerId: "codex", + status: "supported" as const, + sessionCount: 3, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(result.efficiency?.overall).toMatchObject({ + oneShotRate: 0.333, + retryRate: 0.333, + selfCorrectionRate: 0.333, + readToEditRatio: 2, + commandToEditRatio: 1.5, + cacheHitShare: 0.231, + gitAwareSessionRate: 1, + }); + expect(result.efficiency?.byProvider).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + providerId: "claude", + summary: expect.objectContaining({ + retryRate: 1, + selfCorrectionRate: 1, + readToEditRatio: 2, + commandToEditRatio: 1, + cacheHitShare: 0.5, + gitAwareSessionRate: 1, + }), + }), + expect.objectContaining({ + providerId: "codex", + summary: expect.objectContaining({ + oneShotRate: 0.5, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 2, + commandToEditRatio: 2, + cacheHitShare: 0.111, + gitAwareSessionRate: 1, + }), + }), + ]) + ); + expect(result.tasks.turns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "sess-one-shot", + hasEdits: true, + retries: 0, + }), + expect.objectContaining({ + sessionId: "sess-retry-fix", + hasEdits: true, + retries: 0, + }), + ]) + ); + }); + + it("derives retry and per-task one-shot metrics from task turns instead of session-level fallbacks", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { + startAt: Date.UTC(2026, 5, 1, 0, 0, 0), + endAt: Date.UTC(2026, 5, 7, 0, 0, 0), + label: "7d", + }, + availableWorkspacePaths: ["/repo/app"], + sessions: [ + { + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 3, 9, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 3, 9, 30, 0), + usage: { + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + }, + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 4, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "mixed-1", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 3, 9, 0, 0), + role: "user", + text: "fix the failing test in foo", + rawRefs: [], + }, + { + eventId: "mixed-2", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 9, 1, 0), + toolName: "Edit", + filePath: "src/foo.ts", + rawRefs: [], + }, + { + eventId: "mixed-3", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "command", + canonicalEventType: "command", + occurredAt: Date.UTC(2026, 5, 3, 9, 2, 0), + toolName: "Bash", + commandText: "pnpm test src/foo.test.ts", + rawRefs: [], + }, + { + eventId: "mixed-4", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 9, 3, 0), + toolName: "Edit", + filePath: "src/foo.ts", + rawRefs: [], + }, + { + eventId: "mixed-5", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 3, 9, 10, 0), + role: "user", + text: "add a new profile card component", + rawRefs: [], + }, + { + eventId: "mixed-6", + providerId: "codex", + sessionId: "sess-mixed-turns", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 9, 11, 0), + toolName: "Edit", + filePath: "src/profile-card.tsx", + rawRefs: [], + }, + ], + }, + ], + dataSources: { + providers: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(result.tasks.turns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "sess-mixed-turns", + primaryTask: "debugging", + hasEdits: true, + retries: 1, + }), + expect.objectContaining({ + sessionId: "sess-mixed-turns", + primaryTask: "feature_dev", + hasEdits: true, + retries: 0, + }), + ]) + ); + expect(result.efficiency?.byTask).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskType: "debugging", + summary: expect.objectContaining({ + oneShotRate: 0, + retryRate: 1, + }), + }), + expect.objectContaining({ + taskType: "feature_dev", + summary: expect.objectContaining({ + oneShotRate: 1, + retryRate: 0, + }), + }), + ]) + ); + expect(result.yield?.byTask).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskType: "debugging", + turnBehavior: expect.objectContaining({ + turnCount: 1, + editTurnCount: 1, + oneShotTurnCount: 0, + retryTurnCount: 1, + oneShotRate: 0, + retryRate: 1, + }), + }), + expect.objectContaining({ + taskType: "feature_dev", + turnBehavior: expect.objectContaining({ + turnCount: 1, + editTurnCount: 1, + oneShotTurnCount: 1, + retryTurnCount: 0, + oneShotRate: 1, + retryRate: 0, + }), + }), + ]) + ); + }); + + it("accepts by-task retry rates greater than 1 when a single edit turn retries multiple times", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { + startAt: Date.UTC(2026, 5, 1, 0, 0, 0), + endAt: Date.UTC(2026, 5, 5, 0, 0, 0), + label: "7d", + }, + availableWorkspacePaths: ["/repo/app"], + sessions: [ + { + sessionId: "sess-multi-retry", + providerId: "codex", + workspacePath: "/repo/app", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 3, 10, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 3, 10, 15, 0), + usage: { + inputTokens: 120, + outputTokens: 55, + totalTokens: 175, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 5, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "multi-retry-1", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + role: "user", + text: "fix the flaky profile card flow", + occurredAt: Date.UTC(2026, 5, 3, 10, 0, 0), + rawRefs: [], + }, + { + eventId: "multi-retry-2", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 10, 1, 0), + toolName: "Edit", + filePath: "src/profile-card.tsx", + rawRefs: [], + }, + { + eventId: "multi-retry-3", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 10, 2, 0), + toolName: "Bash", + commandText: "pnpm vitest src/profile-card.test.tsx", + rawRefs: [], + }, + { + eventId: "multi-retry-4", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 10, 3, 0), + toolName: "Edit", + filePath: "src/profile-card.tsx", + rawRefs: [], + }, + { + eventId: "multi-retry-5", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 10, 4, 0), + toolName: "Bash", + commandText: "pnpm vitest src/profile-card.test.tsx", + rawRefs: [], + }, + { + eventId: "multi-retry-6", + providerId: "codex", + sessionId: "sess-multi-retry", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + occurredAt: Date.UTC(2026, 5, 3, 10, 5, 0), + toolName: "Edit", + filePath: "src/profile-card.tsx", + rawRefs: [], + }, + ], + }, + ], + dataSources: { + providers: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(result.tasks.turns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "sess-multi-retry", + primaryTask: "debugging", + retries: 2, + }), + ]) + ); + expect(result.efficiency?.byTask).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskType: "debugging", + summary: expect.objectContaining({ + retryRate: 2, + }), + }), + ]) + ); + }); + + it("returns chart-friendly trend and share payloads", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { + startAt: Date.UTC(2026, 5, 1, 0, 0, 0), + endAt: Date.UTC(2026, 5, 5, 0, 0, 0), + label: "7d", + }, + availableWorkspacePaths: ["/repo/a", "/repo/b"], + sessions: [ + { + sessionId: "sess-a1", + workspacePath: "/repo/a", + providerId: "claude", + modelId: "anthropic:claude-sonnet-4-5", + startedAt: Date.UTC(2026, 5, 1, 10, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 1, 10, 20, 0), + usage: { + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + { + sessionId: "sess-b1", + workspacePath: "/repo/b", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 2, 11, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 11, 30, 0), + usage: { + inputTokens: 180, + outputTokens: 120, + totalTokens: 300, + }, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + { + sessionId: "sess-a2", + workspacePath: "/repo/a", + providerId: "claude", + modelId: "anthropic:claude-sonnet-4-5", + startedAt: Date.UTC(2026, 5, 3, 9, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 3, 9, 45, 0), + usage: { + inputTokens: 240, + outputTokens: 160, + totalTokens: 400, + }, + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(result.activity.daily).toEqual([ + { day: "2026-06-01", totalTokens: 200, sessionCount: 1 }, + { day: "2026-06-02", totalTokens: 300, sessionCount: 1 }, + { day: "2026-06-03", totalTokens: 400, sessionCount: 1 }, + ]); + expect(result.compare.workspaces[0]).toEqual( + expect.objectContaining({ + workspacePath: "/repo/a", + totalTokens: 600, + sharePercent: 66.7, + sessionCount: 2, + }) + ); + expect(result.compare.providers[0]).toEqual( + expect.objectContaining({ + providerId: "claude", + totalTokens: 600, + sharePercent: 66.7, + }) + ); + expect(result.compare.models[0]).toEqual( + expect.objectContaining({ + providerId: "claude", + modelId: "anthropic:claude-sonnet-4-5", + totalTokens: 600, + sharePercent: 66.7, + }) + ); + expect(result).not.toHaveProperty("budgets"); + }); + + it("clamps negative durations and returns empty aggregates without sessions", () => { + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "24h" as const } }, + timeRange: { startAt: 100, endAt: 200, label: "24h" }, + availableWorkspacePaths: ["/repo/app"], + sessions: [ + { + sessionId: "sess-1", + workspacePath: "/repo/app", + providerId: "codex", + startedAt: 1_000, + lastActiveAt: 500, + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 1, + timestampQuality: "mixed" as const, + }, + ], + dataSources: { + providers: [ + { + providerId: "codex", + status: "partial" as const, + sessionCount: 1, + parseErrorCount: 1, + warningCount: 1, + }, + ], + }, + skillInventory: { + installedSkills: [{ slug: "review" }], + mounts: [{ skillSlug: "review", enabled: false }], + }, + }); + + expect(result.activity.totalDurationMs).toBe(0); + expect(result.activity.averageDurationMs).toBe(0); + expect(result.workHabits.hourBuckets).toEqual([{ hour: 0, sessionCount: 1 }]); + expect(result.skillInventory.mountedCount).toBe(0); + expect(result.skillInventory.unmountedCount).toBe(1); + expect(result.executionSignals).toEqual({ + sessionsWithActivity: 0, + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + fileMtimeTimestampCount: 0, + }); + expect(result.dataQuality).toEqual({ + clampedDurationCount: 1, + emptySessionCount: 0, + }); + expect(result.compare?.dimensions.workspace[0]).toMatchObject({ + key: "/repo/app", + shareOfTokens: 0, + averageTokensPerSession: 0, + }); + expect(result.yield?.overall).toMatchObject({ + sessionCount: 1, + shippedSessionCount: 0, + averageTokensPerNonShippedSession: 0, + }); + expect(result).not.toHaveProperty("budgets"); + }); + + it("returns zeroed activity and no hour buckets when there are no sessions", () => { + const result = analyzeWorkBasic({ + query: { workspacePaths: ["/repo/app"], timeRange: { preset: "7d" as const } }, + timeRange: { startAt: 0, endAt: 10_000, label: "7d" }, + availableWorkspacePaths: ["/repo/app"], + sessions: [], + dataSources: { + providers: [], + }, + skillInventory: { + installedSkills: [{ slug: "review" }], + mounts: [{ skillSlug: "review", enabled: true }], + }, + }); + + expect(result.coverage.sessionCount).toBe(0); + expect(result.coverage.providerCount).toBe(0); + expect(result.activity.totalDurationMs).toBe(0); + expect(result.activity.averageDurationMs).toBe(0); + expect(result.workHabits.hourBuckets).toEqual([]); + expect(result.skillInventory.mountedCount).toBe(1); + expect(result.skillInventory.unmountedCount).toBe(0); + expect(result.usage).toEqual({ + totalSessions: 0, + sessionsByProvider: {}, + totals: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + }, + byDay: [], + byHour: [], + byProvider: [], + byWorkspace: [], + byModel: [], + byTool: [], + byCommand: [], + topSessionsByTotalTokens: [], + }); + expect(result.tasks).toMatchObject({ + turns: [], + byType: [], + byTypeAndModel: [], + byTypeAndWorkspace: [], + sessions: [], + }); + expect(result.efficiency).toEqual({ + overall: { + sessionCount: 0, + averageTokensPerSession: 0, + averageInputTokensPerSession: 0, + averageOutputTokensPerSession: 0, + averageTokensPerToolUse: 0, + commandSessionRate: 0, + cacheParticipationRate: 0, + editSignalCoverageRate: 0, + highTokenSessionRate: 0, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0, + }, + byProvider: [], + byTask: [], + }); + expect(result.optimize).toEqual({ + totalFindings: 0, + totalEstimatedWastedTokens: 0, + findings: [], + }); + expect(result.compare?.topDimension).toBe("workspace"); + expect(result.yield?.overall).toMatchObject({ + sessionCount: 0, + shippedSessionCount: 0, + shippedSessionRate: 0, + editSessionCount: 0, + commandSessionCount: 0, + gitSessionCount: 0, + artifactSessionCount: 0, + shippedTokens: 0, + shippedTokenShare: 0, + averageTokensPerShippedSession: 0, + averageTokensPerNonShippedSession: 0, + outputToInputRatio: 0, + artifactSignalPerThousandTokens: 0, + gitAwareSessionRate: 0, + }); + expect(result).not.toHaveProperty("budgets"); + expect(result.agentModelMix.providers).toEqual([]); + expect(result.executionSignals).toEqual({ + sessionsWithActivity: 0, + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + fileMtimeTimestampCount: 0, + }); + expect(result.dataSources.providers).toEqual([]); + expect(result.dataQuality).toEqual({ + clampedDurationCount: 0, + emptySessionCount: 1, + }); + }); + + it("collects ordered canonical events and discovered workspace paths from provider logs", async () => { + const home = await mkdtemp(join(tmpdir(), "work-analysis-canonical-events-")); + + try { + const claudeProjectDir = join(home, ".claude", "projects", "project-a"); + const codexSessionDir = join(home, ".codex", "sessions", "2026", "06", "01"); + await mkdir(claudeProjectDir, { recursive: true }); + await mkdir(codexSessionDir, { recursive: true }); + + await writeFile( + join(claudeProjectDir, "claude-session.jsonl"), + [ + JSON.stringify({ + sessionId: "claude-session", + cwd: "/root/workspace/a", + type: "assistant", + timestamp: "2026-06-02T00:00:03.000Z", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + usage: { input_tokens: 80, output_tokens: 40 }, + content: [{ type: "text", text: "Working on it" }], + }, + }), + JSON.stringify({ + sessionId: "claude-session", + cwd: "/root/workspace/a", + type: "user", + timestamp: "2026-06-02T00:00:01.000Z", + message: { + role: "user", + model: "claude-sonnet-4-5", + content: [{ type: "text", text: "Fix tests" }], + }, + }), + JSON.stringify({ + sessionId: "claude-session", + cwd: "/root/workspace/a", + type: "tool", + timestamp: "2026-06-02T00:00:02.000Z", + toolUse: { name: "shell", command: "pnpm test" }, + }), + JSON.stringify({ + sessionId: "claude-session", + cwd: "/root/workspace/a", + type: "assistant", + timestamp: "2026-06-02T00:00:02.000Z", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [{ type: "text", text: "same-time follow-up" }], + }, + }), + ].join("\n") + ); + + await writeFile( + join(codexSessionDir, "codex-session.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T00:00:02.000Z", + type: "tool_call", + payload: { + id: "codex-session", + cwd: "/root/workspace/b", + name: "grep", + text: "src", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T00:00:01.000Z", + type: "user_message", + payload: { + id: "codex-session", + cwd: "/root/workspace/b", + text: "Investigate regression", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T00:00:03.000Z", + type: "event_msg", + event: "token_count", + payload: { + id: "codex-session", + cwd: "/root/workspace/b", + model: "gpt-5-codex", + input_tokens: 60, + output_tokens: 20, + total_tokens: 80, + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T00:00:02.000Z", + type: "assistant_message", + payload: { + id: "codex-session", + cwd: "/root/workspace/b", + text: "same-time summary", + model: "gpt-5-codex", + }, + }), + ].join("\n") + ); + + const [claudeResult, codexResult] = await Promise.all([ + createClaudeWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.UTC(2026, 5, 1), + endAt: Date.UTC(2026, 5, 4), + label: "3d", + }, + }), + createCodexWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.UTC(2026, 5, 1), + endAt: Date.UTC(2026, 5, 4), + label: "3d", + }, + }), + ]); + + const sessions = [...claudeResult.sessions, ...codexResult.sessions]; + const discoveredWorkspacePaths = [ + ...new Set(sessions.map((session) => session.workspacePath)), + ].sort(); + const result = analyzeWorkBasic({ + query: { timeRange: { preset: "7d" as const } }, + timeRange: { startAt: Date.UTC(2026, 5, 1), endAt: Date.UTC(2026, 5, 4), label: "3d" }, + availableWorkspacePaths: discoveredWorkspacePaths, + sessions, + dataSources: { + providers: [ + { + providerId: "claude", + status: claudeResult.status, + sessionCount: claudeResult.sessions.length, + parseErrorCount: claudeResult.parseErrorCount, + warningCount: claudeResult.warnings.length, + }, + { + providerId: "codex", + status: codexResult.status, + sessionCount: codexResult.sessions.length, + parseErrorCount: codexResult.parseErrorCount, + warningCount: codexResult.warnings.length, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); + + expect(discoveredWorkspacePaths).toEqual(["/root/workspace/a", "/root/workspace/b"]); + expect(result.availableWorkspacePaths).toEqual(["/root/workspace/a", "/root/workspace/b"]); + expect(result.usage.byDay).toEqual([ + expect.objectContaining({ day: "2026-06-02", sessionCount: 1 }), + expect.objectContaining({ day: "2026-06-03", sessionCount: 1 }), + ]); + expect(result.capabilityMatrix.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ providerId: "claude" }), + expect.objectContaining({ providerId: "codex" }), + ]) + ); + + expect(claudeResult.sessions[0]?.events?.map((event) => event.canonicalEventType)).toEqual([ + "message_turn", + "command", + "message_turn", + "message_turn", + "usage", + ]); + expect(codexResult.sessions[0]?.events?.map((event) => event.canonicalEventType)).toEqual([ + "message_turn", + "tool_call", + "message_turn", + "usage", + ]); + expect( + claudeResult.sessions[0]?.events?.map((event) => event.text ?? event.commandText) + ).toEqual(["Fix tests", "pnpm test", "same-time follow-up", "Working on it", undefined]); + expect(codexResult.sessions[0]?.events?.map((event) => event.text ?? event.toolName)).toEqual( + ["Investigate regression", "Grep", "same-time summary", undefined] + ); + expect(claudeResult.sessions[0]?.events?.every((event) => event.rawRefs?.length === 1)).toBe( + true + ); + expect(codexResult.sessions[0]?.events?.every((event) => event.rawRefs?.length === 1)).toBe( + true + ); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-commands.test.ts b/packages/server/src/__tests__/work-analysis-commands.test.ts new file mode 100644 index 000000000..2898c9788 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-commands.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatch } from "../ws/dispatch.js"; +import "../commands/work-analysis.js"; + +describe("work analysis commands", () => { + it("dispatches work.analysis.runBasic with workspacePaths", async () => { + const ctx = { + workAnalysisService: { + runBasic: vi.fn(async () => ({ basicStatus: "running" })), + runDeep: vi.fn(), + get: vi.fn(), + }, + } as never; + + const result = await dispatch( + { + kind: "command", + id: "1", + op: "work.analysis.runBasic", + args: { workspacePaths: ["/repo/a"], timeRange: { preset: "7d" } }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.workAnalysisService.runBasic).toHaveBeenCalledWith({ + workspacePaths: ["/repo/a"], + timeRange: { preset: "7d" }, + }); + }); + + it("returns unknown_op for the removed work.analysis.export command", async () => { + const ctx = { + workAnalysisService: { + runBasic: vi.fn(), + runDeep: vi.fn(), + get: vi.fn(), + }, + } as never; + + const result = await dispatch( + { + kind: "command", + id: "2", + op: "work.analysis.export", + args: { workspacePaths: ["/repo/a"], timeRange: { preset: "7d" } }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "unknown_op", + message: "Unknown operation: work.analysis.export", + }); + }); + + it("dispatches work.analysis.dashboard.refresh as a manual refresh", async () => { + const ctx = { + workAnalysisService: { + runBasic: vi.fn(), + runDeep: vi.fn(), + get: vi.fn(), + getDashboard: vi.fn(), + refreshDashboard: vi.fn(async () => ({ + scanState: { status: "succeeded" }, + dashboard: { rankings: { projects: [], models: [], agents: [] } }, + })), + }, + } as never; + + const result = await dispatch( + { + kind: "command", + id: "3", + op: "work.analysis.dashboard.refresh", + args: { workspacePaths: ["/repo/a"], timeRange: { preset: "7d" } }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.workAnalysisService.refreshDashboard).toHaveBeenCalledWith( + { + workspacePaths: ["/repo/a"], + timeRange: { preset: "7d" }, + }, + "manual" + ); + }); + + it("dispatches work.analysis.dashboard.rebuild to clear and rebuild the hourly index", async () => { + const ctx = { + workAnalysisService: { + runBasic: vi.fn(), + runDeep: vi.fn(), + get: vi.fn(), + getDashboard: vi.fn(), + refreshDashboard: vi.fn(), + rebuildDashboardIndex: vi.fn(async () => ({ + scanState: { status: "succeeded" }, + dashboard: { rankings: { projects: [], models: [], agents: [] } }, + })), + }, + } as never; + + const result = await dispatch( + { + kind: "command", + id: "4", + op: "work.analysis.dashboard.rebuild", + args: { workspacePaths: ["/repo/a"], timeRange: { preset: "7d" } }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(ctx.workAnalysisService.rebuildDashboardIndex).toHaveBeenCalledWith({ + workspacePaths: ["/repo/a"], + timeRange: { preset: "7d" }, + }); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-deep-runner.test.ts b/packages/server/src/__tests__/work-analysis-deep-runner.test.ts new file mode 100644 index 000000000..b3d54f064 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-deep-runner.test.ts @@ -0,0 +1,217 @@ +import type { ProviderDefinition } from "@coder-studio/core"; +import { describe, expect, it, vi } from "vitest"; +import { buildWorkDeepAnalysisPrompt } from "../work-analysis/deep-prompt.js"; +import { WorkDeepAnalysisRunner } from "../work-analysis/deep-runner.js"; +import { workDeepAnalysisResultSchema } from "../work-analysis/deep-schema.js"; + +describe("work deep analysis prompt", () => { + it("includes basic analysis and evidence in the prompt", () => { + const prompt = buildWorkDeepAnalysisPrompt({ + basicResult: { + availableWorkspacePaths: ["/repo/a", "/repo/b"], + coverage: { + workspaceCount: 2, + sessionCount: 1, + providerCount: 1, + timeRangeLabel: "7d", + }, + activity: { + sessionCount: 1, + totalDurationMs: 1000, + averageDurationMs: 1000, + }, + workHabits: { hourBuckets: [{ hour: 10, sessionCount: 1 }] }, + skillInventory: { installedCount: 1, mountedCount: 1, unmountedCount: 0 }, + usage: { totalSessions: 1, sessionsByProvider: { codex: 1 } }, + agentModelMix: { providers: [{ providerId: "codex", sessionCount: 1 }] }, + workSurface: { workspacePaths: ["/repo/a", "/repo/b"] }, + executionSignals: { + sessionsWithActivity: 1, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + fileMtimeTimestampCount: 0, + }, + dataSources: { providers: [] }, + dataQuality: { clampedDurationCount: 0, emptySessionCount: 0 }, + }, + evidence: { + sessions: [ + { + providerId: "codex", + sessionId: "session-1", + workspacePath: "/repo/project", + title: "Session", + startedAt: 100, + lastActiveAt: 200, + excerpts: [{ role: "user", text: "investigate" }], + }, + ], + skillInventory: { + installedSkills: [{ slug: "review" }], + mounts: [{ skillSlug: "review", enabled: true }], + }, + }, + }); + + expect(prompt).toContain("workspaceCount"); + expect(prompt).toContain("excerpts"); + }); +}); + +describe("workDeepAnalysisResultSchema", () => { + it("accepts valid structured output", () => { + const result = { + workSummary: "done", + repeatedPatterns: [], + bottlenecks: [], + workflowSuggestions: [], + skillCandidates: [], + openLoops: [], + followUpSuggestions: [], + confidence: "high", + } as const; + + expect(workDeepAnalysisResultSchema.parse(result)).toEqual(result); + }); +}); + +describe("WorkDeepAnalysisRunner", () => { + const provider = { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [], + install: { + prerequisites: [], + manualGuideKeys: [], + docUrls: { prerequisites: {} }, + strategies: {}, + }, + buildCommand: () => ({ argv: ["codex"], env: {}, cwd: "/workspace" }), + configSchema: { parse: (value: unknown) => value } as ProviderDefinition["configSchema"], + defaultConfig: {}, + requiredCommands: ["codex"], + headless: { + supportedScenarios: ["session_analysis"], + buildCommand: () => ({ + argv: ["codex", "exec"], + cwd: "/workspace", + env: {}, + }), + }, + } satisfies ProviderDefinition; + + const baseInput = { + providerId: "codex", + sessionId: "work-analysis-ws-1", + workspacePath: "/workspace", + basicResult: { + availableWorkspacePaths: ["/workspace"], + coverage: { workspaceCount: 1, sessionCount: 1, providerCount: 1, timeRangeLabel: "7d" }, + activity: { sessionCount: 1, totalDurationMs: 1000, averageDurationMs: 1000 }, + workHabits: { hourBuckets: [{ hour: 10, sessionCount: 1 }] }, + skillInventory: { installedCount: 0, mountedCount: 0, unmountedCount: 0 }, + usage: { totalSessions: 1, sessionsByProvider: { codex: 1 } }, + agentModelMix: { providers: [{ providerId: "codex", sessionCount: 1 }] }, + workSurface: { workspacePaths: ["/workspace"] }, + executionSignals: { + sessionsWithActivity: 1, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + fileMtimeTimestampCount: 0, + }, + dataSources: { providers: [] }, + dataQuality: { clampedDurationCount: 0, emptySessionCount: 0 }, + }, + evidence: { + sessions: [ + { + providerId: "codex", + sessionId: "session-1", + workspacePath: "/workspace", + title: "Session", + startedAt: 100, + lastActiveAt: 200, + excerpts: [{ role: "user", text: "hi" }], + }, + ], + skillInventory: { installedSkills: [], mounts: [] }, + }, + }; + + it("parses a plain JSON response", async () => { + const runner = new WorkDeepAnalysisRunner({ + providerRegistry: [provider], + commandRunner: async () => ({ + stdout: JSON.stringify({ + workSummary: "done", + repeatedPatterns: [], + bottlenecks: [], + workflowSuggestions: [], + skillCandidates: [], + openLoops: [], + followUpSuggestions: [], + confidence: "high", + }), + stderr: "", + }), + }); + + await expect(runner.run(baseInput)).resolves.toMatchObject({ + workSummary: "done", + confidence: "high", + }); + }); + + it("parses Codex JSONL output by extracting the completed agent message", async () => { + const runner = new WorkDeepAnalysisRunner({ + providerRegistry: [provider], + commandRunner: async () => ({ + stdout: [ + '{"type":"item.started","item":{"type":"agent_message"}}', + JSON.stringify({ + type: "item.completed", + item: { + type: "agent_message", + text: JSON.stringify({ + workSummary: "done", + repeatedPatterns: [], + bottlenecks: [], + workflowSuggestions: [], + skillCandidates: [], + openLoops: [], + followUpSuggestions: [], + confidence: "medium", + }), + }, + }), + ].join("\n"), + stderr: "", + }), + }); + + await expect(runner.run(baseInput)).resolves.toMatchObject({ + workSummary: "done", + confidence: "medium", + }); + }); + + it("falls back to the first supported provider when the preferred provider is unsupported", () => { + const fallbackRunner = new WorkDeepAnalysisRunner({ + providerRegistry: [ + { ...provider, id: "claude" }, + { + ...provider, + id: "cursor", + headless: { supportedScenarios: [], buildCommand: () => null }, + }, + ], + }); + + expect(fallbackRunner.resolveProviderId("cursor")).toBe("claude"); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts b/packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts new file mode 100644 index 000000000..33d968d4a --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-efficiency-and-optimize.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeWorkBasic } from "../work-analysis/basic-analyzer.js"; +import { + summarizeEfficiency, + usageTotalsToEfficiencyInput, +} from "../work-analysis/metrics/token-efficiency.js"; +import { findHighCostLowYieldSessions } from "../work-analysis/metrics/yield.js"; +import { detectOptimizeFindings } from "../work-analysis/optimize/detect-findings.js"; + +describe("work analysis efficiency helpers", () => { + it("summarizes token efficiency across sessions", () => { + const result = summarizeEfficiency([ + usageTotalsToEfficiencyInput({ + sessionId: "s1", + providerId: "codex", + taskType: "testing", + totals: { + inputTokens: 100, + outputTokens: 40, + cachedInputTokens: 0, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 160, + }, + toolUseCount: 2, + hasCommandSignal: true, + hasEditSignal: false, + }), + usageTotalsToEfficiencyInput({ + sessionId: "s2", + providerId: "claude", + taskType: "planning", + totals: { + inputTokens: 50, + outputTokens: 20, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 70, + }, + toolUseCount: 0, + hasCommandSignal: false, + hasEditSignal: false, + }), + ]); + + expect(result).toEqual({ + sessionCount: 2, + averageTokensPerSession: 115, + averageInputTokensPerSession: 75, + averageOutputTokensPerSession: 30, + averageTokensPerToolUse: 115, + commandSessionRate: 0.5, + cacheParticipationRate: 0.5, + editSignalCoverageRate: 0, + highTokenSessionRate: 0.5, + toolHeavySessionCount: 0, + oneShotRate: 0, + retryRate: 0, + selfCorrectionRate: 0, + readToEditRatio: 0, + commandToEditRatio: 0, + cacheHitShare: 0, + gitAwareSessionRate: 0, + }); + }); + + it("uses only analyzable event sessions for event-derived rate denominators", () => { + const result = summarizeEfficiency([ + usageTotalsToEfficiencyInput({ + sessionId: "s-analyzable", + providerId: "codex", + taskType: "testing", + totals: { + inputTokens: 100, + outputTokens: 20, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 120, + }, + toolUseCount: 1, + hasCommandSignal: true, + hasEditSignal: true, + events: [ + { canonicalEventType: "message_turn", role: "user" }, + { canonicalEventType: "message_turn", role: "assistant" }, + { canonicalEventType: "command" }, + { canonicalEventType: "edit" }, + ], + }), + usageTotalsToEfficiencyInput({ + sessionId: "s-missing-events", + providerId: "claude", + taskType: "planning", + totals: { + inputTokens: 50, + outputTokens: 10, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 60, + }, + toolUseCount: 0, + hasCommandSignal: false, + hasEditSignal: false, + }), + ]); + + expect(result.oneShotRate).toBe(1); + expect(result.retryRate).toBe(0); + expect(result.selfCorrectionRate).toBe(0); + expect(result.gitAwareSessionRate).toBe(1); + }); +}); + +describe("work analysis optimize detectors", () => { + it("detects missing provider usage and token-heavy low-yield sessions", () => { + const findings = detectOptimizeFindings({ + providers: [ + { + providerId: "codex", + sessionCount: 3, + totals: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + }, + }, + ], + sessions: [ + { + sessionId: "s-heavy", + providerId: "claude", + workspacePath: "/repo/app", + taskType: "debugging", + supportsLowYieldInference: true, + toolUseCount: 4, + parseErrorCount: 0, + hasCommandSignal: true, + hasEditSignal: false, + hasGitSignal: false, + totals: { + inputTokens: 120_000, + outputTokens: 10_000, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 130_000, + }, + }, + ], + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "high-cost-low-yield", + type: "high_cost_low_yield", + severity: "high", + }), + expect.objectContaining({ + type: "tool_heavy_low_output", + }), + expect.objectContaining({ + type: "provider_missing_usage", + }), + ]) + ); + expect( + findings.find((finding) => finding.type === "tool_heavy_low_output")?.estimatedWastedTokens + ).toBeGreaterThan(0); + }); + + it("detects expensive low-yield sessions using the same shipped-session rules as yield", () => { + const lowYieldSessions = findHighCostLowYieldSessions([ + { + sessionId: "sess-edit-no-ship", + providerId: "codex", + workspacePath: "/repo/app", + taskType: "debugging", + totals: { + inputTokens: 45_000, + outputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 5_000, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 50_000, + }, + hasEditSignal: true, + hasCommandSignal: false, + hasGitSignal: false, + }, + ]); + + expect(lowYieldSessions).toEqual([ + expect.objectContaining({ + sessionId: "sess-edit-no-ship", + totalTokens: 50_000, + }), + ]); + }); + + it("flags high-cost low-yield sessions in optimize findings", () => { + const result = runBasicAnalysis(makeLowYieldFixture()); + + expect(result.optimize.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "high-cost-low-yield", + type: "high_cost_low_yield", + severity: "high", + }), + ]) + ); + expect(result.yield.lowYieldSessions.length).toBeGreaterThan(0); + }); + + it("does not emit high-cost low-yield findings when the source is only partial", () => { + const result = runBasicAnalysis(makeLowYieldFixture(), "partial"); + + expect(result.optimize.findings.some((finding) => finding.type === "high_cost_low_yield")).toBe( + false + ); + expect(result.yield.lowYieldSessions).toEqual([ + expect.objectContaining({ + sessionId: "sess-expensive-low-yield", + }), + ]); + }); + + it("does not emit high-cost low-yield findings for sessions with parse errors", () => { + const findings = detectOptimizeFindings({ + providers: [], + sessions: [ + { + sessionId: "s-parse-errors", + providerId: "codex", + workspacePath: "/repo/app", + taskType: "debugging", + supportsLowYieldInference: true, + toolUseCount: 1, + parseErrorCount: 2, + hasCommandSignal: false, + hasEditSignal: false, + hasGitSignal: false, + totals: { + inputTokens: 45_000, + outputTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 5_000, + cacheReadInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 50_000, + }, + }, + ], + }); + + expect(findings.some((finding) => finding.type === "high_cost_low_yield")).toBe(false); + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "parse_error_hotspot", + }), + ]) + ); + }); +}); + +function runBasicAnalysis( + sessions: Parameters[0]["sessions"], + providerStatus: "supported" | "partial" = "supported" +): ReturnType { + return analyzeWorkBasic({ + query: { timeRange: { preset: "7d" } }, + timeRange: { + startAt: Date.UTC(2026, 5, 1, 0, 0, 0), + endAt: Date.UTC(2026, 5, 7, 0, 0, 0), + label: "7d", + }, + availableWorkspacePaths: ["/repo/app"], + sessions, + dataSources: { + providers: [ + { + providerId: "codex", + status: providerStatus, + sessionCount: sessions.length, + parseErrorCount: 0, + warningCount: 0, + }, + ], + }, + skillInventory: { + installedSkills: [], + mounts: [], + }, + }); +} + +function makeLowYieldFixture(): Parameters[0]["sessions"] { + return [ + { + sessionId: "sess-expensive-low-yield", + workspacePath: "/repo/app", + providerId: "codex", + modelId: "gpt-5-codex", + startedAt: Date.UTC(2026, 5, 2, 10, 0, 0), + lastActiveAt: Date.UTC(2026, 5, 2, 10, 45, 0), + usage: { + inputTokens: 40_000, + outputTokens: 0, + cacheCreationInputTokens: 10_000, + totalTokens: 50_000, + }, + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "evt-1", + providerId: "codex", + sessionId: "sess-expensive-low-yield", + workspacePath: "/repo/app", + eventType: "message", + canonicalEventType: "message_turn", + occurredAt: Date.UTC(2026, 5, 2, 10, 0, 0), + role: "user", + text: "investigate the codebase", + rawRefs: [], + }, + { + eventId: "evt-2", + providerId: "codex", + sessionId: "sess-expensive-low-yield", + workspacePath: "/repo/app", + eventType: "edit", + canonicalEventType: "edit", + occurredAt: Date.UTC(2026, 5, 2, 10, 10, 0), + text: "updated notes without producing output", + rawRefs: [], + }, + { + eventId: "evt-3", + providerId: "codex", + sessionId: "sess-expensive-low-yield", + workspacePath: "/repo/app", + eventType: "usage", + canonicalEventType: "usage", + occurredAt: Date.UTC(2026, 5, 2, 10, 12, 0), + tokenUsage: { + inputTokens: 40_000, + outputTokens: 0, + cacheCreationInputTokens: 10_000, + totalTokens: 50_000, + }, + rawRefs: [], + }, + ], + }, + ]; +} diff --git a/packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts b/packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts new file mode 100644 index 000000000..2a66e7517 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-efficiency-metrics.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { buildEfficiencyMetrics } from "../work-analysis/metrics/efficiency.js"; + +describe("buildEfficiencyMetrics", () => { + it("computes efficiency scorecards from user turns and all cache usage fields", () => { + const metrics = buildEfficiencyMetrics([ + makeSessionWithEvents({ + events: [ + event("message_turn", { role: "user" }), + event("message_turn", { role: "assistant" }), + event("command"), + event("edit"), + event("git_signal"), + event("usage", { + inputTokens: 75, + totalTokens: 1_000, + cachedInputTokens: 25, + }), + ], + }), + makeSessionWithEvents({ + events: [ + event("message_turn", { role: "user" }), + event("message_turn", { role: "assistant" }), + event("message_turn", { role: "user" }), + event("message_turn", { role: "assistant" }), + event("command"), + event("edit"), + event("usage", { + inputTokens: 50, + totalTokens: 1_000, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + }), + ], + }), + makeSessionWithEvents({ + events: [ + event("message_turn", { role: "user" }), + event("message_turn", { role: "assistant" }), + event("command"), + event("usage", { inputTokens: 200, totalTokens: 1_000 }), + ], + }), + ]); + + expect(metrics).toEqual({ + oneShotRate: 0.333, + retryRate: 0.333, + selfCorrectionRate: 0.333, + readToEditRatio: 2, + commandToEditRatio: 1.5, + cacheHitShare: 0.188, + gitAwareSessionRate: 1, + }); + }); +}); + +function makeSessionWithEvents(input: { id?: string; events: Array> }) { + return { + id: input.id ?? `session-${sessionCounter++}`, + events: input.events, + }; +} + +function event( + canonicalEventType: "message_turn" | "command" | "edit" | "git_signal" | "usage", + options: { + role?: "user" | "assistant"; + inputTokens?: number; + totalTokens?: number; + cachedInputTokens?: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + } = {} +) { + return { + canonicalEventType, + role: options.role, + inputTokens: options.inputTokens ?? 0, + totalTokens: options.totalTokens ?? 0, + cachedInputTokens: options.cachedInputTokens ?? 0, + cacheCreationInputTokens: options.cacheCreationInputTokens ?? 0, + cacheReadInputTokens: options.cacheReadInputTokens ?? 0, + }; +} + +let sessionCounter = 1; diff --git a/packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts b/packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts new file mode 100644 index 000000000..bfd33caf7 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-evidence-sampler.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { sampleWorkLogEvidence } from "../work-analysis/evidence-sampler.js"; +import type { WorkLogSession } from "../work-analysis/log-sources/types.js"; + +function session( + id: string, + providerId: WorkLogSession["providerId"], + lastActiveAt: number +): WorkLogSession { + return { + providerId, + sessionId: id, + workspacePath: "/repo/app", + startedAt: lastActiveAt - 100, + lastActiveAt, + sourceRef: `/logs/${id}`, + title: id, + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit", + evidence: [ + { + providerId, + sessionId: id, + workspacePath: "/repo/app", + startedAt: lastActiveAt - 100, + lastActiveAt, + excerpts: [ + { role: "user", text: "x".repeat(1000) }, + { role: "tool", toolName: "shell", commandKind: "test" }, + ], + }, + ], + }; +} + +describe("sampleWorkLogEvidence", () => { + it("caps excerpts and truncates long text", () => { + const result = sampleWorkLogEvidence({ + sessions: [session("s1", "codex", 100)], + skillInventory: { installedSkills: [], mounts: [] }, + maxSessions: 1, + maxExcerptsPerSession: 1, + maxTextChars: 20, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.excerpts).toHaveLength(1); + expect(result.sessions[0]?.excerpts?.[0]?.text?.length).toBeLessThanOrEqual(20); + }); + + it("keeps provider diversity before filling remaining slots", () => { + const result = sampleWorkLogEvidence({ + sessions: [ + session("old-codex", "codex", 10), + session("new-codex", "codex", 30), + session("claude", "claude", 20), + ], + skillInventory: { installedSkills: [], mounts: [] }, + maxSessions: 2, + maxExcerptsPerSession: 2, + maxTextChars: 100, + }); + + expect(new Set(result.sessions.map((entry) => entry.providerId))).toEqual( + new Set(["codex", "claude"]) + ); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-log-collector.test.ts b/packages/server/src/__tests__/work-analysis-log-collector.test.ts new file mode 100644 index 000000000..2d5de3f66 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-log-collector.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { createWorkLogCollector } from "../work-analysis/log-sources/collector.js"; +import type { ProviderWorkLogSource } from "../work-analysis/log-sources/types.js"; + +function source( + input: Awaited> +): ProviderWorkLogSource { + return { + providerId: input.providerId, + discover: async () => input, + }; +} + +describe("WorkLogCollector", () => { + it("collects sessions without a workspace path allowlist", async () => { + const collector = createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [], + sessions: [ + { + providerId: "codex", + sessionId: "s1", + workspacePath: "/repo/a", + startedAt: 1, + lastActiveAt: 2, + sourceRef: "a", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit", + }, + { + providerId: "codex", + sessionId: "s2", + workspacePath: "/repo/b", + startedAt: 3, + lastActiveAt: 4, + sourceRef: "b", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit", + }, + ], + }), + ], + }); + + const result = await collector.collect({ + timeRange: { startAt: 0, endAt: 10, label: "7d" }, + }); + + expect(result.sessions.map((session) => session.workspacePath)).toEqual(["/repo/a", "/repo/b"]); + }); + + it("runs sources, sorts sessions, and reports provider statuses", async () => { + const collector = createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [ + { providerId: "codex", kind: "file", path: "/b", mtimeMs: 2, sizeBytes: 20 }, + ], + sessions: [ + { + providerId: "codex", + sessionId: "b", + workspacePath: "/repo", + startedAt: 20, + lastActiveAt: 30, + sourceRef: "/b", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit", + }, + ], + }), + source({ + providerId: "claude", + status: "no_logs", + parseErrorCount: 0, + warnings: [], + sourceRefs: [], + sessions: [], + }), + ], + }); + + const result = await collector.collect({ + timeRange: { startAt: 0, endAt: 100, label: "custom" }, + }); + + expect(result.sessions.map((session) => session.sessionId)).toEqual(["b"]); + expect(result.providers.map((provider) => provider.providerId)).toEqual(["codex", "claude"]); + expect(result.sourceDigest).toMatch(/^[a-f0-9]{64}$/); + }); + + it("changes sourceDigest when source refs change", async () => { + const left = await createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [ + { providerId: "codex", kind: "file", path: "/a", mtimeMs: 1, sizeBytes: 10 }, + ], + sessions: [], + }), + ], + }).collect({ timeRange: { startAt: 0, endAt: 1, label: "x" } }); + + const right = await createWorkLogCollector({ + sources: [ + source({ + providerId: "codex", + status: "supported", + parseErrorCount: 0, + warnings: [], + sourceRefs: [ + { providerId: "codex", kind: "file", path: "/a", mtimeMs: 2, sizeBytes: 10 }, + ], + sessions: [], + }), + ], + }).collect({ timeRange: { startAt: 0, endAt: 1, label: "x" } }); + + expect(left.sourceDigest).not.toBe(right.sourceDigest); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts b/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts new file mode 100644 index 000000000..ff3eaa2f4 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-log-source-helpers.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCursorWorkspaceHash, + encodeProviderWorkspacePath, + isWithinRange, + parseOptionalTimestamp, + resolveHomePath, + safeJsonParse, +} from "../work-analysis/log-sources/path-encoding.js"; + +describe("work analysis log source helpers", () => { + it("encodes absolute workspace paths for provider project directories", () => { + expect(encodeProviderWorkspacePath("/home/spencer/workspace/coder-studio")).toBe( + "-home-spencer-workspace-coder-studio" + ); + }); + + it("builds the Cursor md5 workspace hash from the absolute workspace path", () => { + expect(buildCursorWorkspaceHash("/home/spencer/workspace/coder-studio")).toBe( + "cf4c2089ed329fb5e3bba38e6a05f0bc" + ); + }); + + it("parses ISO and numeric timestamps and rejects invalid input", () => { + expect(parseOptionalTimestamp("2026-06-03T00:00:00.000Z")).toBe( + Date.parse("2026-06-03T00:00:00.000Z") + ); + expect(parseOptionalTimestamp(1_770_000_000_000)).toBe(1_770_000_000_000); + expect(parseOptionalTimestamp("1770000000000")).toBe(1_770_000_000_000); + expect(parseOptionalTimestamp(" ")).toBeUndefined(); + expect(parseOptionalTimestamp("not-a-date")).toBeUndefined(); + }); + + it("parses JSON safely without throwing", () => { + expect(safeJsonParse<{ ok: boolean }>('{"ok":true}')?.ok).toBe(true); + expect(safeJsonParse("{bad json")).toBeUndefined(); + }); + + it("expands tilde-prefixed home paths", () => { + expect(resolveHomePath("~/workspace", "/tmp/home")).toBe("/tmp/home/workspace"); + expect(resolveHomePath("~", "/tmp/home")).toBe("/tmp/home"); + expect(resolveHomePath("/tmp/home/workspace", "/ignored")).toBe("/tmp/home/workspace"); + }); + + it("treats overlapping sessions as within range", () => { + expect( + isWithinRange(100, 200, { + startAt: 150, + endAt: 250, + }) + ).toBe(true); + expect( + isWithinRange(100, 120, { + startAt: 121, + endAt: 250, + }) + ).toBe(false); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts b/packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts new file mode 100644 index 000000000..ca573682e --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-log-source-opencode.test.ts @@ -0,0 +1,327 @@ +import { mkdirSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { describe, expect, it } from "vitest"; + +import { createOpenCodeWorkLogSource } from "../work-analysis/log-sources/opencode.js"; + +async function createDbFixture() { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + const dir = join(home, ".local/share/opencode"); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "opencode.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + create table project ( + id text primary key, + worktree text not null, + time_created integer not null, + time_updated integer not null + ); + create table session ( + id text primary key, + project_id text not null, + directory text not null, + title text not null, + version text not null, + summary_files integer, + summary_additions integer, + summary_deletions integer, + time_created integer not null, + time_updated integer not null + ); + create table message ( + id text primary key, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + create table part ( + id text primary key, + message_id text not null, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + insert into project values ('proj-1', '/repo/app', 1000, 3000); + insert into session values ('ses-1', 'proj-1', '/repo/app', 'Fix tests', '1.2.15', 2, 10, 1, 1000, 3000); + insert into message values ('msg-1', 'ses-1', 1000, 1000, '{"role":"user","text":"fix"}'); + insert into message values ('msg-2', 'ses-1', 2000, 3000, '{"role":"assistant","text":"done"}'); + insert into part values ('part-1', 'msg-2', 'ses-1', 2500, 2500, '{"type":"tool","tool":"bash"}'); + `); + db.close(); + return home; +} + +async function createTokenDbFixture() { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + const dir = join(home, ".local/share/opencode"); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "opencode.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + create table project ( + id text primary key, + worktree text not null, + time_created integer not null, + time_updated integer not null + ); + create table session ( + id text primary key, + project_id text not null, + directory text not null, + title text not null, + version text not null, + model_id text, + cost real, + tokens_input integer, + tokens_output integer, + tokens_reasoning integer, + tokens_cache_read integer, + tokens_cache_write integer, + summary_files integer, + summary_additions integer, + summary_deletions integer, + time_created integer not null, + time_updated integer not null + ); + create table message ( + id text primary key, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + create table part ( + id text primary key, + message_id text not null, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + insert into project values ('proj-1', '/repo/app', 1000, 5000); + insert into session values ( + 'ses-1', + 'proj-1', + '/repo/app', + 'Implement tokens', + '0.10.0', + 'anthropic/claude-sonnet-4', + 0.14, + 0, + 0, + 0, + 0, + 0, + 3, + 12, + 1, + 1000, + 5000 + ); + insert into session values ( + 'ses-2', + 'proj-1', + '/repo/app', + 'Session-level tokens', + '0.10.0', + 'openai/gpt-5', + 0.09, + 1000, + 200, + 50, + 60, + 70, + 0, + 0, + 0, + 2000, + 4000 + ); + insert into message values ('msg-1', 'ses-1', 1000, 1000, '{"role":"user","text":"fix"}'); + insert into message values ( + 'msg-2', + 'ses-1', + 2000, + 2000, + '{"role":"assistant","modelID":"anthropic/claude-sonnet-4","tokens":{"input":120,"output":35,"reasoning":9,"cache":{"read":20,"write":30}},"cost":0.05}' + ); + insert into message values ( + 'msg-3', + 'ses-1', + 3000, + 3000, + '{"role":"assistant","model":"anthropic/claude-sonnet-4","usage":{"input_tokens":80,"output_tokens":15,"cache_creation_input_tokens":5,"cache_read_input_tokens":7},"cost":0.03}' + ); + insert into message values ('msg-4', 'ses-2', 3500, 3500, '{"role":"assistant","modelID":"openai/gpt-5"}'); + insert into part values ('part-1', 'msg-2', 'ses-1', 2500, 2500, '{"type":"tool","tool":"bash"}'); + `); + db.close(); + return home; +} + +describe("OpenCode work log source", () => { + it("returns missing_root when the OpenCode database does not exist", async () => { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("missing_root"); + expect(result.sessions).toHaveLength(0); + expect(result.sourceRefs).toHaveLength(0); + }); + + it("returns no_logs when the sqlite query finds no sessions in range", async () => { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + const dir = join(home, ".local/share/opencode"); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "opencode.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + create table project ( + id text primary key, + worktree text not null, + time_created integer not null, + time_updated integer not null + ); + create table session ( + id text primary key, + project_id text not null, + directory text not null, + title text not null, + version text not null, + time_created integer not null, + time_updated integer not null + ); + create table message ( + id text primary key, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + create table part ( + id text primary key, + message_id text not null, + session_id text not null, + time_created integer not null, + time_updated integer not null, + data text not null + ); + `); + db.close(); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("no_logs"); + expect(result.sessions).toHaveLength(0); + }); + + it("returns partial when the OpenCode database has an unsupported schema", async () => { + const home = await mkdtemp(join(tmpdir(), "opencode-home-")); + const dir = join(home, ".local/share/opencode"); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "opencode.db"); + const db = new DatabaseSync(dbPath); + db.exec("create table unrelated (id text primary key);"); + db.close(); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("partial"); + expect(result.sessions).toHaveLength(0); + expect(result.warnings[0]).toMatchObject({ + code: "sqlite_query_failed", + }); + }); + + it("reads sessions from the OpenCode SQLite database by workspace path", async () => { + const home = await createDbFixture(); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + providerId: "opencode", + sessionId: "ses-1", + workspacePath: "/repo/app", + title: "Fix tests", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + timestampQuality: "explicit", + }); + expect(result.sourceRefs[0]).toMatchObject({ + providerId: "opencode", + kind: "sqlite", + }); + }); + + it("extracts OpenCode token usage from assistant messages and session-level totals", async () => { + const home = await createTokenDbFixture(); + + const result = await createOpenCodeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 0, endAt: 5_000, label: "custom" }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(2); + + const messageUsageSession = result.sessions.find((session) => session.sessionId === "ses-1"); + expect(messageUsageSession?.modelId).toBe("anthropic/claude-sonnet-4"); + expect(messageUsageSession?.usage).toEqual({ + inputTokens: 200, + outputTokens: 50, + cachedInputTokens: 27, + cacheCreationInputTokens: 35, + cacheReadInputTokens: 27, + reasoningOutputTokens: 9, + totalTokens: 312, + estimatedCostUsd: 0.08, + }); + expect(messageUsageSession?.usageCoverage).toMatchObject({ + hasUsage: true, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + }); + expect(messageUsageSession?.usageCalls).toHaveLength(2); + expect( + messageUsageSession?.events?.filter((event) => event.eventType === "usage") + ).toHaveLength(2); + + const sessionLevelUsage = result.sessions.find((session) => session.sessionId === "ses-2"); + expect(sessionLevelUsage?.modelId).toBe("openai/gpt-5"); + expect(sessionLevelUsage?.usage).toEqual({ + inputTokens: 1000, + outputTokens: 200, + cachedInputTokens: 60, + cacheCreationInputTokens: 70, + cacheReadInputTokens: 60, + reasoningOutputTokens: 50, + totalTokens: 1330, + estimatedCostUsd: 0.09, + }); + expect(sessionLevelUsage?.usageCalls).toHaveLength(1); + expect(sessionLevelUsage?.usageCalls?.[0]?.kind).toBe("assistant_message"); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts b/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts new file mode 100644 index 000000000..6c8ef89f2 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-log-sources-file-adapters.test.ts @@ -0,0 +1,1265 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createClaudeWorkLogSource } from "../work-analysis/log-sources/claude.js"; +import { createCodexWorkLogSource } from "../work-analysis/log-sources/codex.js"; +import { createCursorWorkLogSource } from "../work-analysis/log-sources/cursor.js"; +import { createGeminiWorkLogSource } from "../work-analysis/log-sources/gemini.js"; + +async function makeHome() { + return await mkdtemp(join(tmpdir(), "work-log-home-")); +} + +describe("file provider work log sources", () => { + it("reads Codex sessions by metadata cwd and time range", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "session.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-session-1", + cwd: "/repo/app", + model_provider: "openai", + git: { branch: "main", commit_hash: "abc123" }, + }, + }), + JSON.stringify({ type: "user_message", payload: { text: "fix tests" } }), + JSON.stringify({ type: "agent_message", payload: { text: "done" } }), + JSON.stringify({ type: "tool_call", payload: { name: "shell" } }), + JSON.stringify({ + timestamp: "2026-06-03T01:10:00.000Z", + type: "event_msg", + event: "token_count", + payload: { + input_tokens: 120, + cached_input_tokens: 40, + output_tokens: 55, + reasoning_output_tokens: 12, + total_tokens: 227, + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + providerId: "codex", + sessionId: "codex-session-1", + workspacePath: "/repo/app", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + gitBranch: "main", + gitCommit: "abc123", + usage: { + inputTokens: 120, + cachedInputTokens: 40, + outputTokens: 55, + reasoningOutputTokens: 12, + totalTokens: 227, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + }); + expect(result.sessions[0]?.usageCalls).toHaveLength(1); + }); + + it("reads Codex token_count events from payload.type records used by current local logs", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "session-payload-type.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-session-payload-type", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:10:00.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 120, + cached_input_tokens: 40, + output_tokens: 55, + reasoning_output_tokens: 12, + total_tokens: 175, + }, + last_token_usage: { + input_tokens: 120, + cached_input_tokens: 40, + output_tokens: 55, + reasoning_output_tokens: 12, + total_tokens: 175, + }, + }, + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + sessionId: "codex-session-payload-type", + usage: { + inputTokens: 120, + cachedInputTokens: 40, + outputTokens: 55, + reasoningOutputTokens: 12, + totalTokens: 175, + }, + }); + }); + + it("limits Codex partial status and parse errors to matched metadata files", async () => { + const home = await makeHome(); + const matchedDir = join(home, ".codex/sessions/2026/06/03"); + const unmatchedDir = join(home, ".codex/sessions/2026/06/04"); + mkdirSync(matchedDir, { recursive: true }); + mkdirSync(unmatchedDir, { recursive: true }); + + writeFileSync( + join(matchedDir, "matched.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { id: "matched-session", cwd: "/repo/app" }, + }), + "{bad json", + JSON.stringify({ type: "user_message", payload: { text: "fix tests" } }), + ].join("\n") + ); + writeFileSync( + join(unmatchedDir, "unmatched.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-04T01:00:00.000Z", + type: "session_meta", + payload: { id: "unmatched-session", cwd: "/repo/other" }, + }), + "{also bad json", + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-05T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("partial"); + expect(result.parseErrorCount).toBe(2); + expect(result.warnings).toHaveLength(2); + expect(result.sessions).toHaveLength(2); + expect(result.sessions.map((session) => session.workspacePath)).toEqual([ + "/repo/app", + "/repo/other", + ]); + }); + + it("uses the first valid Codex JSON line as metadata for workspace attribution", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "session.jsonl"), + [ + "{bad json", + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { id: "wrong-session", cwd: "/repo/other" }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:05:00.000Z", + type: "session_meta", + payload: { id: "later-session", cwd: "/repo/app" }, + }), + JSON.stringify({ + type: "user_message", + payload: { text: "should not match later metadata" }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("partial"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + sessionId: "wrong-session", + workspacePath: "/repo/other", + userTurnCount: 1, + parseErrorCount: 1, + }); + expect(result.parseErrorCount).toBe(1); + }); + + it("returns partial for matched Codex files with parse errors even when they are out of range", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "out-of-range.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { id: "old-session", cwd: "/repo/app" }, + }), + "{bad json", + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-04T00:00:00.000Z"), + endAt: Date.parse("2026-06-05T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("partial"); + expect(result.sessions).toHaveLength(0); + expect(result.parseErrorCount).toBe(1); + expect(result.warnings).toHaveLength(1); + }); + + it("reads Claude sessions from encoded workspace project logs", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-session-1", + cwd: "/repo/app", + gitBranch: "feature", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-session-1", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + usage: { + input_tokens: 300, + output_tokens: 120, + cache_creation_input_tokens: 90, + cache_read_input_tokens: 60, + }, + content: [{ type: "thinking" }, { type: "text" }], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + providerId: "claude", + sessionId: "claude-session-1", + workspacePath: "/repo/app", + userTurnCount: 1, + assistantTurnCount: 1, + gitBranch: "feature", + modelId: "claude-sonnet-4-5", + usage: { + inputTokens: 300, + outputTokens: 120, + cacheCreationInputTokens: 90, + cacheReadInputTokens: 60, + reasoningOutputTokens: 0, + totalTokens: 570, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + }); + expect(result.sessions[0]?.usageCalls).toHaveLength(1); + }); + + it("sums Claude assistant usage calls within a session", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-session-sum", + cwd: "/repo/app", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-session-sum", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + usage: { + input_tokens: 300, + output_tokens: 120, + cache_creation_input_tokens: 90, + cache_read_input_tokens: 60, + }, + content: [{ type: "thinking" }, { type: "text", text: "first" }], + }, + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:10:00.000Z", + sessionId: "claude-session-sum", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + usage: { + input_tokens: 20, + output_tokens: 10, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 15, + }, + content: [{ type: "text", text: "second" }], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "claude-session-sum", + usage: { + inputTokens: 320, + outputTokens: 130, + cacheCreationInputTokens: 95, + cacheReadInputTokens: 75, + reasoningOutputTokens: 0, + totalTokens: 620, + }, + usageCoverage: { + hasUsage: true, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + }, + }); + expect(result.sessions[0]?.usageCalls?.map((call) => call.usage.totalTokens)).toEqual([ + 570, 50, + ]); + }); + + it("extracts skill names from Claude Skill tool_use parts", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude-skill.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-skill-session", + cwd: "/repo/app", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-skill-session", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [ + { + type: "tool_use", + name: "Skill", + input: { + skill: "superpowers:systematic-debugging", + }, + }, + ], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Skill", + toolCategory: "skill", + skillName: "superpowers:systematic-debugging", + payload: { input: { skill: "superpowers:systematic-debugging" } }, + }), + ]) + ); + }); + + it("reads Claude Skill tool_use parts from nested subagent logs", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app/subagents"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "agent-skill.jsonl"), + [ + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-subagent-skill-session", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [ + { + type: "tool_use", + name: "Skill", + input: { + skill: "superpowers:subagent-driven-development", + }, + }, + ], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Skill", + toolCategory: "skill", + skillName: "superpowers:subagent-driven-development", + }), + ]) + ); + }); + + it("normalizes Claude tool blocks into command and edit/read events with file paths", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude-tools.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-tool-events", + cwd: "/repo/app", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-tool-events", + cwd: "/repo/app", + toolUse: { + name: "shell", + command: "pnpm test src/app.test.ts", + }, + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [ + { + type: "tool_use", + name: "Read", + input: { file_path: "src/app.ts" }, + }, + { + type: "tool_use", + name: "Edit", + input: { + file_path: "src/app.ts", + old_string: "before", + new_string: "after", + }, + }, + { type: "text", text: "updated app.ts" }, + ], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "claude-tool-events", + toolUseCount: 3, + }); + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "command", + canonicalEventType: "command", + toolName: "Bash", + toolCategory: "bash", + commandText: "pnpm test src/app.test.ts", + }), + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Read", + toolCategory: "read", + filePath: "src/app.ts", + }), + expect.objectContaining({ + eventType: "edit", + canonicalEventType: "edit", + toolName: "Edit", + toolCategory: "edit", + filePath: "src/app.ts", + }), + ]) + ); + }); + + it("normalizes Claude task and search tool blocks to exact classifier-facing names", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude-task-search.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-task-search", + cwd: "/repo/app", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-task-search", + cwd: "/repo/app", + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [ + { + type: "tool_use", + name: "TodoWrite", + input: { items: [{ content: "verify task names" }] }, + }, + { + type: "tool_use", + name: "WebSearch", + input: { query: "vitest docs" }, + }, + ], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "claude-task-search", + toolUseCount: 2, + }); + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "plan", + canonicalEventType: "plan", + toolName: "TodoWrite", + toolCategory: "task", + }), + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "WebSearch", + toolCategory: "search", + }), + ]) + ); + }); + + it("ignores Claude attachments that are not actual tool calls", async () => { + const home = await makeHome(); + const dir = join(home, ".claude/projects/-repo-app"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "claude-attachments.jsonl"), + [ + JSON.stringify({ + type: "user", + timestamp: "2026-06-03T02:00:00.000Z", + sessionId: "claude-attachments", + cwd: "/repo/app", + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-06-03T02:05:00.000Z", + sessionId: "claude-attachments", + cwd: "/repo/app", + attachment: { + command: "session-start-hook", + note: "not a tool call", + }, + message: { + role: "assistant", + model: "claude-sonnet-4-5", + content: [{ type: "text", text: "no tool use happened" }], + }, + }), + ].join("\n") + ); + + const result = await createClaudeWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "claude-attachments", + toolUseCount: 0, + }); + expect(result.sessions[0]?.events?.filter((event) => event.role === "tool")).toEqual([]); + }); + + it("derives Codex session usage from token_count deltas when cumulative totals are reported", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "delta-session.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-delta-1", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:10:00.000Z", + type: "event_msg", + event: "token_count", + payload: { + info: { + last_token_usage: { + input_tokens: 120, + cached_input_tokens: 40, + output_tokens: 55, + reasoning_output_tokens: 12, + total_tokens: 227, + }, + total_token_usage: { + input_tokens: 120, + cached_input_tokens: 40, + output_tokens: 55, + reasoning_output_tokens: 12, + total_tokens: 227, + }, + }, + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:20:00.000Z", + type: "event_msg", + event: "token_count", + payload: { + info: { + total_token_usage: { + input_tokens: 150, + cached_input_tokens: 50, + output_tokens: 65, + reasoning_output_tokens: 15, + total_tokens: 280, + }, + }, + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "codex-delta-1", + usage: { + inputTokens: 150, + cachedInputTokens: 50, + outputTokens: 65, + reasoningOutputTokens: 15, + totalTokens: 280, + }, + usageCoverage: { + hasUsage: true, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + }, + }); + expect(result.sessions[0]?.usageCalls?.map((call) => call.usage.totalTokens)).toEqual([ + 227, 53, + ]); + }); + + it("normalizes Codex parsed commands and custom tool calls into richer turn signals", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "tool-events.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-tool-events", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:01:00.000Z", + type: "tool_call", + payload: { + name: "shell", + text: "pnpm test src/app.test.ts", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:02:00.000Z", + type: "event_msg", + payload: { + type: "exec_command_end", + parsed_cmd: [ + { + type: "read", + cmd: "cat src/app.ts", + name: "app.ts", + path: "src/app.ts", + }, + ], + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:03:00.000Z", + type: "response_item", + payload: { + type: "custom_tool_call", + name: "apply_patch", + input: [ + "*** Begin Patch", + "*** Update File: src/app.ts", + "@@", + "-before", + "+after", + "*** End Patch", + ].join("\n"), + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "codex-tool-events", + toolUseCount: 3, + }); + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "command", + canonicalEventType: "command", + toolName: "Bash", + toolCategory: "bash", + commandText: "pnpm test src/app.test.ts", + }), + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Read", + toolCategory: "read", + filePath: "src/app.ts", + commandText: "cat src/app.ts", + }), + expect.objectContaining({ + eventType: "edit", + canonicalEventType: "edit", + toolName: "Edit", + toolCategory: "edit", + filePath: "src/app.ts", + }), + ]) + ); + }); + + it("extracts skill names from Codex Skill tool payloads", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "skill-events.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-skill-events", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:01:00.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "Skill", + input: { + skill: "frontend-design", + }, + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Skill", + toolCategory: "skill", + skillName: "frontend-design", + payload: { input: { skill: "frontend-design" } }, + }), + ]) + ); + }); + + it("normalizes Codex exec_command function calls without double-counting parsed command echoes", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "exec-command-events.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-exec-command-events", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:01:00.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: JSON.stringify({ + cmd: "pnpm test src/app.test.ts", + }), + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:01:01.000Z", + type: "event_msg", + payload: { + type: "exec_command_end", + parsed_cmd: [ + { + type: "exec_command", + cmd: "pnpm test src/app.test.ts", + }, + ], + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "codex-exec-command-events", + toolUseCount: 1, + }); + expect( + result.sessions[0]?.events?.filter( + (event) => + event.eventType === "command" && event.commandText === "pnpm test src/app.test.ts" + ) + ).toEqual([ + expect.objectContaining({ + eventType: "command", + canonicalEventType: "command", + toolName: "Bash", + toolCategory: "bash", + commandText: "pnpm test src/app.test.ts", + }), + ]); + }); + + it("extracts file paths from Codex edit_file payload inputs", async () => { + const home = await makeHome(); + const dir = join(home, ".codex/sessions/2026/06/03"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "edit-file-events.jsonl"), + [ + JSON.stringify({ + timestamp: "2026-06-03T01:00:00.000Z", + type: "session_meta", + payload: { + id: "codex-edit-file-events", + cwd: "/repo/app", + model: "gpt-5-codex", + }, + }), + JSON.stringify({ + timestamp: "2026-06-03T01:01:00.000Z", + type: "response_item", + payload: { + type: "custom_tool_call", + name: "edit_file", + input: { + file_path: "src/app.ts", + old_string: "before", + new_string: "after", + }, + }, + }), + ].join("\n") + ); + + const result = await createCodexWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.sessions[0]).toMatchObject({ + sessionId: "codex-edit-file-events", + toolUseCount: 1, + }); + expect(result.sessions[0]?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: "edit", + canonicalEventType: "edit", + toolName: "Edit", + toolCategory: "edit", + filePath: "src/app.ts", + }), + ]) + ); + }); + + it("reads Gemini chats by .project_root, keeps evidence excerpts, and deduplicates tmp/history", async () => { + const home = await makeHome(); + const tmpDir = join(home, ".gemini/tmp/app"); + const historyDir = join(home, ".gemini/history/app"); + mkdirSync(join(tmpDir, "chats"), { recursive: true }); + mkdirSync(join(historyDir, "chats"), { recursive: true }); + writeFileSync(join(tmpDir, ".project_root"), "/repo/app"); + writeFileSync(join(historyDir, ".project_root"), "/repo/app"); + const chatJson = JSON.stringify({ + kind: "chat", + sessionId: "gemini-session-1", + startTime: "2026-06-03T03:00:00.000Z", + lastUpdated: "2026-06-03T03:10:00.000Z", + summary: "Fix tests", + messages: [ + { + type: "user", + timestamp: "2026-06-03T03:00:00.000Z", + content: [{ text: "fix failing tests" }], + }, + { + type: "assistant", + timestamp: "2026-06-03T03:10:00.000Z", + content: [{ text: "implemented the fix" }], + }, + ], + }); + writeFileSync(join(tmpDir, "chats/session-2026-06-03T01-00-abcd.json"), chatJson); + writeFileSync(join(historyDir, "chats/session-2026-06-03T01-00-abcd.json"), chatJson); + + const result = await createGeminiWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]).toMatchObject({ + providerId: "gemini", + sessionId: "gemini-session-1", + title: "Fix tests", + userTurnCount: 1, + assistantTurnCount: 1, + }); + expect(result.sessions[0]?.evidence?.[0]?.excerpts).toMatchObject([ + { role: "user", text: "fix failing tests" }, + { role: "assistant", text: "implemented the fix" }, + ]); + }); + + it("reads Gemini chats whose message content is stored as plain strings", async () => { + const home = await makeHome(); + const tmpDir = join(home, ".gemini/tmp/string-content"); + mkdirSync(join(tmpDir, "chats"), { recursive: true }); + writeFileSync(join(tmpDir, ".project_root"), "/repo/app"); + writeFileSync( + join(tmpDir, "chats/session-2026-06-03T02-00-string.json"), + JSON.stringify({ + kind: "chat", + sessionId: "gemini-string-content", + startTime: "2026-06-03T04:00:00.000Z", + lastUpdated: "2026-06-03T04:05:00.000Z", + summary: "String content", + messages: [ + { + type: "user", + timestamp: "2026-06-03T04:00:00.000Z", + content: [{ text: "fix parser" }], + }, + { + type: "gemini", + timestamp: "2026-06-03T04:05:00.000Z", + content: "handled string content", + }, + ], + }) + ); + + const result = await createGeminiWorkLogSource({ home }).discover({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.parse("2026-06-03T00:00:00.000Z"), + endAt: Date.parse("2026-06-04T00:00:00.000Z"), + label: "custom", + }, + }); + + expect(result.status).toBe("supported"); + expect(result.sessions[0]).toMatchObject({ + providerId: "gemini", + sessionId: "gemini-string-content", + userTurnCount: 1, + assistantTurnCount: 1, + }); + expect(result.sessions[0]?.evidence?.[0]?.excerpts).toMatchObject([ + { role: "user", text: "fix parser" }, + { role: "assistant", text: "handled string content" }, + ]); + }); + + it("reads Cursor transcripts by encoded workspace with flexible jsonl filenames and reports mtime timestamp quality", async () => { + const home = await makeHome(); + const dir = join(home, ".cursor/projects/-repo-app/agent-transcripts/cursor-session-1"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "transcript-42.jsonl"), + [ + JSON.stringify({ + role: "user", + cwd: "/repo/app", + message: { content: [{ type: "text", text: "fix" }] }, + }), + JSON.stringify({ + role: "assistant", + cwd: "/repo/app", + message: { content: [{ type: "tool_call", name: "shell" }] }, + }), + ].join("\n") + ); + + const result = await createCursorWorkLogSource({ home }).discover({ + timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" }, + }); + + expect(result.sessions[0]).toMatchObject({ + providerId: "cursor", + sessionId: "transcript-42", + workspacePath: "/repo/app", + timestampQuality: "file_mtime", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + }); + }); + + it("skips Cursor transcripts that do not expose a workspace path in the log records", async () => { + const home = await makeHome(); + const encodedPath = "-tmp-a-b-c"; + const dir = join(home, `.cursor/projects/${encodedPath}/agent-transcripts/cursor-session-1`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "transcript.jsonl"), + [ + JSON.stringify({ role: "user", message: { content: [{ type: "text", text: "fix" }] } }), + ].join("\n") + ); + + const result = await createCursorWorkLogSource({ home }).discover({ + timeRange: { startAt: 0, endAt: Date.now() + 60_000, label: "custom" }, + }); + + expect(result.status).toBe("no_logs"); + expect(result.sessions).toHaveLength(0); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-query.test.ts b/packages/server/src/__tests__/work-analysis-query.test.ts new file mode 100644 index 000000000..1376fa5cf --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-query.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + buildWorkAnalysisQueryDigest, + normalizeWorkAnalysisQuery, + resolveWorkAnalysisTimeRange, +} from "../work-analysis/query.js"; + +describe("work analysis query helpers", () => { + it("sorts and de-duplicates workspace paths during normalization", () => { + expect( + normalizeWorkAnalysisQuery({ + workspacePaths: ["/repo/b", "/repo/a", "/repo/a"], + timeRange: { preset: "7d" }, + }) + ).toEqual({ + workspacePaths: ["/repo/a", "/repo/b"], + timeRange: { preset: "7d" }, + }); + }); + + it("resolves preset ranges relative to now", () => { + expect(resolveWorkAnalysisTimeRange({ preset: "24h" }, 10_000)).toEqual({ + startAt: 10_000 - 24 * 60 * 60 * 1000, + endAt: 10_000, + label: "24h", + }); + }); + + it("passes through custom ranges unchanged", () => { + expect( + resolveWorkAnalysisTimeRange( + { + startAt: 1_000, + endAt: 2_000, + }, + 10_000 + ) + ).toEqual({ + startAt: 1_000, + endAt: 2_000, + label: "1000-2000", + }); + }); + + it("builds a stable digest for equivalent queries", () => { + const left = buildWorkAnalysisQueryDigest({ + workspacePaths: ["/repo/b", "/repo/a", "/repo/a"], + timeRange: { preset: "30d" }, + }); + const right = buildWorkAnalysisQueryDigest({ + workspacePaths: ["/repo/a", "/repo/b"], + timeRange: { preset: "30d" }, + }); + + expect(left).toBe(right); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-repo.test.ts b/packages/server/src/__tests__/work-analysis-repo.test.ts new file mode 100644 index 000000000..349554b7f --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-repo.test.ts @@ -0,0 +1,397 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { WorkAnalysisRepo } from "../storage/repositories/work-analysis-repo.js"; + +describe("WorkAnalysisRepo", () => { + it("stores hourly analysis cache in a SQLite database file instead of JSON text", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const filePath = join(dir, "work-analysis.sqlite"); + const repo = new WorkAnalysisRepo({ filePath }); + + repo.upsertHourlyIndex({ + version: 1, + bucketMode: "hourly_session_slices", + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 2), + sourceDigest: "hourly-source", + providerStatuses: [], + buckets: [], + }); + + const file = readFileSync(filePath); + expect(file.subarray(0, 16).toString("utf8")).toBe("SQLite format 3\0"); + expect(() => JSON.parse(file.toString("utf8"))).toThrow(); + expect(new WorkAnalysisRepo({ filePath }).findHourlyIndex()).toMatchObject({ + sourceDigest: "hourly-source", + buckets: [], + }); + }); + + it("closes the SQLite connection and can reopen the repository", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const filePath = join(dir, "work-analysis.sqlite"); + const repo = new WorkAnalysisRepo({ filePath }); + + repo.upsert({ + id: "analysis-1", + queryDigest: "digest-1", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + }); + + repo.close(); + repo.close(); + + const reloaded = new WorkAnalysisRepo({ filePath }); + expect(reloaded.findByQueryDigest("digest-1")).toMatchObject({ + id: "analysis-1", + queryDigest: "digest-1", + }); + reloaded.close(); + }); + + it("persists and clears the hourly dashboard index", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const repo = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + + repo.upsertHourlyIndex({ + version: 1, + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 3), + sourceDigest: "hourly-source", + providerStatuses: [ + { + providerId: "codex", + status: "supported", + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 2), + sessions: [ + { + providerId: "codex", + sessionId: "codex-1", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 15), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 45), + sourceRef: "codex-1", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit", + usage: { + inputTokens: 80, + outputTokens: 20, + totalTokens: 100, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + events: [], + }, + ], + }, + ], + }); + + const reloaded = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + expect(reloaded.findHourlyIndex()).toMatchObject({ + sourceDigest: "hourly-source", + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 2), + sessions: [ + { + sessionId: "codex-1", + usage: { totalTokens: 100 }, + }, + ], + }, + ], + }); + + reloaded.clearAnalysisCache(); + + expect(reloaded.findHourlyIndex()).toBeUndefined(); + expect(reloaded.findByQueryDigest("missing")).toBeUndefined(); + }); + + it("compacts hourly index event details before persisting", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const filePath = join(dir, "work-analysis.sqlite"); + const repo = new WorkAnalysisRepo({ filePath }); + const longText = "debug ".repeat(400); + const longCommand = "pnpm test ".repeat(400); + + repo.upsertHourlyIndex({ + version: 1, + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 3), + sourceDigest: "hourly-source", + providerStatuses: [ + { + providerId: "codex", + status: "supported", + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 2), + sessions: [ + { + providerId: "codex", + sessionId: "codex-1", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 15), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 45), + sourceRef: "codex-1", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit", + events: [ + { + eventId: "skill-1", + providerId: "codex", + sessionId: "codex-1", + workspacePath: "/repo/app", + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Skill", + toolCategory: "skill", + text: longText, + commandText: longCommand, + payload: { input: { skill: "frontend-design", transcript: "x".repeat(8_000) } }, + evidence: ["y".repeat(8_000)], + rawRefs: ["codex-1"], + }, + ], + }, + ], + }, + ], + }); + + const event = new WorkAnalysisRepo({ filePath }).findHourlyIndex()?.buckets[0]?.sessions[0] + ?.events?.[0]; + expect(event).toMatchObject({ skillName: "frontend-design" }); + expect(event?.payload).toBeUndefined(); + expect(event?.evidence).toBeUndefined(); + expect(event?.text?.length).toBeLessThan(longText.length); + expect(event?.commandText?.length).toBeLessThan(longCommand.length); + }); + + it("persists hourly index provider warning details", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const repo = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + + repo.upsertHourlyIndex({ + version: 1, + bucketMode: "hourly_session_slices", + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 2), + sourceDigest: "hourly-source", + providerStatuses: [ + { + providerId: "opencode", + status: "partial", + sessionCount: 0, + parseErrorCount: 0, + warningCount: 1, + warnings: [ + { + code: "sqlite_query_failed", + message: "Failed to query OpenCode SQLite database", + sourceRef: "/home/user/.local/share/opencode/opencode.db", + }, + ], + }, + ], + buckets: [], + }); + + const reloaded = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + + expect(reloaded.findHourlyIndex()).toMatchObject({ + providerStatuses: [ + { + providerId: "opencode", + warnings: [ + { + code: "sqlite_query_failed", + message: "Failed to query OpenCode SQLite database", + sourceRef: "/home/user/.local/share/opencode/opencode.db", + }, + ], + }, + ], + }); + }); + + it("persists and reloads a record by query digest", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const repo = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + + repo.upsert({ + id: "analysis-1", + queryDigest: "digest-1", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + basicResult: { + availableWorkspacePaths: ["/repo/app"], + coverage: { workspaceCount: 1 }, + workSurface: { workspacePaths: ["/repo/app"] }, + }, + sourceSnapshot: { + sourceDigest: "digest-source", + collectedAt: 1_234, + providerStatuses: [ + { + providerId: "codex", + status: "supported", + sessionCount: 1, + parseErrorCount: 0, + }, + ], + }, + }); + + const reloaded = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + expect(reloaded.findByQueryDigest("digest-1")).toMatchObject({ + id: "analysis-1", + basicStatus: "succeeded", + sourceSnapshot: { + sourceDigest: "digest-source", + collectedAt: 1_234, + providerStatuses: [ + { + providerId: "codex", + status: "supported", + sessionCount: 1, + parseErrorCount: 0, + }, + ], + }, + }); + }); + + it("returns undefined for a missing query digest and overwrites existing records on upsert", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const repo = new WorkAnalysisRepo({ filePath: join(dir, "work-analysis.sqlite") }); + + expect(repo.findByQueryDigest("missing-digest")).toBeUndefined(); + + repo.upsert({ + id: "analysis-1", + queryDigest: "digest-1", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" }, + basicStatus: "running", + deepStatus: "idle", + }); + + repo.upsert({ + id: "analysis-2", + queryDigest: "digest-1", + workspacePaths: ["/repo/app", "/repo/lib"], + timeRange: { preset: "30d" }, + basicStatus: "succeeded", + deepStatus: "failed", + }); + + expect(repo.findByQueryDigest("digest-1")).toMatchObject({ + id: "analysis-2", + workspacePaths: ["/repo/app", "/repo/lib"], + basicStatus: "succeeded", + deepStatus: "failed", + }); + }); + + it("ignores malformed persisted records when loading", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const filePath = join(dir, "work-analysis.sqlite"); + const legacyJsonFilePath = join(dir, "work-analysis.json"); + + writeFileSync( + legacyJsonFilePath, + JSON.stringify({ + version: 1, + records: { + "digest-valid": { + id: "analysis-valid", + queryDigest: "digest-valid", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + }, + "digest-invalid": { + id: 123, + queryDigest: "digest-invalid", + workspacePaths: ["/repo/lib"], + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + }, + }, + }), + "utf-8" + ); + + const repo = new WorkAnalysisRepo({ filePath, legacyJsonFilePath }); + + expect(repo.findByQueryDigest("digest-valid")).toMatchObject({ + id: "analysis-valid", + queryDigest: "digest-valid", + }); + expect(repo.findByQueryDigest("digest-invalid")).toBeUndefined(); + }); + + it("ignores persisted records whose outer key disagrees with queryDigest", () => { + const dir = mkdtempSync(join(tmpdir(), "work-analysis-repo-")); + const filePath = join(dir, "work-analysis.sqlite"); + const legacyJsonFilePath = join(dir, "work-analysis.json"); + + writeFileSync( + legacyJsonFilePath, + JSON.stringify({ + version: 1, + records: { + "outer-digest": { + id: "analysis-1", + queryDigest: "inner-digest", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" }, + basicStatus: "succeeded", + deepStatus: "idle", + }, + }, + }), + "utf-8" + ); + + const repo = new WorkAnalysisRepo({ filePath, legacyJsonFilePath }); + + expect(repo.findByQueryDigest("outer-digest")).toBeUndefined(); + expect(repo.findByQueryDigest("inner-digest")).toBeUndefined(); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-retry-metrics.test.ts b/packages/server/src/__tests__/work-analysis-retry-metrics.test.ts new file mode 100644 index 000000000..ef53349ee --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-retry-metrics.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeOneShot } from "../work-analysis/metrics/one-shot.js"; +import { countTurnRetries } from "../work-analysis/metrics/retry.js"; + +describe("work analysis retry metrics", () => { + it("counts a same-file edit shell edit sequence as one retry", () => { + expect( + countTurnRetries([ + { tool: "Edit", file: "src/foo.ts" }, + { tool: "Bash", command: "pnpm test" }, + { tool: "Edit", file: "src/foo.ts" }, + ]) + ).toBe(1); + }); + + it("does not treat editing different files across shell steps as a retry", () => { + expect( + countTurnRetries([ + { tool: "Edit", file: "src/foo.ts" }, + { tool: "Bash", command: "pnpm test" }, + { tool: "Edit", file: "src/bar.ts" }, + ]) + ).toBe(0); + }); + + it("counts multiple same-file retry cycles within a turn", () => { + expect( + countTurnRetries([ + { tool: "Edit", file: "src/foo.ts" }, + { tool: "Bash", command: "pnpm test foo" }, + { tool: "Edit", file: "src/foo.ts" }, + { tool: "Bash", command: "pnpm test foo" }, + { tool: "Edit", file: "src/foo.ts" }, + ]) + ).toBe(2); + }); + + it("summarizes one-shot and retries per edit turn", () => { + const summary = summarizeOneShot([ + { hasEdits: true, retries: 0 }, + { hasEdits: true, retries: 2 }, + { hasEdits: false, retries: 0 }, + ]); + + expect(summary).toEqual({ + editTurnCount: 2, + oneShotTurnCount: 1, + retryTurnCount: 1, + oneShotRate: 0.5, + retryRate: 1, + }); + }); + + it("returns zero rates when there are no edit turns", () => { + expect(summarizeOneShot([{ hasEdits: false, retries: 3 }])).toEqual({ + editTurnCount: 0, + oneShotTurnCount: 0, + retryTurnCount: 0, + oneShotRate: 0, + retryRate: 0, + }); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-service.test.ts b/packages/server/src/__tests__/work-analysis-service.test.ts new file mode 100644 index 000000000..6efabb858 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-service.test.ts @@ -0,0 +1,1571 @@ +import { describe, expect, it, vi } from "vitest"; + +import { WorkAnalysisService } from "../work-analysis/service.js"; +import type { WorkAnalysisHourlyIndex } from "../work-analysis/types.js"; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +describe("WorkAnalysisService", () => { + it("refreshes a dashboard projection with token trend and contribution rankings", async () => { + const collect = vi.fn(async () => ({ + sourceDigest: "source-dashboard", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + { + providerId: "claude" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-1", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 1, 10, 10), + lastActiveAt: Date.UTC(2026, 5, 1, 10, 40), + sourceRef: "codex-1", + modelId: "gpt-5-codex", + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 1, + usage: { + inputTokens: 800, + outputTokens: 150, + reasoningOutputTokens: 50, + totalTokens: 1_000, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "codex-skill-1", + providerId: "codex" as const, + sessionId: "codex-1", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 1, 10, 20), + toolName: "Skill", + toolCategory: "skill" as const, + payload: { input: { skill: "frontend-design" } }, + rawRefs: ["codex-1"], + }, + { + eventId: "codex-read-1", + providerId: "codex" as const, + sessionId: "codex-1", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 1, 10, 22), + toolName: "Read", + toolCategory: "read" as const, + rawRefs: ["codex-1"], + }, + { + eventId: "codex-skill-2", + providerId: "codex" as const, + sessionId: "codex-1", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 1, 10, 25), + toolName: "Skill", + toolCategory: "skill" as const, + payload: { arguments: JSON.stringify({ skill: "frontend-design" }) }, + rawRefs: ["codex-1"], + }, + ], + }, + { + providerId: "claude" as const, + sessionId: "claude-1", + workspacePath: "/repo/lib", + startedAt: Date.UTC(2026, 5, 1, 11, 5), + lastActiveAt: Date.UTC(2026, 5, 1, 12, 5), + sourceRef: "claude-1", + modelId: "sonnet-4.5", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 400, + outputTokens: 80, + cacheReadInputTokens: 20, + totalTokens: 500, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "claude-skill-1", + providerId: "claude" as const, + sessionId: "claude-1", + workspacePath: "/repo/lib", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 1, 11, 25), + toolName: "Skill", + toolCategory: "skill" as const, + payload: { input: { skill: "superpowers:systematic-debugging" } }, + rawRefs: ["claude-1"], + }, + ], + }, + ], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 6, 10), + }); + + const dashboard = await service.refreshDashboard({ timeRange: { preset: "7d" } }, "manual"); + + expect(dashboard.scanState.status).toBe("succeeded"); + expect(dashboard.dashboard.kpis.find((item) => item.key === "totalTokens")?.value).toBe(1_500); + expect(dashboard.dashboard.trends.tokenHourly).toEqual([ + expect.objectContaining({ + hourStart: Date.UTC(2026, 5, 1, 10), + totalTokens: 1_000, + sessionCount: 1, + }), + expect.objectContaining({ + hourStart: Date.UTC(2026, 5, 1, 11), + totalTokens: 500, + sessionCount: 1, + }), + ]); + expect(dashboard.dashboard.rankings.projects.map((entry) => entry.label)).toEqual([ + "/repo/app", + "/repo/lib", + ]); + expect(dashboard.dashboard.rankings.models.map((entry) => entry.label)).toEqual([ + "codex / gpt-5-codex", + "claude / sonnet-4.5", + ]); + expect(dashboard.dashboard.rankings.agents.map((entry) => entry.label)).toEqual([ + "codex", + "claude", + ]); + expect(dashboard.dashboard.breakdowns.skills).toEqual([ + expect.objectContaining({ + key: "frontend-design", + label: "frontend-design", + callCount: 2, + sessionCount: 1, + shareOfCalls: 2 / 3, + providerIds: ["codex"], + }), + expect.objectContaining({ + key: "superpowers:systematic-debugging", + label: "superpowers:systematic-debugging", + callCount: 1, + sessionCount: 1, + shareOfCalls: 1 / 3, + providerIds: ["claude"], + }), + ]); + expect(dashboard.dashboard.breakdowns.tools.map((tool) => tool.key)).not.toContain("Skill"); + expect(dashboard.mode).toBe("manual"); + expect(dashboard.scanState.sourceDigest).toBe("source-dashboard"); + }); + + it("only promotes provider parse failures to dashboard warnings", async () => { + const collect = vi.fn(async () => ({ + sourceDigest: "source-quality", + providers: [ + { + providerId: "codex" as const, + status: "no_logs" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + { + providerId: "gemini" as const, + status: "missing_root" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + { + providerId: "opencode" as const, + status: "unsupported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [ + { + code: "sqlite_unavailable", + message: "sqlite3 CLI is unavailable", + }, + ], + }, + { + providerId: "cursor" as const, + status: "partial" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 2, + warnings: [ + { + code: "parse_error", + message: "Failed to parse Cursor transcript", + }, + ], + }, + ], + sessions: [], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 6, 10), + }); + + const dashboard = await service.refreshDashboard({ timeRange: { preset: "7d" } }, "manual"); + + expect(dashboard.dashboard.quality.providers.map((provider) => provider.status)).toEqual([ + "no_logs", + "missing_root", + "unsupported", + "partial", + ]); + expect( + dashboard.scanState.providerStatuses.find((provider) => provider.providerId === "cursor") + ).toMatchObject({ + warnings: [ + { + code: "parse_error", + message: "Failed to parse Cursor transcript", + }, + ], + }); + expect(dashboard.dashboard.quality.warnings).toEqual([ + "cursor: Failed to parse Cursor transcript", + ]); + }); + + it("builds a filtered dashboard from the hourly index without rescanning provider logs", async () => { + const collect = vi.fn(); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => ({ + version: 1, + bucketMode: "hourly_session_slices", + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 3), + sourceDigest: "hourly-index-1", + providerStatuses: [ + { + providerId: "codex", + status: "supported", + sessionCount: 2, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 1), + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-app", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 1, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 1, 40), + sourceRef: "codex-app", + modelId: "gpt-5-codex", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + usage: { + inputTokens: 800, + outputTokens: 200, + totalTokens: 1_000, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + { + providerId: "codex" as const, + sessionId: "codex-lib", + workspacePath: "/repo/lib", + startedAt: Date.UTC(2026, 5, 7, 1, 20), + lastActiveAt: Date.UTC(2026, 5, 7, 1, 50), + sourceRef: "codex-lib", + modelId: "gpt-5-codex", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 400, + outputTokens: 100, + totalTokens: 500, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + }, + ], + })), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.getDashboard({ + workspacePaths: ["/repo/app"], + timeRange: { + startAt: Date.UTC(2026, 5, 7, 1, 30), + endAt: Date.UTC(2026, 5, 7, 1, 45), + }, + }); + + expect(collect).not.toHaveBeenCalled(); + expect(dashboard.scanState.sourceDigest).toBe("hourly-index-1"); + expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(1_000); + expect(dashboard.dashboard?.rankings.projects).toEqual([ + expect.objectContaining({ + label: "/repo/app", + totalTokens: 1_000, + }), + ]); + }); + + it("projects dashboard results directly from the hourly index", async () => { + const repo = { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => ({ + version: 1, + bucketMode: "hourly_session_slices" as const, + indexedAt: Date.UTC(2026, 5, 7, 3), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 2), + sourceDigest: "hourly-index-live", + providerStatuses: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 2), + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-live", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 30), + sourceRef: "codex-live", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 123, + totalTokens: 123, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + }, + ], + })), + }; + const service = new WorkAnalysisService({ + repo, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect: vi.fn() }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.getDashboard({ timeRange: { preset: "24h" } }); + + expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(123); + }); + + it("refreshes the hourly index from the next unindexed hour before projecting the dashboard", async () => { + let hourlyIndex = { + version: 1 as const, + bucketMode: "hourly_session_slices" as const, + indexedAt: Date.UTC(2026, 5, 7, 2), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 1), + sourceDigest: "hourly-index-old", + providerStatuses: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 1), + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-1", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 1, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 1, 20), + sourceRef: "codex-1", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 100, + outputTokens: 0, + totalTokens: 100, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + }, + ], + }; + const collect = vi.fn(async () => ({ + sourceDigest: "source-new-hours", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-2", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 40), + sourceRef: "codex-2", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 200, + outputTokens: 0, + totalTokens: 200, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + { + providerId: "codex" as const, + sessionId: "codex-3", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 3, 5), + lastActiveAt: Date.UTC(2026, 5, 7, 3, 15), + sourceRef: "codex-3", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 300, + outputTokens: 0, + totalTokens: 300, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + })); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => hourlyIndex), + upsertHourlyIndex: vi.fn((nextIndex) => { + hourlyIndex = nextIndex; + return nextIndex; + }), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.refreshDashboard({ timeRange: { preset: "24h" } }, "manual"); + + expect(collect).toHaveBeenCalledWith({ + workspacePaths: [], + timeRange: { + startAt: Date.UTC(2026, 5, 7, 2), + endAt: Date.UTC(2026, 5, 7, 3, 30), + label: "incremental", + }, + }); + expect(hourlyIndex.buckets.map((bucket) => bucket.hourStart)).toEqual([ + Date.UTC(2026, 5, 7, 1), + Date.UTC(2026, 5, 7, 2), + Date.UTC(2026, 5, 7, 3), + ]); + expect(hourlyIndex.indexedThroughHourStart).toBe(Date.UTC(2026, 5, 7, 2)); + expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(600); + }); + + it("rebuilds the previously partial hour after crossing into the next hour", async () => { + let hourlyIndex = { + version: 1 as const, + bucketMode: "hourly_session_slices" as const, + indexedAt: Date.UTC(2026, 5, 7, 3, 45), + indexedThroughHourStart: Date.UTC(2026, 5, 7, 3), + sourceDigest: "hourly-index-partial", + providerStatuses: [ + { + providerId: "codex", + status: "supported" as const, + sessionCount: 1, + parseErrorCount: 0, + warningCount: 0, + }, + ], + buckets: [ + { + hourStart: Date.UTC(2026, 5, 7, 2), + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-2", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 20), + sourceRef: "codex-2", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 100, + totalTokens: 100, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + }, + { + hourStart: Date.UTC(2026, 5, 7, 3), + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-3-old", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 3, 20), + lastActiveAt: Date.UTC(2026, 5, 7, 3, 30), + sourceRef: "codex-3-old", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 200, + totalTokens: 200, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + }, + ], + }; + const collect = vi.fn(async () => ({ + sourceDigest: "source-after-partial", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "codex-3-new", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 3, 55), + lastActiveAt: Date.UTC(2026, 5, 7, 3, 58), + sourceRef: "codex-3-new", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 300, + totalTokens: 300, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + { + providerId: "codex" as const, + sessionId: "codex-4", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 4, 5), + lastActiveAt: Date.UTC(2026, 5, 7, 4, 8), + sourceRef: "codex-4", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 400, + totalTokens: 400, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + })); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => hourlyIndex), + upsertHourlyIndex: vi.fn((nextIndex) => { + hourlyIndex = nextIndex; + return nextIndex; + }), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 4, 10), + }); + + await service.refreshDashboard({ timeRange: { preset: "24h" } }, "manual"); + + expect(collect).toHaveBeenCalledWith({ + workspacePaths: [], + timeRange: { + startAt: Date.UTC(2026, 5, 7, 3), + endAt: Date.UTC(2026, 5, 7, 4, 10), + label: "incremental", + }, + }); + expect(hourlyIndex.buckets.map((bucket) => bucket.hourStart)).toEqual([ + Date.UTC(2026, 5, 7, 2), + Date.UTC(2026, 5, 7, 3), + Date.UTC(2026, 5, 7, 4), + ]); + expect( + hourlyIndex.buckets + .find((bucket) => bucket.hourStart === Date.UTC(2026, 5, 7, 3)) + ?.sessions.map((session) => session.sessionId) + ).toEqual(["codex-3-new"]); + }); + + it("attributes indexed usage to event hours when a session spans multiple hours", async () => { + let hourlyIndex: WorkAnalysisHourlyIndex | undefined; + const collect = vi.fn(async () => ({ + sourceDigest: "source-spanning-session", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "spanning-session", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 1, 50), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 20), + sourceRef: "spanning-session", + userTurnCount: 2, + assistantTurnCount: 2, + toolUseCount: 2, + usage: { + inputTokens: 300, + totalTokens: 300, + }, + usageCoverage: { + hasUsage: true, + callCount: 2, + callsWithTotalTokens: 2, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "hour-1-read", + providerId: "codex" as const, + sessionId: "spanning-session", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 7, 1, 55), + toolName: "Read", + toolCategory: "read" as const, + tokenUsage: { + inputTokens: 100, + totalTokens: 100, + }, + rawRefs: ["spanning-session"], + }, + { + eventId: "hour-2-bash", + providerId: "codex" as const, + sessionId: "spanning-session", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 7, 2, 10), + toolName: "Bash", + toolCategory: "bash" as const, + tokenUsage: { + inputTokens: 200, + totalTokens: 200, + }, + rawRefs: ["spanning-session"], + }, + ], + }, + ], + })); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => undefined), + upsertHourlyIndex: vi.fn((nextIndex) => { + hourlyIndex = nextIndex; + return nextIndex; + }), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.refreshDashboard({ timeRange: { preset: "24h" } }, "manual"); + + expect(hourlyIndex?.buckets.map((bucket) => bucket.hourStart)).toEqual([ + Date.UTC(2026, 5, 7, 1), + Date.UTC(2026, 5, 7, 2), + ]); + expect(hourlyIndex?.buckets.map((bucket) => bucket.sessions[0]?.usage?.totalTokens)).toEqual([ + 100, 200, + ]); + expect(dashboard.dashboard?.trends.tokenHourly).toEqual([ + expect.objectContaining({ + hourStart: Date.UTC(2026, 5, 7, 1), + totalTokens: 100, + sessionCount: 1, + }), + expect.objectContaining({ + hourStart: Date.UTC(2026, 5, 7, 2), + totalTokens: 200, + sessionCount: 1, + }), + ]); + expect(dashboard.dashboard?.kpis.find((item) => item.key === "sessions")?.value).toBe(1); + }); + + it("stores compact event signals in the hourly index", async () => { + const longText = "debugging feature work ".repeat(400); + const longCommand = "pnpm --filter @coder-studio/server test ".repeat(300); + let hourlyIndex: WorkAnalysisHourlyIndex | undefined; + const collect = vi.fn(async () => ({ + sourceDigest: "source-large-events", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "large-session", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 40), + sourceRef: "large-session", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 1, + usage: { + inputTokens: 200, + outputTokens: 50, + totalTokens: 250, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [ + { + eventId: "large-skill", + providerId: "codex" as const, + sessionId: "large-session", + workspacePath: "/repo/app", + eventType: "tool" as const, + canonicalEventType: "tool_call" as const, + occurredAt: Date.UTC(2026, 5, 7, 2, 20), + role: "tool" as const, + toolName: "Skill", + toolCategory: "skill" as const, + text: longText, + commandText: longCommand, + payload: { + input: { + skill: "frontend-design", + transcript: "x".repeat(8_000), + }, + }, + evidence: ["y".repeat(8_000)], + rawRefs: ["large-session"], + }, + ], + }, + ], + })); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => undefined), + upsertHourlyIndex: vi.fn((nextIndex) => { + hourlyIndex = nextIndex; + return nextIndex; + }), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.refreshDashboard({ timeRange: { preset: "24h" } }, "manual"); + + const indexedEvent = hourlyIndex?.buckets[0]?.sessions[0]?.events?.[0]; + expect(indexedEvent).toMatchObject({ + eventId: "large-skill", + toolName: "Skill", + toolCategory: "skill", + skillName: "frontend-design", + rawRefs: ["large-session"], + }); + expect(indexedEvent?.payload).toBeUndefined(); + expect(indexedEvent?.evidence).toBeUndefined(); + expect(indexedEvent?.text?.length).toBeLessThan(longText.length); + expect(indexedEvent?.commandText?.length).toBeLessThan(longCommand.length); + expect(dashboard.dashboard?.breakdowns.skills).toEqual([ + expect.objectContaining({ + key: "frontend-design", + callCount: 1, + }), + ]); + }); + + it("clears previous analysis state before rebuilding the hourly index", async () => { + const clearAnalysisCache = vi.fn(); + const collect = vi.fn(async () => ({ + sourceDigest: "source-rebuilt", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "rebuilt-session", + workspacePath: "/repo/app", + startedAt: Date.UTC(2026, 5, 7, 2, 10), + lastActiveAt: Date.UTC(2026, 5, 7, 2, 40), + sourceRef: "rebuilt-session", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 900, + outputTokens: 100, + totalTokens: 1_000, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + })); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => undefined), + upsertHourlyIndex: vi.fn((record) => record), + clearAnalysisCache, + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 7, 3, 30), + }); + + const dashboard = await service.rebuildDashboardIndex({ timeRange: { preset: "24h" } }); + + expect(clearAnalysisCache).toHaveBeenCalledOnce(); + expect(collect).toHaveBeenCalledWith({ + workspacePaths: [], + timeRange: { + startAt: 0, + endAt: Date.UTC(2026, 5, 7, 3, 30), + label: "all history", + }, + }); + expect(dashboard.scanState.sourceDigest).toBe("source-rebuilt"); + expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(1_000); + }); + + it("builds a full-history hourly index on first dashboard request so filters project immediately", async () => { + let hourlyIndex: WorkAnalysisHourlyIndex | undefined; + const collect = vi.fn(async () => ({ + sourceDigest: "source-filtered", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "a", + workspacePath: "/repo/a", + startedAt: Date.UTC(2026, 5, 1, 10), + lastActiveAt: Date.UTC(2026, 5, 1, 10, 30), + sourceRef: "a", + modelId: "gpt-5-codex", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + usage: { + inputTokens: 400, + outputTokens: 100, + totalTokens: 500, + }, + usageCoverage: { + hasUsage: true, + callCount: 1, + callsWithTotalTokens: 1, + estimatedCallCount: 0, + }, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + events: [], + }, + ], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + findHourlyIndex: vi.fn(() => hourlyIndex), + upsertHourlyIndex: vi.fn((nextIndex) => { + hourlyIndex = nextIndex; + return nextIndex; + }), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => Date.UTC(2026, 5, 6, 10), + }); + + const dashboard = await service.getDashboard({ + timeRange: { preset: "90d" }, + workspacePaths: ["/repo/a"], + }); + + expect(collect).toHaveBeenCalledWith({ + timeRange: { + startAt: 0, + endAt: Date.UTC(2026, 5, 6, 10), + label: "all history", + }, + workspacePaths: [], + }); + expect(hourlyIndex?.buckets.map((bucket) => bucket.hourStart)).toEqual([ + Date.UTC(2026, 5, 1, 10), + ]); + expect(dashboard.scanState.status).toBe("succeeded"); + expect(dashboard.dashboard?.rankings.projects).toEqual([ + expect.objectContaining({ + label: "/repo/a", + totalTokens: 500, + }), + ]); + expect(dashboard.dashboard?.kpis.find((item) => item.key === "totalTokens")?.value).toBe(500); + }); + + it("serializes concurrent dashboard refreshes without returning another query result", async () => { + const firstCollection = createDeferred<{ + sourceDigest: string; + providers: []; + sessions: []; + }>(); + const collect = vi.fn().mockReturnValueOnce(firstCollection.promise).mockResolvedValueOnce({ + sourceDigest: "source-7d", + providers: [], + sessions: [], + }); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert: vi.fn((record) => record), + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: vi.fn(() => Date.UTC(2026, 5, 6, 10)), + }); + + const autoRefresh = service.refreshDashboard({ timeRange: { preset: "90d" } }, "auto"); + const manualRefresh = service.refreshDashboard({ timeRange: { preset: "7d" } }, "manual"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(collect).toHaveBeenCalledTimes(1); + + firstCollection.resolve({ + sourceDigest: "source-90d", + providers: [], + sessions: [], + }); + + const [autoResult, manualResult] = await Promise.all([autoRefresh, manualRefresh]); + + expect(collect).toHaveBeenCalledTimes(2); + expect(autoResult.query.timeRange).toEqual({ preset: "90d" }); + expect(autoResult.scanState.sourceDigest).toBe("source-90d"); + expect(manualResult.query.timeRange).toEqual({ preset: "7d" }); + expect(manualResult.scanState.sourceDigest).toBe("source-7d"); + }); + + it("filters collected sessions by workspacePaths after discovery", async () => { + const upsert = vi.fn((record) => record); + const collect = vi.fn(async () => ({ + sourceDigest: "source-1", + providers: [], + sessions: [ + { + providerId: "codex" as const, + sessionId: "a", + workspacePath: "/repo/a", + startedAt: 1, + lastActiveAt: 2, + sourceRef: "a", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + }, + { + providerId: "codex" as const, + sessionId: "b", + workspacePath: "/repo/b", + startedAt: 3, + lastActiveAt: 4, + sourceRef: "b", + userTurnCount: 1, + assistantTurnCount: 1, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + }, + ], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert, + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => 1_000, + }); + + const result = await service.runBasic({ + workspacePaths: ["/repo/b"], + timeRange: { preset: "7d" }, + }); + + expect(result.basicResult?.availableWorkspacePaths).toEqual(["/repo/a", "/repo/b"]); + expect(result.basicResult?.workSurface.workspacePaths).toEqual(["/repo/b"]); + expect(result.basicResult?.activity.daily).toEqual([ + { day: "1970-01-01", totalTokens: 0, sessionCount: 1 }, + ]); + expect(result.basicResult?.compare.workspaces).toEqual([ + expect.objectContaining({ + workspacePath: "/repo/b", + sessionCount: 1, + totalTokens: 0, + sharePercent: 0, + }), + ]); + expect(result.basicResult).not.toHaveProperty("budgets"); + expect(collect).toHaveBeenCalledWith({ + workspacePaths: ["/repo/b"], + timeRange: { startAt: 1_000 - 7 * 24 * 60 * 60 * 1000, endAt: 1_000, label: "7d" }, + }); + }); + + it("rescans provider logs when running basic analysis even if a previous result succeeded", async () => { + const upsert = vi.fn((record) => record); + const collect = vi.fn(async () => ({ + sourceDigest: "source-1", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [], + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => ({ + id: "analysis-1", + queryDigest: "digest-1", + workspacePaths: ["/repo/app"], + timeRange: { preset: "7d" as const }, + basicStatus: "succeeded" as const, + deepStatus: "idle" as const, + })), + upsert, + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { collect }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(), + }, + now: () => 1_000, + }); + + await service.runBasic({ workspacePaths: ["/repo/app"], timeRange: { preset: "7d" } }); + + expect(collect).toHaveBeenCalledWith({ + workspacePaths: ["/repo/app"], + timeRange: { startAt: 1_000 - 7 * 24 * 60 * 60 * 1000, endAt: 1_000, label: "7d" }, + }); + expect(upsert).toHaveBeenCalled(); + }); + + it("persists running and succeeded states around deep analysis", async () => { + const upsert = vi.fn((record) => record); + const run = vi.fn(async () => ({ + workSummary: "done", + repeatedPatterns: [], + bottlenecks: [], + workflowSuggestions: [], + skillCandidates: [], + openLoops: [], + followUpSuggestions: [], + confidence: "high" as const, + })); + + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert, + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { + collect: vi.fn(async () => ({ + sourceDigest: "source-1", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + sourceRef: "/logs/sess-1", + title: "Fix tests", + userTurnCount: 2, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + evidence: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + excerpts: [{ role: "user" as const, text: "fix tests" }], + }, + ], + }, + ], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + sourceRef: "/logs/sess-1", + title: "Fix tests", + userTurnCount: 2, + assistantTurnCount: 1, + toolUseCount: 1, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + evidence: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + excerpts: [{ role: "user" as const, text: "fix tests" }], + }, + ], + }, + ], + })), + }, + skillLibraryRepo: { list: vi.fn(() => [{ slug: "review" }]) }, + skillMountRepo: { list: vi.fn(() => [{ skillSlug: "review", enabled: true }]) }, + deepRunner: { + run, + }, + now: vi.fn(() => 1_234), + }); + + const result = await service.runDeep({ + workspacePaths: ["/repo/project"], + timeRange: { preset: "7d" }, + }); + + expect(upsert).toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePath: "/repo/project", + evidence: expect.objectContaining({ + sessions: [ + expect.objectContaining({ + providerId: "codex", + sessionId: "sess-1", + excerpts: [{ role: "user", text: "fix tests" }], + }), + ], + }), + }) + ); + expect(result.basicStatus).toBe("succeeded"); + expect(result.deepStatus).toBe("succeeded"); + expect(result.deepResult?.workSummary).toBe("done"); + }); + + it("persists a failed record when the deep runner throws", async () => { + const upsert = vi.fn((record) => record); + const service = new WorkAnalysisService({ + repo: { + findByQueryDigest: vi.fn(() => undefined), + upsert, + }, + workspaceMgr: { get: vi.fn() }, + workLogCollector: { + collect: vi.fn(async () => ({ + sourceDigest: "source-1", + providers: [ + { + providerId: "codex" as const, + status: "supported" as const, + sessions: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + sourceRef: "/logs/sess-1", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + evidence: [], + }, + ], + sourceRefs: [], + parseErrorCount: 0, + warnings: [], + }, + ], + sessions: [ + { + providerId: "codex" as const, + sessionId: "sess-1", + workspacePath: "/repo/project", + startedAt: 100, + lastActiveAt: 200, + sourceRef: "/logs/sess-1", + userTurnCount: 0, + assistantTurnCount: 0, + toolUseCount: 0, + parseErrorCount: 0, + timestampQuality: "explicit" as const, + evidence: [], + }, + ], + })), + }, + skillLibraryRepo: { list: vi.fn(() => []) }, + skillMountRepo: { list: vi.fn(() => []) }, + deepRunner: { + run: vi.fn(async () => { + throw new Error("boom"); + }), + }, + now: vi.fn(() => 1_234), + }); + + const result = await service.runDeep({ + workspacePaths: ["/repo/project"], + timeRange: { preset: "7d" }, + }); + + expect(result.deepStatus).toBe("failed"); + expect(result.deepErrorMessage).toBe("boom"); + }); +}); diff --git a/packages/server/src/__tests__/work-analysis-task-classifier.test.ts b/packages/server/src/__tests__/work-analysis-task-classifier.test.ts new file mode 100644 index 000000000..c8ec50013 --- /dev/null +++ b/packages/server/src/__tests__/work-analysis-task-classifier.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { classifyTaskTurn } from "../work-analysis/classification/task-classifier.js"; +import { deriveTaskTurns } from "../work-analysis/classification/task-turn-builder.js"; +import type { WorkLogEvent, WorkLogSession } from "../work-analysis/log-sources/types.js"; + +function createSession(events: WorkLogEvent[]): WorkLogSession { + return { + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/repo", + startedAt: 100, + lastActiveAt: 400, + sourceRef: "/tmp/session.jsonl", + userTurnCount: events.filter((event) => event.eventType === "message" && event.role === "user") + .length, + assistantTurnCount: events.filter( + (event) => event.eventType === "message" && event.role === "assistant" + ).length, + toolUseCount: events.filter((event) => event.eventType === "tool").length, + parseErrorCount: 0, + timestampQuality: "explicit", + events, + }; +} + +function createEvent( + index: number, + overrides: Partial & Pick +): WorkLogEvent { + return { + eventId: `event-${index}`, + providerId: "codex", + sessionId: "sess-1", + workspacePath: "/repo", + eventType: overrides.eventType, + canonicalEventType: overrides.canonicalEventType, + occurredAt: index * 100, + rawRefs: [], + ...overrides, + }; +} + +describe("classifyTaskTurn", () => { + it("classifies a bash-only test turn as testing", () => { + const result = classifyTaskTurn({ + userMessage: "run vitest and verify the failing spec", + toolNames: ["Bash"], + commandTexts: ["pnpm vitest src/foo.test.ts"], + filePaths: [], + hasEdits: false, + hasReads: false, + hasPlanMode: false, + hasAgentSpawn: false, + hasSearch: false, + hasMcpTool: false, + hasTaskTool: false, + hasSkillTool: false, + }); + + expect(result.primaryTask).toBe("testing"); + }); + + it("classifies an edit turn as feature development with debugging as secondary", () => { + const result = classifyTaskTurn({ + userMessage: "add error handling to the fetch helper", + toolNames: ["Edit"], + commandTexts: [], + filePaths: ["src/fetch.ts"], + toolSteps: [{ tool: "Edit", file: "src/fetch.ts" }], + hasEdits: true, + hasReads: false, + hasPlanMode: false, + hasAgentSpawn: false, + hasSearch: false, + hasMcpTool: false, + hasTaskTool: false, + hasSkillTool: false, + }); + + expect(result.primaryTask).toBe("feature_dev"); + expect(result.secondaryTasks).toContain("debugging"); + }); + + it("counts retries from ordered tool steps within a turn", () => { + const result = classifyTaskTurn({ + userMessage: "fix the failing test", + toolNames: ["Edit", "Bash", "Edit"], + commandTexts: ["pnpm test src/foo.test.ts"], + filePaths: ["src/foo.ts", "src/foo.ts"], + toolSteps: [ + { tool: "Edit", file: "src/foo.ts" }, + { tool: "Bash", command: "pnpm test src/foo.test.ts" }, + { tool: "Edit", file: "src/foo.ts" }, + ], + hasEdits: true, + hasReads: false, + hasPlanMode: false, + hasAgentSpawn: false, + hasSearch: false, + hasMcpTool: false, + hasTaskTool: false, + hasSkillTool: false, + }); + + expect(result.primaryTask).toBe("debugging"); + expect(result.retries).toBe(1); + }); +}); + +describe("deriveTaskTurns", () => { + it("starts turns on user messages when the session contains any user input", () => { + const turns = deriveTaskTurns( + createSession([ + createEvent(1, { + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Read", + filePath: "src/prelude.ts", + }), + createEvent(2, { + eventType: "message", + canonicalEventType: "message_turn", + role: "user", + text: "run the test suite", + }), + createEvent(3, { + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Bash", + commandText: "pnpm vitest src/foo.test.ts", + }), + ]) + ); + + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + turnId: "sess-1:turn:0", + userMessage: "run the test suite", + toolNames: ["Bash"], + commandTexts: ["pnpm vitest src/foo.test.ts"], + filePaths: [], + toolSteps: [{ tool: "Bash", command: "pnpm vitest src/foo.test.ts" }], + startedAt: 200, + }); + }); + + it("synthesizes a single turn when the session has no user messages", () => { + const turns = deriveTaskTurns( + createSession([ + createEvent(1, { + eventType: "tool", + canonicalEventType: "tool_call", + toolName: "Read", + filePath: "src/only-tool.ts", + }), + createEvent(2, { + eventType: "command", + canonicalEventType: "command", + commandText: "pnpm vitest src/foo.test.ts", + }), + ]) + ); + + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + turnId: "sess-1:turn:0", + userMessage: "", + toolNames: ["Read"], + commandTexts: ["pnpm vitest src/foo.test.ts"], + filePaths: ["src/only-tool.ts"], + toolSteps: [ + { tool: "Read", file: "src/only-tool.ts" }, + { tool: "command", command: "pnpm vitest src/foo.test.ts" }, + ], + startedAt: 100, + }); + }); +}); diff --git a/packages/server/src/__tests__/workspace-commands.test.ts b/packages/server/src/__tests__/workspace-commands.test.ts index 90671663a..23bdf873e 100644 --- a/packages/server/src/__tests__/workspace-commands.test.ts +++ b/packages/server/src/__tests__/workspace-commands.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, rmSync } from "node:fs"; -import { mkdir, stat, writeFile } from "node:fs/promises"; +import { mkdir, stat, symlink, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -7,6 +7,7 @@ import { EventBus } from "../bus/event-bus.js"; import { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; import { SettingsRepo } from "../storage/repositories/settings-repo.js"; import { WorkspaceRepo } from "../storage/repositories/workspace-repo.js"; +import { WORKSPACE_HISTORY_KEY } from "../workspace/history-store.js"; import { WorkspaceManager } from "../workspace/manager.js"; import type { CommandContext } from "../ws/dispatch.js"; import { dispatch } from "../ws/dispatch.js"; @@ -92,6 +93,186 @@ describe("Workspace Commands", () => { }); }); + describe("workspace.history.list", () => { + it("returns an empty list by default", async () => { + const result = await dispatch( + { + kind: "command", + id: "workspace-history-empty", + op: "workspace.history.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([]); + }); + + it("records successful workspace opens in newest-first order", async () => { + const olderDir = join(tmpdir(), `workspace-history-older-${Date.now()}`); + const newerDir = join(tmpdir(), `workspace-history-newer-${Date.now()}`); + await mkdir(olderDir, { recursive: true }); + await mkdir(newerDir, { recursive: true }); + + await dispatch( + { + kind: "command", + id: "workspace-history-open-older", + op: "workspace.open", + args: { + path: olderDir, + }, + }, + ctx + ); + + await dispatch( + { + kind: "command", + id: "workspace-history-open-newer", + op: "workspace.open", + args: { + path: newerDir, + }, + }, + ctx + ); + + const result = await dispatch( + { + kind: "command", + id: "workspace-history-list-newest-first", + op: "workspace.history.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + expect.objectContaining({ + path: newerDir, + name: expect.stringMatching(/^workspace-history-newer-/), + }), + expect.objectContaining({ + path: olderDir, + name: expect.stringMatching(/^workspace-history-older-/), + }), + ]); + }); + + it("dedupes repeated opens of the same path and moves them to the front", async () => { + vi.useFakeTimers(); + try { + const alphaDir = join(tmpdir(), "workspace-history-alpha"); + const betaDir = join(tmpdir(), "workspace-history-beta"); + await mkdir(alphaDir, { recursive: true }); + await mkdir(betaDir, { recursive: true }); + + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + await dispatch( + { + kind: "command", + id: "workspace-history-open-alpha-first", + op: "workspace.open", + args: { + path: alphaDir, + }, + }, + ctx + ); + + vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z")); + await dispatch( + { + kind: "command", + id: "workspace-history-open-beta", + op: "workspace.open", + args: { + path: betaDir, + }, + }, + ctx + ); + + vi.setSystemTime(new Date("2026-01-03T00:00:00.000Z")); + await dispatch( + { + kind: "command", + id: "workspace-history-open-alpha-second", + op: "workspace.open", + args: { + path: alphaDir, + }, + }, + ctx + ); + + const result = await dispatch( + { + kind: "command", + id: "workspace-history-list-deduped", + op: "workspace.history.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + path: alphaDir, + name: "workspace-history-alpha", + lastOpenedAt: new Date("2026-01-03T00:00:00.000Z").getTime(), + }, + { + path: betaDir, + name: "workspace-history-beta", + lastOpenedAt: new Date("2026-01-02T00:00:00.000Z").getTime(), + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("filters malformed stored history entries before returning the list", async () => { + settingsRepo.set(WORKSPACE_HISTORY_KEY, [ + { + path: "/repo/valid", + name: "valid", + lastOpenedAt: 2, + }, + { + path: 123, + name: "broken", + lastOpenedAt: 1, + }, + "bad-entry", + ]); + + const result = await dispatch( + { + kind: "command", + id: "workspace-history-list-filters-malformed", + op: "workspace.history.list", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual([ + { + path: "/repo/valid", + name: "valid", + lastOpenedAt: 2, + }, + ]); + }); + }); + describe("workspace.open", () => { it("should fail for non-existent path", async () => { const result = await dispatch( @@ -149,6 +330,38 @@ describe("Workspace Commands", () => { const workspaceId = (result.data as { id: string }).id; expect(triggerOpenTimeFetch).toHaveBeenCalledWith(workspaceId); }); + + it("publishes agent instructions during workspace.open", async () => { + const dir = join(tmpdir(), `workspace-open-publish-${Date.now()}`); + await mkdir(dir); + const calls: string[] = []; + + ctx = { + ...ctx, + agentInstructionPublisher: { + syncWorkspace: vi.fn(async () => { + calls.push("publish"); + }), + scheduleWorkspaceSync: vi.fn(), + syncAllOpenWorkspaces: vi.fn(), + } as never, + } as CommandContext; + + const result = await dispatch( + { + kind: "command", + id: "workspace-open-publish", + op: "workspace.open", + args: { + path: dir, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(calls).toEqual(["publish"]); + }); }); describe("workspace.browse", () => { @@ -189,6 +402,32 @@ describe("Workspace Commands", () => { expect.arrayContaining(["/", homedir()]) ); }); + + it("includes symlinked directories in browse results", async () => { + const dir = join(tmpdir(), `workspace-browse-symlink-${Date.now()}`); + const target = join(dir, "target"); + await mkdir(target, { recursive: true }); + await symlink(target, join(dir, "linked"), "dir"); + + const result = await dispatch( + { + kind: "command", + id: "workspace-browse-symlink", + op: "workspace.browse", + args: { + path: dir, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect((result.data as { directories: Array<{ name: string }> }).directories).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "linked" })]) + ); + + rmSync(dir, { recursive: true, force: true }); + }); }); describe("workspace.mkdir", () => { @@ -592,6 +831,53 @@ describe("Workspace Commands", () => { (result.data as { uiState: { activeEditorPath?: string | null } }).uiState.activeEditorPath ).toBe("src/app.tsx"); }); + + it("drops auto attach state while persisting other agent instruction ui state", async () => { + const dir = join(tmpdir(), `workspace-agent-instructions-ui-state-${Date.now()}`); + await mkdir(dir); + + const openResult = await dispatch( + { + kind: "command", + id: "open-workspace-agent-instructions-ui-state", + op: "workspace.open", + args: { path: dir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + const workspaceId = (openResult.data as { id: string }).id; + + const result = await dispatch( + { + kind: "command", + id: "set-ui-state-agent-instructions", + op: "workspace.uiState.set", + args: { + workspaceId, + uiState: { + leftPanelWidth: 320, + bottomPanelHeight: 210, + focusMode: false, + agentInstructionsExpanded: false, + agentInstructionsAutoAttach: true, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + (result.data as { uiState: { agentInstructionsExpanded?: boolean } }).uiState + ).toMatchObject({ + agentInstructionsExpanded: false, + }); + expect((result.data as { uiState: Record }).uiState).not.toHaveProperty( + "agentInstructionsAutoAttach" + ); + }); }); describe("workspace.lastViewedTarget", () => { diff --git a/packages/server/src/__tests__/workspace-intelligence-command.test.ts b/packages/server/src/__tests__/workspace-intelligence-command.test.ts index d253ac630..a91a32d3b 100644 --- a/packages/server/src/__tests__/workspace-intelligence-command.test.ts +++ b/packages/server/src/__tests__/workspace-intelligence-command.test.ts @@ -99,6 +99,7 @@ describe("workspace.intelligence command", () => { branch: "main", }, packageManager: "npm", + workspaceKind: "node_app", frameworks: ["Vite", "Node"], scripts: { dev: "vite", @@ -108,6 +109,18 @@ describe("workspace.intelligence command", () => { }, recommendedCommands: [{ key: "dev", command: "npm run dev", source: "package_json" }], docs: [{ path: "README.md", kind: "readme" }], + documentationEntries: [{ path: "README.md", kind: "readme" }], + verificationCommands: [ + { + priority: "dev", + command: "npm run dev", + reason: "Primary local development entrypoint.", + }, + ], + topLevelDirectories: [], + keyDirectories: [], + packages: [], + fileConstraints: ["Use repository-level verification commands before claiming completion."], agentInstructions: { exists: false, path: AGENT_INSTRUCTIONS_RELATIVE_PATH, diff --git a/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts b/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts index b1c2a87bd..0de9316fb 100644 --- a/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts +++ b/packages/server/src/__tests__/workspace-watcher-hydrate-restart.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import chokidar, { type FSWatcher } from "chokidar"; @@ -120,4 +121,58 @@ describe("workspace watcher hydrate restart", () => { }) ); }); + + it("restores managed target files after restart", async () => { + mkdirSync(join(workspaceDir, ".coder-studio"), { recursive: true }); + writeFileSync( + join(workspaceDir, ".coder-studio", "agent.md"), + "# Agent Instructions\n\n- Custom rule.\n" + ); + + server = await createServer({ + stateDir, + host: "127.0.0.1", + port: 0, + }); + + const firstCtx = server.__test__!.commandContext; + + const openResult = await dispatch( + { + kind: "command", + id: "workspace-open-publish", + op: "workspace.open", + args: { path: workspaceDir }, + }, + firstCtx + ); + + expect(openResult.ok).toBe(true); + expect(await readFile(join(workspaceDir, "AGENTS.md"), "utf8")).toContain( + "# Agent Instructions" + ); + expect(await readFile(join(workspaceDir, ".claude", "CLAUDE.md"), "utf8")).toContain( + "# Agent Instructions" + ); + + await server.stop(); + server = undefined; + watchSpy.mockClear(); + + rmSync(join(workspaceDir, "AGENTS.md"), { force: true }); + rmSync(join(workspaceDir, ".claude", "CLAUDE.md"), { force: true }); + + server = await createServer({ + stateDir, + host: "127.0.0.1", + port: 0, + }); + + expect(await readFile(join(workspaceDir, "AGENTS.md"), "utf8")).toContain( + "# Agent Instructions" + ); + expect(await readFile(join(workspaceDir, ".claude", "CLAUDE.md"), "utf8")).toContain( + "# Agent Instructions" + ); + }); }); diff --git a/packages/server/src/__tests__/workspace/intelligence.test.ts b/packages/server/src/__tests__/workspace/intelligence.test.ts index 2228a8227..d754dc38b 100644 --- a/packages/server/src/__tests__/workspace/intelligence.test.ts +++ b/packages/server/src/__tests__/workspace/intelligence.test.ts @@ -64,7 +64,7 @@ describe("inspectWorkspaceIntelligence", () => { rootPath, }); - expect(summary).toEqual({ + expect(summary).toMatchObject({ workspaceId: "ws-1", rootPath, git: { @@ -94,6 +94,8 @@ describe("inspectWorkspaceIntelligence", () => { path: AGENT_INSTRUCTIONS_RELATIVE_PATH, }, }); + expect(summary.workspaceKind).toBe("monorepo"); + expect(summary.topLevelDirectories).toEqual([".coder-studio", "docs"]); }); it("handles non-git folders without package.json", async () => { @@ -107,7 +109,7 @@ describe("inspectWorkspaceIntelligence", () => { rootPath, }); - expect(summary).toEqual({ + expect(summary).toMatchObject({ workspaceId: "ws-plain", rootPath, git: { @@ -128,6 +130,8 @@ describe("inspectWorkspaceIntelligence", () => { path: AGENT_INSTRUCTIONS_RELATIVE_PATH, }, }); + expect(summary.workspaceKind).toBe("unknown"); + expect(summary.topLevelDirectories).toEqual(["docs"]); }); it("reads branch metadata from a worktree-style .git file", async () => { @@ -149,4 +153,133 @@ describe("inspectWorkspaceIntelligence", () => { branch: "review/phase-3", }); }); + + it("infers a monorepo architecture summary with key directories and stronger verification commands", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-rich-")); + tempDirs.push(rootPath); + + await writeFile(join(rootPath, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"); + await writeFile( + join(rootPath, "package.json"), + JSON.stringify( + { + scripts: { + dev: "tsx scripts/dev.ts", + build: "tsx scripts/build.ts", + lint: "biome lint .", + "ci:test": "pnpm -r test", + "ci:typecheck": "pnpm -r exec tsc -p tsconfig.json --noEmit", + "ci:verify": "pnpm ci:test && pnpm ci:typecheck", + "acceptance:phase1": "pnpm --dir e2e exec playwright test --grep @phase1", + }, + }, + null, + 2 + ) + ); + await writeFile(join(rootPath, "README.md"), "# Repo\n"); + await mkdir(join(rootPath, "docs", "help"), { recursive: true }); + await writeFile(join(rootPath, "docs", "help", "quick-start.md"), "# Quick Start\n"); + await mkdir(join(rootPath, "packages", "web"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "web", "package.json"), + JSON.stringify({ name: "@repo/web", scripts: { test: "vitest run" } }) + ); + await mkdir(join(rootPath, "packages", "server"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "server", "package.json"), + JSON.stringify({ name: "@repo/server" }) + ); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-1", + rootPath, + }); + + expect(summary).toMatchObject({ + workspaceKind: "monorepo", + keyDirectories: [ + { + path: "packages/web", + kind: "frontend", + reason: expect.any(String), + }, + { + path: "packages/server", + kind: "backend", + reason: expect.any(String), + }, + { + path: "docs", + kind: "docs", + reason: expect.any(String), + }, + ], + packages: expect.arrayContaining([ + { + path: "packages/web", + name: "@repo/web", + role: "frontend_ui", + scripts: ["test"], + }, + { + path: "packages/server", + name: "@repo/server", + role: "backend_runtime", + scripts: [], + }, + ]), + verificationCommands: expect.arrayContaining([ + { + command: "pnpm ci:test", + reason: expect.any(String), + priority: "verification", + }, + { + command: "pnpm ci:typecheck", + reason: expect.any(String), + priority: "quality", + }, + { + command: "pnpm ci:verify", + reason: expect.any(String), + priority: "verification", + }, + ]), + fileConstraints: expect.arrayContaining([ + expect.stringContaining("package boundaries"), + expect.stringContaining("unrelated refactors"), + ]), + }); + }); + + it("caps key directories and skips noisy root folders", async () => { + const rootPath = await mkdtemp(join(tmpdir(), "workspace-intelligence-noise-")); + tempDirs.push(rootPath); + + await writeFile(join(rootPath, "package.json"), JSON.stringify({ scripts: {} })); + await mkdir(join(rootPath, "packages", "core"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "core", "package.json"), + JSON.stringify({ name: "@repo/core" }) + ); + await mkdir(join(rootPath, "packages", "providers"), { recursive: true }); + await writeFile( + join(rootPath, "packages", "providers", "package.json"), + JSON.stringify({ name: "@repo/providers" }) + ); + await mkdir(join(rootPath, "node_modules"), { recursive: true }); + await mkdir(join(rootPath, ".git"), { recursive: true }); + await mkdir(join(rootPath, "scripts"), { recursive: true }); + await mkdir(join(rootPath, "e2e"), { recursive: true }); + + const summary = await inspectWorkspaceIntelligence({ + workspaceId: "ws-noise", + rootPath, + }); + + expect(summary.keyDirectories?.length).toBeLessThanOrEqual(6); + expect(summary.keyDirectories?.map((entry) => entry.path)).not.toContain("node_modules"); + expect(summary.topLevelDirectories).not.toContain(".git"); + }); }); diff --git a/packages/server/src/agent-instructions/agent-generator.ts b/packages/server/src/agent-instructions/agent-generator.ts new file mode 100644 index 000000000..d6565818e --- /dev/null +++ b/packages/server/src/agent-instructions/agent-generator.ts @@ -0,0 +1,220 @@ +import { + type ProviderConfig, + type ProviderDefinition, + providerSupportsAgentInstructionsGeneration, +} from "@coder-studio/core"; +import { type CommandRunner, runCommandAsString } from "../provider-runtime/command-runner.js"; +import type { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; +import { inspectWorkspaceIntelligence } from "../workspace/intelligence.js"; +import { + extractAgentInstructionsReplyText, + parseGeneratedAgentInstructionsPayload, +} from "./output.js"; +import { buildAgentInstructionsGenerationPrompt } from "./prompt.js"; + +type AgentInstructionsGenerationError = { + code: + | "agent_instructions_generation_failed" + | "agent_instructions_generation_timeout" + | "agent_instructions_generation_no_output"; + message: string; + details?: { + providerId?: string; + stderr?: string; + exitCode?: number; + timeoutMs?: number; + }; +}; + +export interface AgentInstructionsGenerationResult { + content: string; + meta: { + providerId: string; + model?: string; + }; +} + +export interface AgentInstructionsGeneratorDeps { + providerConfigRepo?: Pick; + commandRunner?: CommandRunner; +} + +export interface AgentInstructionsGenerationRequest { + providerId?: string; + model?: string; +} + +const AGENT_INSTRUCTIONS_GENERATION_TIMEOUT_MS = 120_000; + +export class AgentInstructionsGenerator { + private readonly commandRunner: CommandRunner; + + constructor(private readonly deps: AgentInstructionsGeneratorDeps = {}) { + this.commandRunner = deps.commandRunner ?? runCommandAsString; + } + + async generate( + workspaceId: string, + rootPath: string, + providerRegistry: ProviderDefinition[], + request: AgentInstructionsGenerationRequest = {} + ): Promise { + const model = this.normalizeModel(request.model); + const provider = this.resolveProvider(providerRegistry, request.providerId); + + try { + const summary = await inspectWorkspaceIntelligence({ + workspaceId, + rootPath, + }); + const prompt = buildAgentInstructionsGenerationPrompt(summary); + const providerConfig = this.resolveProviderConfig(provider); + const command = provider.headless!.buildCommand( + providerConfig, + "agent_instructions_generate", + { + prompt, + sessionId: `agent-instructions-${workspaceId}`, + workspacePath: rootPath, + model, + } + ); + + if (!command) { + throw this.createUnsupportedProviderError(provider.id); + } + + const { stdout } = await this.commandRunner(command.argv[0]!, command.argv.slice(1), { + cwd: command.cwd, + env: { ...process.env, ...command.env }, + windowsHide: true, + timeoutMs: AGENT_INSTRUCTIONS_GENERATION_TIMEOUT_MS, + }); + const replyText = extractAgentInstructionsReplyText(provider.id, stdout); + const content = parseGeneratedAgentInstructionsPayload(replyText); + + return { + content, + meta: { + providerId: provider.id, + model, + }, + }; + } catch (error) { + if (this.isTypedError(error, "agent_instructions_provider_unsupported")) { + throw error; + } + + if (this.isTypedError(error, "agent_instructions_parse_failed")) { + if ((error as { message?: string }).message === "Agent instructions output was empty") { + throw this.createGenerationFailedError(provider.id, error); + } + throw error; + } + + throw this.createGenerationFailedError(provider.id, error); + } + } + + private resolveProvider( + providerRegistry: ProviderDefinition[], + requestedProviderId?: string + ): ProviderDefinition { + if (requestedProviderId) { + const requestedProvider = providerRegistry.find((entry) => entry.id === requestedProviderId); + if (!requestedProvider || !providerSupportsAgentInstructionsGeneration(requestedProvider)) { + throw this.createUnsupportedProviderError(requestedProviderId); + } + + return requestedProvider; + } + + const provider = providerRegistry.find((entry) => + providerSupportsAgentInstructionsGeneration(entry) + ); + if (!provider) { + throw this.createUnsupportedProviderError(); + } + + return provider; + } + + private normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim(); + return trimmed ? trimmed : undefined; + } + + private resolveProviderConfig(provider: ProviderDefinition): ProviderConfig { + const savedConfig = this.deps.providerConfigRepo?.get(provider.id); + return provider.configSchema.parse({ + ...(provider.defaultConfig ?? {}), + ...(savedConfig ?? {}), + }); + } + + private createUnsupportedProviderError(providerId?: string) { + return { + code: "agent_instructions_provider_unsupported", + message: providerId + ? `Provider does not support agent-instructions generation: ${providerId}` + : "No provider supports agent-instructions generation", + }; + } + + private createGenerationFailedError( + providerId: string, + error: unknown + ): AgentInstructionsGenerationError { + const candidate = error as { + code?: string; + message?: string; + stderr?: string; + exitCode?: number; + timeoutMs?: number; + }; + + if (candidate.code === "command_timeout") { + return { + code: "agent_instructions_generation_timeout", + message: `Timed out waiting for ${providerId} to generate agent instructions`, + details: { + providerId, + stderr: candidate.stderr, + timeoutMs: candidate.timeoutMs, + }, + }; + } + + if (candidate.code === "agent_instructions_parse_failed") { + if (candidate.message === "Agent instructions output was empty") { + return { + code: "agent_instructions_generation_no_output", + message: `${providerId} returned no output for agent instructions generation`, + details: { + providerId, + }, + }; + } + } + + return { + code: "agent_instructions_generation_failed", + message: + candidate.message ?? `Agent instructions generation failed for provider: ${providerId}`, + details: { + providerId, + stderr: candidate.stderr, + exitCode: candidate.exitCode, + }, + }; + } + + private isTypedError(codeCandidate: unknown, code: string): boolean { + return ( + typeof codeCandidate === "object" && + codeCandidate !== null && + "code" in codeCandidate && + (codeCandidate as { code?: string }).code === code + ); + } +} diff --git a/packages/server/src/agent-instructions/effective.ts b/packages/server/src/agent-instructions/effective.ts new file mode 100644 index 000000000..4d9862d70 --- /dev/null +++ b/packages/server/src/agent-instructions/effective.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { readFile } from "../fs/file-io.js"; +import { AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH } from "../workspace/workspace-state.js"; + +interface AgentInstructionsDocumentReadResult { + exists: boolean; + content: string; +} + +export function normalizeAgentInstructionsContent(content: string | undefined): string | undefined { + const trimmed = content?.trim(); + return trimmed ? `${trimmed}\n` : undefined; +} + +export function hashAgentInstructionsContent(content: string): string { + return createHash("sha256") + .update(normalizeAgentInstructionsContent(content) ?? "") + .digest("hex"); +} + +async function readAgentInstructionsDocument( + workspaceId: string, + rootPath: string +): Promise { + try { + const result = await readFile(workspaceId, rootPath, AGENT_INSTRUCTIONS_CUSTOM_RELATIVE_PATH); + if (result.kind !== "text") { + return { + exists: true, + content: "", + }; + } + + return { + exists: true, + content: result.content, + }; + } catch { + return { + exists: false, + content: "", + }; + } +} + +export async function resolveEffectiveAgentInstructions( + workspaceId: string, + rootPath: string +): Promise<{ content: string; effectiveHash: string } | null> { + const document = await readAgentInstructionsDocument(workspaceId, rootPath); + + if (!document.exists) { + return null; + } + + const normalized = normalizeAgentInstructionsContent(document.content); + if (!normalized) { + return null; + } + + return { + content: normalized, + effectiveHash: hashAgentInstructionsContent(normalized), + }; +} diff --git a/packages/server/src/agent-instructions/generator.ts b/packages/server/src/agent-instructions/generator.ts index fcd5e6085..83650cb7b 100644 --- a/packages/server/src/agent-instructions/generator.ts +++ b/packages/server/src/agent-instructions/generator.ts @@ -1,13 +1,13 @@ import type { WorkspaceIntelligenceSummary } from "@coder-studio/core"; -const WORKING_RULES = [ +const WORKFLOW_EXPECTATIONS = [ "Keep changes focused on the requested task.", "Do not revert user changes unless explicitly asked.", "Prefer the project's existing patterns.", "Run the relevant verification command before reporting completion.", ] as const; -const REVIEW_EXPECTATIONS = [ +const REVIEW_CHECKLIST = [ "Summarize changed files.", "Report verification commands and results.", "Call out risks, skipped tests, and assumptions.", @@ -22,12 +22,17 @@ export function buildAgentInstructionsMarkdown(summary: WorkspaceIntelligenceSum const lines: string[] = ["# Agent Instructions", ""]; pushSection(lines, "Project Overview", buildProjectOverview(summary)); + pushSection(lines, "Architecture Map", buildArchitectureMap(summary)); + pushSection(lines, "Key Directories", buildKeyDirectories(summary)); pushSection(lines, "Development Commands", buildDevelopmentCommands(summary)); - pushSection(lines, "Working Rules", [...WORKING_RULES.map((rule) => `- ${rule}`)]); + pushSection(lines, "Workflow Expectations", [ + ...WORKFLOW_EXPECTATIONS.map((rule) => `- ${rule}`), + ]); + pushSection(lines, "File Constraints", buildFileConstraints(summary)); pushSection( lines, - "Review Expectations", - REVIEW_EXPECTATIONS.map((rule) => `- ${rule}`) + "Review Checklist", + REVIEW_CHECKLIST.map((rule) => `- ${rule}`) ); pushSection( lines, @@ -55,6 +60,10 @@ function buildProjectOverview(summary: WorkspaceIntelligenceSummary): string[] { lines.push(`- Package manager: ${summary.packageManager}`); } + if (summary.workspaceKind) { + lines.push(`- Workspace kind: ${summary.workspaceKind}`); + } + if (summary.frameworks.length > 0) { lines.push(`- Frameworks: ${summary.frameworks.join(", ")}`); } @@ -70,27 +79,281 @@ function buildProjectOverview(summary: WorkspaceIntelligenceSummary): string[] { return lines; } +function buildArchitectureMap(summary: WorkspaceIntelligenceSummary): string[] { + const lines: string[] = []; + const packages = summary.packages ?? []; + const packagePaths = new Set(packages.map((entry) => entry.path)); + const coderStudioWorkspace = isCoderStudioWorkspace(summary); + + const userFlow = buildUserFlow(packagePaths); + if (userFlow.length > 0) { + lines.push("- User-facing change routing:"); + lines.push(...userFlow.map((line) => ` - ${line}`)); + } + + const runtimeFlow = buildRuntimeFlow(packagePaths); + if (runtimeFlow.length > 0) { + lines.push("- Runtime and integration flow:"); + lines.push(...runtimeFlow.map((line) => ` - ${line}`)); + } + + if (packages.length > 0) { + lines.push("- Package responsibilities:"); + for (const entry of packages.slice(0, 6)) { + lines.push(` - \`${entry.path}\`: ${describePackageRole(entry.role)}`); + } + } + + if (coderStudioWorkspace) { + lines.push("- Common source entrypoints:"); + lines.push( + " - Workspace and UI-triggered actions usually start in `packages/web/src/features/workspace/actions/`, then cross into `packages/server/src/ws/dispatch.ts` and `packages/server/src/commands/*.ts`." + ); + lines.push( + " - Agent-instructions work usually starts in `packages/web/src/features/workspace/actions/use-agent-instructions-actions.ts`, then flows through `packages/server/src/commands/agent-instructions.ts`, `packages/server/src/agent-instructions/agent-generator.ts`, `prompt.ts`, and `workspace/intelligence.ts`." + ); + lines.push( + " - Provider behavior usually starts in `packages/providers/src/*/definition.ts` and the provider-specific headless/supervisor builders, then runs through `packages/server/src/provider-runtime/command-runner.ts`." + ); + lines.push( + " - Shared contract changes usually start in `packages/core/src/domain/types.ts` and `packages/core/src/provider/definition.ts` before validating downstream server/web/provider consumers." + ); + } + + const documentationMap = buildDocumentationMap(summary); + if (documentationMap.length > 0) { + lines.push("- Documentation entrypoints:"); + for (const entry of documentationMap) { + lines.push(` - ${entry}`); + } + } + + if (lines.length > 0) { + return lines; + } + + if ((summary.topLevelDirectories?.length ?? 0) > 0) { + return summary.topLevelDirectories!.slice(0, 6).map((directory) => `- \`${directory}/\``); + } + + return ["- No package structure inferred from the workspace."]; +} + +function buildKeyDirectories(summary: WorkspaceIntelligenceSummary): string[] { + const keyDirectories = summary.keyDirectories ?? []; + if (keyDirectories.length === 0) { + return ["- No key directories inferred from the workspace."]; + } + + return keyDirectories.map((entry) => `- \`${entry.path}\`: ${entry.reason}`); +} + function buildDevelopmentCommands(summary: WorkspaceIntelligenceSummary): string[] { const lines: string[] = []; - const commandLabels: Record<"dev" | "test" | "build" | "lint", string> = { - dev: "Dev", - test: "Test", - build: "Build", - lint: "Lint", - }; + + for (const entry of summary.verificationCommands ?? []) { + lines.push( + `- ${formatCommandLabel(entry.priority)}: \`${entry.command}\` - ${describeCommandReason(entry)}` + ); + } for (const key of ["dev", "test", "build", "lint"] as const) { - const command = summary.recommendedCommands.find((item) => item.key === key)?.command; - if (!command) { + const entry = summary.recommendedCommands.find((item) => item.key === key); + if (!entry || lines.some((line) => line.includes(`\`${entry.command}\``))) { continue; } - lines.push(`- ${commandLabels[key]}: \`${command}\``); + lines.push( + `- ${formatRecommendedCommandLabel(key)}: \`${entry.command}\` - ${describeRecommendedCommandReason(key)}` + ); + } + + return lines.length > 0 ? lines : ["- No project commands were inferred."]; +} + +function buildFileConstraints(summary: WorkspaceIntelligenceSummary): string[] { + if ((summary.fileConstraints?.length ?? 0) > 0) { + return summary.fileConstraints!.map((constraint) => `- ${constraint}`); + } + + return [ + "- Keep edits scoped to the requested task.", + "- Follow the conventions of the package or directory you are touching.", + ]; +} + +function formatCommandLabel(priority: "verification" | "quality" | "dev"): string { + switch (priority) { + case "verification": + return "Verify"; + case "quality": + return "Quality"; + case "dev": + default: + return "Dev"; + } +} + +function formatRecommendedCommandLabel(key: "dev" | "test" | "build" | "lint"): string { + switch (key) { + case "dev": + return "Dev"; + case "test": + return "Test"; + case "build": + return "Build"; + case "lint": + default: + return "Lint"; + } +} + +function describePackageRole( + role: NonNullable[number]["role"] +): string { + switch (role) { + case "frontend_ui": + return "Owns UI, interaction flows, and client-side state orchestration."; + case "backend_runtime": + return "Owns commands, runtime behavior, workspace logic, and server-side orchestration."; + case "provider_integrations": + return "Owns provider definitions, headless scenarios, and external runtime adapters."; + case "shared_contracts": + return "Owns shared contracts, protocol shapes, and cross-package types."; + case "cli_entrypoint": + return "Owns CLI entrypoints and launcher behavior."; + case "shared_utilities": + return "Owns reusable helpers that should stay free of package-specific policy."; + case "shared_package": + default: + return "Supporting shared package; follow local patterns before broadening scope."; + } +} + +function buildUserFlow(packagePaths: Set): string[] { + const lines: string[] = []; + + if (packagePaths.has("packages/web") && packagePaths.has("packages/server")) { + lines.push( + "UI and interaction changes usually start in `packages/web`, then cross into `packages/server` when they need commands, persistence, or runtime side effects." + ); + } + + if (packagePaths.has("packages/web") && packagePaths.has("packages/core")) { + lines.push( + "If a UI change requires new shared data shapes, update `packages/core` deliberately and then verify downstream consumers in web/server/providers." + ); + } + + return lines; +} + +function buildRuntimeFlow(packagePaths: Set): string[] { + const lines: string[] = []; + + if (packagePaths.has("packages/server")) { + let serverFlow = + "`packages/server` is the orchestration layer for commands, runtime workflows, and workspace behavior"; + if (packagePaths.has("packages/providers")) { + serverFlow += "; provider-backed behavior usually continues into `packages/providers`."; + } else { + serverFlow += "."; + } + lines.push(serverFlow); + } + + if (packagePaths.has("packages/cli")) { + lines.push( + "CLI and launcher behavior should start in `packages/cli`; only drop into server/core when the entrypoint needs shared runtime logic." + ); + } + + if (packagePaths.has("packages/providers")) { + lines.push( + "Provider-specific behavior belongs in `packages/providers`; avoid pushing provider adapters or headless scenario rules into UI packages." + ); } return lines; } +function buildDocumentationMap(summary: WorkspaceIntelligenceSummary): string[] { + const documentationEntries = summary.documentationEntries ?? []; + if (documentationEntries.length === 0) { + return summary.docs.map((entry) => `\`${entry.path}\`: general repository documentation.`); + } + + return documentationEntries.slice(0, 4).map((entry) => { + return `\`${entry.path}\`: ${describeDocumentationPurpose(entry.path)}`; + }); +} + +function describeDocumentationPurpose(path: string): string { + if (path.includes("app-overview")) { + return "start here for product and application structure."; + } + if (path.includes("cli")) { + return "use for CLI flows and command behavior."; + } + if (path.includes("provider")) { + return "use for provider setup and integration behavior."; + } + if (path.includes("desktop")) { + return "use for desktop-specific UX and runtime behavior."; + } + if (path.includes("mobile")) { + return "use for mobile-specific UX behavior."; + } + if (path === "README.md") { + return "start here for repository orientation."; + } + + return "project documentation relevant to implementation details."; +} + +function describeCommandReason( + entry: NonNullable[number] +): string { + if (entry.command.includes("ci:verify")) { + return "full repository verification before handoff"; + } + if (entry.command.includes("ci:test")) { + return "main automated test entrypoint"; + } + if (entry.command.includes("typecheck")) { + return "cross-package type validation"; + } + if (entry.command.includes("build")) { + return "build validation for affected packages"; + } + if (entry.command.includes("lint")) { + return "repository lint checks"; + } + if (entry.command.includes("acceptance")) { + return "acceptance coverage when UI behavior changes"; + } + + return entry.reason.charAt(0).toLowerCase() + entry.reason.slice(1); +} + +function describeRecommendedCommandReason(key: "dev" | "test" | "build" | "lint"): string { + switch (key) { + case "dev": + return "local development entrypoint"; + case "test": + return "package-level test entrypoint"; + case "build": + return "package-level build entrypoint"; + case "lint": + default: + return "package-level lint entrypoint"; + } +} + +function isCoderStudioWorkspace(summary: WorkspaceIntelligenceSummary): boolean { + return (summary.packages ?? []).some((entry) => entry.name?.startsWith("@coder-studio/")); +} + function pushSection(lines: string[], heading: string, body: string[]): void { lines.push(`## ${heading}`, ""); lines.push(...body); diff --git a/packages/server/src/agent-instructions/health.ts b/packages/server/src/agent-instructions/health.ts index 5189a5320..78036006d 100644 --- a/packages/server/src/agent-instructions/health.ts +++ b/packages/server/src/agent-instructions/health.ts @@ -1,14 +1,14 @@ import type { AgentInstructionsHealth, AgentInstructionsHealthIssue } from "@coder-studio/core"; import { AGENT_INSTRUCTIONS_RELATIVE_PATH } from "../workspace/workspace-state.js"; -const REQUIRED_WORKING_RULES = [ +const REQUIRED_WORKFLOW_EXPECTATIONS = [ "Keep changes focused on the requested task.", "Do not revert user changes unless explicitly asked.", "Prefer the project's existing patterns.", "Run the relevant verification command before reporting completion.", ] as const; -const REQUIRED_REVIEW_EXPECTATIONS = [ +const REQUIRED_REVIEW_CHECKLIST = [ "Summarize changed files.", "Report verification commands and results.", "Call out risks, skipped tests, and assumptions.", @@ -42,13 +42,15 @@ export function evaluateAgentInstructionsMarkdown(content: string): AgentInstruc const sections = indexSections(content); const projectOverview = sections.has("Project Overview"); const developmentCommands = hasAnyBullet(sections.get("Development Commands")); - const workingRulesSection = sections.get("Working Rules"); - const reviewExpectationsSection = sections.get("Review Expectations"); + const workingRulesSection = + sections.get("Workflow Expectations") ?? sections.get("Working Rules"); + const reviewExpectationsSection = + sections.get("Review Checklist") ?? sections.get("Review Expectations"); const providerNotesSection = sections.get("Provider Notes"); const workingRules = hasAnyBullet(workingRulesSection); const reviewExpectations = hasAnyBullet(reviewExpectationsSection) && - REQUIRED_REVIEW_EXPECTATIONS.every((rule) => + REQUIRED_REVIEW_CHECKLIST.every((rule) => reviewExpectationsSection?.some((line) => line.includes(rule)) ); const providerNotes = @@ -56,7 +58,7 @@ export function evaluateAgentInstructionsMarkdown(content: string): AgentInstruc PROVIDER_NOTE_MARKERS.some((marker) => providerNotesSection?.some((line) => line.includes(marker)) ); - const safetyRules = REQUIRED_WORKING_RULES.every((rule) => + const safetyRules = REQUIRED_WORKFLOW_EXPECTATIONS.every((rule) => workingRulesSection?.some((line) => line.includes(rule)) ); @@ -76,19 +78,19 @@ export function evaluateAgentInstructionsMarkdown(content: string): AgentInstruc if (!workingRules) { issues.push({ code: "missing_working_rules", - message: "Working Rules section is missing", + message: "Workflow Expectations section is missing", }); } if (!reviewExpectations) { issues.push({ code: "missing_review_expectations", - message: "Review Expectations section is missing", + message: "Review Checklist section is missing", }); } if (!safetyRules) { issues.push({ code: "missing_safety_rules", - message: "Working rules do not include the required safety rules", + message: "Workflow expectations do not include the required safety rules", }); } if (!providerNotes) { diff --git a/packages/server/src/agent-instructions/output.ts b/packages/server/src/agent-instructions/output.ts new file mode 100644 index 000000000..c8a4a00d5 --- /dev/null +++ b/packages/server/src/agent-instructions/output.ts @@ -0,0 +1,202 @@ +type AgentInstructionsParseError = { + code: "agent_instructions_parse_failed"; + message: string; +}; + +type JsonRecord = Record; + +function createParseError(message: string): AgentInstructionsParseError { + return { + code: "agent_instructions_parse_failed", + message, + }; +} + +function unwrapCodeFence(content: string): string { + const trimmed = content.trim(); + return trimmed.match(/^```[^\n`]*\n([\s\S]*?)\n```$/)?.[1] ?? trimmed; +} + +function parseJsonRecord(raw: string, context: string): JsonRecord { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw createParseError(`${context} must be a JSON object`); + } + return parsed as JsonRecord; + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "agent_instructions_parse_failed" + ) { + throw error; + } + + const message = error instanceof Error ? error.message : "Unknown JSON parse failure"; + throw createParseError(`${context} contained invalid JSON: ${message}`); + } +} + +function tryParseJsonRecord(raw: string): JsonRecord | null { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as JsonRecord; + } catch { + return null; + } +} + +function extractEnvelopeResultText(providerId: string, record: JsonRecord): string | null { + if (record.is_error === true || record.subtype === "error_during_execution") { + const message = + typeof record.message === "string" + ? record.message + : typeof record.result === "string" + ? record.result + : typeof record.error === "string" + ? record.error + : `${providerId} reported an error in its result envelope`; + throw createParseError(message); + } + + if (typeof record.result === "string") { + return record.result; + } + + if (typeof record.content === "string") { + return record.content; + } + + return null; +} + +function extractAgentInstructionsReplyTextFromEnvelope(providerId: string, stdout: string): string { + const trimmed = stdout.trim(); + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const candidates = lines.length > 1 ? [trimmed, ...lines.slice().reverse()] : [trimmed]; + + for (const candidate of candidates) { + const record = tryParseJsonRecord(candidate); + if (!record) { + continue; + } + + const result = extractEnvelopeResultText(providerId, record); + if (typeof result === "string" && result.trim()) { + return result; + } + } + + throw createParseError(`${providerId} output did not contain a recognizable result envelope`); +} + +export function normalizeGeneratedAgentInstructionsMarkdown(content: string): string { + const unwrapped = unwrapCodeFence(content); + const normalized = `${unwrapped.trim()}\n`; + const firstLine = normalized.split("\n", 1)[0]; + + if (firstLine !== "# Agent Instructions") { + throw createParseError( + "Generated content must start with an exact '# Agent Instructions' heading" + ); + } + + return normalized; +} + +export function extractAgentInstructionsReplyText(providerId: string, stdout: string): string { + const trimmed = stdout.trim(); + if (!trimmed) { + throw createParseError("Agent instructions output was empty"); + } + + if (providerId === "codex") { + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + let latestText: string | null = null; + + for (const line of lines) { + let event: { + type?: string; + item?: { + type?: string; + item_type?: string; + text?: string; + }; + }; + + try { + event = JSON.parse(line) as typeof event; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown JSON parse failure"; + throw createParseError(`Codex output contained malformed JSONL: ${message}`); + } + + const itemType = event.item?.type ?? event.item?.item_type; + if (event.type === "item.completed" && itemType === "agent_message") { + latestText = event.item?.text ?? ""; + } + } + + if (!latestText?.trim()) { + throw createParseError("Codex output did not contain a completed agent_message"); + } + + return latestText; + } + + if (providerId === "claude" || providerId === "gemini" || providerId === "cursor") { + return extractAgentInstructionsReplyTextFromEnvelope(providerId, stdout); + } + + throw createParseError(`Unsupported agent instructions provider: ${providerId}`); +} + +export function parseGeneratedAgentInstructionsPayload(replyText: string): string { + const payload = parseJsonRecord( + unwrapCodeFence(replyText), + "Generated agent instructions payload" + ); + + if (payload.ok === false) { + const message = + typeof payload.message === "string" + ? payload.message + : typeof payload.error === "string" + ? payload.error + : "Agent instructions generation reported failure"; + throw createParseError(message); + } + + if (payload.ok !== true) { + throw createParseError("Generated agent instructions payload must set ok to true"); + } + + if (typeof payload.content !== "string") { + throw createParseError("Generated agent instructions payload must include a string content"); + } + + return normalizeGeneratedAgentInstructionsMarkdown(payload.content); +} + +export function extractAgentInstructionsMarkdownFromCodexJsonl(stdout: string): string { + const replyText = extractAgentInstructionsReplyText("codex", stdout); + const unwrapped = unwrapCodeFence(replyText); + + if (unwrapped.startsWith("{")) { + return parseGeneratedAgentInstructionsPayload(replyText); + } + + return normalizeGeneratedAgentInstructionsMarkdown(replyText); +} diff --git a/packages/server/src/agent-instructions/prompt.ts b/packages/server/src/agent-instructions/prompt.ts new file mode 100644 index 000000000..1b2566f76 --- /dev/null +++ b/packages/server/src/agent-instructions/prompt.ts @@ -0,0 +1,180 @@ +import type { WorkspaceIntelligenceSummary } from "@coder-studio/core"; + +const REQUIRED_WORKFLOW_EXPECTATIONS = [ + "Keep changes focused on the requested task.", + "Do not revert user changes unless explicitly asked.", + "Prefer the project's existing patterns.", + "Run the relevant verification command before reporting completion.", +] as const; + +const REQUIRED_REVIEW_CHECKLIST = [ + "Summarize changed files.", + "Report verification commands and results.", + "Call out risks, skipped tests, and assumptions.", +] as const; + +const REQUIRED_PROVIDER_NOTES = [ + "Claude Code: use the project rules above.", + "Codex: use the project rules above.", +] as const; + +export function buildAgentInstructionsGenerationPrompt( + summary: WorkspaceIntelligenceSummary +): string { + return [ + "You are generating a workspace-local agent instructions document.", + "Return exactly one JSON object and nothing else.", + "Do not wrap the JSON in code fences.", + "Do not add commentary, explanation, or preamble.", + "The JSON must follow this shape:", + "{", + ' "ok": true,', + ' "content": ""', + "}", + "If you cannot produce a reliable document from the provided facts, return:", + "{", + ' "ok": false,', + ' "error": ""', + "}", + "Rules for `content`:", + "- It must be a complete Markdown document.", + "- The first line must be exactly: # Agent Instructions", + "Use exactly these second-level sections in this order:", + "- Project Overview", + "- Architecture Map", + "- Key Directories", + "- Development Commands", + "- Workflow Expectations", + "- File Constraints", + "- Review Checklist", + "- Provider Notes", + "Do not add other sections.", + "Do not invent commands, tools, frameworks, or workflows that are not supported by the provided workspace facts.", + "If a command is unknown, omit it instead of guessing.", + "Keep the document concise, concrete, and project-specific.", + "Under 'Architecture Map', use a pure Markdown hierarchy only. Do not use Mermaid or code fences.", + "Under 'Architecture Map', optimize for change routing rather than directory listing.", + "Explain where an agent should usually start for UI changes, server/runtime changes, provider changes, shared-type changes, and CLI changes when the workspace facts support it.", + "Prefer responsibility boundaries and call-flow guidance over flat package dumps.", + "When the repository exposes recognizable source entrypoints, include representative file paths or folders that an agent should inspect first for each major workflow.", + "Prefer concrete call chains such as web action hook -> ws dispatch -> server command -> provider/runtime layer when those paths are supported by the repository.", + "If representative source entrypoints are provided in the workspace context, include them explicitly in the Architecture Map instead of collapsing them into package-only summaries.", + "Under 'Key Directories', include only 3-6 items with one-line reasons.", + "Under 'Development Commands', include at most 6 real commands, prioritizing repository-level verify/test/typecheck/build commands before local helper commands.", + "Exclude report-only or baseline-update helper commands unless they are the primary verification entrypoint.", + "Under 'Development Commands', prefer bullets in the form `-
) : null} {mobileOpen ? ( {content}} + body={ + +
{content}
+
+ } bodyClassName={sheetBodyClassName} onClose={() => onOpenChange(false)} title={title} diff --git a/packages/web/src/components/ui/tooltip/index.module.css b/packages/web/src/components/ui/tooltip/index.module.css index 05a6cc024..ce21cdc96 100644 --- a/packages/web/src/components/ui/tooltip/index.module.css +++ b/packages/web/src/components/ui/tooltip/index.module.css @@ -16,5 +16,6 @@ line-height: var(--type-body-5-line-height); font-weight: var(--type-body-5-weight); pointer-events: none; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } diff --git a/packages/web/src/components/ui/tooltip/index.styles.test.ts b/packages/web/src/components/ui/tooltip/index.styles.test.ts new file mode 100644 index 000000000..63c8115e6 --- /dev/null +++ b/packages/web/src/components/ui/tooltip/index.styles.test.ts @@ -0,0 +1,15 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const stylesheet = readFileSync( + `${process.cwd()}/src/components/ui/tooltip/index.module.css`, + "utf8" +); + +describe("Tooltip styles", () => { + it("wraps longer copy instead of forcing a single-line bubble", () => { + expect(stylesheet).toContain("white-space: normal;"); + expect(stylesheet).toContain("overflow-wrap: anywhere;"); + }); +}); diff --git a/packages/web/src/components/ui/tooltip/index.test.tsx b/packages/web/src/components/ui/tooltip/index.test.tsx index d25c594be..ce8c6dc0e 100644 --- a/packages/web/src/components/ui/tooltip/index.test.tsx +++ b/packages/web/src/components/ui/tooltip/index.test.tsx @@ -189,6 +189,30 @@ describe("Tooltip", () => { expect(screen.queryByRole("tooltip")).toBeNull(); }); + it("renders structured ReactNode tooltip content", () => { + render( + +
OpenAI
+
Summary: Active
+
Reason: Provider matched requested model
+ + } + > + +
+ ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + fireEvent.mouseEnter(trigger); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("OpenAI"); + expect(tooltip).toHaveTextContent("Summary: Active"); + expect(tooltip).toHaveTextContent("Reason: Provider matched requested model"); + }); + it("keeps the tooltip within the viewport when the trigger is near the right edge", () => { vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( this: HTMLElement diff --git a/packages/web/src/components/ui/tooltip/index.tsx b/packages/web/src/components/ui/tooltip/index.tsx index 15cd6e304..b2ef9a96f 100644 --- a/packages/web/src/components/ui/tooltip/index.tsx +++ b/packages/web/src/components/ui/tooltip/index.tsx @@ -7,6 +7,7 @@ import { type KeyboardEvent, type MouseEvent, type ReactElement, + type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useId, @@ -21,7 +22,7 @@ import styles from "./index.module.css"; export interface TooltipProps { readonly children: ReactElement; - readonly content: string; + readonly content: ReactNode; readonly disabled?: boolean; } diff --git a/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx b/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx index b91e082ea..3753d2c8c 100644 --- a/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx +++ b/packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx @@ -1,14 +1,207 @@ +import type { ProviderListItem } from "@coder-studio/core"; import { act, renderHook, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DispatchCommand } from "../../../atoms/connection"; +import { providerListAtom, providerRuntimeStatusAtom } from "../../../atoms/providers"; import { useProviderLauncher } from "./use-provider-launcher"; +function createProviderList(): ProviderListItem[] { + return [ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + requiredCommands: ["claude"], + }, + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + requiredCommands: ["codex"], + }, + { + id: "gemini", + displayName: "Gemini CLI", + badge: "Gemini", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + requiredCommands: ["gemini"], + }, + { + id: "cursor", + displayName: "Cursor Agent", + badge: "Cursor", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + requiredCommands: ["agent"], + }, + { + id: "opencode", + displayName: "OpenCode", + badge: "OpenCode", + kind: "built_in", + stability: "experimental", + capability: "limited", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor evaluation" }, + ], + requiredCommands: ["opencode"], + }, + ]; +} + +function createWrapper(store: ReturnType) { + return ({ children }: { children: ReactNode }) => {children}; +} + describe("useProviderLauncher", () => { afterEach(() => { vi.useRealTimers(); }); - it("auto-installs a missing provider CLI, refreshes status, and creates the session", async () => { + it("builds fallback runtime entries from provider metadata when runtime status is missing", async () => { + const store = createStore(); + store.set(providerListAtom, createProviderList()); + const wrapper = createWrapper(store); + const dispatch = vi.fn().mockResolvedValueOnce({ + ok: true, + data: { + providers: {}, + }, + }) as DispatchCommand; + + const onSessionCreated = vi.fn(); + + const { result } = renderHook( + () => + useProviderLauncher(dispatch, "ws-1", onSessionCreated, { + launchMode: "replace", + }), + { wrapper } + ); + + await waitFor(() => { + expect(result.current.states.codex?.runtime).toMatchObject({ + providerId: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + requiredCommands: ["codex"], + available: true, + missingCommands: [], + }); + }); + }); + + it("hydrates provider cards from cached provider metadata before provider.list resolves", async () => { + const dispatch = vi.fn().mockResolvedValueOnce({ + ok: true, + data: { + providers: {}, + }, + }) as DispatchCommand; + + const store = createStore(); + store.set(providerListAtom, createProviderList()); + const wrapper = createWrapper(store); + const onSessionCreated = vi.fn(); + + const { result } = renderHook( + () => + useProviderLauncher(dispatch, "ws-1", onSessionCreated, { + launchMode: "replace", + }), + { wrapper } + ); + + expect(result.current.providers.map((provider) => provider.id)).toEqual([ + "claude", + "codex", + "gemini", + "cursor", + "opencode", + ]); + expect(result.current.states.cursor?.runtime).toMatchObject({ + providerId: "cursor", + available: true, + missingCommands: [], + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith("provider.runtimeStatus", {}); + }); + + expect(dispatch).not.toHaveBeenCalledWith("provider.list", {}); + expect(store.get(providerRuntimeStatusAtom)).toEqual({}); + }); + + it("tracks provider ids directly from the shared provider atom after mount", async () => { + const dispatch = vi.fn().mockResolvedValue({ + ok: true, + data: { + providers: {}, + }, + }) as DispatchCommand; + + const store = createStore(); + store.set(providerListAtom, createProviderList().slice(0, 2)); + const wrapper = createWrapper(store); + + const { result } = renderHook( + () => + useProviderLauncher(dispatch, "ws-1", vi.fn(), { + launchMode: "replace", + }), + { wrapper } + ); + + expect(result.current.providers.map((provider) => provider.id)).toEqual(["claude", "codex"]); + + act(() => { + store.set(providerListAtom, createProviderList()); + }); + + expect(result.current.providers.map((provider) => provider.id)).toEqual([ + "claude", + "codex", + "gemini", + "cursor", + "opencode", + ]); + }); + + it("loads providers dynamically, auto-installs a missing CLI, refreshes status, and creates the session", async () => { + const store = createStore(); + store.set(providerListAtom, createProviderList()); + const wrapper = createWrapper(store); const dispatch = vi .fn() .mockResolvedValueOnce({ @@ -42,6 +235,45 @@ describe("useProviderLauncher", () => { prerequisites: {}, }, }, + gemini: { + providerId: "gemini", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, + cursor: { + providerId: "cursor", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, + opencode: { + providerId: "opencode", + available: false, + missingCommands: ["opencode"], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "unsupported_platform", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, }, }, }) @@ -96,6 +328,45 @@ describe("useProviderLauncher", () => { prerequisites: {}, }, }, + gemini: { + providerId: "gemini", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, + cursor: { + providerId: "cursor", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, + opencode: { + providerId: "opencode", + available: false, + missingCommands: ["opencode"], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "unsupported_platform", + manualGuideKeys: [], + docUrls: { + provider: "", + prerequisites: {}, + }, + }, }, }, }) @@ -115,15 +386,24 @@ describe("useProviderLauncher", () => { const onSessionCreated = vi.fn(); - const { result } = renderHook(() => - useProviderLauncher(dispatch, "ws-1", onSessionCreated, { - paneId: "pane-1", - launchMode: "assign", - }) + const { result } = renderHook( + () => + useProviderLauncher(dispatch, "ws-1", onSessionCreated, { + paneId: "pane-1", + launchMode: "assign", + }), + { wrapper } ); await waitFor(() => { - expect(result.current.states.claude.runtime?.available).toBe(false); + expect(result.current.providers.map((provider) => provider.id)).toEqual([ + "claude", + "codex", + "gemini", + "cursor", + "opencode", + ]); + expect(result.current.states.claude?.runtime?.available).toBe(false); }); const originalSetTimeout = window.setTimeout.bind(window); @@ -155,11 +435,10 @@ describe("useProviderLauncher", () => { await result.current.launch("claude"); }); - expect(dispatch).toHaveBeenNthCalledWith(2, "provider.install.start", { + expect(dispatch).toHaveBeenCalledWith("provider.install.start", { providerId: "claude", }); - expect(result.current.states.claude.installJob?.status).toBe("running"); - + expect(result.current.states.claude?.installJob?.status).toBe("running"); expect(pollHandler).not.toBeNull(); await act(async () => { @@ -190,6 +469,9 @@ describe("useProviderLauncher", () => { }); it("refreshes runtime status and exposes an inline error when session creation discovers a stale missing CLI", async () => { + const store = createStore(); + store.set(providerListAtom, createProviderList()); + const wrapper = createWrapper(store); const dispatch = vi .fn() .mockResolvedValueOnce({ @@ -270,12 +552,18 @@ describe("useProviderLauncher", () => { const onSessionCreated = vi.fn(); - const { result } = renderHook(() => - useProviderLauncher(dispatch, "ws-1", onSessionCreated, { - launchMode: "replace", - }) + const { result } = renderHook( + () => + useProviderLauncher(dispatch, "ws-1", onSessionCreated, { + launchMode: "replace", + }), + { wrapper } ); + await waitFor(() => { + expect(result.current.providers).toHaveLength(5); + }); + await act(async () => { await result.current.launch("claude"); }); @@ -290,9 +578,10 @@ describe("useProviderLauncher", () => { themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/), }) ); - expect(result.current.states.claude.runtime?.available).toBe(false); - expect(result.current.states.claude.inlineError).toBe("Claude CLI is missing"); + expect(result.current.states.claude?.runtime?.available).toBe(false); + expect(result.current.states.claude?.inlineError).toBe("Claude CLI is missing"); }); + expect(onSessionCreated).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts b/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts index f7340c996..8b7575d98 100644 --- a/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts +++ b/packages/web/src/features/agent-panes/actions/use-provider-launcher.ts @@ -1,15 +1,18 @@ import type { ProviderInstallFailure, ProviderInstallJobSnapshot, + ProviderListItem, ProviderRuntimeStatusEntry, ProviderRuntimeStatusResponse, Session, } from "@coder-studio/core"; -import { useEffect, useRef, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { DispatchCommand } from "../../../atoms/connection"; +import { providerListAtom, providerRuntimeStatusAtom } from "../../../atoms/providers"; import { useTerminalThemeBackground } from "../../../theme"; -export type ProviderId = "claude" | "codex"; +export type ProviderId = string; export interface ProviderCardState { runtime?: ProviderRuntimeStatusEntry; @@ -19,6 +22,7 @@ export interface ProviderCardState { } interface UseProviderLauncherResult { + providers: ProviderListItem[]; states: Record; launch: (providerId: ProviderId) => Promise; } @@ -27,9 +31,37 @@ function canAutoInstall(runtime: ProviderRuntimeStatusEntry): boolean { return runtime.autoInstallSupported && runtime.installReadiness === "ready"; } -function createFallbackRuntimeEntry(providerId: ProviderId): ProviderRuntimeStatusEntry { +function createFallbackProvider(providerId: string): ProviderListItem { + const title = providerId + .split(/[-_]/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); + + return { + id: providerId, + displayName: title, + badge: title, + kind: "built_in", + capability: "unsupported", + capabilities: [], + requiredCommands: [], + }; +} + +function createFallbackRuntimeEntry(provider: ProviderListItem): ProviderRuntimeStatusEntry { return { - providerId, + providerId: provider.id, + displayName: provider.displayName, + badge: provider.badge, + kind: provider.kind, + stability: provider.stability, + supportsAgentInstructions: provider.supportsAgentInstructions, + supportsAgentInstructionsGeneration: provider.supportsAgentInstructionsGeneration, + supportsSkillsMount: provider.supportsSkillsMount, + capability: provider.capability, + capabilities: provider.capabilities.map((capability) => ({ ...capability })), + requiredCommands: [...provider.requiredCommands], available: true, missingCommands: [], missingPrerequisites: [], @@ -43,19 +75,35 @@ function createFallbackRuntimeEntry(providerId: ProviderId): ProviderRuntimeStat }; } +function normalizeProviders( + providers?: ProviderListItem[], + providerRuntimeStatus?: ProviderRuntimeStatusResponse["providers"] +): ProviderListItem[] { + const orderedProviders = Array.isArray(providers) ? [...providers] : []; + const providerIds = new Set(orderedProviders.map((provider) => provider.id)); + + for (const providerId of Object.keys(providerRuntimeStatus ?? {})) { + if (!providerIds.has(providerId)) { + orderedProviders.push(createFallbackProvider(providerId)); + } + } + + return orderedProviders; +} + function buildStateMap( + providerList: ProviderListItem[], providers?: ProviderRuntimeStatusResponse["providers"] ): Record { - return { - claude: { - runtime: providers?.claude ?? createFallbackRuntimeEntry("claude"), - loading: false, - }, - codex: { - runtime: providers?.codex ?? createFallbackRuntimeEntry("codex"), - loading: false, - }, - }; + return Object.fromEntries( + providerList.map((provider) => [ + provider.id, + { + runtime: providers?.[provider.id] ?? createFallbackRuntimeEntry(provider), + loading: false, + }, + ]) + ); } export function useProviderLauncher( @@ -64,39 +112,77 @@ export function useProviderLauncher( onSessionCreated: (session: Session, providerId: ProviderId) => void, _continuation?: { paneId?: string; launchMode?: "assign" | "replace" } ): UseProviderLauncherResult { - const [states, setStates] = useState>(buildStateMap()); + const cachedProviders = useAtomValue(providerListAtom); + const providerRuntimeStatus = useAtomValue(providerRuntimeStatusAtom); + const setProviderRuntimeStatus = useSetAtom(providerRuntimeStatusAtom); + const [states, setStates] = useState>(() => + buildStateMap(cachedProviders) + ); + const providers = useMemo( + () => normalizeProviders(cachedProviders, providerRuntimeStatus), + [cachedProviders, providerRuntimeStatus] + ); + const cachedProvidersRef = useRef(cachedProviders); + const providersRef = useRef(providers); const pollingTimers = useRef>>({}); const themeBackground = useTerminalThemeBackground(); + useEffect(() => { + cachedProvidersRef.current = cachedProviders; + providersRef.current = providers; + setStates((prev) => + Object.fromEntries( + providers.map((provider) => [ + provider.id, + { + runtime: + providerRuntimeStatus?.[provider.id] ?? + prev[provider.id]?.runtime ?? + createFallbackRuntimeEntry(provider), + installJob: prev[provider.id]?.installJob, + loading: prev[provider.id]?.loading ?? false, + inlineError: prev[provider.id]?.inlineError, + }, + ]) + ) + ); + }, [cachedProviders, providerRuntimeStatus, providers]); + useEffect(() => { let cancelled = false; const loadStatus = async () => { - const result = await dispatch("provider.runtimeStatus", {}); - if (cancelled) { - return; - } + const runtimeResult = await dispatch( + "provider.runtimeStatus", + {} + ); - if (!result.ok || !result.data) { - setStates((prev) => ({ - claude: { ...prev.claude, ...buildStateMap().claude }, - codex: { ...prev.codex, ...buildStateMap().codex }, - })); + if (cancelled) { return; } - const nextStates = buildStateMap(result.data.providers); - - setStates((prev) => ({ - claude: { - ...prev.claude, - ...nextStates.claude, - }, - codex: { - ...prev.codex, - ...nextStates.codex, - }, - })); + const nextProviderList = cachedProvidersRef.current; + const nextProviderRuntimeStatus = runtimeResult.ok + ? runtimeResult.data?.providers + : undefined; + const nextProviders = normalizeProviders(nextProviderList, nextProviderRuntimeStatus); + const nextStates = buildStateMap(nextProviders, nextProviderRuntimeStatus); + + setProviderRuntimeStatus(nextProviderRuntimeStatus); + providersRef.current = nextProviders; + setStates((prev) => + Object.fromEntries( + nextProviders.map((provider) => [ + provider.id, + { + ...nextStates[provider.id], + installJob: prev[provider.id]?.installJob, + loading: prev[provider.id]?.loading ?? false, + inlineError: prev[provider.id]?.inlineError, + }, + ]) + ) + ); }; void loadStatus(); @@ -109,7 +195,7 @@ export function useProviderLauncher( } } }; - }, [dispatch]); + }, [dispatch, setProviderRuntimeStatus]); const refreshStatus = async (): Promise => { const result = await dispatch("provider.runtimeStatus", {}); @@ -117,20 +203,28 @@ export function useProviderLauncher( return; } - const nextStates = buildStateMap(result.data.providers); - - setStates((prev) => ({ - claude: { - ...prev.claude, - runtime: nextStates.claude.runtime, - loading: false, - }, - codex: { - ...prev.codex, - runtime: nextStates.codex.runtime, - loading: false, - }, - })); + const nextProviderList = normalizeProviders(providersRef.current, result.data.providers); + const nextStates = buildStateMap(nextProviderList, result.data.providers); + + providersRef.current = nextProviderList; + setProviderRuntimeStatus(result.data.providers); + + setStates((prev) => + Object.fromEntries( + nextProviderList.map((provider) => { + const nextState = nextStates[provider.id]; + + return [ + provider.id, + { + ...prev[provider.id], + runtime: nextState?.runtime ?? createFallbackRuntimeEntry(provider), + loading: false, + }, + ]; + }) + ) + ); }; const updateFailureState = ( @@ -144,12 +238,12 @@ export function useProviderLauncher( ...prev[providerId], loading: false, inlineError, - installJob: prev[providerId].installJob + installJob: prev[providerId]?.installJob ? { - ...prev[providerId].installJob!, + ...prev[providerId].installJob, failure, } - : prev[providerId].installJob, + : prev[providerId]?.installJob, }, })); }; @@ -174,7 +268,8 @@ export function useProviderLauncher( }; const launch = async (providerId: ProviderId): Promise => { - const runtime = states[providerId].runtime; + const state = states[providerId]; + const runtime = state?.runtime; if (!runtime) { return; } @@ -337,5 +432,5 @@ export function useProviderLauncher( pollingTimers.current[providerId] = window.setTimeout(poll, 1500); }; - return { states, launch }; + return { providers, states, launch }; } diff --git a/packages/web/src/features/agent-panes/components/session-card.test.tsx b/packages/web/src/features/agent-panes/components/session-card.test.tsx index 31dc2688e..14cefb737 100644 --- a/packages/web/src/features/agent-panes/components/session-card.test.tsx +++ b/packages/web/src/features/agent-panes/components/session-card.test.tsx @@ -769,6 +769,31 @@ describe("SessionCard", () => { expect(screen.queryByText(/^SESSION-/)).toBeNull(); }); + it("shows the full first submitted input in a tooltip when hovering the session title", () => { + const { store } = createSessionStore({ + state: "running", + endedAt: undefined, + title: "hello wor…", + firstSubmittedUserInput: "hello world this is a test", + }); + + render( + + + + ); + + const title = screen.getByText("hello wor…"); + + expect(screen.queryByRole("tooltip")).toBeNull(); + + fireEvent.mouseEnter(title); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("hello world this is a test"); + expect(title).toHaveAttribute("aria-describedby", tooltip.getAttribute("id") ?? ""); + }); + it("falls back to SESSION-XX while the session has no title yet", () => { const { store } = createSessionStore({ state: "running", diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index 57b320c22..ff38403f1 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@coder-studio/core"; +import type { ProviderRuntimeStatusResponse, Session } from "@coder-studio/core"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -201,6 +201,45 @@ const mockEditorPaneCard = vi.fn( ) ); +const AVAILABLE_PROVIDER_RUNTIME_STATUS: ProviderRuntimeStatusResponse = { + providers: { + claude: { + providerId: "claude", + displayName: "Claude", + badge: "Claude", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [], + requiredCommands: ["claude"], + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + codex: { + providerId: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [], + requiredCommands: ["codex"], + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: [], + docUrls: { provider: "", prerequisites: {} }, + }, + }, +}; + vi.mock("./views/shared/editor-pane-card", () => ({ EditorPaneCard: (props: { paneId: string; @@ -1436,6 +1475,9 @@ describe("AgentPanes", () => { it("disables provider buttons while session.create is in flight to prevent re-entry", async () => { let resolveCreate: ((value: unknown) => void) | undefined; const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "provider.runtimeStatus") { + return AVAILABLE_PROVIDER_RUNTIME_STATUS; + } if (op === "session.create") { return new Promise((resolve) => { resolveCreate = resolve; diff --git a/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx b/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx index e615250e0..ede312059 100644 --- a/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx +++ b/packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx @@ -1,4 +1,5 @@ -import { act, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import type { ProviderListItem } from "@coder-studio/core"; +import { createEvent, fireEvent, render, screen, within } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { localeAtom } from "../../../../atoms/app-ui"; @@ -10,8 +11,6 @@ const mockUseProviderLauncher = vi.fn(); const paneDragEnabledMock = vi.hoisted(() => ({ value: true, })); -const originalResizeObserver = global.ResizeObserver; - vi.mock("../../actions/use-provider-launcher", () => ({ useProviderLauncher: (...args: unknown[]) => mockUseProviderLauncher(...args), })); @@ -20,7 +19,25 @@ vi.mock("../../actions/use-pane-drag-enabled", () => ({ usePaneDragEnabled: () => paneDragEnabledMock.value, })); -function createRuntimeState(providerId: "claude" | "codex") { +function createProvider( + provider: Partial & Pick +): ProviderListItem { + return { + id: provider.id, + displayName: provider.displayName ?? provider.id, + badge: provider.badge ?? provider.id, + kind: provider.kind ?? "built_in", + stability: provider.stability, + capability: provider.capability ?? "full", + capabilities: provider.capabilities ?? [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + ], + requiredCommands: provider.requiredCommands ?? [provider.id], + }; +} + +function createRuntimeState(providerId: string) { return { runtime: { providerId, @@ -39,6 +56,36 @@ function createRuntimeState(providerId: "claude" | "codex") { }; } +function createProviderLauncherValue() { + return { + providers: [ + createProvider({ id: "claude", displayName: "Claude Code", badge: "Claude" }), + createProvider({ id: "codex", displayName: "Codex", badge: "Codex" }), + createProvider({ id: "gemini", displayName: "Gemini CLI", badge: "Gemini" }), + createProvider({ id: "cursor", displayName: "Cursor Agent", badge: "Cursor" }), + createProvider({ + id: "opencode", + displayName: "OpenCode", + badge: "OpenCode", + stability: "experimental", + capability: "limited", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor evaluation" }, + ], + }), + ], + states: { + claude: createRuntimeState("claude"), + codex: createRuntimeState("codex"), + gemini: createRuntimeState("gemini"), + cursor: createRuntimeState("cursor"), + opencode: createRuntimeState("opencode"), + }, + launch: vi.fn(), + }; +} + function createDraftLauncherStore() { const store = createStore(); @@ -51,55 +98,13 @@ function createDraftLauncherStore() { return store; } -function installResizeObserverMock() { - let callback: ResizeObserverCallback | null = null; - - class ResizeObserverMock { - constructor(observerCallback: ResizeObserverCallback) { - callback = observerCallback; - } - - observe() {} - disconnect() {} - } - - global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; - - return { - resize(target: Element, width: number) { - if (!callback) { - throw new Error("ResizeObserver was not created"); - } - - callback( - [ - { - target, - contentRect: { width }, - } as ResizeObserverEntry, - ], - {} as ResizeObserver - ); - }, - }; -} - describe("DraftLauncher", () => { - afterEach(() => { - global.ResizeObserver = originalResizeObserver; - vi.useRealTimers(); - }); + afterEach(() => {}); beforeEach(() => { vi.clearAllMocks(); paneDragEnabledMock.value = true; - mockUseProviderLauncher.mockReturnValue({ - states: { - claude: createRuntimeState("claude"), - codex: createRuntimeState("codex"), - }, - launch: vi.fn(), - }); + mockUseProviderLauncher.mockReturnValue(createProviderLauncherValue()); }); it("uses shared IconButton compatibility classes for header actions", () => { @@ -170,7 +175,7 @@ describe("DraftLauncher", () => { expect(onPaneDragStart).toHaveBeenCalledWith(expect.objectContaining({ paneId: "pane-1" })); }); - it("renders provider cards with semantic business icons", () => { + it("renders provider cards without capability metadata in the launcher", () => { const store = createStore(); store.set(localeAtom, "en"); @@ -185,8 +190,46 @@ describe("DraftLauncher", () => { ); - expect(container.querySelector('[data-icon-semantic="agent.provider.claude"]')).toBeTruthy(); - expect(container.querySelector('[data-icon-semantic="agent.provider.codex"]')).toBeTruthy(); + const claudeCard = container.querySelector(".agent-provider-card-claude"); + const codexCard = container.querySelector(".agent-provider-card-codex"); + const geminiCard = container.querySelector(".agent-provider-card-gemini"); + const cursorCard = container.querySelector(".agent-provider-card-cursor"); + const opencodeCard = container.querySelector(".agent-provider-card-opencode"); + + expect(claudeCard).not.toBeNull(); + expect(codexCard).not.toBeNull(); + expect(geminiCard).not.toBeNull(); + expect(cursorCard).not.toBeNull(); + expect(opencodeCard).not.toBeNull(); + expect(container.querySelector('[data-icon-semantic^="agent.provider."]')).toBeNull(); + expect(within(claudeCard as HTMLElement).getByText("CL")).toBeInTheDocument(); + expect(within(codexCard as HTMLElement).getByText("CO")).toBeInTheDocument(); + expect(within(geminiCard as HTMLElement).getByText("GE")).toBeInTheDocument(); + expect(within(cursorCard as HTMLElement).getByText("CU")).toBeInTheDocument(); + expect(within(opencodeCard as HTMLElement).getByText("OP")).toBeInTheDocument(); + expect(screen.queryByText("Limited Support")).not.toBeInTheDocument(); + expect(screen.queryByText("Experimental")).not.toBeInTheDocument(); + expect(screen.queryByText("Supervisor evaluation")).not.toBeInTheDocument(); + expect(screen.queryByText("Interactive session")).not.toBeInTheDocument(); + }); + + it("renders provider rows as single-line labels with a dedicated CTA column", () => { + const store = createDraftLauncherStore(); + const { container } = render( + + + + ); + + const claudeCard = container.querySelector(".agent-provider-card-claude"); + const claudeMeta = claudeCard?.querySelector(".agent-provider-card-meta"); + + expect(claudeCard).not.toBeNull(); + expect(claudeMeta).not.toBeNull(); + expect(container.querySelector(".agent-provider-card-subtitle")).toBeNull(); + expect(screen.queryByText("Claude Code")).not.toBeInTheDocument(); + expect(within(claudeCard as HTMLElement).getByText("Claude")).toBeInTheDocument(); + expect(within(claudeMeta as HTMLElement).getByText("Start Session")).toBeInTheDocument(); }); it("renders the agent selection title", () => { @@ -207,91 +250,46 @@ describe("DraftLauncher", () => { expect(screen.getByText("Select Agent")).toBeInTheDocument(); }); - it("switches draft launcher carousel panels", () => { + it("renders split helper copy inside the agent and file headers", () => { const store = createDraftLauncherStore(); - const { container } = render( ); - const agentButton = screen.getByRole("button", { name: "Agent" }); - const fileButton = screen.getByRole("button", { name: "File Editor" }); - const carouselTrack = container.querySelector(".agent-draft-component-row"); - - expect(agentButton).toHaveAttribute("aria-pressed", "true"); - expect(fileButton).toHaveAttribute("aria-pressed", "false"); - expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); - - fireEvent.click(fileButton); - - expect(agentButton).toHaveAttribute("aria-pressed", "false"); - expect(fileButton).toHaveAttribute("aria-pressed", "true"); - expect(carouselTrack).toHaveClass("agent-draft-component-row--file"); + const workarea = container.querySelector(".agent-draft-workarea"); + const workareaMain = container.querySelector(".agent-draft-workarea-main"); + const workareaSide = container.querySelector(".agent-draft-workarea-side"); + + expect(workarea).not.toBeNull(); + expect(workareaMain).not.toBeNull(); + expect(workareaSide).not.toBeNull(); + expect(container.querySelector(".agent-draft-footer")).toBeNull(); + expect(container.querySelector(".agent-draft-workarea-copy")).toBeNull(); + expect(container.querySelector(".agent-draft-panel-icon")).toBeNull(); + expect( + within(workareaMain as HTMLElement).getByText("Click to launch an Agent") + ).toBeInTheDocument(); + expect( + within(workareaSide as HTMLElement).getByText("Or drop files on the right side to open them") + ).toBeInTheDocument(); + expect(within(workareaSide as HTMLElement).getByText("Drop files to open")).toBeInTheDocument(); }); - it("auto-rotates draft launcher carousel panels in compact layout", async () => { - vi.useFakeTimers(); - const resizeObserver = installResizeObserverMock(); + it("does not render the old agent and file editor panel titles", () => { const store = createDraftLauncherStore(); - const { container } = render( ); - const launcher = container.querySelector(".agent-draft-launcher"); - const agentButton = screen.getByRole("button", { name: "Agent" }); - const fileButton = screen.getByRole("button", { name: "File Editor" }); - const carouselTrack = container.querySelector(".agent-draft-component-row"); - - expect(launcher).not.toBeNull(); - expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); - - act(() => { - resizeObserver.resize(launcher as Element, 360); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(4000); - }); - - expect(agentButton).toHaveAttribute("aria-pressed", "false"); - expect(fileButton).toHaveAttribute("aria-pressed", "true"); - expect(carouselTrack).toHaveClass("agent-draft-component-row--file"); - }); - - it("does not auto-rotate draft launcher carousel panels in wide layout", async () => { - vi.useFakeTimers(); - const resizeObserver = installResizeObserverMock(); - const store = createDraftLauncherStore(); - - const { container } = render( - - - + expect(screen.queryByText("File Editor")).not.toBeInTheDocument(); + expect(container.querySelector(".agent-draft-carousel-dots")).toBeNull(); + expect(container.querySelector(".agent-draft-component-row")).not.toHaveAttribute( + "data-active-panel" ); - - const launcher = container.querySelector(".agent-draft-launcher"); - const agentButton = screen.getByRole("button", { name: "Agent" }); - const fileButton = screen.getByRole("button", { name: "File Editor" }); - const carouselTrack = container.querySelector(".agent-draft-component-row"); - - expect(launcher).not.toBeNull(); - - act(() => { - resizeObserver.resize(launcher as Element, 640); - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(4000); - }); - - expect(agentButton).toHaveAttribute("aria-pressed", "true"); - expect(fileButton).toHaveAttribute("aria-pressed", "false"); - expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file"); }); it("renders a draft drop label when pane drag hover is active", () => { @@ -322,6 +320,7 @@ describe("DraftLauncher", () => { it("preserves session-start intent in diagnostics links for blocked providers", () => { mockUseProviderLauncher.mockReturnValue({ + providers: createProviderLauncherValue().providers, states: { claude: { runtime: { @@ -341,6 +340,9 @@ describe("DraftLauncher", () => { inlineError: "manual", }, codex: createRuntimeState("codex"), + gemini: createRuntimeState("gemini"), + cursor: createRuntimeState("cursor"), + opencode: createRuntimeState("opencode"), }, launch: vi.fn(), }); @@ -364,6 +366,58 @@ describe("DraftLauncher", () => { ); }); + it("does not show manual install guidance for providers that are already available", () => { + mockUseProviderLauncher.mockReturnValue({ + providers: createProviderLauncherValue().providers, + states: { + claude: createRuntimeState("claude"), + codex: createRuntimeState("codex"), + gemini: createRuntimeState("gemini"), + cursor: { + runtime: { + providerId: "cursor", + available: true, + missingCommands: [], + missingPrerequisites: [], + autoInstallSupported: false, + installReadiness: "ready", + manualGuideKeys: ["provider.install.cursor.manual"], + docUrls: { + provider: "https://cursor.com/docs/cli/installation", + prerequisites: {}, + }, + }, + loading: false, + }, + opencode: createRuntimeState("opencode"), + }, + launch: vi.fn(), + }); + + const store = createDraftLauncherStore(); + const { container } = render( + + + + ); + + const cursorCard = container.querySelector(".agent-provider-card-cursor"); + + expect(cursorCard).not.toBeNull(); + expect( + within(cursorCard as HTMLElement).queryByText( + "Install Cursor Agent from the official CLI installation guide, then make sure agent is available on PATH." + ) + ).toBeNull(); + expect( + within(cursorCard as HTMLElement).queryByRole("link", { name: "Open official docs" }) + ).toBeNull(); + expect( + within(cursorCard as HTMLElement).queryByRole("link", { name: "Open Diagnostics" }) + ).toBeNull(); + expect(within(cursorCard as HTMLElement).getByText("Start Session")).toBeInTheDocument(); + }); + it("highlights file drag-over state and opens the dropped workspace file in an editor pane", async () => { const store = createStore(); const onOpenFile = vi.fn(); diff --git a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx index 625e142f8..60335c7d6 100644 --- a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx +++ b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx @@ -1,10 +1,10 @@ import type { Session } from "@coder-studio/core"; import { useAtomValue, useSetAtom } from "jotai"; -import { ArrowRight, FlipHorizontal, FlipVertical, GripVertical, X } from "lucide-react"; -import { type DragEvent, type FC, type PointerEvent, useEffect, useRef, useState } from "react"; +import { FlipHorizontal, FlipVertical, GripVertical, X } from "lucide-react"; +import { type DragEvent, type FC, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../../atoms/connection"; import { sessionsAtom } from "../../../../atoms/sessions"; -import { Button, IconButton, StatusDot, Tag, ThemedIcon, Tooltip } from "../../../../components/ui"; +import { Button, IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { getWorkspacePathDragPayload, @@ -16,24 +16,6 @@ import type { PaneDragSourceSnapshot } from "../../actions/use-pane-drag-control import { usePaneDragEnabled } from "../../actions/use-pane-drag-enabled"; import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher"; -const COMPACT_CAROUSEL_MAX_WIDTH_REM = 28; -const COMPACT_CAROUSEL_INTERVAL_MS = 4000; - -function getCompactCarouselMaxWidthPx(): number { - if (typeof window === "undefined" || typeof document === "undefined") { - return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16; - } - - const rootFontSize = window.getComputedStyle(document.documentElement).fontSize; - const parsedRootFontSize = Number.parseFloat(rootFontSize); - - if (!Number.isFinite(parsedRootFontSize) || parsedRootFontSize <= 0) { - return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16; - } - - return COMPACT_CAROUSEL_MAX_WIDTH_REM * parsedRootFontSize; -} - interface DraftLauncherDragState { isDragging: boolean; isActiveDropTarget: boolean; @@ -68,15 +50,12 @@ export const DraftLauncher: FC = ({ const t = useTranslation(); const dispatch = useAtomValue(dispatchCommandAtom); const setSessions = useSetAtom(sessionsAtom); - const [activePanel, setActivePanel] = useState<"agent" | "file">("agent"); - const [isCompactCarousel, setIsCompactCarousel] = useState(false); const [isFileDropTarget, setIsFileDropTarget] = useState(false); const draftLauncherRef = useRef(null); - const swipeStartXRef = useRef(null); const supportsPaneDrag = usePaneDragEnabled(); const canDragPane = supportsPaneDrag && Boolean(paneId && onPaneDragStart); const paneDropOverlayPlacement = dragState?.isActiveDropTarget ? dragState.hoverPlacement : null; - const { states, launch } = useProviderLauncher( + const { providers, states, launch } = useProviderLauncher( dispatch, workspaceId, (session: Session, _providerId: ProviderId) => { @@ -97,6 +76,14 @@ export const DraftLauncher: FC = ({ } ); + const renderProviderIcon = (title: string) => { + return ( + + ); + }; + const buildProviderDiagnosticsPath = (providerId: ProviderId) => buildDiagnosticsPath({ context: "session_start", @@ -107,12 +94,16 @@ export const DraftLauncher: FC = ({ }); const canAutoInstall = (providerId: ProviderId): boolean => { - const runtime = states[providerId].runtime; + const runtime = states[providerId]?.runtime; return Boolean(runtime?.autoInstallSupported && runtime.installReadiness === "ready"); }; - const _getProviderCta = (providerId: ProviderId): string => { + const getProviderCta = (providerId: ProviderId): string => { const state = states[providerId]; + if (!state) { + return t("provider.install.cta.start"); + } + if ( state.loading || state.installJob?.status === "queued" || @@ -131,6 +122,16 @@ export const DraftLauncher: FC = ({ const getProviderGuide = (providerId: ProviderId): { message?: string; docUrl?: string } => { const state = states[providerId]; + if (!state) { + return {}; + } + + if (state.runtime?.available) { + return { + docUrl: state.runtime.docUrls.provider, + }; + } + const failure = state.installJob?.failure; if (failure) { @@ -218,90 +219,6 @@ export const DraftLauncher: FC = ({ onOpenFile?.(paneId, path); }; - const handleCarouselPointerDown = (event: PointerEvent) => { - if (event.pointerType === "mouse") { - return; - } - - swipeStartXRef.current = event.clientX; - }; - - const handleCarouselPointerUp = (event: PointerEvent) => { - const startX = swipeStartXRef.current; - swipeStartXRef.current = null; - - if (startX === null || event.pointerType === "mouse") { - return; - } - - const deltaX = event.clientX - startX; - if (Math.abs(deltaX) < 48) { - return; - } - - setActivePanel(deltaX < 0 ? "file" : "agent"); - }; - - const handleCarouselPointerCancel = () => { - swipeStartXRef.current = null; - }; - - useEffect(() => { - const element = draftLauncherRef.current; - - if (!element) { - return; - } - - const updateCompactState = (width: number) => { - setIsCompactCarousel(width > 0 && width <= getCompactCarouselMaxWidthPx()); - }; - - updateCompactState(element.getBoundingClientRect().width); - - if (typeof ResizeObserver === "function") { - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - - if (!entry) { - return; - } - - updateCompactState(entry.contentRect.width || entry.target.getBoundingClientRect().width); - }); - - observer.observe(element); - - return () => { - observer.disconnect(); - }; - } - - const handleWindowResize = () => { - updateCompactState(element.getBoundingClientRect().width); - }; - - window.addEventListener("resize", handleWindowResize); - - return () => { - window.removeEventListener("resize", handleWindowResize); - }; - }, []); - - useEffect(() => { - if (!isCompactCarousel) { - return; - } - - const timer = window.setTimeout(() => { - setActivePanel((currentPanel) => (currentPanel === "agent" ? "file" : "agent")); - }, COMPACT_CAROUSEL_INTERVAL_MS); - - return () => { - window.clearTimeout(timer); - }; - }, [activePanel, isCompactCarousel]); - return (
= ({
-
-
-
+
+
+
- - + + {t("agent_panes.draft_agent_header")} - {t("agent_panes.agent_panel")}
- {( - [ - { - id: "claude", - title: "Claude", - icon: , - className: "agent-provider-card-claude", - }, - { - id: "codex", - title: "Codex", - icon: , - className: "agent-provider-card-codex", - }, - ] as const - ).map((provider) => { + {providers.map((provider) => { const state = states[provider.id]; + if (!state) { + return null; + } + const guide = getProviderGuide(provider.id); const isBusy = state.loading || state.installJob?.status === "queued" || state.installJob?.status === "running"; + const title = provider.badge || provider.displayName || provider.id; + const cardClasses = [ + "agent-provider-card", + `agent-provider-card-${provider.id}`, + provider.stability ? `agent-provider-card--${provider.stability}` : "", + ] + .filter(Boolean) + .join(" "); return (
-
+ -
+
- - - - - + + {t("agent_panes.draft_file_header")} - {t("agent_panes.file_editor")}
@@ -527,30 +428,8 @@ export const DraftLauncher: FC = ({ {t("agent_panes.drop_file_to_open")}
-
-
- -
- {[ - { id: "agent" as const, label: t("agent_panes.agent_panel") }, - { id: "file" as const, label: t("agent_panes.file_editor") }, - ].map((panel) => ( -
- -
{t("agent_panes.draft_footer")}
diff --git a/packages/web/src/features/agent-panes/views/shared/session-card.tsx b/packages/web/src/features/agent-panes/views/shared/session-card.tsx index c25242168..7b61e799e 100644 --- a/packages/web/src/features/agent-panes/views/shared/session-card.tsx +++ b/packages/web/src/features/agent-panes/views/shared/session-card.tsx @@ -114,6 +114,7 @@ export const SessionCard: FC = ({ } const sessionTitle = session.title?.trim() || formatSessionLabel(session.id); + const sessionTitleTooltip = session.firstSubmittedUserInput?.trim(); const providerLabel = formatProviderLabel(session.providerId); const sessionStateLabel = formatSessionStateLabel(session.state, t); const terminalReadOnly = terminalReadOnlyOverride ?? !isSessionInteractive(session.state); @@ -197,6 +198,7 @@ export const SessionCard: FC = ({ ) { @@ -46,6 +47,54 @@ describe("useAgentProviders", () => { ], requiredCommands: ["codex"], }, + { + id: "gemini", + displayName: "Gemini CLI", + badge: "Gemini", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["gemini"], + }, + { + id: "cursor", + displayName: "Cursor Agent", + badge: "Cursor", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["agent"], + }, + { + id: "opencode", + displayName: "OpenCode", + badge: "OpenCode", + kind: "built_in", + stability: "experimental", + capability: "limited", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor evaluation" }, + { key: "idle_detection", supported: true, label: "Idle detection" }, + { key: "context_attach", supported: false, label: "Context attach" }, + { key: "review", supported: false, label: "Review" }, + ], + requiredCommands: ["opencode"], + }, ]); const store = createStore(); @@ -70,6 +119,64 @@ describe("useAgentProviders", () => { id: "codex", kind: "built_in", }), + expect.objectContaining({ + id: "gemini", + stability: "stable", + }), + expect.objectContaining({ + id: "cursor", + requiredCommands: ["agent"], + }), + expect.objectContaining({ + id: "opencode", + capability: "limited", + stability: "experimental", + }), ]); }); + + it("returns the shared provider atom and writes refresh results back into it", async () => { + const sendCommand = vi.fn().mockResolvedValue([ + { + id: "claude", + displayName: "Claude Code", + badge: "Claude", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + ], + requiredCommands: ["claude"], + }, + ]); + + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(providerListAtom, [ + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + capability: "full", + capabilities: [ + { key: "interactive_session", supported: true, label: "Interactive session" }, + ], + requiredCommands: ["codex"], + }, + ]); + + const { result } = renderHook(() => useAgentProviders(), { + wrapper: wrapperFor(store), + }); + + expect(result.current.providers.map((provider) => provider.id)).toEqual(["codex"]); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(store.get(providerListAtom).map((provider) => provider.id)).toEqual(["claude"]); + expect(result.current.providers.map((provider) => provider.id)).toEqual(["claude"]); + }); }); diff --git a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts index 1c831a92f..73beaae87 100644 --- a/packages/web/src/features/agent-providers/actions/use-agent-providers.ts +++ b/packages/web/src/features/agent-providers/actions/use-agent-providers.ts @@ -1,7 +1,8 @@ import type { ProviderListItem } from "@coder-studio/core"; -import { useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; +import { providerListAtom } from "../../../atoms/providers"; import { useTranslation } from "../../../lib/i18n"; interface UseAgentProvidersResult { @@ -11,11 +12,19 @@ interface UseAgentProvidersResult { refresh: () => Promise; } +function normalizeProviders(providers: ProviderListItem[]): ProviderListItem[] { + return providers.map((provider) => ({ + ...provider, + capabilities: provider.capabilities.map((capability) => ({ ...capability })), + requiredCommands: [...provider.requiredCommands], + })); +} + export function useAgentProviders(): UseAgentProvidersResult { const t = useTranslation(); - const dispatch = useAtomValue(dispatchCommandAtom); - const [providers, setProviders] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [dispatch] = useAtom(dispatchCommandAtom); + const [providers, setProviders] = useAtom(providerListAtom); + const [isLoading, setIsLoading] = useState(providers.length === 0); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -24,13 +33,12 @@ export function useAgentProviders(): UseAgentProvidersResult { const result = await dispatch("provider.list", {}); if (!result.ok || !result.data) { - setProviders([]); setError(result.error?.message ?? t("provider.load_failed")); setIsLoading(false); return; } - setProviders(result.data); + setProviders(normalizeProviders(result.data)); setError(null); setIsLoading(false); }, [dispatch, t]); diff --git a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts index bd8da6ec5..0f6976035 100644 --- a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts +++ b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts @@ -1,4 +1,8 @@ -import type { GitCommitFileEntry, GitFileDiffPayload } from "@coder-studio/core"; +import type { + AgentInstructionsSystemDocument, + GitCommitFileEntry, + GitFileDiffPayload, +} from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../atoms/connection"; @@ -20,6 +24,7 @@ import { type WorkspaceEditorMode, } from "../../workspace/atoms"; import { monacoModelRegistry } from "../monaco/model-registry"; +import { parseSystemAgentInstructionsEditorPath } from "../system-agent-instructions-path"; import { beginPendingEditorLoad, cancelPendingEditorLoad, @@ -46,6 +51,22 @@ type FileReadImagePayload = { }; type FileReadPayload = FileReadTextPayload | FileReadImagePayload; +type EditorReadTextPayload = FileReadTextPayload & { + displayPath?: string; + exists?: boolean; +}; +type EditorReadPayload = EditorReadTextPayload | FileReadImagePayload; + +function toSystemFileReadPayload(document: AgentInstructionsSystemDocument): EditorReadTextPayload { + return { + kind: "text", + content: document.content, + baseHash: document.baseHash ?? "", + encoding: "utf-8", + displayPath: document.displayPath, + exists: document.exists, + }; +} export function useCodeEditorActions() { const t = useTranslation(); @@ -179,10 +200,16 @@ export function useCodeEditorActions() { const requestId = beginPendingEditorLoad(workspaceId, path); setFileLoadError((current) => (current?.path === path ? null : current)); - const result = await dispatch("file.read", { - workspaceId, - path, - }); + const systemProviderId = parseSystemAgentInstructionsEditorPath(path); + const result = systemProviderId + ? await dispatch("agentInstructions.system.read", { + workspaceId, + providerId: systemProviderId, + }) + : await dispatch("file.read", { + workspaceId, + path, + }); if (shouldIgnorePendingEditorLoadResult(workspaceId, path, requestId)) { return; @@ -196,7 +223,9 @@ export function useCodeEditorActions() { return; } - const data = result.data; + const data: EditorReadPayload = systemProviderId + ? toSystemFileReadPayload(result.data as AgentInstructionsSystemDocument) + : (result.data as FileReadPayload); if (options?.forceText && data.kind === "image" && data.isTextBacked) { try { @@ -230,7 +259,7 @@ export function useCodeEditorActions() { finishPendingEditorLoad(workspaceId, path, requestId); setOpenFiles((prev) => ({ ...prev, [path]: newFile })); - if (workspaceRootPath) { + if (workspaceRootPath && !systemProviderId) { monacoModelRegistry.updateFromDisk({ workspaceRootPath, path, @@ -254,6 +283,7 @@ export function useCodeEditorActions() { ? { kind: "text", path, + displayPath: data.displayPath, content: data.content, savedContent: data.content, baseHash: data.baseHash, @@ -273,7 +303,7 @@ export function useCodeEditorActions() { finishPendingEditorLoad(workspaceId, path, requestId); setOpenFiles((prev) => ({ ...prev, [path]: newFile })); - if (workspaceRootPath && data.kind === "text") { + if (workspaceRootPath && data.kind === "text" && !systemProviderId) { monacoModelRegistry.updateFromDisk({ workspaceRootPath, path, @@ -316,12 +346,20 @@ export function useCodeEditorActions() { setSavingPaths(nextSavingPaths); setSaveError((current) => (current?.path === path ? null : current)); - const result = await dispatch<{ newHash: string }>("file.write", { - workspaceId, - path, - content, - baseHash: baseHash || undefined, - }); + const systemProviderId = parseSystemAgentInstructionsEditorPath(path); + const result = systemProviderId + ? await dispatch("agentInstructions.system.write", { + workspaceId, + providerId: systemProviderId, + content, + baseHash: baseHash || undefined, + }) + : await dispatch<{ newHash: string }>("file.write", { + workspaceId, + path, + content, + baseHash: baseHash || undefined, + }); if (activeSaveRequestIdByPathRef.current.get(path) !== requestId) { return; @@ -334,12 +372,20 @@ export function useCodeEditorActions() { return prev; } + const nextBaseHash = systemProviderId + ? ((result.data as AgentInstructionsSystemDocument).baseHash ?? "") + : (result.data as { newHash: string }).newHash; + const nextDisplayPath = systemProviderId + ? (result.data as AgentInstructionsSystemDocument).displayPath + : prevFile.displayPath; + return { ...prev, [path]: { ...prevFile, savedContent: content, - baseHash: result.data!.newHash, + baseHash: nextBaseHash, + displayPath: nextDisplayPath, isDirty: false, externalState: undefined, }, @@ -412,17 +458,26 @@ export function useCodeEditorActions() { const reconcileOpenFiles = async () => { for (const [path, file] of entries) { - const result = await dispatch("file.read", { - workspaceId, - path, - }); + const systemProviderId = parseSystemAgentInstructionsEditorPath(path); + const result = systemProviderId + ? await dispatch("agentInstructions.system.read", { + workspaceId, + providerId: systemProviderId, + }) + : await dispatch("file.read", { + workspaceId, + path, + }); if (cancelled) { return; } - if (!result.ok || !result.data) { - const isMissing = result.error?.code === "not_found"; + const systemDocument = systemProviderId + ? (result.data as AgentInstructionsSystemDocument | undefined) + : undefined; + if (!result.ok || !result.data || (systemDocument && !systemDocument.exists)) { + const isMissing = systemDocument ? true : result.error?.code === "not_found"; setOpenFiles((prev) => { const existing = prev[path]; if (!existing) { @@ -442,7 +497,9 @@ export function useCodeEditorActions() { continue; } - const nextData = result.data; + const nextData: EditorReadPayload = systemProviderId + ? toSystemFileReadPayload(result.data as AgentInstructionsSystemDocument) + : (result.data as FileReadPayload); if (file.kind === "text" && nextData.kind === "text") { const hasChangedOnDisk = nextData.baseHash !== file.baseHash; @@ -475,6 +532,7 @@ export function useCodeEditorActions() { [path]: { kind: "text", path, + displayPath: nextData.displayPath ?? file.displayPath, content: nextData.content, savedContent: nextData.content, baseHash: nextData.baseHash, @@ -483,7 +541,7 @@ export function useCodeEditorActions() { viewingTextBackedImageAsText: file.viewingTextBackedImageAsText, }, })); - if (workspaceRootPath) { + if (workspaceRootPath && !systemProviderId) { monacoModelRegistry.updateFromDisk({ workspaceRootPath, path, diff --git a/packages/web/src/features/code-editor/components/image-preview.test.tsx b/packages/web/src/features/code-editor/components/image-preview.test.tsx index 298102696..6cbe7a9f8 100644 --- a/packages/web/src/features/code-editor/components/image-preview.test.tsx +++ b/packages/web/src/features/code-editor/components/image-preview.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { createEvent, fireEvent, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import type { PropsWithChildren, ReactElement } from "react"; import { describe, expect, it } from "vitest"; @@ -82,4 +82,222 @@ describe("ImagePreview", () => { "/api/file?workspaceId=ws-1&path=logo.png&v=7" ); }); + + it("renders accessible zoom controls in the preview footer", () => { + renderWithLocale( + + ); + + expect(screen.getByRole("toolbar", { name: "Image zoom controls" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Zoom out" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Zoom in" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fit to window" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Actual size" })).toBeInTheDocument(); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("Fit"); + }); + + it("updates the zoom level when the zoom buttons are used", () => { + renderWithLocale( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("125%"); + + fireEvent.click(screen.getByRole("button", { name: "Zoom out" })); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("100%"); + + fireEvent.click(screen.getByRole("button", { name: "Fit to window" })); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("Fit"); + + fireEvent.click(screen.getByRole("button", { name: "Actual size" })); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("100%"); + }); + + it("zooms with modified wheel input while normal wheel leaves zoom unchanged", () => { + renderWithLocale( + + ); + + const canvas = document.querySelector(".image-preview-canvas"); + expect(canvas).toBeTruthy(); + + const normalWheel = createEvent.wheel(canvas as HTMLElement, { + cancelable: true, + deltaY: -120, + }); + fireEvent(canvas as HTMLElement, normalWheel); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("Fit"); + + const zoomWheel = createEvent.wheel(canvas as HTMLElement, { + cancelable: true, + ctrlKey: true, + deltaY: -120, + }); + fireEvent(canvas as HTMLElement, zoomWheel); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("125%"); + }); + + it("resets the zoom level when only the version changes", () => { + const { rerender } = renderWithLocale( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("125%"); + + rerender( + + ); + + expect(screen.getByLabelText("Zoom level")).toHaveTextContent("Fit"); + }); + + it("pans a manually zoomed image with mouse drag", () => { + renderWithLocale( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Actual size" })); + + const canvas = document.querySelector(".image-preview-canvas") as HTMLElement; + canvas.scrollLeft = 80; + canvas.scrollTop = 40; + + fireEvent.pointerDown(canvas, { + button: 0, + clientX: 200, + clientY: 160, + pointerId: 1, + pointerType: "mouse", + }); + fireEvent.pointerMove(canvas, { + clientX: 150, + clientY: 120, + pointerId: 1, + pointerType: "mouse", + }); + + expect(canvas.scrollLeft).toBe(130); + expect(canvas.scrollTop).toBe(80); + + fireEvent.pointerUp(canvas, { + clientX: 150, + clientY: 120, + pointerId: 1, + pointerType: "mouse", + }); + expect(canvas).not.toHaveClass("image-preview-canvas--dragging"); + }); + + it("pans a manually zoomed image with a touch pointer gesture", () => { + renderWithLocale( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Actual size" })); + + const canvas = document.querySelector(".image-preview-canvas") as HTMLElement; + canvas.scrollLeft = 80; + canvas.scrollTop = 40; + + fireEvent.pointerDown(canvas, { + clientX: 100, + clientY: 100, + pointerId: 2, + pointerType: "touch", + }); + fireEvent.pointerMove(canvas, { + clientX: 130, + clientY: 70, + pointerId: 2, + pointerType: "touch", + }); + + expect(canvas.scrollLeft).toBe(50); + expect(canvas.scrollTop).toBe(70); + expect(canvas).toHaveClass("image-preview-canvas--dragging"); + + fireEvent.pointerCancel(canvas, { + pointerId: 2, + pointerType: "touch", + }); + expect(canvas).not.toHaveClass("image-preview-canvas--dragging"); + }); + + it("keeps fit mode drag from hijacking the preview canvas scroll", () => { + renderWithLocale( + + ); + + const canvas = document.querySelector(".image-preview-canvas") as HTMLElement; + canvas.scrollLeft = 80; + canvas.scrollTop = 40; + + fireEvent.pointerDown(canvas, { + button: 0, + clientX: 200, + clientY: 160, + pointerId: 1, + pointerType: "mouse", + }); + fireEvent.pointerMove(canvas, { + clientX: 150, + clientY: 120, + pointerId: 1, + pointerType: "mouse", + }); + + expect(canvas.scrollLeft).toBe(80); + expect(canvas.scrollTop).toBe(40); + expect(canvas).not.toHaveClass("image-preview-canvas--dragging"); + }); }); diff --git a/packages/web/src/features/code-editor/components/image-preview.tsx b/packages/web/src/features/code-editor/components/image-preview.tsx index f9070ff08..e1040ac9c 100644 --- a/packages/web/src/features/code-editor/components/image-preview.tsx +++ b/packages/web/src/features/code-editor/components/image-preview.tsx @@ -7,9 +7,10 @@ * HTTP endpoint so large images don't have to travel over the WS channel. */ -import type { FC } from "react"; -import { useEffect, useState } from "react"; -import { EmptyState } from "../../../components/ui"; +import { Maximize2, Scan, ZoomIn, ZoomOut } from "lucide-react"; +import type { CSSProperties, FC, PointerEvent, WheelEvent } from "react"; +import { useEffect, useRef, useState } from "react"; +import { EmptyState, IconButton, Tooltip } from "../../../components/ui"; import { useTranslation } from "../../../lib/i18n"; interface ImagePreviewProps { @@ -20,6 +21,20 @@ interface ImagePreviewProps { alt: string; } +type ZoomMode = "fit" | "manual"; + +interface PanState { + pointerId: number; + startX: number; + startY: number; + scrollLeft: number; + scrollTop: number; +} + +const MIN_ZOOM = 0.25; +const MAX_ZOOM = 4; +const ZOOM_STEP = 0.25; + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -33,10 +48,23 @@ function mimeToLabel(mime: string): string { return head.toUpperCase(); } +function clampZoom(value: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2)))); +} + +function formatZoom(value: number): string { + return `${Math.round(value * 100)}%`; +} + export const ImagePreview: FC = ({ url, version, mime, sizeBytes, alt }) => { const t = useTranslation(); + const canvasRef = useRef(null); + const panStateRef = useRef(null); const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const [errored, setErrored] = useState(false); + const [zoomMode, setZoomMode] = useState("fit"); + const [zoom, setZoom] = useState(1); + const [isPanning, setIsPanning] = useState(false); const src = `${url}${url.includes("?") ? "&" : "?"}v=${version}`; useEffect(() => { @@ -44,11 +72,115 @@ export const ImagePreview: FC = ({ url, version, mime, sizeBy // previous file don't get shown for a frame. setDimensions(null); setErrored(false); + setZoomMode("fit"); + setZoom(1); + panStateRef.current = null; + setIsPanning(false); }, [url, version]); + const changeZoom = (delta: number) => { + setZoom((currentZoom) => clampZoom((zoomMode === "fit" ? 1 : currentZoom) + delta)); + setZoomMode("manual"); + }; + + const stopPan = (event?: PointerEvent) => { + const panState = panStateRef.current; + if (!panState || (event && event.pointerId !== panState.pointerId)) { + return; + } + + if (event?.currentTarget.hasPointerCapture?.(panState.pointerId)) { + event.currentTarget.releasePointerCapture(panState.pointerId); + } + + panStateRef.current = null; + setIsPanning(false); + }; + + const handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey && !event.metaKey) { + return; + } + + event.preventDefault(); + changeZoom(event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP); + }; + + const handleActualSize = () => { + setZoomMode("manual"); + setZoom(1); + }; + + const handleFit = () => { + setZoomMode("fit"); + setZoom(1); + stopPan(); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (zoomMode !== "manual" || errored) { + return; + } + + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + + const canvas = event.currentTarget; + panStateRef.current = { + pointerId: event.pointerId, + scrollLeft: canvas.scrollLeft, + scrollTop: canvas.scrollTop, + startX: event.clientX, + startY: event.clientY, + }; + setIsPanning(true); + canvas.setPointerCapture?.(event.pointerId); + event.preventDefault(); + }; + + const handlePointerMove = (event: PointerEvent) => { + const panState = panStateRef.current; + if (!panState || event.pointerId !== panState.pointerId) { + return; + } + + const canvas = canvasRef.current ?? event.currentTarget; + canvas.scrollLeft = panState.scrollLeft - (event.clientX - panState.startX); + canvas.scrollTop = panState.scrollTop - (event.clientY - panState.startY); + event.preventDefault(); + }; + + const imageStyle: CSSProperties | undefined = + zoomMode === "manual" && dimensions + ? { + height: `${Math.max(1, Math.round(dimensions.h * zoom))}px`, + width: `${Math.max(1, Math.round(dimensions.w * zoom))}px`, + } + : undefined; + const zoomLabel = zoomMode === "fit" ? t("code_editor.image_zoom_fit_level") : formatZoom(zoom); + const zoomOutDisabled = zoomMode === "manual" && zoom <= MIN_ZOOM; + const zoomInDisabled = zoomMode === "manual" && zoom >= MAX_ZOOM; + const canPan = zoomMode === "manual" && !errored; + const canvasClassName = [ + "image-preview-canvas", + canPan ? "image-preview-canvas--pannable" : "", + isPanning ? "image-preview-canvas--dragging" : "", + ] + .filter(Boolean) + .join(" "); + return (
-
+
{errored ? ( = ({ url, version, mime, sizeBy /> ) : ( {alt} { const img = e.currentTarget; setDimensions({ w: img.naturalWidth, h: img.naturalHeight }); @@ -71,14 +204,64 @@ export const ImagePreview: FC = ({ url, version, mime, sizeBy /> )}
-
- {mimeToLabel(mime)} - {dimensions && ( - - {dimensions.w} × {dimensions.h} +
+
+ {mimeToLabel(mime)} + {dimensions && ( + + {dimensions.w} × {dimensions.h} + + )} + {formatBytes(sizeBytes)} +
+
+ + } + onClick={() => changeZoom(-ZOOM_STEP)} + size="sm" + /> + + + {zoomLabel} - )} - {formatBytes(sizeBytes)} + + } + onClick={() => changeZoom(ZOOM_STEP)} + size="sm" + /> + + + } + onClick={handleFit} + size="sm" + /> + + + } + onClick={handleActualSize} + size="sm" + /> + +
); diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx index 090a596d9..5afbb2914 100644 --- a/packages/web/src/features/code-editor/index.test.tsx +++ b/packages/web/src/features/code-editor/index.test.tsx @@ -201,6 +201,331 @@ describe("CodeEditorHost", () => { }); }); + it("opens system agent instructions through the provider-specific read command", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "agentInstructions.system.read") { + return { + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + content: "# Codex\n", + baseHash: "system-hash-1", + }; + } + + if (op === "file.read") { + throw new Error("system agent files must not use file.read"); + } + + return null; + }); + const { store } = setupStore({ activePath: "agent-system:codex", sendCommand }); + + render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "agentInstructions.system.read", + { + workspaceId: "ws-1", + providerId: "codex", + }, + undefined + ); + }); + + expect(sendCommand.mock.calls.some(([op]) => op === "file.read")).toBe(false); + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("# Codex"); + }); + expect(screen.getByText("~/.codex/AGENTS.md")).toBeInTheDocument(); + expect(store.get(openFilesAtomFamily("ws-1"))["agent-system:codex"]).toMatchObject({ + kind: "text", + path: "agent-system:codex", + content: "# Codex\n", + savedContent: "# Codex\n", + baseHash: "system-hash-1", + isDirty: false, + }); + expect(mockRegistryUpdateFromDisk).not.toHaveBeenCalled(); + }); + + it("saves system agent instructions through the provider-specific write command", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "agentInstructions.system.read") { + return { + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + content: "# Codex\n", + baseHash: "system-hash-1", + }; + } + + if (op === "agentInstructions.system.write") { + return { + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + content: "# Codex\n\n- Prefer focused answers.\n", + baseHash: "system-hash-2", + }; + } + + if (op === "file.write") { + throw new Error("system agent files must not use file.write"); + } + + return null; + }); + const { store } = setupStore({ activePath: "agent-system:codex", sendCommand }); + + render( + + + + ); + + const editor = await screen.findByRole("textbox", { name: "Editor content" }); + fireEvent.change(editor, { + target: { value: "# Codex\n\n- Prefer focused answers.\n" }, + }); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["agent-system:codex"]).toMatchObject({ + isDirty: true, + }); + }); + + pressSaveShortcut(); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "agentInstructions.system.write", + { + workspaceId: "ws-1", + providerId: "codex", + content: "# Codex\n\n- Prefer focused answers.\n", + baseHash: "system-hash-1", + }, + undefined + ); + }); + + expect(sendCommand.mock.calls.some(([op]) => op === "file.write")).toBe(false); + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["agent-system:codex"]).toMatchObject({ + savedContent: "# Codex\n\n- Prefer focused answers.\n", + baseHash: "system-hash-2", + isDirty: false, + }); + }); + }); + + it("keeps a seeded missing project agent draft in memory until save creates the file", async () => { + const draftContent = "# Agent Instructions\n\n- Add project context here.\n"; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "file.read") { + throw new Error("seeded missing project agent draft should not read from disk"); + } + + if (op === "file.write") { + return { + newHash: "draft-hash-1", + }; + } + + return null; + }); + const { store } = setupStore({ + activePath: ".coder-studio/agent.md", + sendCommand, + openFiles: { + ".coder-studio/agent.md": { + kind: "text", + path: ".coder-studio/agent.md", + content: draftContent, + savedContent: "", + baseHash: "", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + + const editor = await screen.findByRole("textbox", { name: "Editor content" }); + expect(editor).toHaveValue(draftContent); + expect(sendCommand.mock.calls.some(([op]) => op === "file.read")).toBe(false); + + await act(async () => { + pressSaveShortcut(); + }); + + await waitFor(() => { + const writeCall = sendCommand.mock.calls.find(([op]) => op === "file.write"); + expect(writeCall).toBeDefined(); + expect(writeCall?.[1]).toEqual({ + workspaceId: "ws-1", + path: ".coder-studio/agent.md", + content: draftContent, + baseHash: undefined, + }); + }); + + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))[".coder-studio/agent.md"]).toMatchObject({ + savedContent: draftContent, + baseHash: "draft-hash-1", + isDirty: false, + }); + }); + }); + + it("keeps a seeded missing system agent draft in memory until save creates the file", async () => { + const draftContent = "# Codex\n\n- Prefer focused answers.\n"; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "agentInstructions.system.read") { + throw new Error("seeded missing system agent draft should not read from disk"); + } + + if (op === "agentInstructions.system.write") { + return { + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + content: draftContent, + baseHash: "system-hash-created", + }; + } + + if (op === "file.write") { + throw new Error("system agent drafts must not use file.write"); + } + + return null; + }); + const { store } = setupStore({ + activePath: "agent-system:codex", + sendCommand, + openFiles: { + "agent-system:codex": { + kind: "text", + path: "agent-system:codex", + displayPath: "~/.codex/AGENTS.md", + content: draftContent, + savedContent: "", + baseHash: "", + isDirty: true, + }, + }, + }); + + render( + + + + ); + + const editor = await screen.findByRole("textbox", { name: "Editor content" }); + expect(editor).toHaveValue(draftContent); + expect(screen.getByText("~/.codex/AGENTS.md")).toBeInTheDocument(); + expect(sendCommand.mock.calls.some(([op]) => op === "agentInstructions.system.read")).toBe( + false + ); + + await act(async () => { + pressSaveShortcut(); + }); + + await waitFor(() => { + const writeCall = sendCommand.mock.calls.find( + ([op]) => op === "agentInstructions.system.write" + ); + expect(writeCall).toBeDefined(); + expect(writeCall?.[1]).toEqual({ + workspaceId: "ws-1", + providerId: "codex", + content: draftContent, + baseHash: undefined, + }); + }); + + expect(sendCommand.mock.calls.some(([op]) => op === "file.write")).toBe(false); + await waitFor(() => { + expect(store.get(openFilesAtomFamily("ws-1"))["agent-system:codex"]).toMatchObject({ + savedContent: draftContent, + baseHash: "system-hash-created", + displayPath: "~/.codex/AGENTS.md", + isDirty: false, + }); + }); + }); + + it("refreshes a clean system agent buffer through the virtual path and keeps the display path", async () => { + let readCount = 0; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "agentInstructions.system.read") { + readCount += 1; + return { + providerId: "codex", + path: ".codex/AGENTS.md", + displayPath: "~/.codex/AGENTS.md", + exists: true, + content: readCount === 1 ? "# Codex\n" : "# Codex\n\nUpdated on disk\n", + baseHash: readCount === 1 ? "system-hash-1" : "system-hash-2", + }; + } + + if (op === "file.read") { + throw new Error("system agent files must not use file.read"); + } + + return null; + }); + const { store } = setupStore({ activePath: "agent-system:codex", sendCommand }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("# Codex"); + }); + + act(() => { + store.set(editorRefreshTokenAtomFamily("ws-1"), 1); + }); + + await waitFor(() => { + expect(screen.getByTestId("monaco-host")).toHaveTextContent("Updated on disk"); + }); + expect(screen.getByText("~/.codex/AGENTS.md")).toBeInTheDocument(); + expect(store.get(openFilesAtomFamily("ws-1"))["agent-system:codex"]).toMatchObject({ + path: "agent-system:codex", + savedContent: "# Codex\n\nUpdated on disk\n", + baseHash: "system-hash-2", + isDirty: false, + }); + expect(mockRegistryUpdateFromDisk).not.toHaveBeenCalled(); + }); + it("shows an error instead of staying in loading when file.read fails", async () => { const sendCommand = vi.fn().mockRejectedValue(new Error("File not found")); const { store } = setupStore({ activePath: "src/missing.ts", sendCommand }); diff --git a/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts index 6433b13cc..c0ef728ee 100644 --- a/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts +++ b/packages/web/src/features/code-editor/monaco/language-tokenization.test.ts @@ -1,4 +1,33 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const { originalMatchMedia } = vi.hoisted(() => { + const viewportQuery = "(max-width: 899px), (pointer: coarse)"; + const originalMatchMedia = window.matchMedia; + + const applyMatchMedia = (device: "desktop" | "mobile") => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: (query: string) => ({ + matches: query === viewportQuery ? device === "mobile" : false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => true, + }), + }); + }; + + applyMatchMedia("desktop"); + return { originalMatchMedia }; +}); + +import * as monaco from "monaco-editor"; + +import { ensureVueLanguageRegistered } from "./vue-language"; const samples = [ { languageId: "python", source: "def main():\n return 1\n" }, @@ -10,10 +39,11 @@ const samples = [ }, ] as const; -let monaco: typeof import("monaco-editor"); -let ensureVueLanguageRegistered: typeof import("./vue-language").ensureVueLanguageRegistered; - describe("Monaco language tokenization", () => { + beforeAll(() => { + ensureVueLanguageRegistered(); + }); + it.each(samples)("tokenizes $languageId code with non-plaintext tokens", async ({ languageId, source, @@ -26,20 +56,10 @@ describe("Monaco language tokenization", () => { }); }); -beforeAll(async () => { - window.matchMedia ??= () => - ({ - matches: false, - media: "", - onchange: null, - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - dispatchEvent: () => false, - }) as MediaQueryList; - - monaco = await import("monaco-editor"); - ({ ensureVueLanguageRegistered } = await import("./vue-language")); - ensureVueLanguageRegistered(); -}, 30_000); +afterAll(() => { + if (originalMatchMedia) { + window.matchMedia = originalMatchMedia; + } else { + delete (window as typeof window & { matchMedia?: typeof window.matchMedia }).matchMedia; + } +}); diff --git a/packages/web/src/features/code-editor/system-agent-instructions-path.ts b/packages/web/src/features/code-editor/system-agent-instructions-path.ts new file mode 100644 index 000000000..7d9a2ab98 --- /dev/null +++ b/packages/web/src/features/code-editor/system-agent-instructions-path.ts @@ -0,0 +1,29 @@ +import type { SystemAgentInstructionsProviderId } from "@coder-studio/core"; +import { SYSTEM_AGENT_INSTRUCTIONS_PROVIDER_IDS } from "@coder-studio/core"; + +export const SYSTEM_AGENT_INSTRUCTIONS_EDITOR_PATH_PREFIX = "agent-system:"; + +const SYSTEM_AGENT_PROVIDER_ID_SET = new Set(SYSTEM_AGENT_INSTRUCTIONS_PROVIDER_IDS); + +export function toSystemAgentInstructionsEditorPath( + providerId: SystemAgentInstructionsProviderId +): string { + return `${SYSTEM_AGENT_INSTRUCTIONS_EDITOR_PATH_PREFIX}${providerId}`; +} + +export function parseSystemAgentInstructionsEditorPath( + path: string +): SystemAgentInstructionsProviderId | null { + if (!path.startsWith(SYSTEM_AGENT_INSTRUCTIONS_EDITOR_PATH_PREFIX)) { + return null; + } + + const providerId = path.slice(SYSTEM_AGENT_INSTRUCTIONS_EDITOR_PATH_PREFIX.length); + return SYSTEM_AGENT_PROVIDER_ID_SET.has(providerId) + ? (providerId as SystemAgentInstructionsProviderId) + : null; +} + +export function isSystemAgentInstructionsEditorPath(path: string): boolean { + return parseSystemAgentInstructionsEditorPath(path) !== null; +} diff --git a/packages/web/src/features/code-editor/views/shared/editor-surface.tsx b/packages/web/src/features/code-editor/views/shared/editor-surface.tsx index 61f5ba13a..ce45f0563 100644 --- a/packages/web/src/features/code-editor/views/shared/editor-surface.tsx +++ b/packages/web/src/features/code-editor/views/shared/editor-surface.tsx @@ -16,6 +16,7 @@ import { ImageDiffPreview } from "../../components/image-diff-preview"; import { ImagePreview } from "../../components/image-preview"; import { MonacoDiffHost } from "../../components/monaco-diff-host"; import { MonacoHost } from "../../components/monaco-host"; +import { isSystemAgentInstructionsEditorPath } from "../../system-agent-instructions-path"; import type { CodeEditorChrome, CodeEditorState } from "./code-editor-host"; import { CodeEditorDesktopHeaderActions } from "./code-editor-host"; @@ -100,6 +101,9 @@ export const EditorSurface: FC = ({ state, chrome = "full" } : null; const canRenderTextDiff = textDiffPreview !== null; const canRenderImageDiff = imageDiffPreview !== null; + const isSystemTextFile = Boolean( + currentTextFile && isSystemAgentInstructionsEditorPath(currentTextFile.path) + ); const shouldRenderDocumentPreview = mode === "preview" && currentTextFile !== null && @@ -107,7 +111,7 @@ export const EditorSurface: FC = ({ state, chrome = "full" } const titleText = commitPreview ? (commitPreview.title ?? commitPreview.path) : currentFile - ? getFileName(currentFile.path) + ? (currentFile.displayPath ?? getFileName(currentFile.path)) : (activeDiffChange?.title ?? activeFilePath ?? t("file.title")); const closeConfirmFileName = currentTextFile?.path !== undefined ? getFileName(currentTextFile.path) : t("file.title"); @@ -164,7 +168,11 @@ export const EditorSurface: FC = ({ state, chrome = "full" }
{currentFile && !isCommitPreview ? ( <> @@ -263,9 +271,10 @@ export const EditorSurface: FC = ({ state, chrome = "full" } ) : currentTextFile ? ( = {}, - checks: DiagnosticsCheck[] = [] + checks: DiagnosticsCheck[] = [], + lspServices: DiagnosticsLspServiceEntry[] = [] ): DiagnosticsResponse { return { context: "manual_check", canContinue: true, checks, metadata: {}, + lspServices, ...overrides, }; } @@ -282,11 +289,113 @@ describe("DiagnosticsPage", () => { expect(screen.getByText("Current version: v24.1.0")).toBeInTheDocument(); }); + it("renders a separate read-only LSP services section", async () => { + const sendCommand = vi.fn().mockResolvedValue( + createResponse( + { + context: "session_start", + canContinue: false, + metadata: { + workspaceId: "ws-1", + workspacePath: "/repo", + providerId: "claude", + lspRuntimeContext: { + targetRuntime: "native", + managedInstallSupported: true, + }, + }, + }, + [ + { + id: "provider-missing", + code: "provider_cli_missing", + status: "needs_attention", + providerId: "claude", + missingCommands: ["claude"], + }, + ], + [ + { + serverKind: "typescript", + displayName: "TypeScript language server", + status: "installed", + }, + { + serverKind: "python", + displayName: "Python language server", + status: "prerequisite_missing", + missingPrerequisites: ["python3"], + }, + { + serverKind: "go", + displayName: "Go language server", + status: "install_failed", + }, + { + serverKind: "rust", + displayName: "Rust language server", + status: "runtime_off", + }, + { + serverKind: "vue", + displayName: "Vue language server", + status: "not_installed", + }, + ] + ) + ); + + renderDiagnostics( + "/diagnostics?context=session_start&workspaceId=ws-1&providerId=claude", + sendCommand + ); + + const results = await waitFor(() => { + const element = document.querySelector(".diagnostics-results"); + if (!(element instanceof HTMLElement)) { + throw new Error("Diagnostics results did not render"); + } + return element; + }); + const sectionOrder = Array.from(results.children).map((element) => element.className); + + expect(await screen.findByText("LSP Services")).toBeInTheDocument(); + expect(sectionOrder).toEqual(["diagnostics-issues", "diagnostics-section"]); + expect(screen.getByText("TypeScript language server")).toBeInTheDocument(); + expect(screen.getByText("Python language server")).toBeInTheDocument(); + expect(screen.getByText("Go language server")).toBeInTheDocument(); + expect(screen.getByText("Rust language server")).toBeInTheDocument(); + expect(screen.getByText("Vue language server")).toBeInTheDocument(); + expect(screen.getByText("Installed")).toBeInTheDocument(); + expect(screen.getByText("Prerequisite missing")).toBeInTheDocument(); + expect(screen.getByText("Install failed")).toBeInTheDocument(); + expect(screen.getByText("Runtime off")).toBeInTheDocument(); + expect(screen.getByText("Not installed")).toBeInTheDocument(); + expect( + screen.getByText( + "LSP services power editor features like diagnostics, go to definition, and hover. These statuses are informational only and do not block diagnostics." + ) + ).toBeInTheDocument(); + expect( + screen.getByText("Workspace context only affects runtime availability.") + ).toBeInTheDocument(); + expect(screen.getByText("Install ownership is global to this app.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Install" })).toBeNull(); + }); + it("installs a missing git dependency inline, accepts a sudo password, and rechecks on success", async () => { let diagnosticsCallCount = 0; let installGetCalls = 0; let submitted = false; let subscriptionHandler: ((topic: string, payload: unknown) => void) | undefined; + const subscribe = vi.fn( + (_topics: string[], handler: (topic: string, payload: unknown) => void) => { + subscriptionHandler = handler; + return () => { + subscriptionHandler = undefined; + }; + } + ); const sendCommand = vi.fn(async (op: string, args?: Record) => { if (op === "diagnostics.get" || op === "diagnostics.recheck") { diagnosticsCallCount += 1; @@ -382,12 +491,7 @@ describe("DiagnosticsPage", () => { const store = createStoreWithClient(sendCommand); store.set(wsClientAtom, { sendCommand, - subscribe: vi.fn((_topics: string[], handler: (topic: string, payload: unknown) => void) => { - subscriptionHandler = handler; - return () => { - subscriptionHandler = undefined; - }; - }), + subscribe, } as never); render( @@ -405,6 +509,12 @@ describe("DiagnosticsPage", () => { fireEvent.click(screen.getByRole("button", { name: "Install Git" })); expect(await screen.findByText("Package manager: apt-get")).toBeInTheDocument(); expect(screen.getByLabelText("Administrator password")).toHaveAttribute("type", "password"); + await waitFor(() => { + expect(subscribe).toHaveBeenCalledWith( + ["systemDeps.install.job-1.output"], + expect.any(Function) + ); + }); act(() => { subscriptionHandler?.("systemDeps.install.job-1.output", { diff --git a/packages/web/src/features/diagnostics/page.tsx b/packages/web/src/features/diagnostics/page.tsx index 7c402fc97..67ef3cdc8 100644 --- a/packages/web/src/features/diagnostics/page.tsx +++ b/packages/web/src/features/diagnostics/page.tsx @@ -1,5 +1,6 @@ import type { DiagnosticsCheck, + DiagnosticsLspServiceEntry, DiagnosticsRequest, DiagnosticsResponse, Session, @@ -36,7 +37,12 @@ import { usePersistWorkspaceLastViewedTarget } from "../workspace/actions/use-pe import { useWorkspaceUiStatePersistence } from "../workspace/actions/use-workspace-ui-state-persistence"; import { useSystemDependencyInstaller } from "./actions/use-system-dependency-installer"; import { SystemDependencyInstallPanel } from "./components/system-dependency-install-panel"; -import { parseDiagnosticsSearch } from "./navigation"; +import { type DiagnosticsRouteIntent, parseDiagnosticsSearch } from "./navigation"; + +interface DiagnosticsPageProps { + embedded?: boolean; + intent?: DiagnosticsRouteIntent; +} function getProviderLabel(providerId?: string): string { if (providerId === "claude") { @@ -54,6 +60,62 @@ function formatList(values: string[] | undefined): string { return Array.isArray(values) && values.length > 0 ? values.join(", ") : "—"; } +function buildLspServiceCopy( + t: ReturnType, + service: DiagnosticsLspServiceEntry +): { title: string; description: string } { + switch (service.status) { + case "installed": + return { + title: t("diagnostics.lsp_services.status.installed"), + description: t("diagnostics.lsp_services.description.installed"), + }; + case "not_installed": + return { + title: t("diagnostics.lsp_services.status.not_installed"), + description: t("diagnostics.lsp_services.description.not_installed"), + }; + case "install_failed": + return { + title: t("diagnostics.lsp_services.status.install_failed"), + description: t("diagnostics.lsp_services.description.install_failed"), + }; + case "prerequisite_missing": + return { + title: t("diagnostics.lsp_services.status.prerequisite_missing"), + description: t("diagnostics.lsp_services.description.prerequisite_missing"), + }; + case "runtime_off": + return { + title: t("diagnostics.lsp_services.status.runtime_off"), + description: t("diagnostics.lsp_services.description.runtime_off"), + }; + } +} + +function buildLspRuntimeContextCopy( + t: ReturnType, + runtimeContext: + | { + targetRuntime: "native" | "wsl"; + managedInstallSupported: boolean; + } + | undefined +): string[] { + if (!runtimeContext) { + return []; + } + + const lines = [t("diagnostics.lsp_services.runtime_context.workspace_scope")]; + if (!runtimeContext.managedInstallSupported || runtimeContext.targetRuntime !== "native") { + lines.push(t("diagnostics.lsp_services.runtime_context.managed_install_unavailable")); + return lines; + } + + lines.push(t("diagnostics.lsp_services.runtime_context.install_scope")); + return lines; +} + function resolveMobileWorkspaceUrl(host: string | undefined): string | null { if (typeof window === "undefined") { return null; @@ -198,13 +260,16 @@ function buildCheckCopy( } } -export function DiagnosticsPage() { +export function DiagnosticsPage({ + embedded = false, + intent: intentOverride, +}: DiagnosticsPageProps) { const t = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const viewport = useViewport(); const isMobile = viewport === "mobile"; - const intent = parseDiagnosticsSearch(location.search); + const intent = intentOverride ?? parseDiagnosticsSearch(location.search); const store = useStore(); const dispatch = useAtomValue(dispatchCommandAtom); const themeBackground = useTerminalThemeBackground(); @@ -484,6 +549,10 @@ export function DiagnosticsPage() { const canPrimaryContinue = intent.context === "workspace_open" ? Boolean(response?.canContinue) : true; + const lspRuntimeContextLines = buildLspRuntimeContextCopy( + t, + response?.metadata.lspRuntimeContext + ); const contextTitle = t(`diagnostics.context.${intent.context}.title`); const contextDescription = t(`diagnostics.context.${intent.context}.description`); @@ -494,25 +563,29 @@ export function DiagnosticsPage() { }; return ( -
-
- {isMobile ? ( - - ) : ( - - )} -
+
+ {embedded ? null : ( +
+ {isMobile ? ( + + ) : ( + + )} +
+ )}
@@ -691,6 +764,67 @@ export function DiagnosticsPage() { ); })}
+ +
+
+

+ {t("diagnostics.lsp_services.title")} +

+

+ {t("diagnostics.lsp_services.description.summary")} +

+
+
+ {response.lspServices.map((service) => { + const copy = buildLspServiceCopy(t, service); + + return ( +
+
+
+ {service.displayName} +
+ + {copy.title} + +
+

{copy.description}

+
+ {service.missingCommands?.length ? ( + + {t("diagnostics.details.missing_commands")}:{" "} + {formatList(service.missingCommands)} + + ) : null} + {service.missingPrerequisites?.length ? ( + + {t("diagnostics.details.missing_prerequisites")}:{" "} + {formatList(service.missingPrerequisites)} + + ) : null} +
+
+ ); + })} +
+ {lspRuntimeContextLines.length > 0 ? ( +
+ {lspRuntimeContextLines.map((line) => ( +

{line}

+ ))} +
+ ) : null} +
) : null}
diff --git a/packages/web/src/features/monitoring/page.test.tsx b/packages/web/src/features/monitoring/page.test.tsx index a3d1561d9..6833dc0d6 100644 --- a/packages/web/src/features/monitoring/page.test.tsx +++ b/packages/web/src/features/monitoring/page.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { describe, expect, it, vi } from "vitest"; import { localeAtom } from "../../atoms/app-ui"; @@ -535,7 +535,7 @@ describe("MonitoringContent", () => { expect(screen.queryByRole("tab", { name: "Overview" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "Attribution" })).not.toBeInTheDocument(); expect(screen.queryByRole("tab", { name: "Process" })).not.toBeInTheDocument(); - expect(screen.getByText("Enable runtime summary in settings")).toBeInTheDocument(); + expect(screen.getAllByText("Enable runtime summary in settings").length).toBeGreaterThan(0); }); it("renders hierarchical attribution and keeps subprocesses out of the attribution tree until drill-down is enabled", async () => { @@ -779,6 +779,7 @@ describe("MonitoringContent", () => { "Attribution tree", "Detail panel", "Subprocess drill-down", + "Background runtime", ]); }); @@ -1514,4 +1515,209 @@ describe("MonitoringContent", () => { ).toBeGreaterThan(0); expect(screen.queryByText("Enable runtime summary in settings")).not.toBeInTheDocument(); }); + + it("renders background runtime groups so LSP processes are visible in the dashboard", async () => { + const response = { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 10, + mode: "standard", + host: { + cpuPercent: 72, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 60, + pressure: "elevated", + }, + runtime: { + serverCpuPercent: 10, + serverMemoryBytes: 100, + totalManagedCpuPercent: 17, + totalManagedMemoryBytes: 250, + managedProcessCount: 2, + cpuShareOfHostPercent: 23.61, + memoryShareOfHostPercent: 25, + }, + workspaces: [], + sessions: [], + subprocessGroups: [], + backgroundGroups: [ + { + id: "background:lsp:ws-1:typescript", + kind: "background_group", + label: "TypeScript language server", + cpuPercent: 7, + memoryBytes: 150, + processCount: 1, + uptimeSec: 45, + trend: "steady", + }, + ], + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 72, memoryBytes: 800 }] }, + runtime: { points: [{ sampledAt: 10, cpuPercent: 17, memoryBytes: 250, processCount: 2 }] }, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + + renderMonitoringPage(response); + + expect(await screen.findByText("Background runtime")).toBeInTheDocument(); + expect(screen.getAllByText("TypeScript language server").length).toBeGreaterThan(0); + + fireEvent.click( + screen.getByRole("button", { + name: "Background task TypeScript language server 7.0% / 150 B", + }) + ); + + expect(screen.getByText("Detail panel")).toBeInTheDocument(); + expect(screen.getAllByText("TypeScript language server")).toHaveLength(2); + }); + + it("renders workspace-scoped background groups inside the attribution tree instead of background runtime", async () => { + const response = { + settings: { + enabled: true, + hostMetricsEnabled: true, + runtimeSummaryEnabled: true, + workspaceAttributionEnabled: true, + subprocessDrilldownEnabled: false, + sampleIntervalMs: 2000, + }, + snapshot: { + sampledAt: 10, + mode: "standard", + host: { + cpuPercent: 72, + memoryUsedBytes: 800, + memoryTotalBytes: 1000, + memoryAvailableBytes: 200, + loadAverage: [1, 1, 1], + uptimeSec: 60, + pressure: "elevated", + }, + runtime: { + serverCpuPercent: 10, + serverMemoryBytes: 100, + totalManagedCpuPercent: 23, + totalManagedMemoryBytes: 430, + managedProcessCount: 3, + cpuShareOfHostPercent: 31.94, + memoryShareOfHostPercent: 43, + }, + workspaces: [ + { + id: "workspace:ws-1", + kind: "workspace", + label: "Workspace Alpha", + cpuPercent: 16, + memoryBytes: 310, + processCount: 2, + uptimeSec: 80, + trend: "steady", + }, + ], + sessions: [ + { + id: "session:sess-1", + parentId: "workspace:ws-1", + kind: "session", + label: "Claude session", + cpuPercent: 9, + memoryBytes: 160, + processCount: 1, + uptimeSec: 40, + trend: "steady", + }, + ], + subprocessGroups: [], + backgroundGroups: [ + { + id: "background:lsp:ws-1:typescript", + parentId: "workspace:ws-1", + workspaceId: "ws-1", + kind: "background_group", + label: "TypeScript language server", + cpuPercent: 7, + memoryBytes: 150, + processCount: 1, + uptimeSec: 45, + trend: "steady", + }, + { + id: "background:installer:1", + kind: "background_group", + label: "Extension installer", + cpuPercent: 2, + memoryBytes: 120, + processCount: 1, + uptimeSec: 15, + trend: "steady", + }, + ], + }, + history: { + host: { points: [{ sampledAt: 10, cpuPercent: 72, memoryBytes: 800 }] }, + runtime: { points: [{ sampledAt: 10, cpuPercent: 23, memoryBytes: 430, processCount: 3 }] }, + workspaces: {}, + sessions: {}, + subprocessGroups: {}, + }, + capabilities: { + loadAverageAvailable: true, + processMetricsAvailable: true, + subprocessHistoryLimited: false, + }, + telemetry: null, + }; + + renderMonitoringPage(response); + + expect(await screen.findByText("Attribution tree")).toBeInTheDocument(); + + const attributionTree = screen + .getByRole("heading", { level: 2, name: "Attribution tree" }) + .closest(".monitoring-tree"); + const backgroundRuntime = screen + .getByRole("heading", { level: 2, name: "Background runtime" }) + .closest("section"); + + expect(attributionTree).not.toBeNull(); + expect(backgroundRuntime).not.toBeNull(); + + expect( + within(attributionTree as HTMLElement).getByRole("button", { + name: "Background task TypeScript language server 7.0% / 150 B", + }) + ).toBeInTheDocument(); + expect( + within(attributionTree as HTMLElement).getByText("Background task · 7.0% / 150 B") + ).toBeInTheDocument(); + + expect( + within(backgroundRuntime as HTMLElement).queryByText("TypeScript language server") + ).not.toBeInTheDocument(); + expect( + within(backgroundRuntime as HTMLElement).getByText("Extension installer") + ).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/monitoring/page.tsx b/packages/web/src/features/monitoring/page.tsx index e489c0a9c..a2867d86c 100644 --- a/packages/web/src/features/monitoring/page.tsx +++ b/packages/web/src/features/monitoring/page.tsx @@ -97,18 +97,18 @@ function sortEntities(entities: MonitoringEntitySummary[], mode: SortMode) { function sortAttributionTree( workspaces: MonitoringEntitySummary[], - sessions: MonitoringEntitySummary[], + childEntities: MonitoringEntitySummary[], mode: SortMode ) { - const sessionsByParent = new Map(); - for (const session of sessions) { - const parentId = session.parentId ?? ""; - sessionsByParent.set(parentId, [...(sessionsByParent.get(parentId) ?? []), session]); + const childrenByParent = new Map(); + for (const entity of childEntities) { + const parentId = entity.parentId ?? ""; + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) ?? []), entity]); } return sortEntities(workspaces, mode).flatMap((workspace) => [ workspace, - ...sortEntities(sessionsByParent.get(workspace.id) ?? [], mode), + ...sortEntities(childrenByParent.get(workspace.id) ?? [], mode), ]); } @@ -209,7 +209,7 @@ function EntityList({ aria-label={`${kindLabel} ${displayTitle} ${formatPercent(entity.cpuPercent)} / ${formatBytes( entity.memoryBytes )}`} - className={`monitoring-entity-row ${entity.kind === "session" || entity.kind === "subprocess_group" ? "monitoring-entity-row--child" : ""} ${ + className={`monitoring-entity-row ${entity.parentId ? "monitoring-entity-row--child" : ""} ${ selectedEntityId === entity.id ? "monitoring-entity-row--selected" : "" }`} onClick={() => onSelect(entity)} @@ -376,7 +376,16 @@ export function MonitoringDashboard({ return []; } - return sortAttributionTree(response.snapshot.workspaces, response.snapshot.sessions, sortMode); + return sortAttributionTree( + response.snapshot.workspaces, + [ + ...response.snapshot.sessions, + ...response.snapshot.backgroundGroups.filter((entity) => + entity.parentId?.startsWith("workspace:") + ), + ], + sortMode + ); }, [response, sortMode]); const processEntities = useMemo(() => { @@ -387,6 +396,17 @@ export function MonitoringDashboard({ return sortEntities(response.snapshot.subprocessGroups, sortMode); }, [response, sortMode]); + const backgroundEntities = useMemo(() => { + if (!response) { + return []; + } + + return sortEntities( + response.snapshot.backgroundGroups.filter((entity) => !entity.parentId), + sortMode + ); + }, [response, sortMode]); + const selectableEntities = useMemo(() => { if (!response) { return []; @@ -396,6 +416,7 @@ export function MonitoringDashboard({ ...sortEntities(response.snapshot.workspaces, sortMode), ...sortEntities(response.snapshot.sessions, sortMode), ...sortEntities(response.snapshot.subprocessGroups, sortMode), + ...sortEntities(response.snapshot.backgroundGroups, sortMode), ]; }, [response, sortMode]); @@ -409,6 +430,7 @@ export function MonitoringDashboard({ ...response.snapshot.workspaces, ...response.snapshot.sessions, ...response.snapshot.subprocessGroups, + ...response.snapshot.backgroundGroups, ].find((entity) => entity.id === selectedEntityId) ?? null ); }, [response, selectedEntityId]); @@ -480,6 +502,25 @@ export function MonitoringDashboard({ return "empty"; }, [processEntities.length, response, runtimeStatus]); + const backgroundStatus = useMemo(() => { + if (!response) { + return "loading"; + } + if (!response.settings.runtimeSummaryEnabled) { + return "disabled"; + } + if (backgroundEntities.length > 0) { + return "ready"; + } + if (runtimeStatus === "degraded") { + return "degraded"; + } + if (runtimeStatus === "waiting") { + return "waiting"; + } + return "empty"; + }, [backgroundEntities.length, response, runtimeStatus]); + const primaryState = loading || (error && !response) ? ( )} + +
+
+
+

{t("monitoring.background_runtime")}

+

{t("monitoring.background_runtime_description")}

+
+
+ {backgroundStatus === "ready" ? ( + setSelectedEntityId(entity.id)} + history={response.history} + sampledAt={response.snapshot.sampledAt} + timeWindow={timeWindow} + /> + ) : backgroundStatus === "degraded" ? ( + + ) : backgroundStatus === "waiting" ? ( + + ) : backgroundStatus === "disabled" ? ( + + ) : ( + + )} +
) : null; diff --git a/packages/web/src/features/settings/components/provider-settings.test.tsx b/packages/web/src/features/settings/components/provider-settings.test.tsx index dd1f4bbc6..19eeb4c40 100644 --- a/packages/web/src/features/settings/components/provider-settings.test.tsx +++ b/packages/web/src/features/settings/components/provider-settings.test.tsx @@ -12,6 +12,14 @@ import { import { type ProviderInfo, ProviderSettings } from "./provider-settings"; const editorMountSpy = vi.fn(); +const providerCapabilities = [ + { key: "interactive_session", supported: true, label: "Interactive Session" }, + { key: "supervisor_eval", supported: true, label: "Supervisor Eval" }, +] as const; +const limitedProviderCapabilities = [ + { key: "interactive_session", supported: true, label: "Interactive Session" }, + { key: "supervisor_eval", supported: false, label: "Supervisor Eval" }, +] as const; vi.mock("./config-editor", () => ({ ConfigEditor: ({ @@ -83,14 +91,60 @@ function renderHarness({ }), } = {}) { const providers: ProviderInfo[] = [ - { id: "claude", displayName: "Claude" }, - { id: "codex", displayName: "Codex" }, + { + id: "claude", + displayName: "Claude", + badge: "Claude", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [...providerCapabilities], + }, + { + id: "codex", + displayName: "Codex", + badge: "Codex", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [...providerCapabilities], + }, + { + id: "gemini", + displayName: "Gemini CLI", + badge: "Gemini", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [...providerCapabilities], + }, + { + id: "cursor", + displayName: "Cursor Agent", + badge: "Cursor", + kind: "built_in", + stability: "stable", + capability: "full", + capabilities: [...providerCapabilities], + }, + { + id: "opencode", + displayName: "OpenCode", + badge: "OpenCode", + kind: "built_in", + stability: "experimental", + capability: "limited", + capabilities: [...limitedProviderCapabilities], + }, ]; function Harness() { const [additionalArgsById, setAdditionalArgsById] = useState>({ claude: "--verbose", codex: "--sandbox", + gemini: "--yolo", + cursor: "--fast", + opencode: "--local", }); return ( @@ -123,7 +177,7 @@ describe("ProviderSettings desktop", () => { }); it("defaults to base settings and switches to config files explicitly", async () => { - renderHarness(); + const { container } = renderHarness(); expect(screen.getByRole("tablist", { name: "Agents" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Claude" })).toHaveAttribute("aria-selected", "true"); @@ -136,6 +190,12 @@ describe("ProviderSettings desktop", () => { const input = await screen.findByLabelText("启动命令参数"); expect(input).toHaveValue("--verbose"); expect(input).toHaveClass("input", "textarea", "settings-provider-args-input"); + expect(container.querySelector(".settings-provider-base-layout")).not.toBeNull(); + expect(container.querySelector(".settings-provider-base-main")).toBeNull(); + expect(container.querySelector(".settings-provider-base-side")).toBeNull(); + expect( + container.querySelectorAll(".settings-provider-base-layout > .settings-group") + ).toHaveLength(3); expect(screen.getByRole("tablist", { name: "配置" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "基础配置" })).toHaveAttribute("aria-selected", "true"); @@ -167,6 +227,36 @@ describe("ProviderSettings desktop", () => { expect(screen.queryByLabelText("启动命令参数")).not.toBeInTheDocument(); }); + it("shows Gemini Cursor and OpenCode settings sections", async () => { + renderHarness(); + + expect(screen.getByRole("tab", { name: "Gemini CLI" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Cursor Agent" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "OpenCode" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Gemini CLI" })); + + expect(screen.getByRole("tab", { name: "Gemini CLI" })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("heading", { name: "Gemini CLI" })).toBeInTheDocument(); + expect(screen.getByText("Gemini")).toBeInTheDocument(); + expect(screen.getAllByText("完整支持").length).toBeGreaterThan(0); + expect(screen.getByText("稳定")).toBeInTheDocument(); + expect(screen.getByText(/Interactive Session, Supervisor Eval/)).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "配置文件" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "OpenCode" })); + + expect(screen.getByRole("tab", { name: "OpenCode" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("heading", { name: "OpenCode" })).toBeInTheDocument(); + expect(screen.getAllByText("有限支持").length).toBeGreaterThan(0); + expect(screen.getByText("实验性")).toBeInTheDocument(); + expect(screen.getByText(/Interactive Session/)).toBeInTheDocument(); + expect(screen.queryByText(/Supervisor Eval/)).not.toBeInTheDocument(); + }); + it("keeps command preview scoped to the provider that requested it", async () => { const claudePreview = createDeferred<{ preview: string }>(); const codexPreview = createDeferred<{ preview: string }>(); diff --git a/packages/web/src/features/settings/components/provider-settings.tsx b/packages/web/src/features/settings/components/provider-settings.tsx index 9b8f3165b..9b1a41be4 100644 --- a/packages/web/src/features/settings/components/provider-settings.tsx +++ b/packages/web/src/features/settings/components/provider-settings.tsx @@ -1,17 +1,26 @@ -import type { ProviderRuntimeStatusEntry, ProviderRuntimeStatusResponse } from "@coder-studio/core"; +import type { + ProviderListItem, + ProviderRuntimeStatusEntry, + ProviderRuntimeStatusResponse, +} from "@coder-studio/core"; import { useAtomValue } from "jotai"; import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { connectionStatusAtom } from "../../../atoms/connection"; -import { Button, Notice, SegmentedControl, Textarea } from "../../../components/ui"; +import { Button, Notice, SegmentedControl, Tag, Textarea } from "../../../components/ui"; import { useTranslation } from "../../../lib/i18n"; import { buildDiagnosticsPath } from "../../diagnostics"; import { ConfigEditor, type ConfigType } from "./config-editor"; import { useSessionGateDispatch } from "./use-session-gate-dispatch"; export interface ProviderInfo { - id: "claude" | "codex"; + id: string; displayName: string; + badge?: string; + kind?: ProviderListItem["kind"]; + stability?: ProviderListItem["stability"]; + capability?: ProviderListItem["capability"]; + capabilities?: ProviderListItem["capabilities"]; } interface ProviderSettingsProps { @@ -39,6 +48,12 @@ function createProviderRecord( return Object.fromEntries(providers.map((provider) => [provider.id, createValue()])); } +function supportsConfigEditor(provider: ProviderInfo | undefined): provider is ProviderInfo & { + id: ConfigType; +} { + return provider?.id === "claude" || provider?.id === "codex"; +} + export function ProviderSettings({ providers, additionalArgsById, @@ -157,11 +172,37 @@ export function ProviderSettings({ const showConfig = isMobile ? mobileView === "config" : desktopView === "config"; const currentPreview = provider ? (previewByProvider[provider.id] ?? "") : ""; const useFillHeightLayout = showConfig; + const providerSupportsConfigEditor = supportsConfigEditor(provider); + const providerBadge = provider?.badge ?? provider?.displayName ?? ""; + const providerCapabilitySummary = provider?.capability + ? t(`settings.provider.capability_${provider.capability}`) + : null; + const providerStabilitySummary = provider?.stability + ? t(`agent_panes.provider_stability_${provider.stability}`) + : null; + const providerCapabilitiesSummary = + provider?.capabilities + ?.filter((capability) => capability.supported) + .map((capability) => capability.label) ?? []; useEffect(() => { onLayoutModeChange?.(useFillHeightLayout ? "fill-height" : "default"); }, [onLayoutModeChange, useFillHeightLayout]); + useEffect(() => { + if (providerSupportsConfigEditor) { + return; + } + + if (desktopView === "config") { + setDesktopView("base"); + } + + if (mobileView === "config") { + setMobileView("base"); + } + }, [desktopView, mobileView, providerSupportsConfigEditor]); + useEffect(() => { if (connectionStatus !== "connected") { return; @@ -302,7 +343,7 @@ export function ProviderSettings({ value={selectedProvider} /> - {!isMobile ? ( + {!isMobile && providerSupportsConfigEditor ? ( - {runtime ? ( +
-

{t("settings.provider.status")}

- - {runtime.docUrls.provider ? ( +

{provider.displayName}

+
+ {providerBadge} + {providerCapabilitySummary ? ( + {providerCapabilitySummary} + ) : null} + {providerStabilitySummary ? ( + {providerStabilitySummary} + ) : null} +
+ {providerCapabilitiesSummary.length > 0 ? ( +

+ {t("agent_panes.provider_capabilities")}: {providerCapabilitiesSummary.join(", ")} +

+ ) : null} +
+ + {runtime ? ( +
+

{t("settings.provider.status")}

+ + {runtime.docUrls.provider ? ( + + ) : null} - ) : null} - -
- } - /> -
- ) : null} +
+ } + /> +
+ ) : null} -
-

{t("settings.provider.config")}

-

{t("settings.provider.startup_args_hint")}

-
- -