원격 터미널 브리지 POC 전에 Client HTTP lifecycle과 Edge run result surface 계약을 고정해야 하므로 관련 구현, 테스트, 로드맵 상태를 함께 정리한다.
187 lines
5.2 KiB
Go
187 lines
5.2 KiB
Go
package a2a_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"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"
|
|
)
|
|
|
|
// feedHandle builds a RunHandle pre-loaded with the given events.
|
|
func feedHandle(runID string, events ...*iop.RunEvent) *edgeservice.RunHandle {
|
|
ch := make(chan *iop.RunEvent, len(events))
|
|
for _, e := range events {
|
|
ch <- e
|
|
}
|
|
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
|
|
return &edgeservice.RunHandle{
|
|
RunDispatch: edgeservice.RunDispatch{RunID: runID, TimeoutSec: 5},
|
|
RunStream: edgeservice.RunStream{Events: ch, NodeEvents: nodeEvents},
|
|
}
|
|
}
|
|
|
|
func checkBlockingResult(t *testing.T, handle edgeservice.RunResult, check func(map[string]any)) {
|
|
t.Helper()
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
return handle, 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: "ping"}},
|
|
},
|
|
Configuration: &a2a.SendConfig{Blocking: boolPtr(true)},
|
|
}),
|
|
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)
|
|
}
|
|
task, ok := resp.Result.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected map result, got %T", resp.Result)
|
|
}
|
|
check(task)
|
|
}
|
|
|
|
func TestTaskStoreMapsRunEventsToCompletedTask(t *testing.T) {
|
|
runID := "run-ts-1"
|
|
checkBlockingResult(t,
|
|
feedHandle(runID,
|
|
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "hello "},
|
|
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "world"},
|
|
&iop.RunEvent{RunId: runID, Type: "complete"},
|
|
),
|
|
func(task map[string]any) {
|
|
status := task["status"].(map[string]any)
|
|
if status["state"] != "completed" {
|
|
t.Errorf("expected completed, got %q", status["state"])
|
|
}
|
|
artifacts, ok := task["artifacts"].([]any)
|
|
if !ok || len(artifacts) == 0 {
|
|
t.Fatal("expected non-empty artifacts")
|
|
}
|
|
parts := artifacts[0].(map[string]any)["parts"].([]any)
|
|
text := parts[0].(map[string]any)["text"].(string)
|
|
if text != "hello world" {
|
|
t.Errorf("expected %q, got %q", "hello world", text)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestTaskStoreMapsErrorToFailedTask(t *testing.T) {
|
|
runID := "run-ts-2"
|
|
checkBlockingResult(t,
|
|
feedHandle(runID,
|
|
&iop.RunEvent{RunId: runID, Type: "error", Error: "something broke"},
|
|
),
|
|
func(task map[string]any) {
|
|
status := task["status"].(map[string]any)
|
|
if status["state"] != "failed" {
|
|
t.Errorf("expected failed, got %q", status["state"])
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestMessageSendBlockingReturnsCompletedTask(t *testing.T) {
|
|
runID := "run-ts-3"
|
|
checkBlockingResult(t,
|
|
feedHandle(runID,
|
|
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "done"},
|
|
&iop.RunEvent{RunId: runID, Type: "complete"},
|
|
),
|
|
func(task map[string]any) {
|
|
status := task["status"].(map[string]any)
|
|
if status["state"] != "completed" {
|
|
t.Errorf("expected completed, got %q", status["state"])
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestCancelTaskSendsCancelRun(t *testing.T) {
|
|
runID := "run-ts-cancel"
|
|
cancelCalled := false
|
|
|
|
events := make(chan *iop.RunEvent, 1)
|
|
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
|
|
handle := &edgeservice.RunHandle{
|
|
RunDispatch: edgeservice.RunDispatch{RunID: runID, TimeoutSec: 60},
|
|
RunStream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
|
|
}
|
|
|
|
svc := &fakeService{
|
|
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
|
|
return handle, 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())
|
|
|
|
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)
|
|
sendResp := decodeRPCResponse(t, w)
|
|
if sendResp.Error != nil {
|
|
t.Fatalf("message/send: %+v", sendResp.Error)
|
|
}
|
|
taskID := sendResp.Result.(map[string]any)["id"].(string)
|
|
|
|
cancelBody := a2a.JSONRPCRequest{
|
|
JSONRPC: "2.0",
|
|
Method: "tasks/cancel",
|
|
Params: mustMarshal(t, a2a.TaskIDParams{ID: taskID}),
|
|
ID: 2,
|
|
}
|
|
w2 := rpcPost(t, mux, cancelBody)
|
|
cancelResp := decodeRPCResponse(t, w2)
|
|
if cancelResp.Error != nil {
|
|
t.Fatalf("tasks/cancel: %+v", cancelResp.Error)
|
|
}
|
|
if !cancelCalled {
|
|
t.Error("CancelRun was not called")
|
|
}
|
|
}
|