diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs new file mode 100644 index 000000000..09e3f9e7b --- /dev/null +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs @@ -0,0 +1,28 @@ +namespace Aevatar.GAgents.Authoring.Lark; + +/// +/// Single source of truth for the agent_builder_action identifiers wired between the +/// card-rendering surface () and the dispatch surfaces +/// (, ). +/// +/// +/// Keeping these in one place avoids the silent-divergence hazard of redeclaring the same string +/// literal in every consumer: a typo in a renderer's button argument would route the click to a +/// fallback branch with no compile-time signal. The card-flow router and the shared content +/// builders both reference the same constant by name. +/// +internal static class AgentBuilderActionIds +{ + public const string DailyReport = "create_daily_report"; + public const string SocialMedia = "create_social_media"; + public const string OpenDailyReportForm = "open_daily_report_form"; + public const string OpenSocialMediaForm = "open_social_media_form"; + public const string ListTemplates = "list_templates"; + public const string ListAgents = "list_agents"; + public const string AgentStatus = "agent_status"; + public const string RunAgent = "run_agent"; + public const string DisableAgent = "disable_agent"; + public const string EnableAgent = "enable_agent"; + public const string ConfirmDeleteAgent = "confirm_delete_agent"; + public const string DeleteAgent = "delete_agent"; +} diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs index 0bb047709..90b6a80ed 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Scheduled; @@ -11,8 +12,12 @@ namespace Aevatar.GAgents.Authoring.Lark; /// public static class AgentBuilderCardContent { - private const string DailyReportAction = "create_daily_report"; - private const string SocialMediaAction = "create_social_media"; + private const string DailyReportAction = AgentBuilderActionIds.DailyReport; + private const string SocialMediaAction = AgentBuilderActionIds.SocialMedia; + private const string OpenDailyReportFormAction = AgentBuilderActionIds.OpenDailyReportForm; + private const string OpenSocialMediaFormAction = AgentBuilderActionIds.OpenSocialMediaForm; + private const string ListTemplatesAction = AgentBuilderActionIds.ListTemplates; + private const string ListAgentsAction = AgentBuilderActionIds.ListAgents; private const string DefaultScheduleTime = "09:00"; public static MessageContent BuildDailyReportForm(string? preferredGithubUsername) => @@ -185,6 +190,131 @@ public static MessageContent FormatDailyReportToolReply(JsonElement root) return TextContent(string.Join('\n', lines)); } + /// + /// Renders /agents as a single consolidated card. The earlier design produced one + /// per agent plus per-agent "Status: …" buttons; in Lark that compiled + /// into many stacked markdown blocks followed by a long button row, which users perceived as a + /// text list mixed with a separate status card (issue #476). The unified design surfaces one + /// card with a structured agent list in the body and a small footer of global actions, while + /// per-agent operations stay accessible through the documented slash commands listed inline. + /// + /// The list-agents tool result JSON root element. + /// + /// Optional headline to prepend to the body, e.g. a "Deleted agent X" notice when the same + /// renderer is reused as the post-delete acknowledgment so the user sees the updated registry + /// without a second card hop. + /// + public static MessageContent FormatListAgentsResult(JsonElement root, string? noticeMarkdown = null) + { + if (TryReadError(root, out var error)) + return TextContent($"List agents failed: {error}"); + + var content = new MessageContent(); + var notice = NormalizeOptionalMarkdown(noticeMarkdown); + + if (!root.TryGetProperty("agents", out var agentsElement) || + agentsElement.ValueKind != JsonValueKind.Array || + agentsElement.GetArrayLength() == 0) + { + var emptyBody = new StringBuilder(); + if (notice is not null) + { + emptyBody.Append(notice); + emptyBody.Append("\n\n"); + } + emptyBody.Append("No agents yet. Create one to get started:\n"); + emptyBody.Append("- `/daily` — daily GitHub report\n"); + emptyBody.Append("- `/social-media` — social-media drafter\n\n"); + emptyBody.Append("Run `/templates` to browse all available templates."); + + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = "agents_empty", + Title = "Your Agents", + Text = emptyBody.ToString(), + }); + content.Actions.Add(BuildAction("Create Daily Report", OpenDailyReportFormAction, isPrimary: true)); + content.Actions.Add(BuildAction("Create Social Media", OpenSocialMediaFormAction, isPrimary: false)); + content.Actions.Add(BuildAction("Templates", ListTemplatesAction, isPrimary: false)); + return content; + } + + var totalCount = agentsElement.GetArrayLength(); + var bodyBuilder = new StringBuilder(); + if (notice is not null) + { + bodyBuilder.Append(notice); + bodyBuilder.Append("\n\n"); + } + + var index = 0; + foreach (var agent in agentsElement.EnumerateArray()) + { + index++; + var agentId = TryReadString(agent, "agent_id") ?? "unknown-agent"; + var template = TryReadString(agent, "template") ?? "unknown-template"; + var status = TryReadString(agent, "status") ?? "unknown"; + var nextRun = TryReadString(agent, "next_scheduled_run") ?? "pending"; + var lastRun = TryReadOptional(agent, "last_run_at"); + + if (index > 1) + bodyBuilder.Append("\n\n"); + + bodyBuilder.Append($"**{index}. `{template}`** · {status}\n"); + bodyBuilder.Append($"- Agent ID: `{agentId}`\n"); + bodyBuilder.Append($"- Next run: `{nextRun}`"); + if (lastRun is not null) + { + bodyBuilder.Append('\n'); + bodyBuilder.Append($"- Last run: `{lastRun}`"); + } + } + + bodyBuilder.Append("\n\n**Manage agents** with these commands:\n"); + bodyBuilder.Append("- `/agent-status ` — view full details\n"); + bodyBuilder.Append("- `/run-agent ` — trigger immediately\n"); + bodyBuilder.Append("- `/disable-agent ` · `/enable-agent ` — toggle scheduling\n"); + bodyBuilder.Append("- `/delete-agent confirm` — remove the agent"); + + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = "agents_list", + Title = $"Your Agents ({totalCount})", + Text = bodyBuilder.ToString(), + }); + + // Footer is intentionally limited to discovery / creation shortcuts. Per-agent actions + // (status, run, disable, enable, delete) deliberately stay off this card to avoid the + // visual "list + status panel" duplication called out in issue #476; the inline command + // hints in the body cover the same ground without the layout noise. + content.Actions.Add(BuildAction("Refresh", ListAgentsAction, isPrimary: false)); + content.Actions.Add(BuildAction("Templates", ListTemplatesAction, isPrimary: false)); + content.Actions.Add(BuildAction("Create Daily Report", OpenDailyReportFormAction, isPrimary: false)); + content.Actions.Add(BuildAction("Create Social Media", OpenSocialMediaFormAction, isPrimary: false)); + return content; + } + + private static string? NormalizeOptionalMarkdown(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + + private static ActionElement BuildAction(string label, string agentBuilderAction, bool isPrimary) + { + var button = new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = agentBuilderAction, + Label = label, + IsPrimary = isPrimary, + }; + button.Arguments["agent_builder_action"] = agentBuilderAction; + return button; + } + private static MessageContent BuildDailyReportCredentialsCard(JsonElement root, string status) { var providerId = TryReadString(root, "provider_id") ?? "unknown-provider"; @@ -251,46 +381,17 @@ private static ActionElement BuildFormSubmit(string actionId, string label, bool IsPrimary = isPrimary, }; - private static MessageContent TextContent(string text) => new() { Text = text }; - - private static bool TryReadError(JsonElement root, out string error) - { - error = TryReadString(root, "error") ?? string.Empty; - return error.Length > 0; - } - - private static string? TryReadString(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - return null; + private static MessageContent TextContent(string text) => AgentBuilderJson.TextContent(text); - return property.ValueKind switch - { - JsonValueKind.String => property.GetString(), - JsonValueKind.Number => property.GetRawText(), - JsonValueKind.True => bool.TrueString, - JsonValueKind.False => bool.FalseString, - _ => null, - }; - } + private static bool TryReadError(JsonElement root, out string error) => + AgentBuilderJson.TryReadError(root, out error); - private static bool TryReadBool(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - return false; + private static string? TryReadString(JsonElement element, string propertyName) => + AgentBuilderJson.TryReadString(element, propertyName); - return property.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String => bool.TryParse(property.GetString(), out var parsed) && parsed, - _ => false, - }; - } + private static bool TryReadBool(JsonElement element, string propertyName) => + AgentBuilderJson.TryReadBool(element, propertyName); - private static string? TryReadOptional(JsonElement element, string propertyName) - { - var raw = TryReadString(element, propertyName); - return string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(); - } + private static string? TryReadOptional(JsonElement element, string propertyName) => + AgentBuilderJson.TryReadOptional(element, propertyName); } diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs index 4e47f8aa3..11c908970 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Runtime; @@ -11,18 +12,18 @@ public static class AgentBuilderCardFlow { private const string PrivateChatType = "p2p"; private const string CardActionChatType = "card_action"; - private const string OpenDailyReportFormAction = "open_daily_report_form"; - private const string OpenSocialMediaFormAction = "open_social_media_form"; - private const string DailyReportAction = "create_daily_report"; - private const string SocialMediaAction = "create_social_media"; - private const string ListTemplatesAction = "list_templates"; - private const string ListAgentsAction = "list_agents"; - private const string AgentStatusAction = "agent_status"; - private const string RunAgentAction = "run_agent"; - private const string DisableAgentAction = "disable_agent"; - private const string EnableAgentAction = "enable_agent"; - private const string ConfirmDeleteAgentAction = "confirm_delete_agent"; - private const string DeleteAgentAction = "delete_agent"; + private const string OpenDailyReportFormAction = AgentBuilderActionIds.OpenDailyReportForm; + private const string OpenSocialMediaFormAction = AgentBuilderActionIds.OpenSocialMediaForm; + private const string DailyReportAction = AgentBuilderActionIds.DailyReport; + private const string SocialMediaAction = AgentBuilderActionIds.SocialMedia; + private const string ListTemplatesAction = AgentBuilderActionIds.ListTemplates; + private const string ListAgentsAction = AgentBuilderActionIds.ListAgents; + private const string AgentStatusAction = AgentBuilderActionIds.AgentStatus; + private const string RunAgentAction = AgentBuilderActionIds.RunAgent; + private const string DisableAgentAction = AgentBuilderActionIds.DisableAgent; + private const string EnableAgentAction = AgentBuilderActionIds.EnableAgent; + private const string ConfirmDeleteAgentAction = AgentBuilderActionIds.ConfirmDeleteAgent; + private const string DeleteAgentAction = AgentBuilderActionIds.DeleteAgent; private const string DefaultScheduleTime = "09:00"; private const string SocialMediaCommand = "/social-media"; private const string AgentStatusCommand = "/agent-status"; @@ -237,6 +238,8 @@ private static bool TryResolve( return true; } + // Use the MessageContent overload so the relay composer renders this as a real + // Lark card instead of forwarding a JSON-as-text payload (issue #482). decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard( agentId, evt.Extra.TryGetValue("template", out var template) ? template : null)); @@ -257,6 +260,12 @@ private static bool TryResolve( } } + /// + /// Formats the tool result for a card-action invocation. Each branch returns a structured + /// with Cards and Actions populated; never a Lark + /// card JSON string wrapped as . The latter shape used to + /// reach the relay verbatim and the user saw raw {"config":...} blobs (issue #482). + /// public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, string toolResultJson) { ArgumentNullException.ThrowIfNull(decision); @@ -268,16 +277,20 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, { // Daily report creation uses the shared formatter so Nyx-relay slash commands and // Feishu card-action submits render the same "running now, I'll reply when done" - // acknowledgment instead of one path dumping the legacy JSON card as text. + // acknowledgment. DailyReportAction => AgentBuilderCardContent.FormatDailyReportToolReply(doc.RootElement), - SocialMediaAction => ToTextContent(FormatCreateSocialMediaResult(doc.RootElement)), - ListTemplatesAction => ToTextContent(FormatListTemplatesResult(doc.RootElement)), - ListAgentsAction => ToTextContent(FormatListAgentsResult(doc.RootElement)), - AgentStatusAction => ToTextContent(FormatAgentStatusResult(doc.RootElement)), - RunAgentAction => ToTextContent(FormatRunAgentResult(doc.RootElement)), - DisableAgentAction => ToTextContent(FormatDisableAgentResult(doc.RootElement)), - EnableAgentAction => ToTextContent(FormatEnableAgentResult(doc.RootElement)), - DeleteAgentAction => ToTextContent(FormatDeleteAgentResult(doc.RootElement)), + SocialMediaAction => FormatCreateSocialMediaResult(doc.RootElement), + ListTemplatesAction => FormatListTemplatesResult(doc.RootElement), + // Card-click "Refresh List" and the typed `/agents` command share the same + // unified renderer (issue #476). + ListAgentsAction => AgentBuilderCardContent.FormatListAgentsResult(doc.RootElement), + AgentStatusAction => FormatAgentStatusResult(doc.RootElement), + RunAgentAction => FormatRunAgentResult(doc.RootElement), + DisableAgentAction => FormatDisableAgentResult(doc.RootElement), + EnableAgentAction => FormatEnableAgentResult(doc.RootElement), + // After a delete completes, surface the updated registry through the same unified + // list renderer with the delete notice prepended. + DeleteAgentAction => FormatDeleteAgentResultAsList(doc.RootElement), _ => ToTextContent(toolResultJson), }; } @@ -287,7 +300,7 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, } } - private static MessageContent ToTextContent(string text) => new() { Text = text }; + private static MessageContent ToTextContent(string text) => AgentBuilderJson.TextContent(text); public static string ResolveToolChatType(ChannelInboundEvent evt) { @@ -500,19 +513,56 @@ private static bool TryResolvePrivateChatCommand( return true; } - if (TryParseAgentCommand(normalizedText, DeleteAgentCommand, out agentId, out errorReply)) + if (TryResolveDeleteAgentTextCommand(normalizedText, out decision)) + return true; + + return false; + } + + /// + /// Parses /delete-agent <agent_id> [confirm]. The optional confirm trailer + /// matches the NyxRelay text contract (and the inline command hint surfaced from the shared + /// /agents renderer) so a user who follows the printed hint + /// /delete-agent <id> confirm in a direct-webhook chat does not end up with + /// "<id> confirm" being treated as a single agent_id by the legacy + /// parser. Without the trailing keyword we still surface + /// the explicit confirmation card; with it we skip the extra step and dispatch the delete + /// directly, mirroring the relay path's semantics. + /// + private static bool TryResolveDeleteAgentTextCommand( + string normalizedText, + out AgentBuilderFlowDecision? decision) + { + decision = null; + if (!normalizedText.StartsWith(DeleteAgentCommand, StringComparison.OrdinalIgnoreCase)) + return false; + + var tokens = ChannelTextCommandParser.Tokenize(normalizedText); + if (tokens.Count < 2 || string.IsNullOrWhiteSpace(tokens[1])) { - if (errorReply != null) - { - decision = AgentBuilderFlowDecision.DirectReply(errorReply); - return true; - } + decision = AgentBuilderFlowDecision.DirectReply($"Usage: {DeleteAgentCommand} "); + return true; + } - decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard(agentId!, null)); + var agentId = tokens[1].Trim(); + var confirmed = tokens.Count > 2 && + string.Equals(tokens[2], "confirm", StringComparison.OrdinalIgnoreCase); + + if (confirmed) + { + decision = AgentBuilderFlowDecision.ToolCall( + DeleteAgentAction, + JsonSerializer.Serialize(new + { + action = DeleteAgentAction, + agent_id = agentId, + confirm = true, + })); return true; } - return false; + decision = AgentBuilderFlowDecision.DirectReply(BuildDeleteConfirmationCard(agentId, null)); + return true; } private static bool TryParseAgentCommand( @@ -597,65 +647,69 @@ private static bool ShouldLoadPreferredGithubUsername(ChannelInboundEvent evt) return normalized.Length == 0 ? null : normalized; } - private static string FormatCreateSocialMediaResult(JsonElement root) + private static MessageContent FormatCreateSocialMediaResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"Create social media agent failed: {error}"; + return ToTextContent($"Create social media agent failed: {error}"); var status = ReadString(root, "status") ?? "accepted"; var agentId = ReadString(root, "agent_id") ?? "unknown-agent"; var workflowId = ReadString(root, "workflow_id") ?? "pending"; var nextRun = ReadString(root, "next_scheduled_run") ?? "pending"; - var note = ReadString(root, "note"); - - var lines = new List - { - string.Equals(status, "created", StringComparison.OrdinalIgnoreCase) - ? $"Social media agent created: {agentId}" - : $"Social media agent accepted: {agentId}", - $"Workflow ID: `{workflowId}`", - $"Next scheduled run: {nextRun}", - }; - - if (!string.IsNullOrWhiteSpace(note)) - lines.Add(note!); - - return BuildInfoCard( - "Social Media Agent", - string.Join("\n", lines), - "orange", - new object[] - { - BuildButton("View Agents", "primary", new - { - agent_builder_action = ListAgentsAction, - }), - BuildButton("Create Another", "default", new - { - agent_builder_action = OpenSocialMediaFormAction, - }), - }); + var note = NormalizeOptional(ReadString(root, "note")); + + var headline = string.Equals(status, "created", StringComparison.OrdinalIgnoreCase) + ? "Social media agent created." + : "Social media agent accepted."; + + var body = new StringBuilder(); + body.Append(headline).Append('\n'); + body.Append($"- Agent ID: `{agentId}`\n"); + body.Append($"- Workflow ID: `{workflowId}`\n"); + body.Append($"- Next scheduled run: `{nextRun}`"); + if (note is not null) + body.Append("\n\n").Append(note); + + var content = new MessageContent(); + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = $"social_media_created:{agentId}", + Title = "Social Media Agent", + Text = body.ToString(), + }); + content.Actions.Add(BuildCardAction("View Agents", ListAgentsAction, isPrimary: true)); + content.Actions.Add(BuildCardAction("Create Another", OpenSocialMediaFormAction, isPrimary: false)); + return content; } - private static string FormatListTemplatesResult(JsonElement root) + private static MessageContent FormatListTemplatesResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"List templates failed: {error}"; + return ToTextContent($"List templates failed: {error}"); + + var content = new MessageContent(); if (!root.TryGetProperty("templates", out var templatesElement) || - templatesElement.ValueKind != JsonValueKind.Array) + templatesElement.ValueKind != JsonValueKind.Array || + templatesElement.GetArrayLength() == 0) { - return "No templates available."; + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = "templates_empty", + Title = "Available Templates", + Text = "No templates available right now.", + }); + content.Actions.Add(BuildCardAction("View Agents", ListAgentsAction, isPrimary: false)); + return content; } - var elements = new List - { - new - { - tag = "markdown", - content = "Day One currently exposes the templates below.", - }, - }; + var body = new StringBuilder(); + body.Append("Day One currently exposes the templates below."); + + var hasReadyDaily = false; + var hasReadySocial = false; foreach (var item in templatesElement.EnumerateArray()) { @@ -665,106 +719,41 @@ private static string FormatListTemplatesResult(JsonElement root) var requiredFields = ReadStringArray(item, "required_fields"); var optionalFields = ReadStringArray(item, "optional_fields"); - elements.Add(new - { - tag = "markdown", - content = - $"**{EscapeMarkdown(name)}**\nStatus: `{EscapeMarkdown(status)}`\n{EscapeMarkdown(description)}\nRequired: {FormatFieldList(requiredFields)}\nOptional: {FormatFieldList(optionalFields)}", - }); + body.Append("\n\n"); + body.Append($"**`{name}`** · {status}\n"); + body.Append($"{description}\n"); + body.Append($"- Required: {FormatFieldList(requiredFields)}\n"); + body.Append($"- Optional: {FormatFieldList(optionalFields)}"); - if (string.Equals(name, "daily_report", StringComparison.OrdinalIgnoreCase) && - string.Equals(status, "ready", StringComparison.OrdinalIgnoreCase)) - { - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("Create Daily Report", "primary", new - { - agent_builder_action = OpenDailyReportFormAction, - }), - }, - }); - } - else if (string.Equals(name, "social_media", StringComparison.OrdinalIgnoreCase) && - string.Equals(status, "ready", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(status, "ready", StringComparison.OrdinalIgnoreCase)) { - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("Create Social Media", "primary", new - { - agent_builder_action = OpenSocialMediaFormAction, - }), - }, - }); + if (string.Equals(name, "daily_report", StringComparison.OrdinalIgnoreCase)) + hasReadyDaily = true; + else if (string.Equals(name, "social_media", StringComparison.OrdinalIgnoreCase)) + hasReadySocial = true; } } - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("List Agents", "default", new - { - agent_builder_action = ListAgentsAction, - }), - }, - }); - - return JsonSerializer.Serialize(new + content.Cards.Add(new CardBlock { - config = new - { - wide_screen_mode = true, - }, - header = new - { - title = new - { - tag = "plain_text", - content = "Available Templates", - }, - template = "indigo", - }, - elements, + Kind = CardBlockKind.Section, + BlockId = "templates_list", + Title = "Available Templates", + Text = body.ToString(), }); - } - - private static string FormatListAgentsResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return $"List agents failed: {error}"; - - if (!root.TryGetProperty("agents", out var agentsElement) || - agentsElement.ValueKind != JsonValueKind.Array) - { - return BuildEmptyAgentListCard(); - } - var agents = new List(); - foreach (var item in agentsElement.EnumerateArray()) - { - var agentId = ReadString(item, "agent_id") ?? "unknown-agent"; - var template = ReadString(item, "template") ?? "unknown-template"; - var status = ReadString(item, "status") ?? "unknown"; - var nextRun = ReadString(item, "next_scheduled_run") ?? "pending"; - agents.Add(new AgentListCardItem(agentId, template, status, nextRun)); - } - - return agents.Count == 0 - ? BuildEmptyAgentListCard() - : BuildAgentListCard(agents); + if (hasReadyDaily) + content.Actions.Add(BuildCardAction("Create Daily Report", OpenDailyReportFormAction, isPrimary: true)); + if (hasReadySocial) + content.Actions.Add(BuildCardAction("Create Social Media", OpenSocialMediaFormAction, isPrimary: !hasReadyDaily)); + content.Actions.Add(BuildCardAction("View Agents", ListAgentsAction, isPrimary: false)); + return content; } - private static string FormatAgentStatusResult(JsonElement root) + private static MessageContent FormatAgentStatusResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"Agent status failed: {error}"; + return ToTextContent($"Agent status failed: {error}"); var agentId = ReadString(root, "agent_id") ?? "unknown-agent"; var template = ReadString(root, "template") ?? "unknown-template"; @@ -774,79 +763,99 @@ private static string FormatAgentStatusResult(JsonElement root) var lastRunAt = ReadString(root, "last_run_at") ?? "n/a"; var nextRunAt = ReadString(root, "next_scheduled_run") ?? "n/a"; var errorCount = ReadString(root, "error_count") ?? "0"; - var lastError = ReadString(root, "last_error"); - var note = ReadString(root, "note"); + var lastError = NormalizeOptional(ReadString(root, "last_error")); + var note = NormalizeOptional(ReadString(root, "note")); + + var body = new StringBuilder(); + body.Append($"- Agent ID: `{agentId}`\n"); + body.Append($"- Template: `{template}`\n"); + body.Append($"- Status: `{status}`\n"); + body.Append($"- Schedule: `{scheduleCron}` ({scheduleTimezone})\n"); + body.Append($"- Last run: `{lastRunAt}`\n"); + body.Append($"- Next run: `{nextRunAt}`\n"); + body.Append($"- Error count: `{errorCount}`"); + if (lastError is not null) + body.Append($"\n- Last error: {lastError}"); + if (note is not null) + body.Append("\n\n").Append(note); + + var content = new MessageContent(); + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = $"agent_status:{agentId}", + Title = "Agent Status", + Text = body.ToString(), + }); - var lines = new List + var isDisabled = string.Equals( + status, + SkillRunnerDefaults.StatusDisabled, + StringComparison.OrdinalIgnoreCase); + content.Actions.Add(BuildAgentScopedCardAction("Refresh Status", AgentStatusAction, agentId, isPrimary: false)); + if (isDisabled) { - $"**Agent:** `{agentId}`", - $"Template: `{template}`", - $"Status: `{status}`", - $"Schedule: `{scheduleCron}` ({scheduleTimezone})", - $"Last run: `{lastRunAt}`", - $"Next run: `{nextRunAt}`", - $"Error count: `{errorCount}`", - }; - - if (!string.IsNullOrWhiteSpace(lastError)) - lines.Add($"Last error: `{lastError}`"); - if (!string.IsNullOrWhiteSpace(note)) - lines.Add(note!); + content.Actions.Add(BuildAgentScopedCardAction("Enable", EnableAgentAction, agentId, isPrimary: true)); + } + else + { + content.Actions.Add(BuildAgentScopedCardAction("Run Now", RunAgentAction, agentId, isPrimary: true)); + content.Actions.Add(BuildAgentScopedCardAction("Disable", DisableAgentAction, agentId, isPrimary: false)); + } + content.Actions.Add(BuildCardAction("Back to Agents", ListAgentsAction, isPrimary: false)); - return BuildInfoCard( - "Agent Status", - string.Join("\n", lines), - string.Equals(status, SkillRunnerDefaults.StatusDisabled, StringComparison.OrdinalIgnoreCase) ? "grey" : "green", - BuildStatusCardActions(agentId, template, status)); + // The card-flow path keeps the explicit confirmation step before deletion (vs. the typed + // /agent-status path's direct delete) so the per-agent template is carried along to the + // confirmation card. Danger styling matches Lark's red-button affordance. + var deleteButton = BuildAgentScopedCardAction("Delete", ConfirmDeleteAgentAction, agentId, isPrimary: false); + deleteButton.IsDanger = true; + deleteButton.Arguments["template"] = template; + content.Actions.Add(deleteButton); + return content; } - private static string FormatRunAgentResult(JsonElement root) + private static MessageContent FormatRunAgentResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"Run agent failed: {error}"; + return ToTextContent($"Run agent failed: {error}"); var agentId = ReadString(root, "agent_id") ?? "unknown-agent"; var template = ReadString(root, "template") ?? "unknown-template"; var note = ReadString(root, "note") ?? "Manual run dispatched."; - return BuildInfoCard( - "Run Triggered", - $"Agent `{agentId}` (`{template}`)\n{note}", - "green", - new object[] - { - BuildButton("Back to Agents", "default", new - { - agent_builder_action = ListAgentsAction, - }), - BuildButton("Refresh Status", "primary", new - { - agent_builder_action = AgentStatusAction, - agent_id = agentId, - }), - }); + var content = new MessageContent(); + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = $"run_triggered:{agentId}", + Title = "Run Triggered", + Text = $"Agent `{agentId}` (`{template}`)\n\n{note}", + }); + content.Actions.Add(BuildCardAction("Back to Agents", ListAgentsAction, isPrimary: false)); + content.Actions.Add(BuildAgentScopedCardAction("Refresh Status", AgentStatusAction, agentId, isPrimary: true)); + return content; } - private static string FormatDisableAgentResult(JsonElement root) + private static MessageContent FormatDisableAgentResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"Disable agent failed: {error}"; + return ToTextContent($"Disable agent failed: {error}"); return FormatAgentStatusResult(root); } - private static string FormatEnableAgentResult(JsonElement root) + private static MessageContent FormatEnableAgentResult(JsonElement root) { if (TryReadError(root, out var error)) - return $"Enable agent failed: {error}"; + return ToTextContent($"Enable agent failed: {error}"); return FormatAgentStatusResult(root); } - private static string FormatDeleteAgentResult(JsonElement root) + private static MessageContent FormatDeleteAgentResultAsList(JsonElement root) { if (TryReadError(root, out var error)) - return $"Delete agent failed: {error}"; + return ToTextContent($"Delete agent failed: {error}"); var status = ReadString(root, "status") ?? "accepted"; var agentId = ReadString(root, "agent_id") ?? "unknown-agent"; @@ -866,33 +875,14 @@ private static string FormatDeleteAgentResult(JsonElement root) if (!string.IsNullOrWhiteSpace(note)) lines.Add(note!); - var noticeMarkdown = string.Join("\n", lines); - var agents = ReadAgentList(root); - return agents.Count == 0 - ? BuildEmptyAgentListCard(noticeMarkdown) - : BuildAgentListCard(agents, noticeMarkdown); + return AgentBuilderCardContent.FormatListAgentsResult(root, string.Join("\n", lines)); } - private static bool TryReadError(JsonElement root, out string error) - { - error = ReadString(root, "error") ?? string.Empty; - return error.Length > 0; - } + private static bool TryReadError(JsonElement root, out string error) => + AgentBuilderJson.TryReadError(root, out error); - private static string? ReadString(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - return null; - - return property.ValueKind switch - { - JsonValueKind.String => property.GetString(), - JsonValueKind.Number => property.GetRawText(), - JsonValueKind.True => bool.TrueString, - JsonValueKind.False => bool.FalseString, - _ => null, - }; - } + private static string? ReadString(JsonElement element, string propertyName) => + AgentBuilderJson.TryReadString(element, propertyName); private static IReadOnlyList ReadStringArray(JsonElement element, string propertyName) { @@ -915,354 +905,52 @@ private static string FormatFieldList(IReadOnlyList fields) => ? "`None`" : string.Join(", ", fields.Select(static field => $"`{field}`")); - private static string BuildAgentListCard(IReadOnlyList agents, string? noticeMarkdown = null) - { - var elements = new List - { - new - { - tag = "markdown", - content = $"You currently have **{agents.Count}** agent(s).", - }, - new - { - tag = "markdown", - content = "Quick commands: `/daily`, `/social-media`, `/agent-status `, `/run-agent `, `/disable-agent `, `/enable-agent `, `/delete-agent `", - }, - }; - - if (!string.IsNullOrWhiteSpace(noticeMarkdown)) - { - elements.Insert(0, new - { - tag = "markdown", - content = noticeMarkdown, - }); - } - - foreach (var agent in agents) - { - elements.Add(new - { - tag = "markdown", - content = $"**{EscapeMarkdown(agent.Template)}**\nID: `{EscapeMarkdown(agent.AgentId)}`\nStatus: `{EscapeMarkdown(agent.Status)}`\nNext run: `{EscapeMarkdown(agent.NextRun)}`", - }); - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("Status", "primary", new - { - agent_builder_action = AgentStatusAction, - agent_id = agent.AgentId, - }), - BuildAgentListPrimaryAction(agent), - BuildButton("Delete", "danger", new - { - agent_builder_action = ConfirmDeleteAgentAction, - agent_id = agent.AgentId, - template = agent.Template, - }), - }, - }); - } - - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("Refresh List", "default", new - { - agent_builder_action = ListAgentsAction, - }), - BuildButton("Create Daily Report", "default", new - { - agent_builder_action = OpenDailyReportFormAction, - }), - BuildButton("Create Social Media", "default", new - { - agent_builder_action = OpenSocialMediaFormAction, - }), - BuildButton("View Templates", "default", new - { - agent_builder_action = ListTemplatesAction, - }), - }, - }); - - return JsonSerializer.Serialize(new - { - config = new - { - wide_screen_mode = true, - }, - header = new - { - title = new - { - tag = "plain_text", - content = "Current Agents", - }, - template = "wathet", - }, - elements, - }); - } - - private static string BuildEmptyAgentListCard() - { - return BuildEmptyAgentListCard(null); - } - - private static string BuildEmptyAgentListCard(string? noticeMarkdown) - { - var elements = new List(); - if (!string.IsNullOrWhiteSpace(noticeMarkdown)) - { - elements.Add(new - { - tag = "markdown", - content = noticeMarkdown, - }); - } - - elements.Add(new - { - tag = "markdown", - content = "No agents found yet. Create your first daily report or social media agent from here.", - }); - elements.Add(new - { - tag = "markdown", - content = "Quick commands: `/templates`, `/daily`, `/social-media`, `/agent-status `", - }); - elements.Add(new - { - tag = "action", - actions = new object[] - { - BuildButton("Create Daily Report", "primary", new - { - agent_builder_action = OpenDailyReportFormAction, - }), - BuildButton("View Templates", "default", new - { - agent_builder_action = ListTemplatesAction, - }), - BuildButton("Create Social Media", "default", new - { - agent_builder_action = OpenSocialMediaFormAction, - }), - }, - }); - - return JsonSerializer.Serialize(new - { - config = new - { - wide_screen_mode = true, - }, - header = new - { - title = new - { - tag = "plain_text", - content = "Current Agents", - }, - template = "wathet", - }, - elements, - }); - } - - private static string BuildDeleteConfirmationCard(string agentId, string? template) + private static MessageContent BuildDeleteConfirmationCard(string agentId, string? template) { var templateLabel = NormalizeOptional(template) ?? "unknown-template"; - return BuildInfoCard( - "Delete Agent", - $"Delete agent `{EscapeMarkdown(agentId)}` from template `{EscapeMarkdown(templateLabel)}`?\nThis will disable scheduling, revoke the Nyx API key, and tombstone the registry entry.", - "red", - new object[] - { - BuildButton("Confirm Delete", "danger", new - { - agent_builder_action = DeleteAgentAction, - agent_id = agentId, - }), - BuildButton("Back to Agents", "default", new - { - agent_builder_action = ListAgentsAction, - }), - }); - } - - private static string BuildInfoCard( - string title, - string markdown, - string template, - object[] actions) - { - return JsonSerializer.Serialize(new - { - config = new - { - wide_screen_mode = true, - }, - header = new - { - title = new - { - tag = "plain_text", - content = title, - }, - template, - }, - elements = new object[] - { - new - { - tag = "markdown", - content = markdown, - }, - new - { - tag = "action", - actions, - }, - }, + var content = new MessageContent(); + content.Cards.Add(new CardBlock + { + Kind = CardBlockKind.Section, + BlockId = $"delete_confirm:{agentId}", + Title = "Delete Agent", + Text = + $"Delete agent `{agentId}` from template `{templateLabel}`?\n\n" + + "This will disable scheduling, revoke the Nyx API key, and tombstone the registry entry.", }); + var confirmButton = BuildAgentScopedCardAction("Confirm Delete", DeleteAgentAction, agentId, isPrimary: false); + confirmButton.IsDanger = true; + content.Actions.Add(confirmButton); + content.Actions.Add(BuildCardAction("Back to Agents", ListAgentsAction, isPrimary: false)); + return content; } - private static object BuildButton(string label, string style, object value) => - new - { - tag = "button", - type = style, - text = new - { - tag = "plain_text", - content = label, - }, - value, - }; - - private static object BuildLinkButton(string label, string style, string url) => - new - { - tag = "button", - type = style, - text = new - { - tag = "plain_text", - content = label, - }, - multi_url = new - { - url, - pc_url = url, - ios_url = url, - android_url = url, - }, - }; - - private static string EscapeMarkdown(string value) => - value - .Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("`", "\\`", StringComparison.Ordinal); - - private static object[] BuildStatusCardActions(string agentId, string template, string status) + private static ActionElement BuildCardAction(string label, string agentBuilderAction, bool isPrimary) { - var actions = new List + var button = new ActionElement { - BuildButton("Refresh Status", "default", new - { - agent_builder_action = AgentStatusAction, - agent_id = agentId, - }), + Kind = ActionElementKind.Button, + ActionId = agentBuilderAction, + Label = label, + IsPrimary = isPrimary, }; - - if (string.Equals(status, SkillRunnerDefaults.StatusDisabled, StringComparison.OrdinalIgnoreCase)) - { - actions.Add(BuildButton("Enable Agent", "primary", new - { - agent_builder_action = EnableAgentAction, - agent_id = agentId, - })); - } - else - { - actions.Add(BuildButton("Run Now", "primary", new - { - agent_builder_action = RunAgentAction, - agent_id = agentId, - })); - actions.Add(BuildButton("Disable Agent", "default", new - { - agent_builder_action = DisableAgentAction, - agent_id = agentId, - })); - } - - actions.Add(BuildButton("Back to Agents", "default", new - { - agent_builder_action = ListAgentsAction, - })); - actions.Add(BuildButton("Delete Agent", "danger", new - { - agent_builder_action = ConfirmDeleteAgentAction, - agent_id = agentId, - template, - })); - - return actions.ToArray(); + button.Arguments["agent_builder_action"] = agentBuilderAction; + return button; } - private static object BuildAgentListPrimaryAction(AgentListCardItem agent) + private static ActionElement BuildAgentScopedCardAction( + string label, + string agentBuilderAction, + string agentId, + bool isPrimary) { - if (string.Equals(agent.Status, SkillRunnerDefaults.StatusDisabled, StringComparison.OrdinalIgnoreCase)) - { - return BuildButton("Enable", "default", new - { - agent_builder_action = EnableAgentAction, - agent_id = agent.AgentId, - }); - } - - return BuildButton("Run Now", "default", new - { - agent_builder_action = RunAgentAction, - agent_id = agent.AgentId, - }); + var button = BuildCardAction(label, agentBuilderAction, isPrimary); + button.Arguments["agent_id"] = agentId; + return button; } - private static IReadOnlyList ReadAgentList(JsonElement root) - { - if (!root.TryGetProperty("agents", out var agentsElement) || - agentsElement.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - var agents = new List(); - foreach (var item in agentsElement.EnumerateArray()) - { - var agentId = ReadString(item, "agent_id") ?? "unknown-agent"; - var template = ReadString(item, "template") ?? "unknown-template"; - var status = ReadString(item, "status") ?? "unknown"; - var nextRun = ReadString(item, "next_scheduled_run") ?? "pending"; - agents.Add(new AgentListCardItem(agentId, template, status, nextRun)); - } - - return agents; - } } -public sealed record AgentListCardItem( - string AgentId, - string Template, - string Status, - string NextRun); - public sealed record AgentBuilderFlowDecision( bool RequiresToolExecution, string ReplyPayload, diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderJson.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderJson.cs new file mode 100644 index 000000000..b42151215 --- /dev/null +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderJson.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Authoring.Lark; + +/// +/// Shared reading helpers for the agent-builder formatters. +/// +/// +/// These were previously copy-pasted across , +/// , and ; a fix in one +/// copy needed manual replication everywhere or behavior would silently diverge across the typed +/// and card-action surfaces. The helpers are intentionally narrow — only the json-shape concerns +/// every formatter shares — so this file does not become a junk drawer. +/// +internal static class AgentBuilderJson +{ + /// Builds a plain-text reply. + public static MessageContent TextContent(string text) => new() { Text = text }; + + /// + /// Reads the canonical error field. Returns true when the element carries a + /// non-empty error string and emits its value via . + /// + public static bool TryReadError(JsonElement root, out string error) + { + error = TryReadString(root, "error") ?? string.Empty; + return error.Length > 0; + } + + /// Reads a property as a string, coercing scalar JSON kinds to text. + public static string? TryReadString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + return null; + + return property.ValueKind switch + { + JsonValueKind.String => property.GetString(), + JsonValueKind.Number => property.GetRawText(), + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + _ => null, + }; + } + + /// + /// Reads a property as a boolean, also accepting the canonical lowercased string form + /// true/false emitted by some of the upstream tools. + /// + public static bool TryReadBool(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + return false; + + return property.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(property.GetString(), out var parsed) && parsed, + _ => false, + }; + } + + /// + /// Reads an optional string property, returning null for missing or whitespace-only + /// values. Trims surrounding whitespace from the captured value. + /// + public static string? TryReadOptional(JsonElement element, string propertyName) + { + var raw = TryReadString(element, propertyName); + return string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(); + } +} diff --git a/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs index 50ed5deaf..4400bfb21 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs @@ -66,7 +66,7 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, "create_daily_report" => FormatCreateDailyReportResult(doc.RootElement), "create_social_media" => TextContent(FormatCreateSocialMediaResult(doc.RootElement)), "list_templates" => TextContent(FormatListTemplatesResult(doc.RootElement)), - "list_agents" => FormatListAgentsCard(doc.RootElement), + "list_agents" => AgentBuilderCardContent.FormatListAgentsResult(doc.RootElement), "agent_status" => FormatAgentStatusCard(doc.RootElement), "run_agent" => TextContent(FormatRunAgentResult(doc.RootElement)), "disable_agent" => TextContent(FormatLifecycleStatusResult("Agent disabled.", doc.RootElement)), @@ -81,7 +81,7 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, } } - private static MessageContent TextContent(string text) => new() { Text = text }; + private static MessageContent TextContent(string text) => AgentBuilderJson.TextContent(text); private static bool IsKnownCommand(string command) => command is DailyCommand @@ -337,130 +337,6 @@ private static string FormatListTemplatesResult(JsonElement root) return string.Join('\n', lines); } - private static string FormatListAgentsResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return $"List agents failed: {error}"; - - if (!root.TryGetProperty("agents", out var agentsElement) || - agentsElement.ValueKind != JsonValueKind.Array || - agentsElement.GetArrayLength() == 0) - { - return BuildTextBlock( - "No agents found.", - "Create one with:", - BuildDailyReportCommandExample(), - BuildSocialMediaCommandExample()); - } - - var lines = new List { "Current agents:" }; - foreach (var item in agentsElement.EnumerateArray()) - { - var agentId = ReadString(item, "agent_id") ?? "unknown-agent"; - var template = ReadString(item, "template") ?? "unknown-template"; - var status = ReadString(item, "status") ?? "unknown"; - var nextRun = ReadString(item, "next_scheduled_run") ?? "pending"; - lines.Add($"- {agentId}: template={template}, status={status}, next_run={nextRun}"); - } - - lines.Add(string.Empty); - lines.Add("Next commands: /agent-status , /run-agent , /disable-agent , /enable-agent , /delete-agent confirm"); - return string.Join('\n', lines); - } - - /// - /// Renders /agents as an interactive Lark card. Each agent gets a section block with - /// status fields and a "Status" button that triggers agent_builder_action=agent_status - /// (handled by ); a footer button cluster offers shortcuts - /// to create another agent or browse templates. Empty result keeps the existing helper-text - /// reply since there are no per-agent buttons to render. - /// - private static MessageContent FormatListAgentsCard(JsonElement root) - { - if (TryReadError(root, out var error)) - return TextContent($"List agents failed: {error}"); - - var content = new MessageContent(); - - if (!root.TryGetProperty("agents", out var agentsElement) || - agentsElement.ValueKind != JsonValueKind.Array || - agentsElement.GetArrayLength() == 0) - { - content.Cards.Add(new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "agents_empty", - Title = "No agents yet", - Text = "Create one with `/daily` for a daily GitHub report or `/social-media` for a social-media drafter.", - }); - content.Actions.Add(BuildButton("Create Daily Report", "open_daily_report_form", isPrimary: true)); - content.Actions.Add(BuildButton("Create Social Media", "open_social_media_form", isPrimary: false)); - return content; - } - - var summary = new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "agents_summary", - Title = "Your agents", - Text = "Tap **Status** under any agent to drill in. Action buttons there run, disable/enable, or delete the agent.", - }; - content.Cards.Add(summary); - - foreach (var item in agentsElement.EnumerateArray()) - { - var agentId = ReadString(item, "agent_id") ?? "unknown-agent"; - var template = ReadString(item, "template") ?? "unknown-template"; - var status = ReadString(item, "status") ?? "unknown"; - var nextRun = ReadString(item, "next_scheduled_run") ?? "pending"; - var lastRun = NormalizeOptional(ReadString(item, "last_run_at")); - - var card = new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = $"agent_row:{agentId}", - Title = $"`{agentId}`", - Text = $"Template: `{template}` · Status: `{status}`\nNext run: `{nextRun}`{(lastRun is null ? string.Empty : $" · Last run: `{lastRun}`")}", - }; - content.Cards.Add(card); - - // Per-agent "Status" button: triggers `agent_status` action which AgentBuilderCardFlow - // already handles and re-renders as a status card with the run / lifecycle actions. - content.Actions.Add(BuildAgentScopedButton( - label: $"Status: {ShortenAgentId(agentId)}", - agentBuilderAction: "agent_status", - agentId: agentId, - isPrimary: false)); - } - - // Footer shortcut row mirrors what AgentBuilderCardFlow renders on the dedicated card - // path so users have one consistent UX whether they typed `/agents` or arrived via card. - content.Actions.Add(BuildButton("Create Daily Report", "open_daily_report_form", isPrimary: false)); - content.Actions.Add(BuildButton("Create Social Media", "open_social_media_form", isPrimary: false)); - content.Actions.Add(BuildButton("Templates", "list_templates", isPrimary: false)); - - return content; - } - - private static string FormatAgentStatusResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return $"Agent status failed: {error}"; - - var agentId = ReadString(root, "agent_id") ?? "unknown-agent"; - return BuildTextBlock( - "Agent status:", - $"Agent ID: {agentId}", - $"Template: {ReadString(root, "template") ?? "unknown-template"}", - $"Status: {ReadString(root, "status") ?? "unknown"}", - $"Schedule: {ReadString(root, "schedule_cron") ?? "n/a"} ({ReadString(root, "schedule_timezone") ?? "n/a"})", - $"Last run: {ReadString(root, "last_run_at") ?? "n/a"}", - $"Next run: {ReadString(root, "next_scheduled_run") ?? "n/a"}", - NormalizeOptional(ReadString(root, "last_error")) is { } lastError ? $"Last error: {lastError}" : null, - NormalizeOptional(ReadString(root, "note")), - $"Next commands: /run-agent {agentId}, /disable-agent {agentId}, /enable-agent {agentId}, /delete-agent {agentId} confirm"); - } - /// /// Renders /agent-status <agent_id> as an interactive card with action buttons /// (Run, Disable, Enable, Delete). Each button submits the corresponding @@ -544,19 +420,6 @@ private static ActionElement BuildAgentScopedButton(string label, string agentBu return button; } - /// - /// Compresses long agent ids (e.g. skill-runner-94d754dfdfbb416aa5a676cecd0d7a71) into - /// a 10-char suffix so per-agent button labels stay readable in narrow Lark cards. The full - /// id is still carried in the button's arguments so the click handler routes correctly. - /// - private static string ShortenAgentId(string agentId) - { - if (string.IsNullOrEmpty(agentId) || agentId.Length <= 14) - return agentId; - - return $"…{agentId[^10..]}"; - } - private static string FormatRunAgentResult(JsonElement root) { if (TryReadError(root, out var error)) @@ -650,26 +513,11 @@ private static bool ResolveRunImmediately(IReadOnlyDictionary ar return NormalizeOptional(raw); } - private static bool TryReadError(JsonElement root, out string error) - { - error = ReadString(root, "error") ?? string.Empty; - return error.Length > 0; - } - - private static string? ReadString(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - return null; + private static bool TryReadError(JsonElement root, out string error) => + AgentBuilderJson.TryReadError(root, out error); - return property.ValueKind switch - { - JsonValueKind.String => property.GetString(), - JsonValueKind.Number => property.GetRawText(), - JsonValueKind.True => bool.TrueString, - JsonValueKind.False => bool.FalseString, - _ => null, - }; - } + private static string? ReadString(JsonElement element, string propertyName) => + AgentBuilderJson.TryReadString(element, propertyName); private static string BuildDailyReportHelpText() => BuildTextBlock( diff --git a/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs index a772ec1e6..19c5b9b2a 100644 --- a/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs @@ -102,12 +102,25 @@ public LarkOutboundMessage Compose(MessageContent intent, ComposeContext context }); } - foreach (var card in intent.Cards) + for (var i = 0; i < intent.Cards.Count; i++) { + var card = intent.Cards[i]; + // First card's Title is consumed by ResolveHeaderTitle as the card header (Title + // takes precedence over intent.Text there), so render its body markdown without the + // title to avoid header/body duplication. Form mode already does this; non-form mode + // used to leak the title twice and made every single-card response (e.g. /agents, + // /agent-status) show a redundant bold title row right under the header. When the + // first card has no Title, ResolveHeaderTitle falls back to intent.Text and this + // skip is a no-op (no title to elide). + var skipTitle = i == 0; + var markdown = BuildCardMarkdown(card, skipTitle); + if (string.IsNullOrWhiteSpace(markdown)) + continue; + elements.Add(new { tag = "markdown", - content = BuildCardMarkdown(card), + content = markdown, }); } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs index 60fc863b2..c7c1592ea 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs @@ -114,6 +114,280 @@ public async Task TryResolveAsync_TemplatesCardButton_DispatchesListTemplatesToo body.RootElement.GetProperty("action").GetString().Should().Be("list_templates"); } + [Fact] + public void FormatToolResult_ListAgents_ReturnsStructuredCardNotJsonText() + { + // Issue #476 second leg: clicking the "Refresh List" card button used to dispatch the + // list_agents tool through AgentBuilderCardFlow.FormatToolResult, which wrapped a Lark + // card JSON string in MessageContent.Text. The relay forwarded that JSON as plain text, + // so the user saw a raw `{"config":...}` dump alongside (or instead of) a card. The card + // path now returns the same structured MessageContent as the typed `/agents` flow so the + // composer can render it as a single native card, with no JSON-shaped Text body. + var decision = AgentBuilderFlowDecision.ToolCall("list_agents", """{"action":"list_agents"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "agents": [ + { + "agent_id": "skill-runner-card-click-1", + "template": "daily_report", + "status": "running", + "next_scheduled_run": "2026-04-23T09:00:00Z" + } + ] + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "agents_list"); + result.Cards.Single().Title.Should().Be("Your Agents (1)"); + result.Cards.Single().Text.Should().Contain("skill-runner-card-click-1"); + // No raw Lark JSON envelope leaks into the body. + result.Cards.Single().Text.Should().NotContain("\"config\""); + result.Cards.Single().Text.Should().NotContain("\"elements\""); + } + + [Fact] + public void FormatToolResult_DeleteAgent_RendersUpdatedListWithNotice() + { + // After a delete completes, the user should see (a) confirmation that the right agent + // is gone and (b) the remaining registry inline, not the legacy multi-card layout the + // composer previously emitted via BuildAgentListCard's raw Lark JSON. + var decision = AgentBuilderFlowDecision.ToolCall("delete_agent", """{"action":"delete_agent"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "status": "deleted", + "agent_id": "skill-runner-deleted-1", + "revoked_api_key_id": "key-1", + "agents": [ + { + "agent_id": "skill-runner-remaining-1", + "template": "social_media", + "status": "running", + "next_scheduled_run": "2026-04-23T09:00:00Z" + } + ] + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "agents_list"); + var card = result.Cards.Single(); + // Notice + the (still-present) remaining agent are both visible in the same card body. + card.Text.Should().Contain("Deleted agent `skill-runner-deleted-1`"); + card.Text.Should().Contain("skill-runner-remaining-1"); + } + + [Fact] + public void FormatToolResult_ListTemplates_ReturnsStructuredCardNotJsonText() + { + // Issue #482: clicking the `Templates` button used to dispatch list_templates and the + // formatter wrapped a Lark card JSON envelope in MessageContent.Text, which the relay + // then forwarded as raw text. Pin the structured-MessageContent contract here. + var decision = AgentBuilderFlowDecision.ToolCall("list_templates", """{"action":"list_templates"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "templates": [ + { + "name": "daily_report", + "status": "ready", + "description": "Daily GitHub report.", + "required_fields": ["github_username"], + "optional_fields": ["repositories", "schedule_time"] + }, + { + "name": "social_media", + "status": "ready", + "description": "Social media drafter.", + "required_fields": ["topic"], + "optional_fields": ["audience", "style"] + } + ] + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "templates_list"); + var card = result.Cards.Single(); + card.Title.Should().Be("Available Templates"); + card.Text.Should().Contain("daily_report"); + card.Text.Should().Contain("social_media"); + card.Text.Should().NotContain("\"config\""); + result.Actions.Should().Contain(a => a.ActionId == "open_daily_report_form"); + result.Actions.Should().Contain(a => a.ActionId == "open_social_media_form"); + result.Actions.Should().Contain(a => a.ActionId == "list_agents"); + } + + [Fact] + public void FormatToolResult_AgentStatus_ReturnsStructuredCardWithLifecycleButtons() + { + var decision = AgentBuilderFlowDecision.ToolCall("agent_status", """{"action":"agent_status"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "agent_id": "skill-runner-1", + "template": "daily_report", + "status": "running", + "schedule_cron": "0 9 * * *", + "schedule_timezone": "UTC", + "last_run_at": "2026-04-25T05:30:00Z", + "next_scheduled_run": "2026-04-26T09:00:00Z", + "error_count": "0" + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "agent_status:skill-runner-1"); + result.Cards.Single().Text.Should().Contain("Status: `running`"); + // Lifecycle buttons render as actions, not as embedded JSON in MessageContent.Text. + result.Actions.Should().Contain(a => a.ActionId == "run_agent"); + result.Actions.Should().Contain(a => a.ActionId == "disable_agent"); + // `confirm_delete_agent` so the card-flow path keeps the explicit confirmation step. + var deleteButton = result.Actions.Should().Contain(a => a.ActionId == "confirm_delete_agent").Subject; + deleteButton.IsDanger.Should().BeTrue(); + deleteButton.Arguments.Should().Contain(new KeyValuePair("agent_id", "skill-runner-1")); + deleteButton.Arguments.Should().Contain(new KeyValuePair("template", "daily_report")); + } + + [Fact] + public void FormatToolResult_RunAgent_ReturnsStructuredCardNotJsonText() + { + var decision = AgentBuilderFlowDecision.ToolCall("run_agent", """{"action":"run_agent"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "agent_id": "skill-runner-1", + "template": "daily_report", + "status": "running", + "note": "Manual run dispatched." + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "run_triggered:skill-runner-1"); + result.Cards.Single().Text.Should().Contain("Manual run dispatched"); + result.Cards.Single().Text.Should().NotContain("\"config\""); + result.Actions.Should().Contain(a => a.ActionId == "list_agents"); + var refreshButton = result.Actions.Should() + .Contain(a => a.ActionId == "agent_status").Subject; + refreshButton.Arguments.Should().Contain(new KeyValuePair( + "agent_id", "skill-runner-1")); + } + + [Fact] + public void FormatToolResult_CreateSocialMedia_ReturnsStructuredCardNotJsonText() + { + var decision = AgentBuilderFlowDecision.ToolCall("create_social_media", """{"action":"create_agent"}"""); + var result = AgentBuilderCardFlow.FormatToolResult( + decision, + """ + { + "status": "created", + "agent_id": "skill-runner-sm-1", + "workflow_id": "workflow-1", + "next_scheduled_run": "2026-04-26T09:00:00Z" + } + """); + + result.Text.Should().BeNullOrEmpty(); + result.Cards.Should().ContainSingle(card => card.BlockId == "social_media_created:skill-runner-sm-1"); + result.Cards.Single().Text.Should().Contain("skill-runner-sm-1"); + result.Cards.Single().Text.Should().NotContain("\"config\""); + result.Actions.Should().Contain(a => a.ActionId == "list_agents"); + result.Actions.Should().Contain(a => a.ActionId == "open_social_media_form"); + } + + [Fact] + public async Task TryResolveAsync_DeleteAgentTextCommand_TreatsConfirmTrailerAsExplicitConfirmation() + { + // Review feedback on PR #483: the shared `/agents` renderer prints the inline command + // hint `/delete-agent confirm` (matching the NyxRelay text contract). The direct + // CardFlow text-command parser used to take everything after the command as the agent_id, + // so following that hint produced an agent_id of ` confirm` and the confirmation card + // built for a bogus id whose Confirm Delete button then failed the actual delete. This + // test pins the new behavior: the trailing `confirm` token is recognized and the parser + // dispatches the delete directly (matching NyxRelay's semantics) instead of stuffing it + // into the agent_id. + var inbound = new ChannelInboundEvent + { + ChatType = "p2p", + RegistrationScopeId = "scope-1", + Text = "/delete-agent skill-runner-1 confirm", + }; + + var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); + + decision.Should().NotBeNull(); + decision!.RequiresToolExecution.Should().BeTrue(); + decision.ToolAction.Should().Be("delete_agent"); + + using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); + body.RootElement.GetProperty("action").GetString().Should().Be("delete_agent"); + body.RootElement.GetProperty("agent_id").GetString().Should().Be("skill-runner-1"); + body.RootElement.GetProperty("confirm").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task TryResolveAsync_DeleteAgentTextCommand_WithoutConfirmTrailer_StillShowsConfirmationCard() + { + // Without the explicit `confirm` trailer the direct CardFlow path keeps the existing + // UI-driven confirmation step (the user clicks Confirm Delete) — the new parser must + // not change that behavior, only handle the new trailer correctly. + var inbound = new ChannelInboundEvent + { + ChatType = "p2p", + RegistrationScopeId = "scope-1", + Text = "/delete-agent skill-runner-1", + }; + + var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); + + decision.Should().NotBeNull(); + decision!.RequiresToolExecution.Should().BeFalse(); + decision.ReplyContent.Should().NotBeNull(); + decision.ReplyContent!.Cards.Should().ContainSingle(card => + card.BlockId == "delete_confirm:skill-runner-1"); + } + + [Fact] + public async Task TryResolveAsync_ConfirmDeleteAgent_ReturnsStructuredCardNotJsonText() + { + // Pre-fix this branch returned DirectReply with a Lark card JSON string in ReplyPayload + // (no ReplyContent). The runner wrapped that string into MessageContent.Text and the + // relay forwarded raw JSON to the user (issue #482). + var inbound = new ChannelInboundEvent + { + ChatType = "card_action", + RegistrationScopeId = "scope-1", + }; + inbound.Extra["agent_builder_action"] = "confirm_delete_agent"; + inbound.Extra["agent_id"] = "skill-runner-1"; + inbound.Extra["template"] = "daily_report"; + + var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); + + decision.Should().NotBeNull(); + decision!.RequiresToolExecution.Should().BeFalse(); + decision.ReplyContent.Should().NotBeNull(); + decision.ReplyContent!.Text.Should().BeNullOrEmpty(); + decision.ReplyContent.Cards.Should().ContainSingle(card => + card.BlockId == "delete_confirm:skill-runner-1"); + decision.ReplyContent.Cards.Single().Text.Should().Contain("daily_report"); + var confirmButton = decision.ReplyContent.Actions.Should() + .Contain(a => a.ActionId == "delete_agent").Subject; + confirmButton.IsDanger.Should().BeTrue(); + confirmButton.Arguments.Should().Contain(new KeyValuePair( + "agent_id", "skill-runner-1")); + decision.ReplyContent.Actions.Should().Contain(a => a.ActionId == "list_agents"); + } + [Fact] public async Task TryResolveAsync_DailyReportSubmit_AllowsMissingGithubUsername_ForUserConfigFallback() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs index f6e9c999e..d061127cc 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs @@ -154,12 +154,14 @@ public void TryResolve_ShouldBuildCreateSocialMediaToolCall_FromTextCommand() } [Fact] - public void FormatToolResult_ShouldRenderListAgentsAsInteractiveCard() + public void FormatToolResult_ShouldRenderListAgents_AsSingleCardWithoutPerAgentButtons() { - // /agents now returns an interactive card so users can drill into per-agent status - // without retyping the long agent_id. Per-row "Status" buttons carry the full agent_id - // in their arguments so AgentBuilderCardFlow's existing card_action handler routes them - // back to the agent_status tool path. + // Issue #476: /agents used to render as one summary card + N per-agent cards + N + // "Status: …" per-agent buttons. In Lark that compiled into stacked markdown blocks plus + // a long button row, which users perceived as a text list mixed with a separate status + // card. The unified design surfaces ONE card with a structured agent list in the body, + // a small footer of global actions, and the per-agent operations as documented slash + // commands inline in the body. var decision = AgentBuilderFlowDecision.ToolCall("list_agents", """{"action":"list_agents"}"""); var result = NyxRelayAgentBuilderFlow.FormatToolResult( decision, @@ -172,25 +174,46 @@ public void FormatToolResult_ShouldRenderListAgentsAsInteractiveCard() "status": "running", "next_scheduled_run": "2026-04-23T09:00:00Z", "last_run_at": "2026-04-22T09:00:00Z" + }, + { + "agent_id": "skill-runner-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", + "template": "social_media", + "status": "disabled", + "next_scheduled_run": "pending" } ] } """); - result.Cards.Should().NotBeEmpty(); - // First card is the summary; subsequent cards are per-agent rows. - result.Cards[0].Title.Should().Be("Your agents"); - result.Cards.Skip(1).Should().ContainSingle(card => - card.BlockId == "agent_row:skill-runner-94d754dfdfbb416aa5a676cecd0d7a71"); - - var statusButton = result.Actions.Should().Contain(a => a.ActionId == "agent_status").Subject; - statusButton.Arguments.Should().Contain(new KeyValuePair( - "agent_builder_action", "agent_status")); - statusButton.Arguments.Should().Contain(new KeyValuePair( - "agent_id", "skill-runner-94d754dfdfbb416aa5a676cecd0d7a71")); - - result.Actions.Should().Contain(a => a.ActionId == "open_daily_report_form"); - result.Actions.Should().Contain(a => a.ActionId == "open_social_media_form"); + // Single consolidated card — no per-agent CardBlock rows, no `agents_summary` extra block. + result.Cards.Should().ContainSingle(); + var card = result.Cards.Single(); + card.BlockId.Should().Be("agents_list"); + card.Title.Should().Be("Your Agents (2)"); + // Body lists every agent with its identifying fields in markdown. + card.Text.Should().Contain("daily_report"); + card.Text.Should().Contain("skill-runner-94d754dfdfbb416aa5a676cecd0d7a71"); + card.Text.Should().Contain("running"); + card.Text.Should().Contain("social_media"); + card.Text.Should().Contain("disabled"); + // Per-agent commands live in the body so users do not have to remember them. + card.Text.Should().Contain("/agent-status "); + card.Text.Should().Contain("/run-agent "); + card.Text.Should().Contain("/delete-agent confirm"); + + // No per-agent buttons. Specifically no `agent_status` action with an agent_id argument + // — that was the source of the long "Status: …" row that read as a separate panel. + result.Actions.Should().NotContain(a => a.ActionId == "agent_status"); + result.Actions.Should().NotContain(a => a.Arguments.ContainsKey("agent_id")); + + // Footer keeps four global discovery / creation buttons in a single row. + result.Actions.Select(a => a.ActionId).Should().BeEquivalentTo(new[] + { + "list_agents", + "list_templates", + "open_daily_report_form", + "open_social_media_form", + }); } [Fact] @@ -202,6 +225,7 @@ public void FormatToolResult_ShouldRenderEmptyListAgentsAsCallToActionCard() result.Cards.Should().ContainSingle(card => card.BlockId == "agents_empty"); result.Actions.Should().Contain(a => a.ActionId == "open_daily_report_form"); result.Actions.Should().Contain(a => a.ActionId == "open_social_media_form"); + result.Actions.Should().Contain(a => a.ActionId == "list_templates"); } [Fact] diff --git a/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs b/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs index fb33b2561..29cb94f9a 100644 --- a/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs +++ b/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs @@ -117,6 +117,54 @@ public void Compose_WhenRenderingInteractiveCard_UsesLarkV2BodyElements() behavior.GetProperty("value").GetProperty("action_id").GetString().ShouldBe("status"); } + [Fact] + public void Compose_WhenSingleCardSuppliesTitle_DoesNotDuplicateInBody() + { + // The first card's Title is consumed by the Lark card header (see ResolveHeaderTitle). + // Form mode already skipped the title in the body markdown, but non-form mode used to + // re-emit it as `**Title**` right under the header — every single-card response (e.g. + // /agent-status, /agents in its post-fix unified shape) ended up with a redundant bold + // title row. Pin the no-duplicate contract here so a refactor cannot regress it. + var intent = new MessageContent(); + intent.Cards.Add(new CardBlock + { + BlockId = "agents_list", + Title = "Your Agents (1)", + Text = "1. `daily_report` · running", + }); + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "list_agents", + Label = "Refresh", + }); + + var payload = CreateComposer().Compose( + intent, + new ComposeContext + { + Conversation = ConversationReference.Create( + ChannelId.From("lark"), + BotInstanceId.From("bot-1"), + ConversationScope.DirectMessage, + partition: null, + "user-1"), + Capabilities = LarkMessageComposer.DefaultCapabilities.Clone(), + }); + + using var document = JsonDocument.Parse(payload.ContentJson); + // Header title appears exactly once (in the header element). + document.RootElement.GetProperty("header").GetProperty("title").GetProperty("content").GetString() + .ShouldBe("Your Agents (1)"); + var bodyElements = document.RootElement.GetProperty("body").GetProperty("elements"); + // Two body elements: the card body markdown (without the duplicated title) and the button. + bodyElements.GetArrayLength().ShouldBe(2); + var cardMarkdown = bodyElements[0].GetProperty("content").GetString(); + cardMarkdown.ShouldNotBeNull(); + cardMarkdown.ShouldNotContain("**Your Agents (1)**"); + cardMarkdown.ShouldContain("daily_report"); + } + [Fact] public void Compose_WhenFormInputCarriesValue_RendersLarkDefaultValue() {