mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 06:15:25 +00:00
* 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>
467 lines
12 KiB
Go
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")
|
|
}
|
|
}
|