feat: MCP tool fixes + LinkedIn approval workflow (013)
SynapBus MCP improvements: - react tool now returns workflow_state + reactions in response - list_by_state properly filters by computed state (fixes cross-contamination bug) - list_by_state supports include_messages parameter - New get_replies MCP tool for thread reading - New threads action category in registry Deployment: - v0.12.0-013 deployed to kubic - #approve-linkedin-comment channel created with workflow enabled - E2E tested: approve/reject reactions, state transitions, threading Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2b5dc652e7
commit
fa25487290
@@ -516,13 +516,35 @@ func allActions() []Action {
|
||||
Params: []Param{
|
||||
{Name: "channel", Type: "string", Description: "Channel name", Required: true},
|
||||
{Name: "state", Type: "string", Description: "Workflow state to filter by: proposed, approved, in_progress, rejected, done, published", Required: true},
|
||||
{Name: "include_messages", Type: "boolean", Description: "If true, include full message bodies in the response (default: false)"},
|
||||
},
|
||||
Returns: "JSON with message_ids array and count",
|
||||
Returns: "JSON with message_ids array, count, and optionally messages array with id, from_agent, body, priority, created_at, reply_to",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "List approved messages in a channel",
|
||||
Code: `call("list_by_state", {"channel": "approvals", "state": "approved"})`,
|
||||
},
|
||||
{
|
||||
Description: "List approved messages with full content",
|
||||
Code: `call("list_by_state", {"channel": "approvals", "state": "approved", "include_messages": true})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Threads (1 action) ──────────────────────────────────────
|
||||
{
|
||||
Name: "get_replies",
|
||||
Category: "threads",
|
||||
Description: "Get all replies (thread messages) for a given message. Use to read thread conversations, check for edits, or follow-up comments. Also available as a direct MCP tool.",
|
||||
Params: []Param{
|
||||
{Name: "message_id", Type: "number", Description: "ID of the parent message to get replies for", Required: true},
|
||||
},
|
||||
Returns: "JSON with message_id, replies array, and count",
|
||||
Examples: []Example{
|
||||
{
|
||||
Description: "Get all replies to a message",
|
||||
Code: `call("get_replies", {"message_id": 42})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryHas28Actions(t *testing.T) {
|
||||
func TestRegistryHas29Actions(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
got := len(r.List())
|
||||
if got != 28 {
|
||||
t.Errorf("expected 28 actions, got %d", got)
|
||||
if got != 29 {
|
||||
t.Errorf("expected 29 actions, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestRegistryCategories(t *testing.T) {
|
||||
{"swarm", 5},
|
||||
{"attachments", 2},
|
||||
{"reactions", 4},
|
||||
{"threads", 1},
|
||||
{"trust", 1},
|
||||
}
|
||||
|
||||
@@ -53,6 +54,8 @@ func TestRegistryGetByName(t *testing.T) {
|
||||
"upload_attachment", "download_attachment",
|
||||
// reactions
|
||||
"react", "unreact", "get_reactions", "list_by_state",
|
||||
// threads
|
||||
"get_replies",
|
||||
// trust
|
||||
"get_trust",
|
||||
}
|
||||
|
||||
+62
-1
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -121,6 +122,10 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st
|
||||
case "list_by_state":
|
||||
return b.callListByState(ctx, args)
|
||||
|
||||
// --- Threads ---
|
||||
case "get_replies":
|
||||
return b.callGetReplies(ctx, args)
|
||||
|
||||
// --- Trust ---
|
||||
case "get_trust":
|
||||
return b.callGetTrust(ctx, args)
|
||||
@@ -981,6 +986,17 @@ func (b *ServiceBridge) callReact(ctx context.Context, args map[string]any) (any
|
||||
resp["id"] = result.Reaction.ID
|
||||
resp["created_at"] = result.Reaction.CreatedAt
|
||||
}
|
||||
|
||||
// After the toggle, get current reactions and workflow state
|
||||
rxns, state, err := b.reactionService.GetReactions(ctx, int64(messageID))
|
||||
if err != nil {
|
||||
// Non-fatal: still return the toggle result
|
||||
slog.Warn("failed to get reactions after toggle", "error", err)
|
||||
} else {
|
||||
resp["workflow_state"] = state
|
||||
resp["reactions"] = rxns
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -1064,11 +1080,56 @@ func (b *ServiceBridge) callListByState(ctx context.Context, args map[string]any
|
||||
messageIDs = []int64{}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
resp := map[string]any{
|
||||
"message_ids": messageIDs,
|
||||
"count": len(messageIDs),
|
||||
"channel": channelName,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
includeMessages := getBool(args, "include_messages", false)
|
||||
if includeMessages && len(messageIDs) > 0 && b.msgService != nil {
|
||||
var messages []map[string]any
|
||||
for _, id := range messageIDs {
|
||||
msg, err := b.msgService.GetMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
continue // skip messages that can't be fetched
|
||||
}
|
||||
messages = append(messages, map[string]any{
|
||||
"id": msg.ID,
|
||||
"from_agent": msg.FromAgent,
|
||||
"body": msg.Body,
|
||||
"priority": msg.Priority,
|
||||
"created_at": msg.CreatedAt,
|
||||
"reply_to": msg.ReplyTo,
|
||||
})
|
||||
}
|
||||
resp["messages"] = messages
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// --- Threads ---
|
||||
|
||||
func (b *ServiceBridge) callGetReplies(ctx context.Context, args map[string]any) (any, error) {
|
||||
messageID := getInt(args, "message_id", 0)
|
||||
if messageID == 0 {
|
||||
return nil, fmt.Errorf("'message_id' parameter is required")
|
||||
}
|
||||
|
||||
replies, err := b.msgService.GetReplies(ctx, int64(messageID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Enrich with attachments
|
||||
b.msgService.EnrichMessages(ctx, replies)
|
||||
|
||||
return map[string]any{
|
||||
"message_id": messageID,
|
||||
"replies": replies,
|
||||
"count": len(replies),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/reactions"
|
||||
"github.com/synapbus/synapbus/internal/storage"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
@@ -295,4 +297,190 @@ func TestBridge_ParamHelpers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func newTestBridgeWithReactions(t *testing.T) (*ServiceBridge, *channels.Service) {
|
||||
t.Helper()
|
||||
db := newTestDB(t)
|
||||
|
||||
tracer := trace.NewTracer(db)
|
||||
t.Cleanup(func() { tracer.Close() })
|
||||
|
||||
msgStore := messaging.NewSQLiteMessageStore(db)
|
||||
msgService := messaging.NewMessagingService(msgStore, tracer)
|
||||
|
||||
agentStore := agents.NewSQLiteAgentStore(db)
|
||||
agentService := agents.NewAgentService(agentStore, tracer)
|
||||
|
||||
channelStore := channels.NewSQLiteChannelStore(db)
|
||||
channelService := channels.NewService(channelStore, msgService, tracer)
|
||||
|
||||
taskStore := channels.NewSQLiteTaskStore(db)
|
||||
swarmService := channels.NewSwarmService(taskStore, channelStore, tracer)
|
||||
|
||||
reactionStore := reactions.NewSQLiteStore(db)
|
||||
reactionService := reactions.NewService(reactionStore, slog.Default())
|
||||
|
||||
agentService.Register(context.Background(), "agent-a", "Agent A", "ai", nil, 1)
|
||||
agentService.Register(context.Background(), "agent-b", "Agent B", "ai", nil, 1)
|
||||
|
||||
bridge := NewServiceBridge(
|
||||
msgService,
|
||||
agentService,
|
||||
channelService,
|
||||
swarmService,
|
||||
nil, // attachmentService
|
||||
nil, // searchService
|
||||
reactionService,
|
||||
nil, // trustService
|
||||
"agent-a",
|
||||
)
|
||||
return bridge, channelService
|
||||
}
|
||||
|
||||
func TestBridge_React_WorkflowState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reaction string
|
||||
wantAction string
|
||||
wantWorkflowState string
|
||||
}{
|
||||
{
|
||||
name: "approve sets approved state",
|
||||
reaction: "approve",
|
||||
wantAction: "added",
|
||||
wantWorkflowState: "approved",
|
||||
},
|
||||
{
|
||||
name: "in_progress sets in_progress state",
|
||||
reaction: "in_progress",
|
||||
wantAction: "added",
|
||||
wantWorkflowState: "in_progress",
|
||||
},
|
||||
{
|
||||
name: "done sets done state",
|
||||
reaction: "done",
|
||||
wantAction: "added",
|
||||
wantWorkflowState: "done",
|
||||
},
|
||||
{
|
||||
name: "published sets published state",
|
||||
reaction: "published",
|
||||
wantAction: "added",
|
||||
wantWorkflowState: "published",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bridge, channelService := newTestBridgeWithReactions(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a channel and send a message to react to
|
||||
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: "react-test", Type: "standard", CreatedBy: "agent-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelService.JoinChannel(ctx, ch.ID, "agent-a")
|
||||
|
||||
msg, err := bridge.Call(ctx, "send_channel_message", map[string]any{
|
||||
"channel_name": "react-test",
|
||||
"body": "test message",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send_channel_message: %v", err)
|
||||
}
|
||||
msgMap := msg.(map[string]any)
|
||||
msgID := msgMap["message_id"]
|
||||
|
||||
// React to the message
|
||||
result, err := bridge.Call(ctx, "react", map[string]any{
|
||||
"message_id": msgID,
|
||||
"reaction": tt.reaction,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("react: %v", err)
|
||||
}
|
||||
|
||||
resp := result.(map[string]any)
|
||||
|
||||
if resp["action"] != tt.wantAction {
|
||||
t.Errorf("action = %v, want %v", resp["action"], tt.wantAction)
|
||||
}
|
||||
|
||||
state, ok := resp["workflow_state"]
|
||||
if !ok {
|
||||
t.Fatal("response missing workflow_state field")
|
||||
}
|
||||
if state != tt.wantWorkflowState {
|
||||
t.Errorf("workflow_state = %v, want %v", state, tt.wantWorkflowState)
|
||||
}
|
||||
|
||||
rxns, ok := resp["reactions"]
|
||||
if !ok {
|
||||
t.Fatal("response missing reactions field")
|
||||
}
|
||||
rxnSlice, ok := rxns.([]*reactions.Reaction)
|
||||
if !ok {
|
||||
t.Fatalf("reactions has unexpected type %T", rxns)
|
||||
}
|
||||
if len(rxnSlice) == 0 {
|
||||
t.Error("expected at least one reaction")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridge_React_Toggle_Removes_WorkflowState(t *testing.T) {
|
||||
bridge, channelService := newTestBridgeWithReactions(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: "toggle-test", Type: "standard", CreatedBy: "agent-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelService.JoinChannel(ctx, ch.ID, "agent-a")
|
||||
|
||||
msg, err := bridge.Call(ctx, "send_channel_message", map[string]any{
|
||||
"channel_name": "toggle-test",
|
||||
"body": "toggle message",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send_channel_message: %v", err)
|
||||
}
|
||||
msgMap := msg.(map[string]any)
|
||||
msgID := msgMap["message_id"]
|
||||
|
||||
// Add reaction
|
||||
bridge.Call(ctx, "react", map[string]any{
|
||||
"message_id": msgID,
|
||||
"reaction": "approve",
|
||||
})
|
||||
|
||||
// Toggle off (remove)
|
||||
result, err := bridge.Call(ctx, "react", map[string]any{
|
||||
"message_id": msgID,
|
||||
"reaction": "approve",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("react toggle off: %v", err)
|
||||
}
|
||||
|
||||
resp := result.(map[string]any)
|
||||
if resp["action"] != "removed" {
|
||||
t.Errorf("action = %v, want removed", resp["action"])
|
||||
}
|
||||
|
||||
// After removing the only reaction, workflow_state should be "proposed"
|
||||
state, ok := resp["workflow_state"]
|
||||
if !ok {
|
||||
t.Fatal("response missing workflow_state after removal")
|
||||
}
|
||||
if state != "proposed" {
|
||||
t.Errorf("workflow_state = %v, want proposed", state)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -72,14 +72,15 @@ func NewHybridToolRegistrar(
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAllOnServer registers all 4 hybrid tools on an mcp-go MCPServer.
|
||||
// RegisterAllOnServer registers all hybrid tools on an mcp-go MCPServer.
|
||||
func (h *HybridToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
|
||||
s.AddTool(h.myStatusTool(), h.handleMyStatus)
|
||||
s.AddTool(h.sendMessageTool(), h.handleSendMessage)
|
||||
s.AddTool(h.searchTool(), h.handleSearch)
|
||||
s.AddTool(h.executeTool(), h.handleExecute)
|
||||
s.AddTool(h.getRepliesTool(), h.handleGetReplies)
|
||||
|
||||
h.logger.Info("hybrid MCP tools registered", "count", 4)
|
||||
h.logger.Info("hybrid MCP tools registered", "count", 5)
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
@@ -120,6 +121,13 @@ func (h *HybridToolRegistrar) executeTool() mcplib.Tool {
|
||||
)
|
||||
}
|
||||
|
||||
func (h *HybridToolRegistrar) getRepliesTool() mcplib.Tool {
|
||||
return mcplib.NewTool("get_replies",
|
||||
mcplib.WithDescription("Get all replies (thread messages) for a given message. Use this to read thread conversations, check for edits or follow-up comments on a message."),
|
||||
mcplib.WithNumber("message_id", mcplib.Description("ID of the parent message to get replies for"), mcplib.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tool Handlers ---
|
||||
|
||||
func (h *HybridToolRegistrar) handleMyStatus(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
@@ -502,6 +510,32 @@ func (h *HybridToolRegistrar) handleExecute(ctx context.Context, req mcplib.Call
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HybridToolRegistrar) handleGetReplies(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
|
||||
_, ok := extractAgentName(ctx)
|
||||
if !ok {
|
||||
return mcplib.NewToolResultError("authentication required"), nil
|
||||
}
|
||||
|
||||
messageID := req.GetInt("message_id", 0)
|
||||
if messageID == 0 {
|
||||
return mcplib.NewToolResultError("'message_id' parameter is required"), nil
|
||||
}
|
||||
|
||||
replies, err := h.msgService.GetReplies(ctx, int64(messageID))
|
||||
if err != nil {
|
||||
return mcplib.NewToolResultError(fmt.Sprintf("get_replies failed: %s", err)), nil
|
||||
}
|
||||
|
||||
// Enrich replies with attachment info.
|
||||
h.msgService.EnrichMessages(ctx, replies)
|
||||
|
||||
return resultJSON(map[string]any{
|
||||
"message_id": messageID,
|
||||
"replies": replies,
|
||||
"count": len(replies),
|
||||
})
|
||||
}
|
||||
|
||||
// resolveChannel resolves a channel name or numeric ID string to an int64 channel ID.
|
||||
func (h *HybridToolRegistrar) resolveChannel(ctx context.Context, channel string) (int64, error) {
|
||||
// Try parsing as numeric ID first.
|
||||
|
||||
@@ -391,4 +391,136 @@ func TestHybridTool_Execute(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestHybridTool_GetReplies(t *testing.T) {
|
||||
h, msgSvc, agentSvc, _ := newTestHybridRegistrar(t)
|
||||
ctx := context.Background()
|
||||
|
||||
agentSvc.Register(ctx, "alice", "Alice", "ai", nil, 1)
|
||||
agentSvc.Register(ctx, "bob", "Bob", "ai", nil, 1)
|
||||
|
||||
authCtx := ContextWithAgentName(ctx, "alice")
|
||||
|
||||
// Send a parent message from bob to alice.
|
||||
parentMsg, err := msgSvc.SendMessage(ctx, "bob", "alice", "parent message", messaging.SendOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("send parent message: %v", err)
|
||||
}
|
||||
|
||||
// Send two replies to the parent message.
|
||||
replyTo := parentMsg.ID
|
||||
_, err = msgSvc.SendMessage(ctx, "alice", "bob", "reply one", messaging.SendOptions{ReplyTo: &replyTo})
|
||||
if err != nil {
|
||||
t.Fatalf("send reply 1: %v", err)
|
||||
}
|
||||
_, err = msgSvc.SendMessage(ctx, "bob", "alice", "reply two", messaging.SendOptions{ReplyTo: &replyTo})
|
||||
if err != nil {
|
||||
t.Fatalf("send reply 2: %v", err)
|
||||
}
|
||||
|
||||
t.Run("returns replies for message", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{
|
||||
"message_id": float64(parentMsg.ID),
|
||||
})
|
||||
|
||||
result, err := h.handleGetReplies(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetReplies: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
|
||||
count := resp["count"].(float64)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 replies, got %v", count)
|
||||
}
|
||||
|
||||
replies := resp["replies"].([]any)
|
||||
if len(replies) != 2 {
|
||||
t.Errorf("expected 2 replies in array, got %d", len(replies))
|
||||
}
|
||||
|
||||
if resp["message_id"].(float64) != float64(parentMsg.ID) {
|
||||
t.Errorf("expected message_id %d, got %v", parentMsg.ID, resp["message_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns empty for message with no replies", func(t *testing.T) {
|
||||
// Send a message with no replies.
|
||||
noReplyMsg, err := msgSvc.SendMessage(ctx, "bob", "alice", "no replies here", messaging.SendOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("send message: %v", err)
|
||||
}
|
||||
|
||||
req := makeRequest(map[string]any{
|
||||
"message_id": float64(noReplyMsg.ID),
|
||||
})
|
||||
|
||||
result, err := h.handleGetReplies(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleGetReplies: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
|
||||
count := resp["count"].(float64)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 replies, got %v", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing message_id", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{})
|
||||
result, _ := h.handleGetReplies(authCtx, req)
|
||||
if !result.IsError {
|
||||
t.Error("expected error for missing message_id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{
|
||||
"message_id": float64(1),
|
||||
})
|
||||
result, _ := h.handleGetReplies(ctx, req)
|
||||
if !result.IsError {
|
||||
t.Error("expected error for unauthenticated request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get_replies via execute", func(t *testing.T) {
|
||||
req := makeRequest(map[string]any{
|
||||
"code": fmt.Sprintf(`call("get_replies", {"message_id": %d})`, parentMsg.ID),
|
||||
})
|
||||
|
||||
result, err := h.handleExecute(authCtx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("handleExecute: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %v", result.Content)
|
||||
}
|
||||
|
||||
// Parse the execute envelope to get the bridge result.
|
||||
var resp map[string]any
|
||||
text := result.Content[0].(mcplib.TextContent).Text
|
||||
json.Unmarshal([]byte(text), &resp)
|
||||
|
||||
callEnvelope := resp["result"].(map[string]any)
|
||||
inner := callEnvelope["result"].(map[string]any)
|
||||
count := inner["count"].(float64)
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 replies via execute, got %v", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var _ = storage.RunMigrations
|
||||
|
||||
@@ -270,6 +270,33 @@ func (s *Service) GetReactionsByMessageIDs(ctx context.Context, messageIDs []int
|
||||
}
|
||||
|
||||
// ListByState returns message IDs in a channel that have the given workflow state.
|
||||
// For non-proposed states, it verifies each candidate by computing the actual
|
||||
// workflow state from all reactions, so a message with both "approve" and "reject"
|
||||
// only appears in the state matching its highest-priority reaction.
|
||||
func (s *Service) ListByState(ctx context.Context, channelID int64, state string) ([]int64, error) {
|
||||
return s.store.GetMessageIDsByState(ctx, channelID, state)
|
||||
candidates, err := s.store.GetMessageIDsByState(ctx, channelID, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// "proposed" means no reactions at all — the SQL query already handles this correctly.
|
||||
if state == StateProposed {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Batch-fetch reactions for all candidate messages.
|
||||
reactionsMap, err := s.store.GetByMessageIDs(ctx, candidates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch fetch reactions for state filtering: %w", err)
|
||||
}
|
||||
|
||||
// Only keep messages whose computed workflow state matches the requested state.
|
||||
var filtered []int64
|
||||
for _, id := range candidates {
|
||||
rxns := reactionsMap[id]
|
||||
if ComputeWorkflowState(rxns) == state {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package reactions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_ListByState_FiltersCorrectly(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
svc := NewService(store, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
// Ensure user and agents exist
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
||||
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-a', 'agent-a', 'ai', '{}', 1, 'testhash', 'active')`)
|
||||
db.Exec(`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('agent-b', 'agent-b', 'ai', '{}', 1, 'testhash2', 'active')`)
|
||||
|
||||
// Create a channel
|
||||
result, err := db.Exec(`INSERT INTO channels (name, description, created_by, workflow_enabled) VALUES ('test-channel', 'test', 'agent-a', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID, _ := result.LastInsertId()
|
||||
|
||||
// Helper to create a message in the channel
|
||||
createMsg := func(agent, body string) int64 {
|
||||
t.Helper()
|
||||
r, err := db.Exec(
|
||||
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
agent,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
convID, _ := r.LastInsertId()
|
||||
|
||||
r, err = db.Exec(
|
||||
`INSERT INTO messages (conversation_id, from_agent, channel_id, body, priority, status, created_at) VALUES (?, ?, ?, ?, 5, 'pending', CURRENT_TIMESTAMP)`,
|
||||
convID, agent, channelID, body,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
id, _ := r.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
// Scenario: msg1 has approve + reject (should be "rejected" since reject has higher priority)
|
||||
msg1 := createMsg("agent-a", "msg with approve and reject")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg1, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg1, AgentName: "agent-b", Reaction: ReactionReject})
|
||||
|
||||
// Scenario: msg2 has only approve (should be "approved")
|
||||
msg2 := createMsg("agent-a", "msg with only approve")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg2, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
|
||||
// Scenario: msg3 has no reactions (should be "proposed")
|
||||
msg3 := createMsg("agent-a", "msg with no reactions")
|
||||
|
||||
// Scenario: msg4 has approve + in_progress + done (should be "done")
|
||||
msg4 := createMsg("agent-a", "msg with approve, in_progress, done")
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-a", Reaction: ReactionApprove})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-b", Reaction: ReactionInProgress})
|
||||
store.Insert(ctx, &Reaction{MessageID: msg4, AgentName: "agent-b", Reaction: ReactionDone})
|
||||
|
||||
// Test: list "approved" should only return msg2 (NOT msg1 which also has approve but its state is rejected)
|
||||
t.Run("approved returns only truly approved", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateApproved)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(approved): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 approved message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg2 {
|
||||
t.Errorf("expected msg2 (id=%d), got id=%d", msg2, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "rejected" should only return msg1
|
||||
t.Run("rejected returns only truly rejected", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateRejected)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(rejected): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 rejected message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg1 {
|
||||
t.Errorf("expected msg1 (id=%d), got id=%d", msg1, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "proposed" should only return msg3
|
||||
t.Run("proposed returns only messages with no reactions", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(proposed): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 proposed message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg3 {
|
||||
t.Errorf("expected msg3 (id=%d), got id=%d", msg3, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "done" should only return msg4
|
||||
t.Run("done returns only truly done", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateDone)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(done): %v", err)
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 done message, got %d: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != msg4 {
|
||||
t.Errorf("expected msg4 (id=%d), got id=%d", msg4, ids[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test: list "in_progress" should return nothing (msg4 has in_progress but done overrides it)
|
||||
t.Run("in_progress excludes messages that have progressed to done", func(t *testing.T) {
|
||||
ids, err := svc.ListByState(ctx, channelID, StateInProgress)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState(in_progress): %v", err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("expected 0 in_progress messages, got %d: %v", len(ids), ids)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ListByState_EmptyChannel(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
store := NewSQLiteStore(db)
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
svc := NewService(store, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testowner', 'hash', 'Test Owner')`)
|
||||
|
||||
result, err := db.Exec(`INSERT INTO channels (name, description, created_by, workflow_enabled) VALUES ('empty-channel', 'empty', 'testowner', 1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID, _ := result.LastInsertId()
|
||||
|
||||
ids, err := svc.ListByState(ctx, channelID, StateProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState: %v", err)
|
||||
}
|
||||
if ids != nil && len(ids) != 0 {
|
||||
t.Errorf("expected nil or empty slice, got %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# Autonomous Implementation Summary: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Branch**: `013-linkedin-approval-workflow`
|
||||
**Date**: 2026-03-22
|
||||
**Status**: Implementation complete, end-to-end tested
|
||||
|
||||
## What Was Built
|
||||
|
||||
### SynapBus (this repo) — 4 MCP Tool Fixes
|
||||
|
||||
1. **`callReact` now returns `workflow_state`** — After toggle, response includes `workflow_state` and full `reactions` list. Previously only returned action/message_id/reaction.
|
||||
- Files: `internal/mcp/bridge.go`
|
||||
- Tests: `internal/mcp/bridge_test.go` (TestBridge_React_WorkflowState, TestBridge_React_Toggle_Removes_WorkflowState)
|
||||
|
||||
2. **`list_by_state` properly filters by computed state** — Fixed bug where a message with both `approve` and `reject` reactions appeared in both states. Now batch-fetches reactions and verifies with `ComputeWorkflowState()`.
|
||||
- Files: `internal/reactions/service.go`
|
||||
- Tests: `internal/reactions/service_test.go` (TestService_ListByState_FiltersCorrectly, TestService_ListByState_EmptyChannel)
|
||||
|
||||
3. **`list_by_state` supports `include_messages`** — Optional boolean parameter returns full message bodies alongside IDs, eliminating N+1 queries for agents.
|
||||
- Files: `internal/mcp/bridge.go`, `internal/actions/registry.go`
|
||||
|
||||
4. **New `get_replies` MCP tool** — Agents can now fetch thread replies via MCP. Registered as both a direct MCP tool and an execute bridge action.
|
||||
- Files: `internal/mcp/bridge.go`, `internal/mcp/tools_hybrid.go`, `internal/actions/registry.go`, `internal/actions/registry_test.go`
|
||||
- Tests: `internal/mcp/tools_test.go` (TestHybridTool_GetReplies — 5 subtests), `tests/integration/mcp_e2e_test.go`
|
||||
|
||||
### SynapBus Deployment
|
||||
|
||||
- Built and deployed `v0.12.0-013` to kubic (MicroK8s)
|
||||
- Created `#approve-linkedin-comment` channel with `workflow_enabled=true`
|
||||
- Verified reactions, state transitions, threading all work via live MCP calls
|
||||
|
||||
### Searcher Project (~/repos/searcher) — 3 Agent Changes
|
||||
|
||||
5. **Social commenter redirected to `#approve-linkedin-comment`** — Changed from `#approvals` channel, added structured metadata (target_url, comment_type, score, platform). Updated message format with emoji reaction hints.
|
||||
- Files: `agents/social-commenter/src/social_commenter/agent.py`, `agents/social-commenter/src/social_commenter/generator.py`
|
||||
|
||||
6. **Feedback reflection module** — New module reads approved/rejected/edited feedback from SynapBus, generates reflection summaries, updates CLAUDE.md, and commits to git.
|
||||
- Files: `agents/social-commenter/src/social_commenter/feedback.py` (new), `agents/social-commenter/src/social_commenter/main.py` (integrated), `agents/social-commenter/CLAUDE.md` (new)
|
||||
|
||||
7. **LinkedIn posting agent** — New agent that queries approved messages, checks threads for human edits, posts to LinkedIn via Chrome/Playwright MCP, and reacts with published/done.
|
||||
- Files: `agents/linkedin-poster/` (new directory with `agent.py`, `main.py`, `CLAUDE.md`, `pyproject.toml`)
|
||||
- K8s: Updated `k8s/synapbus/agent-cronjobs.yaml` with linkedin-poster CronJob
|
||||
|
||||
## End-to-End Test Results
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Post draft to #approve-linkedin-comment | PASS — Message 5573 created in "proposed" state |
|
||||
| list_by_state returns proposed messages with content | PASS |
|
||||
| Human adds thread edit (reply_to) | PASS — Message 5577 linked as reply |
|
||||
| get_replies returns thread edits | PASS — 1 reply returned |
|
||||
| Human approves via reaction | PASS — State → "approved", workflow_state in response |
|
||||
| list_by_state("approved") returns only approved | PASS — Only msg 5573 |
|
||||
| Human rejects second message | PASS — State → "rejected", trust decreased |
|
||||
| Rejection feedback in thread | PASS — Reply 5579 with rejection reason |
|
||||
| list_by_state("rejected") returns only rejected | PASS — Only msg 5578 |
|
||||
| State filtering correctness (no cross-contamination) | PASS |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The `reply_to` parameter doesn't work through the `execute` tool's JS evaluator for `send_channel_message` (the value gets parsed differently). Use the direct `send_message` MCP tool with `reply_to` instead.
|
||||
- LinkedIn posting agent requires Chrome/Playwright MCP running locally (not available on kubic K8s pods without VNC/browser setup).
|
||||
- Feedback reflection uses a simple state file (`.feedback_state.json`) to track last processed message ID — not persistent across container restarts without PVC.
|
||||
|
||||
## Files Changed (SynapBus)
|
||||
|
||||
```
|
||||
internal/mcp/bridge.go — callReact fix, list_by_state enhance, get_replies dispatch
|
||||
internal/mcp/bridge_test.go — React workflow_state tests
|
||||
internal/mcp/tools_hybrid.go — get_replies tool definition + handler
|
||||
internal/mcp/tools_test.go — get_replies tests
|
||||
internal/reactions/service.go — ListByState proper filtering
|
||||
internal/reactions/service_test.go — Filtering correctness tests (new)
|
||||
internal/actions/registry.go — get_replies action, list_by_state include_messages param
|
||||
internal/actions/registry_test.go — Updated action counts
|
||||
tests/integration/mcp_e2e_test.go — Updated tool count expectations
|
||||
specs/013-linkedin-approval-workflow/ — Spec, plan, research, data-model, checklists
|
||||
```
|
||||
|
||||
## Files Changed (Searcher)
|
||||
|
||||
```
|
||||
agents/social-commenter/src/social_commenter/agent.py — Channel redirect + metadata
|
||||
agents/social-commenter/src/social_commenter/generator.py — Message format update
|
||||
agents/social-commenter/src/social_commenter/feedback.py — New: feedback reflection
|
||||
agents/social-commenter/src/social_commenter/main.py — Integrated feedback call
|
||||
agents/social-commenter/CLAUDE.md — New: agent config
|
||||
agents/linkedin-poster/ — New: entire posting agent
|
||||
k8s/synapbus/agent-cronjobs.yaml — New: linkedin-poster CronJob
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass validation. Spec is ready for `/speckit.plan`.
|
||||
- Assumptions section documents all reasonable defaults chosen for ambiguous areas.
|
||||
- Chrome/Playwright and MCP are referenced as capability descriptions, not implementation prescriptions.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Data Model: LinkedIn Comment Approval Workflow
|
||||
|
||||
## Existing Entities (SynapBus - no changes needed)
|
||||
|
||||
### Message (messages table)
|
||||
Already supports: channel_id, from_agent, body, reply_to (threading), created_at, metadata.
|
||||
Workflow state is derived from reactions, not stored.
|
||||
|
||||
### Reaction (message_reactions table)
|
||||
Already supports: message_id, agent_name, reaction type, metadata, created_at.
|
||||
Types: approve, reject, in_progress, done, published.
|
||||
|
||||
### Channel (channels table)
|
||||
Already supports: workflow_enabled, auto_approve, stalemate_remind_after, stalemate_escalate_after.
|
||||
|
||||
### Trust Score (agent_trust table)
|
||||
Already supports: agent_name, action_type, score, adjustments_count.
|
||||
|
||||
## New Entity: Comment Draft Message Format
|
||||
|
||||
Messages posted to `#approve-linkedin-comment` follow this structured format:
|
||||
|
||||
```
|
||||
**Comment Draft** — LinkedIn
|
||||
**Target**: [URL]
|
||||
**Opportunity**: [title] ([platform])
|
||||
**Score**: [0.0-1.0] | **Type**: [comment_type]
|
||||
|
||||
---
|
||||
[comment text]
|
||||
---
|
||||
|
||||
React: ✅ approve | ❌ reject | Reply with edits before approving.
|
||||
```
|
||||
|
||||
**Metadata** (JSON, stored in message metadata field):
|
||||
```json
|
||||
{
|
||||
"target_url": "https://linkedin.com/posts/...",
|
||||
"comment_type": "technical_insight",
|
||||
"score": 0.85,
|
||||
"opportunity_id": 123,
|
||||
"platform": "linkedin"
|
||||
}
|
||||
```
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
proposed → approved → in_progress → published
|
||||
↓ ↓
|
||||
rejected rejected
|
||||
```
|
||||
|
||||
- **proposed**: No reactions (initial state when social commenter posts)
|
||||
- **approved**: Human clicked approve reaction
|
||||
- **rejected**: Human clicked reject reaction
|
||||
- **in_progress**: Posting agent claimed the message for posting
|
||||
- **published**: Posting agent successfully posted and reacted with published
|
||||
|
||||
## Thread Structure for Edits
|
||||
|
||||
```
|
||||
Message (comment draft) — proposed/approved state
|
||||
└── Reply (human edit) — "Use this text instead: ..."
|
||||
└── Reply (human feedback) — "Tone is too promotional"
|
||||
└── Reply (posting agent) — "Published: [URL]"
|
||||
```
|
||||
|
||||
The posting agent checks for thread replies from human agents before posting. If a human reply contains edited text, that text is used instead of the original.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Implementation Plan: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Branch**: `013-linkedin-approval-workflow` | **Date**: 2026-03-22 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/013-linkedin-approval-workflow/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
End-to-end approval pipeline: social commenter generates LinkedIn comments → posts to `#approve-linkedin-comment` SynapBus channel → human approves/rejects via Web UI reactions → posting agent publishes approved comments to LinkedIn via browser automation → social commenter reflects on feedback and updates its CLAUDE.md/skills. This feature requires fixes to SynapBus MCP tools (react response, list_by_state filtering, get_replies tool) and new agent code in the searcher project.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (SynapBus), Python 3.12 (Searcher agents)
|
||||
**Primary Dependencies**: go-chi/chi, mark3labs/mcp-go, ory/fosite (SynapBus); claude-agent-sdk, httpx, psycopg (Searcher)
|
||||
**Storage**: SQLite via modernc.org/sqlite (SynapBus); PostgreSQL (Searcher)
|
||||
**Testing**: `go test ./...` (SynapBus); `uv run pytest` (Searcher)
|
||||
**Target Platform**: linux/amd64 (K8s on kubic), darwin/arm64 (local dev)
|
||||
**Project Type**: Cross-project: web-service (SynapBus) + agent scripts (Searcher)
|
||||
**Performance Goals**: <30s for message submission, <10min for posting cycle
|
||||
**Constraints**: Zero CGO, single binary (SynapBus); browser session required for LinkedIn posting
|
||||
**Scale/Scope**: ~10 comments/day, 1 approval channel, 2 agents
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | No new external dependencies |
|
||||
| II. MCP-Native | PASS | All agent interactions via MCP tools |
|
||||
| III. Pure Go, Zero CGO | PASS | No new Go dependencies |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Agents have owners, reactions track agent identity |
|
||||
| V. Embedded OAuth 2.1 | PASS | N/A - no auth changes |
|
||||
| VI. Semantic-Ready Storage | PASS | N/A - no search changes |
|
||||
| VII. Swarm Intelligence Patterns | PASS | Using workflow channels (designed for this) |
|
||||
| VIII. Observable by Default | PASS | Reactions traced, trust adjusted, workflow states logged |
|
||||
| IX. Progressive Complexity | PASS | Workflow is opt-in per channel |
|
||||
| X. Web UI as First-Class Citizen | PASS | Reactions already work in Web UI |
|
||||
|
||||
**Result**: All gates pass. No violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/013-linkedin-approval-workflow/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output (MCP tool contracts)
|
||||
└── tasks.md # Phase 2 output
|
||||
```
|
||||
|
||||
### Source Code (SynapBus - this repo)
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── mcp/bridge.go # FIX: react response, list_by_state, add get_replies
|
||||
├── reactions/store.go # FIX: list_by_state filtering by computed state
|
||||
└── mcp/tools_hybrid.go # ADD: get_replies tool definition
|
||||
```
|
||||
|
||||
### Source Code (Searcher - ~/repos/searcher)
|
||||
|
||||
```text
|
||||
agents/social-commenter/
|
||||
├── src/social_commenter/
|
||||
│ ├── agent.py # MODIFY: post to #approve-linkedin-comment
|
||||
│ ├── feedback.py # NEW: feedback reflection logic
|
||||
│ └── generator.py # MINOR: format_approval_message update
|
||||
├── CLAUDE.md # NEW: agent-maintained config (learning target)
|
||||
└── .claude/skills/ # NEW: agent-learned skills
|
||||
|
||||
agents/linkedin-poster/
|
||||
├── src/linkedin_poster/
|
||||
│ ├── __init__.py # NEW
|
||||
│ ├── main.py # NEW: CLI entry point
|
||||
│ └── agent.py # NEW: read approved msgs, post via browser
|
||||
├── CLAUDE.md # NEW: posting agent config
|
||||
└── pyproject.toml # NEW
|
||||
|
||||
k8s/synapbus/
|
||||
└── agent-cronjobs.yaml # MODIFY: add linkedin-poster cronjob
|
||||
```
|
||||
|
||||
**Structure Decision**: Cross-project changes. SynapBus gets MCP tool fixes (3 files). Searcher gets a new `linkedin-poster` agent directory and social-commenter modifications. The posting agent is separated from the existing `engagement/` module to keep it SynapBus-native (reads from channel, not from PostgreSQL).
|
||||
|
||||
## Research Findings
|
||||
|
||||
### 1. SynapBus MCP Tool Issues (Confirmed via code review)
|
||||
|
||||
**Issue A: `react` MCP tool missing `workflow_state` in response**
|
||||
- File: `internal/mcp/bridge.go:975-984`
|
||||
- The REST API handler (`reactions_handler.go`) correctly returns workflow_state and full reactions
|
||||
- But the MCP bridge `callReact()` only returns action, message_id, reaction, id, created_at
|
||||
- Fix: After toggle, call `GetReactions()` and include `workflow_state` + `reactions` in response
|
||||
|
||||
**Issue B: `list_by_state` returns only message IDs**
|
||||
- File: `internal/mcp/bridge.go:1035-1073`
|
||||
- Agents must make N+1 calls to get message content
|
||||
- Fix: Add optional `include_messages=true` parameter that returns full message bodies
|
||||
|
||||
**Issue C: `list_by_state` doesn't properly filter by computed state**
|
||||
- File: `internal/reactions/store.go:143-174`
|
||||
- Comment on line 168: "filter in app layer" — but app layer filtering doesn't happen
|
||||
- A message with both `approve` and `reject` reactions appears in both states
|
||||
- Fix: Fetch candidates then verify with `ComputeWorkflowState()` in the service layer
|
||||
|
||||
**Issue D: No `get_replies` MCP tool**
|
||||
- Agents can't read message threads via MCP
|
||||
- REST API has it at `/api/messages/{id}/replies`
|
||||
- Fix: Add `get_replies` action to MCP bridge
|
||||
|
||||
### 2. Searcher Agent Architecture
|
||||
|
||||
**Social commenter current flow:**
|
||||
1. Reads opportunities from PostgreSQL
|
||||
2. Scores and evaluates via Claude
|
||||
3. Generates comments
|
||||
4. Posts to `#approvals` channel via `_mcp_send_channel()`
|
||||
5. Posts run summary to `#general`
|
||||
|
||||
**Changes needed:**
|
||||
- Redirect to `#approve-linkedin-comment` (channel name change)
|
||||
- Add feedback reflection at start of each run
|
||||
- Load CLAUDE.md from git, update it, commit
|
||||
|
||||
**Posting agent (new):**
|
||||
- Queries `list_by_state(channel="approve-linkedin-comment", state="approved")`
|
||||
- For each approved message: extract URL + comment, check thread for edits
|
||||
- Post via Chrome/Playwright MCP (existing pattern in `engagement/linkedin/poster.py`)
|
||||
- React with "published" on success or "rejected" on failure
|
||||
|
||||
### 3. Agent Configuration Management
|
||||
|
||||
**Decision**: Store CLAUDE.md and .claude/skills under each agent's directory in the searcher repo.
|
||||
**Rationale**: Git provides versioning, agents can commit changes, changes are auditable.
|
||||
**Alternative rejected**: Storing in SynapBus (adds complexity, not version-controlled).
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: SynapBus MCP Tool Fixes (this repo)
|
||||
|
||||
**1A. Fix `callReact` to return workflow_state**
|
||||
- Edit `internal/mcp/bridge.go:callReact()`
|
||||
- After toggle, call `GetReactions()` to get current state and reactions
|
||||
- Return `workflow_state` and `reactions` in response
|
||||
- Test: `go test ./internal/mcp/ -run TestReactReturnsWorkflowState`
|
||||
|
||||
**1B. Fix `list_by_state` filtering**
|
||||
- Edit `internal/reactions/store.go:GetMessageIDsByState()`
|
||||
- For non-proposed states: fetch candidate IDs, then verify each with `ComputeWorkflowState()`
|
||||
- Alternatively: do the filtering in `Service.ListByState()` after fetching candidates
|
||||
- Test: `go test ./internal/reactions/ -run TestListByStateFiltersCorrectly`
|
||||
|
||||
**1C. Enhance `list_by_state` to include message content**
|
||||
- Edit `internal/mcp/bridge.go:callListByState()`
|
||||
- Add optional `include_messages` boolean parameter
|
||||
- When true, fetch full messages for the returned IDs
|
||||
- Test: `go test ./internal/mcp/ -run TestListByStateIncludesMessages`
|
||||
|
||||
**1D. Add `get_replies` MCP tool**
|
||||
- Add `case "get_replies"` in bridge.go dispatch
|
||||
- Implement `callGetReplies()` using existing `store.GetReplies()`
|
||||
- Register tool in `tools_hybrid.go`
|
||||
- Test: `go test ./internal/mcp/ -run TestGetReplies`
|
||||
|
||||
### Phase 2: Create Approval Channel (operational)
|
||||
|
||||
- Create `#approve-linkedin-comment` channel via admin CLI
|
||||
- Enable workflow: `kubectl exec -n synapbus deploy/synapbus -- /synapbus channel update approve-linkedin-comment --workflow-enabled`
|
||||
- Verify via Web UI
|
||||
|
||||
### Phase 3: Social Commenter Changes (searcher repo)
|
||||
|
||||
**3A. Redirect to new channel**
|
||||
- Edit `agent.py:_submit_to_approvals()` to post to `approve-linkedin-comment`
|
||||
- Update `synapbus_client.py:build_approval_message()` format if needed
|
||||
|
||||
**3B. Add feedback reflection module**
|
||||
- Create `agents/social-commenter/src/social_commenter/feedback.py`
|
||||
- `read_feedback()`: Query list_by_state for approved/rejected messages since last run
|
||||
- `reflect_on_feedback()`: Use Claude to analyze patterns in approved vs rejected
|
||||
- `update_agent_config()`: Modify CLAUDE.md and .claude/skills based on reflection
|
||||
- `commit_and_push()`: Git commit + push changes
|
||||
- Integrate into main.py startup sequence (before generating new comments)
|
||||
|
||||
**3C. Create agent CLAUDE.md**
|
||||
- Create `agents/social-commenter/CLAUDE.md` with initial writing rules
|
||||
- Create `agents/social-commenter/.claude/skills/` with initial skills
|
||||
|
||||
### Phase 4: LinkedIn Posting Agent (searcher repo)
|
||||
|
||||
**4A. Create posting agent**
|
||||
- New `agents/linkedin-poster/` directory with `main.py`, `agent.py`
|
||||
- Connect to SynapBus, query approved messages
|
||||
- For each: extract URL/comment, check thread for edits
|
||||
- Post via Claude Agent SDK + Chrome/Playwright MCP
|
||||
- React with published/rejected on SynapBus
|
||||
|
||||
**4B. K8s deployment**
|
||||
- Add linkedin-poster CronJob to `k8s/synapbus/agent-cronjobs.yaml`
|
||||
- Register agent in SynapBus with API key
|
||||
- Docker image update to include new agent
|
||||
|
||||
### Phase 5: End-to-End Testing
|
||||
|
||||
- Run social commenter (local or K8s)
|
||||
- Verify message appears in Web UI
|
||||
- Approve/reject via Web UI reactions
|
||||
- Run posting agent
|
||||
- Verify LinkedIn comment posted
|
||||
- Run social commenter again
|
||||
- Verify CLAUDE.md updated and committed
|
||||
@@ -0,0 +1,31 @@
|
||||
# Research: LinkedIn Comment Approval Workflow
|
||||
|
||||
## Decision 1: SynapBus MCP Tool Fixes
|
||||
|
||||
**Decision**: Fix 4 issues in SynapBus MCP bridge before building agent workflow.
|
||||
**Rationale**: Agents need reliable MCP tools. Current bugs (missing workflow_state in react response, incorrect list_by_state filtering) would cause agent failures.
|
||||
**Alternatives considered**: Working around bugs in agent code (rejected: fragile, defeats MCP-native principle).
|
||||
|
||||
## Decision 2: Posting Agent Architecture
|
||||
|
||||
**Decision**: Create a new standalone `linkedin-poster` agent in the searcher repo that reads approved messages from SynapBus (not from PostgreSQL).
|
||||
**Rationale**: SynapBus is the source of truth for the approval workflow. Reading from SynapBus makes the agent independent of the DB and consistent with the MCP-native approach.
|
||||
**Alternatives considered**: Extending existing `engagement/` posting module to read from SynapBus (rejected: tightly coupled to PostgreSQL schema, would require dual-path logic).
|
||||
|
||||
## Decision 3: Agent Configuration Management
|
||||
|
||||
**Decision**: Store CLAUDE.md and .claude/skills in the searcher git repo under each agent's directory.
|
||||
**Rationale**: Git provides version history, auditable changes, and agents can commit via `gh` CLI.
|
||||
**Alternatives considered**: Storing config in SynapBus messages (rejected: not version-controlled). Storing in a separate repo (rejected: adds complexity).
|
||||
|
||||
## Decision 4: Feedback Reflection Approach
|
||||
|
||||
**Decision**: Use Claude Agent SDK to reflect on approved/rejected comments, generate writing rules, and update CLAUDE.md.
|
||||
**Rationale**: Claude can analyze patterns in human feedback (what was approved, what was rejected, what was edited) and derive actionable rules.
|
||||
**Alternatives considered**: Rule-based pattern extraction (rejected: too rigid, can't understand nuanced feedback).
|
||||
|
||||
## Decision 5: Channel Name
|
||||
|
||||
**Decision**: `approve-linkedin-comment` (not `approvals`, not `approve-comments`).
|
||||
**Rationale**: Platform-specific channels allow different workflow settings per platform. Future channels: `approve-hn-comment`, `approve-reddit-comment`.
|
||||
**Alternatives considered**: Reusing `#approvals` (rejected: mixes LinkedIn with other content types, can't set LinkedIn-specific workflow settings).
|
||||
@@ -0,0 +1,151 @@
|
||||
# Feature Specification: LinkedIn Comment Approval Workflow
|
||||
|
||||
**Feature Branch**: `013-linkedin-approval-workflow`
|
||||
**Created**: 2026-03-22
|
||||
**Status**: Draft
|
||||
**Input**: End-to-end approval pipeline connecting social commenter agent → SynapBus approval channel with reactions workflow → posting agent. Agents learn from feedback and update their configuration.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Social Commenter Posts Draft for Approval (Priority: P1)
|
||||
|
||||
The social commenter agent generates a LinkedIn comment for a discovered opportunity and submits it to the `#approve-linkedin-comment` channel on SynapBus. The message includes the target post URL, generated comment text, relevance score, and comment type. The channel has workflow enabled so the message starts in "proposed" state.
|
||||
|
||||
**Why this priority**: Without draft submission, nothing downstream can work. This is the entry point of the entire pipeline.
|
||||
|
||||
**Independent Test**: Can be tested by running the social commenter agent and verifying a message appears in `#approve-linkedin-comment` with workflow_state="proposed".
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the social commenter agent has a scored opportunity with score >= 0.6, **When** it generates a comment and submits to SynapBus, **Then** a message appears in `#approve-linkedin-comment` with workflow_state="proposed" containing the target URL, comment text, score, and comment type.
|
||||
2. **Given** a comment was already submitted for a given URL, **When** the agent tries to submit another, **Then** it skips the duplicate.
|
||||
3. **Given** the SynapBus server is unreachable, **When** the agent attempts to submit, **Then** it retries up to 3 times with backoff and logs the failure.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Human Reviews and Approves/Rejects via Web UI (Priority: P1)
|
||||
|
||||
A human owner opens the SynapBus Web UI, navigates to `#approve-linkedin-comment`, sees proposed messages with workflow badges. They can click approve or reject reactions. They can also open a thread on a message and add text edits/feedback before approving.
|
||||
|
||||
**Why this priority**: Human-in-the-loop approval is the core safety mechanism. Without it, no comments get published and no feedback loop exists.
|
||||
|
||||
**Independent Test**: Can be tested by creating a test message in the channel, clicking approve/reject in the Web UI, and verifying the workflow_state transitions correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a message in "proposed" state in `#approve-linkedin-comment`, **When** a human clicks the approve reaction, **Then** workflow_state transitions to "approved" and the agent's trust score increases.
|
||||
2. **Given** a message in "proposed" state, **When** a human clicks the reject reaction, **Then** workflow_state transitions to "rejected" and the agent's trust score decreases.
|
||||
3. **Given** a proposed message, **When** a human opens the thread and adds a reply with edited comment text before approving, **Then** the thread contains the edited text and the message is approved.
|
||||
4. **Given** a message is already approved, **When** a human tries to reject it, **Then** the state transitions to "rejected" (latest reaction wins by priority).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Posting Agent Publishes Approved Comments (Priority: P1)
|
||||
|
||||
The posting agent queries SynapBus for messages in "approved" state on `#approve-linkedin-comment`. For each approved message, it extracts the target LinkedIn URL and comment text (checking thread for edited versions). It uses Chrome/Playwright browser automation to navigate to the LinkedIn post and submit the comment. On success, it reacts with "published" (including the posted URL in metadata).
|
||||
|
||||
**Why this priority**: Publishing is the end goal of the pipeline. Without it, approved comments sit idle.
|
||||
|
||||
**Independent Test**: Can be tested by manually approving a message, running the posting agent, and verifying the comment appears on LinkedIn and the message state transitions to "published".
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an approved message in `#approve-linkedin-comment`, **When** the posting agent runs, **Then** it extracts the LinkedIn URL and comment text, posts via browser automation, and reacts with "published" including the post URL.
|
||||
2. **Given** an approved message with a thread containing edited text from the human, **When** the posting agent runs, **Then** it uses the edited text from the thread instead of the original comment.
|
||||
3. **Given** the posting agent encounters a LinkedIn error (CAPTCHA, session expired, comments disabled), **When** posting fails, **Then** it marks the message as failed with a reason and reports the failure.
|
||||
4. **Given** no approved messages exist, **When** the posting agent runs, **Then** it exits cleanly with no actions taken.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Agent Learns from Feedback (Priority: P2)
|
||||
|
||||
After each run cycle, the social commenter agent reads the approval/rejection outcomes and any edited text from the `#approve-linkedin-comment` channel. For approved comments, it notes what worked. For rejected comments, it analyzes the rejection reason. For edited comments, it compares original vs edited text to understand the human's preferences. It then updates its own CLAUDE.md file and .claude/skills with learned patterns and commits these changes to git.
|
||||
|
||||
**Why this priority**: Learning from feedback makes the system improve over time. Without it, the same mistakes repeat.
|
||||
|
||||
**Independent Test**: Can be tested by approving one comment and rejecting another (with edits), running the social commenter agent, and checking that CLAUDE.md was updated with new rules and committed to git.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** 3 approved and 2 rejected comments from the last cycle, **When** the social commenter runs its feedback reflection, **Then** it reads all outcomes, identifies patterns, and updates CLAUDE.md with new writing guidelines.
|
||||
2. **Given** a rejected comment where the human provided edited text in the thread, **When** the agent reflects, **Then** it compares original vs edited text, extracts the diff as a writing rule, and saves it to .claude/skills.
|
||||
3. **Given** the agent updates its CLAUDE.md, **When** the update is complete, **Then** it commits the change to git with a descriptive message and pushes to the repository.
|
||||
4. **Given** no new feedback since last reflection, **When** the agent checks, **Then** it skips the reflection step and proceeds with normal operation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - End-to-End Workflow Verification (Priority: P2)
|
||||
|
||||
The entire pipeline runs end-to-end: social commenter generates a comment, posts to SynapBus, human approves via Web UI, posting agent publishes to LinkedIn, and on next run the social commenter reflects on the feedback. This can be verified by checking agent logs, SynapBus message states, and git commit history.
|
||||
|
||||
**Why this priority**: Integration testing ensures all components work together correctly.
|
||||
|
||||
**Independent Test**: Can be tested by triggering the social commenter, approving in Web UI, running the posting agent, triggering the social commenter again, and verifying CLAUDE.md changes in git.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** all components are deployed, **When** running the full cycle (generate → approve → publish → reflect), **Then** the LinkedIn comment is posted, the message reaches "published" state, and CLAUDE.md contains updated rules.
|
||||
2. **Given** the rejection path, **When** running the full cycle (generate → reject with feedback → reflect), **Then** no comment is posted, the message is "rejected", and CLAUDE.md reflects the feedback.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the SynapBus server is unreachable during agent execution? Agent retries with exponential backoff (3 attempts), then logs failure and exits gracefully.
|
||||
- What happens when a LinkedIn session expires mid-posting? Agent detects session expiry, marks message as failed with reason, and alerts via SynapBus DM to the owner.
|
||||
- What happens when the human edits text but forgets to approve? The stalemate worker sends a reminder after the configured remind period (default 4h).
|
||||
- What happens when two posting agents try to publish the same approved message? The first agent to react with "in_progress" claims it; the second sees the state change and skips.
|
||||
- What happens when the agent's CLAUDE.md update creates a git conflict? Agent pulls latest, attempts auto-merge. If conflict persists, it creates the commit on a separate branch and notifies the owner.
|
||||
- What happens when a comment is approved but the LinkedIn post has been deleted? Agent detects "post not found" error, marks as failed, and notifies via SynapBus.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST provide a `#approve-linkedin-comment` channel with workflow_enabled=true, supporting the state machine: proposed → approved/rejected → in_progress → done/published.
|
||||
- **FR-002**: Social commenter agent MUST post generated LinkedIn comments to `#approve-linkedin-comment` with structured metadata (target_url, comment_text, score, comment_type, opportunity_id).
|
||||
- **FR-003**: Human owners MUST be able to approve or reject proposed comments via reaction buttons in the SynapBus Web UI.
|
||||
- **FR-004**: Human owners MUST be able to add text edits as threaded replies before approving a comment.
|
||||
- **FR-005**: Posting agent MUST query `#approve-linkedin-comment` for messages in "approved" state using the `list_by_state` MCP tool.
|
||||
- **FR-006**: Posting agent MUST check for threaded replies containing edited comment text and use the edited version when present.
|
||||
- **FR-007**: Posting agent MUST publish approved comments to LinkedIn using Chrome/Playwright browser automation via MCP.
|
||||
- **FR-008**: Posting agent MUST react with "published" (including the LinkedIn URL in metadata) after successful posting.
|
||||
- **FR-009**: On approval, the system MUST increase the social commenter agent's trust score. On rejection, it MUST decrease the trust score.
|
||||
- **FR-010**: Social commenter agent MUST read approved/rejected/edited feedback from the channel on each run and reflect on patterns.
|
||||
- **FR-011**: Social commenter agent MUST update its CLAUDE.md and .claude/skills files based on feedback patterns and commit changes to git.
|
||||
- **FR-012**: Both agents MUST load their CLAUDE.md and .claude/skills configuration from the git repository at startup.
|
||||
- **FR-013**: Agents MUST run on Kubernetes (kubic) and be triggerable via CronJob or manual kubectl exec.
|
||||
- **FR-014**: The posting agent MUST handle posting failures gracefully (CAPTCHA, session expired, post deleted) by marking the message as failed with a reason.
|
||||
- **FR-015**: The social commenter MUST deduplicate submissions — no duplicate comments for the same target URL in the channel.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Comment Draft**: A proposed LinkedIn comment with target URL, comment text, score, type, and opportunity reference. Lifecycle: proposed → approved/rejected → published/failed.
|
||||
- **Feedback Record**: An approved/rejected decision with optional edited text, mapped to the original comment draft. Used for agent learning.
|
||||
- **Agent Configuration**: CLAUDE.md and .claude/skills files in the git repository that encode the agent's learned writing rules and preferences.
|
||||
- **Trust Score**: A per-agent metric that increases on approval and decreases on rejection, influencing future behavior thresholds.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A comment draft submitted by the social commenter appears in the approval channel within 30 seconds of generation.
|
||||
- **SC-002**: A human can approve or reject a comment draft in under 3 clicks from the channel view.
|
||||
- **SC-003**: An approved comment is published to LinkedIn within 10 minutes of the posting agent's next run.
|
||||
- **SC-004**: The agent's configuration file is updated with at least one new rule after processing 5+ feedback items.
|
||||
- **SC-005**: 100% of approved comments reach "published" state or have a documented failure reason.
|
||||
- **SC-006**: Trust scores reflect approval patterns — agents with >80% approval rate have increasing trust over time.
|
||||
- **SC-007**: The full cycle (generate → approve → publish → reflect) completes end-to-end without manual intervention beyond the approval step.
|
||||
- **SC-008**: Agent configuration changes are committed to git with descriptive messages traceable to specific feedback.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The `#approve-linkedin-comment` channel will be created by the system owner (algis) or via admin CLI, not auto-created by agents (agents don't have channel creation permissions).
|
||||
- LinkedIn authentication is handled via persistent browser sessions managed outside the agent (pre-logged-in Chrome profile).
|
||||
- The Chrome/Playwright MCP server runs locally on the machine where the posting agent executes, or is accessible via network MCP.
|
||||
- Agent CLAUDE.md and .claude/skills are stored in the searcher git repository under each agent's directory.
|
||||
- The posting agent uses the existing Playwriter MCP integration pattern already established in the searcher project.
|
||||
- Only LinkedIn platform is in scope for this feature; other platforms (Reddit, HN, etc.) are excluded.
|
||||
- The social commenter currently posts to `#approvals` — this feature redirects to `#approve-linkedin-comment` for LinkedIn-specific workflow.
|
||||
- Feedback reflection happens at the start of each social commenter run, before generating new comments.
|
||||
- Git operations (commit, push) are performed by the agent using `gh` CLI or git commands available in the container.
|
||||
@@ -591,11 +591,11 @@ func TestE2E_ListTools(t *testing.T) {
|
||||
aliceClient.Initialize()
|
||||
|
||||
tools := aliceClient.ListTools()
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("expected exactly 4 tools, got %d: %v", len(tools), tools)
|
||||
if len(tools) != 5 {
|
||||
t.Fatalf("expected exactly 5 tools, got %d: %v", len(tools), tools)
|
||||
}
|
||||
|
||||
// Verify the 4 hybrid tools are present.
|
||||
// Verify the 5 hybrid tools are present.
|
||||
toolSet := make(map[string]bool)
|
||||
for _, name := range tools {
|
||||
toolSet[name] = true
|
||||
@@ -605,6 +605,7 @@ func TestE2E_ListTools(t *testing.T) {
|
||||
"send_message",
|
||||
"search",
|
||||
"execute",
|
||||
"get_replies",
|
||||
}
|
||||
for _, name := range expectedTools {
|
||||
if !toolSet[name] {
|
||||
|
||||
Reference in New Issue
Block a user