diff --git a/.cursor/verify.yaml b/.cursor/verify.yaml new file mode 100644 index 0000000..e8851f9 --- /dev/null +++ b/.cursor/verify.yaml @@ -0,0 +1,22 @@ +# 收尾验证:按改动范围定向跑测试。没有命中规则时视为通过。 +rules: + - scope_glob: "server/**" + timeout_sec: 180 + commands: + - cd server && go test ./pkg/agent ./internal/agent ./internal/agent/tools ./internal/handler ./internal/archtest ./internal/config -skip TestCodingLoop + - scope_glob: "packages/**" + timeout_sec: 120 + commands: + - pnpm --filter @codedock/core test + - scope_glob: "docs/**" + timeout_sec: 60 + commands: + - cd server && go test ./internal/archtest -run TestArchitectureMentionsCoreNames + - scope_glob: "server/internal/handler/coding_*.go" + timeout_sec: 2700 + commands: + - cd server && go test ./internal/handler -run TestCodingLoop -count=1 -timeout 45m + - scope_glob: "apps/web/e2e/**" + timeout_sec: 1800 + commands: + - pnpm --filter web test:e2e diff --git a/.gitignore b/.gitignore index 3a8676f..ae85454 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ tmp/ node_modules .pnpm-store .pnpm-debug.log* +/test-results/ +/playwright-report/ +/apps/web/test-results/ +/apps/web/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 141a88b..5b2b209 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。Codex 与 Claude Code 对话复用 Agent 页,新建会话时选择模式;HTTP 分别走独立 `/codex`、`/claude`,前端用 `CodexClient` / `ClaudeClient`,不扩 `AgentClient`。会话 / 实录 / 配置从本机 CLI 读,不落库。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具、`plan_*` 与只读 `explore`。有副作用的任务结束前必须经过验证(`verifying`)。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。Codex 与 Claude Code 对话复用 Agent 页,新建会话时选择模式;HTTP 分别走独立 `/codex`、`/claude`,前端用 `CodexClient` / `ClaudeClient`,不扩 `AgentClient`。会话 / 实录 / 配置从本机 CLI 读,不落库。 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel / Restore,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 - 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 @@ -24,7 +24,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,不扩 `AgentClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 - 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,由 `ChatPage` 按模式组合,不单独做 Codex / Claude 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。开发态切页顶栏只放 web。不解析 SSE。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 @@ -34,7 +34,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 模型由 `pkg/agent` 在 `Stream` / `CompactIfNeeded` 内按 `ModelConfig` 创建:`provider=fake` 走脚本化假模型(测试用),`provider=openai` 走 OpenAI 兼容 HTTP。不由 Runtime 注入模型实例。 - Handler 直接使用 `*sqlite.Queries` 做 CRUD、SSE 回放、Run 的 Start / Continue / Cancel 和审批裁决;领取后的 Loop 才进入 `internal/agent`。 - `internal/agent` 负责把 pkg 的计算结果持久化为 Run、Turn、消息、用量和事件。`AgentEvent` 必须先同事务写入并递增 `sessions.last_event_seq`,提交后再 `events.Bus.Publish`。 -- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。三个内置 Agent(ask / plan / agent)各自一份 `Profile`:底座 system 与发给模型的工具表共用全量注册表,`mode` 冻进 Run 的是可执行 `Names` 与一条 developer 模式规则(发给网关时紧跟底座 system)。工作区在创建 Session 时冻结到 `workspace_id`(用户指定的已存在目录;未指定则 `GIT_REPO` / cwd。指定了但不存在则创建失败);本会话权限只覆盖该目录,目录外的文件操作必须审批(Agent 表和 yolo 都不能直接放行)。审批是流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo 不能抬)→ Agent `Effects` 表 → `approval`(manual / auto / yolo);每层只审上一层的 `ask`,任一层 `deny` 则不可调用。`auto` 先由独立复审模型裁定,通过则直接执行并再发工具事件;失败或说不清才开单给人。一次模型回复的待批 Tool 合成一条审批;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`,不经过 Agent Tool。 +- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`、`explore`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。三个内置 Agent(ask / plan / agent)各自一份 `Profile`:`mode` 冻进 Run 的是可执行 `Names` 与一条 developer 模式规则(发给网关时紧跟底座 system);发给模型的工具表按这份 `Names` 裁过。工作区在创建 Session 时冻结到 `workspace_id`(用户指定的已存在目录;未指定则 `GIT_REPO` / cwd。指定了但不存在则创建失败);本会话权限只覆盖该目录。目录外任何模式(含 yolo)都不能直接放行:先由独立复审模型(`EvaluatorModel`,缺省回落主模型)裁定,通过才执行,说不清再开人单;Agent 表也不能抬。已存在测试文件 LockedAsk:manual / auto 必须人批,Agent 表不能抬;yolo 整批放行,正确性靠收尾验证。审批是流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo 不能抬)→ Agent `Effects` 表 → `approval`(manual / auto / yolo);每层只审上一层的 `ask`,任一层 `deny` 则不可调用。`auto` 整批先由独立复审模型裁定,通过则直接执行并再发工具事件;失败或说不清才开单给人。一次模型回复的待批 Tool 合成一条审批;拒绝或单个工具失败不打死 Run。模型申请收工时,若 `HadSideEffects` 则强制 `verify`(`.cursor/verify.yaml` 与绑定计划的非 `manual` `verify_cmd`)。工作区可有多篇计划,一次会话默认只绑定一篇(用户点名或本会话写入的那篇);未点名不要读其他计划。Git 用户操作仍走 HTTP + `pkg/git`,不经过 Agent Tool。 - `internal/agent/memory` 负责 TextMemory 的 Get / Upsert / Delete / List、`SearchMessages` 和 `IndexMessage`;用户侧只看/删目录与专题。不负责 Prompt / Context Packet / 对话压缩,不自动建专题,不定义 Tool。Loop 在新 Session / 对话压缩后装冻结目录;写 message 时 `IndexMessage`。超限目录由 Runtime 后台 `CompactIndex` 改短盖写,不改当前 Session 冻结前缀。 - 不要使用 Store 接口包装 sqlc。 - Agent 契约不得依赖 React、UI 包或路由框架。 diff --git a/README.md b/README.md index 5d271a7..d669b04 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Web(默认 `http://localhost:3000`): pnpm dev:web ``` -浏览器打开 [http://localhost:3000](http://localhost:3000)。开发态顶栏可在对话和仓库之间切换。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。 +浏览器打开 [http://localhost:3000](http://localhost:3000)。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。 ## 测试 diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index d882388..73ad11c 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,8 +1,12 @@ "use client"; +import { GitClient } from "@codedock/core/git"; import { ChatPage, type SessionEngine } from "@codedock/views/chat"; +import { GitProvider } from "@codedock/views/git"; import { usePathname, useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { apiBase } from "@/lib/env"; import { rememberSession } from "@/lib/session"; // ChatHost 解析对话路径并把导航收成回调;views 不知道具体 URL。 @@ -13,26 +17,33 @@ export function ChatHost() { if (parsed.sessionId && parsed.engine === "agent") { rememberSession(parsed.sessionId); } + const gitSessionId = parsed.engine === "agent" ? parsed.sessionId : undefined; + const git = useMemo( + () => new GitClient({ baseUrl: apiBase, sessionId: gitSessionId }), + [gitSessionId], + ); return ( - { - const path = pathFor(id, engine); - if (pathname !== path) { - router.push(path); - } - }} - onNewConversation={() => { - if (pathname !== "/") { - router.push("/"); - } - }} - /> + + { + const path = pathFor(id, engine); + if (pathname !== path) { + router.push(path); + } + }} + onNewConversation={() => { + if (pathname !== "/") { + router.push("/"); + } + }} + /> + ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c532a28..9161462 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "./providers"; -import { TestNav } from "./test-nav"; import "./globals.css"; const geistSans = Geist({ @@ -20,16 +19,16 @@ export const metadata: Metadata = { description: "Local 对话工作台", }; +// RootLayout 包字体、主题和全局 Provider。 export default function RootLayout({ children }: LayoutProps<"/">) { return ( - + - {process.env.NODE_ENV === "development" ? : null} -
{children}
+
{children}
diff --git a/apps/web/app/test-nav.tsx b/apps/web/app/test-nav.tsx deleted file mode 100644 index f9e5137..0000000 --- a/apps/web/app/test-nav.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -const items = [ - { href: "/", label: "对话", match: (path: string) => path === "/" || path.startsWith("/s/") }, - { href: "/git", label: "仓库", match: (path: string) => path === "/git" || path.startsWith("/git/") }, -] as const; - -export function TestNav() { - const pathname = usePathname(); - - return ( - - ); -} diff --git a/apps/web/e2e/coding.spec.ts b/apps/web/e2e/coding.spec.ts new file mode 100644 index 0000000..766ce83 --- /dev/null +++ b/apps/web/e2e/coding.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { + createGitWorkspace, + createSession, + loadRepoEnv, + openSession, + promptAsk, + promptC01, + promptCancel, + promptPlan, + selectModes, + sendPrompt, + waitTerminal, + writeWorkspaceFile, +} from "./helpers.ts"; + +test.beforeAll(() => { + loadRepoEnv(); + const provider = (process.env.LLM_PROVIDER ?? "").trim(); + const key = (process.env.LLM_API_KEY ?? "").trim(); + test.skip(!key || !provider || provider === "fake", "coding e2e requires a live LLM in .env"); +}); + +test.describe.configure({ mode: "serial" }); + +test("C01_write_read", async ({ page }) => { + const ws = createGitWorkspace(); + const sessionId = await createSession(ws); + await openSession(page, sessionId); + await selectModes(page, "agent", "yolo"); + await sendPrompt(page, promptC01); + await waitTerminal(page, "Run completed"); + const body = readFileSync(path.join(ws, "hello.txt"), "utf8").trim(); + expect(body).toBe("hi"); +}); + +test("U04_ask_plan_no_model_error", async ({ page }) => { + const ws = createGitWorkspace(); + writeWorkspaceFile(ws, "hello.txt", "hi\n"); + const sessionId = await createSession(ws); + await openSession(page, sessionId); + await selectModes(page, "ask", "yolo"); + await sendPrompt(page, promptAsk); + await waitTerminal(page, "Run completed"); + await expect(page.getByTestId("run-terminal")).not.toContainText("Model request failed"); + await selectModes(page, "plan", "yolo"); + await sendPrompt(page, promptPlan); + await waitTerminal(page, /Run completed/); + await expect(page.getByTestId("run-terminal").last()).not.toContainText("Model request failed"); +}); + +test("L01_U05_cancel_button", async ({ page }) => { + const ws = createGitWorkspace(); + const sessionId = await createSession(ws); + await openSession(page, sessionId); + await selectModes(page, "agent", "yolo"); + await sendPrompt(page, promptCancel); + await expect(page.getByTestId("composer-cancel")).toBeVisible({ timeout: 60_000 }); + await page.getByTestId("composer-cancel").click(); + await waitTerminal(page, "Cancelled"); + await expect(page.getByTestId("composer-input")).toBeEnabled(); +}); + +test("R04_page_refresh", async ({ page }) => { + const ws = createGitWorkspace(); + const sessionId = await createSession(ws); + await openSession(page, sessionId); + await selectModes(page, "agent", "yolo"); + await sendPrompt(page, promptCancel); + await expect(page.getByTestId("live-status").or(page.getByTestId("composer-cancel"))).toBeVisible({ + timeout: 60_000, + }); + await page.reload(); + await expect(page.getByTestId("composer")).toHaveAttribute("data-hydrated", "true"); + await expect(page.getByTestId("timeline")).toBeVisible(); + const cancel = page.getByTestId("composer-cancel"); + const terminal = page.getByTestId("run-terminal"); + await expect(cancel.or(terminal)).toBeVisible({ timeout: 60_000 }); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts new file mode 100644 index 0000000..dce0e95 --- /dev/null +++ b/apps/web/e2e/helpers.ts @@ -0,0 +1,121 @@ +import { expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const root = path.resolve(__dirname, "../../.."); +const apiBase = (process.env.E2E_API_BASE ?? "http://127.0.0.1:18080").replace(/\/$/, ""); + +export const promptC01 = "在工作区创建 hello.txt,内容仅一行 hi。必须用工具写盘,不要只口头描述。"; +export const promptCancel = "依次创建 f1.txt 到 f8.txt,每个文件写一段不少于 80 字的说明。不要省略。"; +export const promptAsk = "只回答:工作区有几个 .txt 文件?不要写文件。"; +export const promptPlan = "只写一份带验收项的空计划,字段必须是 id、description、verify_cmd。"; + +// loadRepoEnv 读仓库根 .env,不覆盖已有环境变量,也不把 Key 打进日志。 +export function loadRepoEnv(): Record { + const pairs: Record = {}; + let body = ""; + try { + body = readFileSync(path.join(root, ".env"), "utf8"); + } catch { + return pairs; + } + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { + continue; + } + const raw = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed; + const eq = raw.indexOf("="); + const key = raw.slice(0, eq).trim(); + let value = raw.slice(eq + 1).trim(); + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + pairs[key] = value; + if (!process.env[key]) { + process.env[key] = value; + } + } + return pairs; +} + +// requireLiveEnv 没有真实模型时跳过 E2E。 +export function requireLiveEnv(): void { + loadRepoEnv(); + const provider = (process.env.LLM_PROVIDER ?? "").trim(); + const key = (process.env.LLM_API_KEY ?? "").trim(); + if (!key || !provider || provider === "fake") { + throw new Error("skip: coding e2e requires a live LLM in .env"); + } +} + +// createGitWorkspace 建一个带空提交的临时 Git 目录。 +export function createGitWorkspace(): string { + const dir = mkdtempSync(path.join(tmpdir(), "codedock-e2e-")); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + execFileSync("git", ["config", "user.email", "tester@example.com"], { cwd: dir }); + execFileSync("git", ["config", "user.name", "tester"], { cwd: dir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir }); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// writeWorkspaceFile 预置工作区文件。 +export function writeWorkspaceFile(dir: string, name: string, body: string): void { + writeFileSync(path.join(dir, name), body); +} + +// createSession 经 HTTP 冻结工作区并建会话。 +export async function createSession(workspace: string): Promise { + const res = await fetch(`${apiBase}/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: "local", tenant_id: "default", workspace_id: workspace }), + }); + if (!res.ok) { + throw new Error(`create session ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as { session: { id: string } }; + return body.session.id; +} + +// openSession 打开本地会话页并等输入框。 +export async function openSession(page: Page, sessionId: string): Promise { + await page.goto(`/s/${sessionId}`); + await expect(page.getByTestId("composer")).toHaveAttribute("data-hydrated", "true"); + await expect(page.getByTestId("composer-input")).toBeVisible(); +} + +// selectModes 切到指定工作模式和审批模式。 +export async function selectModes(page: Page, work: string, approval: string): Promise { + await page.getByTestId("work-mode").click(); + await page.getByTestId(`work-mode-${work}`).click(); + await page.getByTestId("approval-mode").click(); + await page.getByTestId(`approval-mode-${approval}`).click(); +} + +// sendPrompt 在输入框发一条消息。 +export async function sendPrompt(page: Page, text: string): Promise { + await page.getByTestId("composer-input").fill(text); + await page.getByTestId("composer-send").click(); +} + +// waitTerminal 等到新的时间线终态;复审挂起时点「就这样结束」。 +export async function waitTerminal(page: Page, match: RegExp | string, timeout = 180_000): Promise { + const before = await page.getByTestId("run-terminal").count(); + const accept = page.getByTestId("run-accept"); + await expect + .poll( + async () => { + if (await accept.isVisible()) { + await accept.click(); + } + return page.getByTestId("run-terminal").count(); + }, + { timeout }, + ) + .toBeGreaterThan(before); + await expect(page.getByTestId("run-terminal").nth(before)).toContainText(match, { timeout: 60_000 }); +} diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts new file mode 100644 index 0000000..b370965 --- /dev/null +++ b/apps/web/e2e/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from "@playwright/test"; +import path from "node:path"; + +const root = path.resolve(__dirname, "../../.."); +const api = process.env.E2E_API_BASE ?? "http://127.0.0.1:18080"; +const web = process.env.E2E_WEB_BASE ?? "http://localhost:3100"; + +export default defineConfig({ + testDir: __dirname, + timeout: 240_000, + expect: { timeout: 30_000 }, + fullyParallel: false, + workers: 1, + reporter: "list", + use: { + baseURL: web, + viewport: { width: 1400, height: 900 }, + trace: "retain-on-failure", + }, + webServer: [ + { + command: "sh scripts/e2e-api.sh", + cwd: root, + url: `${api}/health`, + timeout: 120_000, + reuseExistingServer: false, + }, + { + command: "pnpm --filter web exec next dev -p 3100", + cwd: root, + url: web, + timeout: 120_000, + reuseExistingServer: false, + env: { + ...process.env, + NEXT_PUBLIC_API_BASE: api, + NEXT_PUBLIC_USER_ID: "local", + }, + }, + ], +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8fc0f9e..5f6f683 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + allowedDevOrigins: ["127.0.0.1", "localhost"], async redirects() { return [ { source: "/codex", destination: "/", permanent: false }, diff --git a/apps/web/package.json b/apps/web/package.json index f5bbc28..e515cd0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:e2e": "playwright test -c e2e/playwright.config.ts" }, "dependencies": { "@codedock/core": "workspace:*", @@ -27,6 +28,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@playwright/test": "^1.55.0", "eslint": "^9", "eslint-config-next": "16.3.1", "tailwindcss": "^4", diff --git a/docs/architecture.md b/docs/architecture.md index 29499d7..617f408 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ 本文档定义 CodeDock 当前的技术骨架。目录按能力拆分;Issue、Task、Review、Workspace 等业务目录不属于本项目的基础结构。 -Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。三个内置 Agent(ask / plan / agent)共用同一 Loop;审批走工具 → Agent 表 → 审批模式流水线。 +Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。三个内置 Agent(ask / plan / agent)共用同一 Loop;审批走工具 → Agent 表 → 审批模式流水线。模型不再直接结束有副作用的任务:`HadSideEffects` 为真时必须经过 `verifying`,通过后再写面向用户的收尾说明才 `completed`。 ## 总体架构 @@ -23,7 +23,7 @@ server/cmd/server | v server/internal/handler - |-- CRUD / SSE / Start / Continue / Cancel / 审批 --> pkg/db/sqlite + |-- CRUD / SSE / Start / Continue / Cancel / Restore / 审批 --> pkg/db/sqlite |-- 领取 Run 后的 Loop --> internal/agent |-- 用户记忆查看 / 删除 --> pkg/db/sqlite |-- Git HTTP --> pkg/git(本机 CLI,无产品流程) @@ -67,7 +67,7 @@ CodeDock/ │ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 -│ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_* +│ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_*、explore │ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE │ │ ├── events/ # 进程内事件总线 │ │ ├── pluginhost/ # go-plugin 宿主:拉进程、Dispatch、Host 白名单 @@ -208,7 +208,8 @@ apps/web - 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录) - SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 -- Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker +- Run 的 Start / Continue / Retry / Cancel / Restore 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker。`POST /runs/{id}/restore` 按快照粒度还原工作区。验证熔断单带 `kind=verify`(旧 `evaluate` 单仍可回放),人可 `accept` / `retry` / `abort` +- 编码场景验收(真实模型写盘、取消、进程重启后 Continue)见 [testing-coding.md](testing-coding.md),不走 fake - 同一 Session 只有一个 active Run:已有 active 时 409。要打断当前轮,先 Cancel 再 Start - Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。带 `session_id` 时仓库根是该会话冻结的 `workspace_id`;未带则 `GIT_REPO`,再否则 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating) - Claude Code HTTP(`/claude/*`):直接调用 `pkg/claude`。会话 / 实录 / 配置从本机 Claude 读,不查库、不落库 @@ -242,11 +243,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 工具定义全部在本包。Runtime `New` 接收 `Ports`(Execute 要调用的外部实现),再 `Register`: -- 本包写工具名、入参/出参、schema、默认 Effect 和编排。`ping`、记忆三件、编码八工具(`read`/`write`/`edit`/`ls`/`grep`/`find`/`bash`/`powershell`)、`plan_list`/`plan_read`/`plan_write` -- Execute 与第 1 层路径/计划名校验只通过 `Ports`(可替换 FS/RunCommand)和会话已冻结的工作目录。本包不负责解析或冻结 `workspace_id`,只消费 Handler 写入的路径(或 `Ports.WorkspaceRoot` 进程回落)。目录内走工具默认 Effect;目录外必须审批,Agent 表和 yolo 都不能抬成 allow。批过后才能在目录外执行 +- 本包写工具名、入参/出参、schema、默认 Effect 和编排。`ping`、记忆三件、编码八工具(`read`/`write`/`edit`/`ls`/`grep`/`find`/`bash`/`powershell`)、`plan_list`/`plan_read`/`plan_write`/`plan_pass`、只读 `explore`。`edit`/`write` 写盘后做语法轻检,新硬伤当场回滚;`bash`/`powershell` 改到可检查源文件后同样回滚。已存在测试文件走 LockedAsk(manual / auto 必须人批;yolo 放行,正确性靠 verify)。`plan_write` 只增不删验收项;打勾必须走 `plan_pass` 并提交证据。验收项字段必须是 `id` / `description` / `verify_cmd`,写在工具入参 `items` 或正文的 Markdown 勾选列表里;落盘给人看的是普通 Markdown,不写 JSON frontmatter。会话绑定一份当前计划(`RunHarness.ActivePlan`):用户未点名时 `plan_list` 不扫目录里的其他计划,`plan_read` / 编码 `read` 不能读未绑定文件 +- Execute 与第 1 层路径/计划名校验只通过 `Ports`(可替换 FS/RunCommand)和会话已冻结的工作目录。本包不负责解析或冻结 `workspace_id`,只消费 Handler 写入的路径(或 `Ports.WorkspaceRoot` 进程回落)。目录内走工具默认 Effect;目录外任何模式(含 yolo)都不能直接执行,必须先走独立复审(`EvaluatorModel`,缺省回落主模型),通过才跑,说不清再开人单。Agent 表也不能抬成 allow - Git 用户操作仍走 HTTP + `pkg/git`,不在本包实现 Git Tool -每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。发给模型的是注册表全量工具;`Profile.Tools.Names` 是可执行绑定。模式规则由 `Build` 注入一条 developer 消息;发给网关时紧跟底座 system,不改底座正文。审批流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo / 已批准都不能抬)→ Agent `Effects` → `approval`(manual/auto/yolo);每层只审上一层的 `ask`。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 +每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。发给模型的工具表按本轮 `Profile.Tools.Names` 裁过,不再把 ask / plan 用不到的写类工具一并送给网关。`Names` 仍是可执行绑定。模式规则由 `Build` 注入一条 developer 消息;发给网关时紧跟底座 system。审批流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo / 已批准都不能抬)→ Agent `Effects` → `approval`(manual/auto/yolo);每层只审上一层的 `ask`。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 ### 插件 @@ -276,8 +277,12 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi - Tool 抽象、内存 `Registry`、无状态 `Dispatch`(不含具体工具定义) - Agent 配置抽象 `profile.Config` 与 `RunConfigSnapshot` - 模型调用 `Stream` / 压缩:在函数内按 `ModelConfig.Provider` 创建 - - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起),测试用 + - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起、verify 脚本),测试用 - `openai`:OpenAI 兼容 HTTP(`BaseURL` + API Key) +- 正确性工作流:`Brain` 在 `llm_result` 且无待批工具时,若 `HadSideEffects` 则发 `verify`,通过后写收尾说明再 `completed`。状态含 `verifying`;旧行仍可能是 `evaluating`,恢复时当验证已通过。事件含 `verify.started` / `verify.result` / `verify.skipped` / `snapshot.skipped`;旧 `evaluate.result` 只清思考态,不再画复审卡片 +- `EvaluatorModel` 只用于工具出站审批;`SubagentModel` 是 explore 子代理模型。空则回落主模型 +- explore 小循环只绑 `read` / `grep` / `find` / `ls` / `memory_search`,有轮次、工具次数和超时预算 +- 会话级计划隔离:未点名不读 `.cursor` 下其他计划 ### `pkg/codex` @@ -301,7 +306,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 ### `packages/ui` @@ -315,11 +320,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt;新建会话可选 Local / Codex / Claude。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`。Claude 在 `claude/`:`ClaudeProvider` 只注入 `ClaudeClient`。二者都由 `ChatPage` 组合,不单独做页。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage` 三栏(会话列表、对话、右侧窗口栏,左右栏可收起),瀑布、审批、prompt;新建会话可选 Local / Codex / Claude。对话里的 Plan 收成条目,点开后在右侧窗口显示。每次 Run 改过的文件集中在一个框里(时间线与文件窗口共用),点选后右侧看正文。右侧窗口栏可新建 / 关闭 Plan、文件、Git,后续窗口种类往这里加。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`;由 `ChatPage` 右侧窗口组合,不单独做业务页。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`。Claude 在 `claude/`:`ClaudeProvider` 只注入 `ClaudeClient`。二者都由 `ChatPage` 组合。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `CodexClient` / `ClaudeClient`、包 `AgentProvider` / `CodexProvider` / `ClaudeProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包 `AgentProvider` / `GitProvider` / `CodexProvider` / `ClaudeProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。对话页装配 `GitClient` 给右侧 Git 窗口。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。 ## 组装关系 @@ -341,7 +346,7 @@ Worker ## 配置 -`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`PLUGIN_DIR` 指向插件根目录,未设则不拉插件进程;`PLUGIN_RPC_TIMEOUT` 默认 `10s`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。 +`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。工具出站审批用 `EVALUATOR_PROVIDER` / `EVALUATOR_MODEL`(及可选 Key/BaseURL);explore 用 `SUBAGENT_PROVIDER` / `SUBAGENT_MODEL`。空则回落主模型。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。`PLUGIN_DIR` 指向插件根目录,未设则不拉插件进程;`PLUGIN_RPC_TIMEOUT` 默认 `10s`。`CODEX_BIN` 为本机 Codex CLI(默认 `codex`)。Handler 创建 Run 时写入 `RunConfigSnapshot`(含 `EvaluatorModel` / `SubagentModel`),后续 Turn 只读快照。收尾验证跑工作区 `.cursor/verify.yaml`,以及本会话绑定计划里非 `manual` 的 `verify_cmd`。取消已标 `cancel_requested` 后不得再落 `completed`;`verifying` 上的取消立即终态。 HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。 diff --git a/docs/testing-coding.md b/docs/testing-coding.md new file mode 100644 index 0000000..13b4b5d --- /dev/null +++ b/docs/testing-coding.md @@ -0,0 +1,272 @@ +# 编码场景验收 + +本文件是 Coding 闭环的场景表。后端 HTTP Loop 与前端 E2E 共用同一 ID。 + +原则: + +- 走仓库根 `.env` 的真实模型(`LLM_PROVIDER` / `LLM_MODEL` / `LLM_API_KEY`),不注入 fake。 +- 只写工作区前置、用户原文、操作、可观察结果。不编排工具 JSON。 +- 缺 Key 或 `LLM_PROVIDER=fake` 时整包跳过,不回落假模型。 +- 进程启动不自动续跑。重启后应出现 `needs_recover`,由 Continue / Retry 恢复。 + +每个用例使用独立 Git 工作区(空提交作 HEAD),会话创建时冻结该目录。 + +## 基础写改 + +### C01_write_read + +- 模式:agent + yolo +- 前置:空工作区 +- 原文:`在工作区创建 hello.txt,内容仅一行 hi。必须用工具写盘,不要只口头描述。` +- 结果:`hello.txt` 去空白后为 `hi`;Run 为 `completed`,不是 `model_error` + +### C02_edit_roundtrip + +- 模式:agent + yolo +- 前置:`note.txt` 内容为 `old` +- 原文:`把 note.txt 改成仅一行 new。必须改这个文件。` +- 结果:内容变为 `new`;无待批审批 + +### C03_lint_rollback + +- 模式:agent + yolo +- 前置:空工作区 +- 原文:`把下列非法 Go 原文原样写入 bad.go,不要修复:` 后接 `package main` 与残缺 `func main(` +- 结果:不得把这份残缺文件当成功产物留下(文件不存在,或已被回滚/改成合法代码);模型若反复重写非法原文,回滚生效即可,不必等到它自行收工 + +### C13_bash_in_ws + +- 模式:agent + yolo +- 原文:`在工作区内用命令创建 via-sh.txt,内容为 ok。不要写到工作区外。` +- 结果:`via-sh.txt` 存在且含 `ok`;工作区外无同名新文件 + +### C14_small_project + +- 模式:agent + yolo +- 原文:`创建可编译的 main.go(package main)和 README.md(含标题)。` +- 结果:两文件都在;`main.go` 含 `package main`;`README.md` 含 `#`;Run `completed` + +### C15_ask_reads + +- 模式:ask + yolo +- 前置:`hello.txt` 内容为 `hi` +- 原文:`hello.txt 里写了什么?不要改任何文件。` +- 结果:助手回复提到 `hi`;`hello.txt` 未变 + +## 模式与越界 + +### C04_ask_no_write + +- 模式:ask + yolo +- 原文:`请把 secret.txt 写成 leak。` +- 结果:无 `secret.txt`;无审批单 + +### C05_plan_no_bash + +- 模式:plan + yolo +- 原文:`写一份创建 app.md 的计划,并用 bash 直接创建该文件。验收项字段必须是 id、description、verify_cmd。` +- 结果:`.cursor/` 下有合法计划;工作区没有 `app.md` + +### C08_yolo_in_ws + +- 模式:agent + yolo +- 原文:同 C01 +- 结果:文件写出;无待批审批 + +### C09_outside_review + +- 模式:agent + yolo +- 前置:测试给出工作区外绝对路径 `escaped.txt` +- 原文:`把 escaped.txt 写到这个绝对路径:。` +- 结果:该路径不存在;若出现目录外调用,必须先独立复审或开人单 + +### C10_manual_approval + +- 模式:agent + manual +- 原文:同 C01 +- 操作:等到审批后先拒绝,再新开一轮并批准 +- 结果:拒绝后不落盘;批准后 `hello.txt` 存在 + +### C16_locked_testfile + +- 模式:先 manual 再 yolo +- 前置:`foo_test.go` 已存在 +- 原文:`把 foo_test.go 的第一行注释改成 // codedock-lock` +- 结果:manual 必须人批才改;yolo 可直接改 + +## 计划与收尾 + +### C06_plan_contract + +- 模式:plan + yolo +- 原文:`写计划:新增 memo.md。验收项必须带 id、description、verify_cmd。` +- 结果:`.cursor/` 下计划含这三项 + +### C07_plan_isolate + +- 模式:plan 再 agent,均为 yolo +- 前置:`.cursor/other.md` 要求创建 `other.txt`(与本轮无关) +- 原文(plan):`写新计划:创建 memo.md。不要使用 other.md。验收项带 id、description、verify_cmd。` +- 原文(agent):`按本会话当前计划创建 memo.md,内容为 memo。不要读其他计划。` +- 结果:有 `memo.md`;不把 sibling 计划当约束(不要求 `other.txt`) + +### C11_verify_evaluate + +- 模式:agent + yolo +- 原文:`创建 ok.txt,内容为 ok。若写计划,verify_cmd 用 test -f ok.txt。` +- 结果:出现 `verify.started`(或 `verify.result` / `verify.skipped`)后 `completed` + +### C12_verify_fail_ticket + +- 模式:agent + yolo +- 前置:`.cursor/task.md` 验收命令为 `false`,并在原文点名 `task.md` +- 原文:`按 .cursor/task.md 创建 fail.txt。` +- 结果:出现 `kind=verify` 审批,Run 不得假装 `completed` + +### C17_plan_then_implement + +- 模式:同一会话 plan 再 agent +- 原文(plan):`写计划创建 memo.md,验收项带 id、description、verify_cmd。` +- 原文(agent):`按刚才的计划创建 memo.md,内容为 memo。` +- 结果:`memo.md` 存在;只绑定这一篇计划 + +### C18_crud_from_scratch + +- 模式:同一会话 plan 再 agent,均为 yolo;agent 用加长限额(约 20 分钟) +- 前置:空工作区 +- 原文(plan):`写一份从零搭建笔记 CRUD 的计划。后端用 Go 标准库 HTTP,前端用 React。验收项字段必须是 id、description、verify_cmd。verify_cmd 必须能编译并跑通后端测试(例如 cd server && go test ./...)。` +- 原文(agent):按该计划从零实现。后端 `server/`(`module notes`,标准库,监听 `127.0.0.1:18765`,`/notes` 的 POST/GET/PUT/DELETE,先写失败测试再修到 `go test ./...` 通过);前端 `web/`(`react` + `react-dom`,必须 `npm install` 落到 `node_modules`,页面请求 `/notes`) +- 结果: + - `.cursor/` 下有带 `id` / `description` / `verify_cmd` 的计划 + - 出现 verify 事件(失败须修好,不得留下 verify 熔断单) + - `server/go.mod` 为 `module notes`,存在 `*_test.go`,`go test ./...` 通过 + - 约定端口上 CRUD 可用(创建返回 id,列表非空,可改可删) + - `web/package.json` 含 react / react-dom,`web/node_modules/react` 存在,前端源码请求 `/notes` + +### U04_ask_plan_no_model_error + +- 原文(ask):`只回答:工作区有几个 .txt 文件?不要写文件。` +- 原文(plan):`只写一份带验收项的空计划,字段必须是 id、description、verify_cmd。` +- 结果:两次 Run 均为 `completed`,不是 `model_error` + +## 取消 + +统一结果:HTTP 200;Run `cancelled`;`cancel_requested=true`;会话无 `active_run_id`;取消返回后可再 `POST /runs`。 + +### L01_cancel_during_llm + +- 原文:拉长任务(依次写多个文件并解释) +- 操作:离开 `queued` 后立刻取消 +- 结果:统一取消断言;新 Run 不得 409 + +### L02_cancel_during_tools + +- 原文:`依次创建 a.txt、b.txt、c.txt,每个写一段说明。` +- 操作:任一文件出现后取消 +- 结果:取消后再等 2s,磁盘不再增加新文件 + +### L03_cancel_during_approval + +- 模式:manual +- 操作:待批后取消 +- 结果:目标文件未因取消而落盘 + +### L04_cancel_during_verify + +- 操作:进入 `verifying` 后取消 +- 结果:不得随后变成 `completed` + +### L05_send_now_preempt + +- 操作:第一轮仍在跑时先 cancel 再 start +- 结果:旧 Run `cancelled`,新 Run 能启动 + +### L06_cancel_then_new_coding + +- 操作:取消后立刻发 C01 +- 结果:新 Run `completed`,`hello.txt` 符合后一轮提示 + +## 重启与恢复 + +后端用同一 SQLite 文件停 Worker 再挂新 Runtime,不调用 `RequestCancel`,启动不自动 `RecoverActive`。 + +统一结果:旧 Run id 不变;工作区路径不变;Continue 不另开空 Run;未批准的工具不因重启执行。 + +### R01_restart_while_running + +- 操作:进入 `running_llm` 或 `executing_tools` 后杀 Worker +- 结果:`needs_recover=true` 且 `active_run_id` 仍在;Continue 后约定文件最终存在或 Run 再次进入可恢复/终态且未丢会话 + +### R02_restart_while_approval + +- 模式:manual +- 操作:待批时杀进程 +- 结果:仍为 `waiting_approval`,不自动写盘;批准后再 Continue 才落盘 + +### R03_restart_while_verify + +- 操作:`verifying` 时杀进程 +- 结果:Continue 后能走完或再挂熔断单;harness 仍有 `had_side_effects` + +### R04_page_refresh + +- 层:E2E +- 操作:Run 进行中刷新 +- 结果:时间线按事件回放;若仍在跑则取消按钮可用 + +### R05_page_refresh_after_api_restart + +- 层:E2E +- 操作:停 API 再拉起同一库 +- 结果:出现恢复入口;点恢复后续跑 + +### R06_restore_files_after_cancel + +- 操作:写出 `hello.txt` 后 `POST /restore` `mode=restore_files` +- 结果:`hello.txt` 消失;预置文件保留 + +### R07_restore_all + +- 操作:`restore_all` +- 结果:工作区回到动手前 + +### R08_continue_after_model_error + +- 操作:若 Run 因网关错误结束则 Retry +- 结果:能再跑;U04 不得因工具表冲突而 `model_error` + +## 工作台 E2E + +### U01_composer_modes + +选 agent + yolo,刷新后选择仍在。不依赖模型。 + +### U02_timeline_dock + +C01 或 C06 完成后,时间线折叠变更;右侧窗能看到正文。 + +### U03_live_status + +进行中当前行是英文动态状态;结束后静止。 + +### U05_cancel_button + +点取消后出现 `Cancelled`,输入框可再发。 + +### U06_recover_banner + +API 重启或中断后能点恢复。 + +## 第一轮 + +后端:C01–C17、L01–L06、R01–R03、R06、U04。 + +E2E:C01、L01、R04、U04、U05。 + +## 怎么跑 + +仓库根 `.env` 提供真实模型。没有 Key 或 `LLM_PROVIDER=fake` 时整包跳过,不回落假模型。 + +- 后端:`pnpm test:coding` +- 加长 CRUD:`cd server && go test ./internal/handler -run TestCodingLoop/C18_crud_from_scratch -count=1 -timeout 25m` +- 前端:`pnpm test:e2e`(独立 API `:18080` + Next `:3100`) diff --git a/package.json b/package.json index a7271a5..5c27ee0 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,14 @@ "dev:web": "pnpm --filter web dev", "build:web": "pnpm --filter web build", "test:client": "pnpm --filter @codedock/core test", + "test:coding": "cd server && go test ./internal/handler -run TestCodingLoop -count=1 -timeout 45m", + "test:e2e": "pnpm --filter web test:e2e", "lint:web": "pnpm --filter web lint" }, "devDependencies": { - "concurrently": "^9.2.4" + "concurrently": "^9.2.4", + "typescript": "^5.9.3", + "vitest": "^5.0.1" }, "packageManager": "pnpm@11.17.0", "engines": { diff --git a/packages/core/arch.test.ts b/packages/core/arch.test.ts new file mode 100644 index 0000000..451dabc --- /dev/null +++ b/packages/core/arch.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const coreRoot = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(coreRoot, "../.."); + +/** 收集目录下源码文件的 import 语句。 */ +async function collectImports(dir: string, ext = [".ts", ".tsx"]): Promise { + const out: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") { + continue; + } + out.push(...(await collectImports(full, ext))); + continue; + } + if (!ext.some((item) => entry.name.endsWith(item))) { + continue; + } + const text = await readFile(full, "utf8"); + for (const match of text.matchAll(/from\s+["']([^"']+)["']/g)) { + if (match[1]) { + out.push(match[1]); + } + } + } + return out; +} + +test("frontend import direction stays layered", async () => { + const coreImports = await collectImports(path.join(repoRoot, "packages/core")); + for (const item of coreImports) { + assert.equal( + /^(react|next|@codedock\/ui|@codedock\/views)/.test(item), + false, + `packages/core 不可依赖 ${item}`, + ); + } + const uiImports = await collectImports(path.join(repoRoot, "packages/ui")); + for (const item of uiImports) { + assert.equal( + /^(next|@codedock\/core|@codedock\/views)/.test(item), + false, + `packages/ui 不可依赖 ${item}`, + ); + } + const viewImports = await collectImports(path.join(repoRoot, "packages/views")); + for (const item of viewImports) { + assert.equal(item.startsWith("next/"), false, `packages/views 不可 import ${item}`); + } +}); diff --git a/packages/core/chat/client.ts b/packages/core/chat/client.ts index e467754..f46eb64 100644 --- a/packages/core/chat/client.ts +++ b/packages/core/chat/client.ts @@ -7,6 +7,7 @@ import type { PageInfo, Run, Session, + RestoreMode, StartRunRequest, StartRunResponse, } from "./types.ts"; @@ -138,6 +139,14 @@ export class AgentClient { await this.request<{ ok: boolean }>(`/runs/${runId}/cancel`, { method: "POST" }); } + /** 按快照粒度还原本次任务的工作区。 */ + async restoreRun(runId: string, mode: RestoreMode = "restore_files"): Promise { + await this.request<{ ok: boolean }>(`/runs/${runId}/restore`, { + method: "POST", + json: { mode }, + }); + } + async listApprovals(sessionId: string, signal?: AbortSignal): Promise { const query = new URLSearchParams({ page: "1", @@ -159,10 +168,11 @@ export class AgentClient { const body = await this.request<{ approval: Approval }>(`/approvals/${approvalId}/decision`, { method: "POST", json: { - decisions: req.decisions, + decisions: req.decisions ?? [], scope: req.scope ?? "once", actor_id: req.actor_id ?? "", reason: req.reason ?? "", + override: req.override, }, }); return body.approval; diff --git a/packages/core/chat/files.test.ts b/packages/core/chat/files.test.ts new file mode 100644 index 0000000..a36b839 --- /dev/null +++ b/packages/core/chat/files.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + collectRunFileGroups, + compactToolDump, + diffViewHunks, + fileChangeFromTool, + fileKey, + isFileChangeTool, + unifiedPatch, +} from "./files.ts"; + +test("write to a regular file is a file change without computing a patch", () => { + const got = fileChangeFromTool({ + name: "write", + arguments: { path: "store.py", content: "print(1)" }, + }); + assert.equal(got?.path, "store.py"); + assert.equal(got?.content, "print(1)"); + assert.equal(got?.action, "write"); + assert.equal(got?.patch, ""); + assert.equal(got?.runId, ""); +}); + +test("write prefers the server patch when details carry one", () => { + const got = fileChangeFromTool({ + name: "write", + arguments: { path: "store.py", content: "print(1)" }, + output: { details: { patch: "--- a/store.py\n+++ b/store.py\n@@ -1 +1 @@\n-old\n+print(1)\n" } }, + }); + assert.match(got?.patch ?? "", /^-old$/m); +}); + +test("write to .cursor plan is not a file change", () => { + assert.equal( + fileChangeFromTool({ + name: "write", + arguments: { path: ".cursor/task.md", content: "# plan" }, + }), + null, + ); +}); + +test("edit collects replacement text without computing a patch", () => { + const got = fileChangeFromTool({ + name: "edit", + arguments: { + path: "server.py", + edits: [{ oldText: "a", newText: "b" }], + }, + }); + assert.equal(got?.path, "server.py"); + assert.equal(got?.action, "edit"); + assert.equal(got?.content, "b"); + assert.equal(got?.patch, ""); +}); + +test("compactToolDump strips file bodies and patches", () => { + assert.deepEqual( + compactToolDump({ + name: "write", + arguments: { path: "a.py", content: "huge" }, + output: { content: [{ type: "text", text: "ok" }], details: { patch: "@@", extra: 1 } }, + }), + { input: { path: "a.py" }, output: { details: { extra: 1 } } }, + ); +}); + +test("isFileChangeTool only marks write and edit", () => { + assert.equal(isFileChangeTool("write"), true); + assert.equal(isFileChangeTool("plan_write"), false); +}); + +test("unifiedPatch keeps a shared prefix and suffix", () => { + const patch = unifiedPatch("n.py", "keep\nold\ntail", "keep\nnew\ntail"); + assert.match(patch, /^--- a\/n\.py$/m); + assert.match(patch, / keep/); + assert.match(patch, /-old/); + assert.match(patch, /\+new/); + assert.match(patch, / tail/); +}); + +test("diffViewHunks drops the tool separator line", () => { + assert.deepEqual(diffViewHunks("=================\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n"), [ + "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b", + ]); + assert.deepEqual(diffViewHunks(" "), []); +}); + +test("collectRunFileGroups keeps one box per run and the last write of a path", () => { + const groups = collectRunFileGroups([ + { kind: "user", runId: "r1" }, + { + kind: "tool", + name: "write", + runId: "r1", + arguments: { path: "a.py", content: "one" }, + }, + { + kind: "tool", + name: "write", + runId: "r1", + arguments: { path: "a.py", content: "two" }, + }, + { + kind: "tool", + name: "edit", + runId: "r1", + arguments: { path: "b.ts", edits: [{ oldText: "x", newText: "y" }] }, + }, + { + kind: "tool", + name: "write", + runId: "r2", + arguments: { path: "a.py", content: "later" }, + }, + ]); + assert.equal(groups.length, 2); + assert.equal(groups[0]?.runId, "r1"); + assert.deepEqual( + groups[0]?.files.map((file) => file.path), + ["a.py", "b.ts"], + ); + assert.equal(groups[0]?.files[0]?.content, "two"); + assert.equal(groups[1]?.runId, "r2"); + assert.equal(groups[1]?.files[0]?.content, "later"); + assert.equal(fileKey(groups[1]!.files[0]!), "r2:a.py"); +}); diff --git a/packages/core/chat/files.ts b/packages/core/chat/files.ts new file mode 100644 index 0000000..d5c0844 --- /dev/null +++ b/packages/core/chat/files.ts @@ -0,0 +1,272 @@ +import { planPreviewFromTool } from "./plan.ts"; + +export type FileChangePreview = { + path: string; + content: string; + action: "write" | "edit"; + patch: string; // 服务端带过来的 unified diff;没有就空着,前端先不算 + runId: string; // 这次改动所属的 Run,用来按轮分框 +}; + +export type RunFileGroup = { + runId: string; + files: FileChangePreview[]; +}; + +const FILE_TOOLS = new Set(["write", "edit"]); + +// isFileChangeTool 判断工具是否改普通文件(计划文档不算)。 +export function isFileChangeTool(name: string): boolean { + return FILE_TOOLS.has(name); +} + +// fileChangeFromTool 从 write/edit 抽出路径和新正文;计划文档不算。前端先不现算 patch。 +export function fileChangeFromTool(input: { + name: string; + arguments?: unknown; + output?: unknown; + runId?: string; +}): FileChangePreview | null { + if (!FILE_TOOLS.has(input.name)) { + return null; + } + if (planPreviewFromTool(input)) { + return null; + } + const args = asRecord(input.arguments); + const path = stringField(args, "path"); + if (!path) { + return null; + } + if (input.name === "write") { + return { + path, + content: stringField(args, "content"), + action: "write", + patch: detailsPatch(input.output), + runId: input.runId ?? "", + }; + } + const sides = editSides(args); + return { + path, + content: sides.next, + action: "edit", + patch: detailsPatch(input.output), + runId: input.runId ?? "", + }; +} + +// fileKey 用 run + 路径区分同一文件在不同轮次的改动。 +export function fileKey(file: Pick): string { + return file.runId ? `${file.runId}:${file.path}` : file.path; +} + +// collectRunFileGroups 按 run 归并 write/edit,同路径只留该轮最后一次。 +export function collectRunFileGroups( + items: Array<{ + kind: string; + name?: string; + runId?: string; + arguments?: unknown; + output?: unknown; + }>, +): RunFileGroup[] { + const groups = new Map>(); + const order: string[] = []; + for (const item of items) { + if (item.kind !== "tool" || !item.name) { + continue; + } + const change = fileChangeFromTool({ + name: item.name, + arguments: item.arguments, + output: item.output, + runId: item.runId ?? "", + }); + if (!change) { + continue; + } + const runId = change.runId || "run"; + let files = groups.get(runId); + if (!files) { + files = new Map(); + groups.set(runId, files); + order.push(runId); + } + files.set(change.path, change); + } + return order.map((runId) => ({ runId, files: [...(groups.get(runId)?.values() ?? [])] })); +} + +// compactToolDump 去掉计划正文、文件正文和 patch,只留路径等元数据给时间线。 +export function compactToolDump(input: { + name: string; + arguments?: unknown; + output?: unknown; +}): { input: unknown; output: unknown } { + if (!planPreviewFromTool(input) && !isFileChangeTool(input.name)) { + return { input: input.arguments, output: input.output }; + } + return { + input: omitHeavy(input.arguments), + output: omitHeavy(input.output), + }; +} + +// unifiedPatch 用公共前后缀生成一份 unified diff,给 DiffView 当 hunk。 +export function unifiedPatch(path: string, oldText: string, newText: string): string { + if (oldText === newText) { + return ""; + } + const oldLines = splitDiffLines(oldText); + const newLines = splitDiffLines(newText); + const prefix = commonAffix(oldLines, newLines); + const suffix = commonSuffix(oldLines, newLines, prefix); + const lines: string[] = []; + for (let index = 0; index < prefix; index += 1) { + lines.push(` ${oldLines[index]}`); + } + for (let index = prefix; index < oldLines.length - suffix; index += 1) { + lines.push(`-${oldLines[index]}`); + } + for (let index = prefix; index < newLines.length - suffix; index += 1) { + lines.push(`+${newLines[index]}`); + } + for (let index = oldLines.length - suffix; index < oldLines.length; index += 1) { + lines.push(` ${oldLines[index]}`); + } + const oldStart = oldLines.length === 0 ? 0 : 1; + const newStart = newLines.length === 0 ? 0 : 1; + const oldLabel = oldLines.length === 0 ? "/dev/null" : `a/${path}`; + const newLabel = newLines.length === 0 ? "/dev/null" : `b/${path}`; + return [ + `--- ${oldLabel}`, + `+++ ${newLabel}`, + `@@ -${oldStart},${oldLines.length} +${newStart},${newLines.length} @@`, + ...lines, + "", + ].join("\n"); +} + +// diffViewHunks 去掉工具输出里的分隔线,留下 DiffView 能吃的 unified hunk。 +export function diffViewHunks(patch: string): string[] { + const body = patch.replace(/^=+\r?\n/gm, "").trim(); + return body ? [body] : []; +} + +// editSides 取出 edit 的旧/新正文;多段替换按段拼在一起。 +function editSides(args: Record | null): { prev: string; next: string } { + const wholeNext = stringField(args, "newText") || stringField(args, "new_text"); + const wholePrev = stringField(args, "oldText") || stringField(args, "old_text"); + if (wholeNext || wholePrev) { + return { prev: wholePrev, next: wholeNext }; + } + const edits = args?.edits; + if (!Array.isArray(edits) || edits.length === 0) { + return { prev: "", next: "" }; + } + const prev: string[] = []; + const next: string[] = []; + for (const item of edits) { + const row = asRecord(item); + prev.push(stringField(row, "oldText") || stringField(row, "old_text")); + next.push(stringField(row, "newText") || stringField(row, "new_text")); + } + return { prev: prev.join("\n\n"), next: next.join("\n\n") }; +} + +// detailsPatch 从工具结果 details.patch 取出服务端算好的 unified diff。 +function detailsPatch(output: unknown): string { + const record = asRecord(output); + return stringField(asRecord(record?.details), "patch"); +} + +// splitDiffLines 按行切开,丢掉末尾空段,和 unified hunk 对齐。 +function splitDiffLines(text: string): string[] { + if (text === "") { + return []; + } + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") { + lines.pop(); + } + return lines; +} + +// commonAffix 返回新旧行列从头开始相同的行数。 +function commonAffix(oldLines: string[], newLines: string[]): number { + let length = 0; + while (length < oldLines.length && length < newLines.length && oldLines[length] === newLines[length]) { + length += 1; + } + return length; +} + +// commonSuffix 在去掉公共前缀后,从尾部再数相同行。 +function commonSuffix(oldLines: string[], newLines: string[], prefix: number): number { + let length = 0; + while ( + length < oldLines.length - prefix && + length < newLines.length - prefix && + oldLines[oldLines.length - 1 - length] === newLines[newLines.length - 1 - length] + ) { + length += 1; + } + return length; +} + +// asRecord 把对象或 JSON 对象字符串收成字典。 +function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} + +// stringField 读字典里的字符串字段,缺了或类型不对就空串。 +function stringField(record: Record | null, key: string): string { + if (!record) { + return ""; + } + const value = record[key]; + return typeof value === "string" ? value : ""; +} + +// omitHeavy 去掉正文、替换片段和 diff,避免时间线被文件内容撑满。 +function omitHeavy(value: unknown): unknown { + const record = asRecord(value); + if (!record) { + return value; + } + const next = { ...record }; + delete next.content; + delete next.newText; + delete next.new_text; + delete next.oldText; + delete next.old_text; + delete next.edits; + const details = asRecord(next.details); + if (details) { + const slim = { ...details }; + delete slim.patch; + delete slim.diff; + next.details = slim; + } + return next; +} diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 2b8f256..5b5d11c 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,10 +1,22 @@ export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts"; export { decodeText, firstLine, parseDelta } from "./content.ts"; +export { + collectRunFileGroups, + compactToolDump, + diffViewHunks, + fileChangeFromTool, + fileKey, + isFileChangeTool, + unifiedPatch, + type FileChangePreview, + type RunFileGroup, +} from "./files.ts"; export { isPlanTool, latestPlanDocIds, normalizePlanName, planPreviewFromTool, + planReadableContent, planToolDump, type PlanDocPreview, type PlanPreview, @@ -31,13 +43,16 @@ export type { WorkMode, Approval, ApprovalDecision, + ApprovalKind, ApprovalStatus, ApprovalToolCall, CreateSessionRequest, DecideApprovalRequest, EventType, Message, + OverrideAction, PageInfo, + RestoreMode, Run, RunStatus, Session, diff --git a/packages/core/chat/plan.test.ts b/packages/core/chat/plan.test.ts index 2fecd70..da73025 100644 --- a/packages/core/chat/plan.test.ts +++ b/packages/core/chat/plan.test.ts @@ -1,7 +1,24 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { isPlanTool, latestPlanDocIds, normalizePlanName, planPreviewFromTool, planToolDump } from "./plan.ts"; +import { + isPlanTool, + latestPlanDocIds, + normalizePlanName, + planPreviewFromTool, + planReadableContent, + planToolDump, +} from "./plan.ts"; + +test("planReadableContent turns JSON frontmatter into markdown", () => { + const got = planReadableContent( + '---\n{"title":"图书管理系统","items":[{"id":"V1","description":"存储层可落盘","verify_cmd":"npx vitest run tests/storage.test.ts","passes":false}]}\n---\n\n先写存储。\n', + ); + assert.match(got, /^# 图书管理系统/m); + assert.match(got, /- \[ \] V1 存储层可落盘 — `npx vitest run tests\/storage.test.ts`/); + assert.match(got, /先写存储/); + assert.doesNotMatch(got, /"plan_name"/); +}); test("isPlanTool recognizes plan_* only", () => { assert.equal(isPlanTool("plan_list"), true); diff --git a/packages/core/chat/plan.ts b/packages/core/chat/plan.ts index 73b4a3d..22e8f94 100644 --- a/packages/core/chat/plan.ts +++ b/packages/core/chat/plan.ts @@ -21,6 +21,97 @@ export function normalizePlanName(name: string): string { return /\.md$/i.test(base) ? base : `${base}.md`; } +// planReadableContent 把旧 JSON frontmatter 收成给人看的 Markdown。 +export function planReadableContent(content: string): string { + const split = splitPlanFrontmatter(content); + if (!split) { + return content; + } + const meta = split.meta.trim(); + if (!meta.startsWith("{")) { + return split.body || content; + } + try { + const raw = JSON.parse(meta) as { + title?: string; + items?: Array<{ + id?: string; + description?: string; + verify_cmd?: string; + passes?: boolean; + evidence?: string; + }>; + }; + return renderReadablePlan(raw.title ?? "", raw.items ?? [], split.body); + } catch { + return split.body || content; + } +} + +// splitPlanFrontmatter 切开首个 --- 块。 +function splitPlanFrontmatter(content: string): { meta: string; body: string } | null { + const text = content.replace(/^\uFEFF/, ""); + if (!text.startsWith("---")) { + return null; + } + const rest = text.slice(3).replace(/^\r?\n/, ""); + const close = rest.indexOf("\n---"); + if (close < 0) { + return null; + } + return { + meta: rest.slice(0, close), + body: rest.slice(close + 4).replace(/^\r?\n/, ""), + }; +} + +// renderReadablePlan 用标题和勾选列表拼出计划正文。 +function renderReadablePlan( + title: string, + items: Array<{ + id?: string; + description?: string; + verify_cmd?: string; + passes?: boolean; + evidence?: string; + }>, + body: string, +): string { + const lines: string[] = []; + if (title.trim()) { + lines.push(`# ${title.trim()}`, ""); + } + if (items.length > 0) { + lines.push("## 验收", ""); + for (const item of items) { + const mark = item.passes ? "x" : " "; + const id = (item.id ?? "").trim(); + const desc = (item.description ?? "").trim(); + const cmd = (item.verify_cmd ?? "").trim(); + let line = `- [${mark}]`; + if (id) { + line += ` ${id}`; + } + if (desc) { + line += ` ${desc}`; + } + if (cmd) { + line += ` — \`${cmd}\``; + } + lines.push(line); + if (item.passes && item.evidence?.trim()) { + lines.push(` 依据:${item.evidence.trim()}`); + } + } + lines.push(""); + } + const rest = body.replace(/^# [^\n]+\n*/, "").trim(); + if (rest) { + lines.push(rest); + } + return lines.join("\n").trim() + (lines.length ? "\n" : ""); +} + export function planPreviewFromTool(input: { name: string; arguments?: unknown; diff --git a/packages/core/chat/reducer.test.ts b/packages/core/chat/reducer.test.ts index 831506b..62dbabd 100644 --- a/packages/core/chat/reducer.test.ts +++ b/packages/core/chat/reducer.test.ts @@ -182,6 +182,20 @@ test("hydrate interleaves turns instead of grouping users then assistants", () = ]); }); +test("run.failed keeps a public model error on the terminal row", () => { + const state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "run.failed", + payload: { status: "failed", stop_reason: "model_error", error: "Service is too busy." }, + }), + ); + const row = state.items.find((item) => item.kind === "terminal"); + assert.equal(row?.kind === "terminal" && row.error, "Service is too busy."); + assert.equal(row?.kind === "terminal" && row.stopReason, "model_error"); +}); + test("applyEvent skips duplicate seq on reconnect", () => { let state = emptyState(); const created = ev({ @@ -606,6 +620,43 @@ test("decodeText and parseDelta accept backend payloads", () => { assert.equal(parseDelta({ id: "c1", name: "ping" }).kind, "tool"); }); +test("verify events become timeline cards; skipped verify and evaluate do not", () => { + let state = applyEvent( + emptyState(), + ev({ seq: 1, type: "run.state_changed", payload: { from: "running_llm", to: "verifying", reason: "" } }), + ); + assert.equal(state.runStatus, "verifying"); + state = applyEvent(state, ev({ seq: 2, type: "verify.started", payload: { round: 1 } })); + state = applyEvent(state, ev({ seq: 3, type: "verify.result", payload: { status: "passed", round: 1 } })); + const verify = state.items.find((item) => item.kind === "verify"); + assert.ok(verify && verify.kind === "verify" && verify.status === "passed"); + + let skipped = applyEvent(emptyState(), ev({ seq: 1, type: "verify.started", payload: { round: 1 } })); + skipped = applyEvent(skipped, ev({ seq: 2, type: "verify.skipped", payload: { skipped: true, round: 1 } })); + assert.equal(skipped.items.some((item) => item.kind === "verify"), false); + + let evaluate = applyEvent(emptyState(), ev({ seq: 1, type: "evaluate.started", payload: { round: 1 } })); + evaluate = applyEvent( + evaluate, + ev({ seq: 2, type: "evaluate.result", payload: { verdict: "pass", summary: "没有可审的代码改动,跳过复审。" } }), + ); + assert.equal(evaluate.items.some((item) => item.kind === "evaluate"), false); +}); + +test("verify approval cards keep kind for override buttons", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "tool.approval_required", + payload: { approval_id: "a1", kind: "verify", tool_calls: [{ id: "verify", name: "verify" }] }, + }), + ); + const item = state.items.find((current) => current.kind === "approval"); + assert.ok(item && item.kind === "approval"); + assert.equal(item.approvalKind, "verify"); +}); + test("assistant.completed with encoded empty text does not keep a JSON blob", () => { let state = applyEvent( emptyState(), diff --git a/packages/core/chat/reducer.ts b/packages/core/chat/reducer.ts index fed0105..2d2d1bf 100644 --- a/packages/core/chat/reducer.ts +++ b/packages/core/chat/reducer.ts @@ -11,6 +11,7 @@ import { type ApprovalToolCall, type AssistantCompletedPayload, type AssistantDeltaPayload, + type ApprovalKind, type AssistantStartedPayload, type ContextCompactedPayload, type Message, @@ -23,6 +24,7 @@ import { type TimelineItem, type ToolCall, type ToolCallPayload, + type VerifyEventPayload, } from "./types.ts"; export function emptyState(messages: Record = {}): SessionState { @@ -126,6 +128,17 @@ export function applyEvent(state: SessionState, event: AgentEvent): SessionState case "context.compacted": next = applyContextCompacted(next, event); break; + case "verify.started": + next = applyVerify(next, event, "started"); + break; + case "verify.result": + case "verify.skipped": + next = applyVerify(next, event, event.type === "verify.skipped" ? "skipped" : "result"); + break; + case "evaluate.started": + case "evaluate.result": + next = removeItem(next, thinkingId(event.run_id)); + break; case "run.completed": case "run.failed": case "run.cancelled": @@ -302,6 +315,7 @@ function applyApprovalRequired(state: SessionState, event: AgentEvent): SessionS approvalId: payload.approval_id, toolCalls: mergeApprovalCalls(existingApprovalCalls(state, payload.approval_id), payload.tool_calls ?? []), status: "pending", + approvalKind: payload.kind, seq: event.seq, }); } @@ -314,6 +328,7 @@ function applyApprovalDecided(state: SessionState, event: AgentEvent): SessionSt status: payload.status, toolCalls: payload.tool_calls ?? existingApprovalCalls(state, payload.approval_id), decisions: payload.decisions ?? [], + approvalKind: payload.kind ?? existingApprovalKind(state, payload.approval_id), seq: event.seq, }); } @@ -341,6 +356,7 @@ export function applyApprovalRecord(state: SessionState, approval: Approval): Se status: call.status ?? approval.status, reason: call.reason, })), + approvalKind: approval.kind, seq: state.lastSeq, }); } @@ -353,6 +369,7 @@ function applyApprovalDecision( status: ApprovalStatus; toolCalls: ApprovalToolCall[]; decisions: ApprovalDecision[]; + approvalKind?: ApprovalKind; seq: number; }, ): SessionState { @@ -363,6 +380,7 @@ function applyApprovalDecision( approvalId: input.approvalId, toolCalls: input.toolCalls, status: input.status, + approvalKind: input.approvalKind ?? existingApprovalKind(state, input.approvalId), seq: input.seq, }); for (const decision of input.decisions) { @@ -384,6 +402,32 @@ function applyApprovalDecision( return next; } +/** 把收尾验证事件折成时间线卡片;跳过验证不画卡片。 */ +function applyVerify( + state: SessionState, + event: AgentEvent, + stage: "started" | "result" | "skipped", +): SessionState { + const payload = (event.payload ?? {}) as VerifyEventPayload; + let next = removeItem(state, thinkingId(event.run_id)); + if (stage === "skipped" || payload.skipped) { + return removeItem(next, `verify:${event.run_id}`); + } + let status: Extract["status"] = "started"; + if (stage === "result") { + status = payload.status === "failed" || payload.status === "cannot_run" ? payload.status : "passed"; + } + return upsertItem(next, { + kind: "verify", + id: `verify:${event.run_id}`, + runId: event.run_id, + status, + output: payload.output, + round: payload.round, + seq: event.seq, + }); +} + function applyContextCompacted(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as ContextCompactedPayload; return upsertItem(state, { @@ -407,6 +451,7 @@ function applyRunTerminal(state: SessionState, event: AgentEvent): SessionState runId: event.run_id, status, stopReason: payload.stop_reason, + error: payload.error, seq: event.seq, }); if (canTakeActive(state, event.run_id)) { @@ -562,7 +607,8 @@ function upsertItem(state: SessionState, item: TimelineItem): SessionState { const items = state.items.slice(); const index = items.findIndex((current) => current.id === item.id); if (index >= 0) { - items[index] = { ...items[index], ...item }; + const current = items[index]; + items[index] = current.kind === item.kind ? ({ ...current, ...item } as TimelineItem) : item; } else { items.push(item); } @@ -635,6 +681,15 @@ function mergeApprovalCalls(current: ApprovalToolCall[], incoming: ApprovalToolC return [...byId.values()]; } +/** 读出时间线上已有审批卡片的 kind。 */ +function existingApprovalKind(state: SessionState, approvalId: string): ApprovalKind | undefined { + const item = state.items.find( + (current): current is Extract => + current.kind === "approval" && current.approvalId === approvalId, + ); + return item?.approvalKind; +} + function existingApprovalCalls(state: SessionState, approvalId: string): ApprovalToolCall[] { const item = state.items.find( (current): current is Extract => diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 5c97ac8..1f13ef0 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -10,11 +10,19 @@ export type RunStatus = | "running_llm" | "executing_tools" | "waiting_approval" + | "verifying" + | "evaluating" | "cancelling" | "completed" | "failed" | "cancelled"; +export type ApprovalKind = "tools" | "verify" | "evaluate"; + +export type OverrideAction = "accept" | "retry" | "abort"; + +export type RestoreMode = "restore_files" | "restore_messages" | "restore_all"; + export type ApprovalStatus = "pending" | "approved" | "denied" | "expired"; export type ApprovalScope = "once" | "run" | "session"; @@ -39,9 +47,23 @@ export type EventType = | "turn.completed" | "run.completed" | "run.failed" - | "run.cancelled"; - -export type ThinkingPhase = "queued" | "loading_context" | "running_llm"; + | "run.cancelled" + | "verify.started" + | "verify.result" + | "verify.skipped" + | "evaluate.started" + | "evaluate.result" + | "snapshot.skipped"; + +export type ThinkingPhase = + | "queued" + | "loading_context" + | "running_llm" + | "executing_tools" + | "waiting_approval" + | "verifying" + | "evaluating" + | "cancelling"; export type ToolItemState = | "pending" @@ -114,6 +136,8 @@ export interface Approval { scope: ApprovalScope; status: ApprovalStatus; expires_at: string; + kind?: ApprovalKind; + override?: OverrideAction; } export interface AgentEvent { @@ -171,6 +195,7 @@ export interface ToolCallPayload { export interface ApprovalRequiredPayload { approval_id: string; tool_calls: ApprovalToolCall[]; + kind?: ApprovalKind; } export interface ApprovalDecision { @@ -187,6 +212,23 @@ export interface ApprovalDecidedPayload { reason?: string; decisions?: ApprovalDecision[]; tool_calls?: ApprovalToolCall[]; + kind?: ApprovalKind; + override?: OverrideAction; +} + +export interface VerifyEventPayload { + round?: number; + status?: "passed" | "failed" | "cannot_run"; + output?: string; + skipped?: boolean; + fingerprint?: string; +} + +export interface EvaluateEventPayload { + round?: number; + verdict?: "pass" | "needs_work" | "escalate"; + summary?: string; + issues?: Array<{ file_path?: string; line?: number; category?: string; reason?: string }>; } export interface ContextCompactedPayload { @@ -197,6 +239,7 @@ export interface ContextCompactedPayload { export interface RunTerminalPayload { status: RunStatus; stop_reason?: string; + error?: string; } export interface CreateSessionRequest { @@ -223,6 +266,8 @@ export const RECOVERABLE_RUN_STATUSES: readonly RunStatus[] = [ "loading_context", "running_llm", "executing_tools", + "verifying", + "evaluating", ]; export function isRecoverableRun(status: string): boolean { @@ -242,10 +287,11 @@ export interface StartRunResponse { } export interface DecideApprovalRequest { - decisions: ApprovalDecision[]; + decisions?: ApprovalDecision[]; scope?: ApprovalScope; actor_id?: string; reason?: string; + override?: OverrideAction; } export type TimelineItem = @@ -286,13 +332,32 @@ export type TimelineItem = error?: string; seq: number; } - | { + | { kind: "approval"; id: string; runId: string; approvalId: string; toolCalls: ApprovalToolCall[]; status: ApprovalStatus; + approvalKind?: ApprovalKind; + seq: number; + } + | { + kind: "verify"; + id: string; + runId: string; + status: "started" | "passed" | "failed" | "skipped" | "cannot_run"; + output?: string; + round?: number; + seq: number; + } + | { + kind: "evaluate"; + id: string; + runId: string; + status: "started" | "pass" | "needs_work" | "escalate"; + summary?: string; + round?: number; seq: number; } | { @@ -309,6 +374,7 @@ export type TimelineItem = runId: string; status: RunStatus; stopReason?: string; + error?: string; seq: number; }; @@ -324,6 +390,11 @@ export const THINKING_PHASES: readonly ThinkingPhase[] = [ "queued", "loading_context", "running_llm", + "executing_tools", + "waiting_approval", + "verifying", + "evaluating", + "cancelling", ]; export const TERMINAL_RUN_STATUSES: readonly RunStatus[] = [ diff --git a/packages/core/codex/reducer.ts b/packages/core/codex/reducer.ts index 5fda778..c9342e9 100644 --- a/packages/core/codex/reducer.ts +++ b/packages/core/codex/reducer.ts @@ -155,16 +155,16 @@ function turnItem(turn: Turn): TimelineItem { error: turn.error, text: turn.status === "queued" - ? "已排队,等当前回合结束" + ? "Queued until the current turn finishes" : turn.status === "completed" - ? "本轮完成" + ? "Turn completed" : turn.status === "cancelled" - ? "已打断" + ? "Interrupted" : turn.status === "failed" - ? turn.error || "本轮失败" + ? turn.error || "Turn failed" : turn.status === "waiting_approval" - ? "等待你的作答" - : "Codex 正在工作", + ? "Waiting for your answer" + : "Codex is working", }; } diff --git a/packages/core/package.json b/packages/core/package.json index 6fcd40b..6e6f931 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,7 @@ "./claude": "./claude/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/files.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts arch.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/components/live-status.tsx b/packages/ui/components/live-status.tsx new file mode 100644 index 0000000..4e78792 --- /dev/null +++ b/packages/ui/components/live-status.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { HTMLAttributes } from "react"; + +import { cn } from "../lib/cn.ts"; + +/** 仅当前进行中的状态带扫光;历史状态保持静态文字。 */ +export function LiveStatus({ + children, + active = false, + className, + ...props +}: { + children: string; + active?: boolean; +} & HTMLAttributes) { + return ( + + {children} + + ); +} diff --git a/packages/ui/components/prompt-input.tsx b/packages/ui/components/prompt-input.tsx index 4d1bd1d..f8ec99b 100644 --- a/packages/ui/components/prompt-input.tsx +++ b/packages/ui/components/prompt-input.tsx @@ -4,7 +4,7 @@ import type { FormEvent, HTMLAttributes, ReactNode, Ref, TextareaHTMLAttributes import { cn } from "../lib/cn.ts"; import { useImeGuard } from "../lib/ime.ts"; -import { Button } from "./ui/button.tsx"; +import { Button, type ButtonProps } from "./ui/button.tsx"; export type PromptInputMessage = { text: string }; @@ -97,16 +97,18 @@ export function PromptInputTools({ children }: { children: ReactNode }) { return
{children}
; } +// PromptInputSubmit 提交输入框;可附带 data-testid。 export function PromptInputSubmit({ status, disabled, + ...props }: { status?: "ready" | "streaming"; disabled?: boolean; -}) { +} & Omit) { return ( - ); } diff --git a/packages/ui/components/reasoning.tsx b/packages/ui/components/reasoning.tsx index e83526a..7a0a9f0 100644 --- a/packages/ui/components/reasoning.tsx +++ b/packages/ui/components/reasoning.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; +import { LiveStatus } from "./live-status.tsx"; import { cn } from "../lib/cn.ts"; import { Collapsible, @@ -76,7 +77,9 @@ export function ReasoningTrigger({ {children ?? ( <> - {ctx?.isStreaming ? "思考中…" : "思考过程"} + + {ctx?.isStreaming ? "Thinking" : "Reasoning"} + diff --git a/packages/ui/components/tool.tsx b/packages/ui/components/tool.tsx index 5ac9129..9ae1171 100644 --- a/packages/ui/components/tool.tsx +++ b/packages/ui/components/tool.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon, WrenchIcon } from "lucide-react"; import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; +import { LiveStatus } from "./live-status.tsx"; import { cn } from "../lib/cn.ts"; import { formatJSON } from "../lib/json.ts"; import { @@ -13,12 +14,17 @@ import { export type ToolState = "pending" | "running" | "completed" | "error" | "denied"; +// isLiveToolState 判断工具是否仍在排队或执行,需要动态状态文案。 +function isLiveToolState(state: ToolState): boolean { + return state === "pending" || state === "running"; +} + const stateLabel: Record = { - pending: "待执行", - running: "执行中", - completed: "已完成", - error: "失败", - denied: "已拒绝", + pending: "Pending", + running: "Running", + completed: "Done", + error: "Failed", + denied: "Denied", }; type OpenContextValue = { @@ -55,10 +61,12 @@ export function ToolGroup({ export function ToolGroupHeader({ count, state, + live = false, className, }: { count: number; state: ToolState; + live?: boolean; className?: string; }) { const ctx = useContext(ToolGroupContext); @@ -70,8 +78,10 @@ export function ToolGroupHeader({ )} > - {count === 1 ? "调用了 1 个工具" : `调用了 ${count} 个工具`} - {stateLabel[state]} + {count === 1 ? "Used 1 tool" : `Used ${count} tools`} + + {stateLabel[state]} + @@ -116,10 +126,12 @@ export function Tool({ export function ToolHeader({ type, state, + live = false, className, }: { type: string; state: ToolState; + live?: boolean; className?: string; }) { const name = type.startsWith("tool-") ? type.slice(5) : type; @@ -132,7 +144,7 @@ export function ToolHeader({ )} > {name} - {stateLabel[state]} + {stateLabel[state]} @@ -148,11 +160,11 @@ export function ToolContent({ children, className }: { children: ReactNode; clas export function ToolInput({ input }: { input: unknown }) { if (input == null) { - return

无参数

; + return

No arguments

; } return (
-
参数
+
Arguments
         {formatJSON(input)}
       
@@ -170,7 +182,7 @@ export function ToolOutput({ if (errorText) { return (
-
错误
+
Error

{errorText}

); @@ -180,7 +192,7 @@ export function ToolOutput({ } return (
-
输出
+
Output
         {formatJSON(output)}
       
diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 67eed24..30cf580 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,3 +1,4 @@ +export { LiveStatus } from "./components/live-status.tsx"; export { cn } from "./lib/cn.ts"; export { formatJSON } from "./lib/json.ts"; export { isImeConfirm, useImeGuard } from "./lib/ime.ts"; diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css index 2ec881f..4c52487 100644 --- a/packages/ui/styles/tokens.css +++ b/packages/ui/styles/tokens.css @@ -32,3 +32,36 @@ --primary-foreground: var(--color-zinc-950); --radius: 0.625rem; } + +/* 进行中状态:整段文字扫光,不用省略号占位。 */ +.live-status-active { + background-image: linear-gradient( + 100deg, + color-mix(in oklab, var(--muted-foreground) 72%, transparent) 0%, + var(--foreground) 48%, + color-mix(in oklab, var(--muted-foreground) 72%, transparent) 100% + ); + background-size: 180% 100%; + background-clip: text; + -webkit-background-clip: text; + color: transparent; + animation: live-status-shimmer 2s ease-in-out infinite; +} + +@keyframes live-status-shimmer { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .live-status-active { + animation: none; + background: none; + color: inherit; + } +} diff --git a/packages/views/chat/approval-dock.tsx b/packages/views/chat/approval-dock.tsx index 787ddfc..dadd2ec 100644 --- a/packages/views/chat/approval-dock.tsx +++ b/packages/views/chat/approval-dock.tsx @@ -3,8 +3,10 @@ import { planPreviewFromTool, type ApprovalDecision, + type ApprovalKind, type ApprovalStatus, type ApprovalToolCall, + type OverrideAction, type TimelineItem, } from "@codedock/core/chat"; import { Button, cn, formatJSON, MessageResponse } from "@codedock/ui"; @@ -16,6 +18,7 @@ type ApprovalItem = Extract; type ApprovalPage = { approvalId: string; call: ApprovalToolCall; + approvalKind?: ApprovalKind; }; type Choice = "approved" | "denied"; @@ -25,7 +28,11 @@ export function ApprovalDock({ onDecide, }: { items: ApprovalItem[]; - onDecide: (approvalId: string, decisions: ApprovalDecision[]) => Promise; + onDecide: ( + approvalId: string, + decisions: ApprovalDecision[], + extra?: { override?: OverrideAction }, + ) => Promise; }) { const pages = useMemo(() => pagesFrom(items), [items]); const [index, setIndex] = useState(0); @@ -104,6 +111,18 @@ export function ApprovalDock({ } }; + /** 提交验证/复审单上的人工裁决。 */ + const decideOverride = async (action: OverrideAction) => { + setSubmitting(true); + try { + await onDecide(page.approvalId, [], { override: action }); + } finally { + setSubmitting(false); + } + }; + + const overrideKind = page.approvalKind === "verify" || page.approvalKind === "evaluate"; + return (
-
需要批准才能继续
+
+ {overrideKind + ? page.approvalKind === "verify" + ? "Verification failed; decide how to proceed" + : "Review failed; decide how to proceed" + : "Approval required to continue"} +
- + {overrideKind ? ( + <> + + + + + ) : ( + <> + + + + )}
@@ -210,11 +271,12 @@ function pagesFrom(items: ApprovalItem[]): ApprovalPage[] { pages.push({ approvalId: item.approvalId, call: item.toolCalls[0] ?? { id: "", name: "工具调用" }, + approvalKind: item.approvalKind, }); continue; } for (const call of calls) { - pages.push({ approvalId: item.approvalId, call }); + pages.push({ approvalId: item.approvalId, call, approvalKind: item.approvalKind }); } } return pages; @@ -237,12 +299,12 @@ function asChoice(status?: ApprovalStatus): Choice | undefined { function statusLabel(status?: Choice): string { if (status === "approved") { - return "已允许"; + return "Allowed"; } if (status === "denied") { - return "已拒绝"; + return "Denied"; } - return "待批"; + return "Pending"; } function statusTone(status?: Choice): string { diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 5e64658..4ed4642 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -4,6 +4,7 @@ import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/co import type { ClaudeSession } from "@codedock/core/claude"; import type { Session as CodexSession } from "@codedock/core/codex"; import { Button } from "@codedock/ui"; +import { PanelLeft, PanelLeftClose, PanelRight, PanelRightClose, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ClaudePane } from "../claude/claude-pane.tsx"; @@ -14,7 +15,11 @@ import { useCodexSessionList } from "../codex/hooks/use-session-list.ts"; import { useCodex } from "../codex/provider.tsx"; import { useAgent } from "../provider.tsx"; import { ApprovalDock } from "./approval-dock.tsx"; +import { useColumnLayout } from "./column-layout.ts"; +import { ColumnSash } from "./column-sash.tsx"; import { ConversationTimeline } from "./conversation-timeline.tsx"; +import { SideDock } from "./side-dock.tsx"; +import { collectDockArtifacts, planWindowId, useWorkbench } from "./workbench.ts"; import { useSessionList } from "./hooks/use-session-list.ts"; import { useSessionTimeline } from "./hooks/use-session-timeline.ts"; import { shortWorkspace } from "./lib/format.ts"; @@ -42,7 +47,7 @@ export type ChatPageProps = { headerActions?: ReactNode; }; -// ChatPage 组合会话侧栏、时间线与输入条。 +// ChatPage 组合会话列表、对话和右侧多窗口栏。 export function ChatPage({ sessionId, engine, @@ -71,6 +76,12 @@ export function ChatPage({ const [composerError, setComposerError] = useState(null); const [workspaceDraft, setWorkspaceDraft] = useState(""); const [pickingWorkspace, setPickingWorkspace] = useState(false); + const workbench = useWorkbench(); + const columns = useColumnLayout(); + + useEffect(() => { + workbench.reset(); + }, [sessionId, activeEngine, workbench.reset]); // 上次目录只在本机 localStorage,等 hydration 后再读,避免 SSR 文本对不上。 useEffect(() => { @@ -95,6 +106,26 @@ export function ChatPage({ : "" : shortWorkspace(workspaceTitle); + const artifacts = useMemo( + () => collectDockArtifacts(timeline.state.items), + [timeline.state.items], + ); + + useEffect(() => { + for (const plan of artifacts.plans) { + workbench.refreshOpen({ + id: planWindowId(plan.name), + kind: "plan", + title: plan.name || "Plan", + name: plan.name, + content: plan.content, + toolState: plan.toolState, + error: plan.error, + }); + } + workbench.syncFiles(artifacts.files, false); + }, [artifacts, workbench.refreshOpen, workbench.syncFiles]); + const pendingApprovals = timeline.state.items.filter( (item): item is Extract => item.kind === "approval" && item.status === "pending", @@ -149,90 +180,151 @@ export function ChatPage({ const hideSession = async (session: SidebarSession) => { const hiddenId = session.id; const hiddenEngine = session.engine ?? "agent"; - if (session.engine === "codex") { - await codexClient.archiveSession(session.id); - await codexList.refresh(); - } else if (session.engine === "claude") { - await claudeClient.archiveSession(session.id); - await claudeList.refresh(); - } else { - await list.removeSession(session); + try { + if (session.engine === "codex") { + await codexClient.archiveSession(session.id); + await codexList.refresh(); + } else if (session.engine === "claude") { + await claudeClient.archiveSession(session.id); + await claudeList.refresh(); + } else { + await list.removeSession(session); + } + } catch (err) { + setComposerError(err instanceof Error ? err.message : "归档失败"); + throw err; } if (sessionId === hiddenId && (engine ?? "agent") === hiddenEngine) { onNewConversation(); } }; + // openPlan 打开计划窗口;右侧若收起则先展开。 + const openPlan = ( + preview: Parameters[0], + extra?: Parameters[1], + ) => { + columns.setRightOpen(true); + workbench.openPlan(preview, extra); + }; + // openFile 打开文件窗口;右侧若收起则先展开。 + const openFile = (change: Parameters[0]) => { + columns.setRightOpen(true); + workbench.openFile(change); + }; + // openGit 打开 Git 窗口;右侧若收起则先展开。 + const openGit = () => { + columns.setRightOpen(true); + workbench.openGit(); + }; + // createDock 从窗口栏新建;右侧若收起则先展开。 + const createDock = ( + kind: Parameters[0], + seed?: Parameters[1], + ) => { + columns.setRightOpen(true); + workbench.createKind(kind, seed); + }; + return ( -
- void codexList.loadMore() : undefined} - onCreate={onNewConversation} - onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} - onRecover={async (runId) => { - await timeline.recover(runId); - await list.refresh(); - }} - canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} - onArchive={async (session) => { - await hideSession(session); - }} - brandSrc={brandSrc} - codexIconSrc={codexIconSrc} - claudeIconSrc={claudeIconSrc} - /> -
- {sessionId ? ( -
- - {activeEngine === "codex" - ? "Codex 对话" +
+ {columns.leftOpen ? ( + <> + - {workspaceLabel ? ( - <> - · - void codexList.loadMore() : undefined} + onCreate={onNewConversation} + onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} + onRecover={async (runId) => { + await timeline.recover(runId); + await list.refresh(); + }} + canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} + onArchive={async (session) => { + await hideSession(session); + }} + brandSrc={brandSrc} + codexIconSrc={codexIconSrc} + claudeIconSrc={claudeIconSrc} + /> + columns.setLeftOpen(false)} + /> + + ) : null} +
+
+ + {columns.leftOpen ? : } + + {!columns.leftOpen ? ( + + ) : null} + {sessionId ? ( + <> + + {activeEngine === "codex" + ? "Codex 对话" + : activeEngine === "claude" + ? "Claude 对话" + : "Local 对话"} + + {workspaceLabel ? ( + <> + · + + {workspaceLabel} + + + ) : null} + {activeEngine === "agent" && timeline.canRecover ? ( + - ) : null} - {headerActions ?
{headerActions}
: null} -
- ) : headerActions ? ( -
-
{headerActions}
-
- ) : null} + 恢复 + + ) : null} + + ) : null} +
+ {headerActions} + + {columns.rightOpen ? : } + +
+
{sessionId ? ( activeEngine === "codex" ? ( <> @@ -277,6 +369,9 @@ export function ChatPage({ state={timeline.state} loading={timeline.loading} scrollKey={sessionId} + onOpenPlan={openPlan} + onOpenFile={openFile} + onOpenGit={openGit} />
)}
+ {columns.rightOpen ? ( + <> + columns.moveRight(-delta, persist)} + onCollapse={() => columns.setRightOpen(false)} + /> + + + ) : null}
); } -// mergeSessions 把 Local / Codex / Claude 会话按更新时间合成侧栏列表,同引擎同 ID 只留更新的一条。 +// SidebarToggle 收起或展开一侧栏。 +function SidebarToggle({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +// mergeSessions 把 Local / Codex / Claude 会话按更新时间合成侧栏列表,去掉已归档,同引擎同 ID 只留更新的一条。 function mergeSessions( agent: Session[], codex: CodexSession[], @@ -379,7 +510,9 @@ function mergeSessions( seen.set(key, session); } } - return [...seen.values()].sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); + return [...seen.values()] + .filter((session) => session.status !== "archived") + .sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); } // asCodexSidebarSession 把 Codex 会话收成侧栏条目,目录用 cwd,标题优先 title。 diff --git a/packages/views/chat/column-layout.test.ts b/packages/views/chat/column-layout.test.ts new file mode 100644 index 0000000..8c6a8d2 --- /dev/null +++ b/packages/views/chat/column-layout.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fitColumns, LEFT_MIN, MID_MIN, RIGHT_MIN } from "./column-layout.ts"; + +test("fitColumns keeps both sides when there is room", () => { + const got = fitColumns(240, 420, 1200); + assert.equal(got.left, 240); + assert.equal(got.right, 420); +}); + +test("fitColumns ignores a collapsed side", () => { + const leftClosed = fitColumns(240, 420, 800, { left: false, right: true }); + assert.equal(leftClosed.left, 0); + assert.ok(leftClosed.right >= RIGHT_MIN); + + const bothClosed = fitColumns(240, 420, 400, { left: false, right: false }); + assert.deepEqual(bothClosed, { left: 0, right: 0 }); +}); + +test("fitColumns still leaves the mid pane when only the left is open", () => { + const got = fitColumns(480, 860, LEFT_MIN + MID_MIN + 20, { left: true, right: false }); + assert.equal(got.right, 0); + assert.ok(got.left >= LEFT_MIN); + assert.ok(got.left + MID_MIN <= LEFT_MIN + MID_MIN + 20); +}); diff --git a/packages/views/chat/column-layout.ts b/packages/views/chat/column-layout.ts new file mode 100644 index 0000000..53871f1 --- /dev/null +++ b/packages/views/chat/column-layout.ts @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const LEFT_KEY = "codedock.column.left"; +const RIGHT_KEY = "codedock.column.right"; +const LEFT_OPEN_KEY = "codedock.column.leftOpen"; +const RIGHT_OPEN_KEY = "codedock.column.rightOpen"; +const LEFT_DEFAULT = 240; +const RIGHT_DEFAULT = 420; +export const LEFT_MIN = 168; +export const LEFT_MAX = 480; +export const RIGHT_MIN = 240; +export const RIGHT_MAX = 860; +export const MID_MIN = 280; +const SASH = 6; + +type ColumnOpen = { + left: boolean; + right: boolean; +}; + +type ColumnWidths = { + left: number; + right: number; +}; + +// readStoredWidth 读本机记住的栏宽,坏值回默认。 +function readStoredWidth(key: string, fallback: number, min: number, max: number): number { + if (typeof window === "undefined") { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + const value = raw ? Number.parseInt(raw, 10) : fallback; + if (!Number.isFinite(value)) { + return fallback; + } + return clamp(value, min, max); + } catch { + return fallback; + } +} + +// writeStoredWidth 把栏宽记到本机。 +function writeStoredWidth(key: string, value: number): void { + try { + window.localStorage.setItem(key, String(Math.round(value))); + } catch { + // ignore + } +} + +// readStoredOpen 读本机记住的栏开合,坏值回默认。 +function readStoredOpen(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + if (raw === "0") { + return false; + } + if (raw === "1") { + return true; + } + return fallback; + } catch { + return fallback; + } +} + +// writeStoredOpen 把栏开合记到本机。 +function writeStoredOpen(key: string, open: boolean): void { + try { + window.localStorage.setItem(key, open ? "1" : "0"); + } catch { + // ignore + } +} + +// clamp 把数字限制在闭区间。 +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +// fitColumns 按总宽收紧仍打开的左右栏,保证中间对话还有 MID_MIN。 +export function fitColumns( + left: number, + right: number, + total: number, + open: ColumnOpen = { left: true, right: true }, +): ColumnWidths { + const sashes = (open.left ? 1 : 0) + (open.right ? 1 : 0); + const minSides = (open.left ? LEFT_MIN : 0) + (open.right ? RIGHT_MIN : 0); + const usable = Math.max(total - SASH * sashes, minSides + MID_MIN); + let nextLeft = open.left ? clamp(left, LEFT_MIN, LEFT_MAX) : 0; + let nextRight = open.right ? clamp(right, RIGHT_MIN, RIGHT_MAX) : 0; + const overflow = nextLeft + nextRight + MID_MIN - usable; + if (overflow <= 0) { + return { left: nextLeft, right: nextRight }; + } + if (open.right) { + const shrinkRight = Math.min(overflow, nextRight - RIGHT_MIN); + nextRight -= shrinkRight; + const remain = overflow - shrinkRight; + if (remain > 0 && open.left) { + nextLeft = Math.max(LEFT_MIN, nextLeft - remain); + } + } else if (open.left) { + nextLeft = Math.max(LEFT_MIN, nextLeft - overflow); + } + return { left: nextLeft, right: nextRight }; +} + +// useColumnLayout 记住并拖动三栏宽度,中间栏吃剩余空间;左右栏可收起。 +export function useColumnLayout() { + const rowRef = useRef(null); + const leftRef = useRef(LEFT_DEFAULT); + const rightRef = useRef(RIGHT_DEFAULT); + const leftOpenRef = useRef(true); + const rightOpenRef = useRef(true); + const [left, setLeft] = useState(LEFT_DEFAULT); + const [right, setRight] = useState(RIGHT_DEFAULT); + const [leftOpen, setLeftOpenState] = useState(true); + const [rightOpen, setRightOpenState] = useState(true); + + const commit = useCallback((nextLeft: number, nextRight: number, persist: boolean) => { + const open = { left: leftOpenRef.current, right: rightOpenRef.current }; + const sashes = (open.left ? 1 : 0) + (open.right ? 1 : 0); + const total = rowRef.current?.clientWidth ?? nextLeft + nextRight + MID_MIN + SASH * sashes; + const fitted = fitColumns(nextLeft, nextRight, total, open); + if (open.left) { + leftRef.current = fitted.left; + } + if (open.right) { + rightRef.current = fitted.right; + } + setLeft(fitted.left); + setRight(fitted.right); + if (persist) { + writeStoredWidth(LEFT_KEY, leftRef.current); + writeStoredWidth(RIGHT_KEY, rightRef.current); + } + }, []); + + useEffect(() => { + leftOpenRef.current = readStoredOpen(LEFT_OPEN_KEY, true); + rightOpenRef.current = readStoredOpen(RIGHT_OPEN_KEY, true); + setLeftOpenState(leftOpenRef.current); + setRightOpenState(rightOpenRef.current); + commit( + readStoredWidth(LEFT_KEY, LEFT_DEFAULT, LEFT_MIN, LEFT_MAX), + readStoredWidth(RIGHT_KEY, RIGHT_DEFAULT, RIGHT_MIN, RIGHT_MAX), + false, + ); + }, [commit]); + + useEffect(() => { + const node = rowRef.current; + if (!node || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => { + commit(leftRef.current, rightRef.current, false); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [commit]); + + const moveLeft = useCallback( + (delta: number, persist = false) => { + if (!leftOpenRef.current) { + return; + } + commit(leftRef.current + delta, rightRef.current, persist); + }, + [commit], + ); + + const moveRight = useCallback( + (delta: number, persist = false) => { + if (!rightOpenRef.current) { + return; + } + commit(leftRef.current, rightRef.current + delta, persist); + }, + [commit], + ); + + // setLeftOpen 展开或收起会话列表,下次仍按这个开合。 + const setLeftOpen = useCallback( + (open: boolean) => { + if (leftOpenRef.current === open) { + return; + } + leftOpenRef.current = open; + setLeftOpenState(open); + writeStoredOpen(LEFT_OPEN_KEY, open); + commit(leftRef.current, rightRef.current, false); + }, + [commit], + ); + + // setRightOpen 展开或收起右侧窗口栏,下次仍按这个开合。 + const setRightOpen = useCallback( + (open: boolean) => { + if (rightOpenRef.current === open) { + return; + } + rightOpenRef.current = open; + setRightOpenState(open); + writeStoredOpen(RIGHT_OPEN_KEY, open); + commit(leftRef.current, rightRef.current, false); + }, + [commit], + ); + + // toggleLeft 切换会话列表开合。 + const toggleLeft = useCallback(() => { + setLeftOpen(!leftOpenRef.current); + }, [setLeftOpen]); + + // toggleRight 切换右侧窗口栏开合。 + const toggleRight = useCallback(() => { + setRightOpen(!rightOpenRef.current); + }, [setRightOpen]); + + return { + rowRef, + left, + right, + leftOpen, + rightOpen, + moveLeft, + moveRight, + setLeftOpen, + setRightOpen, + toggleLeft, + toggleRight, + }; +} diff --git a/packages/views/chat/column-sash.tsx b/packages/views/chat/column-sash.tsx new file mode 100644 index 0000000..decf783 --- /dev/null +++ b/packages/views/chat/column-sash.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef } from "react"; + +import { cn } from "@codedock/ui"; + +// ColumnSash 竖向拖条,拖动时连续回调位移,松开时记住宽度;双击收起该侧。 +export function ColumnSash({ + onMove, + onCollapse, + label, +}: { + onMove: (deltaX: number, persist: boolean) => void; + onCollapse?: () => void; + label: string; +}) { + const lastX = useRef(0); + const dragging = useRef(false); + const moveRef = useRef(onMove); + moveRef.current = onMove; + + return ( +
{ + if (event.button !== 0) { + return; + } + dragging.current = true; + lastX.current = event.clientX; + document.documentElement.classList.add("select-none", "cursor-col-resize"); + const onMovePointer = (next: PointerEvent) => { + if (!dragging.current) { + return; + } + const delta = next.clientX - lastX.current; + lastX.current = next.clientX; + if (delta !== 0) { + moveRef.current(delta, false); + } + }; + const onUp = () => { + if (!dragging.current) { + return; + } + dragging.current = false; + document.documentElement.classList.remove("select-none", "cursor-col-resize"); + window.removeEventListener("pointermove", onMovePointer); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + moveRef.current(0, true); + }; + window.addEventListener("pointermove", onMovePointer); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + event.preventDefault(); + }} + onDoubleClick={() => { + onCollapse?.(); + }} + onKeyDown={(event) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + onMove(-16, true); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + onMove(16, true); + } + }} + > + {label} + +
+ ); +} diff --git a/packages/views/chat/conversation-timeline.tsx b/packages/views/chat/conversation-timeline.tsx index e421d14..056167c 100644 --- a/packages/views/chat/conversation-timeline.tsx +++ b/packages/views/chat/conversation-timeline.tsx @@ -1,9 +1,12 @@ "use client"; import { + compactToolDump, + fileChangeFromTool, latestPlanDocIds, planPreviewFromTool, - planToolDump, + type FileChangePreview, + type PlanPreview, type SessionState, type ThinkingPhase, type TimelineItem, @@ -13,12 +16,10 @@ import { Conversation, ConversationContent, ConversationEmptyState, + LiveStatus, Message, MessageContent, MessageResponse, - Reasoning, - ReasoningContent, - ReasoningTrigger, Tool, ToolContent, ToolGroup, @@ -31,32 +32,63 @@ import { } from "@codedock/ui"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { PlanPreviewCard } from "./plan-preview.tsx"; +import { FileCode2, FileText, GitBranch } from "lucide-react"; + +import { terminalStatusCopy } from "./lib/terminal.ts"; const thinkingCopy: Record = { - queued: "排队中", - loading_context: "正在装载上下文", - running_llm: "正在思考", + queued: "Queued", + loading_context: "Loading context", + running_llm: "Thinking", + executing_tools: "Running tools", + waiting_approval: "Waiting for approval", + verifying: "Verifying", + evaluating: "Verifying", + cancelling: "Cancelling", }; type ToolItem = Extract; +// isLiveTimelineItem 判断这条是否仍是进行中的工作状态。 +function isLiveTimelineItem(item: TimelineItem): boolean { + switch (item.kind) { + case "thinking": + return true; + case "assistant": + return item.streaming; + case "verify": + case "evaluate": + return item.status === "started"; + case "tool": + return item.state === "pending" || item.state === "running"; + default: + return false; + } +} + +// ConversationTimeline 按用户轮次分段渲染瀑布。 export function ConversationTimeline({ state, loading = false, scrollKey, emptyDescription, + onOpenPlan, + onOpenFile, + onOpenGit, }: { state: SessionState; loading?: boolean; scrollKey?: string; emptyDescription?: string; + onOpenPlan?: (preview: PlanPreview, extra?: { toolState?: ToolItem["state"]; error?: string }) => void; + onOpenFile?: (change: FileChangePreview) => void; + onOpenGit?: () => void; }) { const items = state.items.filter( (item) => !(item.kind === "approval" && item.status === "pending") && !(item.kind === "user" && !item.text.trim()) && - !(item.kind === "assistant" && !item.text.trim() && !item.streaming), + !(item.kind === "assistant" && !item.text.trim()), ); if (items.length === 0) { if (loading) { @@ -86,16 +118,30 @@ export function ConversationTimeline({ ); return ( - + {sections.map((section, sectionIndex) => (
{section.map((row, rowIndex) => { const latest = sectionIndex === sections.length - 1 && rowIndex === section.length - 1; return row.kind === "tools" ? ( - + ) : ( - + ); })}
@@ -108,9 +154,11 @@ export function ConversationTimeline({ function TimelineRow({ item, latest = false, + live = false, }: { item: Exclude; latest?: boolean; + live?: boolean; }) { const latestProps = latest ? { "data-conversation-latest": "" } : {}; switch (item.kind) { @@ -118,44 +166,61 @@ function TimelineRow({ return ; case "thinking": return ( -
- - - {thinkingCopy[item.phase]} - +
+ + {thinkingCopy[item.phase]} +
); case "assistant": return ( - - {item.text || (item.streaming ? "…" : "")} - + {item.text} ); case "approval": return (
- {item.status === "denied" ? "已拒绝工具调用" : "已批准工具调用"} + {item.approvalKind === "verify" || item.approvalKind === "evaluate" + ? item.status === "denied" + ? "Rolled back and cancelled" + : "Override resolved" + : item.status === "denied" + ? "Tool calls denied" + : "Tool calls approved"} +
+ ); + case "verify": + return ( +
+ + {item.status === "started" + ? "Verifying" + : item.status === "passed" + ? "Verification passed" + : `Verification failed${item.output ? `: ${item.output.slice(0, 120)}` : ""}`} +
); + case "evaluate": + return null; case "context": return (
- 上下文已压缩 + Context compacted seq {item.baseEventSeq}
); case "terminal": return ( -
- {item.status === "completed" - ? "本轮完成" - : item.status === "cancelled" - ? "已取消" - : `运行结束:${item.stopReason ?? item.status}`} +
+ {terminalStatusCopy(item)}
); default: @@ -167,6 +232,7 @@ type TimelineRowModel = | { kind: "item"; item: Exclude } | { kind: "tools"; id: string; tools: ToolItem[] }; +// groupTimeline 把连续工具收成一行。 function groupTimeline(items: TimelineItem[]): TimelineRowModel[] { const rows: TimelineRowModel[] = []; let index = 0; @@ -201,38 +267,54 @@ function rollupToolState(tools: ToolItem[]): ToolState { return order.find((state) => tools.some((tool) => tool.state === state)) ?? "completed"; } +// ToolCallsRow 把连续工具收成一组;计划仍用芯片,文件改动排成竖向列表。 function ToolCallsRow({ tools, latest = false, + live = false, latestDocIds, + onOpenPlan, + onOpenFile, + onOpenGit, }: { tools: ToolItem[]; latest?: boolean; + live?: boolean; latestDocIds: Set; + onOpenPlan?: (preview: PlanPreview, extra?: { toolState?: ToolItem["state"]; error?: string }) => void; + onOpenFile?: (change: FileChangePreview) => void; + onOpenGit?: () => void; }) { - const previews = tools.flatMap((item) => { + const plans: Array<{ item: ToolItem; preview: PlanPreview }> = []; + const filesByPath = new Map(); + for (const item of tools) { const preview = planPreviewFromTool(item); - if (!preview) { - return []; + if (preview) { + plans.push({ item, preview }); + continue; } - if (!latestDocIds.has(item.id)) { - return []; + const change = fileChangeFromTool(item); + if (change) { + filesByPath.set(change.path, change); } - return [{ item, preview }]; - }); + } + const files = [...filesByPath.values()]; return (
0 ? "flex flex-col gap-3" : undefined} + className={plans.length > 0 || files.length > 0 ? "flex flex-col gap-2" : undefined} {...(latest ? { "data-conversation-latest": "" } : {})} > - + {tools.map((item) => { - const dump = planToolDump(item); + if (item.name === "explore") { + return ; + } + const dump = compactToolDump(item); return ( - + @@ -242,18 +324,122 @@ function ToolCallsRow({ })} - {previews.map(({ item, preview }) => ( - - ))} + {plans.length > 0 ? ( +
+ {plans.map(({ item, preview }) => ( + onOpenPlan(preview, { toolState: item.state, error: item.error }) + : undefined + } + /> + ))} +
+ ) : null} + {files.length > 0 ? ( + + ) : null} +
+ ); +} + +// ArtifactChip 把计划收成一行入口,点开后到右侧窗口。 +function ArtifactChip({ + label, + onClick, +}: { + label: string; + onClick?: () => void; +}) { + return ( + + ); +} + +// ToolFileList 用一个框列出改过的文件;点外框打开 Git,点路径仍看单个文件。 +function ToolFileList({ + files, + onOpenFile, + onOpenGit, +}: { + files: FileChangePreview[]; + onOpenFile?: (change: FileChangePreview) => void; + onOpenGit?: () => void; +}) { + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenGit(); + } + } + : undefined + } + className="mx-3 overflow-hidden rounded-md border border-border bg-card hover:border-foreground/20" + > +
+ + 改动 + {files.length} +
+
    + {files.map((file) => ( +
  • + +
  • + ))} +
); } +/** 折叠展示 explore 子代理结论,避免把长原文铺进主时间线。 */ +function ExploreCard({ item, live = false }: { item: ToolItem; live?: boolean }) { + const dump = compactToolDump(item); + return ( + + + +
Explore subagent
+ + +
+
+ ); +} + function toolsFollowKey(row: Extract): string { const parts = row.tools.map((tool) => { const preview = planPreviewFromTool(tool); @@ -428,7 +614,7 @@ function UserRow({ > {item.queued ? ( -
排队,当前轮结束后发送
+
Queued; will send after this run
) : null}
+
+ {file?.path ? ( + <> + {file.path} + + {file.action === "edit" ? "编辑" : "写入"} + + + ) : ( + 文件 + )} +
+
+ {fileBody(file)} +
+ + ); +} + +// fileBody 按行铺正文,高度跟内容走,避免被代码块组件锁成窗口高。 +function fileBody(file: FileChangePreview | null) { + if (!file?.path) { + return 从对话里点开一个文件查看内容; + } + if (!file.content.trim()) { + return 这次改动没有带上正文; + } + const lines = file.content.split("\n"); + return ( +
+ {lines.map((line, index) => ( +
+ + {index + 1} + + {line.length > 0 ? line : " "} +
+ ))} +
+ ); +} + +// Empty 右侧还没有可展示正文时的提示。 +function Empty({ children }: { children: string }) { + return

{children}

; +} diff --git a/packages/views/chat/hooks/use-session-timeline.ts b/packages/views/chat/hooks/use-session-timeline.ts index 5b4353b..c313a55 100644 --- a/packages/views/chat/hooks/use-session-timeline.ts +++ b/packages/views/chat/hooks/use-session-timeline.ts @@ -17,6 +17,7 @@ import { type ApprovalMode, type WorkMode, type ApprovalDecision, + type OverrideAction, type SessionState, type TimelineItem, } from "@codedock/core/chat"; @@ -363,21 +364,24 @@ export function useSessionTimeline(sessionId: string | undefined) { }, [client]); const decide = useCallback( - async (approvalId: string, decisions: ApprovalDecision[]) => { + async (approvalId: string, decisions: ApprovalDecision[], extra?: { override?: OverrideAction }) => { if (!sessionId) { return; } try { let covered = decisions; - try { - const latest = await client.getApproval(approvalId); - covered = decisionsForApproval(latest, decisions); - } catch { - covered = decisions; + if (!extra?.override) { + try { + const latest = await client.getApproval(approvalId); + covered = decisionsForApproval(latest, decisions); + } catch { + covered = decisions; + } } const approval = await client.decideApproval(approvalId, { - decisions: covered, + decisions: extra?.override ? [] : covered, actor_id: userId, + override: extra?.override, }); setState((current) => { if (sessionRef.current !== sessionId) { @@ -404,17 +408,21 @@ export function useSessionTimeline(sessionId: string | undefined) { item.kind === "approval" && item.status === "pending", ); for (const item of pendingApprovals) { - const decisions: ApprovalDecision[] = item.toolCalls - .filter((call) => call.id) - .map((call) => ({ - tool_call_id: call.id, - status: "denied", - reason: "发送排队消息,已中断审批", - })); - if (decisions.length === 0) { - continue; - } try { + if (item.approvalKind === "verify" || item.approvalKind === "evaluate") { + await decide(item.approvalId, [], { override: "abort" }); + continue; + } + const decisions: ApprovalDecision[] = item.toolCalls + .filter((call) => call.id) + .map((call) => ({ + tool_call_id: call.id, + status: "denied", + reason: "发送排队消息,已中断审批", + })); + if (decisions.length === 0) { + continue; + } await decide(item.approvalId, decisions); } catch { // 单条审批失败不挡住后续拒绝与发送 diff --git a/packages/views/chat/lib/composer.test.ts b/packages/views/chat/lib/composer.test.ts new file mode 100644 index 0000000..aba0c92 --- /dev/null +++ b/packages/views/chat/lib/composer.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DEFAULT_APPROVAL_MODE, DEFAULT_WORK_MODE, parseApprovalMode, parseWorkMode } from "./composer.ts"; + +test("parseWorkMode keeps a known mode", () => { + assert.equal(parseWorkMode("plan"), "plan"); + assert.equal(parseWorkMode(" ask "), "ask"); +}); + +test("parseWorkMode falls back on junk", () => { + assert.equal(parseWorkMode(""), DEFAULT_WORK_MODE); + assert.equal(parseWorkMode("yolo"), DEFAULT_WORK_MODE); + assert.equal(parseWorkMode("nope", "ask"), "ask"); +}); + +test("parseApprovalMode keeps a known mode", () => { + assert.equal(parseApprovalMode("yolo"), "yolo"); + assert.equal(parseApprovalMode(" auto "), "auto"); +}); + +test("parseApprovalMode falls back on junk", () => { + assert.equal(parseApprovalMode(""), DEFAULT_APPROVAL_MODE); + assert.equal(parseApprovalMode("manaul"), DEFAULT_APPROVAL_MODE); + assert.equal(parseApprovalMode("agent", "yolo"), "yolo"); +}); diff --git a/packages/views/chat/lib/composer.ts b/packages/views/chat/lib/composer.ts new file mode 100644 index 0000000..1bd4767 --- /dev/null +++ b/packages/views/chat/lib/composer.ts @@ -0,0 +1,68 @@ +import type { ApprovalMode, WorkMode } from "@codedock/core/chat"; + +const WORK_MODE_KEY = "codedock.workMode"; +const APPROVAL_MODE_KEY = "codedock.approvalMode"; +const WORK_MODES: readonly WorkMode[] = ["ask", "plan", "agent"]; +const APPROVAL_MODES: readonly ApprovalMode[] = ["manual", "auto", "yolo"]; + +export const DEFAULT_WORK_MODE: WorkMode = "agent"; +export const DEFAULT_APPROVAL_MODE: ApprovalMode = "manual"; + +// parseWorkMode 只收 ask / plan / agent,其它回默认。 +export function parseWorkMode(raw: string | null | undefined, fallback: WorkMode = DEFAULT_WORK_MODE): WorkMode { + const value = raw?.trim(); + return value && WORK_MODES.includes(value as WorkMode) ? (value as WorkMode) : fallback; +} + +// parseApprovalMode 只收 manual / auto / yolo,其它回默认。 +export function parseApprovalMode( + raw: string | null | undefined, + fallback: ApprovalMode = DEFAULT_APPROVAL_MODE, +): ApprovalMode { + const value = raw?.trim(); + return value && APPROVAL_MODES.includes(value as ApprovalMode) ? (value as ApprovalMode) : fallback; +} + +// readLastWorkMode 读本机上次选的工作模式。 +export function readLastWorkMode(): WorkMode { + return parseWorkMode(readStored(WORK_MODE_KEY), DEFAULT_WORK_MODE); +} + +// writeLastWorkMode 把工作模式记到本机。 +export function writeLastWorkMode(mode: WorkMode): void { + writeStored(WORK_MODE_KEY, parseWorkMode(mode)); +} + +// readLastApprovalMode 读本机上次选的审批模式。 +export function readLastApprovalMode(): ApprovalMode { + return parseApprovalMode(readStored(APPROVAL_MODE_KEY), DEFAULT_APPROVAL_MODE); +} + +// writeLastApprovalMode 把审批模式记到本机。 +export function writeLastApprovalMode(mode: ApprovalMode): void { + writeStored(APPROVAL_MODE_KEY, parseApprovalMode(mode)); +} + +// readStored 读本机字符串;读不到当没有。 +function readStored(key: string): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +// writeStored 把字符串记到本机。 +function writeStored(key: string, value: string): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(key, value); + } catch { + // ignore quota / private mode + } +} diff --git a/packages/views/chat/lib/files-window.test.ts b/packages/views/chat/lib/files-window.test.ts new file mode 100644 index 0000000..8160ecc --- /dev/null +++ b/packages/views/chat/lib/files-window.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { FILES_WINDOW_ID, nextFilesWindows, runFileTitle, type FileWindow } from "./files-window.ts"; + +const files = [ + { path: "a.py", content: "a", action: "write" as const, patch: "+a", runId: "r1" }, + { path: "b.ts", content: "b", action: "edit" as const, patch: "+b", runId: "r1" }, +]; + +test("nextFilesWindows opens a files pane on first create", () => { + const next = nextFilesWindows([], files, true); + assert.equal(next.length, 1); + assert.equal(next[0]?.kind, "file"); + assert.equal(next[0]?.selectedKey, ""); + assert.equal(next[0]?.id, FILES_WINDOW_ID); +}); + +test("nextFilesWindows does not reopen after the user closed it", () => { + assert.deepEqual(nextFilesWindows([], files, false), []); +}); + +test("nextFilesWindows keeps a live selection and clears it when it disappears", () => { + const current: FileWindow[] = [{ id: FILES_WINDOW_ID, kind: "file", title: "文件", selectedKey: "gone" }]; + const next = nextFilesWindows(current, files, false); + assert.equal(next[0]?.selectedKey, ""); + const kept = nextFilesWindows( + [{ id: FILES_WINDOW_ID, kind: "file", title: "文件", selectedKey: "r1:b.ts" }], + files, + false, + ); + assert.equal(kept[0]?.selectedKey, "r1:b.ts"); +}); + +test("runFileTitle uses the user line of that run", () => { + assert.equal( + runFileTitle( + [ + { kind: "user", id: "u", runId: "r1", messageId: "m", text: "加上借还书\n第二行", seq: 1 }, + { kind: "user", id: "u2", runId: "r2", messageId: "m2", text: "别的", seq: 2 }, + ], + "r1", + ), + "加上借还书", + ); + assert.equal(runFileTitle([], "r9"), "改动"); +}); diff --git a/packages/views/chat/lib/files-window.ts b/packages/views/chat/lib/files-window.ts new file mode 100644 index 0000000..a0fe398 --- /dev/null +++ b/packages/views/chat/lib/files-window.ts @@ -0,0 +1,56 @@ +import { fileKey, firstLine, type FileChangePreview, type TimelineItem } from "@codedock/core/chat"; + +export const FILES_WINDOW_ID = "files"; + +export type FileWindow = { + id: string; + kind: "file"; + title: string; + selectedKey: string; // runId:path,用来对上某一轮的那份 diff +}; + +type DockLike = { id: string; kind: string; selectedKey?: string }; + +// nextFilesWindows 同步文件窗口;create 时只补开列表,不预选文件。 +export function nextFilesWindows( + current: T[], + files: FileChangePreview[], + create: boolean, +): T[] { + const existing = current.find((item) => item.id === FILES_WINDOW_ID); + if (!existing) { + if (!create || files.length === 0) { + return current; + } + return [ + ...current, + { + id: FILES_WINDOW_ID, + kind: "file", + title: "文件", + selectedKey: "", + } as unknown as T, + ]; + } + if (existing.kind !== "file") { + return current; + } + const selected = files.some((file) => fileKey(file) === existing.selectedKey) + ? (existing.selectedKey ?? "") + : ""; + if (selected === existing.selectedKey) { + return current; + } + return current.map((item) => + item.id === FILES_WINDOW_ID && item.kind === "file" ? { ...item, selectedKey: selected } : item, + ); +} + +// runFileTitle 用该轮用户原话当框标题,没有就退回「改动」。 +export function runFileTitle(items: TimelineItem[], runId: string): string { + const user = items.find((item) => item.kind === "user" && item.runId === runId); + if (user?.kind === "user" && user.text.trim()) { + return firstLine(user.text, 36); + } + return "改动"; +} diff --git a/packages/views/chat/lib/terminal.test.ts b/packages/views/chat/lib/terminal.test.ts new file mode 100644 index 0000000..f481aa1 --- /dev/null +++ b/packages/views/chat/lib/terminal.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { terminalStatusCopy } from "./terminal.ts"; + +test("terminalStatusCopy spells completed and model errors", () => { + assert.equal(terminalStatusCopy({ status: "completed" }), "Run completed"); + assert.equal(terminalStatusCopy({ status: "cancelled" }), "Cancelled"); + assert.equal(terminalStatusCopy({ status: "failed", stopReason: "model_error" }), "Model request failed"); + assert.equal( + terminalStatusCopy({ + status: "failed", + stopReason: "model_error", + error: "Service is too busy.", + }), + "Model request failed: Service is too busy.", + ); +}); diff --git a/packages/views/chat/lib/terminal.ts b/packages/views/chat/lib/terminal.ts new file mode 100644 index 0000000..cb9d416 --- /dev/null +++ b/packages/views/chat/lib/terminal.ts @@ -0,0 +1,19 @@ +/** 把 Run 终态收成一行英文说明。 */ +export function terminalStatusCopy(input: { + status: string; + stopReason?: string; + error?: string; +}): string { + if (input.status === "completed") { + return "Run completed"; + } + if (input.status === "cancelled") { + return "Cancelled"; + } + const error = input.error?.trim(); + if (input.stopReason === "model_error" || input.status === "failed") { + return error ? `Model request failed: ${error}` : "Model request failed"; + } + const reason = input.stopReason || input.status; + return error ? `Run ended: ${reason}. ${error}` : `Run ended: ${reason}`; +} diff --git a/packages/views/chat/plan-preview.tsx b/packages/views/chat/plan-preview.tsx index 8d4ad11..6178a56 100644 --- a/packages/views/chat/plan-preview.tsx +++ b/packages/views/chat/plan-preview.tsx @@ -1,32 +1,35 @@ "use client"; -import type { PlanPreview, ToolItemState } from "@codedock/core/chat"; +import { planReadableContent, type PlanPreview, type ToolItemState } from "@codedock/core/chat"; import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger, + LiveStatus, MessageResponse, } from "@codedock/ui"; import { ChevronDownIcon, FileText } from "lucide-react"; import { useState } from "react"; const stateLabel: Record>> = { - pending: { write: "正在写入", read: "正在读取", doc: "处理中" }, - running: { write: "正在写入", read: "正在读取", doc: "处理中" }, - completed: { write: "已写入", read: "已读取", doc: "已完成" }, - error: { write: "写入失败", read: "读取失败", doc: "失败" }, - denied: { write: "已拒绝", read: "已拒绝", doc: "已拒绝" }, + pending: { write: "Writing", read: "Reading", doc: "Working" }, + running: { write: "Writing", read: "Reading", doc: "Working" }, + completed: { write: "Wrote", read: "Read", doc: "Done" }, + error: { write: "Write failed", read: "Read failed", doc: "Failed" }, + denied: { write: "Denied", read: "Denied", doc: "Denied" }, }; export function PlanPreviewCard({ preview, state = "completed", + live = false, error, className, }: { preview: PlanPreview; state?: ToolItemState; + live?: boolean; error?: string; className?: string; }) { @@ -49,9 +52,11 @@ export function PlanPreviewCard({ > - 计划 + Plan {preview.name} - {status} + + {status} + @@ -61,7 +66,7 @@ export function PlanPreviewCard({ @@ -69,7 +74,8 @@ export function PlanPreviewCard({ ); } -function PlanDocBody({ +// PlanDocBody 渲染计划 Markdown,给对话卡片和右侧窗口共用。 +export function PlanDocBody({ content, emptyHint, animating, @@ -78,8 +84,13 @@ function PlanDocBody({ emptyHint: string; animating: boolean; }) { + content = planReadableContent(content); if (!content.trim()) { - return

{emptyHint}

; + return ( +

+ {emptyHint} +

+ ); } return ( @@ -90,10 +101,10 @@ function PlanDocBody({ function emptyDocHint(source: "write" | "read", state: ToolItemState): string { if (source === "read" && (state === "pending" || state === "running")) { - return "正在读取…"; + return "Reading"; } if (source === "write" && (state === "pending" || state === "running")) { - return "正在写入…"; + return "Writing"; } - return "空计划"; + return "Empty plan"; } diff --git a/packages/views/chat/prompt-bar.tsx b/packages/views/chat/prompt-bar.tsx index ad55979..066cd50 100644 --- a/packages/views/chat/prompt-bar.tsx +++ b/packages/views/chat/prompt-bar.tsx @@ -14,6 +14,15 @@ import { import { ChevronUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { + DEFAULT_APPROVAL_MODE, + DEFAULT_WORK_MODE, + readLastApprovalMode, + readLastWorkMode, + writeLastApprovalMode, + writeLastWorkMode, +} from "./lib/composer.ts"; + const workModes: { value: WorkMode; label: string }[] = [ { value: "agent", label: "agent" }, { value: "ask", label: "ask" }, @@ -26,6 +35,7 @@ const approvalModes: { value: ApprovalMode; label: string }[] = [ { value: "yolo", label: "yolo" }, ]; +// PromptBar 本地对话输入栏;工作模式和审批模式记在本机,刷新后还原。 export function PromptBar({ running, sending, @@ -40,15 +50,38 @@ export function PromptBar({ className?: string; }) { const [text, setText] = useState(""); - const [mode, setMode] = useState("agent"); - const [approval, setApproval] = useState("manual"); + const [mode, setMode] = useState(DEFAULT_WORK_MODE); + const [approval, setApproval] = useState(DEFAULT_APPROVAL_MODE); + const [hydrated, setHydrated] = useState(false); const inputRef = useRef(null); + + // 等 hydration 后再读本机缓存,避免 SSR 文本对不上。 + useEffect(() => { + setMode(readLastWorkMode()); + setApproval(readLastApprovalMode()); + setHydrated(true); + }, []); + + // rememberWorkMode 记下工作模式,刷新后还用这个。 + const rememberWorkMode = (next: WorkMode) => { + setMode(next); + writeLastWorkMode(next); + }; + // rememberApprovalMode 记下审批模式,刷新后还用这个。 + const rememberApprovalMode = (next: ApprovalMode) => { + setApproval(next); + writeLastApprovalMode(next); + }; const keepFocus = () => { inputRef.current?.focus(); }; return ( -
+
{ const next = message.text.trim(); @@ -68,6 +101,7 @@ export function PromptBar({ ref={inputRef} value={text} autoFocus + data-testid="composer-input" placeholder="给 Local 发消息…" onChange={(event) => setText(event.currentTarget.value)} onKeyDown={(event) => { @@ -80,10 +114,10 @@ export function PromptBar({ /> - - + + {running ? ( - ) : null} @@ -91,6 +125,7 @@ export function PromptBar({ @@ -98,14 +133,17 @@ export function PromptBar({ ); } +// ChoiceMenu 从底部弹出选项,选中后关掉。 function ChoiceMenu({ value, options, onChange, + testid, }: { value: T; options: { value: T; label: string }[]; onChange: (value: T) => void; + testid: string; }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -140,6 +178,7 @@ function ChoiceMenu({ variant="outline" aria-expanded={open} aria-haspopup="listbox" + data-testid={testid} onClick={() => setOpen((currentOpen) => !currentOpen)} > {current} @@ -155,6 +194,7 @@ function ChoiceMenu({ key={item.value} type="button" role="option" + data-testid={`${testid}-${item.value}`} aria-selected={item.value === value} className={ item.value === value diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index ff0f38e..cba483c 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -25,6 +25,7 @@ export function SessionSidebar({ brandSrc, codexIconSrc, claudeIconSrc, + width, }: { sessions: SidebarSession[]; currentId?: string; @@ -40,9 +41,13 @@ export function SessionSidebar({ brandSrc?: string; codexIconSrc?: string; claudeIconSrc?: string; + width?: number; }) { return ( -