원격 터미널 브리지 POC 전에 Client HTTP lifecycle과 Edge run result surface 계약을 고정해야 하므로 관련 구현, 테스트, 로드맵 상태를 함께 정리한다.
376 lines
11 KiB
Go
376 lines
11 KiB
Go
package a2a_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"iop/apps/edge/internal/input/a2a"
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// fakeService is a minimal runService implementation for testing.
|
|
type fakeService struct {
|
|
submitFn func(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
|
|
cancelFn func(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
|
|
}
|
|
|
|
func (f *fakeService) SubmitRun(ctx context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
if f.submitFn != nil {
|
|
return f.submitFn(ctx, req)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeService) CancelRun(ctx context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
|
|
if f.cancelFn != nil {
|
|
return f.cancelFn(ctx, req)
|
|
}
|
|
return edgeservice.CommandResult{}, nil
|
|
}
|
|
|
|
// newTestServer builds a disabled-by-default a2a.Server for handler-level testing.
|
|
// We call the handlers directly via httptest rather than starting a real listener.
|
|
func newTestServer(cfg config.EdgeA2AConf, svc runServiceIface) *a2a.Server {
|
|
return a2a.NewServer(cfg, svc, zap.NewNop())
|
|
}
|
|
|
|
type runServiceIface interface {
|
|
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (edgeservice.RunResult, error)
|
|
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
|
|
}
|
|
|
|
func rpcPost(t *testing.T, handler http.Handler, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(b))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func decodeRPCResponse(t *testing.T, w *httptest.ResponseRecorder) a2a.JSONRPCResponse {
|
|
t.Helper()
|
|
var resp a2a.JSONRPCResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func TestHandleMessageSendDispatchesRun(t *testing.T) {
|
|
dispatched := false
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
dispatched = true
|
|
if req.Metadata["source"] != "a2a" {
|
|
t.Errorf("expected source=a2a, got %q", req.Metadata["source"])
|
|
}
|
|
return feedHandle("run-1", &iop.RunEvent{RunId: "run-1", Type: "complete"}), nil
|
|
},
|
|
}
|
|
|
|
cfg := config.EdgeA2AConf{Enabled: true, Listen: "127.0.0.1:0", Path: "/a2a", TimeoutSec: 5}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
|
|
sendBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "message/send",
|
|
Params: mustMarshal(t, a2a.MessageSendParams{
|
|
Message: a2a.Message{
|
|
Role: "user",
|
|
Parts: []a2a.Part{{Type: "text", Text: "hi"}},
|
|
},
|
|
Configuration: &a2a.SendConfig{Blocking: boolPtr(true)},
|
|
}),
|
|
ID: 1,
|
|
}
|
|
w := rpcPost(t, mux, sendBody)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("message/send status: %d body: %s", w.Code, w.Body.String())
|
|
}
|
|
resp := decodeRPCResponse(t, w)
|
|
if resp.Error != nil {
|
|
t.Fatalf("message/send error: %+v", resp.Error)
|
|
}
|
|
if !dispatched {
|
|
t.Error("SubmitRun was not called")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetTaskReturnsStoredTask(t *testing.T) {
|
|
runID := "run-get-1"
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
return feedHandle(runID,
|
|
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "result text"},
|
|
&iop.RunEvent{RunId: runID, Type: "complete"},
|
|
), nil
|
|
},
|
|
}
|
|
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 5}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
|
|
// Inject a task by calling message/send handler directly via httptest.
|
|
sendBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "message/send",
|
|
Params: mustMarshal(t, a2a.MessageSendParams{
|
|
Message: a2a.Message{
|
|
Role: "user",
|
|
Parts: []a2a.Part{{Type: "text", Text: "hello"}},
|
|
},
|
|
Configuration: &a2a.SendConfig{Blocking: boolPtr(true)},
|
|
}),
|
|
ID: 1,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
w := rpcPost(t, mux, sendBody)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("message/send status: %d body: %s", w.Code, w.Body.String())
|
|
}
|
|
sendResp := decodeRPCResponse(t, w)
|
|
if sendResp.Error != nil {
|
|
t.Fatalf("message/send error: %+v", sendResp.Error)
|
|
}
|
|
|
|
// Extract task ID from result.
|
|
taskMap, ok := sendResp.Result.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected map result, got %T", sendResp.Result)
|
|
}
|
|
taskID, _ := taskMap["id"].(string)
|
|
if taskID == "" {
|
|
t.Fatal("task id is empty")
|
|
}
|
|
|
|
// Now tasks/get.
|
|
getBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "tasks/get",
|
|
Params: mustMarshal(t, a2a.TaskQueryParams{ID: taskID}),
|
|
ID: 2,
|
|
}
|
|
w2 := rpcPost(t, mux, getBody)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("tasks/get status: %d", w2.Code)
|
|
}
|
|
getResp := decodeRPCResponse(t, w2)
|
|
if getResp.Error != nil {
|
|
t.Fatalf("tasks/get error: %+v", getResp.Error)
|
|
}
|
|
taskMap2, _ := getResp.Result.(map[string]any)
|
|
status, _ := taskMap2["status"].(map[string]any)
|
|
state, _ := status["state"].(string)
|
|
if state != "completed" {
|
|
t.Errorf("expected completed, got %q", state)
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelTask(t *testing.T) {
|
|
runID := "run-cancel-1"
|
|
cancelCalled := false
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
// Return a handle that stays working (never completes) for non-blocking.
|
|
events := make(chan *iop.RunEvent, 1)
|
|
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
|
|
return &edgeservice.RunHandle{
|
|
RunDispatch: edgeservice.RunDispatch{RunID: runID, TimeoutSec: 60},
|
|
RunStream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
|
|
}, nil
|
|
},
|
|
cancelFn: func(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
|
|
cancelCalled = true
|
|
if req.RunID != runID {
|
|
t.Errorf("expected RunID=%q, got %q", runID, req.RunID)
|
|
}
|
|
return edgeservice.CommandResult{}, nil
|
|
},
|
|
}
|
|
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 60}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
|
|
// Submit non-blocking so task stays in working state.
|
|
sendBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "message/send",
|
|
Params: mustMarshal(t, a2a.MessageSendParams{
|
|
Message: a2a.Message{
|
|
Role: "user",
|
|
Parts: []a2a.Part{{Type: "text", Text: "work"}},
|
|
},
|
|
Configuration: &a2a.SendConfig{Blocking: boolPtr(false)},
|
|
}),
|
|
ID: 1,
|
|
}
|
|
w := rpcPost(t, mux, sendBody)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("message/send: %d %s", w.Code, w.Body.String())
|
|
}
|
|
sendResp := decodeRPCResponse(t, w)
|
|
if sendResp.Error != nil {
|
|
t.Fatalf("message/send error: %+v", sendResp.Error)
|
|
}
|
|
taskMap, _ := sendResp.Result.(map[string]any)
|
|
taskID, _ := taskMap["id"].(string)
|
|
|
|
// Cancel it.
|
|
cancelBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "tasks/cancel",
|
|
Params: mustMarshal(t, a2a.TaskIDParams{ID: taskID}),
|
|
ID: 2,
|
|
}
|
|
w2 := rpcPost(t, mux, cancelBody)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("tasks/cancel: %d %s", w2.Code, w2.Body.String())
|
|
}
|
|
cancelResp := decodeRPCResponse(t, w2)
|
|
if cancelResp.Error != nil {
|
|
t.Fatalf("tasks/cancel error: %+v", cancelResp.Error)
|
|
}
|
|
if !cancelCalled {
|
|
t.Error("CancelRun was not called")
|
|
}
|
|
}
|
|
|
|
func TestRejectsBadAuth(t *testing.T) {
|
|
svc := &fakeService{}
|
|
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", BearerToken: "secret"}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
|
|
body := a2a.JSONRPCRequest{JSONRPC: "2.0", Method: "message/send", ID: 1}
|
|
b, _ := json.Marshal(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(b))
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRejectsUnknownMethod(t *testing.T) {
|
|
svc := &fakeService{}
|
|
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a"}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
|
|
body := a2a.JSONRPCRequest{JSONRPC: "2.0", Method: "unknown/method", ID: 1}
|
|
w := rpcPost(t, mux, body)
|
|
resp := decodeRPCResponse(t, w)
|
|
if resp.Error == nil {
|
|
t.Fatal("expected error for unknown method")
|
|
}
|
|
if resp.Error.Code != a2a.ErrCodeMethodNotFound {
|
|
t.Errorf("expected code %d, got %d", a2a.ErrCodeMethodNotFound, resp.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestBlockingDefault verifies the four cases for the blocking field:
|
|
// - no configuration → blocking true
|
|
// - configuration: {} (empty object, Blocking nil) → blocking true
|
|
// - configuration: {"blocking": true} → blocking true
|
|
// - configuration: {"blocking": false} → blocking false
|
|
func TestBlockingDefault(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg *a2a.SendConfig
|
|
wantBlock bool
|
|
}{
|
|
{"no configuration", nil, true},
|
|
{"empty configuration", &a2a.SendConfig{}, true},
|
|
{"explicit true", &a2a.SendConfig{Blocking: boolPtr(true)}, true},
|
|
{"explicit false", &a2a.SendConfig{Blocking: boolPtr(false)}, false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
events := make(chan *iop.RunEvent, 2)
|
|
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
|
|
var dispatched bool
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
dispatched = true
|
|
got := req.Metadata["blocking"]
|
|
want := "true"
|
|
if !tc.wantBlock {
|
|
want = "false"
|
|
}
|
|
if got != want {
|
|
t.Errorf("blocking metadata: got %q want %q", got, want)
|
|
}
|
|
if tc.wantBlock {
|
|
events <- &iop.RunEvent{RunId: "r", Type: "complete"}
|
|
}
|
|
return &edgeservice.RunHandle{
|
|
RunDispatch: edgeservice.RunDispatch{RunID: "r", TimeoutSec: 5},
|
|
RunStream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
|
|
}, nil
|
|
},
|
|
}
|
|
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 5}
|
|
srv := a2a.NewServer(cfg, svc, zap.NewNop())
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
|
|
|
|
body := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "message/send",
|
|
Params: mustMarshal(t, a2a.MessageSendParams{
|
|
Message: a2a.Message{Role: "user", Parts: []a2a.Part{{Type: "text", Text: "hi"}}},
|
|
Configuration: tc.cfg,
|
|
}),
|
|
ID: 1,
|
|
}
|
|
w := rpcPost(t, mux, body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: %d body: %s", w.Code, w.Body.String())
|
|
}
|
|
resp := decodeRPCResponse(t, w)
|
|
if resp.Error != nil {
|
|
t.Fatalf("rpc error: %+v", resp.Error)
|
|
}
|
|
if !dispatched {
|
|
t.Error("SubmitRun was not called")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func boolPtr(b bool) *bool { return &b }
|