diff --git a/cmd/fake-acp-agent/main.go b/cmd/fake-acp-agent/main.go new file mode 100644 index 0000000..9699f9c --- /dev/null +++ b/cmd/fake-acp-agent/main.go @@ -0,0 +1,159 @@ +// cmd/fake-acp-agent 是集成测试用的 fake ACP agent,stdin/stdout 走 JSON-RPC。 +// +// 行为通过 env 控制: +// +// FAKE_ACP_HANG=true 永不响应任何请求(测 handshake 超时) +// FAKE_ACP_CRASH_AFTER_PROMPTS=N 处理完 N 个 prompt 后 os.Exit(1) +// FAKE_ACP_FS_READ=path prompt 时发起 fs/read_text_file 请求该路径 +// FAKE_ACP_FS_WRITE=path:content prompt 时发起 fs/write_text_file +// FAKE_ACP_PROMPT_UPDATES=N 每 prompt 发 N 个 update(默认 3) +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "sync/atomic" + "time" +) + +type Message struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +var ( + promptsHandled atomic.Int32 + nextServerID atomic.Int64 +) + +func main() { + if os.Getenv("FAKE_ACP_HANG") == "true" { + select {} // 永不退出 + } + + in := bufio.NewReader(os.Stdin) + out := bufio.NewWriter(os.Stdout) + defer out.Flush() + + for { + line, err := in.ReadBytes('\n') + if err != nil { + return + } + var msg Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + switch msg.Method { + case "initialize": + respond(out, msg.ID, map[string]any{ + "protocolVersion": 1, + "agentInfo": map[string]any{"name": "fake", "version": "0.0.0"}, + }) + case "session/new": + respond(out, msg.ID, map[string]any{"sessionId": "fake-session-id"}) + case "session/prompt": + handlePrompt(out, msg.ID) + case "session/cancel": + // ignore + } + } +} + +func handlePrompt(out *bufio.Writer, id json.RawMessage) { + updates := 3 + if v := os.Getenv("FAKE_ACP_PROMPT_UPDATES"); v != "" { + if n, _ := strconv.Atoi(v); n > 0 { + updates = n + } + } + for i := 0; i < updates; i++ { + notify(out, "session/update", map[string]any{ + "sessionId": "fake-session-id", + "update": map[string]any{ + "sessionUpdate": "agent_message_chunk", + "content": []map[string]any{{"type": "text", "text": fmt.Sprintf("chunk %d", i)}}, + }, + }) + time.Sleep(10 * time.Millisecond) + } + + if path := os.Getenv("FAKE_ACP_FS_READ"); path != "" { + _ = doFsRead(out, path) + } + + if spec := os.Getenv("FAKE_ACP_FS_WRITE"); spec != "" { + parts := strings.SplitN(spec, ":", 2) + if len(parts) == 2 { + _ = doFsWrite(out, parts[0], parts[1]) + } + } + + respond(out, id, map[string]any{"stopReason": "end_turn"}) + + if maxStr := os.Getenv("FAKE_ACP_CRASH_AFTER_PROMPTS"); maxStr != "" { + n, _ := strconv.Atoi(maxStr) + count := promptsHandled.Add(1) + if int(count) >= n { + os.Exit(1) + } + } +} + +func doFsRead(out *bufio.Writer, path string) error { + id := nextServerID.Add(1) + idJSON, _ := json.Marshal(id) + body := mustJSON(Message{ + JSONRPC: "2.0", ID: idJSON, Method: "fs/read_text_file", + Params: mustJSON(map[string]any{"sessionId": "fake-session-id", "path": path}), + }) + _, _ = out.Write(body) + _, _ = out.Write([]byte{'\n'}) + _ = out.Flush() + return nil +} + +func doFsWrite(out *bufio.Writer, path, content string) error { + id := nextServerID.Add(1) + idJSON, _ := json.Marshal(id) + body := mustJSON(Message{ + JSONRPC: "2.0", ID: idJSON, Method: "fs/write_text_file", + Params: mustJSON(map[string]any{"sessionId": "fake-session-id", "path": path, "content": content}), + }) + _, _ = out.Write(body) + _, _ = out.Write([]byte{'\n'}) + _ = out.Flush() + return nil +} + +func respond(out *bufio.Writer, id json.RawMessage, result any) { + body := mustJSON(Message{JSONRPC: "2.0", ID: id, Result: mustJSON(result)}) + _, _ = out.Write(body) + _, _ = out.Write([]byte{'\n'}) + _ = out.Flush() +} + +func notify(out *bufio.Writer, method string, params any) { + body := mustJSON(Message{JSONRPC: "2.0", Method: method, Params: mustJSON(params)}) + _, _ = out.Write(body) + _, _ = out.Write([]byte{'\n'}) + _ = out.Flush() +} + +func mustJSON(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +}