4257307063
Co-authored-by: Kandev Agent <agent@kandev.dev>
133 lines
4.9 KiB
Go
133 lines
4.9 KiB
Go
package pluginsdk
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
hcplugin "github.com/hashicorp/go-plugin"
|
|
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// fakeAuthorPlugin is a minimal author-facing Plugin used to exercise the
|
|
// real go-plugin gRPC + broker wiring end to end (no subprocess spawn —
|
|
// hcplugin.TestPluginGRPCConn wires a real unix-socket grpc.Server/Client
|
|
// pair with a real GRPCBroker, matching production transport behavior).
|
|
type fakeAuthorPlugin struct {
|
|
UnimplementedPlugin
|
|
events []*Event
|
|
}
|
|
|
|
func (p *fakeAuthorPlugin) OnEvent(_ context.Context, e *Event) error {
|
|
p.events = append(p.events, e)
|
|
return nil
|
|
}
|
|
|
|
func (p *fakeAuthorPlugin) HandleWebhook(_ context.Context, req *WebhookRequest) (*WebhookResponse, error) {
|
|
return &WebhookResponse{Status: 200, Body: append([]byte("got:"), req.Body...)}, nil
|
|
}
|
|
|
|
func (p *fakeAuthorPlugin) InvokeAgentTool(_ context.Context, req *AgentToolRequest) (*AgentToolResult, error) {
|
|
return &AgentToolResult{
|
|
Text: "called:" + req.Context.TaskID,
|
|
StructuredContent: map[string]any{
|
|
"name": req.Name, "value": req.Arguments["value"],
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type nilAgentToolPlugin struct{ UnimplementedPlugin }
|
|
|
|
func (*nilAgentToolPlugin) InvokeAgentTool(context.Context, *AgentToolRequest) (*AgentToolResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestGRPCPluginServerRejectsNilAgentToolResult(t *testing.T) {
|
|
server := &grpcPluginServer{impl: &nilAgentToolPlugin{}}
|
|
_, err := server.InvokeAgentTool(context.Background(), &pluginv1.AgentToolRequest{Name: "echo"})
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.Internal, status.Code(err))
|
|
}
|
|
|
|
// TestServe_EndToEnd exercises the same GRPCPlugin wiring Serve() (plugin
|
|
// side) and kandev's runtime manager (host side, via plugin.NewClient)
|
|
// use, over a real go-plugin gRPC connection + broker
|
|
// (hcplugin.TestPluginGRPCConn). It covers both Plugin RPCs plus the
|
|
// Host broker round trip (§3/§4 of docs/plans/plugins/GRPC-CONTRACT.md).
|
|
func TestServe_EndToEnd(t *testing.T) {
|
|
author := &fakeAuthorPlugin{}
|
|
hostImpl := &recordingHost{
|
|
getStateFn: func(_ context.Context, scope, scopeID, key string) (map[string]any, bool, error) {
|
|
if scope == "task" && scopeID == "t1" && key == "k1" {
|
|
return map[string]any{"greeting": "hi"}, true, nil
|
|
}
|
|
return nil, false, nil
|
|
},
|
|
}
|
|
// hcplugin.TestPluginGRPCConn dispenses/serves the SAME Plugin value on
|
|
// both ends of the harness (there's no real process boundary), so one
|
|
// GRPCPlugin with both Impl and Host set plays both roles: GRPCServer
|
|
// runs as the "plugin subprocess" would, GRPCClient runs as "kandev"
|
|
// would. In production these are two separate processes each setting
|
|
// only one of Impl/Host.
|
|
gp := &GRPCPlugin{Impl: author, Host: hostImpl, HostDialTimeout: 5 * time.Second}
|
|
|
|
client, server := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
|
|
PluginMapKey: gp,
|
|
})
|
|
defer func() { _ = client.Close() }()
|
|
defer server.Stop()
|
|
|
|
raw, err := client.Dispense(PluginMapKey)
|
|
require.NoError(t, err)
|
|
remote, ok := raw.(*RemotePlugin)
|
|
require.True(t, ok, "Dispense(%q) should return *RemotePlugin, got %T", PluginMapKey, raw)
|
|
|
|
t.Run("DeliverEvent", func(t *testing.T) {
|
|
err := remote.DeliverEvent(context.Background(), &Event{
|
|
EventID: "e1",
|
|
EventType: "task.created",
|
|
Payload: map[string]any{"a": float64(1)},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, author.events, 1)
|
|
require.Equal(t, "e1", author.events[0].EventID)
|
|
require.Equal(t, map[string]any{"a": float64(1)}, author.events[0].Payload)
|
|
})
|
|
|
|
t.Run("HandleWebhook", func(t *testing.T) {
|
|
resp, err := remote.HandleWebhook(context.Background(), &WebhookRequest{Body: []byte("hi")})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(200), resp.Status)
|
|
require.Equal(t, []byte("got:hi"), resp.Body)
|
|
})
|
|
|
|
t.Run("InvokeAgentTool", func(t *testing.T) {
|
|
resp, err := remote.InvokeAgentTool(context.Background(), &AgentToolRequest{
|
|
InvocationID: "inv-1", Name: "echo", Arguments: map[string]any{"value": "ok"},
|
|
Context: AgentToolContext{TaskID: "task-1", SessionID: "session-1", WorkspaceID: "workspace-1", Surface: "kanban-task"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "called:task-1", resp.Text)
|
|
require.Equal(t, "echo", resp.StructuredContent["name"])
|
|
})
|
|
|
|
t.Run("HostBrokerRoundTrip", func(t *testing.T) {
|
|
require.Eventually(t, func() bool {
|
|
return author.Host() != nil
|
|
}, 5*time.Second, 10*time.Millisecond, "Serve-equivalent GRPCServer should inject Host via the broker")
|
|
|
|
value, found, err := author.Host().GetState(context.Background(), "task", "t1", "k1")
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
require.Equal(t, map[string]any{"greeting": "hi"}, value)
|
|
|
|
_, found, err = author.Host().GetState(context.Background(), "task", "t1", "missing")
|
|
require.NoError(t, err)
|
|
require.False(t, found)
|
|
})
|
|
}
|