Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .cursor/verify.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ tmp/
node_modules
.pnpm-store
.pnpm-debug.log*
/test-results/
/playwright-report/
/apps/web/test-results/
/apps/web/playwright-report/
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand All @@ -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`。

Expand All @@ -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 包或路由框架。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 或密钥。

## 测试

Expand Down
47 changes: 29 additions & 18 deletions apps/web/app/chat-host.tsx
Original file line number Diff line number Diff line change
@@ -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。
Expand All @@ -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 (
<ChatPage
sessionId={parsed.sessionId}
engine={parsed.engine}
brandSrc="/brand/codedock-berth-mark.svg"
codexIconSrc="/brand/codex-app-icon.png"
claudeIconSrc="/brand/claude-app-icon.svg"
onOpenSession={(id, engine = parsed.engine ?? "agent") => {
const path = pathFor(id, engine);
if (pathname !== path) {
router.push(path);
}
}}
onNewConversation={() => {
if (pathname !== "/") {
router.push("/");
}
}}
/>
<GitProvider client={git} sessionId={gitSessionId}>
<ChatPage
sessionId={parsed.sessionId}
engine={parsed.engine}
brandSrc="/brand/codedock-berth-mark.svg"
codexIconSrc="/brand/codex-app-icon.png"
claudeIconSrc="/brand/claude-app-icon.svg"
onOpenSession={(id, engine = parsed.engine ?? "agent") => {
const path = pathFor(id, engine);
if (pathname !== path) {
router.push(path);
}
}}
onNewConversation={() => {
if (pathname !== "/") {
router.push("/");
}
}}
/>
</GitProvider>
);
}

Expand Down
7 changes: 3 additions & 4 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -20,16 +19,16 @@ export const metadata: Metadata = {
description: "Local 对话工作台",
};

// RootLayout 包字体、主题和全局 Provider。
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full dark antialiased`}
>
<body className="flex h-full flex-col bg-background font-sans text-foreground antialiased">
<body className="h-full bg-background font-sans text-foreground antialiased">
<Providers>
{process.env.NODE_ENV === "development" ? <TestNav /> : null}
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
<div className="h-full">{children}</div>
</Providers>
</body>
</html>
Expand Down
38 changes: 0 additions & 38 deletions apps/web/app/test-nav.tsx

This file was deleted.

82 changes: 82 additions & 0 deletions apps/web/e2e/coding.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading