Files
synapbus/internal/jsruntime/runtime_test.go
T
Algis DumbrisandClaude Opus 4.6 fef84ed538 refactor: consolidate 30 MCP tools into 4 hybrid tools
Replace 5 separate tool registrars (messaging, channels, swarm,
attachments, webhooks) with a single HybridToolRegistrar exposing
4 tools: my_status, send_message, search, and execute.

New foundation packages:
- internal/actions: action registry (22 actions) + BM25 search index
- internal/jsruntime: lightweight call() expression parser with
  concurrency-limited execution pool

The `execute` tool dispatches call() expressions through a
ServiceBridge that maps action names to existing service methods,
preserving all original handler logic. The `search` tool enables
agents to discover available actions by keyword. The `send_message`
tool merges DM and channel sending with mutual exclusion.

All unit tests, integration tests, build, and vet pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:16:41 +02:00

305 lines
7.0 KiB
Go

package jsruntime
import (
"context"
"fmt"
"testing"
"time"
)
// mockCaller records calls for testing.
type mockCaller struct {
calls []struct {
Action string
Args map[string]any
}
result any
err error
}
func (m *mockCaller) Call(ctx context.Context, actionName string, args map[string]any) (any, error) {
m.calls = append(m.calls, struct {
Action string
Args map[string]any
}{Action: actionName, Args: args})
return m.result, m.err
}
func TestPool_Execute_SingleCall(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: map[string]any{"ok": true}}
result, err := pool.Execute(context.Background(), `call("read_inbox", { limit: 10 })`, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.Calls != 1 {
t.Errorf("calls = %d, want 1", result.Calls)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
if caller.calls[0].Action != "read_inbox" {
t.Errorf("action = %q, want read_inbox", caller.calls[0].Action)
}
limit, ok := caller.calls[0].Args["limit"]
if !ok {
t.Error("expected limit in args")
}
if limit.(float64) != 10 {
t.Errorf("limit = %v, want 10", limit)
}
}
func TestPool_Execute_MultipleCalls(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: map[string]any{"ok": true}}
code := `
call("read_inbox", { limit: 5 })
call("send_message", { to: "bob", body: "hello" })
`
result, err := pool.Execute(context.Background(), code, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if result.Calls != 2 {
t.Errorf("calls = %d, want 2", result.Calls)
}
if len(caller.calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(caller.calls))
}
if caller.calls[0].Action != "read_inbox" {
t.Errorf("first action = %q, want read_inbox", caller.calls[0].Action)
}
if caller.calls[1].Action != "send_message" {
t.Errorf("second action = %q, want send_message", caller.calls[1].Action)
}
}
func TestPool_Execute_SingleQuotes(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
_, err := pool.Execute(context.Background(), `call('read_inbox', { limit: 5 })`, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if caller.calls[0].Action != "read_inbox" {
t.Errorf("action = %q, want read_inbox", caller.calls[0].Action)
}
}
func TestPool_Execute_NoArgs(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
_, err := pool.Execute(context.Background(), `call("list_channels")`, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
if len(caller.calls[0].Args) != 0 {
t.Errorf("expected empty args, got %v", caller.calls[0].Args)
}
}
func TestPool_Execute_EmptyArgs(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
_, err := pool.Execute(context.Background(), `call("list_channels", {})`, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
}
func TestPool_Execute_Comments(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
code := `
// Read the inbox first
call("read_inbox", { limit: 5 })
// Then send a message
call("send_message", { to: "bob", body: "hi" })
`
_, err := pool.Execute(context.Background(), code, caller, 5*time.Second)
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(caller.calls))
}
}
func TestPool_Execute_NoCalls(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
_, err := pool.Execute(context.Background(), "// just a comment", caller, 5*time.Second)
if err == nil {
t.Error("expected error for code with no call() expressions")
}
}
func TestPool_Execute_CallError(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{err: fmt.Errorf("action not found")}
_, err := pool.Execute(context.Background(), `call("unknown", {})`, caller, 5*time.Second)
if err == nil {
t.Error("expected error when call fails")
}
}
func TestPool_Execute_Timeout(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
// Create a caller that blocks
caller := &mockCaller{}
slowCaller := &slowToolCaller{delay: 2 * time.Second, result: "ok"}
_ = caller // unused, using slowCaller
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := pool.Execute(ctx, `call("slow_action", {})`, slowCaller, 100*time.Millisecond)
if err == nil {
t.Error("expected timeout error")
}
}
type slowToolCaller struct {
delay time.Duration
result any
}
func (s *slowToolCaller) Call(ctx context.Context, actionName string, args map[string]any) (any, error) {
select {
case <-time.After(s.delay):
return s.result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func TestPool_Execute_MaxCalls(t *testing.T) {
pool := NewPool(2)
defer pool.Close()
caller := &mockCaller{result: "ok"}
// Build code with MaxCalls+1 calls
code := ""
for i := 0; i <= MaxCalls; i++ {
code += `call("action", {})` + "\n"
}
_, err := pool.Execute(context.Background(), code, caller, 5*time.Second)
if err == nil {
t.Error("expected error for exceeding max calls")
}
}
func TestParseCalls_JSONArgs(t *testing.T) {
tests := []struct {
name string
code string
wantLen int
wantName string
}{
{
name: "standard JSON args",
code: `call("read_inbox", {"limit": 10})`,
wantLen: 1,
wantName: "read_inbox",
},
{
name: "JS-style unquoted keys",
code: `call("read_inbox", { limit: 10, from_agent: "alice" })`,
wantLen: 1,
wantName: "read_inbox",
},
{
name: "boolean args",
code: `call("read_inbox", { include_read: true })`,
wantLen: 1,
wantName: "read_inbox",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls, err := parseCalls(tt.code)
if err != nil {
t.Fatalf("parseCalls: %v", err)
}
if len(calls) != tt.wantLen {
t.Errorf("got %d calls, want %d", len(calls), tt.wantLen)
}
if len(calls) > 0 && calls[0].Action != tt.wantName {
t.Errorf("action = %q, want %q", calls[0].Action, tt.wantName)
}
})
}
}
func TestJsObjectToJSON(t *testing.T) {
tests := []struct {
input string
valid bool
}{
{`{ limit: 10 }`, true},
{`{ "limit": 10 }`, true},
{`{ from_agent: "alice", limit: 5 }`, true},
{`{ include_read: true }`, true},
{`{ limit: 10, }`, true}, // trailing comma
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result, err := parseArgsJSON(tt.input)
if tt.valid && err != nil {
t.Errorf("parseArgsJSON(%q) failed: %v", tt.input, err)
}
if tt.valid && result == nil {
t.Errorf("parseArgsJSON(%q) returned nil", tt.input)
}
})
}
}