mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
## 是什么
agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。
38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。
## sidecar 后端抽象(本轮核心)
传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:
driver.json 描述一个后端:script / cliPath / env / args / llmConfig
三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}
★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。
默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
· 单线:commondev_branch_open(cli="zcode")
· 按机器:commondev_endpoint_set(host=..., cli="zcode")
## zcode 接入(三条真机判据,全部要求真改工程文件)
· TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
· TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
· TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
cli_path,走端点表而不是全局 settings
★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。
## 其他
· rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
· 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
· secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
· 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)
判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。
274 lines
9.6 KiB
Go
274 lines
9.6 KiB
Go
package main
|
||
|
||
// ★★★ 端到端:**真 pi 真的能接受工程并改代码**。
|
||
//
|
||
// ## 这条判据为什么最重要
|
||
//
|
||
// 覆盖度审计发现:此前**没有任何判据**验证过这条链路。所有测试都停在
|
||
// "工具存在" / "参数被记账" / "驱动被加载" 这一层,而**最根本的假设从未被验证**:
|
||
//
|
||
// 投一句话 → sidecar 转给 pi → pi 用工具**真的改了工作目录的文件**
|
||
//
|
||
// 如果这个假设不成立,整个插件就是空壳 —— 台账、通道、判据复核全都在
|
||
// 正确运转,但 agent 什么都没做。
|
||
//
|
||
// ## 为什么必须走 sidecar(不走 pi 的 API 直调)
|
||
//
|
||
// 直调 pi 的 `createAgentSession` 只能证明"pi 可用",证明不了
|
||
// **本插件的 sidecar 契约是对的**(stdin JSON-RPC、session_id 回报、
|
||
// 裸名模块解析)。实测中这三处每一处都可能单独失败:
|
||
// - 方法名不是 send 而是 prompt(我第一版就猜错了)
|
||
// - 裸名 `@earendil-works/pi-coding-agent` 解析不到会**静默回退到宿主机的 pi**
|
||
//
|
||
// ## 环境要求
|
||
//
|
||
// 需要插件目录下有内嵌运行时(node + pi):
|
||
//
|
||
// HA_PI_E2E=1 go test -run TestRealPiEditsFiles ./...
|
||
//
|
||
// 默认跳过(内嵌运行时是构建产物,不进版本库;且真跑一次要几十秒到几分钟)。
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// piE2EReady 检查内嵌运行时是否就绪。
|
||
func piE2EReady(t *testing.T) (nodeBin, sidecarPath string) {
|
||
t.Helper()
|
||
if os.Getenv("HA_PI_E2E") != "1" {
|
||
t.Skip("设 HA_PI_E2E=1 以运行(需要 rawfile/ 下有内嵌 node+pi,且会真调 LLM)")
|
||
}
|
||
root, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
nodeBin = filepath.Join(root, "rawfile", "node", "bin", "node")
|
||
if _, err := os.Stat(nodeBin); err != nil {
|
||
t.Skipf("内嵌 node 不存在: %v", err)
|
||
}
|
||
piMod := filepath.Join(root, "rawfile", "pi", "node_modules", "@earendil-works", "pi-coding-agent")
|
||
if _, err := os.Stat(piMod); err != nil {
|
||
t.Skipf("内嵌 pi 不存在: %v", err)
|
||
}
|
||
sidecarPath = filepath.Join(root, "sidecar.mjs")
|
||
if _, err := os.Stat(sidecarPath); err != nil {
|
||
t.Fatalf("sidecar.mjs 不存在: %v", err)
|
||
}
|
||
return nodeBin, sidecarPath
|
||
}
|
||
|
||
// rpcResponse 是 sidecar 的应答。
|
||
type rpcResponse struct {
|
||
ID int `json:"id"`
|
||
Result json.RawMessage `json:"result"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
// startSidecarForE2E 拉起真实 sidecar 并完成握手。
|
||
//
|
||
// 走**与 Go 侧完全一样的契约**:stdin 写 JSON-RPC 行,stdout 读应答行。
|
||
func startSidecarForE2E(t *testing.T, nodeBin, sidecarPath, workdir, sessionID string) (*exec.Cmd, *bufio.Writer, *bufio.Reader) {
|
||
t.Helper()
|
||
pluginDir := filepath.Dir(sidecarPath)
|
||
cmd := exec.Command(nodeBin, sidecarPath,
|
||
"--workdir", workdir,
|
||
"--session-id", sessionID,
|
||
"--session-name", "e2e-"+sessionID,
|
||
)
|
||
cmd.Dir = workdir
|
||
// ★ 与 driver.json 里声明的 env 一致(PI_CODING_AGENT_DIR/SESSION_DIR)。
|
||
// ★ PI_CODING_AGENT_DIR 指向**已配好 LLM 源**的 agentdir。
|
||
// 内嵌 pi 是干净的(无凭据),必须先把源导进去 —— 这正是
|
||
// `commondev_pi_source_import` 的职责。判据用现成的 agentdir,
|
||
// 顺带验证"导入后的配置能被内嵌 pi 直接使用"。
|
||
agentDir := os.Getenv("HA_PI_AGENTDIR")
|
||
if agentDir == "" {
|
||
agentDir = filepath.Join(pluginDir, "e2e-agentdir")
|
||
}
|
||
cmd.Env = append(os.Environ(),
|
||
"PI_CODING_AGENT_DIR="+agentDir,
|
||
"PI_CODING_AGENT_SESSION_DIR="+filepath.Join(workdir, ".pi-sessions"),
|
||
)
|
||
stdin, err := cmd.StdinPipe()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stdout, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cmd.Stderr = nil // 忽略 pi 的 a2a/acp 端口告警
|
||
if err := cmd.Start(); err != nil {
|
||
t.Fatalf("启动 sidecar 失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_ = cmd.Process.Kill()
|
||
_, _ = cmd.Process.Wait()
|
||
})
|
||
return cmd, bufio.NewWriter(stdin), bufio.NewReader(stdout)
|
||
}
|
||
|
||
// callSidecar 发一条 JSON-RPC 并读回对应 id 的应答。
|
||
func callSidecar(t *testing.T, w *bufio.Writer, r *bufio.Reader, id int, method string, params map[string]interface{}) *rpcResponse {
|
||
t.Helper()
|
||
req := map[string]interface{}{"jsonrpc": "2.0", "id": id, "method": method}
|
||
if params != nil {
|
||
req["params"] = params
|
||
}
|
||
b, _ := json.Marshal(req)
|
||
if _, err := w.WriteString(string(b) + "\n"); err != nil {
|
||
t.Fatalf("写 sidecar 失败: %v", err)
|
||
}
|
||
if err := w.Flush(); err != nil {
|
||
t.Fatalf("flush 失败: %v", err)
|
||
}
|
||
deadline := time.Now().Add(5 * time.Minute)
|
||
for time.Now().Before(deadline) {
|
||
line, err := r.ReadString('\n')
|
||
if err != nil {
|
||
t.Fatalf("读 sidecar 失败(method=%s): %v", method, err)
|
||
}
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var resp rpcResponse
|
||
if err := json.Unmarshal([]byte(line), &resp); err != nil {
|
||
continue // 非 JSON 行(pi 的日志)跳过
|
||
}
|
||
if resp.ID == id {
|
||
return &resp
|
||
}
|
||
}
|
||
t.Fatalf("等 sidecar 应答超时(method=%s)", method)
|
||
return nil
|
||
}
|
||
|
||
// ★★★ 判据:**真 pi 真的按指令改了工作目录里的文件**。
|
||
//
|
||
// 这是「agent 能接受工程并更新」的地基。整条链:
|
||
//
|
||
// 真 sidecar 进程 → 真 pi 会话 → pi 调自己的写文件工具 → 工作目录出现文件
|
||
func TestRealPiEditsFiles(t *testing.T) {
|
||
nodeBin, sidecarPath := piE2EReady(t)
|
||
workdir := t.TempDir()
|
||
sessionID := "e2e-edit"
|
||
|
||
_, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID)
|
||
|
||
// ① 先握手(sidecar 契约的第一环)
|
||
ping := callSidecar(t, w, r, 1, "ping", nil)
|
||
if ping.Error != nil {
|
||
t.Fatalf("ping 失败: %s", ping.Error.Message)
|
||
}
|
||
var pingRes map[string]interface{}
|
||
if err := json.Unmarshal(ping.Result, &pingRes); err != nil {
|
||
t.Fatalf("ping 应答不是对象: %s", ping.Result)
|
||
}
|
||
if ok, _ := pingRes["ok"].(bool); !ok {
|
||
t.Fatalf("ping 未返回 ok: %v", pingRes)
|
||
}
|
||
// ★ 必须回报 pi 版本(U8 要求可锁定内层 pi)——取不到要如实为 null,
|
||
// 不许编一个。这里是断言"字段存在",至于是否为空由实现诚实性决定。
|
||
if _, has := pingRes["pi_version"]; !has {
|
||
t.Error("ping 应回报 pi_version(用于锁定内层 pi 版本)")
|
||
} else {
|
||
t.Logf("内层 pi 版本: %v", pingRes["pi_version"])
|
||
}
|
||
|
||
// ② 给一个必须动文件的指令
|
||
const wantFile = "hello.txt"
|
||
const wantText = "hello from pi"
|
||
resp := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{
|
||
"session_id": sessionID,
|
||
"text": "在当前工作目录创建文件 " + wantFile +
|
||
",内容恰好一行:" + wantText + "。只做这一件事,不要创建别的文件。",
|
||
})
|
||
if resp.Error != nil {
|
||
t.Fatalf("prompt 失败: %s", resp.Error.Message)
|
||
}
|
||
var pr map[string]interface{}
|
||
if err := json.Unmarshal(resp.Result, &pr); err != nil {
|
||
t.Fatalf("prompt 应答不是对象: %s", resp.Result)
|
||
}
|
||
// ★ 会话不得漂移(Go 侧 deliverToPi 会据此拒绝继续)
|
||
if got, _ := pr["session_id"].(string); got != "" && got != sessionID {
|
||
t.Fatalf("★ 会话漂移: 期望 %s,实际 %s", sessionID, got)
|
||
}
|
||
|
||
// ③ **关键断言**:文件真的出现在工作目录里,且内容正确
|
||
path := filepath.Join(workdir, wantFile)
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
// 列出目录帮助定位(是没创建,还是创建到了别处)
|
||
entries, _ := os.ReadDir(workdir)
|
||
var names []string
|
||
for _, e := range entries {
|
||
names = append(names, e.Name())
|
||
}
|
||
t.Fatalf("★ pi 没有创建工作目录下的 %s —— 说明「接受工程并更新」不成立。目录内容: %v",
|
||
wantFile, names)
|
||
}
|
||
if !strings.Contains(string(b), wantText) {
|
||
t.Errorf("★ 文件内容不对: 期望含 %q,实际 %q", wantText, string(b))
|
||
}
|
||
t.Logf("✅ 真 pi 改文件成功: %s = %q", wantFile, strings.TrimSpace(string(b)))
|
||
}
|
||
|
||
// ★★★ 判据:**同一 session 的第二次投递续在同一会话**(不是新会话)。
|
||
//
|
||
// 「续投」是这个插件的核心能力(设计:session_id 是续投的唯一依据)。
|
||
// 若第二次投递另起会话,agent 就失去了上下文,工程对话不再连续。
|
||
func TestRealPiKeepsSessionAcrossPrompts(t *testing.T) {
|
||
nodeBin, sidecarPath := piE2EReady(t)
|
||
workdir := t.TempDir()
|
||
sessionID := "e2e-cont"
|
||
|
||
_, w, r := startSidecarForE2E(t, nodeBin, sidecarPath, workdir, sessionID)
|
||
callSidecar(t, w, r, 1, "ping", nil)
|
||
|
||
// 第一句:建立上下文(记住一个数)
|
||
resp1 := callSidecar(t, w, r, 2, "prompt", map[string]interface{}{
|
||
"session_id": sessionID,
|
||
"text": "请记住这个数字:7391。只回复「记住了」。",
|
||
})
|
||
if resp1.Error != nil {
|
||
t.Fatalf("第一次投递失败: %s", resp1.Error.Message)
|
||
}
|
||
var p1 map[string]interface{}
|
||
json.Unmarshal(resp1.Result, &p1)
|
||
got1, _ := p1["session_id"].(string)
|
||
if got1 != sessionID {
|
||
t.Fatalf("★ 首次投递会话不对: %s", got1)
|
||
}
|
||
|
||
// 第二句:问它那个数 —— 只有**同一会话**才答得出
|
||
resp2 := callSidecar(t, w, r, 3, "prompt", map[string]interface{}{
|
||
"session_id": sessionID,
|
||
"text": "我刚才让你记的数字是多少?只回复那个数字。",
|
||
})
|
||
if resp2.Error != nil {
|
||
t.Fatalf("第二次投递失败: %s", resp2.Error.Message)
|
||
}
|
||
var p2 map[string]interface{}
|
||
json.Unmarshal(resp2.Result, &p2)
|
||
got2, _ := p2["session_id"].(string)
|
||
if got2 != sessionID {
|
||
t.Fatalf("★ 第二次投递会话漂移: 期望 %s,实际 %s(续投失效)", sessionID, got2)
|
||
}
|
||
reply, _ := p2["reply"].(string)
|
||
if !strings.Contains(reply, "7391") {
|
||
t.Errorf("★ 第二次回复未体现会话连续性(期望含 7391): %q", reply)
|
||
}
|
||
t.Logf("✅ 会话连续性成立,第二次回复: %q", strings.TrimSpace(reply))
|
||
}
|