diff --git a/.gitignore b/.gitignore index 040e493f1..3885d0aba 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ src-tauri/binaries/ # pnpm local store (pnpm 11+) .pnpm-store/ + +# ZCode session artifacts +.zcode/ + +# PK Arena temporary worktrees +.codeg-pk/ diff --git a/docs/PK-ISSUES.md b/docs/PK-ISSUES.md new file mode 100644 index 000000000..a9a4b5e0c --- /dev/null +++ b/docs/PK-ISSUES.md @@ -0,0 +1,501 @@ +# PK Arena 实现问题审计 + +> 本文记录 `feat/agent-pk-arena` 的实现审计过程。各问题正文保留发现时的 +> 原始症状与根因,不代表当前实现仍有该问题;当前状态以文末状态表为准。 +> 初次整理于 2026-08-19,问题 0 来自 server 模式 5 选手实跑。 + +--- + +## 问题 0:server 模式下选手完成后状态卡在就绪,裁判不触发(P0 根因) + +**严重程度**:极高(阻断核心流程) + +**实跑证据**(round 7,2026-08-19,server 模式 / 浏览器): +- 5 个选手(claude_code, codex, open_code, deepseek, qoder)全部完成 git commit +- 后端日志全部有 `turn_complete stop_reason=end_turn` +- 但 DB pk_round status 仍然是 `running`,不是 `finished` +- 日志中 `judge` 出现 0 次,没有裁判 conversation 被创建 +- 选手状态全部停在 `ready`,UI 显示 0/5 + +**根因分析**: + +选手状态流转链:`connecting → ready → (status_changed "prompting") → running → (turn_complete) → done` + +- `use-pk-round.ts:922-924`:turn_complete 到达时检查 `contestant.status === "running"` +- `use-pk-round.ts:947-950`:选手从 ready → running 依赖收到 `status_changed` 事件的 `status === "prompting"` +- 如果 `status_changed(prompting)` 事件未到达前端,选手永远停在 `ready` +- 后续 `turn_complete` 到达时 `contestant.status === "running"` 为 false,直接忽略(return) +- round 永远停在 running,settleContestant 不被调用,裁判不触发 + +**事件投递链路**(server 模式): +- `acp-connections-context.tsx:4388-4398`:web/server 模式跳过全局 `acp://event` 监听 +- 事件只通过 per-connection attach stream 投递(`setupAttachSubscription`) +- PK 在 `use-pk-round.ts:1041` 调 `attachDelegationChild` → `:5912` 调 `setupAttachSubscription` +- attach stream onEvent → `applyMappedEnvelope`(:4177)→ reducer dispatch → fan out 到 useAcpEvent subscribers(:4184-4186) +- 链路代码看起来完整,但实际运行时 status_changed(prompting) 事件未到达 PK handler + +**待查**:是否 attach subscription 建立时机与 status_changed(prompting) 发出时机存在竞态,导致事件在 attach 完成前发出且未被 snapshot/replay 捕获。 + +**修复方向**: +1. 短期:startPrompt 发 prompt 后主动把选手设为 running(不依赖 status_changed 事件) +2. 长期:排查 server 模式 attach stream 是否丢失早期事件 + +--- + +## 问题 1:取消回合不触发裁判 + +**严重程度**:中(影响核心使用场景) + +**场景**:用户启动 PK,某个 agent 耗时过长,用户想提前结束并导出报告时触发裁判打分。 + +**现状**:做不到。 + +- `cancelRound`(`use-pk-round.ts:1102-1131`)只做:markRound("canceled") + 断开未完成选手连接 + 标选手为 canceled +- 裁判的唯一触发点在 `settleContestant`(`:795-814`),条件是所有选手 settled(done/error/canceled)后 markRound("finished") + 检查 judgeAgent +- cancelRound 直接 markRound("canceled"),不走 settleContestant 分支,裁判永远不会被触发 + +**修复方向**:cancelRound 末尾加:如果配了 judgeAgent 且 judgeStatus === "idle",调用 runJudge(复用 settleContestant 里的逻辑)。 + +--- + +## 问题 2:裁判评分不在导出报告里 + +**严重程度**:中 + +**现状**:`buildPkReportHtml`(`pk-report.ts:83-196`)完全没有引用 `round.judgeResult`。报告只包含:任务文本、元信息、计分板表格(选手/状态/用时/token/轮次/diff增删/文件数)、各选手 diff 详情。没有裁判评分板块。 + +**修复方向**:buildPkReportHtml 加上裁判评分渲染——渲染 `round.judgeResult.scores` 的排名、分数、点评、summary。 + +--- + +## 问题 3:裁判评分不在分享截图里 + +**严重程度**:低 + +**现状**:`handleShare`(`pk-arena-dialog.tsx:162-180`)只截 `scoreboardRef.current`,即 PkScoreboard 组件。PkJudgePanel 在 scoreboard 的外面(`:330-339`),截图不包含裁判面板。 + +**修复方向**:把截图范围扩大到包含 PkJudgePanel,或给裁判面板单独加 ref。 + +--- + +## 问题 4:裁判评分不持久化 + +**严重程度**:高 + +**现状**:`judgeResult` 只存在前端 store(`pk-arena-store.ts`)的内存里。`pk_round` 表没有 judge_result 字段(见 migration `m20260819_000001_pk_round.rs`)。刷新页面或重启 server 后,裁判结果丢失。只有裁判的 conversation transcript 还在 DB 里(kind=Pk 的 conversation)。 + +**影响**:用户跑完 PK、关掉 arena、再打开,裁判评分就没了。只能从裁判的 conversation 记录里人肉找 JSON。 + +**修复方向**:pk_round 表加 judge_result JSON 列,store hydrate 时读回。 + +--- + +## 问题 5:导出报告不含裁判评分,且取消时无法触发裁判 + +**严重程度**:高(两个问题叠加) + +这是问题 1 和问题 2 的叠加效应。用户想"提前结束 + 导出报告 + 看裁判打分"这个完整流程,当前完全做不到: +- cancel 不触发裁判(问题 1) +- 即使裁判跑过,报告也不含评分(问题 2) +- 截图也不含评分(问题 3) + +--- + +## 问题 6:控制变量 PK 的 UI 未完成 + +**严重程度**:中 + +**现状**:数据层已完成——`PkRoundConfig.agents` 支持 `Array<{agent, label}>` 新格式,兼容旧 `string[]` 格式(store hydrate 时归一化)。但 launcher UI(`pk-launcher-dialog.tsx`)不支持在选手选择区重复添加同一 agent。每个 agent 只显示一个按钮,选中/取消是 toggle,无法添加第二次。 + +**影响**:用户无法在 UI 上做"Claude Code Sonnet vs Claude Code Opus"这种控制变量实验。只能通过 API 直接创建。 + +**修复方向**:launcher 选手选择改为"添加槽位"模式,每个槽位独立选 agent + label。 + +--- + +## 问题 7:真实工程 PK 的任务来源仅做了 commit 拉取 + +**严重程度**:低 + +**现状**:launcher 有 "From commit" 按钮拉取最近 commit 作为任务。另有 "From diff" 按钮从工作区未提交改动拉取(调 gitStatus 列出改动文件,选择后调 gitDiff 填入 task)。以下任务来源未做(架构限制/收益低): +- 从 TODO 注释拉取 ❌(需新增后端扫描命令,收益低) +- 从 GitHub issue 拉取 ❌(依赖 gh 认证+网络,复杂度高) +- 在文件树里点选问题来 PK ❌(交互复杂,偏离 launcher 定位) + +--- + +## 问题 8:裁判无法手动重跑 + +**严重程度**:低 + +**现状**:裁判是 one-shot 自动触发,judgeStatus 从 idle→running→done/error,没有重跑按钮。如果裁判 JSON 解析失败(judgeStatus="error"),用户无法手动重新触发裁判。 + +**修复方向**:arena 里加"重新评分"按钮,重置 judgeStatus 为 idle 后调用 runJudge。 + +--- + +## 问题 9:进行中的回合可以无限制新开回合 + +**严重程度**:低(可能是预期行为) + +**现状**:arena 的 "New round" 按钮(`pk-arena-dialog.tsx:258-264`)始终可点击,不检查当前回合是否在运行。点击后打开 launcher,可以创建新回合,旧回合继续在后台跑。 + +**影响**:用户可以同时跑多个回合,没有互斥。可能导致资源占用过高(每个回合的选手都开独立 worktree + agent 连接)。 + +**评估**:这可能是有意设计(多回合并行),不是 bug。但如果需要限制,应在 launcher 的 start 校验里加检查。 + +--- + +## 问题 10:裁判评分维度固定,不可配置 + +**严重程度**:低 + +**现状**:裁判 prompt(`use-pk-round.ts:69-90`)硬编码 4 个评分维度: +1. Correctness — 是否完成任务 +2. Code quality — 可读性、结构、边界处理 +3. Completeness — 完成了多少 +4. Efficiency — 代码层面效率(明确排除 token 数和耗时) + +用户无法自定义评分维度或权重。 + +**修复方向**:launcher 加评分维度配置,传入 buildJudgePrompt。 + +--- + +## 问题 11:裁判只看 diff,不看运行结果 + +**严重程度**:低(设计限制) + +**现状**:裁判 prompt 只传 `contestantsWithDiffs`(`use-pk-round.ts:673-693`),即每个选手的 git diff 文本。裁判不跑代码、不看截图、不看运行日志。纯静态 diff 审查。 + +**影响**:对于"代码能跑但 diff 看起来差"或"代码差但能跑"的情况,裁判评分可能不准。 + +**评估**:这是当前架构限制,要支持运行结果需要重大改造(沙箱执行 + 截图捕获)。暂时记录,不做。 + +--- + +## 问题 12:server 模式下点击文件无法打开/定位到文件夹 + +**严重程度**:中 + +**场景**:server 模式(浏览器访问),用户在消息/文件引用里点击文件想打开所在文件夹——桌面模式可以(调系统 Finder),server 模式无反应。 + +**现状**: + +- `revealItemInDir`(`platform.ts:99-104`)和 `openPath`(`platform.ts:87-93`)在 web/server 模式下是 **no-op**——条件 `isDesktop() && getActiveRemoteConnectionId() === null` 为 false 时直接 return +- `file-reference-actions.tsx:128-130`:右键菜单的"在系统文件管理器中打开"选项在 server 模式下通过 `isLocalDesktop()` 守卫隐藏,用户看不到入口 +- `reply-artifacts.tsx:111`:AI 生成文件的"打开"按钮调 `revealItemInDir`,server 模式下静默失败 +- 后端有 `open_worktree_folder` HTTP 端点(`handlers/folders.rs:69`),但它的作用是把 worktree 注册到侧边栏文件夹列表,不是打开系统文件管理器 +- server 模式下浏览器没有权限直接操作本地文件系统,需要后端代理 + +**修复方向**: + +1. 后端加 `reveal_item` / `open_path` HTTP 端点,server 模式下调 `opener` crate 在服务器主机上打开 Finder/Explorer +2. 前端 `revealItemInDir` / `openPath` 在 server 模式下调 HTTP 端点而非 Tauri 插件 +3. `file-reference-actions.tsx` 的 `isLocalDesktop()` 守卫改为"桌面本地 OR server 模式"都显示入口 +4. 注意:server 部署在远程时,打开的是**服务器主机**的文件管理器,不是客户端的——这个限制需要在 UI 上提示用户 + +--- + +## 问题 13:任务完成后 arena 对话框只能最小化无法关闭 + +**严重程度**:中 + +**场景**:用户跑完 PK 后想关掉 arena 对话框,但只能最小化,没有关闭按钮。 + +**现状**: + +三层问题叠加: + +1. **正常进行中的回合**(`pk-arena-dialog.tsx:64-68`): + - `liveRef = round.status === "ready" || round.status === "running"` + - ESC 被阻止(`:199-201` onEscapeKeyDown preventDefault) + - 点遮罩被阻止(`:202-204` onPointerDownOutside preventDefault) + - 没有关闭按钮(`:197` showCloseButton={false}) + - 只有"最小化"按钮(`:265-275` setArenaOpen(false),不真正关闭/清理) + - 注释(`:64-65`)说"只有 X 按钮能显式关闭",但 X 按钮被 showCloseButton={false} 去掉了——设计意图与实现矛盾 + +2. **因问题 #0 导致的卡住**(最常见场景): + - 选手完成后 round.status 仍是 running(问题 #0),liveRef 永远 true + - 所有关闭路径被永久阻止 + - 只能最小化 + +3. **即使正常完成的回合**(status=finished/canceled): + - liveRef 为 false,ESC 和点遮罩能关 + - 但仍然没有 X 按钮(showCloseButton={false}),用户不知道怎么关 + - handleOpenChange(`:184-191`)能处理关闭,但入口不明显 + +**修复方向**: +1. 始终显示关闭按钮(showCloseButton=true),让用户有明确关闭入口 +2. 进行中的回合点关闭时弹确认("回合进行中,确定关闭?"),而非永久阻止 +3. 或保持 ESC/遮罩阻止,但给一个明确的关闭按钮 + 确认对话框 + +--- + +## 问题 14:PK 会话管理 UI 粗糙——下拉框切换 + 标题无信息 + 侧边栏不可见 + +**严重程度**:中(体验差,但不阻断功能) + +**三个子问题:** + +### 14a:回合切换只有一个下拉框 + +**现状**(`pk-arena-dialog.tsx:225-239`): + +```tsx + +``` + +- 只显示"时间 + 选手数",没有任务文本、状态、分数 +- max-w-40 导致长内容被截断 +- 多回合时无法快速区分"哪轮做了什么" +- 没有搜索/过滤 +- 没有删除单轮的入口(只有删除当前轮) + +### 14b:conversation 标题被 agent 自动覆盖,丢失 PK 上下文 + +**现状**: + +- `create_pk`(`conversation_service.rs:99-100`)创建时 `title = "PK · "`,`title_locked = false` +- agent 跑完后,`refresh_auto_title`(`:240-262`)因为 `title_locked = false`,用 agent 自己取的标题覆盖了 PK 标题 +- 实跑证据(round 7):DB 里 5 个选手的 title 分别是 "Interactive jelly blob browser toy"、"Build a tiny browser toy..." 等各不相同的标题,没有一个带 "PK ·" 前缀 +- 裁判会话的标题 `PK Judge · ` 也同样会被覆盖 + +**根因**:PK conversation 应该 `title_locked = true`,防止 agent auto-title 覆盖。 + +### 14c:PK 会话在侧边栏完全不可见 + +**现状**: + +- `sidebar-conversation-list.tsx:1059`:`c.kind !== "pk"` 过滤掉所有 PK 会话 +- `sidebar-conversation-grouping.ts:300`:`if (conv.kind === "pk") continue` 分组时也跳过 +- types.ts:421 注释说 `kind === "pk"` "drives the sidebar's per-round grouping",但实际分组逻辑根本没有实现——只有排除,没有 PK 专用分组渲染 +- 用户无法在侧边栏看到/打开 PK 选手的会话记录,只能通过 arena 对话框的 battle tab 看实时流 + +**修复方向**: + +1. **14b(最简单)**:`create_pk` 里设 `title_locked = true` +2. **14a**:回合切换改为列表/卡片视图,每轮显示:任务摘要、状态徽章、选手头像+分数、创建时间;支持搜索和删除 +3. **14c**:侧边栏加 PK 分组——按 round 分组,每组显示任务摘要 + 选手会话列表,点击打开选手 transcript + +--- + +## 问题 15:battle/diff 列固定 w-80,不按 agent 数量自适应分配空间 + +**严重程度**:中 + +**场景**:3 个选手时右侧大片留白,5 个选手时需要横向滚动。 + +**现状**: + +- `PkBattlePane`(`pk-arena-dialog.tsx:425`):`w-80 shrink-0` — 固定 320px,不可收缩 +- `PkReadyPane`(`:464`):同 `w-80 shrink-0` +- `PkDiffView`(`pk-diff-view.tsx:79`):`flex h-full min-h-0 flex-col` — 没有 shrink-0,但父容器 `flex h-full gap-2 overflow-x-auto`(`:360`)不限制子元素宽度,diff 列实际也不自适应 +- 容器(`:360`):`flex h-full gap-2 overflow-x-auto p-2` — 水平滚动布局,子元素不会被压缩到容器宽度内 +- 没有根据 `round.contestants.length` 计算 `flex-basis` 或 grid 列数的逻辑 + +**影响**: + +- 3 个 agent:3 × 320px = 960px,对话框通常 1400px+,右侧约 440px 留白 +- 5 个 agent:5 × 320px = 1600px,需要横向滚动 +- 8 个 agent(最大支持):8 × 320px = 2560px,大量滚动 + +**修复方向**: + +- 容器改 `grid` 布局,列数 = `min(contestants.length, 上限如6)`,`grid-template-columns: repeat(N, minmax(0, 1fr))` +- 或改 `flex-1 min-w-0`,让每列等分剩余空间 +- 列数超过上限时再回退到固定宽度 + 横向滚动 +- 需要同时改 PkBattlePane / PkReadyPane / PkDiffView 三个组件的根 div + +--- + +## 问题 16:导出报告里输出 token 和轮次全是 "—" + +**严重程度**:中 + +**现状**:`pk-report.ts:105-106` 里 `c.usage ? c.usage.outputTokens : "—"`——usage 为 null 时显示 "—"。实际跑完的 PK 报告里这两列全是 "—"。 + +**根因有三层:** + +1. **问题 #0 场景**(最常见):选手卡在 ready,settleContestant(`use-pk-round.ts:779-790`)从不执行,fetchUsage 从不被调用,usage 永远 null。 + +2. **重启/hydrate 场景**:即使选手曾经正常 done 且 fetchUsage 调过,重启后 hydrate 把 usage 重置为 null: + - `dbRoundToStoreRound`(`pk-arena-store.ts:246`):硬编码 `usage: null` + - `createRound`(`:315`):同 `usage: null` + - `pk-arena-host.tsx:40-48`:hydrate 从 DB 加载 rounds 后直接 `hydrateFromDb(storeRounds)`,**没有调 fetchUsage 重新拉取** + - store 注释(`:15`)说 "Live-only fields (connectionId, diff, usage) stay in the Zustand store — they are meaningless across restarts"——这个设计假设导致 usage 在重启后永远丢失 + +3. **即使 fetchUsage 被调用**(`use-pk-round.ts:471-489`): + - 调 `getFolderConversation` 拿 turns,遍历 assistant turns 累加 `turn.usage?.output_tokens` + - 如果 parser 没从 agent session 文件提取到 usage(某些 agent 格式不包含 token 信息),turn.usage 为 None + - 此时返回 `{ inputTokens: 0, outputTokens: 0, turnCount: N }`——不是 "—" 而是 0 + - DB 里 `token_usage_turn` 表 0 行,说明 token usage 同步机制也有问题 + +**实跑证据**(round 7): +- `token_usage_turn` 表 0 行 +- 5 个选手 status 全是 ready(问题 #0),settleContestant 从不执行 +- 报告里 token 和轮次全 "—" + +**修复方向**: +1. hydrate 时对已完成的选手调 fetchUsage 回填(usage 不应该被当作 live-only) +2. 或把 usage 持久化到 DB(pk_round 或 conversation 表) +3. 修复问题 #0 让选手正常走到 settleContestant + +--- + +## 问题 17:模板任务提示词应为中文,且缺少黑洞效果模板 + +**严重程度**:低(增强需求) + +**现状**(`pk-templates.ts:23-60`): + +6 个内置模板的 task 文本全是英文: +- "Generate an SVG of a pelican riding a bicycle." +- "Create an HTML animation: a ball starts in the center of a triangle..." +- "Build a tiny browser toy: a jelly blob..." +- "Write a Snake game in a single HTML file..." +- "Write a Flappy Bird clone..." +- "Create a voice-enabled chatbot web app..." + +i18n 只翻译了模板**标签**(zh-CN.json:5013-5018 "鹈鹕骑车"/"弹球"等),但 `setTask(tpl.task)` 填进 textarea 的是英文原文,用户提交前看到的是英文 prompt,agent 收到的也是英文。 + +**需求**: + +1. task 文本改为中文(面向中文用户为主) +2. 新增黑洞效果模板——视觉效果好的引力透镜/吸积盘动画,适合 PK 股评对比 + +**修复方向**: + +- `pk-templates.ts` 的 task 字段改为中文 +- 新增 `{ id: "blackHole", emoji: "🕳️", task: "..." }` 黑洞模板 +- i18n 所有语言的 `templates.blackHole` 标签 + +--- + +## 问题 18:"从提交拉取"混在模板按钮里,交互不清晰 + +**严重程度**:中(体验差) + +**现状**(`pk-launcher-dialog.tsx:290-367`): + +"从提交拉取"按钮和 6 个模板按钮并排排列(:304-333),视觉上像一个普通模板,但行为完全不同: + +- 模板按钮:一键填充 textarea,即时生效 +- 从提交拉取:点击后异步加载 git log,展开一个 commit 列表,再点一条 commit 才填充 +- 生成的 prompt 是硬编码英文模板(:352):`Reproduce the change from commit ${hash}: ${message}` + +**问题**: + +1. **分类混淆**:模板(预设创意任务)和 from-commit(真实工程复现任务)是两种完全不同的 PK 模式,混在一个按钮行里,用户不知道点了会发生什么 +2. **prompt 硬编码英文**:和问题 17 一样,生成的 commit 复现 prompt 也是英文 +3. **交互不可发现**:按钮点了会展开列表,但没有视觉提示这是个"可展开"操作,第一次用的用户会困惑 +4. **commit 只显示 hash + message**:没有日期、作者、改动文件数,用户无法判断该选哪条 +5. **只拉 5 条**:`gitLog(workingDir, 5)` 硬编码 5 条,不够时没法翻页 + +**修复方向**: + +把 launcher 的任务来源分成两个清晰的模式区: + +``` +┌─ 创意 PK(模板)──────────────────────────┐ +│ 🦤 🦤 ⚽ 弹球 🫧 果冻 🐍 贪吃蛇 ... │ +│ [一键填充任务文本] │ +└────────────────────────────────────────────┘ + +┌─ 真实工程 PK(从提交)──────────────────────┐ +│ [📋 从提交拉取] → 展开为 commit 选择面板 │ +│ • 展开后显示: hash · 日期 · 作者 · 改动文件数 │ +│ • prompt 改为中文: "复现提交 {hash} 的改动: │ +│ {message}" │ +│ • 支持加载更多 / 翻页 │ +└──────────────────────────────────────────────┘ +``` + +具体改动: +1. 模板按钮和 from-commit 在视觉上分区(不同容器/分隔线/标题) +2. from-commit 展开后的列表增加日期、作者、改动文件数(gitLog 支持 `withFiles`) +3. 复现 prompt 改为中文 +4. 支持加载更多 commit(limit 改为可配置,或加"加载更多"按钮) + +--- + +## 问题 19:战报中的嵌入式 HTML 可能无法完整运行 + +**严重程度**:高(影响战报预览与快速分享) + +**最新一轮实跑证据(round 5)**: + +- 6 份嵌入产物与各自 worktree 原文件逐字节一致,不是导出时截断或编码损坏 +- 其中 4 份页面使用了 `localStorage` +- 战报 iframe 当前使用 `sandbox="allow-scripts allow-pointer-lock"`,页面处于不透明来源 +- 页面访问 Web Storage 时会触发安全异常,导致后续初始化脚本中断;Qoder 页面表现为画布空白、开始层未显示 +- 仅作诊断时加入 `allow-same-origin` 后页面可以启动,证明根因是沙箱来源限制 + +**安全约束**:不能直接把 `allow-same-origin` 与 `allow-scripts` 长期组合使用,否则会明显削弱用户生成代码的隔离边界。 + +**修复方向**: + +1. 保留 iframe 沙箱隔离 +2. 导出时注入按选手隔离的内存版 `localStorage` / `sessionStorage` 兼容层 +3. 导出前扫描常见运行依赖并给出兼容性提示 +4. 增加真实浏览器回归测试,至少覆盖 Web Storage、键盘输入、Canvas 和重新开始流程 +5. 对外链依赖和多文件项目另行做资源打包,不能只靠单文件 `srcdoc` + +--- + +## 问题 20:顶部固定区域的 PK 按钮层级过高且入口重复 + +**严重程度**:中(信息架构与视觉层级问题) + +**现状**:`LeftEdgeChrome` 将 PK 双剑按钮与侧栏开关、远程工作区等壳层操作并列。它既可能新建竞技场,也可能打开已有竞技场,语义不稳定,视觉上还容易被理解为全局或窗口级操作。 + +**已有入口**: + +- 输入框附加菜单:新建 / 打开竞技场 +- 左侧 PK 竞技场列表:打开具体历史回合 +- 缩小胶囊:恢复当前进行中的竞技场 + +**与 `main` 分支风格的冲突**:`main` 的顶部固定区域只保留结构性、窗口级控制;代码中还明确移除了可从其他位置触达的重复入口。PK 属于业务功能,不应占据这一层级。 + +**决策方向**: + +1. 删除 `LeftEdgeChrome` 中的 PK 双剑按钮 +2. 保留输入框入口负责创建,侧栏负责历史,缩小胶囊负责恢复 +3. 如果新建入口仍不够明显,可在“PK 竞技场”分组标题悬停时显示轻量 `+`,不要重新放回全局顶栏 + +--- + +## 优先级排序 + +> ✅ 已修复: #0 #1 #2 #3 #4 #6 #7 #8 #10 #12 #13 #14a #14b #15 #16 #17 #18 #19 #20 + +| 优先级 | 问题 | 状态 | 说明 | +|--------|------|------|------| +| P0 | #0 server 模式选手状态卡 ready,裁判不触发 | ✅ | startPrompt 主动设 running | +| P0 | #4 裁判评分不持久化 | ✅ | pk_round 加 judge_result 列 | +| P0 | #1 取消不触发裁判 | ✅ | cancelRound 末尾调 runJudge | +| P1 | #2 报告不含裁判评分 | ✅ | 报告加颁奖台板块 | +| P1 | #3 截图不含裁判评分 | ✅ | 截图范围含 PkJudgePanel | +| P1 | #12 server 模式无法打开文件夹 | ✅ | 设计正确,加守卫隐藏 | +| P1 | #13 arena 对话框无法关闭 | ✅ | showCloseButton=true | +| P1 | #14b PK 标题被 agent 覆盖 | ✅ | title_locked=true | +| P1 | #15 battle/diff 列不自适应宽度 | ✅ | flex-1 min-w-80 | +| P1 | #16 报告里 token/轮次全 "—" | ✅ | hydrate 回填 usage | +| P1 | #19 战报嵌入式 HTML 无法完整运行 | ✅ | 保持沙箱隔离,注入内存 Web Storage 兼容层并补回归测试 | +| P2 | #14a 回合切换下拉框信息不足 | ✅ | option 加状态+task+冠军 | +| P2 | #14c PK 会话侧边栏不可见 | | 待修 | +| P2 | #6 控制变量 UI 未完成 | ✅ | 选手选择改槽位模式 | +| P2 | #8 裁判无法重跑 | ✅ | 重评按钮 | +| P2 | #20 顶部 PK 按钮层级过高且重复 | ✅ | 删除顶栏入口,保留创建、历史、恢复三条职责清晰的路径 | +| P3 | #10 评分维度不可配 | ✅ | launcher 加维度配置 | +| P3 | #7 任务来源不足 | ✅ | commit + 工作区改动拉取 | +| P3 | #17 模板提示词改中文 + 黑洞模板 | ✅ | 中文 + 🕳️ | +| P3 | #18 从提交拉取和模板分区 | ✅ | 分区 + commit 增强 | +| P4 | #9 无限新开回合 | | 可能预期行为 | +| P4 | #11 裁判只看 diff | | 架构限制 | diff --git a/docs/PK-ROADMAP.md b/docs/PK-ROADMAP.md new file mode 100644 index 000000000..900834cae --- /dev/null +++ b/docs/PK-ROADMAP.md @@ -0,0 +1,124 @@ +# PK Arena 演进路线图 + +> 整理于 2026-08-19。基于当前 `feat/agent-pk-arena` 分支已实现的基础设施, +> 规划下一阶段四个增强功能,目标是把 PK 做成真正的引流点。 + +--- + +## 现状(已实现) + +| 能力 | 状态 | 关键文件 | +|------|------|----------| +| 多 agent 同任务 PK(2-8 选手) | ✅ | `pk-launcher-dialog.tsx` | +| 每选手独立 git worktree 隔离 | ✅ | `use-pk-round.ts` → `gitWorktreeAdd` | +| 权限模式(default/acceptEdits/bypass) | ✅ | launcher + `applyPermissionMode` | +| 思考强度(effort)统一设定 | ✅ | launcher + `applyPreparedOptions` | +| 裸机模式(禁用 skills) | ✅ | `BARE_MODE_RULES` | +| 选手级 model/effort 选择器 | ✅ | `applyContestantSelection` | +| 实时 transcript 分屏 | ✅ | `pk-arena-dialog.tsx` | +| diff 对比 + 计分板 + 截图分享 | ✅ | `pk-diff-view.tsx` / `pk-scoreboard.tsx` | +| 回合持久化到 DB | ✅ | `pk_round` 表 + store hydrate | +| 中断恢复(interrupted) | ✅ | `dbRoundToStoreRound` | + +--- + +## 四个新功能 + +### 功能 1:快捷开赛模板 + +**问题**:每次 PK 都要手动选 agent、写任务、调参数,门槛高。 + +**方案**:预设一组常见任务模板,一键填入 launcher。 + +- 内置模板(基于 AI 圈知名的一句话 benchmark,结果可视化、自带话题性): + + | 模板 | 任务文本 | 测试维度 | 来源 | + |------|----------|----------|------| + | 鹈鹕骑车 | `Generate an SVG of a pelican riding a bicycle` | 空间推理 + SVG 编码 + 指令遵循 | Simon Willison 2024-10,AI 圈最著名的非正式 benchmark | + | 球在三角形里弹跳 | `Create an HTML animation: a ball starts in the center of a triangle. Every time it hits a side it speeds up, and the shape gains an extra side (Triangle→Square→Pentagon→Hexagon…)` | 动画逻辑 + 物理 + 动态形状 | Instagram 病毒对比帖,Qwen Coder 胜出 | + | 果冻 blob | `Build a tiny browser toy: a jelly blob. You poke, grab, stretch it. No scoring, no level, just a satisfying blob.` | 交互物理 + harness 能力 | DeepSeek-Reasonix 的 harness benchmark | + | 贪吃蛇 | `Write a Snake game in a single HTML file with keyboard controls.` | 基础工程 + 游戏逻辑 | 经典编程测试 | + | Flappy Bird | `Write a Flappy Bird clone in a single HTML file.` | 游戏逻辑 + Canvas | 常见 LLM 对比题 | + | 语音聊天 | `Create a voice-enabled chatbot web app using the Web Speech API.` | 多功能集成 + API 调用 | YouTube LLM 对比赛 | + +- 用户可自定义模板(存 localStorage 或 DB) +- 模板内容:`{ name, task, suggestedAgents?, bareMode?, effort? }` +- 入口:launcher 对话框顶部加一排模板按钮,点击即填 +- 引流角度:鹈鹕骑车已是 AI 圈共识 benchmark,"用 Codeg PK 场跑鹈鹕骑车"自带搜索流量和话题认同 + +**工作量**:小。纯前端,不改后端。 + +### 功能 2:真实工程 PK + +**问题**:一句话任务太玩具,不反映 agent 在真实项目里的能力。 + +**方案**:PK 直接在当前打开的项目里跑,选手各自在 worktree 里实现特性 / 修 bug。 + +- 现有基础设施**已支持**:launcher 已从 activeTab 读 `workingDir`,`gitWorktreeAdd` 已在项目下建 `.codeg-pk///` worktree +- 差的是**任务来源体验**: + - 目前只能手输任务文本 + - 增强:支持从 git diff / commit message / TODO 注释 / GitHub issue 拉取任务描述 + - 可选:在项目文件树里点选"就拿这个文件的问题来 PK" +- 选手的 diff 已经对基准分支做(`gitDiffWithBranch`),真实工程的改动能正确捕获 + +**工作量**:中。主要是前端任务输入增强 + 可选的 issue 拉取(需 GitHub API)。 + +### 功能 3:主裁判自动打分 + +**问题**:PK 结果靠人肉看 diff,没有量化评分,不够系统,也不便传播。 + +**方案**:指定一个 agent 当裁判,读所有选手 diff 后打分排座次。 + +- 回合结束后(所有选手 `done`),自动启动裁判 agent +- 裁判 prompt 包含:任务描述 + 每个选手的 diff + 评分维度(正确性 / 代码质量 / 效率 / 完成度) +- 裁判输出结构化评分(JSON:每选手每维度分数 + 总分 + 排名 + 点评) +- 评分结果展示在计分板下方,可随截图一起分享 +- 裁判可以是任意已安装的 agent(甚至可以加入一个"不参赛只裁判"的 agent) +- 可选:多裁判投票制(2-3 个裁判各自打分取平均) + +**工作量**:中。需要: +- 后端:`pk_round` 表加 `judge_agent` + `judge_result` 字段 +- 前端:launcher 加裁判选择器,arena 加评分展示区 +- 编排:`use-pk-round.ts` 在回合结束后触发裁判连接 + +### 功能 4:控制变量 PK(同 agent 不同配置) + +**问题**:想做"同一 agent 跑不同 model / effort"的对比,但当前一个 agent 只能选一个槽位。 + +**方案**:选手身份从 `agentType` 升级为 `(agentType, slotLabel)`,允许同一 agent 出现多次。 + +- 核心改动:contestant 的唯一键从 `agentType` 改为 `contestantId`(`agentType + slot 索引`) +- 影响面: + - `contestantBranchName` / `contestantContextKey` 加 slot 后缀 + - `PkRoundConfig.agents` 从 `string[]` 改为 `Array<{ agent: string; label?: string }>` + - DB `pk_round.config` JSON 结构升级(需兼容旧数据) + - 前端 launcher 支持重复添加同一 agent,每个槽位单独设 model/effort +- 典型场景: + - Claude Code × Sonnet vs Claude Code × Opus(同 agent 不同 model) + - Codex × medium vs Codex × high(同 agent 不同 effort) + - Codex 裸机 vs Codex 带 skills(同 agent 不同 bareMode) + - 同 agent 不同 system prompt / MCP 配置(更远期) + +**工作量**:中偏大。改动横跨前后端 + DB schema + 编排逻辑,是四个功能里最重的。 + +--- + +## 实施优先级 + +| 顺序 | 功能 | 理由 | 预估工作量 | +|------|------|------|-----------| +| 1 | 快捷开赛模板 | 改动最小、体验提升最直接、立刻可用 | 半天 | +| 2 | 主裁判自动打分 | PK 结果可量化 = 引流核心素材,差异化最强 | 1-2 天 | +| 3 | 控制变量 PK | 直击用户真实疑问(Opus 值不值 / high 强多少),天然话题 | 2-3 天 | +| 4 | 真实工程 PK | 基础设施已就绪,增强任务来源即可,但不急于做 issue 拉取 | 1 天(基础)/ 2-3 天(含 issue) | + +**建议**:1 → 2 → 3 → 4 顺序做。1 和 2 做完就能产生第一批传播素材;3 做完 PK 的"控制变量"叙事就完整了;4 是锦上添花。 + +--- + +## 技术约束与风险 + +- **功能 3(裁判)**:裁判也是 one-shot 委托,裁判 agent 的 diff 不参与排名,只输出评分。注意裁判 token 也算成本。 +- **功能 4(控制变量)**:DB schema 变更需写迁移,旧 `config.agents: string[]` 要兼容读。建议用 discriminated union:`agents` 既接受旧 `string[]` 也接受新 `Array<{agent, label}>`,读取时统一归一化。 +- **所有功能**:保持双模式(Tauri + Axum)兼容,`_core` 函数共用,前端 transport 自动检测。 +- **测试**:每个功能完成后跑 `pnpm test` + `cargo test --features test-utils`,功能 4 需新增 DB 迁移测试。 diff --git a/docs/PK-TEST-CASES.md b/docs/PK-TEST-CASES.md new file mode 100644 index 000000000..25b2564af --- /dev/null +++ b/docs/PK-TEST-CASES.md @@ -0,0 +1,775 @@ +# PK Arena 测试用例 + +> 给测试 agent 用。基于 server 模式 + Playwright 浏览器自动化。 +> 测试前先读「环境准备」一节。 + +--- + +## 环境准备 + +### 1. 构建 server 二进制 + +```bash +cd src-tauri +cargo build --no-default-features --bin codeg-server +``` + +### 2. 构建前端静态文件 + +```bash +pnpm build # 产出 out/ 目录 +``` + +### 3. 启动 server + +```bash +CODEG_PORT=3080 \ +CODEG_HOST=127.0.0.1 \ +CODEG_TOKEN=test-token-123 \ +CODEG_DATA_DIR=/tmp/codeg-pk-test \ +CODEG_STATIC_DIR=$(pwd)/out \ +./src-tauri/target/debug/codeg-server +``` + +### 4. 浏览器访问 + +``` +http://127.0.0.1:3080 +``` + +登录页输入 token:`test-token-123` + +### 5. 准备测试用 git 仓库 + +PK 需要一个有 git 仓库的 folder。测试前创建: + +```bash +mkdir -p /tmp/codeg-pk-test-repo +cd /tmp/codeg-pk-test-repo +git init +echo "# Test Repo" > README.md +git add . && git commit -m "initial commit" +echo "print('hello')" > hello.py +git add . && git commit -m "add hello.py" +``` + +然后在 Codeg 里「添加文件夹」指向 `/tmp/codeg-pk-test-repo`。 + +### 6. 安装 agent + +PK 只列出已安装的 agent。至少需要 2 个已安装的 agent(如 Claude Code、Codex)。 +如果测试环境没有真实 agent,可以用 mock——但 UI 自动化测试主要验证前端交互逻辑, +agent 未安装时 launcher 不显示选手按钮,这是预期行为,应作为边界用例。 + +--- + +## 选择器约定 + +PK 组件没有 data-testid(除 scoreboard 和 minimized-pill)。 +用以下策略定位元素: + +| 元素 | 选择器 | +|------|--------| +| Launcher 对话框 | `role="dialog"` 内含文本 "Agent PK" | +| 选手按钮 | 选手区域的 `button[aria-pressed]`,文本匹配 agent 名称 | +| 模板按钮 | `button[title]`,title 属性包含模板任务文本 | +| 任务文本框 | `#pk-task` | +| 权限单选 | `input[name="pk-permission"]` | +| effort 按钮 | effort 区域的 `button[aria-pressed]` | +| 裸机模式复选框 | bareMode label 内的 `input[type="checkbox"]` | +| 裁判按钮 | 裁判区域的 `button[aria-pressed]`,"No judge" 或 agent 名 | +| 开始按钮 | 对话框底部含 "Start match" 文本的 button | +| 取消按钮 | 对话框底部含 "Cancel" 文本的 button | +| 计分板 | `[data-testid="pk-scoreboard"]` | +| 最小化浮标 | `[data-testid="pk-minimized-pill"]` | +| Arena 对话框 | `role="dialog"` 内含 "Agent PK arena" | +| Tab 按钮 | 含 "Battle" 或 "Diff" 文本的 button | +| 选手状态 | 计分板内 `span` 文本 | + +--- + +## 测试用例 + +### TC-01:Launcher 打开与初始状态 + +**前置**:已登录,已打开一个 git 仓库 folder,该 tab 处于活跃状态。 + +**步骤**: +1. 找到入口触发 PK launcher(左侧栏的 ⚔ 图标,或 composer 菜单里的 PK 选项) +2. 等待对话框出现 + +**验证**: +- [ ] 对话框可见,标题为 "Agent PK" +- [ ] 选手区域可见,显示 "Contestants (2-4)" 标签 +- [ ] 已安装的 agent 以圆形按钮列出,每个带图标和名称 +- [ ] 每个选手按钮 `aria-pressed="false"`(未选中状态) +- [ ] 任务文本框 `#pk-task` 存在且为空 +- [ ] 任务文本框上方有模板按钮行(鹈鹕骑车、弹球、果冻 Blob、贪吃蛇、Flappy Bird、语音聊天) +- [ ] 权限区域默认选中 "default"(第一个 radio) +- [ ] effort 区域默认选中 "Default" +- [ ] 裸机模式复选框未选中 +- [ ] 裁判区域可见,"No judge" 按钮处于选中状态(`aria-pressed="true"`) +- [ ] 底部 "Start match" 按钮处于 disabled 状态 +- [ ] 底部计数显示 "0/8 picked (min 2)" + +### TC-02:选手选择与取消 + +**前置**:TC-01 通过。 + +**步骤**: +1. 点击第一个 agent 按钮 +2. 验证该按钮 `aria-pressed="true"` +3. 点击第二个 agent 按钮 +4. 验证两个按钮都 `aria-pressed="true"` +5. 点击第一个 agent 按钮取消 + +**验证**: +- [ ] 选中后按钮样式变化(border-primary + bg-primary/10) +- [ ] 底部计数更新为 "1/8 picked (min 2)"(选一个再取消一个后) +- [ ] 只选 1 个时显示 "Pick at least 2 agents to run a match." 提示 +- [ ] 选 2 个后提示消失 +- [ ] "Start match" 按钮在选满 2 个 + 有任务文本后变为 enabled + +### TC-03:快捷模板填充 + +**前置**:TC-01 通过。 + +**步骤**: +1. 点击 "🦤 Pelican" 模板按钮 + +**验证**: +- [ ] `#pk-task` 文本框内容变为 "Generate an SVG of a pelican riding a bicycle." +- [ ] 模板按钮的 `title` 属性包含完整任务文本 + +**步骤**: +2. 清空文本框(手动或点另一个模板覆盖) +3. 依次点击每个模板按钮,验证文本框内容 + +**验证**: +- [ ] "⚽ Bouncing Ball" → "Create an HTML animation: a ball starts in the center of a triangle..." +- [ ] "🫧 Jelly Blob" → "Build a tiny browser toy: a jelly blob..." +- [ ] "🐍 Snake" → "Write a Snake game in a single HTML file with keyboard controls." +- [ ] "🐤 Flappy Bird" → "Write a Flappy Bird clone in a single HTML file with Canvas rendering." +- [ ] "🎙️ Voice Chat" → "Create a voice-enabled chatbot web app using the Web Speech API." + +### TC-04:从 Git 提交拉取任务 + +**前置**:TC-01 通过,当前 folder 是 git 仓库且有至少 2 个 commit。 + +**步骤**: +1. 找到 "📋 From commit" 按钮(在模板按钮行末尾) +2. 点击它 + +**验证**: +- [ ] 出现一个下拉列表,显示最近的 commit +- [ ] 每个 commit 显示 hash 前 7 位 + commit message 第一行 +- [ ] 如果还在加载,显示 "Loading commits…" + +**步骤**: +3. 点击第一个 commit + +**验证**: +- [ ] 下拉列表关闭 +- [ ] `#pk-task` 文本框内容变为 "Reproduce the change from commit : " +- [ ] 文本中的 hash 是完整 hash 还是短 hash 取决于 API 返回,验证前 7 位匹配 + +### TC-05:权限模式切换 + +**前置**:TC-01 通过。 + +**步骤**: +1. 默认状态验证 "default" radio 被选中 +2. 点击 "acceptEdits" radio +3. 点击 "bypassPermissions" radio +4. 点回 "default" + +**验证**: +- [ ] 每次切换后,对应 radio 的 `checked` 属性为 true +- [ ] 每个选项旁边有提示文本(如 "file edits run without asking") +- [ ] 权限区域下方有说明文本 + +### TC-06:Effort 等级切换 + +**前置**:TC-01 通过。 + +**步骤**: +1. 默认状态验证 "Default" 按钮处于选中状态 +2. 依次点击 Low → Medium → High → Max → Default + +**验证**: +- [ ] 每次点击后,对应按钮 `aria-pressed="true"` +- [ ] 其他按钮 `aria-pressed="false"` +- [ ] 选中按钮有高亮样式 + +### TC-07:裸机模式切换 + +**前置**:TC-01 通过。 + +**步骤**: +1. 验证复选框初始未选中 +2. 点击复选框 + +**验证**: +- [ ] 复选框变为选中状态 +- [ ] 复选框旁有 "Bare mode (no skills)" 标签 +- [ ] 下方有说明文本 + +### TC-08:裁判选择 + +**前置**:TC-01 通过。 + +**步骤**: +1. 验证裁判区域可见 +2. 验证 "No judge" 按钮处于选中状态(`aria-pressed="true"`) +3. 点击一个 agent 作为裁判 + +**验证**: +- [ ] "No judge" 按钮变为未选中 +- [ ] 选中的 agent 按钮 `aria-pressed="true"` +- [ ] 如果该 agent 已被选为选手,其裁判按钮显示 `opacity-40`(半透明,表示不建议自裁判) + +**步骤**: +4. 点击 "No judge" 取消裁判 + +**验证**: +- [ ] "No judge" 按钮回到选中状态 +- [ ] 之前选中的 agent 裁判按钮变为未选中 + +### TC-09:启动验证 — 缺选手 + +**前置**:TC-01 通过。 + +**步骤**: +1. 只选 1 个 agent +2. 在任务文本框输入 "test task" +3. 检查 "Start match" 按钮状态 + +**验证**: +- [ ] "Start match" 按钮处于 disabled 状态 +- [ ] 显示 "Pick at least 2 agents to run a match." 提示 + +### TC-10:启动验证 — 缺任务 + +**前置**:TC-01 通过。 + +**步骤**: +1. 选 2 个 agent +2. 任务文本框留空 +3. 检查 "Start match" 按钮状态 + +**验证**: +- [ ] "Start match" 按钮处于 disabled 状态 + +### TC-11:启动验证 — 非 Git 仓库 + +**前置**:打开一个非 git 仓库的 folder。 + +**步骤**: +1. 打开 PK launcher + +**验证**: +- [ ] 选手区域不显示 agent 按钮(或显示"需要 git 仓库"提示) +- [ ] 显示 "This folder is not a git repository" 文本 +- [ ] 有 "git init" 按钮 +- [ ] "Start match" 按钮处于 disabled 状态 + +### TC-12:完整 PK 流程 — 启动到就绪 + +**前置**:已安装至少 2 个 agent,已打开一个 git 仓库 folder。 + +**步骤**: +1. 打开 PK launcher +2. 选 2 个 agent +3. 点击 "🦤 Pelican" 模板 +4. 权限设为 "acceptEdits" +5. effort 设为 "Medium" +6. 勾选裸机模式 +7. 选一个不参赛的 agent 作为裁判(如果有第 3 个 agent) +8. 点击 "Start match" + +**验证**: +- [ ] Launcher 对话框关闭 +- [ ] Arena 对话框打开 +- [ ] Arena 顶部显示任务文本 +- [ ] 状态显示 "Ready"(不是 "Live") +- [ ] 计分板 `[data-testid="pk-scoreboard"]` 可见 +- [ ] 计分板显示 2 个选手卡片 +- [ ] 每个选手卡片显示 agent 图标、名称、状态点(amber/ready) +- [ ] 有 "Start match" 按钮在 arena 内(准备态的启动按钮) +- [ ] 如果选了裁判,裁判面板可见 +- [ ] Battle tab 默认选中 +- [ ] 每个选手面板显示 "Ready" 标签 + 模型/effort 选择器(如果 agent 通告了选项) + +### TC-13:Arena — 模型/effort 选择器(准备态) + +**前置**:TC-12 通过,选手处于 ready 状态且 agent 通告了 configOptions。 + +**步骤**: +1. 在第一个选手面板里,如果模型选择器存在,选择一个不同的模型 +2. 如果 effort 选择器存在,选择一个不同的 effort + +**验证**: +- [ ] 选择器是 ``)可见 +3. 切换到另一个回合 + +**验证**: +- [ ] 回合选择器的每个 option 显示时间 + 选手数 +- [ ] 切换后,arena 显示选中回合的任务、选手、状态 +- [ ] 已完成回合的 live transcript 不可见(连接已断开),但持久化的会话内容仍可渲染 + +### TC-25:Arena — 清理 Worktree + +**前置**:TC-16 通过,回合已完成。 + +**步骤**: +1. 找到 "Clean worktrees" 按钮 +2. 点击它 + +**验证**: +- [ ] 选手的 worktree 被移除 +- [ ] 分支保留(keepBranches=true) +- [ ] 按钮消失或变为不可用 + +### TC-26:Arena — 删除回合 + +**前置**:TC-16 通过。 + +**步骤**: +1. 找到 "Delete" 按钮 +2. 点击它 +3. 在确认对话框点确认 + +**验证**: +- [ ] 回合从列表中移除 +- [ ] 如果还有其他回合,自动切换到第一个 +- [ ] 如果没有回合了,arena 显示 "No round selected" + +### TC-27:Arena — 分享截图 + +**前置**:TC-16 通过,计分板有数据。 + +**步骤**: +1. 点击 "Share" 按钮 + +**验证**: +- [ ] 按钮文本变为 "Exporting…" +- [ ] 导出完成后(按钮恢复 "Share"),浏览器触发文件下载 +- [ ] 下载的文件名匹配 `codeg-pk-.png` + +### TC-28:Arena — 导出报告 + +**前置**:TC-16 通过。 + +**步骤**: +1. 点击 "Export report" 按钮 + +**验证**: +- [ ] 按钮文本变为 "Building…" +- [ ] 导出完成后浏览器触发文件下载 +- [ ] 下载的文件名匹配 `codeg-pk-.html` +- [ ] 打开 HTML 文件,验证包含任务文本、选手信息、diff 内容 +- [ ] 如果有裁判结果,报告包含裁判评分 + +### TC-29:控制变量 PK — 同 agent 多 slot(数据层) + +**前置**:已安装至少 1 个 agent。 + +**说明**:当前 UI 不支持直接在 launcher 里添加同一 agent 两次。 +此用例通过 API 直接验证数据层。 + +**步骤**: +1. 通过 HTTP API 创建一个带重复 agent 的 round: + +```bash +curl -X POST http://127.0.0.1:3080/pk_round_create \ + -H "Authorization: Bearer test-token-123" \ + -H "Content-Type: application/json" \ + -d '{ + "folder_id": 1, + "task": "test control variable", + "config": { + "agents": [ + {"agent": "claude_code", "label": "Sonnet"}, + {"agent": "claude_code", "label": "Opus"} + ], + "permission_mode": "default", + "bare_mode": false, + "effort": "default" + } + }' +``` + +**验证**: +- [ ] API 返回 200,round 创建成功 +- [ ] 返回的 config.agents 有 2 个条目,都是 claude_code,label 分别为 "Sonnet" 和 "Opus" + +**步骤**: +2. 通过 API 列出 rounds: + +```bash +curl -X POST http://127.0.0.1:3080/pk_round_list \ + -H "Authorization: Bearer test-token-123" \ + -H "Content-Type: application/json" \ + -d '{"folder_id": null}' +``` + +**验证**: +- [ ] 返回列表包含刚创建的 round +- [ ] config.agents 正确反序列化为 2 个 labeled 条目 + +**步骤**: +3. 打开 arena(如果有 UI 入口查看此 round) + +**验证**: +- [ ] 计分板显示 2 个选手卡片 +- [ ] 两个选手都是同一个 agent(相同图标和名称) +- [ ] 选手卡片用 slot 区分(不同的 key) + +### TC-30:控制变量 PK — 旧格式兼容 + +**说明**:验证旧格式(agents 为纯字符串数组)仍能正确反序列化。 + +**步骤**: +1. 通过 API 创建一个旧格式的 round: + +```bash +curl -X POST http://127.0.0.1:3080/pk_round_create \ + -H "Authorization: Bearer test-token-123" \ + -H "Content-Type: application/json" \ + -d '{ + "folder_id": 1, + "task": "test old format", + "config": { + "agents": ["claude_code", "codex"], + "permission_mode": "default", + "bare_mode": false, + "effort": "default" + } + }' +``` + +**验证**: +- [ ] API 返回 200 +- [ ] 返回的 config.agents 是 `["claude_code", "codex"]`(旧格式保持不变) +- [ ] 列表 API 也能正确返回 + +### TC-31:裁判 API — 无裁判的 round + +**步骤**: +1. 创建一个不带 judge_agent 的 round(默认) + +**验证**: +- [ ] config 里没有 `judge_agent` 字段(`skip_serializing_if = "Option::is_none"`) +- [ ] Arena 不显示裁判面板 + +### TC-32:裁判 API — 带裁判的 round + +**步骤**: +1. 创建一个带 judge_agent 的 round: + +```bash +curl -X POST http://127.0.0.1:3080/pk_round_create \ + -H "Authorization: Bearer test-token-123" \ + -H "Content-Type: application/json" \ + -d '{ + "folder_id": 1, + "task": "test judge", + "config": { + "agents": ["claude_code", "codex"], + "permission_mode": "default", + "bare_mode": false, + "effort": "default", + "judge_agent": "gemini" + } + }' +``` + +**验证**: +- [ ] 返回的 config 包含 `judge_agent: "gemini"` +- [ ] Arena 显示裁判面板(即使状态为 idle,面板也应渲染) + +### TC-33:中断恢复 + +**前置**:有一个运行中的 PK 回合。 + +**步骤**: +1. 强制重启 server(kill 进程后重新启动) + +**验证**: +- [ ] 重启后 arena 中该回合状态变为 "Interrupted by restart" +- [ ] 选手状态变为 "canceled" +- [ ] 不会自动重新启动比赛(drivenRef 机制防止重放) +- [ ] 已完成的回合不受影响 + +### TC-34:多人多语言 — 中文界面 + +**前置**:浏览器语言设为 zh-CN。 + +**步骤**: +1. 打开 PK launcher + +**验证**: +- [ ] 对话框标题为 "Agent PK"(英文保持不变,或对应中文翻译) +- [ ] 模板按钮显示中文名称:"鹈鹕骑车"、"弹球"、"果冻 Blob"、"贪吃蛇" +- [ ] 裁判标签显示 "裁判(可选)" +- [ ] "No judge" 按钮显示 "无裁判" + +### TC-35:多人多语言 — 日文界面 + +**前置**:浏览器语言设为 ja。 + +**步骤**: +1. 打开 PK launcher + +**验证**: +- [ ] 模板按钮显示日文名称:"ペリカン"、"ボール"、"ゼリー"、"スネーク" +- [ ] 裁判标签显示 "審査員(任意)" +- [ ] "No judge" 按钮显示 "審査員なし" + +### TC-36:边界 — 最大选手数 + +**步骤**: +1. 选 8 个 agent(如果安装了 8 个) + +**验证**: +- [ ] 第 9 个无法选中(toggle 函数在达到 MAX_CONTESTANTS 时忽略新选择) +- [ ] 底部计数显示 "8/8 picked (min 2)" + +### TC-37:边界 — 空文件夹 + +**前置**:打开一个空的 git 仓库(只有 initial commit,无其他文件)。 + +**步骤**: +1. 打开 PK launcher +2. 点击 "From commit" 按钮 + +**验证**: +- [ ] 只显示 initial commit +- [ ] 选择后任务文本框填充正常 + +### TC-38:完整流程回归 — 从模板到评分 + +**前置**:已安装至少 3 个 agent(2 个选手 + 1 个裁判)。 + +**步骤**: +1. 打开 PK launcher +2. 选 2 个 agent 作为选手 +3. 点击 "🐍 Snake" 模板 +4. 权限设为 "bypassPermissions" +5. effort 设为 "High" +6. 选第 3 个 agent 作为裁判 +7. 点击 "Start match" +8. 在 arena 的 ready 态点击 "Start match" 启动比赛 +9. 等待所有选手完成 +10. 等待裁判完成评分 +11. 切换到 Diff tab 查看 diff +12. 点击 "Share" 导出截图 +13. 点击 "Export report" 导出报告 + +**验证**: +- [ ] 每一步都按预期执行 +- [ ] 选手完成后裁判自动启动 +- [ ] 裁判评分显示在计分板下方 +- [ ] Diff tab 显示每个选手的代码变更 +- [ ] 截图下载成功 +- [ ] HTML 报告下载成功且内容完整 + +### TC-39:状态文案一致性 + +**步骤**: +1. 在 launcher 和 arena 中检查所有可见文本 + +**验证**: +- [ ] Launcher 的按钮文案与 i18n 消息文件一致 +- [ ] Arena 的状态文案(Ready/Live/Finished/Canceled/Interrupted)与 i18n 一致 +- [ ] 计分板的状态文案(preparing/connecting/running/done/error/canceled)与 i18n 一致 +- [ ] 裁判面板的文案(Judge Verdict/Evaluating/Judge failed)与 i18n 一致 + +### TC-40:Launcher 复赛预填 + +**前置**:之前成功启动过一次 PK。 + +**步骤**: +1. 再次打开 PK launcher + +**验证**: +- [ ] 上次的选手选择被预填(如果 agent 仍可用) +- [ ] 上次的任务文本被预填 +- [ ] 上次的权限模式被预填 +- [ ] 上次的 effort 被预填 +- [ ] 上次的裸机模式被预填 +- [ ] 上次的裁判选择被预填 + +--- + +## API 端点参考 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/pk_round_list` | POST | 列出所有 round(可按 folder_id 过滤) | +| `/pk_round_get` | POST | 获取单个 round | +| `/pk_round_create` | POST | 创建 round | +| `/pk_round_update_status` | POST | 更新 round 状态 | +| `/pk_round_delete` | POST | 删除 round(软删除) | +| `/git_log` | POST | 获取 git 提交历史 | +| `/git_branch` | POST | 获取当前分支 | +| `/git_worktree_add` | POST | 创建 worktree | +| `/git_remove_worktree` | POST | 移除 worktree | +| `/git_diff` | POST | 获取 diff | +| `/git_diff_with_branch` | POST | 获取对基准分支的 diff | +| `/git_init` | POST | 初始化 git 仓库 | + +所有请求需要 `Authorization: Bearer ` 头。 + +请求体格式为 JSON,参数名用 snake_case。 diff --git a/docs/STRATEGY-MEMO.md b/docs/STRATEGY-MEMO.md new file mode 100644 index 000000000..6b27b1c19 --- /dev/null +++ b/docs/STRATEGY-MEMO.md @@ -0,0 +1,398 @@ +# Codeg 战略备忘录 + +> 内部决策记录。整理于一次深度代码评审 + 商业化讨论之后。 +> 目的:把散落在对话里的分析、判断、案例、待办沉淀下来,避免重复讨论。 +> 2026-08-16 第二次更新:性能深度扫描 + 上游 issue 需求验证(见二、三、六、九节标注)。 + +--- + +## 一、项目定位(已确认,不再动摇) + +**Codeg = 多智能体编码工作台(Multi-Agent Coding Workspace)。** + +- 服务对象:**开发者**(不是非开发者,不是垂直行业) +- 核心能力:**跨进程异构 agent 委托**(不是单进程换模型) +- 不做:**Cursor 平替、通用 agent 平台、垂直行业方案** + +### 定位边界(加宽 vs 跳船) + +| 允许 | 禁止 | +|------|------| +| 覆盖开发者的更多环节(编码/文档/审查) | 脱离开发者去做通用市场 | +| 引入更多编码 agent(Pi/GenericAgent) | 引入非编码 agent 后转去做非开发场景 | +| 加宽定位(开发者全流程) | 跳船(服务完全不同的人群) | + +--- + +## 二、技术资产评审(代码级事实) + +### 已完成且扎实的部分 + +| 模块 | 规模 | 评价 | +|------|------|------| +| `acp/delegation/` | ~1.4 万行 Rust,200+ 测试 | **核心护城河**,工程深度极高 | +| `acp/` 顶层 | ~2.5 万行(connection/manager/lifecycle 等) | ACP 协议完整实现 | +| `parsers/` | ~1.8 万行,10 个 agent | 会话聚合,覆盖广 | +| 双模式架构 | codeg / codeg-server / codeg-mcp | 部署形态齐全,`_core` 函数共用 | + +### delegation 模块的技术亮点(护城河来源) + +1. **跨进程真委托** — `delegate_to_agent` 起独立 PID,不是进程内换 prompt +2. **异步 fan-out** — 同时开多个子 agent,`get_delegation_status` 批量长轮询 +3. **取消级联** — 四种取消路径全覆盖(外部/子/父连接/父轮次) +4. **Setup 窗口竞态处理** — inflight 注册 + checkpoint + atomic park,零信任时序 +5. **深度限制** — `depth.rs` 防递归爆炸,带 cap 防脏数据 +6. **安全边界** — 一次性 token + parent scoping + UDS/piped 传输 +7. **测试覆盖** — broker 109 测试、companion 53、listener 25,覆盖全边界条件 +8. **trait 解耦** — `ConnectionSpawner` 为 v3 远程 agent 预留扩展点 + +### 当前短板(v1 限制,2026-08-16 复测) + +1. **v1 是 one-shot** — `broker.rs:2527` `disconnect` 写死,子 agent 跑完即杀。`continue_with_session`/`close_session` 仍不存在,`acp/delegation/mod.rs:29` 注释还挂着 v2 计划 +2. **巨石文件三座山** — `commands/acp.rs` 17020 行、`acp/connection.rs` 16155 行、`broker.rs` 8481 行(此前记 7554,又长了 ~930;前两个比 broker 更大,之前漏记)。broker 约 4750 行是测试,外移到 `broker_tests.rs` 是零风险减半 +3. **没有远程 agent** — 只能调度本机,v3 未实现 +4. **结果不落库(比原记轻)** — 完整文本只在 512MB 内存 FIFO 缓存(`broker.rs:79`);但有界 `text_preview` 已持久化进父会话 tool-call meta(`meta_writer.rs:281`),淘汰后 UI 仍有预览。真缺口是"全文不落库",不是"什么都不落" +5. **前端两个上帝对象** — `acp-agent-settings.tsx` 11818 行(单个设置页)、`acp-connections-context.tsx` 5606 行(~99 个 switch/case 的事件分发层) + +### 性能优化机会(2026-08-16 深度扫描,按收益排序) + +1. **侧边栏全量遍历** — `get_sidebar_data`/`list_folders`/`get_stats` 每次都走 `list_conversations_sync`(`commands/conversations.rs:342`),13 个 parser 全目录遍历,但文件夹树只需要 `folder_path`。前端已自己绕开(`skills-settings.tsx:713` 注释承认慢)。改 DB 文件夹索引 = 用户可感知的最大提升 +2. **活跃会话整文件重解析** — 摘要缓存键 `(mtime,size)`(`summary_cache.rs:64`),流式期间每次列表刷新都对整个 JSONL 逐行反序列化(`claude.rs:846`)。`transcript_watermark` 已跟踪字节位置但从未用于增量读(`claude.rs:1866` 注释自认;codex 直接返回 `None`)。基础设施都在,只差接线 +3. **patch 行号解析无缓存** — `resolve_patch_line_numbers` 每个 patch 块全量读目标文件(`parsers/mod.rs:1002`),7 个 parser 共用;同一大文件 N 个 patch = N 次全量读。一次 HashMap memo 即可 +4. **会话文件定位 O(全树)** — Codex 按 id 每次 WalkDir(`codex.rs:336`),Claude 全目录 read_dir(`claude.rs:1060`);id→path 索引变 O(1) +5. **干净的部分(不用动)** — DB 索引齐全、无 N+1、WAL 配置正确;前端虚拟化(virtua)+ RAF 批处理是真做了的;启动无阻塞扫描,感知慢在首次侧边栏(冷缓存 + 上述第 1 条) + +--- + +## 三、产品演进路线(v1 → v2 → v3) + +### v1(现状):一次性委托 + +- 子 agent 跑完第一轮 `TurnComplete` → broker `disconnect` → 杀掉 +- 父 LLM 拿到结果文本,委托结束 +- 暴露 6 个工具:`delegate_to_agent` / `get_delegation_status` / `cancel_delegation` / `check_user_feedback` / `ask_user_question` / `get_session_info` + +### v2:多轮子会话(Continue Session)— 短期 1-2 月 + +**目标**:子会话长期存活,支持多轮交互 + +**工具变化**:新增 `continue_with_session` / `close_session` + +**为什么不难**: +- ACP 协议本身支持多轮(Codeg 已实现 `session/load` / `session/resume`,主会话在用) +- 只需删除 `finalize_delegation` 末尾的 `disconnect`,改成等显式 close +- 取消级联/深度限制/inflight 注册早已为长期会话设计,现在大材小用 + +**真正难点**:状态管理(TTL、并发上限、父结束时的级联关闭、中途失败语义) + +**价值**:从"工具调用"升级为"Agent 团队",产品形态质变 + +### v3:远程 Agent(Remote Spawner)— 中期 3-6 月 + +**目标**:agent 跑在远程,broker 不变 + +**架构**:新增 `RemoteSpawner` 实现 `ConnectionSpawner` trait,broker 零改动 + +**难点不在 Codeg**(写 RemoteSpawner 不难),在基础设施: +- 远程执行环境(sandbox、API key 管理、资源隔离) +- 网络可靠性(远程超时处理) +- 安全(权限模型、审计) +- 认证(多租户隔离) +- 计费 + +**价值**:从"本地工具"升级为"云平台",商业化的钥匙 + +**需求验证(2026-08-16)**:上游 issue #461「以持久 Session 为成员的 Team / Chatroom」——社区独立提出了与功能 B/v2 同构的构想。这是 v2 值得做的最直接外部证据。 + +### v2 必须先于 v3 的理由 + +1. 远程 agent 冷启动成本高,只换一个 turn 的结果经济不成立 → 需要 v2 多轮 +2. v2 纯本地,风险低,能快速验证产品形态 + +--- + +## 四、商业化分析 + +### 市场定位的真实判断 + +**Codeg 的差异化不是"多智能体",而是"多智能体的跨进程编排"。** + +- OpenCode / Pi / DriFox 做的是"单进程多模型/多 prompt"(第一层) +- Codeg 做的是"跨进程真委托"(第二层) +- **不在一个维度上竞争** + +### 四条变现路径(垂直行业已划掉) + +| 路径 | 可行性 | 说明 | +|------|--------|------| +| 开发者工具变现(企业版/团队) | 🟢 最现实 | 开发者为效率付费,Codeg 主场 | +| 托管云 | 🟡 有需求 | 但要扛运维成本 | +| API 计费层 | 🟡 灰色 | 中国市场对接海外模型有法律风险 | +| ~~垂直行业~~ | ❌ 划掉 | 底座是编码 agent,做不了行业方案 | + +### 为什么垂直行业划掉 + +- 垂直行业(律所/金融/医疗)不会以 Claude Code/Codex 为底座 +- 这些 agent 是通用编码 agent,不具备行业专业性 +- 行业数据不能出境(合规问题) +- 没有行业知识库 + +### 最诚实的商业化判断 + +**最大风险:在"多 agent 协作"需求真正普及前,上游平台方可能下场。** +- 窗口期 12-18 个月 +- 最该做的:抢心智占有率("多智能体协作 = Codeg") + +**最该先验证的问题(未回答)**: +1. 现在有没有人重度依赖 Codeg 的 delegation? +2. 你和同事自己日常用 delegation 吗? + +> 这两个问题不回答,所有路径都是空中楼阁。先找 10 个真实用户问三个问题: +> 你用它干过什么?没有它你会怎么做?愿意付多少钱? + +--- + +## 五、案例研究 + +### CoolVibe(coolvibe.io)— 参照系 + +| 维度 | 数据 | +|------|------| +| 本质 | Agent 的网页查看器(壳子) | +| 投入 | 4 个月,~3 万美元 | +| 定价 | 免费 / Pro $29.9/年 | +| 现状 | "订阅免费送"(还在买用户阶段) | +| V2EX 热度 | 1054 条回复 | +| 公开营收 | 查不到 | +| 团队 | xterminal 团队(有成功经验) | + +**启示**: +1. "壳子"能赚钱,但前提是痛点明确且高频 +2. 定价对齐"省下的时间",不是"技术多复杂" +3. 免费送订阅是冷启动的合理策略 +4. CoolVibe 比深得多的 Codeg 先验证了"给 agent 做壳"能跑通 + +### DriFox(github.com/martin98-afk/DriFox)— 反面教材 + 经验库 + +| 维度 | 数据 | +|------|------| +| 本质 | PyQt 桌面对话助手(Cursor 平替) | +| 规模 | 15 万行 Python | +| Stars | **24**(2 个月) | +| 定位错误 | 堆 33 插件做"Cursor 平替",死路 | + +**DriFox 走的错路(Codeg 绝不能走)**: +- 自己做 agent 和 Cursor 正面打(蚂蚁 vs 太阳) +- 堆功能数量拼不过飞轮(数据/生态/资本) +- "平替"定位是诅咒(永远活在正品阴影里) + +**Cursor 的体量(参照)**: +- ARR $40 亿(2026.06 年化) +- 估值 $290-500 亿 +- 日活 100 万+ +- 融资 $23 亿 D 轮 + +### ACP 协议现状 + +- 官方 35+ 兼容 agent +- 已被 Zed / JetBrains / Google / GitHub 采纳 +- 事实标准(类似 LSP 之于语言服务器) +- **zcode 不在列表**(截至 2026-07) + +--- + +## 六、可执行的功能规划 + +### 🎯 功能 A:编程 PK(周末项目,优先级最高) + +**是什么**:同一个任务同时分发给多个 agent,看谁做得好 + +**为什么做**: +1. 成本极低(底层全有,只缺对比 UI)——周末能做完 +2. 硬核技术的最佳展示窗口(把不可见的委托变成可视化) +3. 赛道真空(DriFox 24 star 不构成威胁,异构 PK 无人做) +4. 自带传播(天然话题性 + 可视化 + 争议性) +5. 低成本的市场探测器(PK 没人理 → 大方向要重新考虑) + +**为什么 Codeg 能做、别人不能**: +- Cursor:单进程,没法真异构并行 +- DriFox:单进程,SubAgent 是换 prompt +- OpenCode/Pi:同上 +- **只有 Codeg 有跨进程 ACP 委托** + +**技术可行性(已确认)**: +- `delegate_to_agent` 已支持 fan-out ✅ +- `get_delegation_status` 已支持批量等 ✅ +- `DelegationSuccess` 已带 `duration_ms`/`token_usage`/`turn_count` ✅ +- `DelegationContext` 已流到前端 ✅ +- **只缺一层横向对比 UI** + +**实施档位**: + +| 档位 | 内容 | 工作量 | +|------|------|--------| +| A 最小可玩 | PK 触发器 + 分屏视图 + 计分板 | 周六一天 | +| B 好看+分享 | diff 对比 + 实时终端 + 分享截图 + 任务模板 | +周日上午 | +| C 评判 | 自动测试裁判 + LLM 裁判 | 周日下午(选做) | + +**关键代码位置**: +- 触发器:`src/components/composer/`(加 PK 模式开关) +- 对比组件:新建 `src/components/chat/agent-pk-arena.tsx` +- 数据复用:`src/hooks/use-delegation-card-model.ts`(已解出 agent/task/status/childId) +- 渲染复用:`src/components/message/sub-agent-session-dialog.tsx`(已会渲染单个子 agent) +- 分享截图:`html-to-image`(package.json 已有依赖) + +**传播策略**: +- V2EX 发帖,标题方向:"周末做了个 AI 编程 PK 场:让 Claude Code、Codex、Gemini 同时写贪吃蛇,看谁快" +- 每场 PK 都是一次传播机会 + +**需求验证(2026-08-16)**:上游 issue #428「缺乏派发 subagent 状态的监视」——用户独立要 delegation 可视化监控,与 PK 共用数据层(`use-delegation-card-model.ts`),做完 PK 顺手就有,算免费赠品。 + +### 🎯 功能 B:角色化 Agent 团队(PK 之后的第二张牌) + +**是什么**:预设 leader/build/review 等角色,一键发起"Claude 当 leader 分任务给 Codex(build)和 Gemini(review)" + +**为什么做**: +- 比 PK 更接近"真实有用的工作"(协作 > 竞技) +- 和 PK 共用同一套 delegation 底层,只换 prompt 和 UI +- 让 Codeg 从"对比工具"延伸为"团队编排平台" + +**来源**:DriFox 的 `plugins/system/agents/*.md`(角色 prompt 设计精良,可借鉴) + +**与 v2 的关系**:角色化团队天然需要多轮子会话(v2),但可以先做 one-shot 版本 + +--- + +## 七、从 DriFox 可借鉴的具体设计 + +### 值得偷(按价值排序) + +#### 1. 角色化 agent prompt 模板 +- 来源:`plugins/system/agents/{build,leader,review,explore,plan}.md` +- 用途:Codeg 的角色化团队功能 +- 关键原则(直接抄): + - "不做超出需求的功能。一次性代码不做抽象。" + - "不要顺手改进相邻代码。不要重构没坏的东西。" + - "每一行变更都应该能直接追溯到用户的请求。" + - "使用 question 工具提问,优先提供选项" + +#### 2. 工具安全分类(三分法) +- 来源:`app/tools/command_safety.py` + `app/tools/tool_classifier.py` +- 用途:Codeg 加一层自己的安全护栏(企业版刚需) +- 规则: + - 危险(write/edit/bash/mouse)→ 审批 + - 安全(read/grep/glob/webfetch)→ 放行 + - 命令三分法:无元字符直接跑 / 有管道重定向需确认 / 黑名单拒绝 + +#### 3. "关键文档"记忆机制 +- 来源:`app/core/memory_manager.py` 的 `KeyDocumentsRepository` +- 用途:解决子 agent 冷启动不懂项目上下文的痛点 +- 做法:用户标记项目重要文件(README/架构文档/API 约定),委托时自动注入 task prompt + +#### 4. 任务邮件分发(轻量跨窗口协作) +- 来源:`app/core/team_manager.py`(文件邮箱) +- 用途:v3 远程 agent 的备选轻量方案 +- 优先级:低(记住有这个方案) + +#### 5. AGENTS.md 作为项目笔记 +- DriFox 印证了这是事实标准,Codeg 已在用,继续走 + +### 明确不要偷 + +| 不偷 | 理由 | +|------|------| +| PyQt 桌面浮动窗 | 架构不同,非核心价值 | +| 33 个插件 | 功能堆砌是它 24 star 的原因 | +| 进程内 SubAgent DAG | Codeg 跨进程委托更强 | +| ECharts 力导向图 | PK 用并排对比更直观 | +| LSP 集成(11 种语言) | Cursor 的战场,别送 | + +--- + +## 八、关于接入 zcode 的结论 + +**结论:暂不接入。** + +理由: +- zcode 不开源,改不了,只能向 Z.ai 提需求 +- zcode 不支持 ACP(不在官方 35+ 兼容列表) +- 不支持 ACP → 只能做 L1(会话导入 parser),不能做 L2(完整调度) +- L1 单独价值低(装饰性,用户在 zcode 自己的 app 里已能看会话) +- zcode 加 ACP 是 Z.ai 的战略决策,Codeg 这边无法推动 + +**zcode 数据格式(已摸清,备用)**: +- `~/.zcode/cli/db/db.sqlite` — 主库(session/message/part/todo 表) +- `~/.zcode/v2/tasks-index.sqlite` — 任务索引 +- `~/.zcode/cli/rollout/*.jsonl` — 原始模型 IO 流 +- `~/.zcode/cli/agents/sess_*/agent_*/` — 子 agent 会话 + +如果未来 zcode 支持 ACP,接入成本分两档(2026-08-16 修正,依据:上游 DSH 集成案例): + +**L1(会话导入,半天,4 处)**: +1. `models/agent.rs` — `AgentType` 枚举 +2. `acp/registry.rs` — `AcpAgentMeta` +3. `parsers/mod.rs` — 注册 +4. `parsers/zcode.rs` — 会话导入(可选) + +**L2(完整内置 agent,六层 ~25 处,量级 3-5 天)**: +完整参考:上游提交 `3845e9df`「feat(deepseek): integrate DeepSeek Harness as a built-in agent」,+3271/−50 行,改动约 25 个文件。六层清单: + +| 层 | 改动点 | 示例(DSH 提交) | +|----|--------|----------------| +| 1. 身份层 | `AgentType` 枚举 + wire 名 + 防自定义 shadow | `models/agent.rs`(+12) | +| 2. 启动层 | 注册表元数据:npm 包/版本/node 要求/认证方式 | `acp/registry.rs`(+44) | +| 3. 历史层 | 原生会话解析器(会话进列表/统计/导入,工作量最大) | `parsers/deepseek.rs`(+1291) | +| 4. 运行时层 | 沙箱根目录 / env 键 / skills 存储 / 只读路径 | `file_system_runtime.rs`、`commands/acp.rs`(+237) | +| 5. 协议层 | ACP capabilities / MCP 接线 / 传输限制 | `connection.rs`、`commands/mcp.rs`(+398) | +| 6. 防御层 | 自定义注册表冲突校验 + 文档 | `custom_registry.rs`(+52) | + +> **教训**:曾以为"加一个 agent = 改 4 处",实际"内置 agent"是六层全通——只做 1/2 层的 PR 会被上游以"不够一等公民"拒绝(2026-08-16 DSH PR 实测)。以后评估任何"接新 agent"的工作量,按此清单逐层核对。 + +--- + +## 九、关键待办与未决问题 + +### 必须先回答的问题(阻塞所有商业化决策) + +- [ ] **现在有没有人重度依赖 Codeg 的 delegation?**(找 10 个真实用户) +- [ ] **你和同事自己日常用 delegation 吗?**(如果造的人都不用,别指望别人用) + +### 功能待办(按优先级) + +- [ ] **编程 PK**(周末项目) — 最低成本验证 + 传播 +- [ ] **角色化 Agent 团队**(PK 之后) — 第二个场景 +- [ ] **v2 多轮子会话** — 产品形态质变,1-2 月 +- [ ] **v3 远程 agent** — 商业化钥匙,3-6 月 + +### 工程待办(2026-08-16 新增,按收益排序) + +- [ ] **侧边栏 DB 文件夹索引** — 干掉 13-parser 全量遍历(性能机会 1) +- [ ] **增量会话解析** — 接通已有但闲置的 `transcript_watermark`(性能机会 2) +- [ ] **broker.rs 测试外移** — ~4750 行拆到 `broker_tests.rs`,零风险减半 +- [ ] **patch 行号解析加 memo** — 一次 HashMap 的事(性能机会 3) + +### 上游影响力机会(刷存在感,非主线) + +- [ ] **#391/#387 流式重复文本** — 维护者自己未定位根因(在等用户补 seq 数据);修了就是硬通货 +- [ ] **#396 preferred-config 过滤** — 0xlinn 已给出精确根因:通用路径不按 advertised options 过滤,867 次/23天的重复报错;修法是照抄 grok 路径已有守卫(`connection.rs:1947`)+ `mode` 白名单 +- [x] **#408 接入 qoder cli**(2026-08-17 已完成,待提 PR)— 六层全通:身份/注册表(`qoder-cli`,npx `@qoder-ai/qodercli@1.1.23`,`--acp`)/解析器(Claude 信封格式,`~/.qoder/projects//.jsonl`,state.json 标题加密故走 transcript 明文)/沙箱(`QODER_CONFIG_DIR` RootSlot,实测 `~/.qoder` 可写)/协议(原生 ACP,loadSession+list/resume/fork 全有,MCP 走 settings.json 合并写入+转发跳过)/防御(`qoder-cli` 冲突校验)。规避了既往坑:#396 serde 重命名(测试钉死)、过期 env 误报(important keys 置空)、#468 标题问题(明文派生)。活体验证:server 模式 spawn→握手→prompt→turn_complete→transcript 解析全通。qoder 会话历史与 DSH 等宽(比 DSH 便宜在原生 ACP,贵在 MCP 转发语义相反:qoder 读自己的 settings.json,进跳过名单) +- [x] **deepseek-acp#2 空 callId 工具调用缺陷**(2026-08-17 已提,跟踪回复中)— 双杀伤实证:live 工具调用全部空 callId/name 被拒(17 个样本,arguments 完整唯头部丢失→解析层问题)+ 持久化后 session/load 校验拒绝整会话("写入不校验、读取强校验"自伤)。附三层修法建议+解包填合成 callId 的手工 workaround(本机验证可行)。注意:deepseek-acp 仓库主人就是 codeg 维护者本人。codeg 侧可做的兜底(未做,等需要时):session/load 失败识别该特定校验错误→自动修复会话文件(备份+填合成 id+重试),救 resume 那一半;live 那一半只能等上游 + - **2026-08-17 晚结案**:根因找到并修复——`dsh-llm-deepseek` 适配器用 `!== void 0` 守卫 callId/name 捕获,而 flash 经 opencode zen 代理的后续分片用显式 JSON null 重复字段,null 守卫穿透→每片覆盖首片捕获→空名派发。pro 不受影响(其分片是省略字段)。修复=桥接层 fetch 边界改写(把 null 头摘成省略,D4 合规不碰上游),fork 分支 `asteroida123:fix/flash-empty-tool-name`,PR 已提:**xintaofei/deepseek-acp#3**(rc.7 对齐 + fetch 壳 + TC-GUARD-05 四条用例含反向对照,328 测试绿,flash 实测恢复);治本应进 `@deepseek-ai/dsh-llm-deepseek`(守卫改 `!= null`)。用户本机全局 deepseek-acp 已换 PR 版。经验沉淀:①curl 抓原始 SSE 是定位流解析 bug 的终极手段 ②`!== void 0` vs `!= null` 在 JSON null 渗透的现实里必须选后者 ③官方 dsh 包(rc.7)与桥接层(xintaofei)是两个发布方,升级节奏解耦 ④**提 PR 前必读 CONTRIBUTING.md**——本仓 D4 铁律禁止 patch node_modules,第一版方案(补丁版)被文档否决,重做为 fetch 边界改写才合规 + +### 不做清单(防止分心) + +- [x] ~~接入 zcode~~(暂缓,等 ACP) +- [x] ~~垂直行业方案~~(底座不匹配) +- [x] ~~做 Cursor 平替~~(蚂蚁 vs 太阳) +- [x] ~~堆功能数量~~(学 DriFox 的教训) +- [x] ~~脱离开发者市场~~(丢弃所有资产) + +--- + +## 十、一句话总结 + +> **Codeg 的硬核技术(跨进程异构委托)是真的,但"硬核"本身不产生收入,产生收入的是"有一群人离不开它"。** +> +> **当下的优先级:先用最低成本(PK)验证"多 agent 并行"对开发者有没有吸引力 → 如果有,做 v2/v3 深化 → 如果没有,重新评估方向。** +> +> **别再纠结"能不能打过 Cursor"——那不是 Codeg 该问的问题。该问的是:Cursor 用户有什么事想做但 Cursor 架构上做不到?** diff --git a/docs/images/pk-arena-light.png b/docs/images/pk-arena-light.png new file mode 100644 index 000000000..5fc57dae2 Binary files /dev/null and b/docs/images/pk-arena-light.png differ diff --git a/docs/images/pk-launcher-light.png b/docs/images/pk-launcher-light.png new file mode 100644 index 000000000..57294bec1 Binary files /dev/null and b/docs/images/pk-launcher-light.png differ diff --git a/docs/images/pk-report-light.png b/docs/images/pk-report-light.png new file mode 100644 index 000000000..a3013273f Binary files /dev/null and b/docs/images/pk-report-light.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index e5f5a1c31..f995f8134 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,6 +45,7 @@ const eslintConfig = defineConfig([ // `.gitignore` — but flat config has no such default, so without this // `pnpm eslint .` fails the repo on files that are not in the repo. ".docs/**", + ".codeg-pk/**", ]), eslintConfigPrettier, eslintPluginPrettierRecommended, diff --git a/package.json b/package.json index 1d3a0a185..82a6b3e94 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/jsdom": "^30.0.0", "@types/node": "25.2.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acd129f52..f7b3755d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.3(@testing-library/dom@10.4.1) + '@types/jsdom': + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: 25.2.2 version: 25.2.2 @@ -2948,6 +2951,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsdom@30.0.0': + resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2980,6 +2986,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3982,6 +3991,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4156,6 +4169,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -5623,6 +5637,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6499,6 +6516,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.10.0: + resolution: {integrity: sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -9416,6 +9436,13 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsdom@30.0.0': + dependencies: + '@types/node': 25.2.2 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 8.10.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -9445,6 +9472,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -10464,6 +10493,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -12606,6 +12637,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -13762,6 +13797,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@8.10.0: {} + unicorn-magic@0.3.0: {} unified@11.0.5: diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 9f116b0d8..c999fd07b 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -6402,11 +6402,17 @@ async fn apply_preferred_session_options( initial_config_options: Vec, ) -> Vec { if let Some(pref_mode) = preferred_mode_id { - let needs_apply = session + let is_advertised = session .modes() .as_ref() - .map(|m| m.current_mode_id.to_string() != pref_mode) + .map(|m| m.available_modes.iter().any(|mode| mode.id.to_string() == pref_mode)) .unwrap_or(false); + let needs_apply = is_advertised + && session + .modes() + .as_ref() + .map(|m| m.current_mode_id.to_string() != pref_mode) + .unwrap_or(false); if needs_apply { if let Err(e) = set_session_mode(session, state, emitter, pref_mode.to_string()).await { tracing::error!("[ACP] failed to apply preferred mode '{pref_mode}' on connect: {e}"); diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index ec3ce9065..c759dd32b 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -280,19 +280,28 @@ pub(crate) async fn handle_event( return Ok(()); }; if let Some(ts) = target_status.clone() { - // DB write before emit so any downstream subscriber that observes - // the ConversationStatusChanged event can assume the row is - // already at the target status. - conversation_service::update_status(db_conn, cid, ts.clone()).await?; - emit_with_state( - &state_arc, - &emitter, - AcpEvent::ConversationStatusChanged { - conversation_id: cid, - status: ts, - }, + // A system-owned caller (the PK judge) may mark the row + // Completed as soon as its verdict is parsed. Only advance a + // still-live row so this asynchronous worker cannot overwrite + // that terminal state back to PendingReview. + let changed = conversation_service::update_status_if( + db_conn, + cid, + ConversationStatus::InProgress, + ts.clone(), ) - .await; + .await?; + if changed { + emit_with_state( + &state_arc, + &emitter, + AcpEvent::ConversationStatusChanged { + conversation_id: cid, + status: ts, + }, + ) + .await; + } } // If this conversation was spawned by a delegation, resolve the @@ -2089,6 +2098,48 @@ mod tests { ); } + #[tokio::test] + async fn handle_event_turn_complete_preserves_already_completed_conversation() { + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/turn-complete-race").await; + let conv = + conversation_service::create(&db.conn, folder_id, AgentType::Codex, None, None) + .await + .unwrap(); + conversation_service::update_status( + &db.conn, + conv.id, + ConversationStatus::Completed, + ) + .await + .unwrap(); + + let mgr = ConnectionManager::new(); + { + let mut map = mgr.connections.lock().await; + map.insert( + "judge".to_string(), + fake_connection_with_state("judge", Some(conv.id)), + ); + } + let env = EventEnvelope { + seq: 1, + connection_id: "judge".to_string(), + payload: AcpEvent::TurnComplete { + session_id: "ext-judge".into(), + stop_reason: "end_turn".into(), + agent_type: "codex".into(), + }, + }; + + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); + + assert_eq!( + read_row_status(&db, conv.id).await, + ConversationStatus::Completed + ); + } + #[tokio::test] async fn handle_event_writes_cancelled_on_turn_failure_stop_reasons() { // OpenCode (and similar agents) maps backend errors to `Refusal`. diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 550cbb118..c3a891508 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -1976,6 +1976,7 @@ impl ConnectionManager { deleted_at: Set(None), pinned_at: Set(None), origin_cwd: Set(None), + pk_round_id: Set(None), }; let inserted = sibling.insert(txn).await?; Ok(inserted.id) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 90641c2ac..dec88784d 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1863,6 +1863,49 @@ pub async fn create_conversation( Ok(id) } +/// Create a PK arena contestant conversation: `kind = Pk` + `pk_round_id` set, +/// so the sidebar routes it to the per-round PK section instead of the folder +/// list. The git branch is detected from the folder path just like a regular +/// conversation (contestants run inside a worktree under that folder). +pub async fn create_pk_conversation_core( + conn: &sea_orm::DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + pk_round_id: i32, +) -> Result { + let git_branch = if let Some(folder) = folder_service::get_folder_by_id(conn, folder_id) + .await + .map_err(AppCommandError::from)? + { + detect_git_branch(&folder.path).await + } else { + None + }; + + let model = + conversation_service::create_pk(conn, folder_id, agent_type, title, git_branch, pk_round_id) + .await + .map_err(AppCommandError::from)?; + Ok(model.id) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_pk_conversation( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + folder_id: i32, + agent_type: AgentType, + title: Option, + pk_round_id: i32, +) -> Result { + let id = + create_pk_conversation_core(&db.conn, folder_id, agent_type, title, pk_round_id).await?; + emit_conversation_upsert(&EventEmitter::Tauri(app), &db.conn, id).await; + Ok(id) +} + /// Result of [`create_chat_conversation_core`]: the new conversation id plus the /// hidden chat folder backing it, so the frontend can drop the folder straight /// into `allFolders` (resolving cwd / active-folder) without a refetch. @@ -2441,6 +2484,7 @@ mod tests { parent_tool_use_id: Some(parent_tool_use_id.into()), delegation_call_id: Some("call-1".into()), origin_cwd: None, + pk_round_id: None, } } @@ -5448,6 +5492,7 @@ mod tests { parent_tool_use_id: None, delegation_call_id: None, origin_cwd: None, + pk_round_id: None, }, turns, session_stats: None, diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 942796834..eaa030207 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -2021,6 +2021,42 @@ pub async fn git_worktree_add( ); } + // An unborn HEAD (fresh `git init`, no commits yet) makes + // `worktree add -b ` fail with "invalid reference: HEAD" — there + // is no commit for the new branch to point at. Recover by seeding an + // empty initial commit, which every subsequent worktree bases itself on. + // Only reachable in a state where the command would have errored anyway, + // so existing callers see strictly fewer failures. + let head_check = crate::process::tokio_command("git") + .args(["rev-parse", "--verify", "HEAD"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + if !head_check.status.success() { + // Inline identity overrides: a machine with no global user.name/ + // user.email would otherwise fail the seed commit. + let seed = crate::process::tokio_command("git") + .args([ + "-c", + "user.name=codeg", + "-c", + "user.email=codeg@local", + "commit", + "-q", + "--allow-empty", + "-m", + "codeg: initial commit (repository had no commits)", + ]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + if !seed.status.success() { + return Err(git_command_error("commit (seed empty initial)", &seed.stderr)); + } + } + // 执行 git worktree add -b [] // 显式 base(提交/引用)消除「读分支 → 建 worktree」间用户切分支的漂移窗口; // 省略时沿用 HEAD(既有调用方行为不变)。 @@ -6427,6 +6463,36 @@ mod tests { ); } + /// A fresh `git init` leaves HEAD unborn, and `worktree add -b ` + /// fails against it ("invalid reference: HEAD") because the new branch + /// has no commit to point at. `git_worktree_add` must recover by seeding + /// an empty initial commit — with inline identity, so a machine without + /// global user.name/user.email still works. + #[tokio::test] + async fn git_worktree_add_seeds_empty_initial_commit_on_unborn_head() { + let dir = tempfile::tempdir().expect("tempdir"); + git_run(dir.path(), &["init", "-q"]); + + let wt = dir.path().join("wt"); + git_worktree_add( + dir.path().to_str().unwrap().to_string(), + "codeg-pk/r1/agent".to_string(), + wt.to_str().unwrap().to_string(), + None, + ) + .await + .expect("worktree add must recover from an unborn HEAD"); + + assert!(wt.join(".git").exists(), "worktree must exist"); + // The seeded commit landed on the default branch, so HEAD resolves. + let head = std::process::Command::new("git") + .args(["rev-parse", "--verify", "HEAD"]) + .current_dir(dir.path()) + .output() + .expect("spawn git"); + assert!(head.status.success(), "HEAD must resolve after seeding"); + } + #[tokio::test] async fn resolve_git_head_handles_non_repo() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dcf009cc3..916b66ff9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod office_tools; #[cfg(feature = "tauri-runtime")] pub mod notification; pub mod pet; +pub mod pk; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/commands/pk.rs b/src-tauri/src/commands/pk.rs new file mode 100644 index 000000000..47503a659 --- /dev/null +++ b/src-tauri/src/commands/pk.rs @@ -0,0 +1,391 @@ +//! PK arena round CRUD commands. The `*_core` fns are mode-agnostic and +//! shared by the Tauri wrappers and the Axum handlers. + +use std::path::{Path, PathBuf}; + +use crate::app_error::AppCommandError; +use crate::db::entities::pk_round::PkRoundStatus; +use crate::db::error::DbError; +use crate::db::service::pk_round_service; +use crate::db::AppDatabase; +use crate::models::{PkRoundConfig, PkRoundInfo}; + +const PK_REPORT_SNAPSHOT_DIR: &str = "pk-report-snapshots"; +const PK_REPORT_SNAPSHOT_MAX_BYTES: usize = 48 * 1024 * 1024; + +fn report_snapshot_path(data_dir: &Path, id: i32) -> PathBuf { + data_dir + .join(PK_REPORT_SNAPSHOT_DIR) + .join(format!("{id}.json")) +} + +// -- shared business logic (both modes) -- + +pub async fn pk_round_list_core( + db: &AppDatabase, + folder_id: Option, +) -> Result, DbError> { + pk_round_service::list(&db.conn, folder_id).await +} + +pub async fn pk_round_get_core(db: &AppDatabase, id: i32) -> Result { + pk_round_service::get_info(&db.conn, id).await +} + +pub async fn pk_round_create_core( + db: &AppDatabase, + folder_id: i32, + task: String, + config: PkRoundConfig, +) -> Result { + let row = pk_round_service::create(&db.conn, folder_id, task, config).await?; + pk_round_service::get_info(&db.conn, row.id).await +} + +pub async fn pk_round_update_status_core( + db: &AppDatabase, + id: i32, + status: String, +) -> Result<(), DbError> { + let parsed = match status.as_str() { + "ready" => PkRoundStatus::Ready, + "running" => PkRoundStatus::Running, + "finished" => PkRoundStatus::Finished, + "canceled" => PkRoundStatus::Canceled, + "interrupted" => PkRoundStatus::Interrupted, + other => { + return Err(DbError::Validation(format!( + "unknown pk_round status: {other}" + ))); + } + }; + pk_round_service::update_status(&db.conn, id, parsed).await +} + +pub async fn pk_round_delete_core(db: &AppDatabase, id: i32) -> Result<(), DbError> { + pk_round_service::soft_delete(&db.conn, id).await +} + +pub async fn pk_round_update_judge_core( + db: &AppDatabase, + id: i32, + judge_result: Option, + judge_status: String, +) -> Result<(), DbError> { + pk_round_service::update_judge(&db.conn, id, judge_result, judge_status).await +} + +/// Persist the self-contained artifacts and runtime metrics needed to export a +/// round after its disposable git worktrees have been removed. The payload is +/// versioned JSON owned by the frontend; the backend deliberately treats it as +/// opaque data and only enforces a bounded size and a stable per-round path. +pub async fn pk_round_save_report_snapshot_core( + data_dir: &Path, + id: i32, + snapshot: String, +) -> Result<(), AppCommandError> { + if snapshot.len() > PK_REPORT_SNAPSHOT_MAX_BYTES { + return Err(AppCommandError::invalid_input(format!( + "PK report snapshot exceeds {} MiB", + PK_REPORT_SNAPSHOT_MAX_BYTES / 1024 / 1024 + ))); + } + let path = report_snapshot_path(data_dir, id); + let parent = path + .parent() + .ok_or_else(|| AppCommandError::invalid_input("invalid PK report snapshot path"))?; + tokio::fs::create_dir_all(parent) + .await + .map_err(AppCommandError::io)?; + tokio::fs::write(path, snapshot) + .await + .map_err(AppCommandError::io) +} + +pub async fn pk_round_get_report_snapshot_core( + data_dir: &Path, + id: i32, +) -> Result, AppCommandError> { + let path = report_snapshot_path(data_dir, id); + match tokio::fs::read_to_string(path).await { + Ok(snapshot) => Ok(Some(snapshot)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(AppCommandError::io(error)), + } +} + +pub async fn pk_round_delete_report_snapshot_core( + data_dir: &Path, + id: i32, +) -> Result<(), AppCommandError> { + match tokio::fs::remove_file(report_snapshot_path(data_dir, id)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppCommandError::io(error)), + } +} + +/// Archive is a database operation; deleting its derived report cache is +/// best-effort. A cache cleanup failure must not turn an already committed +/// archive into a user-visible failure. +pub async fn pk_round_archive_core( + db: &AppDatabase, + data_dir: &Path, + id: i32, +) -> Result<(), DbError> { + pk_round_delete_core(db, id).await?; + if let Err(error) = pk_round_delete_report_snapshot_core(data_dir, id).await { + tracing::warn!( + "[PK] round {id} archived, but its report snapshot could not be removed: {error}" + ); + } + Ok(()) +} + +// -- Tauri command wrappers (desktop mode only) -- + +#[cfg(feature = "tauri-runtime")] +fn resolve_desktop_data_dir(app: &tauri::AppHandle) -> Result { + use tauri::Manager; + + let fallback = app + .path() + .app_data_dir() + .map_err(|error| { + AppCommandError::io_error("Resolve app data dir").with_detail(error.to_string()) + })?; + Ok(crate::paths::resolve_effective_data_dir(&fallback)) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_list( + db: tauri::State<'_, AppDatabase>, + folder_id: Option, +) -> Result, DbError> { + pk_round_list_core(&db, folder_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_get( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result { + pk_round_get_core(&db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_create( + db: tauri::State<'_, AppDatabase>, + folder_id: i32, + task: String, + config: PkRoundConfig, +) -> Result { + pk_round_create_core(&db, folder_id, task, config).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_update_status( + db: tauri::State<'_, AppDatabase>, + id: i32, + status: String, +) -> Result<(), DbError> { + pk_round_update_status_core(&db, id, status).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_delete( + db: tauri::State<'_, AppDatabase>, + app: tauri::AppHandle, + id: i32, +) -> Result<(), AppCommandError> { + // Resolve fallible runtime state before committing the archive. + let data_dir = resolve_desktop_data_dir(&app)?; + pk_round_archive_core(&db, &data_dir, id) + .await + .map_err(AppCommandError::from)?; + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_update_judge( + db: tauri::State<'_, AppDatabase>, + id: i32, + judge_result: Option, + judge_status: String, +) -> Result<(), DbError> { + pk_round_update_judge_core(&db, id, judge_result, judge_status).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_save_report_snapshot( + app: tauri::AppHandle, + id: i32, + snapshot: String, +) -> Result<(), AppCommandError> { + let data_dir = resolve_desktop_data_dir(&app)?; + pk_round_save_report_snapshot_core(&data_dir, id, snapshot).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn pk_round_get_report_snapshot( + app: tauri::AppHandle, + id: i32, +) -> Result, AppCommandError> { + let data_dir = resolve_desktop_data_dir(&app)?; + pk_round_get_report_snapshot_core(&data_dir, id).await +} + +#[cfg(test)] +mod tests { + use sea_orm::EntityTrait; + + use super::*; + use crate::db::entities::conversation; + use crate::db::service::conversation_service; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::AgentType; + + fn requires_unmanaged_app_state(source: &str) -> bool { + let forbidden = [ + "tauri::State<'_, crate::app_state::", + "AppState>", + ] + .concat(); + source.contains(&forbidden) + } + + #[test] + fn detects_the_unmanaged_state_signature_that_breaks_tauri_invocation() { + let broken = [ + "state: tauri::State<'_, crate::app_state::", + "AppState>,", + ] + .concat(); + assert!(requires_unmanaged_app_state(&broken)); + } + + #[test] + fn desktop_pk_commands_do_not_require_unmanaged_app_state() { + assert!( + !requires_unmanaged_app_state(include_str!("pk.rs")), + "desktop PK commands must use automatically injected AppHandle or a registered state" + ); + } + + #[tokio::test] + async fn archiving_round_also_hides_its_pk_conversations() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/pk-archive").await; + let round = pk_round_service::create( + &db.conn, + folder_id, + "test task".into(), + PkRoundConfig { + agents: Vec::new(), + permission_mode: "default".into(), + bare_mode: false, + effort: "default".into(), + judge_agent: None, + judge_dimensions: Vec::new(), + base_commit: None, + }, + ) + .await + .unwrap(); + let conversation = conversation_service::create_pk( + &db.conn, + folder_id, + AgentType::Qoder, + Some("PK contestant".into()), + None, + round.id, + ) + .await + .unwrap(); + + pk_round_delete_core(&db, round.id).await.unwrap(); + + assert!(pk_round_service::list(&db.conn, None) + .await + .unwrap() + .is_empty()); + let archived = conversation::Entity::find_by_id(conversation.id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert!(archived.deleted_at.is_some()); + } + + #[tokio::test] + async fn snapshot_cleanup_failure_does_not_turn_archive_into_failure() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/pk-archive-cache").await; + let round = pk_round_service::create( + &db.conn, + folder_id, + "test task".into(), + PkRoundConfig { + agents: Vec::new(), + permission_mode: "default".into(), + bare_mode: false, + effort: "default".into(), + judge_agent: None, + judge_dimensions: Vec::new(), + base_commit: None, + }, + ) + .await + .unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let snapshot_path = report_snapshot_path(data_dir.path(), round.id); + std::fs::create_dir_all(&snapshot_path).unwrap(); + + pk_round_archive_core(&db, data_dir.path(), round.id) + .await + .unwrap(); + + assert!(pk_round_service::list(&db.conn, None) + .await + .unwrap() + .is_empty()); + assert!(snapshot_path.is_dir()); + } + + #[tokio::test] + async fn saved_report_snapshot_survives_worktree_cleanup() { + let data_dir = tempfile::tempdir().unwrap(); + let worktree = tempfile::tempdir().unwrap(); + std::fs::write(worktree.path().join("index.html"), "

entry

").unwrap(); + let snapshot = r#"{"version":1,"artifactsBySlot":{"0":[{"path":"index.html","contentBase64":"PGgxPmVudHJ5PC9oMT4="}]}}"#; + + pk_round_save_report_snapshot_core(data_dir.path(), 7, snapshot.into()) + .await + .unwrap(); + worktree.close().unwrap(); + + assert_eq!( + pk_round_get_report_snapshot_core(data_dir.path(), 7) + .await + .unwrap() + .as_deref(), + Some(snapshot) + ); + + pk_round_delete_report_snapshot_core(data_dir.path(), 7) + .await + .unwrap(); + assert!(pk_round_get_report_snapshot_core(data_dir.path(), 7) + .await + .unwrap() + .is_none()); + } +} diff --git a/src-tauri/src/commands/token_usage.rs b/src-tauri/src/commands/token_usage.rs index c48009ce2..711d778f7 100644 --- a/src-tauri/src/commands/token_usage.rs +++ b/src-tauri/src/commands/token_usage.rs @@ -1337,6 +1337,7 @@ mod tests { parent_tool_use_id: None, delegation_call_id: None, origin_cwd: None, + pk_round_id: None, }, turns, session_stats: stats, diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 7ea73da10..bb9197ccc 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -594,6 +594,56 @@ pub async fn open_folder_window( Ok(folder) } +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn open_pk_round_window( + app: AppHandle, + round_id: String, + title: String, + remote_connection_id: Option, +) -> Result<(), AppCommandError> { + let parsed_round_id = round_id.parse::().map_err(|_| { + AppCommandError::invalid_input("Invalid PK round id") + .with_detail(format!("round_id={round_id}")) + })?; + let label = match remote_connection_id { + Some(remote_id) => format!("remote-pk-round-{remote_id}-{parsed_round_id}"), + None => format!("pk-round-{parsed_round_id}"), + }; + + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.unminimize(); + existing + .set_focus() + .map_err(|e| AppCommandError::window("Failed to focus PK window", e.to_string()))?; + return Ok(()); + } + + let (url_str, remote_window_id) = route_with_new_remote_window( + format!("workspace?pkRoundId={parsed_round_id}"), + remote_connection_id, + ); + let window_title = if title.trim().is_empty() { + "PK Arena".to_string() + } else { + format!("PK - {}", title.trim()) + }; + let builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url_str.into())) + .title(window_title) + .inner_size(1440.0, 900.0) + .min_inner_size(900.0, 600.0) + .center(); + let pk_window = apply_platform_window_style(builder) + .build() + .map_err(|e| AppCommandError::window("Failed to open PK window", e.to_string()))?; + register_remote_window_cleanup(&app, &pk_window, remote_window_id.as_deref()); + post_window_setup(&pk_window); + pk_window + .set_focus() + .map_err(|e| AppCommandError::window("Failed to focus PK window", e.to_string()))?; + Ok(()) +} + #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn open_commit_window( diff --git a/src-tauri/src/db/entities/conversation.rs b/src-tauri/src/db/entities/conversation.rs index ac4c88366..1d465ca6b 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -21,6 +21,8 @@ pub enum ConversationStatus { /// excluded from the sidebar list entirely (no write path yet — reserved for /// the loop engine); `delegate` is a delegation child nested under its /// parent's tool-call view. Invariant: `kind == Delegate` ⟺ `parent_id IS NOT +/// NULL`. `pk` is a PK-arena contestant session — grouped under its round in +/// the sidebar's PK section. Invariant: `kind == Pk` ⟺ `pk_round_id IS NOT /// NULL`. Written once at insert, never updated. #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] @@ -34,6 +36,8 @@ pub enum ConversationKind { Loop, #[sea_orm(string_value = "delegate")] Delegate, + #[sea_orm(string_value = "pk")] + Pk, } #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] @@ -58,6 +62,9 @@ pub struct Model { pub parent_id: Option, pub parent_tool_use_id: Option, pub delegation_call_id: Option, + /// The PK arena round this contestant session belongs to. NULL for every + /// other kind; always set when `kind == Pk`. Written once at insert. + pub pk_round_id: Option, pub message_count: i32, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 1506c19c3..eb1dc6825 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -13,6 +13,7 @@ pub mod folder_command; pub mod folder_link; pub mod model_provider; pub mod opened_tab; +pub mod pk_round; pub mod prelude; pub mod quick_message; pub mod remote_workspace_connection; diff --git a/src-tauri/src/db/entities/pk_round.rs b/src-tauri/src/db/entities/pk_round.rs new file mode 100644 index 000000000..ee04baff0 --- /dev/null +++ b/src-tauri/src/db/entities/pk_round.rs @@ -0,0 +1,61 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum PkRoundStatus { + #[sea_orm(string_value = "ready")] + Ready, + #[sea_orm(string_value = "running")] + Running, + #[sea_orm(string_value = "finished")] + Finished, + #[sea_orm(string_value = "canceled")] + Canceled, + #[sea_orm(string_value = "interrupted")] + Interrupted, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "pk_round")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub folder_id: i32, + #[sea_orm(column_type = "Text")] + pub task: String, + #[sea_orm(column_type = "Text")] + pub config: String, + pub status: PkRoundStatus, + pub failure_reason: Option, + /// JSON: serialized judge verdict (scores, summary, raw text). Null if + /// no judge was configured or the judge hasn't run yet. + #[sea_orm(column_type = "Text", nullable)] + pub judge_result: Option, + /// idle | running | done | error | skipped + #[sea_orm(column_type = "Text", nullable)] + pub judge_status: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub finished_at: Option, + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::folder::Entity", + from = "Column::FolderId", + to = "super::folder::Column::Id" + )] + Folder, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Folder.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index aed43e131..d99477d71 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -15,6 +15,7 @@ pub use super::folder_command::Entity as FolderCommand; pub use super::folder_link::Entity as FolderLink; pub use super::model_provider::Entity as ModelProvider; pub use super::opened_tab::Entity as OpenedTab; +pub use super::pk_round::Entity as PkRound; pub use super::quick_message::Entity as QuickMessage; pub use super::token_usage_sync::Entity as TokenUsageSync; pub use super::token_usage_turn::Entity as TokenUsageTurn; diff --git a/src-tauri/src/db/migration/m20260819_000001_pk_round.rs b/src-tauri/src/db/migration/m20260819_000001_pk_round.rs new file mode 100644 index 000000000..d23f33668 --- /dev/null +++ b/src-tauri/src/db/migration/m20260819_000001_pk_round.rs @@ -0,0 +1,205 @@ +use sea_orm_migration::prelude::*; + +/// PK arena rounds and the conversation↔round link. +/// +/// `pk_round` stores round-level metadata (task, agents, status, competition +/// options) that was previously in localStorage — moving it to the DB makes +/// rounds cross-device, server-mode compatible, and queryable. +/// +/// `conversation.pk_round_id` links each contestant session to its round, +/// matching the `kind == 'pk'` invariant. Indexed for the sidebar's +/// per-round grouping query. +#[derive(DeriveMigrationName)] +pub struct Migration; + +const IDX_PK_ROUND_FOLDER: &str = "idx_pk_round_folder"; +const IDX_CONVERSATION_PK_ROUND: &str = "idx_conversation_pk_round"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(PkRound::Table) + .if_not_exists() + .col( + ColumnDef::new(PkRound::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + // Soft reference to the project folder (never a worktree). + .col(ColumnDef::new(PkRound::FolderId).integer().not_null()) + .col(ColumnDef::new(PkRound::Task).text().not_null()) + // JSON: { agents, permission_mode, bare_mode, effort } + .col(ColumnDef::new(PkRound::Config).text().not_null()) + // ready | running | finished | canceled | interrupted + .col( + ColumnDef::new(PkRound::Status) + .string() + .not_null() + .default("ready"), + ) + .col(ColumnDef::new(PkRound::FailureReason).string().null()) + .col( + ColumnDef::new(PkRound::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(PkRound::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(PkRound::FinishedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(PkRound::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name(IDX_PK_ROUND_FOLDER) + .table(PkRound::Table) + .col(PkRound::FolderId) + .to_owned(), + ) + .await?; + + // Add pk_round_id column to conversation table. + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .add_column(ColumnDef::new(Conversation::PkRoundId).integer().null()) + .to_owned(), + ) + .await?; + + // SQLite cannot create an index for a column that has not been added + // yet, so keep this after the ALTER TABLE above. + manager + .create_index( + Index::create() + .name(IDX_CONVERSATION_PK_ROUND) + .table(Conversation::Table) + .col(Conversation::PkRoundId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .name(IDX_CONVERSATION_PK_ROUND) + .table(Conversation::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .drop_column(Conversation::PkRoundId) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .name(IDX_PK_ROUND_FOLDER) + .table(PkRound::Table) + .to_owned(), + ) + .await?; + + manager + .drop_table(Table::drop().table(PkRound::Table).if_exists().to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum PkRound { + Table, + Id, + FolderId, + Task, + Config, + Status, + FailureReason, + CreatedAt, + UpdatedAt, + FinishedAt, + DeletedAt, +} + +#[derive(DeriveIden)] +enum Conversation { + Table, + PkRoundId, +} + +#[cfg(test)] +mod tests { + use sea_orm::Database; + + use super::*; + + #[tokio::test] + async fn conversation_round_index_follows_column_lifecycle() { + let connection = Database::connect("sqlite::memory:").await.unwrap(); + let manager = SchemaManager::new(&connection); + manager + .create_table( + Table::create() + .table(Conversation::Table) + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .primary_key(), + ) + .to_owned(), + ) + .await + .unwrap(); + + Migration.up(&manager).await.unwrap(); + assert!(manager + .has_column("conversation", "pk_round_id") + .await + .unwrap()); + assert!(manager + .has_index("conversation", IDX_CONVERSATION_PK_ROUND) + .await + .unwrap()); + + Migration.down(&manager).await.unwrap(); + assert!(!manager + .has_column("conversation", "pk_round_id") + .await + .unwrap()); + assert!(!manager + .has_index("conversation", IDX_CONVERSATION_PK_ROUND) + .await + .unwrap()); + } +} diff --git a/src-tauri/src/db/migration/m20260819_000002_pk_round_judge.rs b/src-tauri/src/db/migration/m20260819_000002_pk_round_judge.rs new file mode 100644 index 000000000..3e73f6416 --- /dev/null +++ b/src-tauri/src/db/migration/m20260819_000002_pk_round_judge.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +/// Add `judge_result` and `judge_status` columns to `pk_round`. +/// +/// The judge agent's structured verdict (scores, summary, raw text) was +/// previously live-only in the Zustand store — lost on refresh or restart. +/// Persisting it to the DB makes judge results survive across sessions, +/// which is essential for the export-report and share-screenshot flows. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(PkRound::Table) + .add_column(ColumnDef::new(PkRound::JudgeResult).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(PkRound::Table) + .add_column( + ColumnDef::new(PkRound::JudgeStatus) + .string() + .not_null() + .default("idle"), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(PkRound::Table) + .drop_column(PkRound::JudgeStatus) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(PkRound::Table) + .drop_column(PkRound::JudgeResult) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum PkRound { + Table, + JudgeResult, + JudgeStatus, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 3d2b0dcbd..79cabefa4 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -40,6 +40,8 @@ mod m20260808_000001_custom_agent_supports_mcp; mod m20260817_000001_work_task_conversation_title; mod m20260818_000001_work_task_source; mod m20260819_000001_work_task_completion_kind; +mod m20260819_000001_pk_round; +mod m20260819_000002_pk_round_judge; pub struct Migrator; #[async_trait::async_trait] @@ -86,6 +88,8 @@ impl MigratorTrait for Migrator { Box::new(m20260817_000001_work_task_conversation_title::Migration), Box::new(m20260818_000001_work_task_source::Migration), Box::new(m20260819_000001_work_task_completion_kind::Migration), + Box::new(m20260819_000001_pk_round::Migration), + Box::new(m20260819_000002_pk_round_judge::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 9f109071c..2e72cd9d9 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -78,6 +78,46 @@ pub async fn create_with_delegation( .await } +/// Create a PK arena contestant session: `kind = Pk` + `pk_round_id` set, +/// so the sidebar routes the row to the per-round PK section. +pub async fn create_pk( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, + pk_round_id: i32, +) -> Result { + let at_str = serde_json::to_value(agent_type) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + let now = Utc::now(); + let model = conversation::ActiveModel { + id: NotSet, + folder_id: Set(folder_id), + title: Set(title), + title_locked: Set(true), + agent_type: Set(at_str), + status: Set(conversation::ConversationStatus::InProgress), + kind: Set(ConversationKind::Pk), + model: Set(None), + git_branch: Set(git_branch), + external_id: Set(None), + parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), + pk_round_id: Set(Some(pk_round_id)), + message_count: Set(0), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + pinned_at: Set(None), + origin_cwd: Set(None), + }; + Ok(model.insert(conn).await?) +} + async fn create_inner( conn: &DatabaseConnection, folder_id: i32, @@ -120,6 +160,7 @@ async fn create_inner( deleted_at: Set(None), pinned_at: Set(None), origin_cwd: Set(None), + pk_round_id: Set(None), }; Ok(model.insert(conn).await?) } @@ -854,6 +895,7 @@ struct CarriedOverRow { created_at: chrono::DateTime, updated_at: chrono::DateTime, origin_cwd: Option, + pk_round_id: Option, } impl CarriedOverRow { @@ -892,6 +934,7 @@ impl CarriedOverRow { // `origin_cwd ?? folder.path`, so dropping this would break // history lookup for a re-parented conversation. origin_cwd: row.origin_cwd.clone(), + pk_round_id: row.pk_round_id, } } @@ -921,6 +964,7 @@ impl CarriedOverRow { // not to the history. pinned_at: Set(None), origin_cwd: Set(self.origin_cwd), + pk_round_id: Set(self.pk_round_id), } } } @@ -1038,6 +1082,7 @@ fn conv_to_summary(r: conversation::Model) -> DbConversationSummary { parent_tool_use_id: r.parent_tool_use_id, delegation_call_id: r.delegation_call_id, origin_cwd: r.origin_cwd, + pk_round_id: r.pk_round_id, } } diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 96974036c..e7c87edea 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -454,6 +454,7 @@ async fn import_one( deleted_at: Set(None), pinned_at: Set(None), origin_cwd: Set(None), + pk_round_id: Set(None), }; conv.insert(conn).await?; Ok(ImportOutcome::Imported) @@ -1013,6 +1014,7 @@ mod tests { deleted_at: Set(None), pinned_at: Set(None), origin_cwd: Set(None), + pk_round_id: Set(None), } .insert(&db.conn) .await diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index edda1d933..83b621cda 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -10,6 +10,7 @@ pub mod folder_link_service; pub mod folder_service; pub mod import_service; pub mod model_provider_service; +pub mod pk_round_service; pub mod quick_message_service; pub mod remote_workspace_connection_service; pub mod sender_context_service; diff --git a/src-tauri/src/db/service/pk_round_service.rs b/src-tauri/src/db/service/pk_round_service.rs new file mode 100644 index 000000000..773cd8662 --- /dev/null +++ b/src-tauri/src/db/service/pk_round_service.rs @@ -0,0 +1,179 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + QueryFilter, QueryOrder, Set, TransactionTrait, +}; + +use crate::db::entities::{conversation, pk_round}; +use crate::db::error::DbError; +use crate::models::{PkRoundConfig, PkRoundInfo}; + +/// Map a DB row to a frontend-facing `PkRoundInfo`, deserialising the JSON +/// config blob. A corrupt blob falls back to empty defaults rather than +/// failing the whole list — one bad round should not blank the board. +fn to_info(m: pk_round::Model) -> PkRoundInfo { + let config = serde_json::from_str(&m.config).unwrap_or(PkRoundConfig { + agents: Vec::new(), + permission_mode: "default".into(), + bare_mode: false, + effort: "default".into(), + judge_agent: None, + judge_dimensions: Vec::new(), + base_commit: None, + }); + let status = serde_json::to_value(m.status) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{:?}", m.status)); + let judge_result = m + .judge_result + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + let judge_status = m.judge_status.unwrap_or_else(|| "idle".into()); + PkRoundInfo { + id: m.id, + folder_id: m.folder_id, + task: m.task, + config, + status, + failure_reason: m.failure_reason, + judge_result, + judge_status, + created_at: m.created_at, + updated_at: m.updated_at, + finished_at: m.finished_at, + } +} + +pub async fn create( + conn: &DatabaseConnection, + folder_id: i32, + task: String, + config: PkRoundConfig, +) -> Result { + let now = Utc::now(); + let config_json = serde_json::to_string(&config).unwrap_or_else(|_| "{}".into()); + let model = pk_round::ActiveModel { + id: NotSet, + folder_id: Set(folder_id), + task: Set(task), + config: Set(config_json), + status: Set(pk_round::PkRoundStatus::Ready), + failure_reason: Set(None), + judge_result: Set(None), + judge_status: Set(Some("idle".into())), + created_at: Set(now), + updated_at: Set(now), + finished_at: Set(None), + deleted_at: Set(None), + }; + Ok(model.insert(conn).await?) +} + +pub async fn get(conn: &DatabaseConnection, id: i32) -> Result, DbError> { + Ok(pk_round::Entity::find_by_id(id).one(conn).await?) +} + +/// List all non-deleted rounds, optionally filtered by folder. Returns +/// `PkRoundInfo` ready for the frontend. +pub async fn list( + conn: &DatabaseConnection, + folder_id: Option, +) -> Result, DbError> { + let mut query = pk_round::Entity::find() + .filter(pk_round::Column::DeletedAt.is_null()) + .order_by_desc(pk_round::Column::CreatedAt); + if let Some(fid) = folder_id { + query = query.filter(pk_round::Column::FolderId.eq(fid)); + } + let rows = query.all(conn).await?; + Ok(rows.into_iter().map(to_info).collect()) +} + +/// Get a single round as `PkRoundInfo`. +pub async fn get_info(conn: &DatabaseConnection, id: i32) -> Result { + let row = pk_round::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("PK round not found: {id}")))?; + Ok(to_info(row)) +} + +pub async fn list_by_folder( + conn: &DatabaseConnection, + folder_id: i32, +) -> Result, DbError> { + Ok(pk_round::Entity::find() + .filter(pk_round::Column::FolderId.eq(folder_id)) + .filter(pk_round::Column::DeletedAt.is_null()) + .order_by_desc(pk_round::Column::CreatedAt) + .all(conn) + .await?) +} + +pub async fn update_status( + conn: &DatabaseConnection, + id: i32, + status: pk_round::PkRoundStatus, +) -> Result<(), DbError> { + let round = pk_round::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("PK round not found: {id}")))?; + let mut active: pk_round::ActiveModel = round.into(); + active.status = Set(status); + if status == pk_round::PkRoundStatus::Finished + || status == pk_round::PkRoundStatus::Canceled + || status == pk_round::PkRoundStatus::Interrupted + { + active.finished_at = Set(Some(Utc::now())); + } + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +pub async fn soft_delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + use sea_orm::sea_query::Expr; + + let txn = conn.begin().await?; + let round = pk_round::Entity::find_by_id(id) + .filter(pk_round::Column::DeletedAt.is_null()) + .one(&txn) + .await? + .ok_or_else(|| DbError::Migration(format!("PK round not found: {id}")))?; + let now = Utc::now(); + let mut active: pk_round::ActiveModel = round.into(); + active.deleted_at = Set(Some(now)); + active.update(&txn).await?; + conversation::Entity::update_many() + .col_expr(conversation::Column::DeletedAt, Expr::value(Some(now))) + .col_expr(conversation::Column::UpdatedAt, Expr::value(now)) + .filter(conversation::Column::PkRoundId.eq(id)) + .filter(conversation::Column::DeletedAt.is_null()) + .exec(&txn) + .await?; + txn.commit().await?; + Ok(()) +} + +/// Persist the judge verdict and status. `judge_result` is a pre-serialized +/// JSON string (the frontend sends the full PkJudgeResult object); passing +/// None clears it. +pub async fn update_judge( + conn: &DatabaseConnection, + id: i32, + judge_result: Option, + judge_status: String, +) -> Result<(), DbError> { + let round = pk_round::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("PK round not found: {id}")))?; + let mut active: pk_round::ActiveModel = round.into(); + active.judge_result = Set(judge_result); + active.judge_status = Set(Some(judge_status)); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe7a573ce..d2fb3499f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,7 +70,8 @@ mod tauri_app { experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, folder_links, office_tools as office_tools_commands, folders, logging as logging_commands, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, + model_provider as model_provider_commands, notification, pet as pet_commands, pk as pk_commands, + project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, science as science_commands, @@ -1031,6 +1032,7 @@ mod tauri_app { conversations::get_stats, conversations::get_sidebar_data, conversations::create_conversation, + conversations::create_pk_conversation, conversations::create_chat_conversation, conversations::create_chat_dir, conversations::update_conversation_status, @@ -1132,6 +1134,7 @@ mod tauri_app { folders::git_search_authors, folders::git_commit_branches, windows::open_folder_window, + windows::open_pk_round_window, windows::open_commit_window, windows::open_settings_window, windows::open_merge_window, @@ -1400,6 +1403,14 @@ mod tauri_app { work_task_commands::work_task_template_list, work_task_commands::work_task_template_save, work_task_commands::work_task_template_delete, + pk_commands::pk_round_list, + pk_commands::pk_round_get, + pk_commands::pk_round_create, + pk_commands::pk_round_update_status, + pk_commands::pk_round_delete, + pk_commands::pk_round_update_judge, + pk_commands::pk_round_save_report_snapshot, + pk_commands::pk_round_get_report_snapshot, forge_commands::folder_forge_remote, forge_commands::forge_list_issues, forge_commands::forge_tab_count, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index ac1201496..9f16ceefc 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -68,6 +68,11 @@ pub struct DbConversationSummary { /// path (set when a removed task worktree's conversations were re-parented). #[serde(skip_serializing_if = "Option::is_none")] pub origin_cwd: Option, + /// Mirror of `conversation.pk_round_id`: the PK arena round this contestant + /// session belongs to. Set only when `kind == Pk`; drives the sidebar's + /// per-round grouping. + #[serde(skip_serializing_if = "Option::is_none")] + pub pk_round_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index efe2e9821..32a73f179 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -7,6 +7,7 @@ pub mod folder; pub mod message; pub mod model_provider; pub mod pet; +pub mod pk_round; pub mod quick_message; pub mod remote_workspace_connection; pub mod system; @@ -35,6 +36,7 @@ pub use message::{ TurnRole, TurnUsage, UnifiedMessage, }; pub use quick_message::QuickMessageInfo; +pub use pk_round::{PkRoundConfig, PkRoundInfo}; pub use remote_workspace_connection::RemoteWorkspaceConnectionInfo; pub use token_usage::{ TokenUsageBreakdownItem, TokenUsageBucket, TokenUsageConversationItem, TokenUsageFacets, diff --git a/src-tauri/src/models/pk_round.rs b/src-tauri/src/models/pk_round.rs new file mode 100644 index 000000000..4835c0815 --- /dev/null +++ b/src-tauri/src/models/pk_round.rs @@ -0,0 +1,131 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// One contestant entry in the round config. Supports both a plain string +/// (backward compat with old rounds: `"claude_code"`) and a labeled object +/// (new format: `{"agent":"claude_code","label":"Sonnet", ...}`). +/// `config_values` is applied to +/// this slot's ACP session before its first prompt; `label` is the captured +/// human-readable value used to disambiguate same-agent slots. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PkContestantEntry { + Simple(String), + Labeled { + agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + label: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + config_values: BTreeMap, + }, +} + +impl PkContestantEntry { + pub fn agent(&self) -> &str { + match self { + PkContestantEntry::Simple(a) => a, + PkContestantEntry::Labeled { agent, .. } => agent, + } + } + pub fn label(&self) -> Option<&str> { + match self { + PkContestantEntry::Simple(_) => None, + PkContestantEntry::Labeled { label, .. } => label.as_deref(), + } + } +} + +/// Config stored as JSON in `pk_round.config`. Mirrors the launcher's options +/// so a round is fully reproducible from the DB row alone. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PkRoundConfig { + /// The agent types selected as contestants, in pick order. Each entry + /// is either a plain string (old format) or a labeled object (new format). + pub agents: Vec, + /// Round-level permission policy applied to every contestant. + #[serde(default = "default_permission_mode")] + pub permission_mode: String, + /// Bare mode: contestants are instructed to use no skills at all. + #[serde(default)] + pub bare_mode: bool, + /// Uniform reasoning-effort request applied to every contestant. + #[serde(default = "default_effort")] + pub effort: String, + /// Optional judge agent — after all contestants finish, this agent reads + /// every diff and produces a structured verdict. Stored in config (not a + /// separate column) because it is round-level input, set at creation time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub judge_agent: Option, + /// Optional custom judge evaluation dimensions. Each entry is a free-form + /// line that replaces the default 4 (Correctness / Code quality / + /// Completeness / Efficiency). Empty or absent = use the defaults. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub judge_dimensions: Vec, + /// The git ref each contestant worktree is branched from. Absent or null + /// = current HEAD (the default, "from now"). When the launcher picks a + /// commit X as the task source, this is set to `X^` so the worktree starts + /// one commit BEFORE X — contestants never see X's changes, only its + /// message as the task description. Physical isolation, not a prompt + /// instruction the agent can ignore. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_commit: Option, +} + +fn default_permission_mode() -> String { + "default".into() +} + +fn default_effort() -> String { + "default".into() +} + +/// A PK round summary as returned to the frontend. Carries the round's own +/// fields plus the live contestant status (computed from the linked +/// conversations, not stored on the round row itself). +#[derive(Debug, Clone, Serialize)] +pub struct PkRoundInfo { + pub id: i32, + pub folder_id: i32, + pub task: String, + pub config: PkRoundConfig, + pub status: String, + pub failure_reason: Option, + /// JSON-serialized judge verdict, or null if no judge / not yet run. + #[serde(skip_serializing_if = "Option::is_none")] + pub judge_result: Option, + /// idle | running | done | error | skipped + pub judge_status: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub finished_at: Option>, +} + +#[cfg(test)] +mod tests { + use super::PkContestantEntry; + + #[test] + fn contestant_entry_keeps_legacy_formats_compatible() { + let simple: PkContestantEntry = serde_json::from_str(r#""codex""#).unwrap(); + assert_eq!(simple.agent(), "codex"); + assert_eq!(simple.label(), None); + + let labeled: PkContestantEntry = + serde_json::from_str(r#"{"agent":"claude_code","label":"Sonnet"}"#).unwrap(); + assert_eq!(labeled.agent(), "claude_code"); + assert_eq!(labeled.label(), Some("Sonnet")); + } + + #[test] + fn contestant_entry_round_trips_pinned_config_values() { + let entry: PkContestantEntry = serde_json::from_str( + r#"{"agent":"claude_code","label":"Opus","config_values":{"model":"opus"}}"#, + ) + .unwrap(); + let json = serde_json::to_value(entry).unwrap(); + assert_eq!(json["agent"], "claude_code"); + assert_eq!(json["label"], "Opus"); + assert_eq!(json["config_values"]["model"], "opus"); + } +} diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index a46a20fc1..a03a70a6d 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -270,6 +270,32 @@ pub async fn create_conversation( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePkConversationParams { + pub folder_id: i32, + pub agent_type: AgentType, + pub title: Option, + pub pk_round_id: i32, +} + +pub async fn create_pk_conversation( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let db = &state.db; + let result = conv_commands::create_pk_conversation_core( + &db.conn, + params.folder_id, + params.agent_type, + params.title, + params.pk_round_id, + ) + .await?; + conv_commands::emit_conversation_upsert(&state.emitter, &db.conn, result).await; + Ok(Json(result)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateChatConversationParams { diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 97b7d86f9..5530c9605 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -24,6 +24,7 @@ pub mod model_provider; pub mod office_tools; pub mod office_watch_proxy; pub mod pet; +pub mod pk; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/web/handlers/pk.rs b/src-tauri/src/web/handlers/pk.rs new file mode 100644 index 000000000..23620b76a --- /dev/null +++ b/src-tauri/src/web/handlers/pk.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::pk as core; +use crate::models::PkRoundConfig; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListParams { + #[serde(default)] + pub folder_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdParams { + pub id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateParams { + pub folder_id: i32, + pub task: String, + pub config: PkRoundConfig, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateStatusParams { + pub id: i32, + pub status: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateJudgeParams { + pub id: i32, + pub judge_result: Option, + pub judge_status: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveReportSnapshotParams { + pub id: i32, + pub snapshot: String, +} + +pub async fn pk_round_list( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + let result = core::pk_round_list_core(&state.db, params.folder_id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn pk_round_get( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = core::pk_round_get_core(&state.db, params.id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn pk_round_create( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = + core::pk_round_create_core(&state.db, params.folder_id, params.task, params.config) + .await + .map_err(AppCommandError::from)?; + Ok(Json(result)) +} + +pub async fn pk_round_update_status( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::pk_round_update_status_core(&state.db, params.id, params.status) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +pub async fn pk_round_delete( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::pk_round_archive_core(&state.db, &state.data_dir, params.id) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +pub async fn pk_round_update_judge( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::pk_round_update_judge_core( + &state.db, + params.id, + params.judge_result, + params.judge_status, + ) + .await + .map_err(AppCommandError::from)?; + Ok(Json(())) +} + +pub async fn pk_round_save_report_snapshot( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::pk_round_save_report_snapshot_core(&state.data_dir, params.id, params.snapshot).await?; + Ok(Json(())) +} + +pub async fn pk_round_get_report_snapshot( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::pk_round_get_report_snapshot_core(&state.data_dir, params.id).await?, + )) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 3d420b0ed..92e83fb81 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -139,6 +139,10 @@ pub fn build_router( "/create_conversation", post(handlers::conversations::create_conversation), ) + .route( + "/create_pk_conversation", + post(handlers::conversations::create_pk_conversation), + ) .route( "/create_chat_conversation", post(handlers::conversations::create_chat_conversation), @@ -1425,6 +1429,28 @@ pub fn build_router( "/work_task_template_delete", post(handlers::work_task::work_task_template_delete), ) + // ─── PK arena rounds ─── + .route("/pk_round_list", post(handlers::pk::pk_round_list)) + .route("/pk_round_get", post(handlers::pk::pk_round_get)) + .route("/pk_round_create", post(handlers::pk::pk_round_create)) + .route( + "/pk_round_update_status", + post(handlers::pk::pk_round_update_status), + ) + .route("/pk_round_delete", post(handlers::pk::pk_round_delete)) + .route( + "/pk_round_update_judge", + post(handlers::pk::pk_round_update_judge), + ) + .route( + "/pk_round_save_report_snapshot", + post(handlers::pk::pk_round_save_report_snapshot) + .layer(DefaultBodyLimit::max(64 * 1024 * 1024)), + ) + .route( + "/pk_round_get_report_snapshot", + post(handlers::pk::pk_round_get_report_snapshot), + ) // ─── Workspace background ─── .route( "/background_read", diff --git a/src-tauri/tests/delegation_columns.rs b/src-tauri/tests/delegation_columns.rs index 07639e886..031b9e559 100644 --- a/src-tauri/tests/delegation_columns.rs +++ b/src-tauri/tests/delegation_columns.rs @@ -38,6 +38,7 @@ async fn delegation_columns_round_trip() { deleted_at: Set(None), pinned_at: Set(None), origin_cwd: Set(None), + pk_round_id: Set(None), }; let inserted = active.insert(&db.conn).await.expect("insert"); let id = inserted.id; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 852464b20..7d802fa1c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init" import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init" import { WebConnectionGuard } from "@/components/connection/web-connection-guard" import { WindowResizeGrips } from "@/components/layout/window-resize-grips" +import { ChunkLoadRecovery } from "@/components/chunk-load-recovery" export const viewport: Viewport = { width: "device-width", @@ -69,6 +70,7 @@ export default async function RootLayout({ disableTransitionOnChange > + diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 1e3a6232a..4cf1227a4 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -1,7 +1,35 @@ "use client" +import { useEffect, useRef } from "react" import { ConversationDetailPanel } from "@/components/conversations/conversation-detail-panel" +import { PkArenaHost } from "@/components/pk/pk-arena-host" +import { usePkArenaStore } from "@/stores/pk-arena-store" +import { useTabActions } from "@/stores/tab-store" export default function WorkspacePage() { - return + const rounds = usePkArenaStore((s) => s.rounds) + const hydrating = usePkArenaStore((s) => s.hydrating) + const { openPkRoundTab } = useTabActions() + const openedRoundRef = useRef(null) + + useEffect(() => { + if (hydrating || typeof window === "undefined") return + const roundId = new URLSearchParams(window.location.search).get("pkRoundId") + if (!roundId || openedRoundRef.current === roundId) return + const round = rounds.find((item) => item.id === roundId) + if (!round) return + openedRoundRef.current = roundId + usePkArenaStore.getState().setActiveRound(round.id) + openPkRoundTab(round.id, round.folderId, round.task) + }, [hydrating, openPkRoundTab, rounds]) + + return ( + <> + + {/* Inside the workspace layout's AcpConnectionsProvider — the arena + orchestrator needs the connection actions and the acp://event + subscription. */} + + + ) } diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index 5e9948e17..b06e645c5 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -24,7 +24,13 @@ import { import { Streamdown, defaultRemarkPlugins } from "streamdown" import { Shimmer } from "./shimmer" +import type { Components } from "streamdown" import { markdownLinkComponents } from "./markdown-link" + +/** `

` → `

`: keeps paragraph spacing without the nesting violation. */ +function ReasoningParagraphSafe({ children }: { children: ReactNode }) { + return
{children}
+} import { normalizeMathDelimiters } from "./message" import { remarkTrimCjkAutolinkTail } from "./remark-cjk-autolink-tail" import { remarkRewriteFileUriLinks } from "./remark-file-uri-links" @@ -257,8 +263,15 @@ export const ReasoningContent = memo( plugins={plugins} remarkPlugins={remarkPlugins} {...props} - // Enforce the link icon + safety override after spreading props. - components={markdownLinkComponents} + // Enforce the link icon + safety override after spreading props, + // and render paragraphs as
: reasoning text frequently + // embeds raw HTML/SVG, and Streamdown's default

for the + // wrapper plus a nested element rendered as

trips React's + // nested-paragraph hydration error. + components={{ + ...markdownLinkComponents, + p: ReasoningParagraphSafe as Components["p"], + }} > {normalized} diff --git a/src/components/chat/composer/composer-add-menu.tsx b/src/components/chat/composer/composer-add-menu.tsx index 4c51a8a5a..8c5110a75 100644 --- a/src/components/chat/composer/composer-add-menu.tsx +++ b/src/components/chat/composer/composer-add-menu.tsx @@ -14,6 +14,7 @@ import { Plus, Search, Sparkles, + Swords, Upload, } from "lucide-react" @@ -31,6 +32,8 @@ import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item- import { rankByTextMatch } from "@/lib/fuzzy-text-match" import { isImeCompositionKey } from "@/lib/ime-composition" import { cn } from "@/lib/utils" +import { usePkArenaStore } from "@/stores/pk-arena-store" +import { useTabStore } from "@/stores/tab-store" import type { AvailableCommandInfo } from "@/lib/types" import { commandInvocationToken } from "@/components/chat/composer/invocation-reference" @@ -396,6 +399,29 @@ export function ComposerAddMenu({ )} + { + const state = usePkArenaStore.getState() + // With history to revisit, the menu reopens the ARENA directly + // (the dialog otherwise has no way back after it closes); + // "新一局" inside the arena opens the launcher. + if (state.rounds.length > 0 && state.activeRoundId) { + const round = state.rounds.find( + (item) => item.id === state.activeRoundId + ) + if (round) { + useTabStore + .getState() + .openPkRoundTab(round.id, round.folderId, round.task) + } + } else { + state.setLauncherOpen(true) + } + }} + > + + {t("startPk")} + ) diff --git a/src/components/chat/conversation-context-bar.test.tsx b/src/components/chat/conversation-context-bar.test.tsx index 2c0b5c84a..469a8c9ac 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -28,6 +28,7 @@ vi.mock("sonner", () => ({ // branches) is seeded into the real zustand store in beforeEach. let tabs: Array<{ id: string + kind: "conversation" folderId: number conversationId: number | null isChat?: boolean @@ -93,7 +94,14 @@ describe("ConversationHeaderFolderPicker", () => { folders: [repo, other], allFolders: [repo, other], }) - tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }] + tabs = [ + { + id: "tab-draft", + kind: "conversation", + folderId: 1, + conversationId: null, + }, + ] activeTabId = "tab-draft" const user = userEvent.setup() @@ -111,7 +119,14 @@ describe("ConversationHeaderFolderPicker", () => { folders: [repo, other], allFolders: [repo, other], }) - tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] + tabs = [ + { + id: "tab-1", + kind: "conversation", + folderId: 1, + conversationId: 42, + }, + ] activeTabId = "tab-1" const user = userEvent.setup() @@ -125,7 +140,13 @@ describe("ConversationHeaderFolderPicker", () => { it("shows the chat-mode label for a folderless chat tab", () => { tabs = [ - { id: "tab-chat", folderId: 999, conversationId: null, isChat: true }, + { + id: "tab-chat", + kind: "conversation", + folderId: 999, + conversationId: null, + isChat: true, + }, ] activeTabId = "tab-chat" diff --git a/src/components/chat/conversation-context-bar.tsx b/src/components/chat/conversation-context-bar.tsx index e9d11424b..98a8dbf20 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -7,6 +7,7 @@ import { Check, ChevronDown, Folder, MessageSquare } from "lucide-react" import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { isConversationWorkspaceTab } from "@/lib/workspace-tab" import { Button } from "@/components/ui/button" import { Popover, @@ -125,7 +126,8 @@ export const ConversationHeaderFolderPicker = memo( const ownTab = useMemo(() => { const lookupId = tabId ?? activeTabId - return tabs.find((x) => x.id === lookupId) ?? null + const tab = tabs.find((x) => x.id === lookupId) + return tab && isConversationWorkspaceTab(tab) ? tab : null }, [tabs, tabId, activeTabId]) const ownFolder = useMemo( @@ -250,7 +252,8 @@ export const ConversationFolderBranchPicker = memo( const ownTab = useMemo(() => { const lookupId = tabId ?? activeTabId - return tabs.find((x) => x.id === lookupId) ?? null + const tab = tabs.find((x) => x.id === lookupId) + return tab && isConversationWorkspaceTab(tab) ? tab : null }, [tabs, tabId, activeTabId]) const ownFolder = useMemo( @@ -369,7 +372,9 @@ export function useConversationFolderBranchPickerVisible( const activeTabId = useTabStore((s) => s.activeTabId) const allFolders = useAppWorkspaceStore((s) => s.allFolders) const lookupId = tabId ?? activeTabId - const ownTab = tabs.find((x) => x.id === lookupId) ?? null + const matchedTab = tabs.find((x) => x.id === lookupId) + const ownTab = + matchedTab && isConversationWorkspaceTab(matchedTab) ? matchedTab : null const ownFolder = ownTab ? (allFolders.find((f) => f.id === ownTab.folderId) ?? null) : null diff --git a/src/components/chunk-load-recovery.test.tsx b/src/components/chunk-load-recovery.test.tsx new file mode 100644 index 000000000..5bf1e437f --- /dev/null +++ b/src/components/chunk-load-recovery.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { ChunkLoadRecovery, isChunkLoadError } from "./chunk-load-recovery" + +afterEach(() => { + window.sessionStorage.clear() +}) + +describe("ChunkLoadRecovery", () => { + it("recognizes async chunk failures without matching unrelated errors", () => { + expect( + isChunkLoadError( + new Error( + "Failed to load chunk http://localhost:3000/_next/static/chunks/opener.js" + ) + ) + ).toBe(true) + expect(isChunkLoadError(new Error("Permission denied"))).toBe(false) + }) + + it("reloads once when a lazy chunk is stale", () => { + const reloadPage = vi.fn() + render() + + const rejection = new Event("unhandledrejection") + Object.defineProperty(rejection, "reason", { + value: Object.assign(new Error("Failed to load chunk /opener.js"), { + name: "ChunkLoadError", + }), + }) + window.dispatchEvent(rejection) + window.dispatchEvent(rejection) + + expect(reloadPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/chunk-load-recovery.tsx b/src/components/chunk-load-recovery.tsx new file mode 100644 index 000000000..a5721ed73 --- /dev/null +++ b/src/components/chunk-load-recovery.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useEffect, useRef } from "react" + +const RECOVERY_MARKER = "codeg:chunk-load-recovery" +const HEALTHY_WINDOW_MS = 10_000 +const CHUNK_LOAD_PATTERN = + /chunkloaderror|failed to load chunk|loading chunk .+ failed|failed to fetch dynamically imported module|importing a module script failed/i + +function errorText(value: unknown): string { + if (typeof value === "string") return value + if (value == null || typeof value !== "object") return "" + const record = value as { name?: unknown; message?: unknown } + return [record.name, record.message] + .filter((part): part is string => typeof part === "string") + .join(": ") +} + +export function isChunkLoadError(value: unknown): boolean { + return CHUNK_LOAD_PATTERN.test(errorText(value)) +} + +function reloadWindow(): void { + window.location.reload() +} + +/** + * Recover from a stale Next.js runtime after a deploy or dev-server rebuild. + * Lazy chunks surface only when their feature is first used (for example, + * opening a generated file), so the initial page can look healthy while its + * chunk graph is already invalid. One guarded reload obtains a coherent graph; + * sessionStorage prevents a broken deployment from entering a reload loop. + */ +export function ChunkLoadRecovery({ + reloadPage = reloadWindow, +}: { + reloadPage?: () => void +}) { + const attemptedRef = useRef(false) + + useEffect(() => { + const pageKey = `${window.location.pathname}${window.location.search}` + + const recover = (reason: unknown) => { + if (attemptedRef.current || !isChunkLoadError(reason)) return + + try { + if (window.sessionStorage.getItem(RECOVERY_MARKER) === pageKey) return + window.sessionStorage.setItem(RECOVERY_MARKER, pageKey) + } catch { + // A local in-memory guard still prevents repeated reload attempts in + // this document when storage is unavailable. + } + + attemptedRef.current = true + reloadPage() + } + + const onError = (event: ErrorEvent) => { + recover(event.error ?? event.message) + } + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + recover(event.reason) + } + + window.addEventListener("error", onError) + window.addEventListener("unhandledrejection", onUnhandledRejection) + const healthyTimer = window.setTimeout(() => { + try { + if (window.sessionStorage.getItem(RECOVERY_MARKER) === pageKey) { + window.sessionStorage.removeItem(RECOVERY_MARKER) + } + } catch { + // Storage is optional; there is nothing to clear when it is blocked. + } + }, HEALTHY_WINDOW_MS) + + return () => { + window.removeEventListener("error", onError) + window.removeEventListener("unhandledrejection", onUnhandledRejection) + window.clearTimeout(healthyTimer) + } + }, [reloadPage]) + + return null +} diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index f55ee8700..6bb7748bf 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -284,10 +284,12 @@ describe("ConversationDetailPanel split-group render model", () => { it("pairs every split group with its own title bar and gates the global one", () => { const shellStart = source.indexOf("const renderGroupShell = (groupId") const shellBody = source.slice(shellStart, shellStart + 6000) - expect(shellBody).toContain("{isSplit && selTab && (") + expect(shellBody).toContain("{isSplit && selConversationTab && (") expect(shellBody).toContain(" s.tabs.find((tab) => tab.id === tabId) ?? null - ) + const ownTab = useTabStore((s) => { + const tab = s.tabs.find((item) => item.id === tabId) + return tab && isConversationWorkspaceTab(tab) ? tab : null + }) // Resolve this panel's folder from ITS OWN tab, not the global active folder. // A keep-alive panel for a background tab must NOT re-render when the active // tab switches to a different folder. For the active tab this equals the old @@ -2228,7 +2231,8 @@ export function ConversationDetailPanel() { } = useTabActions() const newConversation = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) - if (!activeTab || activeTab.conversationId != null) return null + if (!activeTab || !isConversationWorkspaceTab(activeTab)) return null + if (activeTab.conversationId != null) return null const workingDir = activeTab.workingDir ?? folder?.path if (!workingDir) return null return { workingDir, folderId: activeTab.folderId } @@ -2294,9 +2298,10 @@ export function ConversationDetailPanel() { const dbId2 = summary?.id const isOpenInTabs = tabs.some( (tab) => - tab.conversationId === matchedConversationId || - tab.runtimeConversationId === matchedConversationId || - (dbId2 != null && tab.conversationId === dbId2) + isConversationWorkspaceTab(tab) && + (tab.conversationId === matchedConversationId || + tab.runtimeConversationId === matchedConversationId || + (dbId2 != null && tab.conversationId === dbId2)) ) if (isOpenInTabs) return @@ -2315,13 +2320,12 @@ export function ConversationDetailPanel() { ) const hasNoTabs = tabs.length === 0 && !activeTabId - const activeConversationTab = useMemo( - () => - tabs.find( - (tab) => tab.id === activeTabId && tab.conversationId != null - ) ?? null, - [tabs, activeTabId] - ) + const activeConversationTab = useMemo(() => { + const tab = tabs.find((item) => item.id === activeTabId) + return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null + ? tab + : null + }, [tabs, activeTabId]) const canReloadActiveConversation = activeConversationTab != null const handleReloadActiveConversation = useCallback(() => { if (!activeConversationTab) return @@ -2547,18 +2551,21 @@ export function ConversationDetailPanel() { // Visible = tiled (all group members shown) or the group's selected tab. const visible = canTileG || tab.id === groupSelection[groupId] const folderPath = allFolders.find((f) => f.id === tab.folderId)?.path - const view = ( - - ) + const view = + tab.kind === "pk" ? ( + + ) : ( + + ) return (

tab.id === groupSelection[groupId]) ?? groupTabs[0] ?? null - const selTabFolder = selTab - ? allFolders.find((f) => f.id === selTab.folderId) + const selConversationTab = + selTab && isConversationWorkspaceTab(selTab) ? selTab : null + const selTabFolder = selConversationTab + ? allFolders.find((f) => f.id === selConversationTab.folderId) : undefined // NOTE: the strip / header / content stay PLAIN SIBLING SLOTS (no fragment // around any pair) — a `false` conditional is a reconciliation hole, so the @@ -2650,7 +2659,7 @@ export function ConversationDetailPanel() { {touchesRight && }
)} - {isSplit && selTab && ( + {isSplit && selConversationTab && (
)} @@ -2710,7 +2723,7 @@ export function ConversationDetailPanel() { return ( <>
- {!isSplit && activeTab && ( + {!isSplit && activeTab && isConversationWorkspaceTab(activeTab) && ( { expect(shellStart).toBeGreaterThan(-1) const shellBody = source.slice(shellStart, shellStart + 6000) const stripIdx = shellBody.indexOf("{isSplit && (") - const headerIdx = shellBody.indexOf("{isSplit && selTab && (") + const headerIdx = shellBody.indexOf("{isSplit && selConversationTab && (") const contentIdx = shellBody.indexOf( '
' ) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 18882b924..78dc8650f 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -262,6 +262,54 @@ export function selectChatConversationsWithReuse( return arraysShallowEqual(prev, next) ? prev : next } +/** + * Select PK-arena contestant conversations (`kind === "pk"`), grouped by their + * `pk_round_id`. Returns a Map keyed by round id → conversations (newest-first + * within each round). Excludes pinned conversations (they surface in the + * Pinned section). `prev` is the Map returned last call for reference reuse. + * + * Each round's conversations are sorted newest-first; the rounds themselves are + * ordered by their newest conversation's `updated_at` (hottest round first) so + * the active PK sits at the top. + */ +export function selectPkConversationsWithReuse( + conversations: readonly DbConversationSummary[], + prev: Map +): Map { + const grouped = new Map() + for (const conv of conversations) { + if (conv.pinned_at != null) continue + if (conv.kind !== "pk") continue + const rid = conv.pk_round_id + if (rid == null) continue + const bucket = grouped.get(rid) + if (bucket) bucket.push(conv) + else grouped.set(rid, [conv]) + } + // Sort conversations within each round newest-first. + for (const bucket of grouped.values()) { + bucket.sort(compareByUpdatedAtDesc) + } + // Sort rounds by hottest conversation (newest updated_at among their + // conversations) first. + const sortedEntries = [...grouped.entries()].sort((a, b) => { + const aMax = a[1][0]?.updated_at ?? "" + const bMax = b[1][0]?.updated_at ?? "" + return bMax.localeCompare(aMax) + }) + const next = new Map(sortedEntries) + return mapsShallowEqual(prev, next) ? prev : next +} + +/** Shallow-equal for Map — same keys and same array refs. */ +function mapsShallowEqual(a: Map, b: Map): boolean { + if (a.size !== b.size) return false + for (const [k, v] of a) { + if (b.get(k) !== v) return false + } + return true +} + /** * Select the flat "Recent" bucket: every conversation the sidebar can reach, * folder-bound and chat alike, newest first — the whole point of the section is @@ -297,6 +345,7 @@ export function selectRecentConversationsWithReuse( for (const conv of conversations) { if (conv.pinned_at != null) continue if (!showCompleted && conv.status === "completed") continue + if (conv.kind === "pk") continue if (conv.kind !== "chat" && !openFolderIds.has(conv.folder_id)) continue next.push(conv) } @@ -494,13 +543,33 @@ export interface RecentMoreRow { } /** - * A collapsible section heading. Four exist: "pinned" (always on top, shown only - * when there are pinned conversations) plus the three user-reorderable ones — - * "folders" (wraps the whole folder list), "chats" (a flat list of folderless - * chat-mode conversations), and "recent" (a flat, folder-agnostic list of the - * newest conversations, shown only when the user keeps it enabled). All live in - * the same flat row array so the single Virtualizer windows them like any other - * row — there is no separate, un-virtualized list. + * A PK-arena round sub-group heading inside the "pk" section: shows the round's + * task summary and gates its contestant conversations. Follows the section + * header; each round is one collapsible group. + */ +export interface PkRoundHeaderRow { + kind: "pk-round" + roundId: number + /** Task preview (truncated for the header) — identifies the round. */ + task: string + /** Number of contestant conversations in this round. */ + count: number +} + +/** Empty hint for the PK section (no PK arena conversations at all). */ +export interface PkEmptyRow { + kind: "pk-empty" +} + +/** + * A collapsible section heading. Five exist: "pinned" and "pk" (always on top, + * shown only when conversations of that kind exist) plus the three + * user-reorderable ones — "folders" (wraps the whole folder list), "chats" (a + * flat list of folderless chat-mode conversations), and "recent" (a flat, + * folder-agnostic list of the newest conversations, shown only when the user + * keeps it enabled). All live in the same flat row array so the single + * Virtualizer windows them like any other row — there is no separate, + * un-virtualized list. */ export interface SectionHeaderRow { kind: "section" @@ -538,6 +607,8 @@ export type SidebarRow = | RecentEmptyRow | RecentMoreRow | SubsessionLoadingRow + | PkRoundHeaderRow + | PkEmptyRow const MAX_RENDER_DEPTH = 32 @@ -555,6 +626,9 @@ const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = // the row output stays identical to the pre-Recent model for callers that don't // pass it. const EMPTY_CONVERSATIONS: readonly DbConversationSummary[] = [] +const EMPTY_PK_MAP: ReadonlyMap = + new Map() +const EMPTY_ROUND_TASKS: ReadonlyMap = new Map() /** * Merge a freshly-fetched children snapshot with child summaries already applied @@ -684,6 +758,16 @@ function pushConversationRow( export function buildRows(args: { pinned: readonly DbConversationSummary[] pinnedExpanded: boolean + /** PK-arena conversations grouped by round id (hottest round first). Empty + * Map = no PK section. */ + pkConversations?: Map + /** Whether the PK section's rows are shown. Optional — defaults to expanded. */ + pkExpanded?: boolean + /** Round metadata for the PK section headers: id → task preview. Absent + * entries fall back to a generic "Round N" label. */ + pkRoundTasks?: ReadonlyMap + /** Ids whose PK round sub-group is collapsed. Absent = expanded. */ + pkRoundCollapsed?: ReadonlySet orderedFolderIds: readonly number[] byFolder: Map folderExpanded: Record @@ -740,6 +824,10 @@ export function buildRows(args: { const { pinned, pinnedExpanded, + pkConversations = EMPTY_PK_MAP, + pkExpanded = true, + pkRoundTasks = EMPTY_ROUND_TASKS, + pkRoundCollapsed = EMPTY_EXPANDED, orderedFolderIds, byFolder, folderExpanded, @@ -812,6 +900,41 @@ export function buildRows(args: { } } + const pushPk = () => { + if (pkConversations.size === 0) return + const totalConvs = [...pkConversations.values()].reduce( + (n, bucket) => n + bucket.length, + 0 + ) + rows.push({ + kind: "section", + section: "pk", + expanded: pkExpanded, + count: totalConvs, + }) + if (!pkExpanded) return + for (const [roundId, convs] of pkConversations) { + const task = pkRoundTasks.get(roundId) ?? `Round #${roundId}` + rows.push({ + kind: "pk-round", + roundId, + task, + count: convs.length, + }) + if (pkRoundCollapsed.has(roundId)) continue + for (const conv of convs) { + pushConversationRow( + rows, + conv, + 0, + conversationExpanded, + childrenByParent, + childrenLoading + ) + } + } + } + const pushFolders = () => { // The Folders section header is always present (a permanent entry point), // mirroring the Chat section — so a workspace with chats but no open folders @@ -916,6 +1039,11 @@ export function buildRows(args: { if (remaining > 0) rows.push({ kind: "recent-more", remaining }) } + // The PK section sits right below Pinned (above the reorderable sections). + // It is not part of `sectionOrder` — it is always-on-top like Pinned, shown + // only when PK arena conversations exist. + pushPk() + // Normalized (not consumed raw) so a truncated / repeated / unknown-entry // order can never drop a section off the sidebar or emit one twice. for (const section of normalizeSectionOrder(sectionOrder)) { diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index 8b4ede4ef..ad041db3c 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -36,6 +36,7 @@ const store = vi.hoisted(() => ({ activeTabId: null as string | null, tabSpec: [] as Array<{ id: string + kind?: string conversationId: number | null agentType: string folderId: number @@ -735,6 +736,7 @@ describe("SidebarConversationList — scrollToActive across a worktree merge", ( store.activeTabId = "tab-21" store.tabSpec = [ { + kind: "conversation", id: "tab-21", conversationId: 21, agentType: "claude_code", diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 8aa06f888..de0d784f2 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -21,6 +21,7 @@ import { ChevronDown, ChevronRight, Download, + Archive, ExternalLink, FolderClosed, FolderGit2, @@ -41,7 +42,9 @@ import { } from "lucide-react" import { useActiveFolder } from "@/contexts/active-folder-context" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" +import { usePkArenaStore } from "@/stores/pk-arena-store" import { useTabActions, useTabStore } from "@/contexts/tab-context" +import { isConversationWorkspaceTab } from "@/lib/workspace-tab" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useThemeColor, useZoomLevel } from "@/hooks/use-appearance" @@ -111,6 +114,7 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, + selectPkConversationsWithReuse, selectRecentConversationsWithReuse, worktreeChildrenByParent, worktreeHeaderAlias, @@ -797,6 +801,8 @@ export function SidebarConversationList({ const tabs = useTabStore((s) => s.tabs) const { openTab, + openPkRoundTab, + closePkRoundTab, closeConversationTab, closeTabsByFolder, openNewConversationTab, @@ -837,7 +843,9 @@ export function SidebarConversationList({ const selectedConversation = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) const next = - !activeTab || activeTab.conversationId == null + !activeTab || + !isConversationWorkspaceTab(activeTab) || + activeTab.conversationId == null ? null : { id: activeTab.conversationId, agentType: activeTab.agentType } const reused = reuseSelected(selectedConvRef.current, next) @@ -849,7 +857,7 @@ export function SidebarConversationList({ const openTabKeys = useMemo(() => { const next = new Set() for (const tab of tabs) { - if (tab.conversationId != null) { + if (isConversationWorkspaceTab(tab) && tab.conversationId != null) { next.add(`${tab.agentType}:${tab.conversationId}`) } } @@ -876,6 +884,7 @@ export function SidebarConversationList({ const [sectionCollapsed, setSectionCollapsed] = useState({}) const pinnedExpanded = !sectionCollapsed.pinned + const pkExpanded = !sectionCollapsed.pk const foldersExpanded = !sectionCollapsed.folders const chatsExpanded = !sectionCollapsed.chats const recentExpanded = !sectionCollapsed.recent @@ -1100,7 +1109,7 @@ export function SidebarConversationList({ // section, so exclude both here; then apply the completed filter as before. const folderConversations = useMemo(() => { const base = conversations.filter( - (c) => c.pinned_at == null && c.kind !== "chat" + (c) => c.pinned_at == null && c.kind !== "chat" && c.kind !== "pk" ) if (showCompleted) return base return base.filter((c) => c.status !== "completed") @@ -1120,6 +1129,84 @@ export function SidebarConversationList({ return next }, [conversations, showCompleted]) + // PK-arena conversations grouped by round (hottest round first). Each round + // renders as a collapsible sub-group under the "PK" section header. The round + // task labels come from the PK arena store; a round not yet hydrated shows a + // generic "Round #N" fallback. + const pkConvsRef = useRef>(new Map()) + const pkConversations = useMemo(() => { + const next = selectPkConversationsWithReuse( + conversations, + pkConvsRef.current + ) + pkConvsRef.current = next + return next + }, [conversations]) + const pkRounds = usePkArenaStore((s) => s.rounds) + const [pkRoundCollapsed, setPkRoundCollapsed] = useState>( + new Set() + ) + const pkRoundTasks = useMemo(() => { + const map = new Map() + for (const r of pkRounds) { + map.set(Number(r.id), r.task) + } + return map + }, [pkRounds]) + const togglePkRound = useCallback((roundId: number) => { + setPkRoundCollapsed((prev) => { + const next = new Set(prev) + if (next.has(roundId)) next.delete(roundId) + else next.add(roundId) + return next + }) + }, []) + const openPkRound = useCallback( + (roundId: number) => { + const store = usePkArenaStore.getState() + const round = store.rounds.find((item) => Number(item.id) === roundId) + if (!round) return + store.setActiveRound(round.id) + openPkRoundTab(round.id, round.folderId, round.task) + }, + [openPkRoundTab] + ) + const archivePkRound = useCallback( + async (roundId: number) => { + const round = usePkArenaStore + .getState() + .rounds.find((item) => Number(item.id) === roundId) + if ( + !round || + !window.confirm(t("pkArchiveConfirm", { task: round.task })) + ) { + return + } + try { + await usePkArenaStore.getState().archiveRound(String(roundId)) + closePkRoundTab(String(roundId)) + for (const conversation of pkConversations.get(roundId) ?? []) { + closeConversationTab( + conversation.folder_id, + conversation.id, + conversation.agent_type + ) + } + await refreshConversations() + toast.success(t("pkArchiveSuccess")) + } catch (error) { + toast.error(t("pkArchiveFailed", { message: String(error) })) + } + }, + [ + closeConversationTab, + closePkRoundTab, + pkConversations, + refreshConversations, + t, + ] + ) + // Pinned bucket: the FULL conversation list (ignores "Show completed" — a // pinned conversation stays visible regardless), sorted most-recently-pinned // first, with reference reuse so an unrelated status event doesn't rebuild it. @@ -1303,6 +1390,10 @@ export function SidebarConversationList({ buildRows({ pinned, pinnedExpanded, + pkConversations, + pkExpanded, + pkRoundTasks, + pkRoundCollapsed, // Top-level (reorderable) folders drive the outer order; buildRows nests // each container's root sub-group + worktrees via `containerChildren`. orderedFolderIds: reorderableFolderIds, @@ -1326,6 +1417,10 @@ export function SidebarConversationList({ [ pinned, pinnedExpanded, + pkConversations, + pkExpanded, + pkRoundTasks, + pkRoundCollapsed, reorderableFolderIds, byFolder, folderExpanded, @@ -2405,6 +2500,62 @@ export function SidebarConversationList({
) } + if (row.kind === "pk-empty") { + return ( +
+ {t("noPk")} +
+ ) + } + if (row.kind === "pk-round") { + const collapsed = pkRoundCollapsed.has(row.roundId) + const roundStatus = pkRounds.find( + (round) => Number(round.id) === row.roundId + )?.status + const canArchive = roundStatus !== "running" && roundStatus !== "ready" + return ( +
+ + + {canArchive ? ( + + ) : null} +
+ ) + } if (row.kind === "recent-more") { // Footer of the paged Recent section — a row, not a hint: each click // reveals another page. Its geometry is the conversation card's, so the @@ -2510,6 +2661,8 @@ export function SidebarConversationList({ if (row.kind === "folders-empty") return "folders-empty" if (row.kind === "recent-empty") return "recent-empty" if (row.kind === "recent-more") return "recent-more" + if (row.kind === "pk-empty") return "pk-empty" + if (row.kind === "pk-round") return `pk-round-${row.roundId}` const prefix = row.recent ? "recent-" : "" if (row.kind === "subsession-loading") { return `${prefix}subloading-${row.parentId}` diff --git a/src/components/conversations/sidebar-section-header.tsx b/src/components/conversations/sidebar-section-header.tsx index 2556f8ac4..e0ef0b0a3 100644 --- a/src/components/conversations/sidebar-section-header.tsx +++ b/src/components/conversations/sidebar-section-header.tsx @@ -82,11 +82,13 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({ const label = section === "pinned" ? t("sectionPinned") - : section === "chats" - ? t("sectionChats") - : section === "recent" - ? t("sectionRecent") - : t("sectionFolders") + : section === "pk" + ? t("sectionPk") + : section === "chats" + ? t("sectionChats") + : section === "recent" + ? t("sectionRecent") + : t("sectionFolders") // "Recent" gets the same right-edge affordance as "Chats": it is a section // people scan to resume work, so "start a new one" belongs at its head too. // The label differs — Chats starts a folderless chat, Recent starts a diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 7d3333c46..ec75b4afd 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -12,7 +12,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react" -import { revealItemInDir, subscribe } from "@/lib/platform" +import { isLocalDesktop, revealItemInDir, subscribe } from "@/lib/platform" import ignore from "ignore" import { Check, ChevronRight, Link2 } from "lucide-react" import { useTranslations } from "next-intl" @@ -822,11 +822,13 @@ function RenderNode({ {t("openIn")} - void handleOpenInSystemExplorer()} - > - {systemExplorerLabel} - + {isLocalDesktop() && ( + void handleOpenInSystemExplorer()} + > + {systemExplorerLabel} + + )} void onOpenDirInTerminal(dirPath, node.name)} > @@ -1058,11 +1060,13 @@ function RenderNode({ {t("openIn")} - void handleOpenDirInSystemExplorer()} - > - {systemExplorerLabel} - + {isLocalDesktop() && ( + void handleOpenDirInSystemExplorer()} + > + {systemExplorerLabel} + + )} void onOpenDirInTerminal(absolutePath, node.name)} > @@ -3002,13 +3006,15 @@ export function FileTreeTab() { {t("openIn")} - { - void revealItemInDir(folder.path) - }} - > - {systemExplorerLabel} - + {isLocalDesktop() && ( + { + void revealItemInDir(folder.path) + }} + > + {systemExplorerLabel} + + )} { void handleOpenDirInTerminal( diff --git a/src/components/layout/aux-panel-session-details-tab.test.tsx b/src/components/layout/aux-panel-session-details-tab.test.tsx index 70a540a13..763f51de0 100644 --- a/src/components/layout/aux-panel-session-details-tab.test.tsx +++ b/src/components/layout/aux-panel-session-details-tab.test.tsx @@ -44,6 +44,7 @@ const mockWorkspace = useAppWorkspaceStore as unknown as Mock type TabSlice = { tabs: Array<{ id: number + kind: "conversation" conversationId: number | null runtimeConversationId?: number }> @@ -79,7 +80,9 @@ function setupScene(opts: { hasActiveConversation: boolean }) { mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" }) const tabState: TabSlice = { - tabs: opts.hasActiveConversation ? [{ id: 1, conversationId: 7 }] : [], + tabs: opts.hasActiveConversation + ? [{ id: 1, kind: "conversation", conversationId: 7 }] + : [], activeTabId: opts.hasActiveConversation ? 1 : null, } mockTabs.mockImplementation((sel: (s: TabSlice) => unknown) => sel(tabState)) diff --git a/src/components/layout/aux-panel-session-details-tab.tsx b/src/components/layout/aux-panel-session-details-tab.tsx index 1335f014a..0c75bb372 100644 --- a/src/components/layout/aux-panel-session-details-tab.tsx +++ b/src/components/layout/aux-panel-session-details-tab.tsx @@ -11,6 +11,7 @@ import { resolveActiveSessionDetails } from "@/components/conversations/active-s import { SessionDetailsContent } from "@/components/conversations/session-details-content" import { ScrollArea } from "@/components/ui/scroll-area" import { useAuxPanelContext } from "@/contexts/aux-panel-context" +import { isConversationWorkspaceTab } from "@/lib/workspace-tab" // Stable empty-turns reference so the `useShallow` slice below stays // reference-equal when there's no active session — otherwise a fresh `[]` each @@ -34,13 +35,12 @@ export function SessionDetailsTab() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) - const activeConversationTab = useMemo( - () => - tabs.find( - (tab) => tab.id === activeTabId && tab.conversationId != null - ) ?? null, - [tabs, activeTabId] - ) + const activeConversationTab = useMemo(() => { + const tab = tabs.find((item) => item.id === activeTabId) + return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null + ? tab + : null + }, [tabs, activeTabId]) // A brand-new conversation streams under its virtual `runtimeConversationId` // until it reconciles; key the live-session lookup on it first (mirrors the diff --git a/src/components/pk/pk-arena-host.tsx b/src/components/pk/pk-arena-host.tsx new file mode 100644 index 000000000..8f7183612 --- /dev/null +++ b/src/components/pk/pk-arena-host.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useEffect, useRef } from "react" +import { PkLauncherDialog } from "@/components/pk/pk-launcher-dialog" +import { PkMinimizedPill } from "@/components/pk/pk-minimized-pill" +import { usePkRound, fetchUsage } from "@/hooks/use-pk-round" +import { + usePkArenaStore, + dbRoundToStoreRound, + type PkRound, +} from "@/stores/pk-arena-store" +import { pkRoundList, updateConversationStatus } from "@/lib/api" +import { getPkConversationStatusRepairs } from "@/lib/pk-conversation-reconciliation" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" + +/** + * Arena mount point — renders global launch/minimized controls and drives the + * orchestrator for rounds created by the launcher. Must live inside + * `AcpConnectionsProvider` (the workspace layout provides it): the + * orchestrator calls `connect`/`sendPrompt` and subscribes to `acp://event`. + * + * The launcher only writes the round into the store; this host picks it up, + * so round creation works from anywhere (composer menu, future entries) + * without prop-drilling. + * + * On mount, hydrates the store from the DB so finished rounds' scoreboards and + * diffs remain viewable after a restart. The folder's path is needed to map + * each DB round's folderId to its workingDir. + */ +export function PkArenaHost() { + const { startRound } = usePkRound() + const rounds = usePkArenaStore((s) => s.rounds) + const hydrating = usePkArenaStore((s) => s.hydrating) + const hydrateFromDb = usePkArenaStore((s) => s.hydrateFromDb) + const folders = useAppWorkspaceStore((s) => s.allFolders) + const conversations = useAppWorkspaceStore((s) => s.conversations) + const conversationsLoading = useAppWorkspaceStore( + (s) => s.conversationsLoading + ) + const reconciledConversationIdsRef = useRef(new Set()) + + // Repair persisted PK conversation rows whose lifecycle no longer agrees + // with the authoritative round. This covers both legacy judge rows and + // contestant rows left live by an older cancellation path. The normal + // conversation event updates the sidebar in place. + useEffect(() => { + for (const repair of getPkConversationStatusRepairs( + rounds, + conversations + )) { + if (reconciledConversationIdsRef.current.has(repair.conversationId)) { + continue + } + reconciledConversationIdsRef.current.add(repair.conversationId) + void updateConversationStatus(repair.conversationId, repair.status).catch( + () => { + reconciledConversationIdsRef.current.delete(repair.conversationId) + } + ) + } + }, [conversations, rounds]) + + // Hydrate each store instance once. Fast Refresh can replace the Zustand + // store while preserving this host's React refs; keying the guard by the + // store's rounds array lets the replacement hydrate again without issuing + // duplicate requests during React Strict Mode's repeated effects. + const hydrationSourceRef = useRef(null) + useEffect(() => { + if ( + !hydrating || + hydrationSourceRef.current === rounds || + folders.length === 0 || + conversationsLoading + ) { + return + } + hydrationSourceRef.current = rounds + void (async () => { + try { + const dbRounds = await pkRoundList() + const storeRounds = dbRounds + .map((info) => { + const folder = folders.find((f) => f.id === info.folder_id) + const workingDir = folder?.path ?? "" + return dbRoundToStoreRound(info, workingDir, conversations) + }) + .filter((r) => r.workingDir !== "") + hydrateFromDb(storeRounds) + // Backfill usage for finished contestants — usage is live-only in + // the store (issue #4 / #16), so after a restart it's null. Fetch + // it from the conversation turns for any contestant that has a + // conversationId and is done/error/canceled. + for (const round of storeRounds) { + for (const c of round.contestants) { + if ( + c.conversationId != null && + (c.status === "done" || + c.status === "error" || + c.status === "canceled") + ) { + const usage = await fetchUsage(c.conversationId) + if (usage) { + usePkArenaStore + .getState() + .updateContestant(round.id, c.slot, { usage }) + } + } + } + } + } catch { + hydrateFromDb([]) + } + })() + }, [ + conversations, + conversationsLoading, + folders, + hydrateFromDb, + hydrating, + rounds, + ]) + + // Drive any round that still has contestants in "preparing" — exactly the + // state the launcher leaves behind. Restarted (interrupted) rounds come + // back with settled statuses, so they are never re-driven. + const drivenRef = useRef(new Set()) + useEffect(() => { + if (hydrating) return + for (const round of rounds) { + if (drivenRef.current.has(round.id)) continue + if (!round.contestants.some((c) => c.status === "preparing")) continue + drivenRef.current.add(round.id) + void startRound(round) + } + }, [rounds, startRound, hydrating]) + + return ( + <> + + + + ) +} diff --git a/src/components/pk/pk-arena-policy.test.ts b/src/components/pk/pk-arena-policy.test.ts new file mode 100644 index 000000000..7d97bb4c8 --- /dev/null +++ b/src/components/pk/pk-arena-policy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" +import type { PkRound } from "@/stores/pk-arena-store" +import { getArenaPillRound, getEffortControl } from "./pk-arena-policy" + +describe("PK arena lifecycle policy", () => { + it.each(["finished", "canceled", "interrupted"] as const)( + "does not show a %s round in the minimized entry", + (status) => { + const terminal = { id: "7", status } as PkRound + expect(getArenaPillRound([terminal], "7")).toBeNull() + } + ) + + it("prefers a live round when the active round is terminal", () => { + const finished = { id: "7", status: "finished" } as PkRound + const running = { id: "8", status: "running" } as PkRound + expect(getArenaPillRound([finished, running], "7")).toBe(running) + }) +}) + +describe("PK contestant reasoning capability", () => { + it("shows the exact levels advertised by Qoder", () => { + expect(getEffortControl(["low", "medium"], "reasoning_effort")).toEqual({ + kind: "select", + configId: "reasoning_effort", + options: ["low", "medium"], + }) + }) + + it("shows an unsupported state instead of silently hiding the field", () => { + expect(getEffortControl([], null)).toEqual({ kind: "unsupported" }) + }) +}) diff --git a/src/components/pk/pk-arena-policy.ts b/src/components/pk/pk-arena-policy.ts new file mode 100644 index 000000000..734cc49c3 --- /dev/null +++ b/src/components/pk/pk-arena-policy.ts @@ -0,0 +1,31 @@ +import type { PkRound } from "@/stores/pk-arena-store" + +const isLiveRound = (round: PkRound) => + round.status === "ready" || round.status === "running" + +/** Pick the live round represented by the minimized entry. */ +export function getArenaPillRound( + rounds: readonly PkRound[], + activeRoundId: string | null +): PkRound | null { + const activeRound = rounds.find((round) => round.id === activeRoundId) + if (activeRound && isLiveRound(activeRound)) return activeRound + return rounds.find(isLiveRound) ?? null +} + +export type PkEffortControl = + | { + kind: "select" + configId: string + options: readonly string[] + } + | { kind: "unsupported" } + +/** Preserve each agent's advertised effort levels; never invent global ones. */ +export function getEffortControl( + options: readonly string[], + configId: string | null +): PkEffortControl { + if (!configId || options.length === 0) return { kind: "unsupported" } + return { kind: "select", configId, options } +} diff --git a/src/components/pk/pk-arena-view.tsx b/src/components/pk/pk-arena-view.tsx new file mode 100644 index 000000000..c7877664f --- /dev/null +++ b/src/components/pk/pk-arena-view.tsx @@ -0,0 +1,564 @@ +"use client" + +import { memo, useEffect, useMemo, useState } from "react" +import { useLocale, useTranslations } from "next-intl" +import { toast } from "sonner" +import { ExternalLink } from "lucide-react" +import { LiveTranscriptView } from "@/components/message/live-transcript-view" +import { PkDiffView } from "@/components/pk/pk-diff-view" +import { PkJudgePanel } from "@/components/pk/pk-judge-panel" +import { PkScoreboard } from "@/components/pk/pk-scoreboard" +import { PkHistoryPicker } from "@/components/pk/pk-history-picker" +import { getEffortControl } from "@/components/pk/pk-arena-policy" +import { usePkRound } from "@/hooks/use-pk-round" +import { AgentIcon } from "@/components/agent-icon" +import { getAgentLabel } from "@/lib/custom-agents" +import { buildPkReportHtml } from "@/lib/pk-report" +import { preparePkReportData } from "@/lib/pk-report-data" +import { savePkReportHtml } from "@/lib/pk-report-export" +import { openPkRoundWindow } from "@/lib/api" +import type { PkContestant, PkRound } from "@/stores/pk-arena-store" +import { cn } from "@/lib/utils" +import { usePkArenaStore } from "@/stores/pk-arena-store" +import { useTabActions } from "@/stores/tab-store" + +/** + * The arena itself: scoreboard on top, one live transcript column per + * contestant, a diff tab once the round settles, and a share button that + * saves a complete self-contained HTML battle report. Round switching comes from the store's + * The view is keyed by `roundId`, so multiple rounds can stay open in separate + * workspace tabs or split groups without fighting over one global active id. + */ + +export function PkArenaView({ + roundId, + tabId, +}: { + roundId: string + tabId: string +}) { + const t = useTranslations("PkArena.arena") + const tWindow = useTranslations("SkillsSettings.actions") + const locale = useLocale() + const setPillDismissed = usePkArenaStore((s) => s.setPillDismissed) + const rounds = usePkArenaStore((s) => s.rounds) + const { closeTab } = useTabActions() + + const round = useMemo( + () => rounds.find((r) => r.id === roundId) ?? null, + [rounds, roundId] + ) + + const { + cancelRound, + cleanupRound, + fetchDiff, + disconnectFinished, + startPrompt, + sendFollowUp, + applyContestantSelection, + runJudge, + } = usePkRound() + const markRound = usePkArenaStore((s) => s.markRound) + const retryPersistence = usePkArenaStore((s) => s.retryPersistence) + const [tab, setTab] = useState<"battle" | "diff">("battle") + const [reportExporting, setReportExporting] = useState(false) + const [diffLoading, setDiffLoading] = useState(false) + + // Literal keys — next-intl's typed messages reject dynamic concatenation. + const roundStatusLabel = useMemo( + () => ({ + ready: t("roundStatus.ready"), + running: t("roundStatus.running"), + finished: t("roundStatus.finished"), + canceled: t("roundStatus.canceled"), + interrupted: t("roundStatus.interrupted"), + }), + [t] + ) + const tabLabel = useMemo( + () => ({ battle: t("tabs.battle"), diff: t("tabs.diff") }) as const, + [t] + ) + + // Diff tab: fetch each contestant's worktree diff once per visit. + useEffect(() => { + if (tab !== "diff" || !round) return + let cancelled = false + const pending = round.contestants.filter( + (c) => c.diff == null && c.worktreePath + ) + if (pending.length === 0) return + setDiffLoading(true) + void Promise.allSettled(pending.map((c) => fetchDiff(round, c))).then( + () => { + if (!cancelled) setDiffLoading(false) + } + ) + return () => { + cancelled = true + } + }, [tab, round, fetchDiff]) + + // 状态自愈:任何原因导致回合停在 ready/running 而选手已全部结算 + // (settle 事件漏一帧、重启后回放等),打开竞技场时立即收敛到 finished + // 并断开残留连接——否则顶部状态永远停在"就绪"。 + useEffect(() => { + if (!round) return + const settled = (s: PkContestant["status"]) => + s === "done" || s === "error" || s === "canceled" + if (round.status === "ready" || round.status === "running") { + if ( + round.contestants.length > 0 && + round.contestants.every((c) => settled(c.status)) + ) { + markRound(round.id, "finished") + void disconnectFinished(round) + } + } + }, [round, markRound, disconnectFinished]) + + const handleExportReport = async () => { + if (!round || reportExporting) return + setReportExporting(true) + try { + const fresh = usePkArenaStore + .getState() + .rounds.find((r) => r.id === round.id) + const reportData = await preparePkReportData(fresh ?? round) + const html = buildPkReportHtml( + reportData.round, + reportData.artifactsBySlot, + locale + ) + const result = await savePkReportHtml(html, round.id) + if (result === "saved") toast.success(t("reportSaved")) + } catch (error) { + toast.error(t("reportFailed", { message: String(error) })) + } finally { + setReportExporting(false) + } + } + + const roundLive = round != null && round.status === "running" + const persistenceError = round + ? Object.values(round.persistenceErrors ?? {}) + .filter(Boolean) + .join(" · ") + : "" + + return ( +
+ {round ? ( +
+
+ + PK + +
+
+ {round.task} +
+
+ {roundStatusLabel[round.status]} ·{" "} + {new Date(round.createdAt).toLocaleString()} +
+
+ + {round.status === "ready" || roundLive ? ( + + ) : round.contestants.some((c) => c.worktreePath) ? ( + + ) : null} + + + + +
+ + {persistenceError ? ( +
+ {t("persistenceFailed")} + +
+ ) : null} + + {round.status === "ready" ? ( +
+ + {t("readyNote")} + + +
+ ) : null} +
+ + + {/* Judge verdict panel — shown when a judge is configured. */} + {round.judgeAgent ? ( + void runJudge(round) + : undefined + } + /> + ) : null} +
+ +
+ {(["battle", "diff"] as const).map((key) => ( + + ))} +
+ +
+
+ {tab === "battle" + ? round.contestants.map((contestant) => + round.status === "ready" ? ( + + ) : ( + + void sendFollowUp(round, contestant, message) + } + /> + ) + ) + : round.contestants.map((contestant) => ( + + ))} +
+
+
+ ) : ( +
+ {t("noRound")} +
+ )} +
+ ) +} + +/** + * One battle column. Memoized on stable props: the dialog re-renders on + * every contestant store update (status/usage/diff of ANY contestant), and + * an unmemoized pane re-rendered four streaming markdown transcripts each + * time — the field-reported arena lag. + */ +const PkBattlePane = memo(function PkBattlePane({ + contestant, + conversationId, + connectionId, + agentType, + task, + statusDetail, + preparingLabel, + followUpLabel, + followUpPlaceholder, + onFollowUp, +}: { + contestant: PkContestant + conversationId: number | null + connectionId: string | null + agentType: PkContestant["agentType"] + task: string + statusDetail: string | null + preparingLabel: string + followUpLabel: string + followUpPlaceholder: string + onFollowUp: (message: string) => void +}) { + // The follow-up box shows when the contestant finished its last turn AND + // its connection is still alive (contextKey set). A disconnected contestant + // can't receive a new prompt. + const canFollowUp = + contestant.status === "done" && contestant.contextKey != null + const [followUpText, setFollowUpText] = useState("") + const [sending, setSending] = useState(false) + + const handleSend = async () => { + const trimmed = followUpText.trim() + if (!trimmed || sending) return + setSending(true) + setFollowUpText("") + try { + await onFollowUp(trimmed) + } finally { + setSending(false) + } + } + + return ( +
+ {conversationId != null ? ( + + ) : ( +
+ {statusDetail ?? preparingLabel} +
+ )} + {canFollowUp ? ( +
+
+ {followUpLabel} +
+