diff --git a/.gitignore b/.gitignore index 3885d0aba..040e493f1 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,3 @@ 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 deleted file mode 100644 index a9a4b5e0c..000000000 --- a/docs/PK-ISSUES.md +++ /dev/null @@ -1,501 +0,0 @@ -# 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 deleted file mode 100644 index 900834cae..000000000 --- a/docs/PK-ROADMAP.md +++ /dev/null @@ -1,124 +0,0 @@ -# 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 deleted file mode 100644 index 25b2564af..000000000 --- a/docs/PK-TEST-CASES.md +++ /dev/null @@ -1,775 +0,0 @@ -# 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 deleted file mode 100644 index 6b27b1c19..000000000 --- a/docs/STRATEGY-MEMO.md +++ /dev/null @@ -1,398 +0,0 @@ -# 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 deleted file mode 100644 index 5fc57dae2..000000000 Binary files a/docs/images/pk-arena-light.png and /dev/null differ diff --git a/docs/images/pk-launcher-light.png b/docs/images/pk-launcher-light.png deleted file mode 100644 index 57294bec1..000000000 Binary files a/docs/images/pk-launcher-light.png and /dev/null differ diff --git a/docs/images/pk-report-light.png b/docs/images/pk-report-light.png deleted file mode 100644 index a3013273f..000000000 Binary files a/docs/images/pk-report-light.png and /dev/null differ diff --git a/eslint.config.mjs b/eslint.config.mjs index f995f8134..e5f5a1c31 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,7 +45,6 @@ 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 82a6b3e94..1d3a0a185 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "@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 f7b3755d2..acd129f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,9 +228,6 @@ 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 @@ -2951,9 +2948,6 @@ 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==} @@ -2986,9 +2980,6 @@ 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==} @@ -3991,10 +3982,6 @@ 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'} @@ -4169,7 +4156,6 @@ 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: '*' @@ -5637,9 +5623,6 @@ 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'} @@ -6516,9 +6499,6 @@ 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'} @@ -9436,13 +9416,6 @@ 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': {} @@ -9472,8 +9445,6 @@ snapshots: '@types/statuses@2.0.6': {} - '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': optional: true @@ -10493,8 +10464,6 @@ snapshots: entities@6.0.1: {} - entities@8.0.0: {} - env-paths@2.2.1: {} error-ex@1.3.4: @@ -12637,10 +12606,6 @@ snapshots: dependencies: entities: 6.0.1 - parse5@8.0.1: - dependencies: - entities: 8.0.0 - parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -13797,8 +13762,6 @@ 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 c999fd07b..9f116b0d8 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -6402,17 +6402,11 @@ async fn apply_preferred_session_options( initial_config_options: Vec, ) -> Vec { if let Some(pref_mode) = preferred_mode_id { - let is_advertised = session + let needs_apply = session .modes() .as_ref() - .map(|m| m.available_modes.iter().any(|mode| mode.id.to_string() == pref_mode)) + .map(|m| m.current_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 c759dd32b..ec3ce9065 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -280,28 +280,19 @@ pub(crate) async fn handle_event( return Ok(()); }; if let Some(ts) = target_status.clone() { - // 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(), + // 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, + }, ) - .await?; - if changed { - emit_with_state( - &state_arc, - &emitter, - AcpEvent::ConversationStatusChanged { - conversation_id: cid, - status: ts, - }, - ) - .await; - } + .await; } // If this conversation was spawned by a delegation, resolve the @@ -2098,48 +2089,6 @@ 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 c3a891508..550cbb118 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -1976,7 +1976,6 @@ 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 dec88784d..90641c2ac 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1863,49 +1863,6 @@ 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. @@ -2484,7 +2441,6 @@ 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, } } @@ -5492,7 +5448,6 @@ 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 eaa030207..942796834 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -2021,42 +2021,6 @@ 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(既有调用方行为不变)。 @@ -6463,36 +6427,6 @@ 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 916b66ff9..dcf009cc3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -25,7 +25,6 @@ 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 deleted file mode 100644 index 47503a659..000000000 --- a/src-tauri/src/commands/pk.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! 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 711d778f7..c48009ce2 100644 --- a/src-tauri/src/commands/token_usage.rs +++ b/src-tauri/src/commands/token_usage.rs @@ -1337,7 +1337,6 @@ 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 bb9197ccc..7ea73da10 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -594,56 +594,6 @@ 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 1d465ca6b..ac4c88366 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -21,8 +21,6 @@ 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)")] @@ -36,8 +34,6 @@ pub enum ConversationKind { Loop, #[sea_orm(string_value = "delegate")] Delegate, - #[sea_orm(string_value = "pk")] - Pk, } #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] @@ -62,9 +58,6 @@ 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 eb1dc6825..1506c19c3 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -13,7 +13,6 @@ 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 deleted file mode 100644 index ee04baff0..000000000 --- a/src-tauri/src/db/entities/pk_round.rs +++ /dev/null @@ -1,61 +0,0 @@ -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 d99477d71..aed43e131 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -15,7 +15,6 @@ 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 deleted file mode 100644 index d23f33668..000000000 --- a/src-tauri/src/db/migration/m20260819_000001_pk_round.rs +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index 3e73f6416..000000000 --- a/src-tauri/src/db/migration/m20260819_000002_pk_round_judge.rs +++ /dev/null @@ -1,65 +0,0 @@ -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 79cabefa4..3d2b0dcbd 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -40,8 +40,6 @@ 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] @@ -88,8 +86,6 @@ 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 2e72cd9d9..9f109071c 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -78,46 +78,6 @@ 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, @@ -160,7 +120,6 @@ 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?) } @@ -895,7 +854,6 @@ struct CarriedOverRow { created_at: chrono::DateTime, updated_at: chrono::DateTime, origin_cwd: Option, - pk_round_id: Option, } impl CarriedOverRow { @@ -934,7 +892,6 @@ 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, } } @@ -964,7 +921,6 @@ impl CarriedOverRow { // not to the history. pinned_at: Set(None), origin_cwd: Set(self.origin_cwd), - pk_round_id: Set(self.pk_round_id), } } } @@ -1082,7 +1038,6 @@ 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 e7c87edea..96974036c 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -454,7 +454,6 @@ 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) @@ -1014,7 +1013,6 @@ 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 83b621cda..edda1d933 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -10,7 +10,6 @@ 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 deleted file mode 100644 index 773cd8662..000000000 --- a/src-tauri/src/db/service/pk_round_service.rs +++ /dev/null @@ -1,179 +0,0 @@ -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 d2fb3499f..fe7a573ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,8 +70,7 @@ 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, pk as pk_commands, - project_boot, + model_provider as model_provider_commands, notification, pet as pet_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, @@ -1032,7 +1031,6 @@ 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, @@ -1134,7 +1132,6 @@ 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, @@ -1403,14 +1400,6 @@ 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 9f16ceefc..ac1201496 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -68,11 +68,6 @@ 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 32a73f179..efe2e9821 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -7,7 +7,6 @@ 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; @@ -36,7 +35,6 @@ 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 deleted file mode 100644 index 4835c0815..000000000 --- a/src-tauri/src/models/pk_round.rs +++ /dev/null @@ -1,131 +0,0 @@ -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 a03a70a6d..a46a20fc1 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -270,32 +270,6 @@ 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 5530c9605..97b7d86f9 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -24,7 +24,6 @@ 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 deleted file mode 100644 index 23620b76a..000000000 --- a/src-tauri/src/web/handlers/pk.rs +++ /dev/null @@ -1,135 +0,0 @@ -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 92e83fb81..3d420b0ed 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -139,10 +139,6 @@ 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), @@ -1429,28 +1425,6 @@ 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 031b9e559..07639e886 100644 --- a/src-tauri/tests/delegation_columns.rs +++ b/src-tauri/tests/delegation_columns.rs @@ -38,7 +38,6 @@ 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 7d802fa1c..852464b20 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,7 +13,6 @@ 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", @@ -70,7 +69,6 @@ export default async function RootLayout({ disableTransitionOnChange > - diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 4cf1227a4..1e3a6232a 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -1,35 +1,7 @@ "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() { - 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. */} - - - ) + return } diff --git a/src/components/ai-elements/reasoning.tsx b/src/components/ai-elements/reasoning.tsx index b06e645c5..5e9948e17 100644 --- a/src/components/ai-elements/reasoning.tsx +++ b/src/components/ai-elements/reasoning.tsx @@ -24,13 +24,7 @@ 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" @@ -263,15 +257,8 @@ export const ReasoningContent = memo( plugins={plugins} remarkPlugins={remarkPlugins} {...props} - // 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"], - }} + // Enforce the link icon + safety override after spreading props. + components={markdownLinkComponents} > {normalized} diff --git a/src/components/chat/composer/composer-add-menu.tsx b/src/components/chat/composer/composer-add-menu.tsx index 8c5110a75..4c51a8a5a 100644 --- a/src/components/chat/composer/composer-add-menu.tsx +++ b/src/components/chat/composer/composer-add-menu.tsx @@ -14,7 +14,6 @@ import { Plus, Search, Sparkles, - Swords, Upload, } from "lucide-react" @@ -32,8 +31,6 @@ 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" @@ -399,29 +396,6 @@ 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 469a8c9ac..2c0b5c84a 100644 --- a/src/components/chat/conversation-context-bar.test.tsx +++ b/src/components/chat/conversation-context-bar.test.tsx @@ -28,7 +28,6 @@ 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 @@ -94,14 +93,7 @@ describe("ConversationHeaderFolderPicker", () => { folders: [repo, other], allFolders: [repo, other], }) - tabs = [ - { - id: "tab-draft", - kind: "conversation", - folderId: 1, - conversationId: null, - }, - ] + tabs = [{ id: "tab-draft", folderId: 1, conversationId: null }] activeTabId = "tab-draft" const user = userEvent.setup() @@ -119,14 +111,7 @@ describe("ConversationHeaderFolderPicker", () => { folders: [repo, other], allFolders: [repo, other], }) - tabs = [ - { - id: "tab-1", - kind: "conversation", - folderId: 1, - conversationId: 42, - }, - ] + tabs = [{ id: "tab-1", folderId: 1, conversationId: 42 }] activeTabId = "tab-1" const user = userEvent.setup() @@ -140,13 +125,7 @@ describe("ConversationHeaderFolderPicker", () => { it("shows the chat-mode label for a folderless chat tab", () => { tabs = [ - { - id: "tab-chat", - kind: "conversation", - folderId: 999, - conversationId: null, - isChat: true, - }, + { id: "tab-chat", 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 98a8dbf20..e9d11424b 100644 --- a/src/components/chat/conversation-context-bar.tsx +++ b/src/components/chat/conversation-context-bar.tsx @@ -7,7 +7,6 @@ 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, @@ -126,8 +125,7 @@ export const ConversationHeaderFolderPicker = memo( const ownTab = useMemo(() => { const lookupId = tabId ?? activeTabId - const tab = tabs.find((x) => x.id === lookupId) - return tab && isConversationWorkspaceTab(tab) ? tab : null + return tabs.find((x) => x.id === lookupId) ?? null }, [tabs, tabId, activeTabId]) const ownFolder = useMemo( @@ -252,8 +250,7 @@ export const ConversationFolderBranchPicker = memo( const ownTab = useMemo(() => { const lookupId = tabId ?? activeTabId - const tab = tabs.find((x) => x.id === lookupId) - return tab && isConversationWorkspaceTab(tab) ? tab : null + return tabs.find((x) => x.id === lookupId) ?? null }, [tabs, tabId, activeTabId]) const ownFolder = useMemo( @@ -372,9 +369,7 @@ export function useConversationFolderBranchPickerVisible( const activeTabId = useTabStore((s) => s.activeTabId) const allFolders = useAppWorkspaceStore((s) => s.allFolders) const lookupId = tabId ?? activeTabId - const matchedTab = tabs.find((x) => x.id === lookupId) - const ownTab = - matchedTab && isConversationWorkspaceTab(matchedTab) ? matchedTab : null + const ownTab = tabs.find((x) => x.id === lookupId) ?? 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 deleted file mode 100644 index 5bf1e437f..000000000 --- a/src/components/chunk-load-recovery.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a5721ed73..000000000 --- a/src/components/chunk-load-recovery.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"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 6bb7748bf..f55ee8700 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -284,12 +284,10 @@ 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 && selConversationTab && (") + expect(shellBody).toContain("{isSplit && selTab && (") expect(shellBody).toContain(" { - const tab = s.tabs.find((item) => item.id === tabId) - return tab && isConversationWorkspaceTab(tab) ? tab : null - }) + const ownTab = useTabStore( + (s) => s.tabs.find((tab) => tab.id === tabId) ?? 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 @@ -2231,8 +2228,7 @@ export function ConversationDetailPanel() { } = useTabActions() const newConversation = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) - if (!activeTab || !isConversationWorkspaceTab(activeTab)) return null - if (activeTab.conversationId != null) return null + if (!activeTab || activeTab.conversationId != null) return null const workingDir = activeTab.workingDir ?? folder?.path if (!workingDir) return null return { workingDir, folderId: activeTab.folderId } @@ -2298,10 +2294,9 @@ export function ConversationDetailPanel() { const dbId2 = summary?.id const isOpenInTabs = tabs.some( (tab) => - isConversationWorkspaceTab(tab) && - (tab.conversationId === matchedConversationId || - tab.runtimeConversationId === matchedConversationId || - (dbId2 != null && tab.conversationId === dbId2)) + tab.conversationId === matchedConversationId || + tab.runtimeConversationId === matchedConversationId || + (dbId2 != null && tab.conversationId === dbId2) ) if (isOpenInTabs) return @@ -2320,12 +2315,13 @@ export function ConversationDetailPanel() { ) const hasNoTabs = tabs.length === 0 && !activeTabId - const activeConversationTab = useMemo(() => { - const tab = tabs.find((item) => item.id === activeTabId) - return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null - ? tab - : null - }, [tabs, activeTabId]) + const activeConversationTab = useMemo( + () => + tabs.find( + (tab) => tab.id === activeTabId && tab.conversationId != null + ) ?? null, + [tabs, activeTabId] + ) const canReloadActiveConversation = activeConversationTab != null const handleReloadActiveConversation = useCallback(() => { if (!activeConversationTab) return @@ -2551,21 +2547,18 @@ 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 = - tab.kind === "pk" ? ( - - ) : ( - - ) + const view = ( + + ) return (

tab.id === groupSelection[groupId]) ?? groupTabs[0] ?? null - const selConversationTab = - selTab && isConversationWorkspaceTab(selTab) ? selTab : null - const selTabFolder = selConversationTab - ? allFolders.find((f) => f.id === selConversationTab.folderId) + const selTabFolder = selTab + ? allFolders.find((f) => f.id === selTab.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 @@ -2659,7 +2650,7 @@ export function ConversationDetailPanel() { {touchesRight && }
)} - {isSplit && selConversationTab && ( + {isSplit && selTab && (
)} @@ -2723,7 +2710,7 @@ export function ConversationDetailPanel() { return ( <>
- {!isSplit && activeTab && isConversationWorkspaceTab(activeTab) && ( + {!isSplit && activeTab && ( { expect(shellStart).toBeGreaterThan(-1) const shellBody = source.slice(shellStart, shellStart + 6000) const stripIdx = shellBody.indexOf("{isSplit && (") - const headerIdx = shellBody.indexOf("{isSplit && selConversationTab && (") + const headerIdx = shellBody.indexOf("{isSplit && selTab && (") const contentIdx = shellBody.indexOf( '
' ) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 78dc8650f..18882b924 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -262,54 +262,6 @@ 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 @@ -345,7 +297,6 @@ 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) } @@ -543,33 +494,13 @@ export interface RecentMoreRow { } /** - * 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. + * 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. */ export interface SectionHeaderRow { kind: "section" @@ -607,8 +538,6 @@ export type SidebarRow = | RecentEmptyRow | RecentMoreRow | SubsessionLoadingRow - | PkRoundHeaderRow - | PkEmptyRow const MAX_RENDER_DEPTH = 32 @@ -626,9 +555,6 @@ 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 @@ -758,16 +684,6 @@ 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 @@ -824,10 +740,6 @@ export function buildRows(args: { const { pinned, pinnedExpanded, - pkConversations = EMPTY_PK_MAP, - pkExpanded = true, - pkRoundTasks = EMPTY_ROUND_TASKS, - pkRoundCollapsed = EMPTY_EXPANDED, orderedFolderIds, byFolder, folderExpanded, @@ -900,41 +812,6 @@ 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 @@ -1039,11 +916,6 @@ 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 ad041db3c..8b4ede4ef 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -36,7 +36,6 @@ const store = vi.hoisted(() => ({ activeTabId: null as string | null, tabSpec: [] as Array<{ id: string - kind?: string conversationId: number | null agentType: string folderId: number @@ -736,7 +735,6 @@ 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 de0d784f2..8aa06f888 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -21,7 +21,6 @@ import { ChevronDown, ChevronRight, Download, - Archive, ExternalLink, FolderClosed, FolderGit2, @@ -42,9 +41,7 @@ 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" @@ -114,7 +111,6 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, - selectPkConversationsWithReuse, selectRecentConversationsWithReuse, worktreeChildrenByParent, worktreeHeaderAlias, @@ -801,8 +797,6 @@ export function SidebarConversationList({ const tabs = useTabStore((s) => s.tabs) const { openTab, - openPkRoundTab, - closePkRoundTab, closeConversationTab, closeTabsByFolder, openNewConversationTab, @@ -843,9 +837,7 @@ export function SidebarConversationList({ const selectedConversation = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) const next = - !activeTab || - !isConversationWorkspaceTab(activeTab) || - activeTab.conversationId == null + !activeTab || activeTab.conversationId == null ? null : { id: activeTab.conversationId, agentType: activeTab.agentType } const reused = reuseSelected(selectedConvRef.current, next) @@ -857,7 +849,7 @@ export function SidebarConversationList({ const openTabKeys = useMemo(() => { const next = new Set() for (const tab of tabs) { - if (isConversationWorkspaceTab(tab) && tab.conversationId != null) { + if (tab.conversationId != null) { next.add(`${tab.agentType}:${tab.conversationId}`) } } @@ -884,7 +876,6 @@ 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 @@ -1109,7 +1100,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.kind !== "pk" + (c) => c.pinned_at == null && c.kind !== "chat" ) if (showCompleted) return base return base.filter((c) => c.status !== "completed") @@ -1129,84 +1120,6 @@ 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. @@ -1390,10 +1303,6 @@ 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, @@ -1417,10 +1326,6 @@ export function SidebarConversationList({ [ pinned, pinnedExpanded, - pkConversations, - pkExpanded, - pkRoundTasks, - pkRoundCollapsed, reorderableFolderIds, byFolder, folderExpanded, @@ -2500,62 +2405,6 @@ 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 @@ -2661,8 +2510,6 @@ 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 e0ef0b0a3..2556f8ac4 100644 --- a/src/components/conversations/sidebar-section-header.tsx +++ b/src/components/conversations/sidebar-section-header.tsx @@ -82,13 +82,11 @@ export const SidebarSectionHeader = memo(function SidebarSectionHeader({ const label = section === "pinned" ? t("sectionPinned") - : section === "pk" - ? t("sectionPk") - : section === "chats" - ? t("sectionChats") - : section === "recent" - ? t("sectionRecent") - : t("sectionFolders") + : 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 ec75b4afd..7d3333c46 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 { isLocalDesktop, revealItemInDir, subscribe } from "@/lib/platform" +import { revealItemInDir, subscribe } from "@/lib/platform" import ignore from "ignore" import { Check, ChevronRight, Link2 } from "lucide-react" import { useTranslations } from "next-intl" @@ -822,13 +822,11 @@ function RenderNode({ {t("openIn")} - {isLocalDesktop() && ( - void handleOpenInSystemExplorer()} - > - {systemExplorerLabel} - - )} + void handleOpenInSystemExplorer()} + > + {systemExplorerLabel} + void onOpenDirInTerminal(dirPath, node.name)} > @@ -1060,13 +1058,11 @@ function RenderNode({ {t("openIn")} - {isLocalDesktop() && ( - void handleOpenDirInSystemExplorer()} - > - {systemExplorerLabel} - - )} + void handleOpenDirInSystemExplorer()} + > + {systemExplorerLabel} + void onOpenDirInTerminal(absolutePath, node.name)} > @@ -3006,15 +3002,13 @@ export function FileTreeTab() { {t("openIn")} - {isLocalDesktop() && ( - { - void revealItemInDir(folder.path) - }} - > - {systemExplorerLabel} - - )} + { + 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 763f51de0..70a540a13 100644 --- a/src/components/layout/aux-panel-session-details-tab.test.tsx +++ b/src/components/layout/aux-panel-session-details-tab.test.tsx @@ -44,7 +44,6 @@ const mockWorkspace = useAppWorkspaceStore as unknown as Mock type TabSlice = { tabs: Array<{ id: number - kind: "conversation" conversationId: number | null runtimeConversationId?: number }> @@ -80,9 +79,7 @@ function setupScene(opts: { hasActiveConversation: boolean }) { mockAux.mockReturnValue({ isOpen: true, activeTab: "session_details" }) const tabState: TabSlice = { - tabs: opts.hasActiveConversation - ? [{ id: 1, kind: "conversation", conversationId: 7 }] - : [], + tabs: opts.hasActiveConversation ? [{ id: 1, 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 0c75bb372..1335f014a 100644 --- a/src/components/layout/aux-panel-session-details-tab.tsx +++ b/src/components/layout/aux-panel-session-details-tab.tsx @@ -11,7 +11,6 @@ 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 @@ -35,12 +34,13 @@ export function SessionDetailsTab() { const tabs = useTabStore((s) => s.tabs) const activeTabId = useTabStore((s) => s.activeTabId) - const activeConversationTab = useMemo(() => { - const tab = tabs.find((item) => item.id === activeTabId) - return tab && isConversationWorkspaceTab(tab) && tab.conversationId != null - ? tab - : null - }, [tabs, activeTabId]) + const activeConversationTab = useMemo( + () => + tabs.find( + (tab) => tab.id === activeTabId && tab.conversationId != null + ) ?? 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 deleted file mode 100644 index 8f7183612..000000000 --- a/src/components/pk/pk-arena-host.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"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 deleted file mode 100644 index 7d97bb4c8..000000000 --- a/src/components/pk/pk-arena-policy.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 734cc49c3..000000000 --- a/src/components/pk/pk-arena-policy.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index c7877664f..000000000 --- a/src/components/pk/pk-arena-view.tsx +++ /dev/null @@ -1,564 +0,0 @@ -"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} -
-