Files
040b0f1944 fix(acp): Gemini ACP protocol fixes and multi-session architecture (#901)
* fix(cli): add missing X-GoClaw-User-Id header to gateway client

* feat(acp): comprehensive integration with Gemini ACP protocol

- Support nested JSON-RPC notification structures
- Add robust streaming text collection and mapping
- Increase handshake timeout to 60s for heavy model initialization
- Fix WebSocket user_id authentication and schema v47 compatibility
- Allow Google/GCP environment variables for ACP subprocesses

* refactor(acp): multi-session architecture with session tracing and temp session cleanup

- One shared Gemini process, multiple ACP sessions per process (one per goclaw conversation)
- resolveSession: per-key mutex prevents TOCTOU race on concurrent session creation
- Respawn detection via proc pointer comparison; session/load fallback after crash
- sessionReaper: purges ACP sessions idle >30min; temp- sessions purged immediately on completion
- WithGoclawSession context propagation: goclaw session key appears alongside ACP sid in all logs
- dispatchUpdate: Gemini agent_message_chunk protocol mapping normalized here
- session_test.go: full coverage of multi-session API (Initialize, NewSession, Prompt, Cancel, dispatch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): align with ACP protocol standard (SDK v0.16.1)

- InitializeRequest: "capabilities" → "clientCapabilities" (standard field name)
- LoadSessionRequest: add mcpServers field (required by standard)
- mapStopReason: add standard stop reasons (max_tokens, cancelled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): address review findings from PR #901

- B1: Restore RequiredSchemaVersion to 55 (was incorrectly set to 47)
- S1: Move X-GoClaw-User-Id header outside token check
- S2: Add env gate (ACP_GEMINI_E2E) to Gemini E2E test
- S3: Fix tab indentation in jsonrpc.go writeMessage
- Build: Extract Pdeathsig to platform-specific files for cross-platform build

* fix(acp): address S4-S7 review findings

S4/S5: Send session/cancel before purging sessions locally
- purgeSession() now cancels ACP session before deleting map entry
- sessionReaper() sends cancel notification for idle sessions
- Updated comments to reflect actual behavior

S6: Tighten GOOGLE_/GCP_ env var filtering
- Add GOOGLE_, GCP_ back to sensitiveEnvPrefixes
- Add allowedEnvExact for safe vars: GOOGLE_API_KEY,
  GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_CLOUD_PROJECT, GCP_PROJECT

S7: Propagate ACP errors to callers
- Chat/ChatStream now return err alongside ChatResponse
- Enables upstream retry logic and error metrics

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-18 12:17:23 +07:00

467 lines
12 KiB
Go

package acp
import (
"context"
"encoding/json"
"io"
"strings"
"testing"
"time"
)
// buildACPProcess creates an ACPProcess wired to an in-process io.Pipe conn.
// Returns the process, a writer the test can inject responses into (serverW),
// and a reader the test can read outbound requests from (serverR).
//
// The returned serverR is a *bufferedPipeReader that always drains — so the
// conn's writeMessage never blocks even if the test ignores outbound bytes.
//
// clientW → serverR (test reads what the process sends)
// serverW → clientR (test injects responses to the process)
func buildACPProcess(handler RequestHandler, extraNotify NotifyHandler) (
proc *ACPProcess,
serverW io.WriteCloser,
serverR io.ReadCloser,
) {
clientR, sW := io.Pipe() // test writes here → conn reads
sR, clientW := io.Pipe() // conn writes here → test reads
// Create proc first so the notifyHandler closure can reference it.
proc = &ACPProcess{
exited: make(chan struct{}),
ctx: context.Background(),
cancel: func() {},
}
// Mirror production spawn: always route session/update to proc.dispatchUpdate,
// then optionally forward to the test's extra notify hook.
internalNotify := func(method string, params json.RawMessage) {
if method == "session/update" {
var update SessionUpdate
if json.Unmarshal(params, &update) == nil {
proc.dispatchUpdate(update)
}
}
if extraNotify != nil {
extraNotify(method, params)
}
}
conn := NewConn(clientW, clientR, handler, internalNotify)
conn.Start()
proc.conn = conn
return proc, sW, sR
}
// drainPipe reads sR in background so writes to clientW never block.
// Returns a channel that receives each complete line written by the conn.
func drainPipeLines(sR io.ReadCloser) chan []byte {
ch := make(chan []byte, 64)
go func() {
defer close(ch)
buf := make([]byte, 32*1024)
for {
n, err := sR.Read(buf)
if n > 0 {
line := make([]byte, n)
copy(line, buf[:n])
select {
case ch <- line:
default:
}
}
if err != nil {
return
}
}
}()
return ch
}
// replyTo reads one JSON-RPC request from r, then writes a response with the
// given result JSON back to w. Runs in its own goroutine and signals done.
func replyTo(r io.Reader, w io.Writer, resultJSON string) chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 32*1024)
n, err := r.Read(buf)
if err != nil || n == 0 {
return
}
var req jsonrpcMessage
if err := json.Unmarshal(buf[:n], &req); err != nil {
return
}
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(resultJSON),
}
data, _ := json.Marshal(resp)
w.Write(append(data, '\n'))
}()
return done
}
// replyError reads one request and sends back a JSON-RPC error response.
func replyError(r io.Reader, w io.Writer, code int, message string) chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
buf := make([]byte, 32*1024)
n, _ := r.Read(buf)
if n == 0 {
return
}
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonrpcError{Code: code, Message: message},
}
data, _ := json.Marshal(resp)
w.Write(append(data, '\n'))
}()
return done
}
// --- Initialize tests ---
func TestACPProcess_Initialize_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
respJSON := `{"agentInfo":{"name":"claude","version":"1.0"},"agentCapabilities":{"loadSession":true}}`
done := replyTo(serverR, serverW, respJSON)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := proc.Initialize(ctx); err != nil {
t.Fatalf("Initialize error: %v", err)
}
if !proc.agentCaps.LoadSession {
t.Error("expected LoadSession=true after Initialize")
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for replyTo goroutine")
}
}
func TestACPProcess_Initialize_ErrorResponse(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32600, "not supported")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := proc.Initialize(ctx)
if err == nil {
t.Fatal("expected error from Initialize")
}
if !strings.Contains(err.Error(), "acp initialize") {
t.Errorf("expected 'acp initialize' prefix, got %q", err.Error())
}
<-done
}
func TestACPProcess_Initialize_Timeout(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
// Drain requests so writeMessage doesn't block, but never send a response.
go func() {
buf := make([]byte, 4096)
for {
_, err := serverR.Read(buf)
if err != nil {
return
}
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := proc.Initialize(ctx)
if err == nil {
t.Fatal("expected timeout error")
}
serverR.Close()
}
// --- NewSession tests ---
func TestACPProcess_NewSession_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyTo(serverR, serverW, `{"sessionId":"sess-xyz"}`)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
sid, err := proc.NewSession(ctx)
if err != nil {
t.Fatalf("NewSession error: %v", err)
}
if sid != "sess-xyz" {
t.Errorf("expected sessionID='sess-xyz', got %q", sid)
}
<-done
}
func TestACPProcess_NewSession_Error(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32000, "session failed")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := proc.NewSession(ctx)
if err == nil {
t.Fatal("expected error from NewSession")
}
if !strings.Contains(err.Error(), "acp session/new") {
t.Errorf("expected 'acp session/new' prefix, got %q", err.Error())
}
<-done
}
// --- Prompt tests ---
func TestACPProcess_Prompt_Success(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyTo(serverR, serverW, `{"stopReason":"endTurn"}`)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := proc.Prompt(ctx, "sess-1", []ContentBlock{{Type: "text", Text: "hi"}}, nil)
if err != nil {
t.Fatalf("Prompt error: %v", err)
}
if resp.StopReason != "endTurn" {
t.Errorf("expected stopReason='endTurn', got %q", resp.StopReason)
}
<-done
}
func TestACPProcess_Prompt_WithUpdateCallback(t *testing.T) {
updates := make(chan SessionUpdate, 10)
notifyHandler := func(method string, params json.RawMessage) {
if method == "session/update" {
var u SessionUpdate
json.Unmarshal(params, &u)
updates <- u
}
}
proc, serverW, serverR := buildACPProcess(nil, notifyHandler)
defer serverW.Close()
defer serverR.Close()
const sid = "sess-2"
// Goroutine: send a notification, then a response
go func() {
buf := make([]byte, 32*1024)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
// Send session/update notification with matching session ID
notif := jsonrpcMessage{
JSONRPC: "2.0",
Method: "session/update",
Params: json.RawMessage(`{"sessionId":"sess-2","kind":"message","stopReason":""}`),
}
nd, _ := json.Marshal(notif)
serverW.Write(append(nd, '\n'))
// Send prompt response
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"stopReason":"endTurn"}`),
}
rd, _ := json.Marshal(resp)
serverW.Write(append(rd, '\n'))
}()
var received []SessionUpdate
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := proc.Prompt(ctx, sid, []ContentBlock{{Type: "text", Text: "hello"}}, func(u SessionUpdate) {
received = append(received, u)
})
if err != nil {
t.Fatalf("Prompt error: %v", err)
}
// Allow notification delivery
time.Sleep(20 * time.Millisecond)
// Verify no panic and at least one update was dispatched
if len(received) == 0 {
t.Error("expected at least one session/update to be dispatched")
}
}
func TestACPProcess_Prompt_Error(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
done := replyError(serverR, serverW, -32000, "prompt failed")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := proc.Prompt(ctx, "sess-3", []ContentBlock{{Type: "text", Text: "hi"}}, nil)
if err == nil {
t.Fatal("expected error from Prompt")
}
if !strings.Contains(err.Error(), "acp session/prompt") {
t.Errorf("expected 'acp session/prompt' prefix, got %q", err.Error())
}
<-done
}
func TestACPProcess_Prompt_SetsInUse(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
defer serverR.Close()
started := make(chan struct{})
go func() {
buf := make([]byte, 32*1024)
n, _ := serverR.Read(buf)
var req jsonrpcMessage
json.Unmarshal(buf[:n], &req)
close(started)
time.Sleep(20 * time.Millisecond)
resp := jsonrpcMessage{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"stopReason":"endTurn"}`),
}
data, _ := json.Marshal(resp)
serverW.Write(append(data, '\n'))
}()
done := make(chan struct{})
go func() {
defer close(done)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
proc.Prompt(ctx, "sess-4", []ContentBlock{{Type: "text", Text: "x"}}, nil)
}()
<-started
if proc.inUse.Load() != 1 {
t.Errorf("expected inUse=1 during Prompt, got %d", proc.inUse.Load())
}
<-done
if proc.inUse.Load() != 0 {
t.Errorf("expected inUse=0 after Prompt, got %d", proc.inUse.Load())
}
}
// --- Cancel tests ---
func TestACPProcess_Cancel_SendsNotification(t *testing.T) {
proc, serverW, serverR := buildACPProcess(nil, nil)
defer serverW.Close()
received := make(chan string, 1)
go func() {
buf := make([]byte, 4096)
n, _ := serverR.Read(buf)
var msg jsonrpcMessage
json.Unmarshal(buf[:n], &msg)
received <- msg.Method
}()
err := proc.Cancel("sess-cancel")
if err != nil {
t.Fatalf("Cancel error: %v", err)
}
select {
case method := <-received:
if method != "session/cancel" {
t.Errorf("expected 'session/cancel', got %q", method)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for cancel notification")
}
}
// --- dispatchUpdate / registerUpdateFn ---
func TestACPProcess_DispatchUpdate_NilFn(t *testing.T) {
proc := &ACPProcess{}
// Should not panic with no registered fn for the session ID
proc.dispatchUpdate(SessionUpdate{SessionID: "x", Kind: "message"})
}
func TestACPProcess_DispatchUpdate_CallsFn(t *testing.T) {
proc := &ACPProcess{}
called := false
proc.registerUpdateFn("sid-1", func(u SessionUpdate) {
called = true
})
proc.dispatchUpdate(SessionUpdate{SessionID: "sid-1", Kind: "message"})
if !called {
t.Error("expected updateFn to be called")
}
}
func TestACPProcess_DispatchUpdate_RoutesCorrectSession(t *testing.T) {
proc := &ACPProcess{}
var calledA, calledB bool
proc.registerUpdateFn("sid-A", func(u SessionUpdate) { calledA = true })
proc.registerUpdateFn("sid-B", func(u SessionUpdate) { calledB = true })
proc.dispatchUpdate(SessionUpdate{SessionID: "sid-A"})
if !calledA {
t.Error("expected fn for sid-A to be called")
}
if calledB {
t.Error("expected fn for sid-B NOT to be called")
}
}
func TestACPProcess_UnregisterUpdateFn(t *testing.T) {
proc := &ACPProcess{}
called := false
proc.registerUpdateFn("sid-x", func(u SessionUpdate) { called = true })
proc.unregisterUpdateFn("sid-x")
proc.dispatchUpdate(SessionUpdate{SessionID: "sid-x"})
if called {
t.Error("expected fn to not be called after unregister")
}
}