diff --git a/pkg/agentic/commands_message.go b/pkg/agentic/commands_message.go new file mode 100644 index 0000000..8b90956 --- /dev/null +++ b/pkg/agentic/commands_message.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import core "dappco.re/go/core" + +func (s *PrepSubsystem) cmdMessageSend(options core.Options) core.Result { + workspace := optionStringValue(options, "workspace", "_arg") + fromAgent := optionStringValue(options, "from_agent", "from") + toAgent := optionStringValue(options, "to_agent", "to") + content := optionStringValue(options, "content", "body") + if workspace == "" || fromAgent == "" || toAgent == "" || core.Trim(content) == "" { + core.Print(nil, "usage: core-agent message send --from=codex --to=claude --subject=\"Review\" --content=\"Please check the prompt.\"") + return core.Result{Value: core.E("agentic.cmdMessageSend", "workspace, from_agent, to_agent, and content are required", nil), OK: false} + } + + result := s.handleMessageSend(s.commandContext(), core.NewOptions( + core.Option{Key: "workspace", Value: workspace}, + core.Option{Key: "from_agent", Value: fromAgent}, + core.Option{Key: "to_agent", Value: toAgent}, + core.Option{Key: "subject", Value: optionStringValue(options, "subject")}, + core.Option{Key: "content", Value: content}, + )) + if !result.OK { + err := commandResultError("agentic.cmdMessageSend", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(MessageSendOutput) + if !ok { + err := core.E("agentic.cmdMessageSend", "invalid message send output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "sent: %s", output.Message.ID) + core.Print(nil, "from: %s", output.Message.FromAgent) + core.Print(nil, "to: %s", output.Message.ToAgent) + if output.Message.Subject != "" { + core.Print(nil, "subject: %s", output.Message.Subject) + } + core.Print(nil, "content: %s", output.Message.Content) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdMessageInbox(options core.Options) core.Result { + workspace := optionStringValue(options, "workspace", "_arg") + agent := optionStringValue(options, "agent", "agent_id", "agent-id") + if workspace == "" || agent == "" { + core.Print(nil, "usage: core-agent message inbox --agent=claude [--limit=50]") + return core.Result{Value: core.E("agentic.cmdMessageInbox", "workspace and agent are required", nil), OK: false} + } + + result := s.handleMessageInbox(s.commandContext(), core.NewOptions( + core.Option{Key: "workspace", Value: workspace}, + core.Option{Key: "agent", Value: agent}, + core.Option{Key: "limit", Value: optionIntValue(options, "limit")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdMessageInbox", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(MessageListOutput) + if !ok { + err := core.E("agentic.cmdMessageInbox", "invalid message inbox output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + if len(output.Messages) == 0 { + core.Print(nil, "no messages") + return core.Result{Value: output, OK: true} + } + + core.Print(nil, "count: %d", output.Count) + for _, message := range output.Messages { + core.Print(nil, " [%s] %s -> %s", message.CreatedAt, message.FromAgent, message.ToAgent) + if message.Subject != "" { + core.Print(nil, " subject: %s", message.Subject) + } + core.Print(nil, " %s", message.Content) + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdMessageConversation(options core.Options) core.Result { + workspace := optionStringValue(options, "workspace", "_arg") + agent := optionStringValue(options, "agent", "agent_id", "agent-id") + withAgent := optionStringValue(options, "with_agent", "with-agent", "with", "to_agent", "to-agent") + if workspace == "" || agent == "" || withAgent == "" { + core.Print(nil, "usage: core-agent message conversation --agent=codex --with=claude [--limit=50]") + return core.Result{Value: core.E("agentic.cmdMessageConversation", "workspace, agent, and with_agent are required", nil), OK: false} + } + + result := s.handleMessageConversation(s.commandContext(), core.NewOptions( + core.Option{Key: "workspace", Value: workspace}, + core.Option{Key: "agent", Value: agent}, + core.Option{Key: "with_agent", Value: withAgent}, + core.Option{Key: "limit", Value: optionIntValue(options, "limit")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdMessageConversation", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(MessageListOutput) + if !ok { + err := core.E("agentic.cmdMessageConversation", "invalid message conversation output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + if len(output.Messages) == 0 { + core.Print(nil, "no messages") + return core.Result{Value: output, OK: true} + } + + core.Print(nil, "count: %d", output.Count) + for _, message := range output.Messages { + core.Print(nil, " [%s] %s -> %s", message.CreatedAt, message.FromAgent, message.ToAgent) + if message.Subject != "" { + core.Print(nil, " subject: %s", message.Subject) + } + core.Print(nil, " %s", message.Content) + } + return core.Result{Value: output, OK: true} +} diff --git a/pkg/agentic/commands_platform.go b/pkg/agentic/commands_platform.go index f725916..3eb7bd5 100644 --- a/pkg/agentic/commands_platform.go +++ b/pkg/agentic/commands_platform.go @@ -13,6 +13,12 @@ func (s *PrepSubsystem) registerPlatformCommands() { c.Command("sync/status", core.Command{Description: "Show platform sync status for the current or named agent", Action: s.cmdSyncStatus}) c.Command("auth/provision", core.Command{Description: "Provision a platform API key for an authenticated agent user", Action: s.cmdAuthProvision}) c.Command("auth/revoke", core.Command{Description: "Revoke a platform API key", Action: s.cmdAuthRevoke}) + c.Command("message/send", core.Command{Description: "Send a direct message to another agent", Action: s.cmdMessageSend}) + c.Command("messages/send", core.Command{Description: "Send a direct message to another agent", Action: s.cmdMessageSend}) + c.Command("message/inbox", core.Command{Description: "List direct messages for an agent", Action: s.cmdMessageInbox}) + c.Command("messages/inbox", core.Command{Description: "List direct messages for an agent", Action: s.cmdMessageInbox}) + c.Command("message/conversation", core.Command{Description: "List a direct conversation between two agents", Action: s.cmdMessageConversation}) + c.Command("messages/conversation", core.Command{Description: "List a direct conversation between two agents", Action: s.cmdMessageConversation}) c.Command("fleet/register", core.Command{Description: "Register a fleet node with the platform API", Action: s.cmdFleetRegister}) c.Command("fleet/heartbeat", core.Command{Description: "Send a heartbeat for a registered fleet node", Action: s.cmdFleetHeartbeat}) diff --git a/pkg/agentic/message.go b/pkg/agentic/message.go new file mode 100644 index 0000000..be1e152 --- /dev/null +++ b/pkg/agentic/message.go @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "sort" + "time" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// message := agentic.AgentMessage{Workspace: "core/go-io/task-5", FromAgent: "codex", ToAgent: "claude", Subject: "Review", Content: "Please check the prompt."} +type AgentMessage struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + FromAgent string `json:"from_agent"` + ToAgent string `json:"to_agent"` + Subject string `json:"subject,omitempty"` + Content string `json:"content"` + ReadAt string `json:"read_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// input := agentic.MessageSendInput{Workspace: "core/go-io/task-5", FromAgent: "codex", ToAgent: "claude", Subject: "Review", Content: "Please check the prompt."} +type MessageSendInput struct { + Workspace string `json:"workspace"` + FromAgent string `json:"from_agent"` + ToAgent string `json:"to_agent"` + Subject string `json:"subject,omitempty"` + Content string `json:"content"` +} + +// input := agentic.MessageInboxInput{Workspace: "core/go-io/task-5", Agent: "claude"} +type MessageInboxInput struct { + Workspace string `json:"workspace"` + Agent string `json:"agent"` + Limit int `json:"limit,omitempty"` +} + +// input := agentic.MessageConversationInput{Workspace: "core/go-io/task-5", Agent: "codex", WithAgent: "claude"} +type MessageConversationInput struct { + Workspace string `json:"workspace"` + Agent string `json:"agent"` + WithAgent string `json:"with_agent"` + Limit int `json:"limit,omitempty"` +} + +// out := agentic.MessageSendOutput{Success: true, Message: agentic.AgentMessage{ID: "msg-1"}} +type MessageSendOutput struct { + Success bool `json:"success"` + Message AgentMessage `json:"message"` +} + +// out := agentic.MessageListOutput{Success: true, Count: 1, Messages: []agentic.AgentMessage{{ID: "msg-1"}}} +type MessageListOutput struct { + Success bool `json:"success"` + Count int `json:"count"` + Messages []AgentMessage `json:"messages"` +} + +// result := c.Action("agentic.message.send").Run(ctx, core.NewOptions( +// +// core.Option{Key: "workspace", Value: "core/go-io/task-5"}, +// core.Option{Key: "from_agent", Value: "codex"}, +// core.Option{Key: "to_agent", Value: "claude"}, +// +// )) +func (s *PrepSubsystem) handleMessageSend(ctx context.Context, options core.Options) core.Result { + _, output, err := s.messageSend(ctx, nil, MessageSendInput{ + Workspace: optionStringValue(options, "workspace", "_arg"), + FromAgent: optionStringValue(options, "from_agent", "from"), + ToAgent: optionStringValue(options, "to_agent", "to"), + Subject: optionStringValue(options, "subject"), + Content: optionStringValue(options, "content", "body"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("agentic.message.inbox").Run(ctx, core.NewOptions(core.Option{Key: "workspace", Value: "core/go-io/task-5"})) +func (s *PrepSubsystem) handleMessageInbox(ctx context.Context, options core.Options) core.Result { + _, output, err := s.messageInbox(ctx, nil, MessageInboxInput{ + Workspace: optionStringValue(options, "workspace", "_arg"), + Agent: optionStringValue(options, "agent", "agent_id", "agent-id"), + Limit: optionIntValue(options, "limit"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("agentic.message.conversation").Run(ctx, core.NewOptions( +// +// core.Option{Key: "workspace", Value: "core/go-io/task-5"}, +// core.Option{Key: "agent", Value: "codex"}, +// core.Option{Key: "with_agent", Value: "claude"}, +// +// )) +func (s *PrepSubsystem) handleMessageConversation(ctx context.Context, options core.Options) core.Result { + _, output, err := s.messageConversation(ctx, nil, MessageConversationInput{ + Workspace: optionStringValue(options, "workspace", "_arg"), + Agent: optionStringValue(options, "agent", "agent_id", "agent-id"), + WithAgent: optionStringValue(options, "with_agent", "with-agent", "with", "to_agent", "to-agent"), + Limit: optionIntValue(options, "limit"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerMessageTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_message_send", + Description: "Send a direct message between two agents within a workspace.", + }, s.messageSend) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_message_inbox", + Description: "List messages delivered to an agent within a workspace.", + }, s.messageInbox) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_message_conversation", + Description: "List the chronological conversation between two agents within a workspace.", + }, s.messageConversation) +} + +func (s *PrepSubsystem) messageSend(_ context.Context, _ *mcp.CallToolRequest, input MessageSendInput) (*mcp.CallToolResult, MessageSendOutput, error) { + message, err := messageStoreSend(input) + if err != nil { + return nil, MessageSendOutput{}, err + } + return nil, MessageSendOutput{Success: true, Message: message}, nil +} + +func (s *PrepSubsystem) messageInbox(_ context.Context, _ *mcp.CallToolRequest, input MessageInboxInput) (*mcp.CallToolResult, MessageListOutput, error) { + messages, err := messageStoreInbox(input.Workspace, input.Agent, input.Limit) + if err != nil { + return nil, MessageListOutput{}, err + } + return nil, MessageListOutput{Success: true, Count: len(messages), Messages: messages}, nil +} + +func (s *PrepSubsystem) messageConversation(_ context.Context, _ *mcp.CallToolRequest, input MessageConversationInput) (*mcp.CallToolResult, MessageListOutput, error) { + messages, err := messageStoreConversation(input.Workspace, input.Agent, input.WithAgent, input.Limit) + if err != nil { + return nil, MessageListOutput{}, err + } + return nil, MessageListOutput{Success: true, Count: len(messages), Messages: messages}, nil +} + +func messageStoreSend(input MessageSendInput) (AgentMessage, error) { + if input.Workspace == "" { + return AgentMessage{}, core.E("messageSend", "workspace is required", nil) + } + if input.FromAgent == "" { + return AgentMessage{}, core.E("messageSend", "from_agent is required", nil) + } + if input.ToAgent == "" { + return AgentMessage{}, core.E("messageSend", "to_agent is required", nil) + } + if core.Trim(input.Content) == "" { + return AgentMessage{}, core.E("messageSend", "content is required", nil) + } + + messages, err := readWorkspaceMessages(input.Workspace) + if err != nil { + return AgentMessage{}, err + } + + now := time.Now().Format(time.RFC3339) + message := AgentMessage{ + ID: messageID(), + Workspace: core.Trim(input.Workspace), + FromAgent: core.Trim(input.FromAgent), + ToAgent: core.Trim(input.ToAgent), + Subject: core.Trim(input.Subject), + Content: input.Content, + CreatedAt: now, + } + messages = append(messages, message) + + if err := writeWorkspaceMessages(input.Workspace, messages); err != nil { + return AgentMessage{}, err + } + + return message, nil +} + +func messageStoreInbox(workspace, agent string, limit int) ([]AgentMessage, error) { + if workspace == "" { + return nil, core.E("messageInbox", "workspace is required", nil) + } + if agent == "" { + return nil, core.E("messageInbox", "agent is required", nil) + } + + return messageStoreFilter(workspace, limit, func(message AgentMessage) bool { + return message.ToAgent == agent + }) +} + +func messageStoreConversation(workspace, agent, withAgent string, limit int) ([]AgentMessage, error) { + if workspace == "" { + return nil, core.E("messageConversation", "workspace is required", nil) + } + if agent == "" { + return nil, core.E("messageConversation", "agent is required", nil) + } + if withAgent == "" { + return nil, core.E("messageConversation", "with_agent is required", nil) + } + + return messageStoreFilter(workspace, limit, func(message AgentMessage) bool { + return (message.FromAgent == agent && message.ToAgent == withAgent) || (message.FromAgent == withAgent && message.ToAgent == agent) + }) +} + +func messageStoreFilter(workspace string, limit int, match func(AgentMessage) bool) ([]AgentMessage, error) { + messages, err := readWorkspaceMessages(workspace) + if err != nil { + return nil, err + } + + filtered := make([]AgentMessage, 0, len(messages)) + for _, message := range messages { + message = normaliseAgentMessage(message) + if match(message) { + filtered = append(filtered, message) + } + } + + sort.SliceStable(filtered, func(i, j int) bool { + return filtered[i].CreatedAt < filtered[j].CreatedAt + }) + + if limit <= 0 { + limit = 50 + } + if len(filtered) > limit { + filtered = filtered[len(filtered)-limit:] + } + + return filtered, nil +} + +func messageRoot() string { + return core.JoinPath(CoreRoot(), "messages") +} + +func messagePath(workspace string) string { + return core.JoinPath(messageRoot(), core.Concat(core.SanitisePath(workspace), ".json")) +} + +func readWorkspaceMessages(workspace string) ([]AgentMessage, error) { + if workspace == "" { + return []AgentMessage{}, nil + } + + result := fs.Read(messagePath(workspace)) + if !result.OK { + err, _ := result.Value.(error) + if err == nil || core.Contains(err.Error(), "no such file") { + return []AgentMessage{}, nil + } + return nil, core.E("readWorkspaceMessages", "failed to read message store", err) + } + + content := core.Trim(result.Value.(string)) + if content == "" { + return []AgentMessage{}, nil + } + + var messages []AgentMessage + if parseResult := core.JSONUnmarshalString(content, &messages); !parseResult.OK { + err, _ := parseResult.Value.(error) + return nil, core.E("readWorkspaceMessages", "failed to parse message store", err) + } + + for i := range messages { + messages[i] = normaliseAgentMessage(messages[i]) + } + + sort.SliceStable(messages, func(i, j int) bool { + return messages[i].CreatedAt < messages[j].CreatedAt + }) + + return messages, nil +} + +func writeWorkspaceMessages(workspace string, messages []AgentMessage) error { + if workspace == "" { + return core.E("writeWorkspaceMessages", "workspace is required", nil) + } + + normalised := make([]AgentMessage, 0, len(messages)) + for _, message := range messages { + normalised = append(normalised, normaliseAgentMessage(message)) + } + + if ensureDirResult := fs.EnsureDir(messageRoot()); !ensureDirResult.OK { + err, _ := ensureDirResult.Value.(error) + return core.E("writeWorkspaceMessages", "failed to create message store directory", err) + } + + if writeResult := fs.WriteAtomic(messagePath(workspace), core.JSONMarshalString(normalised)); !writeResult.OK { + err, _ := writeResult.Value.(error) + return core.E("writeWorkspaceMessages", "failed to write message store", err) + } + + return nil +} + +func normaliseAgentMessage(message AgentMessage) AgentMessage { + message.Workspace = core.Trim(message.Workspace) + message.FromAgent = core.Trim(message.FromAgent) + message.ToAgent = core.Trim(message.ToAgent) + message.Subject = core.Trim(message.Subject) + if message.ID == "" { + message.ID = messageID() + } + if message.CreatedAt == "" { + message.CreatedAt = time.Now().Format(time.RFC3339) + } + return message +} + +func messageID() string { + return core.Concat("msg-", core.Sprint(time.Now().UnixNano())) +} diff --git a/pkg/agentic/message_test.go b/pkg/agentic/message_test.go new file mode 100644 index 0000000..d627450 --- /dev/null +++ b/pkg/agentic/message_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessage_MessageSend_Good_PersistsAndReadsBack(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + + result := s.cmdMessageSend(core.NewOptions( + core.Option{Key: "_arg", Value: "core/go-io/task-5"}, + core.Option{Key: "from", Value: "codex"}, + core.Option{Key: "to", Value: "claude"}, + core.Option{Key: "subject", Value: "Review"}, + core.Option{Key: "content", Value: "Please check the prompt."}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(MessageSendOutput) + require.True(t, ok) + assert.Equal(t, "core/go-io/task-5", output.Message.Workspace) + assert.Equal(t, "codex", output.Message.FromAgent) + assert.Equal(t, "claude", output.Message.ToAgent) + assert.Equal(t, "Review", output.Message.Subject) + assert.Equal(t, "Please check the prompt.", output.Message.Content) + assert.NotEmpty(t, output.Message.ID) + assert.NotEmpty(t, output.Message.CreatedAt) + + messageStorePath := messagePath("core/go-io/task-5") + assert.True(t, fs.Exists(messageStorePath)) + + inboxResult := s.cmdMessageInbox(core.NewOptions( + core.Option{Key: "_arg", Value: "core/go-io/task-5"}, + core.Option{Key: "agent", Value: "claude"}, + )) + require.True(t, inboxResult.OK) + + inbox, ok := inboxResult.Value.(MessageListOutput) + require.True(t, ok) + assert.Equal(t, 1, inbox.Count) + require.Len(t, inbox.Messages, 1) + assert.Equal(t, output.Message.ID, inbox.Messages[0].ID) + + conversationResult := s.cmdMessageConversation(core.NewOptions( + core.Option{Key: "_arg", Value: "core/go-io/task-5"}, + core.Option{Key: "agent", Value: "codex"}, + core.Option{Key: "with", Value: "claude"}, + )) + require.True(t, conversationResult.OK) + + conversation, ok := conversationResult.Value.(MessageListOutput) + require.True(t, ok) + assert.Equal(t, 1, conversation.Count) + require.Len(t, conversation.Messages, 1) + assert.Equal(t, output.Message.ID, conversation.Messages[0].ID) +} + +func TestMessage_MessageSend_Bad_MissingRequiredFields(t *testing.T) { + s := newTestPrep(t) + + result := s.cmdMessageSend(core.NewOptions( + core.Option{Key: "_arg", Value: "core/go-io/task-5"}, + core.Option{Key: "from", Value: "codex"}, + )) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "required") +} + +func TestMessage_MessageInbox_Ugly_CorruptStore(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + require.True(t, fs.EnsureDir(messageRoot()).OK) + require.True(t, fs.Write(messagePath("core/go-io/task-5"), "{broken json").OK) + + result := s.handleMessageInbox(context.Background(), core.NewOptions( + core.Option{Key: "workspace", Value: "core/go-io/task-5"}, + core.Option{Key: "agent", Value: "claude"}, + )) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "failed to parse message store") +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 05255ac..87d936d 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -94,7 +94,8 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { "agentic.prompt", "agentic.task", "agentic.flow", "agentic.persona", "agentic.sync.status", "agentic.fleet.nodes", "agentic.fleet.stats", "agentic.fleet.events", "agentic.credits.balance", "agentic.credits.history", - "agentic.subscription.detect", "agentic.subscription.budget": + "agentic.subscription.detect", "agentic.subscription.budget", + "agentic.message.send", "agentic.message.inbox", "agentic.message.conversation": return core.Entitlement{Allowed: true, Unlimited: true} } if s.frozen { @@ -157,6 +158,12 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agent.subscription.budget", s.handleSubscriptionBudget).Description = "Get the compute budget for a fleet node" c.Action("agentic.subscription.budget.update", s.handleSubscriptionBudgetUpdate).Description = "Update the compute budget for a fleet node" c.Action("agent.subscription.budget.update", s.handleSubscriptionBudgetUpdate).Description = "Update the compute budget for a fleet node" + c.Action("agentic.message.send", s.handleMessageSend).Description = "Send a direct message between agents" + c.Action("agent.message.send", s.handleMessageSend).Description = "Send a direct message between agents" + c.Action("agentic.message.inbox", s.handleMessageInbox).Description = "List direct messages for an agent" + c.Action("agent.message.inbox", s.handleMessageInbox).Description = "List direct messages for an agent" + c.Action("agentic.message.conversation", s.handleMessageConversation).Description = "List a direct conversation between two agents" + c.Action("agent.message.conversation", s.handleMessageConversation).Description = "List a direct conversation between two agents" c.Action("agentic.dispatch", s.handleDispatch).Description = "Prep workspace and spawn a subagent" c.Action("agentic.dispatch.sync", s.handleDispatchSync).Description = "Dispatch a single task synchronously and block until it completes" @@ -378,6 +385,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerTaskTools(server) s.registerTemplateTools(server) s.registerIssueTools(server) + s.registerMessageTools(server) s.registerSprintTools(server) s.registerContentTools(server) s.registerLanguageTools(server) diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 62d67d6..7a14a8b 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -518,6 +518,12 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { assert.True(t, c.Action("issue.comment").Exists()) assert.True(t, c.Action("issue.report").Exists()) assert.True(t, c.Action("issue.archive").Exists()) + assert.True(t, c.Action("agentic.message.send").Exists()) + assert.True(t, c.Action("agent.message.send").Exists()) + assert.True(t, c.Action("agentic.message.inbox").Exists()) + assert.True(t, c.Action("agent.message.inbox").Exists()) + assert.True(t, c.Action("agentic.message.conversation").Exists()) + assert.True(t, c.Action("agent.message.conversation").Exists()) assert.True(t, c.Action("agentic.issue.update").Exists()) assert.True(t, c.Action("agentic.issue.assign").Exists()) assert.True(t, c.Action("agentic.issue.comment").Exists()) @@ -606,6 +612,12 @@ func TestPrep_OnStartup_Good_RegistersPlatformCommandAlias(t *testing.T) { require.True(t, s.OnStartup(context.Background()).OK) assert.Contains(t, c.Commands(), "auth/provision") assert.Contains(t, c.Commands(), "auth/revoke") + assert.Contains(t, c.Commands(), "message/send") + assert.Contains(t, c.Commands(), "messages/send") + assert.Contains(t, c.Commands(), "message/inbox") + assert.Contains(t, c.Commands(), "messages/inbox") + assert.Contains(t, c.Commands(), "message/conversation") + assert.Contains(t, c.Commands(), "messages/conversation") assert.Contains(t, c.Commands(), "subscription/budget/update") assert.Contains(t, c.Commands(), "subscription/update-budget") assert.Contains(t, c.Commands(), "fleet/events") @@ -641,6 +653,9 @@ func TestPrep_RegisterTools_Good_RegistersCompletionTool(t *testing.T) { } assert.Contains(t, toolNames, "agentic_complete") + assert.Contains(t, toolNames, "agentic_message_send") + assert.Contains(t, toolNames, "agentic_message_inbox") + assert.Contains(t, toolNames, "agentic_message_conversation") } func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) {