diff --git a/aevatar.slnx b/aevatar.slnx index e18e03e21..83a26c678 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -157,6 +157,7 @@ + diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs index 09e3f9e7b..b27d8b38b 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderActionIds.cs @@ -13,11 +13,6 @@ namespace Aevatar.GAgents.Authoring.Lark; /// 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"; diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs index dab27fe2a..83d102689 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardContent.cs @@ -1,7 +1,6 @@ using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Scheduled; namespace Aevatar.GAgents.Authoring.Lark; @@ -12,183 +11,7 @@ namespace Aevatar.GAgents.Authoring.Lark; /// public static class AgentBuilderCardContent { - 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) => - BuildDailyReportForm(preferredGithubUsername, introCard: null); - - /// - /// Builds the Daily Report creation form card. When is null the - /// default Day One description card is rendered; callers that need a different header (for - /// example, the credentials-required re-prompt) pass their own and this - /// method uses it verbatim instead. - /// - public static MessageContent BuildDailyReportForm( - string? preferredGithubUsername, - CardBlock? introCard) - { - var normalizedSaved = string.IsNullOrWhiteSpace(preferredGithubUsername) - ? null - : preferredGithubUsername!.Trim(); - - var content = new MessageContent(); - content.Cards.Add(introCard ?? BuildDefaultDailyReportIntroCard(normalizedSaved)); - - // Pre-fill the saved GitHub username into the input's default_value so users see it inline - // and can keep it with one submit click. Placeholder stays as a generic hint so the field - // does not disappear when the user clicks to edit. - var githubInput = BuildTextInput( - "github_username", - "GitHub Username", - placeholder: "octocat"); - if (normalizedSaved is not null) - githubInput.Value = normalizedSaved; - content.Actions.Add(githubInput); - - content.Actions.Add(BuildTextInput( - "repositories", - "Repositories (Optional)", - "owner/repo, owner/repo")); - content.Actions.Add(BuildTextInput( - "schedule_time", - "Daily Time (HH:mm)", - DefaultScheduleTime)); - content.Actions.Add(BuildTextInput( - "schedule_timezone", - "Time Zone", - SkillRunnerDefaults.DefaultTimezone)); - - var submit = BuildFormSubmit( - "submit_daily_report", - "Create Agent", - isPrimary: true); - submit.Arguments["agent_builder_action"] = DailyReportAction; - submit.Arguments["run_immediately"] = "true"; - content.Actions.Add(submit); - - return content; - } - - private static CardBlock BuildDefaultDailyReportIntroCard(string? savedGithubUsername) - { - var savedNote = savedGithubUsername is null - ? string.Empty - : $"\n\nSaved GitHub username: `{savedGithubUsername}` — it is already filled in, just press **Create Agent** to reuse it."; - - return new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "daily_report_intro", - Title = "Create Daily Report Agent", - Text = - "**Day One template:** Daily GitHub report\n" + - "Fill in the fields below. The agent will run once now and then repeat every day at your chosen local time." + - savedNote, - }; - } - - public static MessageContent BuildSocialMediaForm() - { - var content = new MessageContent(); - content.Cards.Add(new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "social_media_intro", - Title = "Create Social Media Agent", - Text = - "**Workflow-backed template:** Social media draft + approval\n" + - "Fill in the fields below. Each scheduled run will generate one draft and send approval instructions into this Feishu private chat.", - }); - - content.Actions.Add(BuildTextInput( - "topic", - "Topic", - "Launch update for the new workflow feature")); - content.Actions.Add(BuildTextInput( - "audience", - "Audience (Optional)", - "Developers and technical founders")); - content.Actions.Add(BuildTextInput( - "style", - "Style (Optional)", - "Confident, concise, product-focused")); - content.Actions.Add(BuildTextInput( - "schedule_time", - "Daily Time (HH:mm)", - DefaultScheduleTime)); - content.Actions.Add(BuildTextInput( - "schedule_timezone", - "Time Zone", - SkillRunnerDefaults.DefaultTimezone)); - - var submit = BuildFormSubmit( - "submit_social_media", - "Create Agent", - isPrimary: true); - submit.Arguments["agent_builder_action"] = SocialMediaAction; - submit.Arguments["run_immediately"] = "true"; - content.Actions.Add(submit); - - return content; - } - - /// - /// Builds the post-tool acknowledgment for the Day One daily report creation flow. - /// The tool response returns GitHub username, preference-save status, and run_immediately trigger - /// status, which this method folds into a short text reply that leads with "running now" when - /// the schedule fired the first report, so the user knows a report is on the way. - /// - public static MessageContent FormatDailyReportToolReply(JsonElement root) - { - if (TryReadError(root, out var error)) - return TextContent($"Create daily report agent failed: {error}"); - - var status = TryReadString(root, "status") ?? "accepted"; - if (string.Equals(status, "credentials_required", StringComparison.OrdinalIgnoreCase) || - string.Equals(status, "oauth_required", StringComparison.OrdinalIgnoreCase)) - { - return BuildDailyReportCredentialsCard(root, status); - } - - var agentId = TryReadString(root, "agent_id") ?? "unknown-agent"; - var githubUsername = TryReadString(root, "github_username"); - var savedPreference = TryReadBool(root, "github_username_preference_saved"); - // The tool reports whether it asked the skill-runner actor to run now, not whether the - // runner actually finished — hence "requested", not "triggered". The ack text still says - // "Running first report now" because we sent the command; if it fails downstream, the - // ground-truth status surfaces through /agent-status, not through this immediate reply. - var runImmediatelyRequested = TryReadBool(root, "run_immediately_requested"); - var nextRun = TryReadString(root, "next_scheduled_run") ?? "pending"; - - var headline = runImmediatelyRequested - ? (string.IsNullOrWhiteSpace(githubUsername) - ? "Daily report scheduled. Running first report now — I'll reply with the results shortly." - : $"Daily report scheduled for `{githubUsername}`. Running first report now — I'll reply with the results shortly.") - : (string.IsNullOrWhiteSpace(githubUsername) - ? "Daily report scheduled." - : $"Daily report scheduled for `{githubUsername}`."); - - var lines = new List { headline }; - if (savedPreference && !string.IsNullOrWhiteSpace(githubUsername)) - lines.Add($"Saved `{githubUsername}` as your default GitHub username."); - - lines.Add($"Next scheduled run: {nextRun}"); - lines.Add($"Agent ID: {agentId}"); - - var note = TryReadOptional(root, "note"); - if (note is not null) - lines.Add(note); - - lines.Add($"Next commands: /agents, /agent-status {agentId}, /run-agent {agentId}"); - - return TextContent(string.Join('\n', lines)); - } /// /// Renders /agents as a single consolidated card. The earlier design produced one @@ -222,10 +45,7 @@ public static MessageContent FormatListAgentsResult(JsonElement root, string? no 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."); + emptyBody.Append("No agents yet."); content.Cards.Add(new CardBlock { @@ -234,9 +54,7 @@ public static MessageContent FormatListAgentsResult(JsonElement root, string? no 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)); + content.Actions.Add(BuildAction("Refresh", ListAgentsAction, isPrimary: false)); return content; } @@ -285,14 +103,7 @@ public static MessageContent FormatListAgentsResult(JsonElement root, string? no 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; } @@ -315,67 +126,6 @@ private static ActionElement BuildAction(string label, string agentBuilderAction return button; } - private static MessageContent BuildDailyReportCredentialsCard(JsonElement root, string status) - { - var providerId = TryReadString(root, "provider_id") ?? "unknown-provider"; - var url = TryReadString(root, "authorization_url") - ?? TryReadString(root, "auth_url") - ?? TryReadString(root, "url") - ?? TryReadString(root, "documentation_url"); - var note = TryReadString(root, "note") - ?? "Enter your GitHub username below — I'll save it as your default and run the report immediately."; - var heading = string.Equals(status, "oauth_required", StringComparison.OrdinalIgnoreCase) - ? "GitHub authorization required." - : "GitHub credentials required."; - - var descriptionLines = new List - { - $"**{heading}**", - note, - $"Provider ID: `{providerId}`", - }; - if (!string.IsNullOrWhiteSpace(url)) - descriptionLines.Add($"Open: {url}"); - descriptionLines.Add("Or just reply with `/daily ` — I'll save it and run the report now."); - - var introCard = new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "daily_report_credentials", - Title = "Create Daily Report Agent", - Text = string.Join('\n', descriptionLines), - }; - - // Echo the username the user already submitted (e.g. `/daily eanzhao`) so it pre-fills - // the form on the auth-required re-prompt — otherwise users had to retype it after the - // OAuth round-trip. The card body alone carries the auth instructions; setting - // content.Text in addition would double-render in Lark form mode (LarkMessageComposer's - // BuildLeadingMarkdown concatenates Text and the first card body), which is the original - // duplicate "GitHub authorization required" block users were seeing. - var submittedGithubUsername = TryReadString(root, "github_username"); - return BuildDailyReportForm( - preferredGithubUsername: submittedGithubUsername, - introCard: introCard); - } - - private static ActionElement BuildTextInput(string actionId, string label, string placeholder) => - new() - { - Kind = ActionElementKind.TextInput, - ActionId = actionId, - Label = label, - Placeholder = placeholder, - }; - - private static ActionElement BuildFormSubmit(string actionId, string label, bool isPrimary) => - new() - { - Kind = ActionElementKind.FormSubmit, - ActionId = actionId, - Label = label, - IsPrimary = isPrimary, - }; - private static MessageContent TextContent(string text) => AgentBuilderJson.TextContent(text); private static bool TryReadError(JsonElement root, out string error) => @@ -384,9 +134,6 @@ private static bool TryReadError(JsonElement root, out string error) => private static string? TryReadString(JsonElement element, string propertyName) => AgentBuilderJson.TryReadString(element, propertyName); - private static bool TryReadBool(JsonElement element, string propertyName) => - AgentBuilderJson.TryReadBool(element, propertyName); - 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 11c908970..a480f23f2 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderCardFlow.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; @@ -12,11 +11,6 @@ public static class AgentBuilderCardFlow { private const string PrivateChatType = "p2p"; private const string CardActionChatType = "card_action"; - 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; @@ -24,31 +18,12 @@ public static class AgentBuilderCardFlow 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"; private const string RunAgentCommand = "/run-agent"; private const string DisableAgentCommand = "/disable-agent"; private const string EnableAgentCommand = "/enable-agent"; private const string DeleteAgentCommand = "/delete-agent"; - private static readonly HashSet LaunchIntents = new(StringComparer.OrdinalIgnoreCase) - { - "/daily", - "create daily report", - "创建日报助手", - "创建日报agent", - }; - - private static readonly HashSet SocialMediaIntents = new(StringComparer.OrdinalIgnoreCase) - { - SocialMediaCommand, - "/create-social-media", - "create social media", - "创建社媒助手", - "创建社媒agent", - }; - private static readonly HashSet ListIntents = new(StringComparer.OrdinalIgnoreCase) { "/agents", @@ -56,45 +31,20 @@ public static class AgentBuilderCardFlow "我的助手", }; - private static readonly HashSet TemplateIntents = new(StringComparer.OrdinalIgnoreCase) - { - "/templates", - "/agent-templates", - "list templates", - "模板列表", - }; - public static bool TryResolve(ChannelInboundEvent evt, out AgentBuilderFlowDecision? decision) => TryResolve(evt, preferredGithubUsername: null, out decision); - public static async Task TryResolveAsync( + public static Task TryResolveAsync( ChannelInboundEvent evt, IUserConfigQueryPort? userConfigQueryPort, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(evt); + _ = userConfigQueryPort; + _ = ct; - string? preferredGithubUsername = null; - if (ShouldLoadPreferredGithubUsername(evt) && userConfigQueryPort is not null) - { - try - { - preferredGithubUsername = (await userConfigQueryPort.GetAsync( - ChannelUserConfigScope.FromInboundEvent(evt), - ct)).GithubUsername; - } - catch (OperationCanceledException) - { - throw; - } - catch - { - preferredGithubUsername = null; - } - } - - TryResolve(evt, preferredGithubUsername, out var decision); - return decision; + TryResolve(evt, preferredGithubUsername: null, out var decision); + return Task.FromResult(decision); } private static bool TryResolve( @@ -103,27 +53,12 @@ private static bool TryResolve( out AgentBuilderFlowDecision? decision) { ArgumentNullException.ThrowIfNull(evt); + _ = preferredGithubUsername; decision = null; if (IsPrivateChatText(evt)) { var normalized = NormalizeText(evt.Text); - if (LaunchIntents.Contains(normalized)) - { - // Direct webhook deployments hit this path (no Nyx relay in front); the pre-serialized - // Lark JSON card from BuildDailyReportCard used to land in MessageContent.Text and - // render as raw JSON. Route through the channel-neutral form builder so the composer - // emits a real interactive card. - decision = AgentBuilderFlowDecision.DirectReply( - AgentBuilderCardContent.BuildDailyReportForm(preferredGithubUsername)); - return true; - } - - if (SocialMediaIntents.Contains(normalized)) - { - decision = AgentBuilderFlowDecision.DirectReply(AgentBuilderCardContent.BuildSocialMediaForm()); - return true; - } if (ListIntents.Contains(normalized)) { @@ -131,12 +66,6 @@ private static bool TryResolve( return true; } - if (TemplateIntents.Contains(normalized)) - { - decision = AgentBuilderFlowDecision.ToolCall(ListTemplatesAction, """{"action":"list_templates"}"""); - return true; - } - if (TryResolvePrivateChatCommand(normalized, out decision)) return true; @@ -149,48 +78,14 @@ private static bool TryResolve( if (!evt.Extra.TryGetValue("agent_builder_action", out var action)) return false; + string? argumentsJson; + string? validationError; switch ((action ?? string.Empty).Trim()) { - case OpenDailyReportFormAction: - decision = AgentBuilderFlowDecision.DirectReply( - AgentBuilderCardContent.BuildDailyReportForm(preferredGithubUsername)); - return true; - - case OpenSocialMediaFormAction: - decision = AgentBuilderFlowDecision.DirectReply(AgentBuilderCardContent.BuildSocialMediaForm()); - return true; - - case DailyReportAction: - if (!TryBuildCreateDailyReportArguments(evt, out var argumentsJson, out var validationError)) - { - decision = AgentBuilderFlowDecision.DirectReply(validationError!); - return true; - } - - decision = AgentBuilderFlowDecision.ToolCall(DailyReportAction, argumentsJson!); - return true; - - case SocialMediaAction: - if (!TryBuildCreateSocialMediaArguments(evt, out argumentsJson, out validationError)) - { - decision = AgentBuilderFlowDecision.DirectReply(validationError!); - return true; - } - - decision = AgentBuilderFlowDecision.ToolCall(SocialMediaAction, argumentsJson!); - return true; - case ListAgentsAction: decision = AgentBuilderFlowDecision.ToolCall(ListAgentsAction, """{"action":"list_agents"}"""); return true; - case ListTemplatesAction: - // The /agents card surfaces a `Templates` button (also reachable via the - // text-flow `/templates` slash command). Without this branch, clicking the - // button leaves the user with an unhandled card action and no feedback. - decision = AgentBuilderFlowDecision.ToolCall(ListTemplatesAction, """{"action":"list_templates"}"""); - return true; - case AgentStatusAction: if (!TryBuildAgentActionArguments(evt, "agent_status", out argumentsJson, out validationError)) { @@ -238,8 +133,6 @@ 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)); @@ -275,21 +168,11 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, using var doc = JsonDocument.Parse(toolResultJson); return decision.ToolAction switch { - // 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. - DailyReportAction => AgentBuilderCardContent.FormatDailyReportToolReply(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), }; @@ -311,98 +194,6 @@ public static string ResolveToolChatType(ChannelInboundEvent evt) : evt.ChatType; } - private static bool TryBuildCreateDailyReportArguments( - ChannelInboundEvent evt, - out string? argumentsJson, - out string? validationError) - { - argumentsJson = null; - validationError = null; - var githubUsername = evt.Extra.TryGetValue("github_username", out var rawGithubUsername) - ? NormalizeOptional(rawGithubUsername) - : null; - - if (!TryBuildDailyCron(evt.Extra.TryGetValue("schedule_time", out var scheduleTime) ? scheduleTime : null, out var scheduleCron, out validationError)) - return false; - - var scheduleTimezone = (evt.Extra.TryGetValue("schedule_timezone", out var rawTimezone) - ? rawTimezone - : null) ?? SkillRunnerDefaults.DefaultTimezone; - scheduleTimezone = string.IsNullOrWhiteSpace(scheduleTimezone) - ? SkillRunnerDefaults.DefaultTimezone - : scheduleTimezone.Trim(); - - var repositories = evt.Extra.TryGetValue("repositories", out var rawRepositories) - ? NormalizeOptional(rawRepositories) - : null; - - var runImmediately = !evt.Extra.TryGetValue("run_immediately", out var rawRunImmediately) || - !bool.TryParse(rawRunImmediately, out var parsedRunImmediately) || - parsedRunImmediately; - - argumentsJson = JsonSerializer.Serialize(new - { - action = "create_agent", - template = "daily_report", - github_username = githubUsername, - save_github_username_preference = githubUsername is not null, - repositories, - schedule_cron = scheduleCron, - schedule_timezone = scheduleTimezone, - run_immediately = runImmediately, - }); - return true; - } - - private static bool TryBuildCreateSocialMediaArguments( - ChannelInboundEvent evt, - out string? argumentsJson, - out string? validationError) - { - argumentsJson = null; - validationError = null; - - if (!TryGetRequiredExtra(evt, "topic", out var topic)) - { - validationError = "Topic is required. Send /social-media and fill in the form again."; - return false; - } - - if (!TryBuildDailyCron(evt.Extra.TryGetValue("schedule_time", out var scheduleTime) ? scheduleTime : null, out var scheduleCron, out validationError)) - return false; - - var scheduleTimezone = (evt.Extra.TryGetValue("schedule_timezone", out var rawTimezone) - ? rawTimezone - : null) ?? SkillRunnerDefaults.DefaultTimezone; - scheduleTimezone = string.IsNullOrWhiteSpace(scheduleTimezone) - ? SkillRunnerDefaults.DefaultTimezone - : scheduleTimezone.Trim(); - - var audience = evt.Extra.TryGetValue("audience", out var rawAudience) - ? NormalizeOptional(rawAudience) - : null; - var style = evt.Extra.TryGetValue("style", out var rawStyle) - ? NormalizeOptional(rawStyle) - : null; - - var runImmediately = !evt.Extra.TryGetValue("run_immediately", out var rawRunImmediately) || - !bool.TryParse(rawRunImmediately, out var parsedRunImmediately) || - parsedRunImmediately; - - argumentsJson = JsonSerializer.Serialize(new - { - action = "create_agent", - template = "social_media", - topic, - audience, - style, - schedule_cron = scheduleCron, - schedule_timezone = scheduleTimezone, - run_immediately = runImmediately, - }); - return true; - } - private static bool TryBuildAgentActionArguments( ChannelInboundEvent evt, string action, @@ -591,27 +382,6 @@ private static bool TryParseAgentCommand( return true; } - private static bool TryBuildDailyCron(string? rawTime, out string? cron, out string? error) - { - cron = null; - error = null; - - var normalized = NormalizeOptional(rawTime) ?? DefaultScheduleTime; - if (!TimeOnly.TryParseExact( - normalized, - ["HH:mm", "H:mm"], - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var time)) - { - error = "schedule_time must use HH:mm, for example 09:00."; - return false; - } - - cron = $"{time.Minute} {time.Hour} * * *"; - return true; - } - private static bool TryGetRequiredExtra(ChannelInboundEvent evt, string key, out string value) { value = string.Empty; @@ -626,19 +396,6 @@ private static bool IsPrivateChatText(ChannelInboundEvent evt) => string.Equals(evt.ChatType, PrivateChatType, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(evt.Text); - private static bool ShouldLoadPreferredGithubUsername(ChannelInboundEvent evt) - { - if (IsPrivateChatText(evt)) - { - var normalized = NormalizeText(evt.Text); - return LaunchIntents.Contains(normalized); - } - - return string.Equals(evt.ChatType, CardActionChatType, StringComparison.Ordinal) && - evt.Extra.TryGetValue("agent_builder_action", out var action) && - string.Equals(action, OpenDailyReportFormAction, StringComparison.Ordinal); - } - private static string NormalizeText(string? text) => (text ?? string.Empty).Trim(); private static string? NormalizeOptional(string? value) @@ -647,109 +404,6 @@ private static bool ShouldLoadPreferredGithubUsername(ChannelInboundEvent evt) return normalized.Length == 0 ? null : normalized; } - private static MessageContent FormatCreateSocialMediaResult(JsonElement root) - { - if (TryReadError(root, out var 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 = 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 MessageContent FormatListTemplatesResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return ToTextContent($"List templates failed: {error}"); - - var content = new MessageContent(); - - if (!root.TryGetProperty("templates", out var templatesElement) || - templatesElement.ValueKind != JsonValueKind.Array || - templatesElement.GetArrayLength() == 0) - { - 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 body = new StringBuilder(); - body.Append("Day One currently exposes the templates below."); - - var hasReadyDaily = false; - var hasReadySocial = false; - - foreach (var item in templatesElement.EnumerateArray()) - { - var name = ReadString(item, "name") ?? "unknown-template"; - var status = ReadString(item, "status") ?? "unknown"; - var description = ReadString(item, "description") ?? "No description."; - var requiredFields = ReadStringArray(item, "required_fields"); - var optionalFields = ReadStringArray(item, "optional_fields"); - - 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(status, "ready", StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals(name, "daily_report", StringComparison.OrdinalIgnoreCase)) - hasReadyDaily = true; - else if (string.Equals(name, "social_media", StringComparison.OrdinalIgnoreCase)) - hasReadySocial = true; - } - } - - content.Cards.Add(new CardBlock - { - Kind = CardBlockKind.Section, - BlockId = "templates_list", - Title = "Available Templates", - Text = body.ToString(), - }); - - 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 MessageContent FormatAgentStatusResult(JsonElement root) { if (TryReadError(root, out var error)) @@ -804,9 +458,6 @@ private static MessageContent FormatAgentStatusResult(JsonElement root) } content.Actions.Add(BuildCardAction("Back to Agents", ListAgentsAction, isPrimary: false)); - // 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; @@ -884,27 +535,6 @@ private static bool TryReadError(JsonElement root, out string error) => private static string? ReadString(JsonElement element, string propertyName) => AgentBuilderJson.TryReadString(element, propertyName); - private static IReadOnlyList ReadStringArray(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property) || - property.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - var values = new List(); - foreach (var item in property.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) - values.Add(item.GetString()!); - } - - return values; - } - - private static string FormatFieldList(IReadOnlyList fields) => - fields.Count == 0 - ? "`None`" - : string.Join(", ", fields.Select(static field => $"`{field}`")); - private static MessageContent BuildDeleteConfirmationCard(string agentId, string? template) { var templateLabel = NormalizeOptional(template) ?? "unknown-template"; diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs deleted file mode 100644 index 3b1441697..000000000 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTemplates.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System.Text; - -namespace Aevatar.GAgents.Authoring.Lark; - -public static class AgentBuilderTemplates -{ - public static IReadOnlyList ListTemplates() => - [ - new - { - name = "daily_report", - status = "ready", - description = "Generate a daily GitHub progress summary and send it back to the current Feishu private chat.", - required_fields = new[] { "schedule_cron" }, - optional_fields = new[] { "github_username", "repositories", "schedule_timezone", "run_immediately" }, - }, - new - { - name = "social_media", - status = "ready", - description = "Generate a social media draft on a schedule and send it into the current Feishu private chat for approval.", - required_fields = new[] { "topic", "schedule_cron" }, - optional_fields = new[] { "audience", "style", "schedule_timezone", "run_immediately" }, - }, - ]; - - public static bool TryBuildDailyReportSpec( - string githubUsername, - string? repositories, - out DailyReportTemplateSpec? spec, - out string? error) - { - spec = null; - error = null; - - var normalizedUser = (githubUsername ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(normalizedUser)) - { - error = "github_username is required for template=daily_report"; - return false; - } - - var repoList = NormalizeRepositories(repositories); - var skillPrompt = BuildDailyReportSkillPrompt(normalizedUser, repoList); - - var executionPrompt = repoList.Count == 0 - ? $"Run the daily report for GitHub user `{normalizedUser}` covering the last 24 hours. Follow the section schema in the system prompt. Return plain text only." - : $"Run the daily report for GitHub user `{normalizedUser}` covering the last 24 hours. Restrict source queries to these repositories (one pass per repo, do not collapse to a global search): {string.Join(", ", repoList)}. Follow the section schema in the system prompt. Return plain text only."; - - spec = new DailyReportTemplateSpec( - "daily_report", - "daily_report", - skillPrompt, - executionPrompt, - ["api-github", "api-lark-bot"], - repoList); - return true; - } - - public static bool TryBuildSocialMediaSpec( - string agentId, - string topic, - string? audience, - string? style, - string? deliveryProviderSlug, - string? publishProviderSlug, - out SocialMediaTemplateSpec? spec, - out string? error) - { - spec = null; - error = null; - - var normalizedAgentId = (agentId ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(normalizedAgentId)) - { - error = "agent_id is required for template=social_media"; - return false; - } - - var normalizedTopic = (topic ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(normalizedTopic)) - { - error = "topic is required for template=social_media"; - return false; - } - - var normalizedAudience = NormalizeOptional(audience) ?? "general followers"; - var normalizedStyle = NormalizeOptional(style) ?? "clear, concise, and professional"; - var normalizedDeliverySlug = NormalizeOptional(deliveryProviderSlug) ?? "api-lark-bot"; - var normalizedPublishSlug = NormalizeOptional(publishProviderSlug) ?? "api-twitter"; - var workflowId = BuildSocialMediaWorkflowId(normalizedAgentId); - var workflowName = BuildSocialMediaWorkflowName(normalizedAgentId); - var displayName = $"Social Media Approval {normalizedAgentId}"; - var executionPrompt = $"Generate the scheduled social media draft for topic `{normalizedTopic}` and route it for approval."; - - spec = new SocialMediaTemplateSpec( - WorkflowId: workflowId, - WorkflowName: workflowName, - DisplayName: displayName, - WorkflowYaml: BuildSocialMediaWorkflowYaml( - workflowName, - normalizedAgentId, - normalizedTopic, - normalizedAudience, - normalizedStyle, - normalizedPublishSlug), - ExecutionPrompt: executionPrompt, - RequiredServiceSlugs: [normalizedDeliverySlug, normalizedPublishSlug]); - return true; - } - - // Daily report system prompt is treated as a fetch-and-summarize SPECIFICATION rather than a - // freeform creative brief: explicit section order, hard per-section line budgets, and an - // "omit if empty" rule. See issue #423 for the rationale (current single-paragraph output is - // too thin and pads when sources are silent). - private static string BuildDailyReportSkillPrompt(string normalizedUser, IReadOnlyList repoList) - { - var repoScope = repoList.Count == 0 - ? "Repository scope: not pinned. Use the global GitHub search endpoints listed below." - : $"Repository scope: {string.Join(", ", repoList)}. Run the per-repo endpoints once per repo; do NOT fold the list into a global search query (the /search/* endpoints don't filter to a repo allowlist cleanly)."; - - var prompt = new StringBuilder() - .AppendLine("You are Aevatar Daily Report Runner.") - .AppendLine("Each run produces one Feishu-ready summary of the user's recent GitHub work over the last 24 hours.") - .AppendLine("Use NyxID-backed tools only. Prefer nyxid_proxy with service slug `api-github` for GitHub data access.") - .AppendLine() - .AppendLine($"Primary GitHub username: {normalizedUser}") - .AppendLine(repoScope) - .AppendLine() - .AppendLine("# Output sections (emit in this exact order)") - .AppendLine() - .AppendLine("Each section has a hard line budget. If a section has zero data OR the source is unavailable, OMIT THE SECTION ENTIRELY (header and body) — do not pad with `no activity` or filler.") - .AppendLine() - .AppendLine("1. Title (1 line) — `Daily report — {username} — last 24h`.") - .AppendLine("2. Shipped (≤6 lines) — PRs merged AND commits authored by the user in the window. Format `- [owner/repo#NNN] title` for PRs, `- [owner/repo@sha7] subject` for commits.") - .AppendLine("3. In flight (≤6 lines) — open PRs authored by the user. Append `(stale)` when the PR has had no activity for >24h.") - .AppendLine("4. Reviews (≤4 lines) — PRs the user reviewed in the window. Include kind counts, e.g. `approved 2 / requested-changes 1 / commented 3`.") - .AppendLine("5. Issues (≤4 lines) — issues opened, closed, or commented on by the user.") - .AppendLine("6. CI (≤3 lines) — failing GitHub Actions runs on the tracked repos. Best-effort and only feasible in repo-allowlist mode; OMIT this section in no-repo mode (the global search endpoints do not expose Actions run conclusions).") - .AppendLine("7. Trend (1 line, optional) — running totals vs the prior 24h, e.g. `Trend: shipped 3 (+1), reviews 5 (-2)`. Omit when the prior-window data could not be fetched.") - .AppendLine("8. Blockers (1 line) — `Blockers: ` or `No blockers.` Auto-detect from: PRs >24h waiting on a review, CI red >2h, issues with labels `blocked` or `needs-info`. Position-locked at slot 8; the only section that may sit below it is the §9 Source health footer.") - .AppendLine("9. Source health (1 line, footer) — `Source health: `. Emit ONLY when at least one source returned a non-2xx / error-shaped tool result. When emitted, this is always the final line — below Blockers, below everything.") - .AppendLine() - .AppendLine("If EVERY source returned 2xx with no matching items (genuine empty day), return ONLY the title line followed by `No measurable activity in the last 24h.` and nothing else — do NOT emit Blockers or Source health. If ANY source failed, you are NOT on the empty-day path: emit at least the title line plus the §9 Source health footer (any other sections that have 2xx data render normally; §8 Blockers is also emitted).") - .AppendLine("Do not invent activity. Do not paraphrase issue or PR titles into different wording. Keep each line short — Feishu text messages have a body cap, prefer trimming trailing detail over exceeding it.") - .AppendLine() - .AppendLine("# Suggested GitHub proxy calls") - .AppendLine(); - - prompt - .AppendLine($"Substitution variables in the URLs below: `{{username}}` → `{normalizedUser}`; `{{iso_date}}` → start of the 24h window in ISO 8601 UTC (e.g. `2026-04-26T09:00:00Z`); `{{owner}}/{{repo}}` → each entry from the repository allowlist. Always substitute these literally before sending.") - .AppendLine(); - - if (repoList.Count == 0) - { - prompt - .AppendLine("Repository allowlist not provided — use the global search endpoints:") - .AppendLine("- GET /search/issues?q=author:{username}+is:pr+is:merged+merged:>={iso_date} // shipped PRs") - .AppendLine("- GET /search/commits?q=author:{username}+author-date:>={iso_date} // shipped commits") - .AppendLine("- GET /search/issues?q=author:{username}+is:pr+is:open // in flight") - .AppendLine("- GET /search/issues?q=reviewed-by:{username}+updated:>={iso_date} // reviews") - .AppendLine("- GET /search/issues?q=author:{username}+is:issue+created:>={iso_date} // issues opened") - .AppendLine("- GET /search/issues?q=author:{username}+is:issue+is:closed+closed:>={iso_date} // issues closed") - .AppendLine("- GET /search/issues?q=commenter:{username}+updated:>={iso_date} // issues commented") - .AppendLine("// CI section is omitted in no-repo mode: the global /search/* endpoints do not expose Actions run conclusions, and per-repo /actions/runs requires a known repo. Skip section 6 entirely."); - } - else - { - prompt - .AppendLine("Repository allowlist provided — run these per-repo (one search per allowlist entry; do NOT collapse into one global query):") - .AppendLine("- GET /search/issues?q=repo:{owner}/{repo}+author:{username}+is:pr+is:merged+merged:>={iso_date} // shipped PRs (search keys on merge time + author, reliable across pagination)") - .AppendLine("- GET /search/commits?q=repo:{owner}/{repo}+author:{username}+author-date:>={iso_date} // shipped commits") - .AppendLine("- GET /search/issues?q=repo:{owner}/{repo}+author:{username}+is:pr+is:open // in flight") - .AppendLine("- GET /search/issues?q=repo:{owner}/{repo}+reviewed-by:{username}+updated:>={iso_date} // reviews") - .AppendLine("- GET /search/issues?q=repo:{owner}/{repo}+author:{username}+is:issue+updated:>={iso_date} // issues authored (created/closed)") - .AppendLine("- GET /search/issues?q=repo:{owner}/{repo}+commenter:{username}+is:issue+updated:>={iso_date} // issues commented") - .AppendLine("- GET /repos/{owner}/{repo}/actions/runs?per_page=10 // CI: filter `conclusion=failure` and `created_at >= {iso_date}` client-side; do NOT add a `branch=` filter (default branch varies; trim noise client-side instead)"); - } - - prompt - .AppendLine() - .AppendLine("# Source health — when to emit the §9 footer") - .AppendLine() - .AppendLine("Do NOT collapse transport, auth, or proxy failures into the empty-day fallback. Classify every tool result before mapping it to a section:") - .AppendLine("- 2xx with an empty list / no matching items → genuine zero data; the section is omitted per the schema. Does NOT trigger §9.") - .AppendLine("- 4xx / 5xx / tool error envelope (e.g. `{\"error\": true, ...}`, revoked OAuth grant, proxy timeout) → the SOURCE is UNAVAILABLE, not zero. Add the source name + short reason to the §9 Source health footer.") - .AppendLine("- The empty-day fallback (`No measurable activity in the last 24h.`) is ONLY valid when EVERY source returned 2xx. If ANY source failed, you are NOT on the empty-day path — emit the title plus the §9 Source health footer at minimum. Silently masking credential expiration as `No measurable activity` is the bug we are guarding against.") - .AppendLine("- Do not retry. Do not fall back to invented data. Do not leave any literal `{username}` / `{iso_date}` / `{owner}/{repo}` placeholders in outbound URLs."); - - return prompt.ToString(); - } - - private static string BuildSocialMediaWorkflowId(string agentId) => - $"social-media-{SanitizeSegment(agentId)}"; - - private static string BuildSocialMediaWorkflowName(string agentId) => - $"social_media_{SanitizeSegment(agentId).Replace('-', '_')}"; - - private static string BuildSocialMediaWorkflowYaml( - string workflowName, - string deliveryTargetId, - string topic, - string audience, - string style, - string publishProviderSlug) - { - return $$""" - name: {{workflowName}} - description: Generate a social media draft, request human approval in Feishu, and publish the approved post to Twitter (X). - - roles: - - id: writer - name: Social Writer - provider: nyxid - system_prompt: | - You write polished short-form social media updates for professional audiences. - Keep drafts specific, concrete, and ready for human approval. - - steps: - - id: draft_post - type: llm_call - role: writer - parameters: - prompt_prefix: | - Draft one short social media post. - Topic: {{EscapeYamlBlock(topic)}} - Audience: {{EscapeYamlBlock(audience)}} - Style: {{EscapeYamlBlock(style)}} - Requirements: - - Return plain text only. - - Keep it concise and publication-ready. - - Do not add hashtags unless they are clearly justified. - next: request_approval - - - id: request_approval - type: human_approval - parameters: - prompt: "Approve this social media draft?" - delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}" - on_reject: skip - branches: - "true": publish_to_twitter - "false": done - - - id: publish_to_twitter - type: twitter_publish - parameters: - publish_provider_slug: "{{EscapeDoubleQuoted(publishProviderSlug)}}" - delivery_target_id: "{{EscapeDoubleQuoted(deliveryTargetId)}}" - on_error: - strategy: skip - default_output: "twitter_publish_failed" - next: done - - - id: done - type: assign - parameters: - target: "result" - value: "$input" - """; - } - - private static string EscapeDoubleQuoted(string value) => - (value ?? string.Empty) - .Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("\"", "\\\"", StringComparison.Ordinal); - - private static string EscapeYamlBlock(string value) => - (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal); - - private static IReadOnlyList NormalizeRepositories(string? repositories) => - (repositories ?? string.Empty) - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(static item => !string.IsNullOrWhiteSpace(item)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - private static string? NormalizeOptional(string? value) - { - var normalized = (value ?? string.Empty).Trim(); - return normalized.Length == 0 ? null : normalized; - } - - private static string SanitizeSegment(string value) - { - var builder = new StringBuilder(value.Length); - foreach (var ch in value) - { - if (char.IsLetterOrDigit(ch)) - builder.Append(char.ToLowerInvariant(ch)); - else if (ch is '-' or '_') - builder.Append('-'); - } - - var sanitized = builder.ToString().Trim('-'); - return string.IsNullOrWhiteSpace(sanitized) ? "agent" : sanitized; - } -} - -public sealed record DailyReportTemplateSpec( - string TemplateName, - string SkillName, - string SkillContent, - string ExecutionPrompt, - IReadOnlyList RequiredServiceSlugs, - IReadOnlyList Repositories); - -public sealed record SocialMediaTemplateSpec( - string WorkflowId, - string WorkflowName, - string DisplayName, - string WorkflowYaml, - string ExecutionPrompt, - IReadOnlyList RequiredServiceSlugs); diff --git a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs index 1f99d3f8c..510b71d87 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/AgentBuilderTool.cs @@ -1,4 +1,3 @@ -using System.Net; using System.Text.Json; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; @@ -7,12 +6,7 @@ using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Platform.Lark; using Aevatar.GAgents.Scheduled; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Workflow.Application.Abstractions.Runs; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -22,10 +16,6 @@ public sealed class AgentBuilderTool : IAgentTool { private readonly IServiceProvider _serviceProvider; private readonly ILogger? _logger; - // Per-instance polling budget for actor -> projector -> document store - // propagation. Defaults to ProjectionWaitDefaults (15 s); tests inject - // shrunk values via the constructor instead of mutating a process-global, - // which would race other tests if the test surface ever parallelizes. private readonly int _projectionWaitAttempts; private readonly int _projectionWaitDelayMilliseconds; @@ -44,8 +34,9 @@ public AgentBuilderTool( public string Name => "agent_builder"; public string Description => - "Create and manage persistent user-facing automation agents for the current channel context. " + - "Actions: list_templates, create_agent, list_agents, agent_status, run_agent, disable_agent, enable_agent, delete_agent."; + "List and manage the caller's persistent automation agents. " + + "Actions: list_agents, agent_status, run_agent, disable_agent, enable_agent, delete_agent. " + + "Agent creation is not handled here — recipes for new agents live as Ornn skills."; // Note (issue #466): no `owner_nyx_user_id` parameter is exposed. The tool always // operates on the caller's own agents; the resolver derives ownership from the @@ -58,73 +49,22 @@ public AgentBuilderTool( "properties": { "action": { "type": "string", - "enum": ["list_templates", "create_agent", "list_agents", "agent_status", "run_agent", "disable_agent", "enable_agent", "delete_agent"] - }, - "template": { - "type": "string", - "description": "Template name, currently supports daily_report and social_media" + "enum": ["list_agents", "agent_status", "run_agent", "disable_agent", "enable_agent", "delete_agent"] }, "agent_id": { "type": "string", - "description": "Optional stable actor ID. Auto-generated when omitted." - }, - "github_username": { - "type": "string", - "description": "GitHub username for the daily_report template" - }, - "save_github_username_preference": { - "type": "boolean", - "description": "When true, save github_username as the owner-scoped default preference after a successful daily_report creation" - }, - "topic": { - "type": "string", - "description": "Primary topic or campaign focus for the social_media template" - }, - "audience": { - "type": "string", - "description": "Optional audience descriptor for the social_media template" - }, - "style": { - "type": "string", - "description": "Optional tone/style instruction for the social_media template" - }, - "repositories": { - "type": "string", - "description": "Optional comma-separated repositories to prioritize" - }, - "schedule_cron": { - "type": "string", - "description": "Cron expression for future executions" - }, - "schedule_timezone": { - "type": "string", - "description": "IANA or system timezone ID (default: UTC)" - }, - "conversation_id": { - "type": "string", - "description": "Override outbound conversation/chat ID. Defaults to current channel context." - }, - "nyx_provider_slug": { - "type": "string", - "description": "Outbound Nyx proxy slug (default: api-lark-bot)" - }, - "publish_provider_slug": { - "type": "string", - "description": "Optional Nyx proxy slug used to publish approved content (default: api-twitter for the social_media template)" - }, - "run_immediately": { - "type": "boolean", - "description": "When true, trigger one execution right after creation" + "description": "Stable actor ID. Required for every action except list_agents." }, "confirm": { "type": "boolean", - "description": "Must be true to execute delete_agent" + "description": "Must be true to execute delete_agent." }, "revision_feedback": { "type": "string", - "description": "Optional revision guidance to include in the next workflow-backed run" + "description": "Optional revision guidance to include in the next run." } - } + }, + "required": ["action"] } """; @@ -138,18 +78,13 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c if (args.HasParseError) return JsonSerializer.Serialize(new { error = args.ParseError }); - var action = args.Str("action", "list_templates"); - if (string.Equals(action, "list_templates", StringComparison.Ordinal)) - return JsonSerializer.Serialize(new { templates = AgentBuilderTemplates.ListTemplates() }); - var queryPort = _serviceProvider.GetService(); var nyxClient = _serviceProvider.GetService(); var skillRunnerPort = _serviceProvider.GetService(); - var workflowAgentPort = _serviceProvider.GetService(); var catalogCommandPort = _serviceProvider.GetService(); var callerScopeResolver = _serviceProvider.GetService(); if (queryPort is null || nyxClient is null || - skillRunnerPort is null || workflowAgentPort is null || catalogCommandPort is null || + skillRunnerPort is null || catalogCommandPort is null || callerScopeResolver is null) { return """{"error":"Agent builder runtime not available. Required services are not registered in DI."}"""; @@ -172,412 +107,25 @@ skillRunnerPort is null || workflowAgentPort is null || catalogCommandPort is nu }); } + var action = args.Str("action", "list_agents"); return action switch { - "create_agent" => await CreateAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, nyxClient, token, caller, ct), "list_agents" => await ListAgentsAsync(queryPort, caller, ct), "agent_status" => await GetAgentStatusAsync(args, queryPort, caller, ct), - "run_agent" => await RunAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, caller, ct), - "disable_agent" => await DisableAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, caller, ct), - "enable_agent" => await EnableAgentAsync(args, queryPort, skillRunnerPort, workflowAgentPort, caller, ct), - "delete_agent" => await DeleteAgentAsync(args, queryPort, catalogCommandPort, skillRunnerPort, workflowAgentPort, nyxClient, token, caller, ct), + "run_agent" => await RunAgentAsync(args, queryPort, skillRunnerPort, caller, ct), + "disable_agent" => await DisableAgentAsync(args, queryPort, skillRunnerPort, caller, ct), + "enable_agent" => await EnableAgentAsync(args, queryPort, skillRunnerPort, caller, ct), + "delete_agent" => await DeleteAgentAsync(args, queryPort, catalogCommandPort, skillRunnerPort, nyxClient, token, caller, ct), _ => JsonSerializer.Serialize(new { error = $"Unsupported action '{action}'" }), }; } - private async Task CreateAgentAsync( - BuilderArgs args, - IUserAgentCatalogQueryPort queryPort, - ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, - NyxIdApiClient nyxClient, - string token, - OwnerScope caller, - CancellationToken ct) - { - var chatType = AgentToolRequestContext.TryGet(ChannelMetadataKeys.ChatType); - if (!string.IsNullOrWhiteSpace(chatType) && - !string.Equals(chatType, "p2p", StringComparison.OrdinalIgnoreCase)) - { - return """{"error":"Day One agent creation only supports private chat (chat_type=p2p)."}"""; - } - - var template = (args.Str("template") ?? string.Empty).Trim(); - return template.ToLowerInvariant() switch - { - "daily_report" => await CreateDailyReportAgentAsync(args, queryPort, skillRunnerPort, nyxClient, token, caller, ct), - "social_media" => await CreateSocialMediaAgentAsync(args, queryPort, workflowAgentPort, nyxClient, token, caller, ct), - _ => JsonSerializer.Serialize(new { error = $"Unsupported template '{template}'. Supported templates: daily_report, social_media." }), - }; - } - - private async Task CreateDailyReportAgentAsync( - BuilderArgs args, - IUserAgentCatalogQueryPort queryPort, - ISkillRunnerCommandPort skillRunnerPort, - NyxIdApiClient nyxClient, - string token, - OwnerScope caller, - CancellationToken ct) - { - var rawScopeId = NormalizeOptional(AgentToolRequestContext.TryGet(ChannelMetadataKeys.RegistrationScopeId)); - var configScopeId = NormalizeScopeId(rawScopeId); - // Bot's RegistrationScopeId is per-NyxID-account (one bot = one scope), so multiple - // Lark users sharing one bot would otherwise share a single UserConfigGAgent and - // overwrite each other's saved github_username (issue #436). Compose a per-end-user - // scope from the channel sender for personal-preference reads/writes only; - // SkillRunner.ScopeId stays bot-scoped for downstream NyxID-tenant tools. - var userConfigScopeId = ChannelUserConfigScope.FromMetadata(AgentToolRequestContext.CurrentMetadata); - var githubUsernameResolution = await ResolveDailyReportGithubUsernameAsync( - args, - nyxClient, - token, - userConfigScopeId, - ct); - if (githubUsernameResolution.ErrorResponse is not null) - return githubUsernameResolution.ErrorResponse; - - if (!AgentBuilderTemplates.TryBuildDailyReportSpec( - githubUsernameResolution.GithubUsername ?? string.Empty, - args.Str("repositories"), - out var templateSpec, - out var templateError)) - { - return JsonSerializer.Serialize(new { error = templateError }); - } - - var scheduleCron = args.Str("schedule_cron"); - if (string.IsNullOrWhiteSpace(scheduleCron)) - return """{"error":"schedule_cron is required for create_agent"}"""; - - var scheduleTimezone = args.Str("schedule_timezone") ?? SkillRunnerDefaults.DefaultTimezone; - if (!ChannelScheduleCalculator.TryGetNextOccurrence(scheduleCron, scheduleTimezone, DateTimeOffset.UtcNow, out var nextRunAtUtc, out var cronError)) - return JsonSerializer.Serialize(new { error = $"Invalid schedule: {cronError}" }); - - var conversationId = args.Str("conversation_id") - ?? AgentToolRequestContext.TryGet(ChannelMetadataKeys.ConversationId); - if (string.IsNullOrWhiteSpace(conversationId)) - return """{"error":"conversation_id is required when no current channel conversation is available"}"""; - - var ownerNyxUserId = caller.NyxUserId; - - var gitHubAuthorizationResponse = await BuildGitHubAuthorizationResponseAsync( - nyxClient, - token, - ct, - submittedGithubUsername: githubUsernameResolution.GithubUsername); - if (!string.IsNullOrWhiteSpace(gitHubAuthorizationResponse)) - return gitHubAuthorizationResponse; - - var providerSlug = (args.Str("nyx_provider_slug") ?? "api-lark-bot").Trim(); - var serviceResolution = await ResolveProxyServiceIdsAsync(nyxClient, token, templateSpec!.RequiredServiceSlugs, ct); - if (serviceResolution.ErrorJson != null) - return serviceResolution.ErrorJson; - - // Issue #423 §C — capture the inbound channel-bot slug as a failure-notification - // fallback. By definition the user can be reached through the bot they just - // messaged, so when a primary outbound delivery is rejected (e.g. cross-tenant - // Lark `99992364`) the failure-notification message can still land if the agent's - // API key is allowed to route through the inbound bot. Optional: if the inbound - // slug is not registered as a per-user UserService row (or equals the primary, - // in which case the fallback would just hit the same proxy), we leave the field - // empty and TrySendFailureAsync degrades to the current single-attempt behavior. - var failureNotificationContext = ResolveFailureNotificationContext( - providerSlug, - serviceResolution.RequiredIds!, - serviceResolution.EligibleIdBySlug); - - var agentId = string.IsNullOrWhiteSpace(args.Str("agent_id")) - ? SkillRunnerDefaults.GenerateActorId() - : args.Str("agent_id")!.Trim(); - - var createKeyResponse = await nyxClient.CreateApiKeyAsync( - token, - BuildCreateApiKeyPayload(agentId, failureNotificationContext.AllowedServiceIds), - ct); - - if (IsErrorPayload(createKeyResponse)) - return createKeyResponse; - - if (!TryParseApiKeyCreateResponse(createKeyResponse, out var apiKeyId, out var apiKeyValue, out var apiKeyError)) - return JsonSerializer.Serialize(new { error = apiKeyError }); - - // Issue aevatarAI/aevatar#411 / #417 follow-up: catch in-flight GitHub-side issues. - // The earlier `BuildGitHubAuthorizationResponseAsync` check covers the "no provider - // token at all" case; this preflight catches misconfigurations that only surface at - // request time (the original case under #421 was a missing `User-Agent` header that - // GitHub rejects with 403; OAuth grant revocation is the other one). - // - // PR #418 review r3141846175: revoke the freshly-minted key on preflight failure so - // each `/daily` retry doesn't leave another orphan proxy-scoped key behind in the - // user's NyxID account. The revoke is best-effort cleanup, not a safety claim about - // the key's correctness. - var preflight = await PreflightGitHubProxyAsync( - nyxClient, - apiKeyValue!, - githubUsernameResolution.GithubUsername ?? string.Empty, - templateSpec!.Repositories, - providerSlug, - ct); - if (preflight is not null) - { - await BestEffortRevokeApiKeyAsync(nyxClient, token, apiKeyId!, "github_preflight_failed", ct); - return preflight; - } - - // Pre-create version baseline. Use the caller-scoped version probe — for an agent - // the caller is about to own (not yet existing), the probe returns null so - // versionBefore stays at -1, which is what the create-confirmation wait expects. - var versionBefore = await queryPort.GetStateVersionForCallerAsync(agentId, caller, ct) ?? -1; - - var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId); -#pragma warning disable CS0612 // legacy fields written for rollback safety during owner_scope migration - var outboundConfig = new SkillRunnerOutboundConfig - { - ConversationId = conversationId.Trim(), - NyxProviderSlug = providerSlug, - NyxApiKey = apiKeyValue!, - OwnerNyxUserId = ownerNyxUserId!, - Platform = caller.Platform, - ApiKeyId = apiKeyId!, - LarkReceiveId = deliveryTarget.Primary.ReceiveId, - LarkReceiveIdType = deliveryTarget.Primary.ReceiveIdType, - LarkReceiveIdFallback = deliveryTarget.Fallback?.ReceiveId ?? string.Empty, - LarkReceiveIdTypeFallback = deliveryTarget.Fallback?.ReceiveIdType ?? string.Empty, - OwnerScope = caller.Clone(), - FailureNotificationProviderSlug = failureNotificationContext.FailureSlug ?? string.Empty, - }; -#pragma warning restore CS0612 - - var initialize = new InitializeSkillRunnerCommand - { - SkillName = templateSpec.SkillName, - TemplateName = templateSpec.TemplateName, - SkillContent = templateSpec.SkillContent, - ExecutionPrompt = templateSpec.ExecutionPrompt, - ScheduleCron = scheduleCron.Trim(), - ScheduleTimezone = scheduleTimezone.Trim(), - Enabled = true, - ScopeId = configScopeId, - ProviderName = SkillRunnerDefaults.DefaultProviderName, - MaxToolRounds = SkillRunnerDefaults.DefaultMaxToolRounds, - MaxHistoryMessages = SkillRunnerDefaults.DefaultMaxHistoryMessages, - OutboundConfig = outboundConfig, - }; - - var runImmediatelyRequested = args.Bool("run_immediately") == true; - await skillRunnerPort.InitializeAsync(agentId, initialize, runImmediatelyRequested, ct); - - var confirmed = await WaitForCreatedAgentAsync( - queryPort, - agentId, - caller, - versionBefore, - entry => string.Equals(entry.AgentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal) && - string.Equals(entry.TemplateName, templateSpec.TemplateName, StringComparison.Ordinal), - ct, - maxAttempts: runImmediatelyRequested ? 20 : 10); - - var savePreferenceRequested = args.Bool("save_github_username_preference") == true; - var preferenceSaved = await SaveGithubUsernamePreferenceIfRequestedAsync( - userConfigScopeId, - githubUsernameResolution.GithubUsername ?? string.Empty, - savePreferenceRequested, - ct); - - return JsonSerializer.Serialize(new - { - status = confirmed ? "created" : "accepted", - agent_id = agentId, - agent_type = SkillRunnerDefaults.AgentType, - template = templateSpec.TemplateName, - github_username = githubUsernameResolution.GithubUsername, - github_username_preference_saved = preferenceSaved, - run_immediately_requested = runImmediatelyRequested, - next_scheduled_run = nextRunAtUtc, - conversation_id = conversationId, - api_key_id = apiKeyId, - note = confirmed ? "" : "Agent initialization accepted but registry projection is not yet confirmed.", - }); - } - - private async Task CreateSocialMediaAgentAsync( - BuilderArgs args, - IUserAgentCatalogQueryPort queryPort, - IWorkflowAgentCommandPort workflowAgentPort, - NyxIdApiClient nyxClient, - string token, - OwnerScope caller, - CancellationToken ct) - { - var scopeId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.RegistrationScopeId); - if (string.IsNullOrWhiteSpace(scopeId)) - return """{"error":"scope_id is required for the social_media template"}"""; - - var workflowCommandPort = _serviceProvider.GetService(); - if (workflowCommandPort is null) - return """{"error":"Scope workflow command port is not registered."}"""; - - var scheduleCron = args.Str("schedule_cron"); - if (string.IsNullOrWhiteSpace(scheduleCron)) - return """{"error":"schedule_cron is required for create_agent"}"""; - - var scheduleTimezone = args.Str("schedule_timezone") ?? WorkflowAgentDefaults.DefaultTimezone; - if (!ChannelScheduleCalculator.TryGetNextOccurrence(scheduleCron, scheduleTimezone, DateTimeOffset.UtcNow, out var nextRunAtUtc, out var cronError)) - return JsonSerializer.Serialize(new { error = $"Invalid schedule: {cronError}" }); - - var conversationId = args.Str("conversation_id") - ?? AgentToolRequestContext.TryGet(ChannelMetadataKeys.ConversationId); - if (string.IsNullOrWhiteSpace(conversationId)) - return """{"error":"conversation_id is required when no current channel conversation is available"}"""; - - var ownerNyxUserId = caller.NyxUserId; - - var providerSlug = (args.Str("nyx_provider_slug") ?? "api-lark-bot").Trim(); - // The social_media template now publishes the approved post to Twitter (X) via the - // api-twitter NyxID proxy in addition to delivering the approval card via api-lark-bot - // (issue #216). Mint the agent api-key with both slugs so a single key carries both - // entitlements; without api-twitter here, NyxID's `allowed_service_ids` enforcement - // (api_keys.rs / proxy.rs) would 403 every publish call regardless of OAuth scope. - var publishProviderSlug = (args.Str("publish_provider_slug") ?? "api-twitter").Trim(); - - var agentId = string.IsNullOrWhiteSpace(args.Str("agent_id")) - ? WorkflowAgentDefaults.GenerateActorId() - : args.Str("agent_id")!.Trim(); - - if (!AgentBuilderTemplates.TryBuildSocialMediaSpec( - agentId, - args.Str("topic") ?? string.Empty, - args.Str("audience"), - args.Str("style"), - providerSlug, - publishProviderSlug, - out var templateSpec, - out var templateError)) - { - return JsonSerializer.Serialize(new { error = templateError }); - } - - // Resolve service IDs from the spec's authoritative slug list (parity with - // daily_report's TemplateSpec.RequiredServiceSlugs — PR #461 review item #6). Inlined - // hardcoded `[providerSlug, publishProviderSlug]` was fine for two slugs but would - // drift if a third slug were ever added; route through the spec so the source of - // truth lives next to the workflow YAML. - var serviceResolution = await ResolveProxyServiceIdsAsync( - nyxClient, - token, - templateSpec!.RequiredServiceSlugs, - ct); - if (serviceResolution.ErrorJson != null) - return serviceResolution.ErrorJson; - - var createKeyResponse = await nyxClient.CreateApiKeyAsync( - token, - BuildCreateApiKeyPayload(agentId, serviceResolution.RequiredIds!), - ct); - - if (IsErrorPayload(createKeyResponse)) - return createKeyResponse; - - if (!TryParseApiKeyCreateResponse(createKeyResponse, out var apiKeyId, out var apiKeyValue, out var apiKeyError)) - return JsonSerializer.Serialize(new { error = apiKeyError }); - - // Mirror the daily_report preflight (#411 / #418) for Twitter: the user may not have - // connected Twitter at NyxID yet, or may have revoked the OAuth grant at x.com between - // connect-time and create-time. Surfacing 401/403 here keeps us from persisting a - // social_media agent whose every approved post would fail at publish time. Best-effort - // revoke the freshly minted key on failure so retries don't accumulate orphan keys. - var preflight = await PreflightTwitterProxyAsync(nyxClient, apiKeyValue!, publishProviderSlug, ct); - if (preflight is not null) - { - await BestEffortRevokeApiKeyAsync(nyxClient, token, apiKeyId!, "twitter_preflight_failed", ct); - return preflight; - } - - var workflowUpsert = await workflowCommandPort.UpsertAsync( - new ScopeWorkflowUpsertRequest( - scopeId.Trim(), - templateSpec!.WorkflowId, - templateSpec.WorkflowYaml, - templateSpec.WorkflowName, - templateSpec.DisplayName), - ct); - - var versionBefore = await queryPort.GetStateVersionForCallerAsync(agentId, caller, ct) ?? -1; - - var deliveryTarget = ResolveDeliveryTarget(conversationId, agentId); -#pragma warning disable CS0612 // legacy fields written for rollback safety during owner_scope migration - var initialize = new InitializeWorkflowAgentCommand - { - WorkflowId = workflowUpsert.Workflow.WorkflowId, - WorkflowName = templateSpec.WorkflowName, - WorkflowActorId = workflowUpsert.Workflow.ActorId, - ExecutionPrompt = templateSpec.ExecutionPrompt, - ScheduleCron = scheduleCron.Trim(), - ScheduleTimezone = scheduleTimezone.Trim(), - ConversationId = conversationId.Trim(), - NyxProviderSlug = providerSlug, - NyxApiKey = apiKeyValue!, - OwnerNyxUserId = ownerNyxUserId!, - Platform = caller.Platform, - ApiKeyId = apiKeyId!, - Enabled = true, - ScopeId = scopeId.Trim(), - LarkReceiveId = deliveryTarget.Primary.ReceiveId, - LarkReceiveIdType = deliveryTarget.Primary.ReceiveIdType, - LarkReceiveIdFallback = deliveryTarget.Fallback?.ReceiveId ?? string.Empty, - LarkReceiveIdTypeFallback = deliveryTarget.Fallback?.ReceiveIdType ?? string.Empty, - OwnerScope = caller.Clone(), - }; -#pragma warning restore CS0612 - - // Initialize via the workflow-agent command port; observation lives in - // the polling loop below since it crosses actors (Workflow → catalog). - // We split run-immediately into a follow-up TriggerAsync so the trigger - // fires only after the catalog projection confirms creation. - await workflowAgentPort.InitializeAsync(agentId, initialize, runImmediately: false, ct); - - var confirmed = await WaitForCreatedAgentAsync( - queryPort, - agentId, - caller, - versionBefore, - entry => string.Equals(entry.AgentType, WorkflowAgentDefaults.AgentType, StringComparison.Ordinal) && - string.Equals(entry.TemplateName, WorkflowAgentDefaults.TemplateName, StringComparison.Ordinal), - ct, - maxAttempts: args.Bool("run_immediately") == true ? 20 : 10); - - if (args.Bool("run_immediately") == true && confirmed) - { - await workflowAgentPort.TriggerAsync(agentId, "create_agent", revisionFeedback: null, ct); - } - - return JsonSerializer.Serialize(new - { - status = confirmed ? "created" : "accepted", - agent_id = agentId, - agent_type = WorkflowAgentDefaults.AgentType, - template = WorkflowAgentDefaults.TemplateName, - next_scheduled_run = nextRunAtUtc, - conversation_id = conversationId, - workflow_id = workflowUpsert.Workflow.WorkflowId, - workflow_actor_id = workflowUpsert.Workflow.ActorId, - api_key_id = apiKeyId, - note = confirmed - ? string.Empty - : args.Bool("run_immediately") == true - ? "Agent initialization accepted but registry projection is not yet confirmed, so the immediate run was not triggered. Use Run Now after the agent appears." - : "Agent initialization accepted but registry projection is not yet confirmed.", - }); - } - private async Task ListAgentsAsync( IUserAgentCatalogQueryPort queryPort, OwnerScope caller, CancellationToken ct) { var agents = await QueryAgentsForCallerAsync(queryPort, caller, ct); - return JsonSerializer.Serialize(new { agents, total = agents.Length }); } @@ -603,7 +151,6 @@ private async Task DeleteAgentAsync( IUserAgentCatalogQueryPort queryPort, IUserAgentCatalogCommandPort catalogCommandPort, ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, NyxIdApiClient nyxClient, string token, OwnerScope caller, @@ -628,19 +175,15 @@ private async Task DeleteAgentAsync( }); } - // Disable via the typed lifecycle port (dispatch + projection priming happen there); - // skip if the agent type isn't managed. var disableResult = await TryDispatchLifecycleAsync( entry, "delete_agent", LifecycleAction.Disable, revisionFeedback: null, - skillRunnerPort, workflowAgentPort, ct); + skillRunnerPort, ct); if (disableResult.error != null) return disableResult.error; if (!string.IsNullOrWhiteSpace(entry.ApiKeyId)) await nyxClient.DeleteApiKeyAsync(token, entry.ApiKeyId, ct); - // Tombstone via UserAgentCatalogCommandPort; port owns priming + - // version observation and returns an honest accepted/observed status. var tombstoneResult = await catalogCommandPort.TombstoneAsync(entry.AgentId, ct); var deleted = tombstoneResult.Outcome == CatalogCommandOutcome.Observed; @@ -675,7 +218,6 @@ private async Task RunAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, OwnerScope caller, CancellationToken ct) { @@ -690,12 +232,11 @@ private async Task RunAgentAsync( if (!SupportsManagedLifecycle(entry.AgentType)) return JsonSerializer.Serialize(new { error = $"Agent '{entry.AgentId}' does not support run_agent" }); - if (string.Equals(entry.Status, SkillRunnerDefaults.StatusDisabled, StringComparison.Ordinal) || - string.Equals(entry.Status, WorkflowAgentDefaults.StatusDisabled, StringComparison.Ordinal)) + if (string.Equals(entry.Status, SkillRunnerDefaults.StatusDisabled, StringComparison.Ordinal)) return JsonSerializer.Serialize(new { error = $"Agent '{entry.AgentId}' is disabled. Enable it before running." }); var revisionFeedback = NormalizeOptional(args.Str("revision_feedback")); - var dispatch = await TryDispatchLifecycleAsync(entry, "run_agent", LifecycleAction.Run, revisionFeedback, skillRunnerPort, workflowAgentPort, ct); + var dispatch = await TryDispatchLifecycleAsync(entry, "run_agent", LifecycleAction.Run, revisionFeedback, skillRunnerPort, ct); if (dispatch.error != null) return dispatch.error; @@ -714,7 +255,6 @@ private async Task DisableAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, OwnerScope caller, CancellationToken ct) { @@ -722,8 +262,7 @@ private async Task DisableAgentAsync( if (entry.error != null) return entry.error; - if (string.Equals(entry.value!.Status, SkillRunnerDefaults.StatusDisabled, StringComparison.Ordinal) || - string.Equals(entry.value.Status, WorkflowAgentDefaults.StatusDisabled, StringComparison.Ordinal)) + if (string.Equals(entry.value!.Status, SkillRunnerDefaults.StatusDisabled, StringComparison.Ordinal)) return SerializeAgentStatus(entry.value, "Agent is already disabled."); // Capture baseline version BEFORE dispatch so the wait can distinguish @@ -733,7 +272,7 @@ private async Task DisableAgentAsync( // against a fast projection that already advanced the version. var versionBefore = await queryPort.GetStateVersionForCallerAsync(entry.value.AgentId, caller, ct) ?? -1; - var dispatch = await TryDispatchLifecycleAsync(entry.value, "disable_agent", LifecycleAction.Disable, null, skillRunnerPort, workflowAgentPort, ct); + var dispatch = await TryDispatchLifecycleAsync(entry.value, "disable_agent", LifecycleAction.Disable, null, skillRunnerPort, ct); if (dispatch.error != null) return dispatch.error; @@ -741,10 +280,6 @@ private async Task DisableAgentAsync( if (observation.Confirmed) return SerializeAgentStatus(observation.Entry!, "Agent disabled. Scheduling paused."); - // Dual gate never passed — the disable was dispatched but the read - // model has not confirmed the lifecycle change within the wait - // budget. Surface the pre-dispatch entry with an honest propagating - // note so the caller (LLM/user) does not assume the agent is paused. return SerializeAgentStatus(entry.value, "Disable submitted. Run /agent-status in a few seconds to confirm the agent is paused."); } @@ -752,7 +287,6 @@ private async Task EnableAgentAsync( BuilderArgs args, IUserAgentCatalogQueryPort queryPort, ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, OwnerScope caller, CancellationToken ct) { @@ -760,15 +294,12 @@ private async Task EnableAgentAsync( if (entry.error != null) return entry.error; - if (string.Equals(entry.value!.Status, SkillRunnerDefaults.StatusRunning, StringComparison.Ordinal) || - string.Equals(entry.value.Status, WorkflowAgentDefaults.StatusRunning, StringComparison.Ordinal)) + if (string.Equals(entry.value!.Status, SkillRunnerDefaults.StatusRunning, StringComparison.Ordinal)) return SerializeAgentStatus(entry.value, "Agent is already enabled."); - // See DisableAgentAsync for why versionBefore is captured here (before - // any dispatch) and not inside WaitForAgentStatusAsync. var versionBefore = await queryPort.GetStateVersionForCallerAsync(entry.value.AgentId, caller, ct) ?? -1; - var dispatch = await TryDispatchLifecycleAsync(entry.value, "enable_agent", LifecycleAction.Enable, null, skillRunnerPort, workflowAgentPort, ct); + var dispatch = await TryDispatchLifecycleAsync(entry.value, "enable_agent", LifecycleAction.Enable, null, skillRunnerPort, ct); if (dispatch.error != null) return dispatch.error; @@ -776,54 +307,9 @@ private async Task EnableAgentAsync( if (observation.Confirmed) return SerializeAgentStatus(observation.Entry!, "Agent enabled. Scheduling resumed."); - // See DisableAgentAsync for the rationale on the un-confirmed branch. return SerializeAgentStatus(entry.value, "Enable submitted. Run /agent-status in a few seconds to confirm the agent is running."); } - /// - /// Builds the JSON body for POST /api/v1/api-keys when the agent-builder mints a - /// scoped child key for a new agent. Pins allow_all_services = false alongside the - /// resolved allowed_service_ids so the agent's proxy reach is bounded to exactly the - /// catalog slugs the template requires. - /// - /// - /// PR #418 review (4175529548): NyxID's CreateApiKeyRequest.allow_all_services - /// (backend/src/handlers/api_keys.rs:105) is #[serde(default = "default_true")], - /// and proxy enforcement (backend/src/handlers/proxy.rs:1030) only checks - /// allowed_service_ids when !auth_user.allow_all_services. Omitting the field - /// means NyxID stores true, the resolved UserService.id list is persisted but - /// never consulted, and the key has broad proxy reach across every service the parent token - /// can see. Setting false explicitly: - /// - /// activates the enforcement path #417 was written to satisfy, - /// makes the narrow-scope intent first-class instead of relying on the parent - /// delegation token's setting (which is what surfaced the bug in production), and - /// triggers validate_service_ids at create-time - /// (backend/src/services/key_service.rs:183), so a malformed - /// UserService.id fails fast at POST /api-keys instead of silently passing - /// through and 403'ing on every later proxy call. - /// - /// allow_all_nodes stays at the NyxID default — this flow does not restrict node - /// routing, and pinning it would surface a separate boundary that has nothing to do with - /// the agent's service reach. - /// - private static string BuildCreateApiKeyPayload(string agentId, IReadOnlyList requiredServiceIds) - { - if (requiredServiceIds.Count == 0) - throw new InvalidOperationException("requiredServiceIds must not be empty."); - - var payload = new Dictionary - { - ["name"] = $"aevatar-agent-{agentId}", - ["scopes"] = "proxy", - ["platform"] = "generic", - ["allowed_service_ids"] = requiredServiceIds, - ["allow_all_services"] = false, - }; - - return JsonSerializer.Serialize(payload); - } - private static string SerializeAgentStatus(UserAgentCatalogEntry entry, string? note = null) { return JsonSerializer.Serialize(new @@ -888,34 +374,6 @@ private async Task QueryAgentsForCallerAsync( return (entry, null); } - private async Task WaitForCreatedAgentAsync( - IUserAgentCatalogQueryPort queryPort, - string agentId, - OwnerScope caller, - long versionBefore, - Func predicate, - CancellationToken ct, - int maxAttempts = 10, - int delayMilliseconds = 500) - { - for (var attempt = 0; attempt < maxAttempts; attempt++) - { - if (attempt > 0) - await Task.Delay(delayMilliseconds, ct); - - var versionAfter = await queryPort.GetStateVersionForCallerAsync(agentId, caller, ct) ?? -1; - if (versionAfter <= versionBefore) - continue; - - var entry = await queryPort.GetForCallerAsync(agentId, caller, ct); - if (entry != null && predicate(entry)) - return true; - } - - return false; - } - - private async Task<(bool Confirmed, UserAgentCatalogEntry? Entry)> WaitForAgentStatusAsync( IUserAgentCatalogQueryPort queryPort, string agentId, @@ -924,22 +382,14 @@ private async Task WaitForCreatedAgentAsync( string expectedStatus, CancellationToken ct) { - // Status + version dual-condition (mirrors WaitForCreatedAgentAsync): - // wait until the read model both advances past the caller-captured - // baseline AND surfaces the expected status. Status alone is not - // enough — a stale replica can hold an expected-looking historical - // status (e.g., a previous disable→enable→disable cycle) and pass a - // status-only check while the actor has not yet processed *this* - // dispatch. Conversely, version alone is not enough either — an - // unrelated state event could advance the version without changing - // status. Both conditions together pin "this specific lifecycle - // event has materialized in the read model". Caller must capture - // versionBefore *before* dispatch, otherwise a fast projection that - // already advanced the version would make versionAfter == versionBefore - // and burn the entire budget. Projection scope priming also happens - // in the caller before dispatch (see DisableAgentAsync / - // EnableAgentAsync) — a late prime here cannot recover an event the - // projector already missed. + // Status + version dual-condition: wait until the read model both advances past the + // caller-captured baseline AND surfaces the expected status. Status alone is not + // enough — a stale replica can hold an expected-looking historical status (e.g., a + // previous disable→enable→disable cycle) and pass a status-only check while the + // actor has not yet processed *this* dispatch. Conversely, version alone is not + // enough either — an unrelated state event could advance the version without + // changing status. Both conditions together pin "this specific lifecycle event has + // materialized in the read model". for (var attempt = 0; attempt < _projectionWaitAttempts; attempt++) { if (attempt > 0) @@ -954,11 +404,6 @@ private async Task WaitForCreatedAgentAsync( return (Confirmed: true, Entry: entry); } - // Budget exhausted: the dual gate never passed. Do NOT fall back to an - // un-gated GetAsync read — that would surface a stale-but-expected- - // looking entry and let callers report success despite the contract - // not being satisfied. Callers must surface honest "submitted / - // propagating" copy when Confirmed is false. return (Confirmed: false, Entry: null); } @@ -968,829 +413,33 @@ private async Task WaitForCreatedAgentAsync( LifecycleAction action, string? revisionFeedback, ISkillRunnerCommandPort skillRunnerPort, - IWorkflowAgentCommandPort workflowAgentPort, CancellationToken ct) { - if (string.Equals(entry.AgentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal)) + if (!string.Equals(entry.AgentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal)) { - switch (action) - { - case LifecycleAction.Run: - await skillRunnerPort.TriggerAsync(entry.AgentId, reason, ct); - break; - case LifecycleAction.Disable: - await skillRunnerPort.DisableAsync(entry.AgentId, reason, ct); - break; - case LifecycleAction.Enable: - await skillRunnerPort.EnableAsync(entry.AgentId, reason, ct); - break; - default: - throw new ArgumentOutOfRangeException(nameof(action), action, null); - } - return (true, null); + return (false, JsonSerializer.Serialize(new { error = $"Agent '{entry.AgentId}' does not support {action.ToString().ToLowerInvariant()}." })); } - if (string.Equals(entry.AgentType, WorkflowAgentDefaults.AgentType, StringComparison.Ordinal)) + switch (action) { - switch (action) - { - case LifecycleAction.Run: - await workflowAgentPort.TriggerAsync(entry.AgentId, reason, revisionFeedback?.Trim(), ct); - break; - case LifecycleAction.Disable: - await workflowAgentPort.DisableAsync(entry.AgentId, reason, ct); - break; - case LifecycleAction.Enable: - await workflowAgentPort.EnableAsync(entry.AgentId, reason, ct); - break; - default: - throw new ArgumentOutOfRangeException(nameof(action), action, null); - } - return (true, null); + case LifecycleAction.Run: + await skillRunnerPort.TriggerAsync(entry.AgentId, reason, ct); + break; + case LifecycleAction.Disable: + await skillRunnerPort.DisableAsync(entry.AgentId, reason, ct); + break; + case LifecycleAction.Enable: + await skillRunnerPort.EnableAsync(entry.AgentId, reason, ct); + break; + default: + throw new ArgumentOutOfRangeException(nameof(action), action, null); } - - return (false, JsonSerializer.Serialize(new { error = $"Agent '{entry.AgentId}' does not support {action.ToString().ToLowerInvariant()}." })); + _ = revisionFeedback; // SkillRunner doesn't accept revision feedback today; reserved for future surfaces. + return (true, null); } private static bool SupportsManagedLifecycle(string? agentType) => - string.Equals(agentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal) || - string.Equals(agentType, WorkflowAgentDefaults.AgentType, StringComparison.Ordinal); - - private async Task ResolveCurrentUserIdAsync(NyxIdApiClient client, string token, CancellationToken ct) - { - var response = await client.GetCurrentUserAsync(token, ct); - if (IsErrorPayload(response)) - return null; - - try - { - using var doc = JsonDocument.Parse(response); - if (doc.RootElement.TryGetProperty("user", out var user)) - return ReadString(user, "id", "user_id", "sub"); - - return ReadString(doc.RootElement, "id", "user_id", "sub"); - } - catch (JsonException) - { - return null; - } - } - - /// - /// Resolves the per-user UserService.id values that the new agent's API key needs in - /// allowed_service_ids to reach each required catalog slug through the NyxID proxy. - /// - /// - /// Issue aevatarAI/aevatar#417. The previous implementation called - /// GET /api/v1/proxy/services (the catalog list) and pulled out each row's - /// id, which is a DownstreamService.id — a global catalog UUID shared across - /// all users. NyxID's proxy enforcement (backend/src/handlers/proxy.rs:1030) checks the - /// API key's allowed_service_ids against the per-user UserService.id, not the - /// catalog id. The mismatch silently passed at POST /api-keys creation time, then - /// surfaced as 403 ApiKeyScopeForbidden on every proxy call. - /// Why the old code looked correct in development: allow_all_services=true - /// short-circuits the enforcement check (NyxID proxy.rs:1030). Session-token-minted - /// API keys default to true, so a developer reproducing the create-key + proxy-call - /// dance from a CLI never tripped the bug. The agent path mints child keys via the - /// channel-relay delegation token; NyxID forces those children to inherit - /// allow_all_services=false from the parent, which is when enforcement kicks in. - /// The BuildCreateApiKeyPayload change in PR #418 (review 4175529548) makes the - /// narrow-scope intent first-class by setting allow_all_services=false explicitly, - /// so this resolver's output is consulted regardless of the parent's setting. - /// The fix: use GET /api/v1/user-services, which lists this user's - /// UserService instances. For each instance the response carries the per-user - /// id (what enforcement actually checks) plus slug, is_active, and a - /// credential_source envelope. We filter to active rows whose slug matches a required - /// slug, and skip org-shared rows the caller cannot use as a proxy target — those would later - /// surface as a less-actionable org_role_insufficient error. - /// - /// - /// Result of . / - /// are mutually exclusive (success vs. blocking error). Even on - /// success, callers can use to look up optional - /// slugs that were not in requiredSlugs — e.g. the inbound channel-bot slug for - /// SkillRunner's failure-notification fallback (issue #423 §C). Optional lookups must - /// not block agent creation, so they go through this map instead of being added to - /// requiredSlugs (which would cause to - /// return a service_not_connected error if the slug is missing). - /// - private readonly record struct ProxyServiceResolutionResult( - IReadOnlyList? RequiredIds, - string? ErrorJson, - IReadOnlyDictionary EligibleIdBySlug); - - private async Task ResolveProxyServiceIdsAsync( - NyxIdApiClient client, - string token, - IReadOnlyList requiredSlugs, - CancellationToken ct) - { - var emptyEligible = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (requiredSlugs.Count == 0) - { - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "no_required_slugs", - hint = "At least one required Nyx proxy service slug must be provided.", - }), emptyEligible); - } - - var response = await client.ListUserServicesAsync(token, ct); - if (IsErrorPayload(response)) - { - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "user_services_unavailable", - hint = "Could not list connected Nyx user-services. Try again or check NyxID availability.", - }), emptyEligible); - } - - try - { - using var doc = JsonDocument.Parse(response); - // List response shape: { "services": [ {id, slug, is_active, credential_source: {...}}, ... ] } - // The catalog response also nests under "services" (and additionally "custom_services"), - // so reusing EnumerateProxyServiceItems is safe — but we accept *only* rows that look - // like UserService instances by checking presence of `slug`. - // - // Codex review (PR #418 r3141846173): users with mixed bindings can have multiple - // rows for the same slug (e.g. an org-shared `allowed:false` row alongside a personal - // active row). NyxID does not guarantee any ordering, so the resolver must keep the - // *most eligible* row per slug rather than the first one seen. We track the first - // ineligible row anyway so that when no eligible row exists we can still emit a - // specific error (`service_inactive` / `service_org_viewer_only`) instead of a - // generic miss. - var bestBySlug = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var svc in EnumerateProxyServiceItems(doc.RootElement)) - { - var slug = ReadString(svc, "slug"); - if (string.IsNullOrWhiteSpace(slug)) - continue; - - var id = ReadString(svc, "id"); - if (string.IsNullOrWhiteSpace(id)) - continue; - - var isActive = TryReadBool(svc, "is_active") ?? true; - var credentialSource = svc.TryGetProperty("credential_source", out var cs) ? cs : default; - var sourceType = credentialSource.ValueKind == JsonValueKind.Object - ? ReadString(credentialSource, "type") - : null; - var orgAllowed = credentialSource.ValueKind == JsonValueKind.Object - ? TryReadBool(credentialSource, "allowed") - : null; - - var candidate = new ServiceResolution( - Id: id!, - IsActive: isActive, - CredentialSourceType: sourceType, - OrgAllowed: orgAllowed); - - if (bestBySlug.TryGetValue(slug, out var existing)) - { - // Already have an eligible row → never downgrade. - if (existing.IsEligible) - continue; - // Existing is ineligible; only replace with another ineligible row if we - // would otherwise lose information. Replace iff candidate is eligible. - if (!candidate.IsEligible) - continue; - } - - bestBySlug[slug] = candidate; - } - - // Snapshot the eligible (slug → id) map before the per-required-slug check so - // callers can look up optional slugs (e.g. inbound channel-bot for failure- - // notification fallback) without re-listing user-services. Ineligible rows are - // intentionally excluded — including them would let optional lookups silently - // pick up an inactive or org-viewer-only service the API key cannot route through. - var eligibleBySlug = bestBySlug - .Where(static pair => pair.Value.IsEligible) - .ToDictionary( - pair => pair.Key, - pair => pair.Value.Id, - StringComparer.OrdinalIgnoreCase); - - var ids = new List(requiredSlugs.Count); - foreach (var slug in requiredSlugs.Distinct(StringComparer.OrdinalIgnoreCase)) - { - if (!bestBySlug.TryGetValue(slug, out var resolution)) - { - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "service_not_connected", - slug, - hint = $"NyxID has no connected user-service for slug `{slug}`. Connect the provider at NyxID before creating this agent.", - }), emptyEligible); - } - - if (resolution.IsEligible) - { - ids.Add(resolution.Id); - continue; - } - - if (string.Equals(resolution.CredentialSourceType, "org", StringComparison.OrdinalIgnoreCase) && - resolution.OrgAllowed != true) - { - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "service_org_viewer_only", - slug, - hint = $"NyxID user-service for slug `{slug}` is shared by your org but your role does not permit using it as a proxy target. Ask an admin to widen the org role scope, or connect a personal credential.", - }), emptyEligible); - } - - // Remaining ineligible reason: !is_active. - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "service_inactive", - slug, - hint = $"NyxID user-service for slug `{slug}` is inactive. Re-activate it at NyxID before creating this agent.", - }), emptyEligible); - } - - return new ProxyServiceResolutionResult( - ids.Distinct(StringComparer.Ordinal).ToArray(), - null, - eligibleBySlug); - } - catch (JsonException) - { - return new ProxyServiceResolutionResult(null, JsonSerializer.Serialize(new - { - error = "user_services_parse_failed", - hint = "NyxID user-services response was not valid JSON.", - }), emptyEligible); - } - } - - private readonly record struct ServiceResolution( - string Id, - bool IsActive, - string? CredentialSourceType, - bool? OrgAllowed) - { - public bool IsEligible => - IsActive && - !(string.Equals(CredentialSourceType, "org", StringComparison.OrdinalIgnoreCase) && OrgAllowed != true); - } - - /// - /// Result of resolving the inbound channel-bot fallback used by SkillRunner's - /// failure-notification path (issue #423 §C). When the inbound slug is reachable - /// (registered + eligible + distinct from the primary), - /// is set and its corresponding UserService.id is appended to - /// so the agent's API key can route through it - /// at runtime. Otherwise is null and the agent - /// degrades to the existing single-attempt failure notification. - /// - private readonly record struct FailureNotificationContext( - string? FailureSlug, - IReadOnlyList AllowedServiceIds); - - private FailureNotificationContext ResolveFailureNotificationContext( - string primarySlug, - IReadOnlyList requiredIds, - IReadOnlyDictionary eligibleIdBySlug) - { - var inboundSlug = AgentToolRequestContext.TryGet(ChannelMetadataKeys.InboundChannelBotProxySlug)?.Trim(); - if (string.IsNullOrWhiteSpace(inboundSlug)) - return new FailureNotificationContext(null, requiredIds); - - // Same-proxy fallback gives no recovery benefit — a primary rejection at - // `slug=X` would also fail at `slug=X`. Skip the capture so TrySendFailureAsync - // doesn't pay the wasted POST and doesn't double-log the same rejection. - if (string.Equals(inboundSlug, primarySlug, StringComparison.Ordinal)) - return new FailureNotificationContext(null, requiredIds); - - // Optional slug must be a connected, eligible user-service for the API key to - // route through it. If it's not, leaving the failure-notification field empty - // keeps the runtime on the existing single-attempt path — better than persisting - // a slug whose every send would 403 at proxy enforcement time. - if (!eligibleIdBySlug.TryGetValue(inboundSlug, out var inboundId)) - return new FailureNotificationContext(null, requiredIds); - - // Dedupe — if the inbound slug's UserService.id is already in requiredIds the - // expanded list is identical, but we still surface the slug on OutboundConfig so - // the runtime knows to use it for failure notifications. - var allowed = requiredIds.Contains(inboundId, StringComparer.Ordinal) - ? requiredIds - : requiredIds.Append(inboundId).ToArray(); - - return new FailureNotificationContext(inboundSlug, allowed); - } - - private async Task BuildGitHubAuthorizationResponseAsync( - NyxIdApiClient client, - string token, - CancellationToken ct, - bool preferCredentialsRequiredStatus = false, - string? submittedGithubUsername = null) - { - var providerTokensResponse = await client.ListProviderTokensAsync(token, ct); - if (IsErrorPayload(providerTokensResponse)) - { - return JsonSerializer.Serialize(new - { - error = "Could not verify GitHub authorization status from NyxID providers.", - }); - } - - if (HasConnectedGitHubProvider(providerTokensResponse)) - return null; - - var catalogResponse = await client.GetCatalogEntryAsync(token, "api-github", ct); - if (IsErrorPayload(catalogResponse)) - { - return JsonSerializer.Serialize(new - { - error = "GitHub provider configuration is not available in the NyxID catalog.", - }); - } - - if (!TryParseGitHubCatalogEntry( - catalogResponse, - out var providerId, - out var providerType, - out var credentialMode, - out var documentationUrl, - out var catalogError)) - return JsonSerializer.Serialize(new { error = catalogError }); - - if (!string.Equals(providerType, "oauth2", StringComparison.OrdinalIgnoreCase)) - { - return JsonSerializer.Serialize(new - { - error = $"GitHub provider requires unsupported connection mode '{providerType ?? "unknown"}'.", - }); - } - - if (string.Equals(credentialMode, "user", StringComparison.OrdinalIgnoreCase)) - { - var credentialsResponse = await client.GetUserCredentialsAsync(token, providerId!, ct); - if (IsErrorPayload(credentialsResponse)) - return credentialsResponse; - - if (!TryParseUserCredentialsStatus(credentialsResponse, out var hasCredentials, out var credentialsError)) - return JsonSerializer.Serialize(new { error = credentialsError }); - - if (!hasCredentials) - { - return JsonSerializer.Serialize(new - { - status = "credentials_required", - template = "daily_report", - provider = "GitHub", - provider_id = providerId, - documentation_url = documentationUrl, - github_username = submittedGithubUsername, - note = "GitHub in NyxID uses user-managed OAuth app credentials. Set your GitHub OAuth app client_id/client_secret in NyxID first, then submit the daily report form again.", - }); - } - } - - var connectResponse = await client.InitiateOAuthConnectAsync(token, providerId!, ct); - if (IsErrorPayload(connectResponse)) - { - return JsonSerializer.Serialize(new - { - error = "Could not initiate GitHub OAuth connect in NyxID.", - }); - } - - if (!TryParseAuthorizationUrl(connectResponse, out var authorizationUrl, out var authError)) - return JsonSerializer.Serialize(new { error = authError }); - - return JsonSerializer.Serialize(new - { - status = preferCredentialsRequiredStatus ? "credentials_required" : "oauth_required", - template = "daily_report", - provider = "GitHub", - provider_id = providerId, - authorization_url = authorizationUrl, - documentation_url = documentationUrl, - github_username = submittedGithubUsername, - note = preferCredentialsRequiredStatus - ? "Connect GitHub in NyxID, then run /daily again." - : "Connect GitHub in NyxID, then return to Feishu and submit the daily report form again.", - }); - } - - private async Task<(string? GithubUsername, string? ErrorResponse)> ResolveDailyReportGithubUsernameAsync( - BuilderArgs args, - NyxIdApiClient nyxClient, - string token, - string scopeId, - CancellationToken ct) - { - var explicitGithubUsername = NormalizeOptional(args.Str("github_username")); - if (explicitGithubUsername is not null) - return (explicitGithubUsername, null); - - var preferredGithubUsername = await TryResolvePreferredGithubUsernameAsync(scopeId, ct); - if (preferredGithubUsername is not null) - return (preferredGithubUsername, null); - - var derivedGithubUsername = await TryResolveGitHubUsernameFromNyxAsync(nyxClient, token, ct); - if (derivedGithubUsername is not null) - return (derivedGithubUsername, null); - - var authorizationResponse = await BuildGitHubAuthorizationResponseAsync( - nyxClient, - token, - ct, - preferCredentialsRequiredStatus: true); - if (authorizationResponse is not null) - return (null, authorizationResponse); - - return (null, JsonSerializer.Serialize(new - { - status = "credentials_required", - template = "daily_report", - provider = "GitHub", - note = "Could not resolve github_username. Provide github_username explicitly, save a default preference, or reconnect GitHub in NyxID.", - })); - } - - private static bool TryParseApiKeyCreateResponse( - string response, - out string? apiKeyId, - out string? apiKeyValue, - out string? error) - { - apiKeyId = null; - apiKeyValue = null; - error = null; - - try - { - using var doc = JsonDocument.Parse(response); - var root = doc.RootElement; - apiKeyId = ReadString(root, "id", "api_key_id"); - apiKeyValue = ReadString(root, "full_key", "api_key", "token"); - - if ((string.IsNullOrWhiteSpace(apiKeyId) || string.IsNullOrWhiteSpace(apiKeyValue)) && - root.TryGetProperty("api_key", out var nested)) - { - apiKeyId ??= ReadString(nested, "id", "api_key_id"); - apiKeyValue ??= ReadString(nested, "full_key", "token", "value"); - } - - if (string.IsNullOrWhiteSpace(apiKeyId) || string.IsNullOrWhiteSpace(apiKeyValue)) - { - error = "NyxID API key response did not include both id and full_key."; - return false; - } - - return true; - } - catch (JsonException ex) - { - error = ex.Message; - return false; - } - } - - private static bool IsErrorPayload(string payload) - { - try - { - using var doc = JsonDocument.Parse(payload); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - return false; - - return doc.RootElement.TryGetProperty("error", out var errorProp) && - errorProp.ValueKind == JsonValueKind.True; - } - catch (JsonException) - { - return false; - } - } - - private static bool HasConnectedGitHubProvider(string response) - { - try - { - using var doc = JsonDocument.Parse(response); - if (!doc.RootElement.TryGetProperty("tokens", out var tokens) || tokens.ValueKind != JsonValueKind.Array) - return false; - - foreach (var element in tokens.EnumerateArray()) - { - if (!LooksLikeGitHubProvider(element)) - continue; - - return string.Equals( - NormalizeOptional(ReadString(element, "status")), - "active", - StringComparison.OrdinalIgnoreCase); - } - } - catch (JsonException) - { - } - - return false; - } - - private static bool TryParseGitHubCatalogEntry( - string response, - out string? providerId, - out string? providerType, - out string? credentialMode, - out string? documentationUrl, - out string? error) - { - providerId = null; - providerType = null; - credentialMode = null; - documentationUrl = null; - error = null; - - try - { - using var doc = JsonDocument.Parse(response); - providerId = ReadStringDeep(doc.RootElement, 3, "provider_config_id", "provider_id"); - providerType = ReadStringDeep(doc.RootElement, 3, "provider_type"); - credentialMode = ReadStringDeep(doc.RootElement, 3, "credential_mode"); - documentationUrl = ReadStringDeep(doc.RootElement, 3, "documentation_url"); - - if (string.IsNullOrWhiteSpace(providerId)) - { - error = "GitHub catalog entry did not include provider_config_id."; - return false; - } - - return true; - } - catch (JsonException ex) - { - error = ex.Message; - return false; - } - } - - private static bool TryParseUserCredentialsStatus( - string response, - out bool hasCredentials, - out string? error) - { - hasCredentials = false; - error = null; - - try - { - using var doc = JsonDocument.Parse(response); - if (doc.RootElement.TryGetProperty("has_credentials", out var property)) - { - if (property.ValueKind == JsonValueKind.True) - { - hasCredentials = true; - return true; - } - - if (property.ValueKind == JsonValueKind.False) - { - hasCredentials = false; - return true; - } - } - - error = "NyxID user credentials response did not include has_credentials."; - return false; - } - catch (JsonException ex) - { - error = ex.Message; - return false; - } - } - - private static bool TryParseAuthorizationUrl( - string response, - out string? authorizationUrl, - out string? error) - { - authorizationUrl = null; - error = null; - - try - { - using var doc = JsonDocument.Parse(response); - authorizationUrl = ReadStringDeep(doc.RootElement, 3, "authorization_url", "auth_url", "url"); - if (string.IsNullOrWhiteSpace(authorizationUrl)) - { - error = "NyxID OAuth connect response did not include an authorization URL."; - return false; - } - - return true; - } - catch (JsonException ex) - { - error = ex.Message; - return false; - } - } - - private async Task TryResolvePreferredGithubUsernameAsync(string scopeId, CancellationToken ct) - { - var queryPort = _serviceProvider.GetService(); - if (queryPort is null) - return null; - - try - { - var config = await queryPort.GetAsync(scopeId, ct); - return NormalizeOptional(config.GithubUsername); - } - catch (OperationCanceledException) - { - throw; - } - catch - { - return null; - } - } - - private async Task TryResolveGitHubUsernameFromNyxAsync( - NyxIdApiClient client, - string token, - CancellationToken ct) - { - try - { - var response = await client.ProxyRequestAsync( - token, - "api-github", - "user", - "GET", - null, - null, - ct); - if (IsErrorPayload(response)) - return null; - - return TryParseGitHubUserLogin(response, out var login) - ? login - : null; - } - catch (OperationCanceledException) - { - throw; - } - catch - { - return null; - } - } - - private async Task SaveGithubUsernamePreferenceIfRequestedAsync( - string scopeId, - string githubUsername, - bool shouldSave, - CancellationToken ct) - { - if (!shouldSave || string.IsNullOrWhiteSpace(githubUsername)) - return false; - - var commandService = _serviceProvider.GetService(); - if (commandService is null) - return false; - - try - { - await commandService.SaveGithubUsernameAsync(scopeId, githubUsername, ct); - return true; - } - catch (OperationCanceledException) - { - throw; - } - catch - { - return false; - } - } - - private static bool TryParseGitHubUserLogin( - string response, - out string? login) - { - login = null; - - try - { - using var doc = JsonDocument.Parse(response); - login = NormalizeOptional(ReadStringDeep(doc.RootElement, 2, "login", "username")); - return login is not null; - } - catch (JsonException) - { - return false; - } - } - - private static string? ReadString(JsonElement element, params string[] names) - { - if (element.ValueKind != JsonValueKind.Object) - return null; - - foreach (var name in names) - { - if (!element.TryGetProperty(name, out var property)) - continue; - - if (property.ValueKind == JsonValueKind.String) - return property.GetString(); - - if (property.ValueKind == JsonValueKind.Number) - return property.GetRawText(); - } - - return null; - } - - private static string? ReadStringDeep(JsonElement element, int maxDepth, params string[] names) - { - var direct = ReadString(element, names); - if (!string.IsNullOrWhiteSpace(direct) || maxDepth <= 0) - return direct; - - if (element.ValueKind == JsonValueKind.Object) - { - foreach (var property in element.EnumerateObject()) - { - var nested = ReadStringDeep(property.Value, maxDepth - 1, names); - if (!string.IsNullOrWhiteSpace(nested)) - return nested; - } - } - else if (element.ValueKind == JsonValueKind.Array) - { - foreach (var item in element.EnumerateArray()) - { - var nested = ReadStringDeep(item, maxDepth - 1, names); - if (!string.IsNullOrWhiteSpace(nested)) - return nested; - } - } - - return null; - } - - private static bool LooksLikeGitHubProvider(JsonElement element) - { - foreach (var value in EnumerateStrings( - ReadStringDeep(element, 2, "provider_name", "name", "display_name", "slug", "provider", "service_slug"))) - { - if (value.Contains("github", StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - - private static IEnumerable EnumerateStrings(params string?[] values) - { - foreach (var value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - yield return value; - } - } - - private static IEnumerable EnumerateProxyServiceItems(JsonElement root) - { - if (root.ValueKind == JsonValueKind.Array) - { - foreach (var item in root.EnumerateArray()) - yield return item; - yield break; - } - - if (root.ValueKind != JsonValueKind.Object) - yield break; - - foreach (var propertyName in new[] { "services", "custom_services", "data" }) - { - if (!root.TryGetProperty(propertyName, out var items) || - items.ValueKind != JsonValueKind.Array) - { - continue; - } - - foreach (var item in items.EnumerateArray()) - yield return item; - } - } - - private static string NormalizeScopeId(string? value) => - NormalizeOptional(value) ?? "default"; + string.Equals(agentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal); private static string? NormalizeOptional(string? value) { @@ -1798,519 +447,6 @@ private static string NormalizeScopeId(string? value) => return normalized.Length == 0 ? null : normalized; } - /// - /// Builds the typed Lark delivery target (primary + optional fallback) from the current - /// AgentToolRequestContext, and emits a LogDebug breadcrumb when the primary fell back from - /// the cross-app safe pair (chat_id / union_id) to the legacy open_id / conversation_id - /// path. The primary is what - /// returns; the fallback (when the primary is a DM chat_id and we also have a union_id at - /// ingress) is captured so the runtime can retry once on a Lark - /// 230002 bot not in chat rejection — the failure mode for cross-app same-tenant - /// deployments where the outbound app is not in the inbound DM. Operators correlating Lark - /// 99992361 open_id cross app rejections need the log line to confirm whether the - /// relay surfaced union_id at agent-create time. - /// - /// - /// Preflights GitHub proxy access using the newly created agent API key. Three-step probe: - /// first /rate_limit (catches token-level OAuth-grant revocation as 401/403), then - /// global /search/issues + /search/commits with the bound github_username - /// (catches scope insufficiency for global search), then per-repo - /// /search/{issues,commits}?q=repo:{owner}/{repo}+author:{username} for every - /// repository in the configured allowlist (catches the case where global public search - /// works but a specific repo in the allowlist is private and the token lacks repo - /// scope — codex review PR #479 r3152148327). - /// - /// Returns a structured error JSON suitable for returning verbatim from the tool on - /// hard-fail shapes; returns null on success or on probe shapes we don't classify - /// as "fundamentally broken" (rate limits, 5xx). - /// - /// - /// Issue aevatarAI/aevatar#411 added the original /rate_limit step to fail fast on - /// a misdiagnosed root cause (we thought the api-key was missing a GitHub binding). Issue - /// #417 fixed that real cause — the api-key now carries the right per-user - /// UserService.ids. The probe was retained because the OAuth grant can still be - /// revoked outside our control. Issue #474 widens the probe surface to /search/* - /// because /rate_limit is scope-light (succeeds with any valid token) and never - /// caught the production failure mode where /search/* 422s every call — agents got - /// persisted but every scheduled run produced an empty report. The freshly minted api-key - /// is best-effort revoked at the call site on any preflight failure so retries don't - /// accumulate orphan proxy-scoped keys. - /// - private async Task PreflightGitHubProxyAsync( - NyxIdApiClient nyxClient, - string apiKey, - string githubUsername, - IReadOnlyList repositories, - string nyxProviderSlug, - CancellationToken ct) - { - // Step 1: cheap read-only endpoint; succeeds even with a rate-limited token, fails with - // 401/403 when the proxy can't resolve a bound GitHub credential. - var rateLimitProbe = await nyxClient.ProxyRequestAsync( - apiKey, - "api-github", - "/rate_limit", - "GET", - body: null, - extraHeaders: null, - ct); - - var rateLimitFailure = ClassifyRateLimitProbeFailure(rateLimitProbe, nyxProviderSlug); - if (rateLimitFailure is not null) - return rateLimitFailure; - - // Step 2: global search-API probes. /rate_limit is scope-light — it returns 200 even - // with a token that GitHub's search engine will reject. Issue #474: all of - // /search/issues and /search/commits return 422 "invalid user/permission" when the - // bound OAuth grant lacks public_repo/repo or the username is unreachable, and the - // daily report is useless if those endpoints don't work. Probe both with per_page=1 so - // we exercise the same auth surface the runtime will hit, without paying for full - // result pages. Skip when no username is bound — the rate_limit step is the only - // signal we have in that case (and CreateDailyReportAgentAsync rejects empty - // github_username earlier, so this guard is defensive only). - var normalizedUser = (githubUsername ?? string.Empty).Trim(); - if (string.IsNullOrEmpty(normalizedUser)) - return null; - - var encodedUser = Uri.EscapeDataString(normalizedUser); - var globalSearchPaths = new (string Path, string Label)[] - { - ($"/search/issues?q=author:{encodedUser}&per_page=1", "/search/issues"), - ($"/search/commits?q=author:{encodedUser}&per_page=1", "/search/commits"), - }; - foreach (var (path, label) in globalSearchPaths) - { - var searchProbe = await nyxClient.ProxyRequestAsync( - apiKey, - "api-github", - path, - "GET", - body: null, - extraHeaders: null, - ct); - - var searchFailure = ClassifySearchProbeFailure(searchProbe, label, normalizedUser, nyxProviderSlug); - if (searchFailure is not null) - return searchFailure; - } - - // Step 3: per-repo search-API probes when a repository allowlist is configured. The - // runtime daily report runs `repo:{owner}/{repo}+author:{username}` queries (see - // AgentBuilderTemplates.cs repo-mode URL list) — different auth surface from the - // global search above, because GitHub enforces per-repo visibility. A token with - // public_repo can pass global search yet 422 every repo-scoped call when one of the - // listed repos is private. Codex review PR #479 r3152148327: probing only global - // queries leaves that case persisting broken agents, so loop the repos here. - if (repositories is null || repositories.Count == 0) - return null; - - foreach (var repoEntry in repositories) - { - var trimmedRepo = (repoEntry ?? string.Empty).Trim(); - if (string.IsNullOrEmpty(trimmedRepo)) - continue; - - // GitHub usernames and repo names are restricted to [a-zA-Z0-9-._] per the - // github.com identifier rules — none of which need percent-encoding. The slash - // separator must be preserved literally (Uri.EscapeDataString would emit %2F, - // which GitHub's q= parser does not consistently accept). Pass repoEntry through - // unescaped; defense-in-depth escaping happens on the username segment. - var repoSearchPaths = new (string Path, string Label)[] - { - ($"/search/issues?q=repo:{trimmedRepo}+author:{encodedUser}&per_page=1", $"/search/issues (repo={trimmedRepo})"), - ($"/search/commits?q=repo:{trimmedRepo}+author:{encodedUser}&per_page=1", $"/search/commits (repo={trimmedRepo})"), - }; - foreach (var (path, label) in repoSearchPaths) - { - var searchProbe = await nyxClient.ProxyRequestAsync( - apiKey, - "api-github", - path, - "GET", - body: null, - extraHeaders: null, - ct); - - var searchFailure = ClassifySearchProbeFailure(searchProbe, label, normalizedUser, nyxProviderSlug); - if (searchFailure is not null) - return searchFailure; - } - } - - return null; - } - - /// - /// Maps a /rate_limit probe response onto a fail-fast structured error or null. - /// Only 401/403 are fail-fast; all other shapes (200, 5xx, transient errors, malformed - /// JSON) flow through so creation can proceed and the operator can debug from logs. - /// - /// - /// `NyxIdApiClient.SendAsync` (NyxIdApiClient.cs:710) wraps HTTP non-2xx as - /// {"error": true, "status": <http>, "body": "<raw downstream body>"} — - /// status, not code. Reviewer (PR #412 r3141699476): the previous parser only - /// read code, so for the actual #411 production failures (HTTP 403 from - /// /api/v1/proxy/s/api-github/rate_limit) it set status=0, returned null, and - /// persisted a daily_report agent that would fail at runtime. Read both status (the - /// SendAsync envelope) AND code (any future inverted-naming envelope or top-level - /// Lark code). - /// - private static string? ClassifyRateLimitProbeFailure(string probe, string nyxProviderSlug) - { - if (string.IsNullOrWhiteSpace(probe)) - return null; - - try - { - using var doc = JsonDocument.Parse(probe); - var root = doc.RootElement; - // `envelopeMessage` is the proxy envelope's `message` field; named to avoid - // shadowing the anonymous-type `detail` property below (codex review PR #479). - if (!IsErrorEnvelope(root, out var status, out var envelopeMessage, out var body)) - return null; - - if (status != (int)HttpStatusCode.Unauthorized && status != (int)HttpStatusCode.Forbidden) - return null; - - return JsonSerializer.Serialize(new - { - error = "github_proxy_access_denied", - detail = string.IsNullOrWhiteSpace(envelopeMessage) ? "GitHub proxy returned 401/403 for the new agent API key." : envelopeMessage, - http_status = status, - proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, - hint = "GitHub returned 401/403 through the NyxID proxy. Common causes: (a) the OAuth grant for GitHub was revoked at github.com/settings/applications or its scopes were downgraded — re-authorize the GitHub provider at NyxID; (b) the request reached GitHub without a User-Agent header (NyxIdApiClient now sends a default; if you see this, check that the deployed binary includes that fix). The agent will not produce a useful daily report until proxy access succeeds.", - nyx_provider_slug = nyxProviderSlug, - }); - } - catch (JsonException) - { - // Non-JSON probe response: don't pretend we know what's going on; let creation - // proceed so the agent can at least be created (operator can debug from logs). - return null; - } - } - - /// - /// Maps a /search/{issues,commits} probe response onto a fail-fast structured - /// error or null. Only 422 is fail-fast (the documented "invalid user/permission" / - /// "validation failed" surface); all other shapes (200 with empty results, 200 with - /// items, transient 5xx, secondary rate limits) flow through. - /// - /// - /// Sub-reason classification reads the upstream GitHub error body, since GitHub does not - /// give different status codes for the four cases the user-facing report needs to - /// distinguish (issue #473's expected behavior): user-not-exist, scope-insufficient, - /// search rate-limited, query-invalid. The first two share a body - /// ("...cannot be searched either because the resources do not exist or you do not - /// have permission to view them..."), so we collapse them into one - /// scope_insufficient_or_user_not_found reason — they're both actionable in the - /// same way (re-authorize GitHub at NyxID with broader scope, then retry; if that still - /// fails, verify the username is reachable). Other 422 bodies fall through as - /// validation_failed. - /// - private static string? ClassifySearchProbeFailure( - string probe, - string githubPath, - string githubUsername, - string nyxProviderSlug) - { - if (string.IsNullOrWhiteSpace(probe)) - return null; - - try - { - using var doc = JsonDocument.Parse(probe); - var root = doc.RootElement; - // `envelopeMessage` is the proxy envelope's `message` field; named to avoid - // shadowing the anonymous-type `detail` property below (codex review PR #479). - if (!IsErrorEnvelope(root, out var status, out var envelopeMessage, out var body)) - return null; - - if (status != (int)HttpStatusCode.UnprocessableEntity) - return null; - - var reason = ClassifyGitHubSearch422Body(body); - return JsonSerializer.Serialize(new - { - error = "github_search_unauthorized", - detail = string.IsNullOrWhiteSpace(envelopeMessage) - ? $"GitHub {githubPath} returned 422 for github_username `{githubUsername}` with the new agent API key. The /rate_limit probe succeeded, so the api-key itself is valid; the failure is specific to GitHub's search API." - : envelopeMessage, - http_status = status, - github_path = githubPath, - github_username = githubUsername, - reason_code = reason, - proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, - // Hint references the `github_username` field above instead of inlining it - // a second time; codex review PR #479 caught a stray `{username}` literal in - // an earlier draft. - hint = "GitHub returned 422 from /search/* with the bound username. /search/commits and /search/issues enforce stricter scope than /rate_limit (which succeeded), so a token that passes /rate_limit can still fail every search call. Most common causes: (a) the OAuth grant for GitHub at NyxID is missing the scope GitHub's search engine requires (need `public_repo` to search public commits/issues, `repo` for private) — re-authorize the GitHub provider at NyxID with appropriate scopes; (b) the bound github_username (see field above) does not exist, was renamed, or has been restricted — verify it resolves at https://github.com/. The agent will not produce a useful daily report until /search/* succeeds.", - nyx_provider_slug = nyxProviderSlug, - }); - } - catch (JsonException) - { - return null; - } - } - - /// - /// Preflights Twitter (X) proxy access using the newly created agent API key against - /// Twitter's /users/me — a cheap read-only endpoint that returns 401 when NyxID has - /// no OAuth grant for the user (or the grant was revoked) and 403 when the bound token - /// lacks tweet.write scope. Returns a structured error JSON suitable for returning - /// verbatim from the tool when access is denied; returns null on success or on - /// probe shapes we don't classify as "fundamentally broken" (rate limits, 5xx). - /// - /// - /// Mirrors (issue aevatarAI/aevatar#216 / #418). - /// Two error codes instead of one because 401 and 403 lead to different user actions: - /// 401 means "go connect Twitter at NyxID" (or re-authorize a revoked grant); 403 means - /// "the bound token is missing tweet.write — operator/seed bug, not user fixable". - /// The freshly minted api-key is best-effort revoked at the call site so retries don't - /// accumulate orphan proxy-scoped keys. - /// - private async Task PreflightTwitterProxyAsync( - NyxIdApiClient nyxClient, - string apiKey, - string nyxProviderSlug, - CancellationToken ct) - { - // Cheap read-only endpoint; succeeds with the default `users.read` scope, fails with - // 401 when no OAuth grant is bound to the user behind the api-key, and 403 when the - // bound token's scope set is too narrow. - // - // PR #461 review (commit d9f6df81 follow-up): probe the *configured* publish slug so - // a caller-overridden `publish_provider_slug` is the slug we actually validate. The - // earlier hardcoded `"api-twitter"` would silently green-light a custom slug at - // create-time only to surface a runtime 4xx on the first publish. - var probe = await nyxClient.ProxyRequestAsync( - apiKey, - nyxProviderSlug, - "/users/me", - "GET", - body: null, - extraHeaders: null, - ct); - - if (string.IsNullOrWhiteSpace(probe)) - return null; - - try - { - using var doc = JsonDocument.Parse(probe); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) - return null; - - if (!root.TryGetProperty("error", out var errorProp)) - return null; - if (errorProp.ValueKind != JsonValueKind.True && errorProp.ValueKind != JsonValueKind.String) - return null; - - var status = TryReadInt32Property(root, "status") - ?? TryReadInt32Property(root, "code") - ?? 0; - if (status != (int)HttpStatusCode.Unauthorized && status != (int)HttpStatusCode.Forbidden) - return null; - - var detail = root.TryGetProperty("message", out var msgProp) && msgProp.ValueKind == JsonValueKind.String - ? msgProp.GetString() - : null; - var body = root.TryGetProperty("body", out var bodyProp) && bodyProp.ValueKind == JsonValueKind.String - ? bodyProp.GetString() - : null; - - // 401 vs 403 distinction is the actionable difference for the user. NyxID seeds - // `tweet.write` into the default scope set (provider_service.rs:405-450), so the - // realistic 401 path is "user has not connected Twitter yet at NyxID" or "the - // user revoked the grant at x.com/settings". A 403 here would mean either the - // seed regressed (ops escalation) or x.com itself denied the request body — keep - // both paths separate so the hint copy steers the right person. - if (status == (int)HttpStatusCode.Unauthorized) - { - return JsonSerializer.Serialize(new - { - error = "twitter_oauth_required", - detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 401 for the new agent API key." : detail, - http_status = status, - proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, - hint = "Twitter (X) returned 401 through the NyxID proxy. The user has not connected Twitter at NyxID, or the OAuth grant was revoked at x.com/settings/connected_apps. Re-authorize the Twitter provider at NyxID before retrying agent creation.", - nyx_provider_slug = nyxProviderSlug, - }); - } - - return JsonSerializer.Serialize(new - { - error = "twitter_proxy_access_denied", - detail = string.IsNullOrWhiteSpace(detail) ? "Twitter proxy returned 403 for the new agent API key." : detail, - http_status = status, - proxy_body = string.IsNullOrWhiteSpace(body) ? null : body, - hint = "Twitter (X) returned 403 through the NyxID proxy. Default provider scope includes `tweet.write`; a 403 here usually means the seeded provider scope was downgraded or the bound token was issued before the scope was widened. Re-authorize at NyxID; if it still fails, ask ops to verify the Twitter provider seed includes `tweet.write`.", - nyx_provider_slug = nyxProviderSlug, - }); - } - catch (JsonException) - { - return null; - } - } - - /// - /// Reads the standard NyxIdApiClient.SendAsync error envelope shape. Returns - /// true when the response is an error envelope (with error: true or - /// error: "...") and extracts status (or code), message, and - /// body for downstream classification. Used by both rate-limit and search probe - /// classifiers so they parse the envelope identically. - /// - private static bool IsErrorEnvelope( - JsonElement root, - out int status, - out string? detail, - out string? body) - { - status = 0; - detail = null; - body = null; - - if (root.ValueKind != JsonValueKind.Object) - return false; - - if (!root.TryGetProperty("error", out var errorProp)) - return false; - if (errorProp.ValueKind != JsonValueKind.True && errorProp.ValueKind != JsonValueKind.String) - return false; - - status = TryReadInt32Property(root, "status") - ?? TryReadInt32Property(root, "code") - ?? 0; - detail = root.TryGetProperty("message", out var msgProp) && msgProp.ValueKind == JsonValueKind.String - ? msgProp.GetString() - : null; - body = root.TryGetProperty("body", out var bodyProp) && bodyProp.ValueKind == JsonValueKind.String - ? bodyProp.GetString() - : null; - return true; - } - - /// - /// Best-effort sub-reason classification for a GitHub 422 search response body. Returns a - /// short stable code so callers / operators can distinguish actionable cases without - /// regex'ing the body themselves. The detection is conservative — when the body doesn't - /// match a known pattern we fall through to validation_failed rather than guessing. - /// - private static string ClassifyGitHubSearch422Body(string? body) - { - if (string.IsNullOrWhiteSpace(body)) - return "validation_failed"; - - // GitHub returns the same body for "user does not exist" and "scope insufficient": - // the search engine refuses to enumerate the user's items in either case. Operators - // distinguish them by checking https://github.com/{username} out of band. - if (body.Contains("cannot be searched", StringComparison.OrdinalIgnoreCase) || - body.Contains("do not have permission to view", StringComparison.OrdinalIgnoreCase)) - { - return "scope_insufficient_or_user_not_found"; - } - - return "validation_failed"; - } - - private static int? TryReadInt32Property(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property) || - property.ValueKind != JsonValueKind.Number || - !property.TryGetInt32(out var value)) - { - return null; - } - return value; - } - - private static bool? TryReadBool(JsonElement element, string propertyName) - { - if (element.ValueKind != JsonValueKind.Object || - !element.TryGetProperty(propertyName, out var property)) - { - return null; - } - - return property.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - _ => null, - }; - } - - /// - /// Best-effort revoke of an API key minted earlier in the create flow. Used when GitHub - /// preflight fails so retries of /daily don't accumulate orphan proxy-scoped keys - /// in the user's NyxID account (codex review #418 r3141846175). Failures here are logged - /// at Warning but do NOT propagate — the structured create-time error is the user-facing - /// signal; an orphan key is an ops cleanup concern, not a hard failure. - /// - private async Task BestEffortRevokeApiKeyAsync( - NyxIdApiClient nyxClient, - string sessionToken, - string apiKeyId, - string reason, - CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(apiKeyId)) - return; - - try - { - var response = await nyxClient.DeleteApiKeyAsync(sessionToken, apiKeyId, ct); - if (LarkProxyResponse.TryGetError(response, out _, out var detail)) - { - _logger?.LogWarning( - "Failed to revoke orphan agent API key {ApiKeyId} after {Reason}: {Detail}", - apiKeyId, - reason, - detail); - } - } - catch (Exception ex) - { - _logger?.LogWarning( - ex, - "Exception revoking orphan agent API key {ApiKeyId} after {Reason}", - apiKeyId, - reason); - } - } - - private LarkReceiveTargetWithFallback ResolveDeliveryTarget(string conversationId, string agentId) - { - var chatType = AgentToolRequestContext.TryGet(ChannelMetadataKeys.ChatType); - var senderId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.SenderId); - var unionId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.LarkUnionId); - var chatId = AgentToolRequestContext.TryGet(ChannelMetadataKeys.LarkChatId); - - var target = LarkConversationTargets.BuildFromInboundWithFallback( - chatType, - conversationId, - senderId, - unionId, - chatId); - - if (target.Primary.FellBackToPrefixInference) - { - _logger?.LogDebug( - "Agent builder fell back to legacy delivery target inference for {AgentId}: chatType={ChatType}, hasUnionId={HasUnionId}, hasLarkChatId={HasLarkChatId}, hasSenderId={HasSenderId}, resolvedReceiveIdType={ReceiveIdType}. Cross-app outbound (e.g. customer api-lark-bot) may surface Lark `99992361 open_id cross app` until the relay propagates union_id.", - agentId, - chatType ?? string.Empty, - !string.IsNullOrWhiteSpace(unionId), - !string.IsNullOrWhiteSpace(chatId), - !string.IsNullOrWhiteSpace(senderId), - target.Primary.ReceiveIdType); - } - - return target; - } - private sealed class BuilderArgs { private readonly Dictionary _properties; diff --git a/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs b/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs index 5163f345a..958d8d2a4 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/FeishuCardHumanInteractionPort.cs @@ -72,16 +72,6 @@ await SendTextMessageAsync( "Feishu approval resolution delivery failed", cancellationToken); - if (ShouldSendApprovedContent(target, resolution)) - { - await SendTextMessageAsync( - target, - resolution.ResolvedContent!, - "Feishu approved-content delivery returned empty response.", - "Feishu approved-content delivery failed", - cancellationToken); - } - _logger.LogInformation( "Delivered human approval resolution text: target={DeliveryTargetId}, run={RunId}, step={StepId}, approved={Approved}", deliveryTargetId, @@ -143,14 +133,6 @@ internal static string BuildApprovalResolutionText( if (!string.IsNullOrWhiteSpace(resolution.Feedback)) lines.Add($"Feedback: {resolution.Feedback}"); - if (!resolution.Approved && target is not null && - string.Equals(target.TemplateName, WorkflowAgentDefaults.TemplateName, StringComparison.OrdinalIgnoreCase)) - { - lines.Add(string.Empty); - lines.Add($"Run again: /run-agent {target.AgentId}"); - lines.Add("View agents: /agents"); - } - return string.Join('\n', lines); } @@ -281,13 +263,6 @@ private async Task ResolveTargetAsync( return target; } - private static bool ShouldSendApprovedContent( - UserAgentDeliveryTarget target, - HumanApprovalResolution resolution) => - resolution.Approved && - !string.IsNullOrWhiteSpace(resolution.ResolvedContent) && - string.Equals(target.TemplateName, WorkflowAgentDefaults.TemplateName, StringComparison.OrdinalIgnoreCase); - private async Task SendTextMessageAsync( UserAgentDeliveryTarget target, string text, @@ -436,8 +411,8 @@ private static string BuildLarkRejectionMessage(string failurePrefix, int? larkC // instead of the cryptic Lark `99992361 open_id cross app`. return $"{failurePrefix} (code={larkCode}): {detail}. " + - "This workflow agent was created before cross-app union_id ingress existed; " + - "delete and recreate it (`/agents` → Delete → `/social-media`) to pick up the cross-app safe target."; + "This agent was created before cross-app union_id ingress existed; " + + "delete it (`/agents` → Delete) and recreate it to pick up the cross-app safe target."; } if (larkCode == LarkBotErrorCodes.UserIdCrossTenant) @@ -449,10 +424,9 @@ private static string BuildLarkRejectionMessage(string failurePrefix, int? larkC return $"{failurePrefix} (code={larkCode}): {detail}. " + "The outbound Lark app is in a different tenant than the inbound app, so " + - "user-id translation is impossible. Delete and recreate the workflow agent " + - "(`/agents` → Delete → `/social-media`) so the new chat_id-preferred outbound " + - "path takes effect, or align the NyxID `s/api-lark-bot` proxy with the channel-bot " + - "that received the inbound event."; + "user-id translation is impossible. Delete the agent (`/agents` → Delete) and recreate " + + "it so the new chat_id-preferred outbound path takes effect, or align the NyxID " + + "`s/api-lark-bot` proxy with the channel-bot that received the inbound event."; } return larkCode is { } code diff --git a/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs index 4653a19b7..3faaa5215 100644 --- a/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs +++ b/agents/Aevatar.GAgents.Authoring.Lark/NyxRelayAgentBuilderFlow.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; @@ -11,17 +10,12 @@ namespace Aevatar.GAgents.Authoring.Lark; public static class NyxRelayAgentBuilderFlow { private const string PrivateChatType = "p2p"; - private const string DailyCommand = "/daily"; - private const string SocialMediaCommand = "/social-media"; - private const string SocialMediaAlias = "/create-social-media"; - private const string ListTemplatesCommand = "/templates"; private const string ListAgentsCommand = "/agents"; private const string AgentStatusCommand = "/agent-status"; private const string RunAgentCommand = "/run-agent"; private const string DisableAgentCommand = "/disable-agent"; private const string EnableAgentCommand = "/enable-agent"; private const string DeleteAgentCommand = "/delete-agent"; - private const string DefaultScheduleTime = "09:00"; public static bool TryResolve( ChannelInboundEvent evt, @@ -55,7 +49,7 @@ public static bool TryResolve( return true; } - return TryResolveKnownCommand(command, tokens, evt.ConversationId, out decision); + return TryResolveKnownCommand(command, tokens, out decision); } public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, string toolResultJson) @@ -67,9 +61,6 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, using var doc = JsonDocument.Parse(toolResultJson); return decision.ToolAction switch { - "create_daily_report" => FormatCreateDailyReportResult(doc.RootElement), - "create_social_media" => TextContent(FormatCreateSocialMediaResult(doc.RootElement)), - "list_templates" => TextContent(FormatListTemplatesResult(doc.RootElement)), "list_agents" => AgentBuilderCardContent.FormatListAgentsResult(doc.RootElement), "agent_status" => FormatAgentStatusCard(doc.RootElement), "run_agent" => TextContent(FormatRunAgentResult(doc.RootElement)), @@ -88,10 +79,7 @@ public static MessageContent FormatToolResult(AgentBuilderFlowDecision decision, private static MessageContent TextContent(string text) => AgentBuilderJson.TextContent(text); private static bool IsKnownCommand(string command) => - command is DailyCommand - or SocialMediaCommand or SocialMediaAlias - or ListTemplatesCommand - or ListAgentsCommand + command is ListAgentsCommand or AgentStatusCommand or RunAgentCommand or DisableAgentCommand @@ -104,22 +92,10 @@ private static bool IsPrivateChat(string? chatType) => private static bool TryResolveKnownCommand( string command, IReadOnlyList tokens, - string? conversationId, out AgentBuilderFlowDecision? decision) { switch (command) { - case DailyCommand: - return TryResolveDailyReport(tokens, conversationId, out decision); - - case SocialMediaCommand: - case SocialMediaAlias: - return TryResolveSocialMedia(tokens, conversationId, out decision); - - case ListTemplatesCommand: - decision = AgentBuilderFlowDecision.ToolCall("list_templates", """{"action":"list_templates"}"""); - return true; - case ListAgentsCommand: decision = AgentBuilderFlowDecision.ToolCall("list_agents", """{"action":"list_agents"}"""); return true; @@ -145,102 +121,6 @@ private static bool TryResolveKnownCommand( } } - private static bool TryResolveDailyReport( - IReadOnlyList tokens, - string? conversationId, - out AgentBuilderFlowDecision? decision) - { - decision = null; - var args = ChannelTextCommandParser.ParseNamedArguments(tokens); - var githubUsername = NormalizeOptional( - GetOptional(args, "github_username") ?? FirstPositionalArgument(tokens)); - - if (!TryResolveSchedule(args, out var scheduleCron, out var scheduleTimezone, out var error)) - { - decision = AgentBuilderFlowDecision.DirectReply(error! + "\n\n" + BuildDailyReportHelpText()); - return true; - } - - var repositories = GetOptional(args, "repositories"); - var runImmediately = ResolveRunImmediately(args); - // When the user typed a positional username we persist it as their default so the next /daily - // call auto-resolves via the saved preference fallback inside AgentBuilderTool. - var savePreference = githubUsername is not null; - decision = AgentBuilderFlowDecision.ToolCall( - "create_daily_report", - JsonSerializer.Serialize(new - { - action = "create_agent", - template = "daily_report", - github_username = githubUsername, - save_github_username_preference = savePreference, - repositories, - schedule_cron = scheduleCron, - schedule_timezone = scheduleTimezone, - run_immediately = runImmediately, - conversation_id = NormalizeOptional(conversationId), - })); - return true; - } - - private static bool TryResolveSocialMedia( - IReadOnlyList tokens, - string? conversationId, - out AgentBuilderFlowDecision? decision) - { - decision = null; - if (tokens.Count == 1) - { - decision = AgentBuilderFlowDecision.DirectReply(BuildSocialMediaHelpText()); - return true; - } - - var args = ChannelTextCommandParser.ParseNamedArguments(tokens); - var topic = GetOptional(args, "topic") ?? FirstPositionalArgument(tokens); - if (string.IsNullOrWhiteSpace(topic)) - { - decision = AgentBuilderFlowDecision.DirectReply( - "topic is required.\n\n" + BuildSocialMediaHelpText()); - return true; - } - - if (!TryResolveSchedule(args, out var scheduleCron, out var scheduleTimezone, out var error)) - { - decision = AgentBuilderFlowDecision.DirectReply(error! + "\n\n" + BuildSocialMediaHelpText()); - return true; - } - - decision = AgentBuilderFlowDecision.ToolCall( - "create_social_media", - JsonSerializer.Serialize(new - { - action = "create_agent", - template = "social_media", - topic, - audience = GetOptional(args, "audience"), - style = GetOptional(args, "style"), - schedule_cron = scheduleCron, - schedule_timezone = scheduleTimezone, - run_immediately = ResolveRunImmediately(args), - conversation_id = NormalizeOptional(conversationId), - })); - return true; - } - - private static string? FirstPositionalArgument(IReadOnlyList tokens) - { - for (var i = 1; i < tokens.Count; i++) - { - var token = tokens[i]; - if (string.IsNullOrWhiteSpace(token)) - continue; - if (token.IndexOf('=', StringComparison.Ordinal) >= 0) - continue; - return token.Trim(); - } - return null; - } - private static bool TryResolveSimpleAgentAction( IReadOnlyList tokens, string action, @@ -296,58 +176,12 @@ private static bool TryResolveDeleteAgent( return true; } - private static MessageContent FormatCreateDailyReportResult(JsonElement root) => - AgentBuilderCardContent.FormatDailyReportToolReply(root); - - private static string FormatCreateSocialMediaResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return $"Create social media agent failed: {error}"; - - return BuildTextBlock( - "Social media agent registered.", - $"Agent ID: {ReadString(root, "agent_id") ?? "unknown-agent"}", - $"Workflow ID: {ReadString(root, "workflow_id") ?? "pending"}", - $"Next scheduled run: {ReadString(root, "next_scheduled_run") ?? "pending"}", - NormalizeOptional(ReadString(root, "note")), - "Approvals will arrive as interactive cards in this chat. Text commands such as /approve and /reject still work as fallback.", - "Next commands: /agents, /agent-status , /run-agent "); - } - - private static string FormatListTemplatesResult(JsonElement root) - { - if (TryReadError(root, out var error)) - return $"List templates failed: {error}"; - - if (!root.TryGetProperty("templates", out var templatesElement) || - templatesElement.ValueKind != JsonValueKind.Array || - templatesElement.GetArrayLength() == 0) - { - return "No templates available."; - } - - var lines = new List { "Available templates:" }; - foreach (var item in templatesElement.EnumerateArray()) - { - var name = ReadString(item, "name") ?? "unknown-template"; - var description = ReadString(item, "description") ?? "No description."; - lines.Add($"- {name}: {description}"); - } - - lines.Add(string.Empty); - lines.Add("Examples:"); - lines.Add(BuildDailyReportCommandExample()); - lines.Add(BuildSocialMediaCommandExample()); - return string.Join('\n', lines); - } - /// /// Renders /agent-status <agent_id> as an interactive card with action buttons /// (Run, Disable, Enable, Delete). Each button submits the corresponding /// agent_builder_action with the agent_id as an argument so /// can route the click to the existing tool action without - /// the user having to retype the id. Mirrors the card produced by the card-flow path so the - /// text-command and card-flow surfaces stay visually consistent. + /// the user having to retype the id. /// private static MessageContent FormatAgentStatusCard(JsonElement root) { @@ -386,10 +220,6 @@ private static MessageContent FormatAgentStatusCard(JsonElement root) Text = string.Join("\n", bodyLines), }); - // Lifecycle buttons mirror the legacy text "Next commands: ..." line. Disable and Enable - // are both shown so the user can flip status either direction without typing; the click - // handler enforces the invariants. Delete is marked danger so Lark renders it red and the - // user has a final visual confirm before submitting. var isRunning = string.Equals(status, SkillRunnerDefaults.StatusRunning, StringComparison.OrdinalIgnoreCase) || string.Equals(status, SkillRunnerDefaults.StatusError, StringComparison.OrdinalIgnoreCase); content.Actions.Add(BuildAgentScopedButton("Run Now", "run_agent", agentId, isPrimary: isRunning)); @@ -469,81 +299,12 @@ private static string FormatDeleteAgentResult(JsonElement root) "Run /agents to refresh the registry view."); } - private static bool TryResolveSchedule( - IReadOnlyDictionary args, - out string? scheduleCron, - out string scheduleTimezone, - out string? error) - { - scheduleCron = null; - error = null; - - scheduleTimezone = GetOptional(args, "schedule_timezone") ?? SkillRunnerDefaults.DefaultTimezone; - var rawCron = GetOptional(args, "schedule_cron"); - if (!string.IsNullOrWhiteSpace(rawCron)) - { - scheduleCron = rawCron; - return true; - } - - var rawTime = GetOptional(args, "schedule_time"); - var normalized = rawTime ?? DefaultScheduleTime; - if (!TimeOnly.TryParseExact( - normalized, - ["HH:mm", "H:mm"], - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var time)) - { - error = "schedule_time must use HH:mm, for example 09:00."; - return false; - } - - scheduleCron = $"{time.Minute} {time.Hour} * * *"; - return true; - } - - private static bool ResolveRunImmediately(IReadOnlyDictionary args) - { - var raw = GetOptional(args, "run_immediately"); - return !bool.TryParse(raw, out var parsed) || parsed; - } - - private static string? GetOptional(IReadOnlyDictionary args, string key) - { - if (!args.TryGetValue(key, out var raw)) - return null; - - return NormalizeOptional(raw); - } - private static bool TryReadError(JsonElement root, out string error) => AgentBuilderJson.TryReadError(root, out error); private static string? ReadString(JsonElement element, string propertyName) => AgentBuilderJson.TryReadString(element, propertyName); - private static string BuildDailyReportHelpText() => - BuildTextBlock( - "Daily report agent command", - "GitHub username can be passed explicitly, or omitted to reuse a saved preference when available.", - "Schedule defaults to 09:00 if schedule_time and schedule_cron are both omitted.", - $"Example: {BuildDailyReportCommandExample()}", - "Optional: github_username (otherwise uses your saved preference or connected GitHub login), repositories=owner/repo,owner/repo schedule_timezone=Asia/Singapore run_immediately=false"); - - private static string BuildSocialMediaHelpText() => - BuildTextBlock( - "Social media agent command", - "Required: topic plus either schedule_time or schedule_cron.", - $"Example: {BuildSocialMediaCommandExample()}", - "Optional: audience=\"Developers\" style=\"Confident and concise\" schedule_timezone=Asia/Singapore run_immediately=false"); - - private static string BuildDailyReportCommandExample() => - "/daily [github_username] schedule_time=09:00 repositories=owner/repo"; - - private static string BuildSocialMediaCommandExample() => - "/social-media topic=\"Launch update\" schedule_time=10:30 audience=\"Developers\" style=\"Confident and concise\""; - private static string BuildUnknownCommandReply( string command, ChannelSlashCommandRegistry? slashCommandRegistry) => @@ -552,9 +313,6 @@ private static string BuildUnknownCommandReply( { $"Unknown command: {command}", "Supported commands:", - BuildDailyReportCommandExample(), - BuildSocialMediaCommandExample(), - "/templates", "/agents", "/agent-status ", "/run-agent ", diff --git a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingNotFoundException.cs b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingNotFoundException.cs index 66b63dba9..31e0f05d1 100644 --- a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingNotFoundException.cs +++ b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingNotFoundException.cs @@ -12,9 +12,9 @@ namespace Aevatar.GAgents.Channel.Identity.Abstractions; /// /// Caller behaviour: /// -/// Outbound / turn path: prompt the sender to run /init. -/// Do NOT fall back to bot-owner credentials or any cached token -/// (ADR-0018 §Implementation Notes #4). +/// Binding-required commands: prompt the sender to run /init. +/// Normal LLM turns: treat the sender config as unavailable and fall +/// back to the bot owner's LLM credentials. /// /// public sealed class BindingNotFoundException : Exception diff --git a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingRevokedException.cs b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingRevokedException.cs index b5c7b4fc8..a0738c8f2 100644 --- a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingRevokedException.cs +++ b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingRevokedException.cs @@ -5,8 +5,8 @@ namespace Aevatar.GAgents.Channel.Identity.Abstractions; /// /// Thrown by when /// NyxID reports the binding as revoked (HTTP 400 invalid_grant). -/// Callers MUST event-source revoke the local binding actor and prompt the -/// sender to run /init again. See ADR-0018 Decision §invalid_grant. +/// Binding-required callers should prompt the sender to run /init +/// again; normal LLM turns may fall back to the bot owner's LLM credentials. /// public sealed class BindingRevokedException : Exception { diff --git a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingScopeMismatchException.cs b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingScopeMismatchException.cs index db2e99f12..a0416f16d 100644 --- a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingScopeMismatchException.cs +++ b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/BindingScopeMismatchException.cs @@ -5,8 +5,9 @@ namespace Aevatar.GAgents.Channel.Identity.Abstractions; /// /// Thrown by when /// NyxID reports that the existing binding cannot mint the requested scope -/// (HTTP 400 invalid_scope). The user must re-run /init so the -/// binding is recreated against the current OAuth client scopes. +/// (HTTP 400 invalid_scope). Binding-required callers should ask the +/// user to re-run /init; normal LLM turns may fall back to the bot +/// owner's LLM credentials. /// public sealed class BindingScopeMismatchException : Exception { diff --git a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/IExternalIdentityBindingQueryPort.cs b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/IExternalIdentityBindingQueryPort.cs index d1ec57e84..61f311202 100644 --- a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/IExternalIdentityBindingQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/IExternalIdentityBindingQueryPort.cs @@ -13,8 +13,9 @@ public interface IExternalIdentityBindingQueryPort /// /// Returns the active for the given external subject, /// or null when no active binding is materialized in the readmodel. - /// A miss MUST drive the caller to prompt the sender to /init; - /// callers MUST NOT fall back to bot-owner credentials or any cached token. + /// A miss means the sender has no usable per-user NyxID context. Callers + /// that require per-user state may prompt /init; normal LLM turns + /// may continue with bot-owner fallback credentials. /// Task ResolveAsync( ExternalSubjectRef externalSubject, diff --git a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs index 92746a029..e7b16063f 100644 --- a/agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs +++ b/agents/Aevatar.GAgents.Channel.Identity.Abstractions/INyxIdCapabilityBroker.cs @@ -40,9 +40,9 @@ Task RevokeBindingAsync( /// when NyxID reports /// invalid_grant on a previously-bound subject; throws /// when NyxID reports - /// invalid_scope for an existing binding. Callers MUST event-source - /// revoke the local binding actor on invalid_grant and prompt the sender - /// to re-run /init for both user-remediable cases. + /// invalid_scope for an existing binding. Binding-required callers + /// can prompt the sender to re-run /init; normal LLM turns can + /// continue with bot-owner fallback credentials. /// /// /// No active binding exists for the subject (never bound, or readmodel diff --git a/agents/Aevatar.GAgents.Channel.Identity/DependencyInjection/IdentityServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Channel.Identity/DependencyInjection/IdentityServiceCollectionExtensions.cs index a6892bcb1..c704ce93d 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/DependencyInjection/IdentityServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/DependencyInjection/IdentityServiceCollectionExtensions.cs @@ -84,6 +84,8 @@ public static IServiceCollection AddChannelIdentity( // inbound message's gate keeps re-sending the binding card. See // issue #549 follow-up observed 2026-05-01. services.TryAddSingleton(); + services.TryAddSingleton( + sp => sp.GetRequiredService()); // ─── Cluster-singleton OAuth client projection ─── services.AddProjectionMaterializationRuntimeCore< @@ -112,6 +114,18 @@ public static IServiceCollection AddChannelIdentity( // (production regression observed 2026-04-30 in aismart-app-mainnet). services.TryAddSingleton(); + // Endpoint filter for the operator /rebuild path — rejects unauthenticated + // callers before model binding/DI resolution kicks in. + services.TryAddTransient(); + + // ─── Operator admin surface (rebuild endpoint, issue #549) ─── + // Bound from configuration when present; absence keeps the rebuild + // endpoint fail-secure (503 with "rebuild not configured"). Production + // sets the token via env var ChannelIdentity__Admin__RebuildToken. + var adminOptions = services.AddOptions(); + if (configuration is not null) + adminOptions.Bind(configuration.GetSection(AevatarOAuthAdminOptions.SectionName)); + // ─── Broker (self-bootstrapping, no appsettings dependency) ─── // Register broker as a *singleton* and inject IHttpClientFactory so // each call resolves a fresh HttpClient backed by the factory's diff --git a/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs b/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs index 4900c6392..dc608458c 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Endpoints/IdentityOAuthEndpoints.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Core; using Aevatar.GAgents.Channel.Abstractions; @@ -8,7 +10,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aevatar.GAgents.Channel.Identity.Endpoints; @@ -19,6 +23,14 @@ namespace Aevatar.GAgents.Channel.Identity.Endpoints; public static class IdentityOAuthEndpoints { private static readonly TimeSpan ProjectionWaitTimeout = TimeSpan.FromSeconds(3); + // 15s leaves comfortable margin under typical reverse-proxy idle-timeout + // budgets (Cloudflare 100s, AWS ALB 60s default, stricter corporate + // proxies 30s) so the operator does not hit a 504 race on the happy path + // even when the readmodel takes a few seconds to materialize. Callers + // that hit the timeout still get a 202 with a poll URL — see issue #549 + // PR #570 review (mimo-v2.5-pro / glm-5.1). + private static readonly TimeSpan RebuildObservationTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan RebuildObservationPollDelay = TimeSpan.FromMilliseconds(250); private const int MaxWebhookBodyBytes = 64 * 1024; public static IEndpointRouteBuilder MapIdentityOAuthEndpoints(this IEndpointRouteBuilder app) @@ -34,6 +46,18 @@ public static IEndpointRouteBuilder MapIdentityOAuthEndpoints(this IEndpointRout app.MapGet("/api/oauth/aevatar-client/status", HandleAevatarOAuthClientStatusAsync) .WithTags("ChannelIdentity") .AllowAnonymous(); + // Operator-only: rebuild the cluster-singleton OAuth client snapshot + // to point at an admin-supplied client_id (issue #549 production + // unblock). Auth is by static admin token header — see + // AevatarOAuthAdminOptions. AllowAnonymous because the auth check is + // done inline; no ASP.NET auth handler is wired for this module. The + // RebuildAuthEndpointFilter rejects unauthenticated callers BEFORE + // model binding / DI resolution so a flooded admin-token-less request + // does not run through deserialization and DI on every call. + app.MapPost("/api/oauth/aevatar-client/rebuild", HandleAevatarOAuthClientRebuildAsync) + .WithTags("ChannelIdentity") + .AddEndpointFilter() + .AllowAnonymous(); return app; } @@ -44,11 +68,12 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( [FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, + [FromQuery] string? format, [FromServices] INyxIdBrokerCallbackClient brokerCallback, [FromServices] IExternalIdentityBindingQueryPort queryPort, [FromServices] IActorRuntime actorRuntime, [FromServices] IProjectionReadinessPort projectionReadiness, - [FromServices] ExternalIdentityBindingProjectionPort bindingProjectionPort, + [FromServices] IExternalIdentityBindingProjectionPort bindingProjectionPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { @@ -159,7 +184,7 @@ internal static async Task HandleNyxIdOAuthCallbackAsync( // orphan. Best-effort revoke at NyxID before responding so the // orphan does not accumulate at NyxID with no local reference. await TryRevokeOrphanBindingAsync(brokerCallback, exchange.BindingId, logger, ct).ConfigureAwait(false); - return Results.Ok(new { status = "already_bound", detail = "已绑定 NyxID 账号,可以回到 Lark 继续对话" }); + return RenderBoundSuccess(displayName: null, alreadyBound: true, format: format); } var actor = await TryActivateActorAsync(actorRuntime, actorId, logger, ct).ConfigureAwait(false); @@ -252,7 +277,7 @@ await projectionReadiness resolvedAfterTimeout.Value, exchange.BindingId); await TryRevokeOrphanBindingAsync(brokerCallback, exchange.BindingId, logger, ct).ConfigureAwait(false); - return Results.Ok(new { status = "already_bound", detail = "已绑定 NyxID 账号,可以回到 Lark 继续对话" }); + return RenderBoundSuccess(displayName: null, alreadyBound: true, format: format); } logger.LogWarning( @@ -271,13 +296,7 @@ await projectionReadiness "Bound external identity {Platform}:{Tenant}:{User} -> binding_id={BindingId}", subject.Platform, subject.Tenant, subject.ExternalUserId, exchange.BindingId); - return Results.Ok(new - { - status = "bound", - detail = displayName is null - ? "已绑定 NyxID 账号,可以回到 Lark 继续对话" - : $"已绑定 NyxID 账号({displayName}),可以回到 Lark 继续对话", - }); + return RenderBoundSuccess(displayName, alreadyBound: false, format: format); } // ─── Status endpoint ─── @@ -328,6 +347,315 @@ internal static async Task HandleAevatarOAuthClientStatusAsync( } } + // ─── Operator rebuild ─── + + /// + /// Body for POST /api/oauth/aevatar-client/rebuild. The operator + /// supplies a fresh client_id (typically created via NyxID admin + /// after a wedge — see issue #549) and the actor pins its snapshot to + /// it. redirect_uri and oauth_scope are NOT operator- + /// supplied fields: the endpoint always uses + /// and + /// respectively, + /// otherwise the next bootstrap pass would observe drift and re-DCR + /// away the freshly-pinned client (PR #570 review consensus on the + /// drift bug + URL-validation surface). + /// + public sealed record RebuildAevatarOAuthClientRequest( + string? client_id, + long? client_id_issued_at_unix); + + internal static Task HandleAevatarOAuthClientRebuildAsync( + HttpContext http, + [FromBody] RebuildAevatarOAuthClientRequest? body, + [FromServices] IOptions adminOptions, + [FromServices] IAevatarOAuthClientProvider provider, + [FromServices] AevatarOAuthClientProjectionPort projectionPort, + [FromServices] IActorRuntime actorRuntime, + [FromServices] IActorDispatchPort actorDispatchPort, + [FromServices] ILoggerFactory loggerFactory, + CancellationToken ct) => + HandleAevatarOAuthClientRebuildCoreAsync( + http, + body, + adminOptions, + provider, + projectionPort, + actorRuntime, + actorDispatchPort, + loggerFactory, + observationTimeout: RebuildObservationTimeout, + observationPollDelay: RebuildObservationPollDelay, + ct); + + /// + /// Implementation seam exposed for tests so the readmodel-propagation + /// timeout can be tightened without waiting the full operator-grade + /// 30-second budget on every assertion. Production routes call the + /// thin overload above with the canonical defaults. + /// + internal static async Task HandleAevatarOAuthClientRebuildCoreAsync( + HttpContext http, + RebuildAevatarOAuthClientRequest? body, + IOptions adminOptions, + IAevatarOAuthClientProvider provider, + AevatarOAuthClientProjectionPort projectionPort, + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + ILoggerFactory loggerFactory, + TimeSpan observationTimeout, + TimeSpan observationPollDelay, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Aevatar.Channel.Identity.OAuthRebuild"); + + var configuredToken = adminOptions.Value.RebuildToken; + if (string.IsNullOrEmpty(configuredToken)) + { + logger.LogWarning( + "Rebuild endpoint invoked but ChannelIdentity:Admin:RebuildToken is unset; refusing fail-secure."); + return Results.Json(new + { + error = "rebuild_not_configured", + detail = "ChannelIdentity:Admin:RebuildToken is unset. Configure it (env var ChannelIdentity__Admin__RebuildToken) and redeploy before retrying.", + }, statusCode: StatusCodes.Status503ServiceUnavailable); + } + + if (!http.Request.Headers.TryGetValue(AevatarOAuthAdminOptions.RebuildTokenHeader, out var presented) + || !ConstantTimeEquals(configuredToken, presented.ToString())) + { + logger.LogWarning( + "Rebuild endpoint rejected: missing or invalid {Header}.", + AevatarOAuthAdminOptions.RebuildTokenHeader); + return Results.Unauthorized(); + } + + if (body is null || string.IsNullOrWhiteSpace(body.client_id)) + { + return Results.BadRequest(new + { + error = "client_id_required", + detail = "Body must include client_id (the NyxID-issued OAuth client_id this cluster should pin to).", + }); + } + + var authority = NyxIdAuthorityResolver.Resolve(logger); + var redirectUri = NyxIdRedirectUriResolver.Resolve(logger); + var oauthScope = AevatarOAuthClientScopes.AuthorizationScope; + + // Validate Unix-seconds before dispatching: AevatarOAuthClient + // ProjectionProvider later calls DateTimeOffset.FromUnixTimeSeconds + // on the persisted value, which throws ArgumentOutOfRangeException + // for values like long.MaxValue. Surface the bad input as a 400 + // here instead of letting the read path crash on the next status + // poll (codex P1 on PR #570). + long issuedAtUnix; + if (body.client_id_issued_at_unix is { } supplied) + { + try + { + _ = DateTimeOffset.FromUnixTimeSeconds(supplied); + } + catch (ArgumentOutOfRangeException) + { + return Results.BadRequest(new + { + error = "client_id_issued_at_unix_invalid", + detail = "client_id_issued_at_unix must be a Unix-seconds value within DateTimeOffset range.", + }); + } + issuedAtUnix = supplied; + } + else + { + issuedAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + } + + // Activate the projection scope first so the projector subscribes to + // the actor's committed events before we dispatch the provision + // command — same pattern as AevatarOAuthClientBootstrapService. + // Without this the readmodel never updates and the wait loop below + // times out even though the actor committed correctly. + await projectionPort + .EnsureProjectionForActorAsync(AevatarOAuthClientGAgent.WellKnownId, ct) + .ConfigureAwait(false); + + // Dispatch through IActorDispatchPort to match /unbind and the rest of the + // codebase. CLAUDE.md "Runtime 与 Dispatch 分责" forbids inline + // actor.HandleEventAsync from app/host code — that bypasses the inbox + // serialization guarantees and any middleware/logging the dispatch port + // owns. The rebuild path deliberately skips DCR mediation (operator + // already holds the client_id), so we publish the provision command + // directly to the cluster-singleton actor and let the inbox process it. + var provisionEnvelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(new ProvisionAevatarOAuthClientCommand + { + ClientId = body.client_id!.Trim(), + ClientIdIssuedAtUnix = issuedAtUnix, + NyxidAuthority = authority, + OauthScope = oauthScope, + RedirectUri = redirectUri, + }), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = AevatarOAuthClientGAgent.WellKnownId }, + }, + }; + try + { + await actorDispatchPort + .DispatchAsync(AevatarOAuthClientGAgent.WellKnownId, provisionEnvelope, ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Rebuild endpoint failed to dispatch ProvisionAevatarOAuthClientCommand."); + return Results.Json(new + { + error = "actor_dispatch_failed", + detail = "Failed to dispatch the provision command to the OAuth client actor. Check silo logs.", + }, statusCode: StatusCodes.Status503ServiceUnavailable); + } + + logger.LogWarning( + "Operator rebuild dispatched for AevatarOAuthClientGAgent: client_id={ClientId}, authority={Authority}, redirect_uri={RedirectUri}.", + body.client_id, + authority, + redirectUri); + + var observed = await WaitForRebuildObservedAsync( + provider, + expectedClientId: body.client_id!.Trim(), + expectedAuthority: authority, + expectedRedirectUri: redirectUri, + expectedOauthScope: oauthScope, + timeout: observationTimeout, + pollDelay: observationPollDelay, + ct) + .ConfigureAwait(false); + if (observed is null) + { + return Results.Json(new + { + status = "rebuild_pending_propagation", + detail = $"Provision command dispatched but readmodel has not yet caught up within {observationTimeout.TotalSeconds:n0}s. Re-poll /api/oauth/aevatar-client/status; it will reflect the new client_id once the projection materializes.", + }, statusCode: StatusCodes.Status202Accepted); + } + + return Results.Ok(new + { + status = "rebuilt", + client_id = observed.ClientId, + client_id_issued_at = observed.ClientIdIssuedAt, + nyxid_authority = observed.NyxIdAuthority, + redirect_uri_registered = observed.RedirectUri, + oauth_scope_registered = observed.OauthScope, + broker_capability_observed = observed.BrokerCapabilityObserved, + detail = "OAuth client rebuilt. New /init flows will use the supplied client_id; the previous client_id is now an orphan at NyxID — delete it via NyxID admin to keep the registration list clean.", + }); + } + + private static async Task WaitForRebuildObservedAsync( + IAevatarOAuthClientProvider provider, + string expectedClientId, + string expectedAuthority, + string expectedRedirectUri, + string expectedOauthScope, + TimeSpan timeout, + TimeSpan pollDelay, + CancellationToken ct) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + + try + { + var snapshot = await provider.GetAsync(ct).ConfigureAwait(false); + if (string.Equals(snapshot.ClientId, expectedClientId, StringComparison.Ordinal) + && string.Equals(snapshot.NyxIdAuthority, expectedAuthority, StringComparison.Ordinal) + && string.Equals(snapshot.RedirectUri, expectedRedirectUri, StringComparison.Ordinal) + && string.Equals(snapshot.OauthScope, expectedOauthScope, StringComparison.Ordinal)) + { + return snapshot; + } + } + catch (AevatarOAuthClientNotProvisionedException) + { + // Projection has not yet materialized the very first state + // root for this actor — possible on a brand-new cluster + // where rebuild is the first provisioning event. + } + + await Task.Delay(pollDelay, ct).ConfigureAwait(false); + } + return null; + } + + /// + /// Length-tolerant constant-time string compare. FixedTimeEquals + /// itself returns false on length mismatch in O(1), which leaks the + /// configured token's length to a timing observer — for an admin + /// break-glass surface keyed on a high-entropy token this residual leak + /// is acceptable (the attacker still has to brute-force the content). + /// The earlier shape returned early on right is null; the call + /// site short-circuits via TryGetValue so right is never null in + /// practice, but we still treat null as empty to keep the helper's + /// signature constant-time-uniform (PR #570 review, 4-model consensus). + /// + /// + /// SCOPE: this helper is intentionally private static and tied to + /// the rebuild admin-token check. It is NOT for general callers — if a new + /// caller needs constant-time string compare for a lower-entropy secret, + /// the length leak above becomes material; do not promote this to + /// internal/public without first replacing it with a length-padding scheme. + /// + private static bool ConstantTimeEquals(string left, string? right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right ?? string.Empty); + return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + /// + /// Endpoint filter that performs the rebuild admin-token check before model binding + /// and per-request DI activation kick in. Without this filter the handler method + /// still rejects unauthenticated callers (it re-runs the same check inline), but + /// every unauthenticated POST would needlessly deserialize the body and resolve + /// IActorRuntime / IActorDispatchPort etc. — a small but real DoS amplifier on a + /// /rebuild that is supposed to be operator-only break-glass. + /// + internal sealed class RebuildAuthEndpointFilter : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var http = context.HttpContext; + var adminOptions = http.RequestServices + .GetRequiredService>() + .Value; + var configuredToken = adminOptions.RebuildToken; + if (string.IsNullOrEmpty(configuredToken)) + { + // Fall through to the handler so it can return the standard + // "rebuild_not_configured" 503; we don't want this filter to short-circuit + // and bypass that explicit operator-facing error. + return await next(context).ConfigureAwait(false); + } + + if (!http.Request.Headers.TryGetValue(AevatarOAuthAdminOptions.RebuildTokenHeader, out var presented) + || !ConstantTimeEquals(configuredToken, presented.ToString())) + { + return Results.Unauthorized(); + } + + return await next(context).ConfigureAwait(false); + } + } + // ─── Broker revocation webhook ─── internal static async Task HandleBrokerRevocationWebhookAsync( @@ -486,4 +814,85 @@ private static byte[] Base64UrlDecode(string value) } return Convert.FromBase64String(padded); } + + /// + /// Render the user-facing success page returned in the OAuth-callback + /// response. Issue #513 phase 1 asked for a "callback success → please pick + /// a model" prompt. The full version is a card update pushed back into + /// Lark, which requires capturing the /init card's adapter-owned message + /// id and passing it through the OAuth state token — substantial new + /// design surface left as a follow-up. This page is the browser-side + /// substitute the user sees immediately after the OAuth redirect, and it + /// names the next-step commands (/model, /whoami) explicitly + /// so the user is not left guessing what to type back in Lark. + /// + /// + /// Display name comes from the id_token "name" / sub claim; HTML-encoded + /// before interpolation so a malicious id_token cannot inject markup. + /// Other error paths in the callback intentionally keep returning JSON for + /// ops/programmatic consumers. + /// + internal static IResult RenderBoundSuccessHtml(string? displayName, bool alreadyBound) => + RenderBoundSuccess(displayName, alreadyBound, format: null); + + /// + /// Render the post-binding success response. Default is the HTML browser page that + /// users land on after clicking the OAuth approve button. Programmatic consumers + /// (CLI, SDK, integration tests) opt into a JSON envelope by passing + /// ?format=json on the callback URL — the same shape the endpoint returned + /// before the HTML render landed (PR #570 review #24). + /// + internal static IResult RenderBoundSuccess(string? displayName, bool alreadyBound, string? format) + { + if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) + { + return Results.Json(new + { + status = "bound", + already_bound = alreadyBound, + display_name = string.IsNullOrWhiteSpace(displayName) ? null : displayName, + }); + } + + return RenderBoundSuccessHtmlInternal(displayName, alreadyBound); + } + + internal static IResult RenderBoundSuccessHtmlInternal(string? displayName, bool alreadyBound) + { + var badge = alreadyBound ? "已绑定" : "绑定成功"; + var heading = alreadyBound ? "NyxID 账号已绑定" : "已绑定 NyxID 账号"; + var displayLine = string.IsNullOrWhiteSpace(displayName) + ? string.Empty + : $"

账号:{System.Net.WebUtility.HtmlEncode(displayName)}

"; + var body = alreadyBound + ? "

当前账号已经完成绑定,无需重复操作。可以关闭此页,回到 Lark 继续对话。

" + : "

可以关闭此页,回到 Lark 继续对话。

"; + + var html = $@" + + + + +NyxID 绑定 — {badge} + + + +{badge} +

{heading}

+{displayLine} +{body} +
+下一步
+回到 Lark 后,发送 /model 选择想用的模型,或 /whoami 查看当前绑定状态。 +
+ +"; + return Results.Content(html, "text/html; charset=utf-8"); + } } diff --git a/agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs b/agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs index 2fbbe4a21..cbfea5d09 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/ExternalIdentityBindingGAgent.cs @@ -83,8 +83,8 @@ public async Task HandleCommitBinding(CommitBindingCommand cmd) // was never activated (issue #549 follow-up: the binding scope // missed an EnsureProjectionForActorAsync wiring while every // other GAgent had one) leaves the readmodel empty, the OAuth - // callback's readiness wait times out, and the next inbound - // message's binding gate keeps re-sending the user back to /init. + // callback's readiness wait times out, and binding-required + // commands keep re-sending the user back to /init. // Apply is identity, so the binding facts are not mutated by // this event. await PersistDomainEventAsync(new ExternalIdentityBindingProjectionRebuildRequestedEvent @@ -118,11 +118,13 @@ await PersistDomainEventAsync(new ExternalIdentityBoundEvent } /// - /// Revokes the active binding. NO-OP when state has no active binding - /// (e.g. concurrent /unbind, or revoke-after-revoke from invalid_grant - /// retry). Caller must have already invoked the NyxID-side revoke - /// (or observed invalid_grant) — this command only transitions - /// local state. + /// Revokes the active binding. When state has no active binding (for + /// example concurrent /unbind, revoke-after-revoke from + /// invalid_grant, or remote-side self-heal after projection drift), + /// emits a no-op rebuild event so the readmodel is overwritten from the + /// actor's authoritative empty state. Caller must have already invoked + /// the NyxID-side revoke (or observed invalid_grant) — this command + /// only transitions local state. /// [EventHandler] public async Task HandleRevokeBinding(RevokeBindingCommand cmd) @@ -138,26 +140,37 @@ public async Task HandleRevokeBinding(RevokeBindingCommand cmd) if (!IsCommandSubjectMatchingActor(cmd.ExternalSubject)) return; + // Use the explicit "unspecified" sentinel so the persisted audit + // trail distinguishes "caller did not supply a reason" from a + // missing/empty value. The event Reason field is non-nullable in + // proto3 (defaults to ""), so the sentinel substitution lives at + // the boundary here rather than relying on per-call interpretation + // (kimi-k2p6 L109 / L124 5/5 consensus). + var reason = string.IsNullOrWhiteSpace(cmd.Reason) ? "unspecified" : cmd.Reason; + if (string.IsNullOrEmpty(State.BindingId)) { + // Remote revocation self-heal can land here when the actor state + // is already empty but the readmodel still contains an old active + // binding. Persisting an identity event republishes the committed + // state root, allowing the projector to overwrite that stale + // document without inventing query-time repair logic. + await PersistDomainEventAsync(new ExternalIdentityBindingProjectionRebuildRequestedEvent + { + Reason = $"revoke_without_active_binding:{reason}", + RequestedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }); Logger.LogInformation( - "RevokeBinding skipped: no active binding for {Platform}:{Tenant}:{User}", + "RevokeBinding found no active binding for {Platform}:{Tenant}:{User}; rebuild requested so the projector materializes the authoritative empty state (reason={Reason})", cmd.ExternalSubject.Platform, cmd.ExternalSubject.Tenant, - cmd.ExternalSubject.ExternalUserId); + cmd.ExternalSubject.ExternalUserId, + reason); return; } var revokedBindingId = State.BindingId; - // Use the explicit "unspecified" sentinel so the persisted audit - // trail distinguishes "caller did not supply a reason" from a - // missing/empty value. The event Reason field is non-nullable in - // proto3 (defaults to ""), so the sentinel substitution lives at - // the boundary here rather than relying on per-call interpretation - // (kimi-k2p6 L109 / L124 5/5 consensus). - var reason = string.IsNullOrWhiteSpace(cmd.Reason) ? "unspecified" : cmd.Reason; - await PersistDomainEventAsync(new ExternalIdentityBindingRevokedEvent { ExternalSubject = cmd.ExternalSubject.Clone(), diff --git a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionPort.cs b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionPort.cs index 789cf2653..f530659a4 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionPort.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionPort.cs @@ -17,13 +17,14 @@ namespace Aevatar.GAgents.Channel.Identity; /// Pre-this-port, the binding scope was never activated for any actor and /// every legacy cluster's binding readmodel was empty even when the /// actor's State held an active binding — the OAuth callback's readiness -/// wait would time out, and the next inbound message's binding gate would -/// keep sending the user back to /init forever (issue #549 follow-up +/// wait would time out, and binding-required commands would keep sending +/// the user back to /init forever (issue #549 follow-up /// observed 2026-05-01: CommitBinding discarded: already bound /// without a corresponding readmodel materialization). /// public sealed class ExternalIdentityBindingProjectionPort - : MaterializationProjectionPortBase + : MaterializationProjectionPortBase, + IExternalIdentityBindingProjectionPort { public const string ProjectionKind = "external-identity-binding"; diff --git a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionQueryPort.cs b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionQueryPort.cs index 17c099775..dc737a605 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionQueryPort.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionQueryPort.cs @@ -8,8 +8,9 @@ namespace Aevatar.GAgents.Channel.Identity; /// Reads through the projection /// document reader (Elasticsearch / in-memory provider). No event-store replay, /// no actor state mirror, no query-time priming — see ADR-0018 §Projection -/// Readiness. A miss returns null; callers MUST drive the sender to -/// /init rather than fall back to bot-owner credentials. +/// Readiness. A miss returns null; binding-required command handlers can +/// prompt /init, while normal LLM turns may fall back to bot-owner +/// credentials. /// public sealed class ExternalIdentityBindingProjectionQueryPort : IExternalIdentityBindingQueryPort diff --git a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionReadinessPort.cs b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionReadinessPort.cs index 7ad020a53..102103012 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionReadinessPort.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjectionReadinessPort.cs @@ -57,6 +57,8 @@ expectedBindingId is null private static bool Matches(ExternalIdentityBindingDocument? document, string? expectedBindingId) { + if (expectedBindingId is null && document is null) + return true; if (document is null) return false; if (expectedBindingId is null) diff --git a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjector.cs b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjector.cs index bb3b77112..3247aa73e 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjector.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Projection/ExternalIdentityBindingProjector.cs @@ -3,6 +3,8 @@ using Aevatar.CQRS.Projection.Runtime.Abstractions; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.GAgents.Channel.Identity; @@ -14,18 +16,31 @@ namespace Aevatar.GAgents.Channel.Identity; /// the write dispatcher. Read side (`IExternalIdentityBindingQueryPort`) /// reads the same documents — see ADR-0018 §Projection Readiness. /// +/// +/// READMODEL CONTRACT: when state.BindingId is empty (revoked / never bound), +/// the projector DELETES the document rather than upserting an inactive record. This +/// is a deliberate semantic change from earlier builds that left an inactive document +/// behind: IExternalIdentityBindingQueryPort.ResolveAsync returns null +/// for revoked bindings now, which lets ExternalIdentityBindingProjectionReadinessPort.Matches +/// match the (null, null) tuple cleanly. Downstream consumers that want the +/// audit history (e.g. admin dashboards) must consume the committed-event log directly +/// — they cannot rely on a tombstone in the readmodel. +/// public sealed class ExternalIdentityBindingProjector : ICurrentStateProjectionMaterializer { private readonly IProjectionWriteDispatcher _writeDispatcher; private readonly IProjectionClock _clock; + private readonly ILogger _logger; public ExternalIdentityBindingProjector( IProjectionWriteDispatcher writeDispatcher, - IProjectionClock clock) + IProjectionClock clock, + ILogger? logger = null) { _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _logger = logger ?? NullLogger.Instance; } public async ValueTask ProjectAsync( @@ -56,6 +71,17 @@ public async ValueTask ProjectAsync( UpdatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow), }; + if (string.IsNullOrEmpty(document.BindingId)) + { + _logger.LogWarning( + "Deleting external identity binding document {DocumentId} because projected BindingId is empty. event={EventId}, version={Version}", + document.Id, + document.LastEventId, + document.StateVersion); + await _writeDispatcher.DeleteAsync(document.Id, ct); + return; + } + await _writeDispatcher.UpsertAsync(document, ct); } } diff --git a/agents/Aevatar.GAgents.Channel.Identity/Projection/IExternalIdentityBindingProjectionPort.cs b/agents/Aevatar.GAgents.Channel.Identity/Projection/IExternalIdentityBindingProjectionPort.cs new file mode 100644 index 000000000..ddcf8bb7a --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Identity/Projection/IExternalIdentityBindingProjectionPort.cs @@ -0,0 +1,19 @@ +using Aevatar.CQRS.Projection.Core.Orchestration; + +namespace Aevatar.GAgents.Channel.Identity; + +/// +/// Abstraction for activating the projection materialization scope for a per-(platform, +/// tenant, external_user_id) . Consumers +/// (OAuth endpoints, identity slash-command self-heal) must depend on this interface +/// per CLAUDE.md "依赖反转" rather than the concrete +/// — that gives the host a seam to +/// swap implementations (e.g. fire-and-forget self-heal in tests vs. a real activation +/// service in production). +/// +public interface IExternalIdentityBindingProjectionPort +{ + Task EnsureProjectionForActorAsync( + string actorId, + CancellationToken ct = default); +} diff --git a/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthAdminOptions.cs b/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthAdminOptions.cs new file mode 100644 index 000000000..1b4d4b8fb --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthAdminOptions.cs @@ -0,0 +1,35 @@ +namespace Aevatar.GAgents.Channel.Identity; + +/// +/// Operator credentials for the cluster-singleton OAuth client admin +/// surface. Currently only protects the rebuild endpoint +/// (POST /api/oauth/aevatar-client/rebuild) — see issue #549 for the +/// production wedge that motivated it. +/// +/// +/// Bound from configuration section ChannelIdentity:Admin. When +/// is empty the rebuild endpoint refuses to +/// run (503), so a misconfigured cluster is fail-secure rather than +/// fail-open. Production deploys set the token via env var +/// ChannelIdentity__Admin__RebuildToken; tests/dev clusters may +/// leave it unset and the endpoint stays disabled. +/// +public sealed class AevatarOAuthAdminOptions +{ + /// + /// Configuration section name under . + /// + public const string SectionName = "ChannelIdentity:Admin"; + + /// + /// Header callers send the rebuild token in. Constant-time compared to + /// ; mismatch returns 401. + /// + public const string RebuildTokenHeader = "X-Aevatar-Admin-Token"; + + /// + /// Shared secret required on the rebuild endpoint. Empty disables the + /// endpoint entirely (fail-secure default). + /// + public string RebuildToken { get; set; } = string.Empty; +} diff --git a/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthClientGAgent.cs b/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthClientGAgent.cs index a4c538a84..85309562b 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthClientGAgent.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Provisioning/AevatarOAuthClientGAgent.cs @@ -289,9 +289,19 @@ private Task AbsorbPeerHmacSeedAsync(EventStoreOptimisticConcurrencyExcept /// production bootstrap path uses /// instead so the actor (not the /// caller) mediates the DCR call. Idempotent: re-issuing the same - /// triple is a no-op. Always seeds a fresh HMAC key when the state has - /// none — bootstrap and provisioning are single-step. + /// snapshot (client_id + authority + redirect_uri + oauth_scope) is a + /// no-op. Always seeds a fresh HMAC key when the state has none — + /// bootstrap and provisioning are single-step. /// + /// + /// The same-snapshot check covers redirect_uri + oauth_scope on top of + /// client_id + authority because the operator-rebuild path + /// (POST /api/oauth/aevatar-client/rebuild, issue #549) must be + /// able to heal a wedged actor whose state has the right client_id but + /// stale or empty redirect_uri / oauth_scope — leaving those drifted + /// would let the next bootstrap re-DCR and replace the operator's + /// freshly-pinned client_id with a new (orphan-creating) one. + /// [EventHandler] public async Task HandleProvision(ProvisionAevatarOAuthClientCommand cmd) { @@ -307,9 +317,21 @@ public async Task HandleProvision(ProvisionAevatarOAuthClientCommand cmd) return; } - var sameClient = string.Equals(State.ClientId, cmd.ClientId, StringComparison.Ordinal) - && string.Equals(State.NyxidAuthority, cmd.NyxidAuthority, StringComparison.Ordinal); - if (!sameClient) + // Empty cmd field = "field not supplied by this caller", NOT "set + // to empty". Otherwise a legacy / pre-redirect_uri caller (e.g. + // ProvisionAevatarOAuthClientCommand v1 wire-compatibility, manual + // operator scripts that only know client_id + authority) would + // overwrite previously-persisted redirect_uri / oauth_scope with + // "" — and the next bootstrap pass would observe the cleared + // value, detect drift, re-DCR the freshly-pinned client, and + // rotate it away. Codex P1 on PR #570. + var redirectUri = string.IsNullOrEmpty(cmd.RedirectUri) ? State.RedirectUri : cmd.RedirectUri; + var oauthScope = string.IsNullOrEmpty(cmd.OauthScope) ? State.OauthScope : cmd.OauthScope; + var sameSnapshot = string.Equals(State.ClientId, cmd.ClientId, StringComparison.Ordinal) + && string.Equals(State.NyxidAuthority, cmd.NyxidAuthority, StringComparison.Ordinal) + && string.Equals(State.RedirectUri, redirectUri, StringComparison.Ordinal) + && string.Equals(State.OauthScope, oauthScope, StringComparison.Ordinal); + if (!sameSnapshot) { await PersistDomainEventAsync(new AevatarOAuthClientProvisionedEvent { @@ -317,12 +339,14 @@ await PersistDomainEventAsync(new AevatarOAuthClientProvisionedEvent ClientIdIssuedAtUnix = cmd.ClientIdIssuedAtUnix, NyxidAuthority = cmd.NyxidAuthority, PersistedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - OauthScope = cmd.OauthScope ?? string.Empty, + OauthScope = oauthScope, + RedirectUri = redirectUri, }); Logger.LogInformation( - "Provisioned aevatar OAuth client: client_id={ClientId}, authority={Authority}", + "Provisioned aevatar OAuth client: client_id={ClientId}, authority={Authority}, redirect_uri={RedirectUri}", cmd.ClientId, - cmd.NyxidAuthority); + cmd.NyxidAuthority, + string.IsNullOrEmpty(redirectUri) ? "" : redirectUri); } if (State.HmacKey.Length == 0) diff --git a/agents/Aevatar.GAgents.Channel.Identity/Slash/UnbindChannelSlashCommandHandler.cs b/agents/Aevatar.GAgents.Channel.Identity/Slash/UnbindChannelSlashCommandHandler.cs index bbbeac6e3..4a529aff0 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Slash/UnbindChannelSlashCommandHandler.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Slash/UnbindChannelSlashCommandHandler.cs @@ -16,16 +16,16 @@ namespace Aevatar.GAgents.Channel.Identity.Slash; public sealed class UnbindChannelSlashCommandHandler : IChannelSlashCommandHandler { private readonly INyxIdCapabilityBroker _broker; - private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; private readonly ILogger _logger; public UnbindChannelSlashCommandHandler( INyxIdCapabilityBroker broker, - IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, ILogger logger) { _broker = broker ?? throw new ArgumentNullException(nameof(broker)); - _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -70,9 +70,6 @@ public UnbindChannelSlashCommandHandler( { try { - var actor = await _actorRuntime - .CreateAsync(actorId, ct) - .ConfigureAwait(false); var envelope = new EventEnvelope { Id = Guid.NewGuid().ToString("N"), @@ -82,12 +79,9 @@ public UnbindChannelSlashCommandHandler( ExternalSubject = context.Subject.Clone(), Reason = "user_unbind", }), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actorId }, - }, + Route = EnvelopeRouteSemantics.CreateDirect("channel.identity.unbind", actorId), }; - await actor.HandleEventAsync(envelope, ct).ConfigureAwait(false); + await _actorDispatchPort.DispatchAsync(actorId, envelope, ct).ConfigureAwait(false); localDispatchError = null; break; } diff --git a/agents/Aevatar.GAgents.Channel.Identity/Slash/WhoamiChannelSlashCommandHandler.cs b/agents/Aevatar.GAgents.Channel.Identity/Slash/WhoamiChannelSlashCommandHandler.cs index 6d22caa1a..1d8302fda 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/Slash/WhoamiChannelSlashCommandHandler.cs +++ b/agents/Aevatar.GAgents.Channel.Identity/Slash/WhoamiChannelSlashCommandHandler.cs @@ -4,15 +4,17 @@ namespace Aevatar.GAgents.Channel.Identity.Slash; /// -/// /whoami — show the inbound sender their current binding state. Always -/// requires a binding; the runner short-circuits unbound senders to the -/// /init prompt before invoking the handler. +/// /whoami — show the inbound sender their current binding state. Issue #513 +/// Phase 6 specifies /init, /unbind, and /whoami do NOT +/// require a binding so an unbound sender can introspect their own state +/// without being bounced through the binding gate. Bound senders see masked +/// binding info; unbound senders see "未绑定" with a /init hint. /// public sealed class WhoamiChannelSlashCommandHandler : IChannelSlashCommandHandler { public string Name => "whoami"; - public bool RequiresBinding => true; + public bool RequiresBinding => false; public ChannelSlashCommandUsage Usage => new( Name, @@ -28,13 +30,21 @@ public sealed class WhoamiChannelSlashCommandHandler : IChannelSlashCommandHandl ? context.SenderId : context.SenderName; - var lines = new[] - { - $"已绑定 NyxID 账号。", - $"- 平台账号:{senderName}", - $"- Binding ID:{Mask(bindingId)}", - $"- 平台:{context.Subject.Platform}", - }; + var lines = string.IsNullOrEmpty(bindingId) + ? new[] + { + "未绑定 NyxID 账号。", + $"- 平台账号:{senderName}", + $"- 平台:{context.Subject.Platform}", + "发送 /init 完成绑定。", + } + : new[] + { + "已绑定 NyxID 账号。", + $"- 平台账号:{senderName}", + $"- Binding ID:{Mask(bindingId)}", + $"- 平台:{context.Subject.Platform}", + }; var reply = new MessageContent { diff --git a/agents/Aevatar.GAgents.Channel.Identity/protos/aevatar_oauth_client.proto b/agents/Aevatar.GAgents.Channel.Identity/protos/aevatar_oauth_client.proto index ff6ae2d68..7eb286667 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/protos/aevatar_oauth_client.proto +++ b/agents/Aevatar.GAgents.Channel.Identity/protos/aevatar_oauth_client.proto @@ -74,16 +74,25 @@ message EnsureAevatarOAuthClientProvisionedCommand { } // Issued by tests / manual operator scripts that already hold a client_id -// (e.g. seeded fixture, post-rotation retag). Bootstrap NEVER uses this — -// it always sends EnsureAevatarOAuthClientProvisionedCommand instead so the -// actor mediates the DCR call. +// (e.g. seeded fixture, post-rotation retag, post-incident rebuild). Bootstrap +// NEVER uses this — it always sends EnsureAevatarOAuthClientProvisionedCommand +// instead so the actor mediates the DCR call. message ProvisionAevatarOAuthClientCommand { string client_id = 1; int64 client_id_issued_at_unix = 2; string nyxid_authority = 3; // Optional diagnostic scope for manually provisioned clients. Bootstrap - // never uses this command path; an empty value means unknown. + // never uses this command path; an empty value means unknown. The + // operator-rebuild path must set this to AevatarOAuthClientScopes + // .AuthorizationScope so the next bootstrap does not detect drift and + // re-DCR the freshly-pinned client. string oauth_scope = 4; + // Optional redirect URI. The operator-rebuild path (POST /api/oauth/ + // aevatar-client/rebuild) must set this to the resolver output so the next + // bootstrap does not detect redirect drift and re-DCR the freshly-pinned + // client. Tests / fixture seeds may leave it empty when they don't care + // about drift detection on a subsequent bootstrap pass. + string redirect_uri = 5; } // Issued by ops to force a fresh HMAC key rotation. Old tokens signed with diff --git a/agents/Aevatar.GAgents.Channel.Identity/protos/external_identity_binding.proto b/agents/Aevatar.GAgents.Channel.Identity/protos/external_identity_binding.proto index 0dcae6467..bc0586187 100644 --- a/agents/Aevatar.GAgents.Channel.Identity/protos/external_identity_binding.proto +++ b/agents/Aevatar.GAgents.Channel.Identity/protos/external_identity_binding.proto @@ -35,8 +35,9 @@ message CommitBindingCommand { } // Issued by the /unbind handler after a successful NyxID DELETE call, or by -// the turn path on `invalid_grant` from token-exchange. NO-OP at the actor -// when state has no active binding. +// the turn path on `invalid_grant` from token-exchange. When state has no +// active binding, the actor leaves binding facts unchanged but republishes +// its authoritative state root so stale readmodels can be overwritten. message RevokeBindingCommand { aevatar.gagents.channel.abstractions.ExternalSubjectRef external_subject = 1; // Free-form reason for audit (e.g. "user_unbind", "nyx_invalid_grant", @@ -59,13 +60,12 @@ message ExternalIdentityBindingRevokedEvent { } // Persisted when an inbound CommitBindingCommand is discarded because the -// actor already holds an active binding_id, OR when a deploy needs to re- -// publish the authoritative state root for a legacy binding actor whose -// projection scope was never activated. Apply is identity — the binding -// facts are not mutated. The projector still sees a state-root publication -// and materializes the existing binding into the readmodel, fixing the -// 2026-05-01 production regression where the binding scope was missing -// (issue #549 follow-up). +// actor already holds an active binding_id, when RevokeBindingCommand observes +// already-empty actor state, OR when a deploy needs to re-publish the +// authoritative state root for a legacy binding actor whose projection scope +// was never activated. Apply is identity — the binding facts are not mutated. +// The projector still sees a state-root publication and materializes the +// authoritative state into the readmodel. message ExternalIdentityBindingProjectionRebuildRequestedEvent { string reason = 1; google.protobuf.Timestamp requested_at = 2; diff --git a/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs index bb788f49a..168d25a47 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/ChannelMetadataKeys.cs @@ -39,8 +39,8 @@ public static class ChannelMetadataKeys /// /// Authoritative outbound Lark receive_id for the current workflow run, captured at /// agent-create time. Propagated via WorkflowChatRunRequest.Metadata so workflow - /// modules (e.g. TwitterPublishModule) can surface their result back into the same - /// chat without having to look up the catalog at execution time. + /// modules can surface their result back into the same chat without having to look up the + /// catalog at execution time. /// public const string LarkReceiveId = "channel.lark.receive_id"; /// Companion to — its receive_id_type. diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.LarkCardStreaming.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.LarkCardStreaming.cs new file mode 100644 index 000000000..a6b134361 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.LarkCardStreaming.cs @@ -0,0 +1,512 @@ +using Aevatar.GAgents.Channel.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Channel.Runtime; + +public sealed partial class ConversationGAgent +{ + private readonly Dictionary _larkCardStreamingStates = new(StringComparer.Ordinal); + + /// + /// Per-turn phase of the Lark CardKit streaming pipeline. Distinct from + /// (which models channel-relay edit-message + /// streaming): card streaming has its own lifecycle (allocate card entity, bind to + /// chat, stream element content, close streaming mode) and goes through the API-key + /// proxy directly rather than channel-relay's /reply{,/update} surface. + /// + /// + /// Fallback semantics: when card creation fails (), the + /// dispatcher routes the turn to the legacy text-edit sink (NyxRelayStreamingPhase + /// machine). Once is reached, the card path owns the turn — + /// mid-stream rate-limit / table-limit failures terminate the turn at + /// with the last flushed text persisted as partial. + /// + private enum LarkCardStreamingPhase + { + Idle, + Creating, + Streaming, + Completed, + Aborted, + Terminated, + CreationFailed, + } + + private enum LarkCardStreamingGuardSource + { + AcceptInterimChunk, + Finalize, + } + + /// + /// Actor-scoped, in-memory streaming state for one CardKit-driven turn. Keyed by + /// correlation_id, same lifecycle as . + /// + /// Lifecycle phase; gates interim updates and finalization. + /// + /// CardKit card entity id returned by cardkit/v1/cards. Null until + /// ; required for every element-content + /// and settings update afterwards. + /// + /// + /// Lark IM message id returned by the im/v1/messages send that bound the card + /// to a chat. Used by the unavailable-guard to detect upstream message recall. + /// + /// + /// Preserved card id for terminal full-card update if mid-stream we fall back to text + /// patch (table-limit class errors). Currently always equal to ; + /// reserved for the mid-stream-fallback follow-up (#589 Scope D). + /// + /// + /// Last text successfully streamed into the card element. Persisted as the user-visible + /// terminal state when finalization fails after streaming started. + /// + /// + /// Monotonic counter passed to every CardKit write. Pre-incremented before each call; + /// Lark rejects stale writes deterministically. + /// + /// + /// Element id within the card to stream into. Defaults to streaming_main; + /// must match the card template's element naming. + /// + /// Diagnostic reason captured on entry to terminal phases. + private sealed record LarkCardStreamingState( + LarkCardStreamingPhase Phase, + string? CardId, + string? CardMessageId, + string? OriginalCardId, + string LastFlushedText, + long Sequence, + string StreamingElementId, + string? TerminalReason) + { + public const string DefaultStreamingElementId = "streaming_main"; + + public static LarkCardStreamingState Initial { get; } = new( + LarkCardStreamingPhase.Idle, + CardId: null, + CardMessageId: null, + OriginalCardId: null, + LastFlushedText: string.Empty, + Sequence: 0, + StreamingElementId: DefaultStreamingElementId, + TerminalReason: null); + + /// Phase permits accepting a new chunk (initial or interim). + public bool AllowsInterimEdit => + Phase is LarkCardStreamingPhase.Idle + or LarkCardStreamingPhase.Streaming; + + /// + /// Card creation already failed — dispatcher should route subsequent chunks to the + /// text-edit sink for the rest of this turn. + /// + public bool AllowsTextEditFallback => + Phase is LarkCardStreamingPhase.Idle + or LarkCardStreamingPhase.CreationFailed; + + /// Phase permits attempting a finalize (close streaming + optional final update). + public bool AllowsFinalize => + Phase is LarkCardStreamingPhase.Streaming; + } + + private static bool IsTerminalLarkCardStreamingPhase(LarkCardStreamingPhase phase) => + phase is LarkCardStreamingPhase.Completed + or LarkCardStreamingPhase.Aborted + or LarkCardStreamingPhase.Terminated + or LarkCardStreamingPhase.CreationFailed; + + private static bool IsLegalLarkCardStreamingTransition(LarkCardStreamingPhase from, LarkCardStreamingPhase to) => + (from, to) switch + { + (LarkCardStreamingPhase.Idle, LarkCardStreamingPhase.Creating) => true, + + (LarkCardStreamingPhase.Creating, LarkCardStreamingPhase.Streaming) => true, + (LarkCardStreamingPhase.Creating, LarkCardStreamingPhase.CreationFailed) => true, + (LarkCardStreamingPhase.Creating, LarkCardStreamingPhase.Terminated) => true, + + (LarkCardStreamingPhase.Streaming, LarkCardStreamingPhase.Streaming) => true, + (LarkCardStreamingPhase.Streaming, LarkCardStreamingPhase.Completed) => true, + (LarkCardStreamingPhase.Streaming, LarkCardStreamingPhase.Aborted) => true, + (LarkCardStreamingPhase.Streaming, LarkCardStreamingPhase.Terminated) => true, + + _ => false, + }; + + private LarkCardStreamingState GetOrInitLarkCardStreamingState(string correlationId) => + _larkCardStreamingStates.GetValueOrDefault(correlationId) ?? LarkCardStreamingState.Initial; + + private static bool ShouldSkipLarkCardStreamingForUnavailable( + LarkCardStreamingState state, + LarkCardStreamingGuardSource source) => + source switch + { + LarkCardStreamingGuardSource.AcceptInterimChunk => !state.AllowsInterimEdit, + LarkCardStreamingGuardSource.Finalize => !state.AllowsFinalize, + _ => false, + }; + + private LarkCardStreamingState TransitionLarkCardStreamingPhase( + string correlationId, + LarkCardStreamingState current, + LarkCardStreamingPhase next, + string? terminalReason = null, + Func? fieldUpdate = null) + { + if (!IsLegalLarkCardStreamingTransition(current.Phase, next)) + { + Logger.LogWarning( + "Illegal Lark card streaming phase transition {From}->{To} for correlation={CorrelationId}; keeping current state", + current.Phase, next, correlationId); + return current; + } + + var carried = fieldUpdate?.Invoke(current) ?? current; + var updated = carried with + { + Phase = next, + TerminalReason = IsTerminalLarkCardStreamingPhase(next) + ? (terminalReason ?? carried.TerminalReason) + : carried.TerminalReason, + }; + _larkCardStreamingStates[correlationId] = updated; + return updated; + } + + private IConversationCardTurnRunner ResolveCardRunner() => + Services.GetService() ?? new NullConversationCardTurnRunner(); + + /// + /// Drives one CardKit-mode streaming chunk. Returns true when the card handler owns the + /// outcome (Idle->Creating[->Streaming], Streaming->Streaming, terminal-drop) and false + /// only when the caller should fall through to the legacy text-edit path — + /// CreationFailed phase signals "card path is dead for this turn, route the rest of the + /// chunks through edit-message streaming." + /// + private async Task HandleLarkCardStreamingChunkCoreAsync( + LlmReplyCardStreamChunkEvent evt, + string correlationId) + { + var state = GetOrInitLarkCardStreamingState(correlationId); + + // Already-decided text-edit fallback: let the caller continue down the text-edit path. + if (state.Phase is LarkCardStreamingPhase.CreationFailed) + return false; + + if (ShouldSkipLarkCardStreamingForUnavailable(state, LarkCardStreamingGuardSource.AcceptInterimChunk)) + return true; + + var runtimeContext = BuildNyxRelayRuntimeContext(evt.CorrelationId, evt.Activity); + var runner = ResolveCardRunner(); + + if (state.Phase is LarkCardStreamingPhase.Idle) + { + TransitionLarkCardStreamingPhase(correlationId, state, LarkCardStreamingPhase.Creating); + var creating = GetOrInitLarkCardStreamingState(correlationId); + ConversationCardCreateResult createResult; + try + { + createResult = await runner.RunCardCreateAsync( + evt, + creating.StreamingElementId, + runtimeContext, + CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Card create threw; falling back to text-edit. correlation={CorrelationId}", evt.CorrelationId); + TransitionLarkCardStreamingPhase( + correlationId, + creating, + LarkCardStreamingPhase.CreationFailed, + terminalReason: $"create_threw:{ex.GetType().Name}"); + return false; + } + + if (!createResult.Success) + { + if (createResult.IsPostSendFailure) + { + // Card was already sent to the chat — falling back to text-edit would + // produce a duplicate visible reply. Terminate the turn at Terminated and + // persist a partial-card record using the orphan card_message_id so the + // event store has a terminal entry. The runner has already attempted a + // best-effort streaming-mode close on the orphan card. + Logger.LogWarning( + "Card post-send failure (create+send succeeded, first stream failed); terminating turn without text-edit fallback. correlation={CorrelationId}, code={ErrorCode}, cardId={CardId}", + evt.CorrelationId, + createResult.ErrorCode, + createResult.CardId); + var terminated = TransitionLarkCardStreamingPhase( + correlationId, + creating, + LarkCardStreamingPhase.Terminated, + terminalReason: $"create_post_send_failed:{createResult.ErrorCode}", + fieldUpdate: s => s with + { + CardId = createResult.CardId, + CardMessageId = createResult.CardMessageId, + OriginalCardId = createResult.CardId, + }); + await PersistCardStreamedCompletionAsync( + correlationId, + BuildLlmReplyCommandId(evt.CorrelationId), + evt.Activity, + evt.Activity, + terminated.CardMessageId ?? string.Empty, + terminated.LastFlushedText); + return true; + } + + Logger.LogInformation( + "Card create failed; falling back to text-edit for the rest of this turn. correlation={CorrelationId}, code={ErrorCode}, rateLimited={RateLimited}, tableLimit={TableLimit}, cardUnavailable={CardUnavailable}", + evt.CorrelationId, + createResult.ErrorCode, + createResult.IsRateLimited, + createResult.IsTableLimitExceeded, + createResult.IsCardUnavailable); + TransitionLarkCardStreamingPhase( + correlationId, + creating, + LarkCardStreamingPhase.CreationFailed, + terminalReason: $"create_failed:{createResult.ErrorCode}"); + return false; + } + + TransitionLarkCardStreamingPhase( + correlationId, + creating, + LarkCardStreamingPhase.Streaming, + fieldUpdate: s => s with + { + CardId = createResult.CardId, + CardMessageId = createResult.CardMessageId, + OriginalCardId = createResult.CardId, + LastFlushedText = evt.AccumulatedText, + Sequence = 1, + }); + return true; + } + + // Streaming: interim element-content update. Sequence pre-incremented; on success + // record the new sequence + last-flushed text so finalize knows whether to write. + var nextSequence = state.Sequence + 1; + ConversationCardStreamResult streamResult; + try + { + streamResult = await runner.RunCardStreamAsync( + evt, + state.CardId ?? string.Empty, + state.StreamingElementId, + nextSequence, + runtimeContext, + CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Card stream threw; dropping frame. correlation={CorrelationId}, seq={Sequence}", evt.CorrelationId, nextSequence); + return true; + } + + if (!streamResult.Success) + { + if (streamResult.IsRateLimited) + { + // Recoverable: skip the frame, keep sequence unchanged so the next chunk + // re-uses this slot. + Logger.LogDebug( + "Card stream rate-limited; dropping frame. correlation={CorrelationId}, seq={Sequence}", + evt.CorrelationId, nextSequence); + return true; + } + if (streamResult.IsTableLimitExceeded || streamResult.IsCardUnavailable) + { + Logger.LogWarning( + "Card stream terminal failure; ending turn. correlation={CorrelationId}, code={ErrorCode}", + evt.CorrelationId, streamResult.ErrorCode); + var terminated = TransitionLarkCardStreamingPhase( + correlationId, + state, + LarkCardStreamingPhase.Terminated, + terminalReason: $"stream_failed:{streamResult.ErrorCode}"); + // Persist the partial-card terminal record so the event store records the + // turn even though LlmReplyReady has not arrived yet. Without this the + // ProcessedCommandIds guard in HandleLlmReplyReadyAsync would still see no + // matching entry, fall through to the legacy reply path, and post a + // duplicate text reply on top of the visible card. + await PersistCardStreamedCompletionAsync( + correlationId, + BuildLlmReplyCommandId(evt.CorrelationId), + evt.Activity, + evt.Activity, + terminated.CardMessageId ?? string.Empty, + terminated.LastFlushedText); + return true; + } + Logger.LogInformation( + "Card stream non-terminal failure; continuing. correlation={CorrelationId}, code={ErrorCode}", + evt.CorrelationId, streamResult.ErrorCode); + return true; + } + + TransitionLarkCardStreamingPhase( + correlationId, + state, + LarkCardStreamingPhase.Streaming, + fieldUpdate: s => s with + { + LastFlushedText = evt.AccumulatedText, + Sequence = nextSequence, + }); + return true; + } + + /// + /// Drives the card-mode finalize when sees a + /// live Streaming phase. Persists a ConversationTurnCompletedEvent with + /// SentActivityId="lark-card-stream:{cardMessageId}" so observers can distinguish + /// the card path from the legacy nyx-relay-stream: path. + /// + private async Task TryCompleteCardStreamedReplyAsync( + LlmReplyReadyEvent evt, + string correlationId, + string commandId, + ChatActivity? referenceActivity) + { + var state = GetOrInitLarkCardStreamingState(correlationId); + // Idle: card path was never started for this turn (or already cleaned up); let the + // legacy edit-message finalize path handle it. CreationFailed: card create rejected + // pre-send, which already routed the chunks to the text-edit sink, so the text-edit + // finalize must run too. Both → return false to fall through. + if (state.Phase is LarkCardStreamingPhase.Idle + or LarkCardStreamingPhase.CreationFailed) + return false; + + // Already-terminal card phase (post-send-failure, mid-stream rate/unavailable, or + // a previous finalize): persistence already happened at the transition site, so + // simply consume the ready event without running text-edit finalize. The + // ProcessedCommandIds guard in HandleLlmReplyReadyAsync also short-circuits late + // ready events, but returning true here keeps the contract explicit. + if (state.Phase is LarkCardStreamingPhase.Completed + or LarkCardStreamingPhase.Aborted + or LarkCardStreamingPhase.Terminated) + return true; + + // Phase is Streaming or Creating. Creating during finalize is unexpected (card.create + // is synchronous within a single chunk's handler); treat it as Streaming with no + // prior interim text. Anything else falls through to text-edit, but the explicit + // guards above mean we only reach this point with phase=Streaming/Creating. + var finalText = evt.Outbound?.Text ?? string.Empty; + var finalDiffers = !string.IsNullOrWhiteSpace(finalText) + && !string.Equals(finalText, state.LastFlushedText, StringComparison.Ordinal); + + var runtimeContext = BuildNyxRelayRuntimeContext(evt.CorrelationId, evt.Activity); + var runner = ResolveCardRunner(); + var nextSequence = state.Sequence + 1; + var activityForToken = referenceActivity ?? evt.Activity ?? new ChatActivity(); + + ConversationCardFinalizeResult finalizeResult; + try + { + finalizeResult = await runner.RunCardFinalizeAsync( + activityForToken, + state.CardId ?? string.Empty, + state.StreamingElementId, + finalText, + finalDiffers, + nextSequence, + runtimeContext, + CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Card finalize threw; persisting last flushed partial. correlation={CorrelationId}", evt.CorrelationId); + TransitionLarkCardStreamingPhase( + correlationId, + state, + LarkCardStreamingPhase.Terminated, + terminalReason: $"finalize_threw:{ex.GetType().Name}"); + await PersistCardStreamedCompletionAsync( + correlationId, + commandId, + evt.Activity, + referenceActivity, + state.CardMessageId ?? string.Empty, + state.LastFlushedText); + return true; + } + + // visibleText must match what the user actually sees on the card. Two failure modes: + // * Final stream write failed → card shows LastFlushedText + // * Final stream succeeded but close-streaming failed → card shows finalText, just + // with a still-blinking cursor. Persist finalText so the durable record agrees + // with the visible state. + var visibleText = finalizeResult.FinalTextWritten ? finalText : state.LastFlushedText; + if (finalizeResult.Success) + { + TransitionLarkCardStreamingPhase( + correlationId, + state, + LarkCardStreamingPhase.Completed, + terminalReason: "completed"); + } + else + { + Logger.LogWarning( + "Card finalize failed; persisting partial. correlation={CorrelationId}, code={ErrorCode}", + evt.CorrelationId, finalizeResult.ErrorCode); + TransitionLarkCardStreamingPhase( + correlationId, + state, + LarkCardStreamingPhase.Terminated, + terminalReason: $"finalize_failed:{finalizeResult.ErrorCode}"); + } + + await PersistCardStreamedCompletionAsync( + correlationId, + commandId, + evt.Activity, + referenceActivity, + state.CardMessageId ?? string.Empty, + visibleText); + return true; + } + + /// + /// Persists the terminal ConversationTurnCompletedEvent for a card-streamed turn. + /// Decoupled from the inbound event type so both the LlmReplyReady finalize path and the + /// mid-stream Terminated path (post-send-failure / table-limit / unavailable, observed + /// while still processing chunks) can share one writer. + /// + private async Task PersistCardStreamedCompletionAsync( + string correlationId, + string commandId, + ChatActivity? eventActivity, + ChatActivity? referenceActivity, + string cardMessageId, + string outboundText) + { + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var completed = new ConversationTurnCompletedEvent + { + ProcessedActivityId = string.Empty, + CausationCommandId = commandId, + SentActivityId = $"lark-card-stream:{cardMessageId}", + AuthPrincipal = "bot", + Conversation = eventActivity?.Conversation?.Clone() + ?? State.Conversation?.Clone() + ?? new ConversationReference(), + Outbound = new MessageContent { Text = outboundText }, + CompletedAtUnixMs = nowMs, + OutboundDelivery = ToOutboundDeliveryReceipt(eventActivity?.OutboundDelivery), + }; + await PersistDomainEventAsync(completed); + RemoveNyxRelayReplyToken(correlationId, referenceActivity); + Logger.LogInformation( + "Completed card-streamed LLM reply: correlation={CorrelationId} cardMessageId={CardMessageId} conversation={Key}", + correlationId, + cardMessageId, + completed.Conversation?.CanonicalKey); + } +} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.NyxRelayStreaming.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.NyxRelayStreaming.cs new file mode 100644 index 000000000..3ba1bf86b --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.NyxRelayStreaming.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Channel.Runtime; + +public sealed partial class ConversationGAgent +{ + /// + /// Per-turn phase of the NyxID-relay edit-message streaming pipeline. + /// + /// + /// The reply token consumes on the first successful send. After that, only + /// /reply/update is valid; falling back to /reply would reuse a dead JTI + /// and surface as 401. The two boolean flags this enum replaces (Disabled + + /// SuppressInterim) failed to express that asymmetry directly, so callers had + /// to derive it from PlatformMessageId emptiness. The phase enum makes the + /// asymmetry the primary state. + /// + private enum NyxRelayStreamingPhase + { + Idle, + PlaceholderSent, + Streaming, + SuppressingInterim, + DisabledPreSend, + TerminalSucceeded, + TerminalPartial, + } + + /// + /// Identifies which streaming entry point is asking the unavailable guard to decide + /// whether to short-circuit. Different sources have different "should I bail?" semantics. + /// + private enum NyxRelayStreamingGuardSource + { + AcceptInterimChunk, + Finalize, + } + + /// + /// Actor-scoped, in-memory streaming state for one conversation turn. Never persisted. + /// Keyed by correlation_id, same lifecycle as . + /// + private sealed record NyxRelayStreamingState( + NyxRelayStreamingPhase Phase, + string? PlatformMessageId, + string LastFlushedText, + int EditCount, + string? TerminalReason) + { + public static NyxRelayStreamingState Initial { get; } = + new(NyxRelayStreamingPhase.Idle, null, string.Empty, 0, null); + + public bool AllowsInterimEdit => + Phase is NyxRelayStreamingPhase.Idle + or NyxRelayStreamingPhase.PlaceholderSent + or NyxRelayStreamingPhase.Streaming; + + public bool AllowsFinalEdit => + Phase is NyxRelayStreamingPhase.PlaceholderSent + or NyxRelayStreamingPhase.Streaming + or NyxRelayStreamingPhase.SuppressingInterim; + + public bool AllowsReplyFallback => + Phase is NyxRelayStreamingPhase.Idle + or NyxRelayStreamingPhase.DisabledPreSend; + } + + private static bool IsTerminalNyxRelayStreamingPhase(NyxRelayStreamingPhase phase) => + phase is NyxRelayStreamingPhase.DisabledPreSend + or NyxRelayStreamingPhase.TerminalSucceeded + or NyxRelayStreamingPhase.TerminalPartial; + + private static bool IsLegalNyxRelayStreamingTransition(NyxRelayStreamingPhase from, NyxRelayStreamingPhase to) => + (from, to) switch + { + (NyxRelayStreamingPhase.Idle, NyxRelayStreamingPhase.PlaceholderSent) => true, + (NyxRelayStreamingPhase.Idle, NyxRelayStreamingPhase.DisabledPreSend) => true, + + (NyxRelayStreamingPhase.PlaceholderSent, NyxRelayStreamingPhase.Streaming) => true, + (NyxRelayStreamingPhase.PlaceholderSent, NyxRelayStreamingPhase.SuppressingInterim) => true, + (NyxRelayStreamingPhase.PlaceholderSent, NyxRelayStreamingPhase.TerminalSucceeded) => true, + (NyxRelayStreamingPhase.PlaceholderSent, NyxRelayStreamingPhase.TerminalPartial) => true, + + (NyxRelayStreamingPhase.Streaming, NyxRelayStreamingPhase.Streaming) => true, + (NyxRelayStreamingPhase.Streaming, NyxRelayStreamingPhase.SuppressingInterim) => true, + (NyxRelayStreamingPhase.Streaming, NyxRelayStreamingPhase.TerminalSucceeded) => true, + (NyxRelayStreamingPhase.Streaming, NyxRelayStreamingPhase.TerminalPartial) => true, + + (NyxRelayStreamingPhase.SuppressingInterim, NyxRelayStreamingPhase.TerminalSucceeded) => true, + (NyxRelayStreamingPhase.SuppressingInterim, NyxRelayStreamingPhase.TerminalPartial) => true, + + _ => false, + }; + + private NyxRelayStreamingState GetOrInitNyxRelayStreamingState(string correlationId) => + _nyxRelayStreamingStates.GetValueOrDefault(correlationId) ?? NyxRelayStreamingState.Initial; + + /// + /// Single guard that owns the "should this streaming callback short-circuit?" decision. + /// Every public handler that touches the streaming path defers to this helper at the + /// top instead of repeating ad-hoc checks. Returns true when the caller should bail. + /// + /// + /// The Finalize branch also short-circuits when + /// is empty: a turn whose first send did not surface a platform message id (Nyx returned + /// an empty PlatformMessageId on initial /reply) cannot be finalized via + /// /reply/update — we have no upstream message to address — so the legacy + /// RunLlmReplyAsync fallback owns the terminal user-visible state. This preserves + /// the explicit empty-PlatformMessageId check that lived in the pre-refactor path. + /// + private static bool ShouldSkipNyxRelayStreamingForUnavailable( + NyxRelayStreamingState state, + NyxRelayStreamingGuardSource source) => + source switch + { + NyxRelayStreamingGuardSource.AcceptInterimChunk => !state.AllowsInterimEdit, + NyxRelayStreamingGuardSource.Finalize => + state.AllowsReplyFallback || string.IsNullOrEmpty(state.PlatformMessageId), + _ => false, + }; + + /// + /// Validates the transition, applies if any, writes the + /// updated state, and returns it. Illegal transitions are logged at warn level and + /// return the unchanged current state — actor turns must keep making progress. + /// + private NyxRelayStreamingState TransitionNyxRelayStreamingPhase( + string correlationId, + NyxRelayStreamingState current, + NyxRelayStreamingPhase next, + string? terminalReason = null, + Func? fieldUpdate = null) + { + if (!IsLegalNyxRelayStreamingTransition(current.Phase, next)) + { + Logger.LogWarning( + "Illegal Nyx relay streaming phase transition {From}->{To} for correlation={CorrelationId}; keeping current state", + current.Phase, next, correlationId); + return current; + } + + var carried = fieldUpdate?.Invoke(current) ?? current; + var updated = carried with + { + Phase = next, + TerminalReason = IsTerminalNyxRelayStreamingPhase(next) + ? (terminalReason ?? carried.TerminalReason) + : carried.TerminalReason, + }; + _nyxRelayStreamingStates[correlationId] = updated; + return updated; + } +} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs index c1df93f33..33f037889 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/ConversationGAgent.cs @@ -30,14 +30,15 @@ public sealed partial class ConversationGAgent : GAgentBase _nyxRelayReplyTokens = new(StringComparer.Ordinal); private readonly Dictionary _nyxRelayStreamingStates = new(StringComparer.Ordinal); - /// - /// Actor-scoped, in-memory streaming state for one conversation turn. Never persisted: tracks - /// the upstream platform message id of the placeholder send and the two distinct failure - /// modes that can disable parts of the streaming path. Keyed by correlation_id, same - /// lifecycle as . - /// - /// - /// The two failure flags carry different semantics with respect to the NyxID reply token: - /// - /// Disabled means streaming was aborted before any successful send, so - /// the reply token is still available and the actor may safely fall back to a single-shot - /// /reply via . - /// SuppressInterim means the first chunk already consumed the reply token (the - /// placeholder or first delta landed) but a later interim edit failed. The final edit must - /// still be attempted via /reply/update; falling back to /reply would reuse a - /// dead token and turn the partial into the user-visible terminal state. - /// - /// - private sealed record NyxRelayStreamingState( - string? PlatformMessageId, - string LastFlushedText, - int EditCount, - bool Disabled, - bool SuppressInterim) - { - public static NyxRelayStreamingState Initial { get; } = new(null, string.Empty, 0, false, false); - - /// - /// True once the first successful send has landed: the NyxID reply token has been - /// consumed and any further outbound must go through /reply/update. Used as the - /// "token is dead, don't fall back to /reply" guard. - /// - public bool ReplyTokenConsumed => !string.IsNullOrEmpty(PlatformMessageId); - } - /// /// Sliding window cap on retained processed ids. Keeps state size bounded while still /// catching typical redelivery windows (seconds to minutes). @@ -156,16 +122,16 @@ private async Task HandleInboundActivityCoreAsync( var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (result.LlmReplyRequest is not null) { - // The transient inbox copy keeps reply_token + expiry so the LLM worker can + // The transient run command copy keeps reply_token + expiry so the run actor can // echo them back inside LlmReplyReadyEvent; the persisted state copy must // not carry the credential into the event store / projection / read model. - var inboxCopy = result.LlmReplyRequest.Clone(); - inboxCopy.TargetActorId = Id; - var persistedCopy = inboxCopy.Clone(); + var runCopy = result.LlmReplyRequest.Clone(); + runCopy.TargetActorId = Id; + var persistedCopy = runCopy.Clone(); persistedCopy.ReplyToken = string.Empty; persistedCopy.ReplyTokenExpiresAtUnixMs = 0; await PersistDomainEventAsync(persistedCopy); - await DispatchPendingLlmReplyAsync(inboxCopy, CancellationToken.None); + await DispatchPendingLlmReplyAsync(runCopy, CancellationToken.None); Logger.LogInformation( "Accepted inbound activity for deferred LLM reply: activity={ActivityId} conversation={Key}", activity.Id, @@ -332,7 +298,7 @@ public async Task HandleDeferredLlmReplyDroppedAsync(DeferredLlmReplyDroppedEven CausationId = string.Empty, Kind = FailureKind.PermanentAdapterError, ErrorCode = reason, - ErrorSummary = "Deferred LLM reply request was dropped by the inbox pre-LLM gate.", + ErrorSummary = "Deferred LLM reply request was dropped by the run actor pre-LLM gate.", NotRetryable = new Google.Protobuf.WellKnownTypes.Empty(), FailedAtUnixMs = evt.DroppedAtUnixMs > 0 ? evt.DroppedAtUnixMs @@ -342,7 +308,7 @@ public async Task HandleDeferredLlmReplyDroppedAsync(DeferredLlmReplyDroppedEven RemoveNyxRelayReplyToken(evt.CorrelationId, pending.Activity); Logger.LogInformation( - "Retired pending LLM reply after inbox drop: correlation={CorrelationId} reason={Reason}", + "Retired pending LLM reply after run drop: correlation={CorrelationId} reason={Reason}", evt.CorrelationId, reason); } @@ -378,11 +344,11 @@ public async Task HandleDeferredInboundTurnRetryRequestedAsync(DeferredInboundTu private async Task DispatchPendingLlmReplyAsync(NeedsLlmReplyEvent request, CancellationToken ct) { - var inbox = Services.GetService(); - if (inbox is null) + var dispatcher = Services.GetService(); + if (dispatcher is null) { Logger.LogWarning( - "Channel LLM reply inbox not registered; scheduling durable retry: correlation={CorrelationId}", + "Channel LLM reply run dispatcher not registered; scheduling durable retry: correlation={CorrelationId}", request.CorrelationId); await ScheduleDeferredLlmReplyDispatchAsync(request, DeferredLlmDispatchRetryDelay, ct); return; @@ -391,24 +357,24 @@ private async Task DispatchPendingLlmReplyAsync(NeedsLlmReplyEvent request, Canc // Retry and rehydration paths read `request` from State.PendingLlmReplyRequests, // which always carries an empty ReplyToken (the inbound handler strips it before // persist). If the actor is still alive and the in-memory dict still has the - // token for this correlation, re-enrich the inbox copy so the subscriber's relay - // credential gate does not mistake a legitimate retry for a dead request. + // token for this correlation, re-enrich the run command copy so AgentRunGAgent's + // relay credential gate does not mistake a legitimate retry for a dead request. var enriched = EnrichWithRuntimeReplyTokenIfNeeded(request); try { - await inbox.EnqueueAsync(enriched.Clone(), ct); + await dispatcher.DispatchAsync(enriched.Clone(), ct); Logger.LogInformation( - "Enqueued LLM reply request to inbox: correlation={CorrelationId} conversation={Key} replyTokenSource={Source}", + "Dispatched LLM reply run request: correlation={CorrelationId} conversation={Key} replyTokenSource={Source}", enriched.CorrelationId, enriched.Activity?.Conversation?.CanonicalKey, - DescribeEnqueuedReplyTokenSource(request, enriched)); + DescribeDispatchedReplyTokenSource(request, enriched)); } catch (Exception ex) { Logger.LogError( ex, - "Failed to enqueue LLM reply request; scheduling durable retry: correlation={CorrelationId}", + "Failed to dispatch LLM reply run request; scheduling durable retry: correlation={CorrelationId}", request.CorrelationId); await ScheduleDeferredLlmReplyDispatchAsync(request, DeferredLlmDispatchRetryDelay, ct); } @@ -439,7 +405,7 @@ private NeedsLlmReplyEvent EnrichWithRuntimeReplyTokenIfNeeded(NeedsLlmReplyEven return enriched; } - private static string DescribeEnqueuedReplyTokenSource( + private static string DescribeDispatchedReplyTokenSource( NeedsLlmReplyEvent original, NeedsLlmReplyEvent enriched) { @@ -561,7 +527,21 @@ await ScheduleDeferredLlmReplyDispatchAsync( /// boundary and the edit ordering is enforced by actor serialization. /// [EventHandler] - public async Task HandleLlmReplyStreamChunkAsync(LlmReplyStreamChunkEvent evt) + public Task HandleLlmReplyStreamChunkAsync(LlmReplyStreamChunkEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + return HandleNyxRelayStreamingChunkCoreAsync(evt); + } + + /// + /// CardKit-streaming chunks travel on a structurally distinct proto type so a misbehaving + /// persistence layer cannot silently re-route a replayed event back to the card sink. The + /// card handler owns Idle / Creating / Streaming / terminal transitions; on + /// CreationFailed it returns false and we drop into the legacy text-edit core + /// helper so the user still sees a reply for the rest of the turn. + /// + [EventHandler] + public async Task HandleLlmReplyCardStreamChunkAsync(LlmReplyCardStreamChunkEvent evt) { ArgumentNullException.ThrowIfNull(evt); @@ -569,40 +549,85 @@ public async Task HandleLlmReplyStreamChunkAsync(LlmReplyStreamChunkEvent evt) if (correlationId is null || evt.Activity is null || string.IsNullOrWhiteSpace(evt.AccumulatedText)) { Logger.LogDebug( - "Dropping malformed streaming chunk: correlation={CorrelationId}", + "Dropping malformed card streaming chunk: correlation={CorrelationId}", evt.CorrelationId); return; } - var state = _nyxRelayStreamingStates.GetValueOrDefault(correlationId) ?? NyxRelayStreamingState.Initial; - if (state.Disabled || state.SuppressInterim) + if (State.ProcessedCommandIds.Contains(BuildLlmReplyCommandId(evt.CorrelationId))) + { + // Turn already finalized; drop any late chunk that sneaks in via the actor inbox. + return; + } + + // Plain `await`: actor turns run on a single-threaded scheduler and the continuation + // must observe that context for subsequent state mutations on + // `_larkCardStreamingStates` / `_nyxRelayStreamingStates`. + if (await HandleLarkCardStreamingChunkCoreAsync(evt, correlationId)) return; + // CardCreation failed (pre-flight or first chunk). Route the rest of the turn through + // the legacy text-edit core so the user still gets a reply. Synthesize the equivalent + // edit-message chunk from the card-event payload — both proto types carry the same + // fields so the projection is loss-less. + await HandleNyxRelayStreamingChunkCoreAsync(new LlmReplyStreamChunkEvent + { + CorrelationId = evt.CorrelationId, + RegistrationId = evt.RegistrationId, + Activity = evt.Activity?.Clone() ?? new ChatActivity(), + AccumulatedText = evt.AccumulatedText, + ChunkAtUnixMs = evt.ChunkAtUnixMs, + }); + } + + private async Task HandleNyxRelayStreamingChunkCoreAsync(LlmReplyStreamChunkEvent evt) + { + var correlationId = NormalizeOptional(evt.CorrelationId); + if (correlationId is null || evt.Activity is null || string.IsNullOrWhiteSpace(evt.AccumulatedText)) + { + Logger.LogDebug( + "Dropping malformed streaming chunk: correlation={CorrelationId}", + evt.CorrelationId); + return; + } + if (State.ProcessedCommandIds.Contains(BuildLlmReplyCommandId(evt.CorrelationId))) { // Turn already finalized; drop any late chunk that sneaks in via the actor inbox. return; } + var state = GetOrInitNyxRelayStreamingState(correlationId); + if (ShouldSkipNyxRelayStreamingForUnavailable(state, NyxRelayStreamingGuardSource.AcceptInterimChunk)) + return; + var runtimeContext = BuildNyxRelayRuntimeContext(evt.CorrelationId, evt.Activity); if (runtimeContext.NyxRelayReplyToken is null) { Logger.LogInformation( "Streaming chunk received but relay reply token is unavailable; disabling streaming for turn. correlation={CorrelationId}", evt.CorrelationId); - _nyxRelayStreamingStates[correlationId] = state with { Disabled = true }; + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.DisabledPreSend, + terminalReason: "no_reply_token"); return; } var runner = ResolveRunner(); + // Bound the upstream edit so a stuck relay/network can't pin the actor turn forever + // (PR #562 review). 10s matches the failure-path timeout below; the edit is best-effort, + // so timing out cleanly into the !result.Success branch preserves correctness. + using var streamChunkCts = new CancellationTokenSource(StreamingFailureUpdateTimeout); var result = await runner.RunStreamChunkAsync( evt, state.PlatformMessageId, runtimeContext, - CancellationToken.None); + streamChunkCts.Token); if (!result.Success) { - if (state.ReplyTokenConsumed) + if (state.AllowsFinalEdit) { // First chunk already consumed the reply token. Skip further interim edits but // preserve PlatformMessageId so the final edit on LlmReplyReady can still try @@ -613,7 +638,11 @@ public async Task HandleLlmReplyStreamChunkAsync(LlmReplyStreamChunkEvent evt) evt.CorrelationId, result.ErrorCode, result.EditUnsupported); - _nyxRelayStreamingStates[correlationId] = state with { SuppressInterim = true }; + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.SuppressingInterim, + terminalReason: $"interim_edit_failed:{result.ErrorCode}"); } else { @@ -624,21 +653,29 @@ public async Task HandleLlmReplyStreamChunkAsync(LlmReplyStreamChunkEvent evt) evt.CorrelationId, result.ErrorCode, result.EditUnsupported); - _nyxRelayStreamingStates[correlationId] = state with { Disabled = true }; + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.DisabledPreSend, + terminalReason: $"first_send_failed:{result.ErrorCode}"); } return; } - var isFirstChunk = string.IsNullOrEmpty(state.PlatformMessageId); + var isFirstChunk = state.Phase == NyxRelayStreamingPhase.Idle; var newPlatformMessageId = string.IsNullOrWhiteSpace(result.PlatformMessageId) ? state.PlatformMessageId : result.PlatformMessageId; - _nyxRelayStreamingStates[correlationId] = state with - { - PlatformMessageId = newPlatformMessageId, - LastFlushedText = evt.AccumulatedText, - EditCount = isFirstChunk ? 0 : state.EditCount + 1, - }; + TransitionNyxRelayStreamingPhase( + correlationId, + state, + isFirstChunk ? NyxRelayStreamingPhase.PlaceholderSent : NyxRelayStreamingPhase.Streaming, + fieldUpdate: s => s with + { + PlatformMessageId = newPlatformMessageId, + LastFlushedText = evt.AccumulatedText, + EditCount = isFirstChunk ? 0 : s.EditCount + 1, + }); } private async Task TryCompleteStreamedReplyAsync( @@ -647,22 +684,86 @@ private async Task TryCompleteStreamedReplyAsync( ChatActivity? referenceActivity, ConversationTurnRuntimeContext runtimeContext) { - if (evt.TerminalState != LlmReplyTerminalState.Completed) - return false; - var correlationId = NormalizeOptional(evt.CorrelationId); if (correlationId is null) return false; - if (!_nyxRelayStreamingStates.TryGetValue(correlationId, out var state)) - return false; - // Disabled means the initial send never landed, so the reply token is still usable - // and the caller may fall back to a single-shot /reply. A missing PlatformMessageId - // with SuppressInterim would be inconsistent, but treat it the same for safety. - if (state.Disabled || string.IsNullOrEmpty(state.PlatformMessageId)) + // Card path takes precedence when active; falls through to text-edit when card never + // started (Idle), card creation failed (CreationFailed → text-edit fallback), or card + // finished as a terminal phase. Plain `await` so the continuation stays on the + // actor's single-threaded scheduler (no ConfigureAwait(false) — it would let the + // post-await `_nyxRelayStreamingStates` reads run off the actor turn). + if (await TryCompleteCardStreamedReplyAsync(evt, correlationId, commandId, referenceActivity)) + return true; + + var state = GetOrInitNyxRelayStreamingState(correlationId); + if (ShouldSkipNyxRelayStreamingForUnavailable(state, NyxRelayStreamingGuardSource.Finalize)) return false; var platformMessageId = state.PlatformMessageId!; + + // Streaming-start already consumed the reply token. On Failed, falling through to + // RunLlmReplyAsync would issue a fresh /reply against the dead token and surface + // as `401 Reply token already used` to NyxID — leaving the user staring at the + // streaming partial (often just "...") forever with no error explanation. Self-heal + // by editing the existing placeholder in place with the classified failure text; + // turn is then terminal (no retry, no second /reply). + if (evt.TerminalState == LlmReplyTerminalState.Failed) + { + var failureText = NormalizeOptional(evt.Outbound?.Text) + ?? NormalizeOptional(evt.ErrorSummary) + ?? "Sorry, the reply failed. Please try again."; + var runner = ResolveRunner(); + var failureChunk = new LlmReplyStreamChunkEvent + { + CorrelationId = evt.CorrelationId, + RegistrationId = evt.RegistrationId, + Activity = referenceActivity?.Clone() ?? evt.Activity?.Clone() ?? new ChatActivity(), + AccumulatedText = failureText, + ChunkAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + using var failureUpdateCts = new CancellationTokenSource(StreamingFailureUpdateTimeout); + var failureResult = await runner.RunStreamChunkAsync( + failureChunk, + platformMessageId, + runtimeContext, + failureUpdateCts.Token); + if (failureResult.Success) + { + Logger.LogWarning( + "LLM reply failed after streaming-start; updated placeholder with failure text. correlation={CorrelationId}, errorCode={ErrorCode}, platformMessageId={PlatformMessageId}", + evt.CorrelationId, + evt.ErrorCode, + platformMessageId); + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.TerminalSucceeded, + terminalReason: $"failed_self_heal:{evt.ErrorCode}"); + await PersistStreamedCompletionAsync(evt, commandId, referenceActivity, platformMessageId, failureText, state.EditCount + 1); + return true; + } + + // Edit failed too (rare — Lark may reject a message edit for unrelated reasons). + // Falling back to /reply would still hit the dead token, so persist the last + // flushed partial as terminal. The user sees the partial (potentially empty) + // but we don't spin on a guaranteed 401. + Logger.LogWarning( + "Streaming LLM failure-update could not edit placeholder; persisting last flushed partial as terminal. correlation={CorrelationId}, code={Code}, platformMessageId={PlatformMessageId}", + evt.CorrelationId, + failureResult.ErrorCode, + platformMessageId); + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.TerminalPartial, + terminalReason: $"failed_self_heal_edit_failed:{failureResult.ErrorCode}"); + await PersistStreamedCompletionAsync(evt, commandId, referenceActivity, platformMessageId, state.LastFlushedText, state.EditCount); + return true; + } + + if (evt.TerminalState != LlmReplyTerminalState.Completed) + return false; var finalText = evt.Outbound?.Text ?? string.Empty; if (string.IsNullOrWhiteSpace(finalText)) { @@ -674,6 +775,11 @@ private async Task TryCompleteStreamedReplyAsync( "Streaming LLM reply final text was empty; persisting last flushed partial as terminal. correlation={CorrelationId} platformMessageId={PlatformMessageId}", evt.CorrelationId, platformMessageId); + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.TerminalPartial, + terminalReason: "empty_final_text"); await PersistStreamedCompletionAsync(evt, commandId, referenceActivity, platformMessageId, state.LastFlushedText, state.EditCount); return true; } @@ -690,11 +796,12 @@ private async Task TryCompleteStreamedReplyAsync( AccumulatedText = finalText, ChunkAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; + using var finalChunkCts = new CancellationTokenSource(StreamingFailureUpdateTimeout); var finalResult = await runner.RunStreamChunkAsync( finalChunk, platformMessageId, runtimeContext, - CancellationToken.None); + finalChunkCts.Token); if (!finalResult.Success) { // The reply token was already consumed by the first chunk, so falling back to @@ -707,12 +814,22 @@ private async Task TryCompleteStreamedReplyAsync( evt.CorrelationId, finalResult.ErrorCode, platformMessageId); + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.TerminalPartial, + terminalReason: $"final_edit_failed:{finalResult.ErrorCode}"); await PersistStreamedCompletionAsync(evt, commandId, referenceActivity, platformMessageId, state.LastFlushedText, state.EditCount); return true; } edits += 1; } + TransitionNyxRelayStreamingPhase( + correlationId, + state, + NyxRelayStreamingPhase.TerminalSucceeded, + terminalReason: "completed"); await PersistStreamedCompletionAsync(evt, commandId, referenceActivity, platformMessageId, finalText, edits); return true; } @@ -1053,9 +1170,9 @@ private ConversationTurnRuntimeContext BuildNyxRelayRuntimeContextForReply( { var activity = pendingActivity ?? evt.Activity; - // Inbox-echoed credential is the authoritative source — it survives actor + // Run-echoed credential is the authoritative source: it survives actor // deactivation between inbound capture and LLM reply ready, which the in-memory - // dict cannot. Fall back to the dict only when the inbox didn't carry a token + // dict cannot. Fall back to the dict only when the run event didn't carry a token // (legacy in-flight messages from before this change deployed). var inlineToken = NormalizeOptional(evt.ReplyToken); if (inlineToken is not null) @@ -1082,7 +1199,7 @@ private string DescribeReplyTokenSource(LlmReplyReadyEvent evt, ConversationTurn if (runtimeContext.NyxRelayReplyToken is null) return "none"; if (!string.IsNullOrWhiteSpace(evt.ReplyToken)) - return "inbox-echo"; + return "run-echo"; return "actor-runtime-dict"; } @@ -1107,6 +1224,7 @@ private void RemoveNyxRelayReplyToken(string? correlationId, ChatActivity? activ { _nyxRelayReplyTokens.Remove(normalizedCorrelationId); _nyxRelayStreamingStates.Remove(normalizedCorrelationId); + _larkCardStreamingStates.Remove(normalizedCorrelationId); } } diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyInbox.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyInbox.cs deleted file mode 100644 index f3d10ce82..000000000 --- a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyInbox.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.GAgents.Channel.Runtime; - -public interface IChannelLlmReplyInbox -{ - Task EnqueueAsync(NeedsLlmReplyEvent request, CancellationToken ct); -} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyRunDispatcher.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyRunDispatcher.cs new file mode 100644 index 000000000..df2889a1f --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IChannelLlmReplyRunDispatcher.cs @@ -0,0 +1,10 @@ +namespace Aevatar.GAgents.Channel.Runtime; + +/// +/// Stateless port used by to hand one deferred +/// LLM reply run to its run-scoped continuation owner. +/// +public interface IChannelLlmReplyRunDispatcher +{ + Task DispatchAsync(NeedsLlmReplyEvent request, CancellationToken ct); +} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IConversationCardTurnRunner.cs b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IConversationCardTurnRunner.cs new file mode 100644 index 000000000..7459bd1a4 --- /dev/null +++ b/agents/Aevatar.GAgents.Channel.Runtime/Conversation/IConversationCardTurnRunner.cs @@ -0,0 +1,216 @@ +using Aevatar.GAgents.Channel.Abstractions; + +namespace Aevatar.GAgents.Channel.Runtime; + +/// +/// Runs the CardKit-streaming variant of a bot turn inside . +/// Parallel to but with three distinct operations +/// (create-and-send, interim element stream, finalize) to match Lark CardKit's lifecycle. +/// The grain owns the per-turn LarkCardStreamingState; this seam only does the +/// outbound call and translates the response into a runner-shaped result. +/// +/// +/// All three operations are invoked under the actor's turn-serial invariant, so the runner +/// implementation must be safe under that single-threaded contract. The +/// sequence parameter is owned by the grain (pre-incremented before each call) and +/// passed verbatim into the CardKit API. +/// +public interface IConversationCardTurnRunner +{ + /// + /// Allocates a new CardKit card entity (POST /open-apis/cardkit/v1/cards), binds it + /// to the chat via an interactive im/v1/messages send referencing the new + /// card_id, and writes the initial accumulated text into + /// . Implicit sequence = 1. + /// + Task RunCardCreateAsync( + LlmReplyCardStreamChunkEvent chunk, + string streamingElementId, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct); + + /// + /// Streams the latest accumulated text into the existing card element. Sequence is + /// pre-incremented by the grain. Lark rejects stale sequences deterministically. + /// + Task RunCardStreamAsync( + LlmReplyCardStreamChunkEvent chunk, + string cardId, + string elementId, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct); + + /// + /// Closes the card's streaming mode (cursor disappears) and, if the final text differs + /// from the last interim flush, writes one more element-content update so the persisted + /// card matches the LLM's final output. + /// + /// + /// Carries TransportExtras.NyxUserAccessToken for the proxy call. Stream chunk + /// methods read it from the chunk's own activity; finalize is invoked from the + /// LlmReplyReadyEvent path so the actor passes the event's reference activity + /// here instead of a chunk. + /// + Task RunCardFinalizeAsync( + ChatActivity referenceActivity, + string cardId, + string elementId, + string finalText, + bool finalTextDiffersFromLastFlushed, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct); +} + +/// +/// Outcome of . The classification +/// flags drive the grain's fallback decision: +/// +/// Pre-send failures (create call rejected before any chat-visible side effect): the +/// actor transitions to CreationFailed and falls back to the legacy text-edit sink +/// so the user still sees a reply. / +/// imply this path. +/// Post-send failures (create + send succeeded but the first stream-content write +/// failed — see ): an empty card is already visible in the +/// chat. Falling back to text-edit would produce a duplicate reply. The actor terminates +/// the turn at Terminated using the surfaced / +/// and persists the partial-card terminal record. The runner +/// makes a best-effort settings patch to close streaming mode on the orphan card before +/// returning so the cursor does not blink forever. +/// on its own terminates the turn (no fallback). +/// +/// +public sealed record ConversationCardCreateResult( + bool Success, + string? CardId, + string? CardMessageId, + bool IsRateLimited, + bool IsTableLimitExceeded, + bool IsCardUnavailable, + bool IsPostSendFailure, + string ErrorCode, + string ErrorSummary) +{ + public static ConversationCardCreateResult Succeeded(string cardId, string cardMessageId) => + new(true, cardId, cardMessageId, false, false, false, false, string.Empty, string.Empty); + + public static ConversationCardCreateResult Failed( + string errorCode, + string errorSummary, + bool isRateLimited = false, + bool isTableLimitExceeded = false, + bool isCardUnavailable = false) => + new(false, null, null, isRateLimited, isTableLimitExceeded, isCardUnavailable, false, errorCode, errorSummary); + + /// + /// Failure factory for the "card was already sent to the chat but the first + /// element-content write failed" case. The actor must NOT fall back to text-edit + /// (the orphan card is already visible) — it transitions the turn to Terminated + /// and uses / for the + /// persisted partial-card record. + /// + public static ConversationCardCreateResult PostSendFailed( + string cardId, + string cardMessageId, + string errorCode, + string errorSummary, + bool isRateLimited = false, + bool isTableLimitExceeded = false, + bool isCardUnavailable = false) => + new(false, cardId, cardMessageId, isRateLimited, isTableLimitExceeded, isCardUnavailable, true, errorCode, errorSummary); +} + +/// +/// Outcome of . Mid-stream +/// rate-limit (Lark 230020) is recoverable — the grain skips the frame and continues. +/// Table-limit (230099/11310) and unavailability terminate the turn. +/// +public sealed record ConversationCardStreamResult( + bool Success, + bool IsRateLimited, + bool IsTableLimitExceeded, + bool IsCardUnavailable, + string ErrorCode, + string ErrorSummary) +{ + public static ConversationCardStreamResult Succeeded() => + new(true, false, false, false, string.Empty, string.Empty); + + public static ConversationCardStreamResult Failed( + string errorCode, + string errorSummary, + bool isRateLimited = false, + bool isTableLimitExceeded = false, + bool isCardUnavailable = false) => + new(false, isRateLimited, isTableLimitExceeded, isCardUnavailable, errorCode, errorSummary); +} + +/// True only when both the optional final stream write AND the +/// streaming-mode close succeeded. +/// +/// True when the trailing element-content write either succeeded OR was skipped +/// (final text equals last flushed). False only when the runner attempted the trailing +/// write and it failed; lets the actor persist the visible-state text correctly when +/// success is false but the final text actually did land before the close-streaming-mode +/// failure. +/// +public sealed record ConversationCardFinalizeResult( + bool Success, + bool FinalTextWritten, + string ErrorCode, + string ErrorSummary) +{ + public static ConversationCardFinalizeResult Succeeded() => + new(true, true, string.Empty, string.Empty); + + /// + /// Failure factory. distinguishes between "trailing + /// write failed; user sees stale interim" (false) and "trailing write succeeded but + /// streaming-mode close failed; user sees the final text with a still-blinking cursor" + /// (true). + /// + public static ConversationCardFinalizeResult Failed(string errorCode, string errorSummary, bool finalTextWritten = false) => + new(false, finalTextWritten, errorCode, errorSummary); +} + +/// +/// No-op default. Every CardKit operation reports a transient failure that disables the +/// card path so the grain can fall back to the legacy text-edit sink. Production DI registers +/// a real implementation when CardKit is enabled. +/// +public sealed class NullConversationCardTurnRunner : IConversationCardTurnRunner +{ + public Task RunCardCreateAsync( + LlmReplyCardStreamChunkEvent chunk, + string streamingElementId, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) => + Task.FromResult(ConversationCardCreateResult.Failed( + "no_card_runner", + "no IConversationCardTurnRunner registered")); + + public Task RunCardStreamAsync( + LlmReplyCardStreamChunkEvent chunk, + string cardId, + string elementId, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) => + Task.FromResult(ConversationCardStreamResult.Failed( + "no_card_runner", + "no IConversationCardTurnRunner registered")); + + public Task RunCardFinalizeAsync( + ChatActivity referenceActivity, + string cardId, + string elementId, + string finalText, + bool finalTextDiffersFromLastFlushed, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) => + Task.FromResult(ConversationCardFinalizeResult.Failed( + "no_card_runner", + "no IConversationCardTurnRunner registered")); +} diff --git a/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs index aa2c48d55..5de88c691 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/DependencyInjection/ChannelRuntimeServiceCollectionExtensions.cs @@ -48,6 +48,7 @@ public static IServiceCollection AddChannelRuntime( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); // ─── Tombstone compaction options + diagnostics + ES watermark ─── services.AddOptions(); diff --git a/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs b/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs index 1769c0a4a..64b09f271 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/IStreamingReplySink.cs @@ -2,13 +2,13 @@ namespace Aevatar.GAgents.Channel.Runtime; /// /// Receives per-delta streaming updates from so the reply -/// inbox can fan the accumulated text to the conversation actor as it is being generated. The +/// run actor can fan the accumulated text to the conversation actor as it is being generated. The /// actor is the sole holder of the relay reply token, so only it is allowed to drive the relay /// placeholder send and subsequent edit calls; this sink therefore fans out signals (chunk events) /// and never touches the outbound port directly. /// /// -/// Implementations are per-turn and owned by the inbox runtime. A null sink signals that streaming +/// Implementations are per-turn and owned by the run actor. A null sink signals that streaming /// is disabled for the turn (for example, the feature flag is off, the activity is not a relay /// turn, or an earlier failure invalidated the turn); generators must tolerate a null sink by /// simply accumulating the final text without calling any sink method. diff --git a/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs b/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs index 8d846797b..c6ddcebd3 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs +++ b/agents/Aevatar.GAgents.Channel.Runtime/TurnStreamingReplySink.cs @@ -1,6 +1,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Runtime; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -31,7 +32,7 @@ namespace Aevatar.GAgents.Channel.Runtime; /// bypasses the throttle so the actor sees the complete text /// once the stream ends; if a dispatch is in flight, the final text reflushes after it and /// awaits the dispatch loop's drain signal before returning so the -/// caller (the inbox runtime) does not race the ready event past the final chunk. +/// caller (the run actor) does not race the ready event past the final chunk. /// /// /// @@ -52,6 +53,8 @@ public sealed class TurnStreamingReplySink : IStreamingReplySink, IDisposable private readonly string _registrationId; private readonly ChatActivity _activityTemplate; private readonly TimeSpan _throttle; + private readonly int _maxInterimChunks; + private readonly bool _cardMode; private readonly TimeProvider _timeProvider; private readonly ILogger? _logger; @@ -65,7 +68,7 @@ public sealed class TurnStreamingReplySink : IStreamingReplySink, IDisposable private bool _dispatchInProgress; private bool _disposed; // Signaled by the dispatch loop when it fully drains. FinalizeAsync awaits this when a - // dispatch is already in flight so the caller does not race the inbox runtime's + // dispatch is already in flight so the caller does not race AgentRunGAgent's // LlmReplyReadyEvent past the final chunk dispatch (the ConversationGAgent // processed-command guard would otherwise drop the late chunk). private TaskCompletionSource? _drainTcs; @@ -78,7 +81,9 @@ public TurnStreamingReplySink( ChatActivity activityTemplate, TimeSpan throttle, TimeProvider timeProvider, - ILogger? logger = null) + ILogger? logger = null, + int maxInterimChunks = int.MaxValue, + bool cardMode = false) { _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); if (string.IsNullOrWhiteSpace(targetActorId)) @@ -90,6 +95,8 @@ public TurnStreamingReplySink( _registrationId = registrationId ?? string.Empty; _activityTemplate = activityTemplate ?? throw new ArgumentNullException(nameof(activityTemplate)); _throttle = throttle < TimeSpan.Zero ? TimeSpan.Zero : throttle; + _maxInterimChunks = maxInterimChunks < 0 ? 0 : maxInterimChunks; + _cardMode = cardMode; _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger; } @@ -109,7 +116,7 @@ public Task OnDeltaAsync(string accumulatedText, CancellationToken ct) => /// Applies the final accumulated text, bypassing the throttle so the actor can drive the final /// edit once the stream ends. If a dispatch is already in flight, the final text is stashed and /// this call awaits the dispatch loop's drain signal so the final chunk is on the wire before - /// the caller proceeds (the inbox runtime sends LlmReplyReadyEvent immediately after). + /// the caller proceeds (AgentRunGAgent sends LlmReplyReadyEvent immediately after). /// public Task FinalizeAsync(string finalText, CancellationToken ct) => FlushAsync(finalText, isFinal: true, ct); @@ -158,6 +165,19 @@ private async Task FlushAsync(string text, bool isFinal, CancellationToken ct) return; } + // Lark/Feishu refuses message edits past a per-message cap (~20 in mainnet, code + // 230072). Once that cap is reached the platform rejects every subsequent edit + // including the final flush, leaving the user with a truncated reply. Cap interim + // dispatches here so the final always has headroom; we still stash the latest text + // so FinalizeAsync can dispatch the complete content when the stream ends. + if (!isFinal && _chunksEmitted >= _maxInterimChunks) + { + _pendingText = text; + _hasPending = true; + CancelTimerLocked(); + return; + } + if (_dispatchInProgress) { // A dispatch is in flight. Stash the latest text; the dispatch loop's reflush @@ -168,7 +188,7 @@ private async Task FlushAsync(string text, bool isFinal, CancellationToken ct) if (isFinal) { // Block FinalizeAsync until the dispatch loop drains the stashed final text. - // Without this wait, ChannelLlmReplyInboxRuntime sends LlmReplyReadyEvent + // Without this wait, AgentRunGAgent sends LlmReplyReadyEvent // first and ConversationGAgent's processed-command guard drops the late // final chunk. _drainTcs ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -265,7 +285,7 @@ private async Task DispatchLoopAsync(string firstText, CancellationToken ct) { await DispatchOneAsync(current, ct).ConfigureAwait(false); - string? next; + string? next = null; lock (_lock) { if (_disposed || !_hasPending) @@ -286,6 +306,61 @@ private async Task DispatchLoopAsync(string firstText, CancellationToken ct) break; } + var nextIsFinal = _drainTcs is not null; + + // Stop dispatching interim chunks once the cap is reached. Clear the + // pending stash too — keeping it would only cost a follow-up + // OnDeltaAsync re-overwrites it with newer accumulated text anyway, and + // an explicit drain here matches the invariant the reviewer asked for + // (PR #562 review #14): pending text is never left behind when we + // release _dispatchInProgress=false. FinalizeAsync, when it arrives + // later, uses its `text` parameter (not _pendingText), so this clear + // doesn't affect the final flush. + if (!nextIsFinal && _chunksEmitted >= _maxInterimChunks) + { + _pendingText = string.Empty; + _hasPending = false; + _dispatchInProgress = false; + drainSignal = _drainTcs; + _drainTcs = null; + break; + } + + // Throttle gate between dispatches. Without this, the loop drains stashed + // text at network round-trip pace (~50ms) and exhausts the platform-side + // per-message edit cap (Lark code 230072). When the throttle window has + // not elapsed, arm the deferred timer atomically with releasing + // _dispatchInProgress so a concurrent OnDeltaAsync (PR #562 review #17) + // cannot squeeze in between the release and the arm and observe a stale + // (no-timer + not-dispatching) state. Final dispatches bypass the + // throttle so the user sees the complete text immediately when the + // stream ends. + // + // Invariant: if we reach this branch, nextIsFinal == false, so _drainTcs + // must be null. FinalizeAsync sets _drainTcs only when it arrives during + // an in-flight dispatch, and that path re-evaluates nextIsFinal inside + // this same lock acquisition. We do NOT signal drainSignal here: the + // timer-driven loop is the one that eventually drains _pendingText and + // signals whatever _drainTcs gets attached. + if (!nextIsFinal && _throttle > TimeSpan.Zero) + { + var elapsed = _timeProvider.GetUtcNow() - _lastEmitAt; + if (elapsed < _throttle) + { + var delay = _throttle - elapsed; + _dispatchInProgress = false; + if (!_disposed && _hasPending && _flushTimer is null) + { + _flushTimer = _timeProvider.CreateTimer( + OnFlushTimerFired, + state: null, + dueTime: delay, + period: Timeout.InfiniteTimeSpan); + } + break; + } + } + next = _pendingText; _pendingText = string.Empty; _hasPending = false; @@ -312,14 +387,26 @@ private async Task DispatchLoopAsync(string firstText, CancellationToken ct) private async Task DispatchOneAsync(string text, CancellationToken ct) { - var chunk = new LlmReplyStreamChunkEvent - { - CorrelationId = _correlationId, - RegistrationId = _registrationId, - Activity = _activityTemplate.Clone(), - AccumulatedText = text, - ChunkAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), - }; + // Card mode dispatches a structurally distinct message type so persistence layers + // cannot silently re-route a replayed event back to the card sink. The two proto + // types carry identical payloads; the type identity itself signals routing. + IMessage chunk = _cardMode + ? new LlmReplyCardStreamChunkEvent + { + CorrelationId = _correlationId, + RegistrationId = _registrationId, + Activity = _activityTemplate.Clone(), + AccumulatedText = text, + ChunkAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + } + : new LlmReplyStreamChunkEvent + { + CorrelationId = _correlationId, + RegistrationId = _registrationId, + Activity = _activityTemplate.Clone(), + AccumulatedText = text, + ChunkAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }; var envelope = new EventEnvelope { Id = Guid.NewGuid().ToString("N"), diff --git a/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto b/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto index ea0cc98e6..801f0b0c7 100644 --- a/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto +++ b/agents/Aevatar.GAgents.Channel.Runtime/protos/conversation_events.proto @@ -30,10 +30,10 @@ message NeedsLlmReplyEvent { aevatar.gagents.channel.abstractions.ChatActivity activity = 4; map metadata = 5; int64 requested_at_unix_ms = 6; - // Transient inbox-only credential. The actor MUST clear `reply_token` and + // Transient run-command-only credential. The actor MUST clear `reply_token` and // `reply_token_expires_at_unix_ms` (set them to the empty default) on the - // copy passed to PersistDomainEventAsync; only the inbox-bound copy may - // carry them so the LLM worker can echo the credential back without the + // copy passed to PersistDomainEventAsync; only the run-bound copy may + // carry them so AgentRunGAgent can echo the credential back without the // actor's in-memory dict surviving deactivation. Never persist to event // store, projection, or read model. string reply_token = 7; @@ -70,16 +70,16 @@ message LlmReplyReadyEvent { string error_code = 7; string error_summary = 8; int64 ready_at_unix_ms = 9; - // Transient inbox-echoed credential carried back from the LLM worker so the + // Transient run-echoed credential carried back from AgentRunGAgent so the // actor's outbound relay reply does not depend on its in-memory token dict // surviving deactivation. The actor consumes these fields directly and never - // persists them. The inbox subscriber copies the values from the inbound + // persists them. AgentRunGAgent copies the values from the inbound // NeedsLlmReplyEvent verbatim. string reply_token = 10; int64 reply_token_expires_at_unix_ms = 11; } -// Per-delta streaming signal dispatched from the LLM inbox runtime to the conversation actor while +// Per-delta streaming signal dispatched from AgentRunGAgent to the conversation actor while // the reply is still being generated. The actor owns the outbound reply credential and the // placeholder message identifier for the turn, so it must be the one issuing the relay placeholder // send and subsequent edit calls. This message carries only the cumulative accumulated text for @@ -87,6 +87,14 @@ message LlmReplyReadyEvent { // in-memory keyed by `correlation_id`. This event must never be persisted — it is a runtime-only // signal. message LlmReplyStreamChunkEvent { + // Field 6 (`card_mode`) was a runtime-only routing flag that has been promoted to its own + // message type (`LlmReplyCardStreamChunkEvent`) so the structural contract of this domain- + // event-shaped envelope no longer carries any "should I re-route to a different sink?" + // signal. Reserved here so accidental reuse of the field number, or a stale serializer + // built before the split, fails loudly instead of silently flipping back to card mode. + reserved 6; + reserved "card_mode"; + string correlation_id = 1; string registration_id = 2; // Clone of the inbound activity so the actor/turn runner can resolve the platform, conversation, @@ -97,6 +105,24 @@ message LlmReplyStreamChunkEvent { int64 chunk_at_unix_ms = 5; } +// Per-delta streaming signal for the Lark CardKit (card-mode) outbound path. Identical +// payload to LlmReplyStreamChunkEvent, but a separate proto type so the routing decision is +// structural: there is no boolean a misbehaving persistence layer can flip — the actor's +// HandleLlmReplyCardStreamChunkAsync handler is reachable only via this type. Like its +// edit-message sibling, this event is a runtime-only signal and must never be persisted to +// the event store, projection, or any durable state. +message LlmReplyCardStreamChunkEvent { + string correlation_id = 1; + string registration_id = 2; + // Clone of the inbound activity so the actor/runner can resolve the platform, conversation, + // delivery context, and TransportExtras (NyxUserAccessToken, NyxLarkChatId, NyxLarkUnionId) + // without re-reading from durable state. + aevatar.gagents.channel.abstractions.ChatActivity activity = 3; + // Current accumulated reply text (not a delta slice). Each chunk supersedes the previous one. + string accumulated_text = 4; + int64 chunk_at_unix_ms = 5; +} + message DeferredLlmReplyDispatchRequestedEvent { string correlation_id = 1; int64 requested_at_unix_ms = 2; @@ -128,7 +154,7 @@ message NyxRelayReplyTokenCleanupRequestedEvent { int64 requested_at_unix_ms = 2; } -// Sent by ChannelLlmReplyInboxRuntime when its pre-LLM gates (stale age, +// Sent by AgentRunGAgent when its pre-LLM gates (stale age, // missing relay credential, malformed payload) refuse to process a deferred // LLM reply. The actor consumes this to retire the matching pending entry // from State.PendingLlmReplyRequests via a NotRetryable diff --git a/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj b/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj index 85b7ac4fb..4d1f9ab4d 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj +++ b/agents/Aevatar.GAgents.NyxidChat/Aevatar.GAgents.NyxidChat.csproj @@ -24,6 +24,7 @@ + @@ -35,10 +36,21 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentRunDispatcher.cs b/agents/Aevatar.GAgents.NyxidChat/AgentRunDispatcher.cs new file mode 100644 index 000000000..fadee6582 --- /dev/null +++ b/agents/Aevatar.GAgents.NyxidChat/AgentRunDispatcher.cs @@ -0,0 +1,65 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.NyxidChat; + +/// +/// Thin Channel.Runtime port implementation that creates the run actor and +/// dispatches the start command. It holds no run state. +/// +public sealed class AgentRunDispatcher : IChannelLlmReplyRunDispatcher +{ + private readonly IActorRuntime _actorRuntime; + private readonly IStreamProvider _streamProvider; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public AgentRunDispatcher( + IActorRuntime actorRuntime, + IStreamProvider streamProvider, + ILogger logger, + TimeProvider? timeProvider = null) + { + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _streamProvider = streamProvider ?? throw new ArgumentNullException(nameof(streamProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task DispatchAsync(NeedsLlmReplyEvent request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.CorrelationId)) + throw new InvalidOperationException("Deferred LLM reply request requires correlation_id for AgentRunGAgent dispatch."); + + var runId = request.CorrelationId.Trim(); + var actorId = AgentRunGAgent.BuildActorId(runId); + var actor = await _actorRuntime.GetAsync(actorId) + ?? await _actorRuntime.CreateAsync(actorId, ct); + + var command = new AgentRunStartRequested + { + Request = request.Clone(), + }; + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()), + Payload = Any.Pack(command), + Route = EnvelopeRouteSemantics.CreateDirect("channel-llm-reply-run-dispatcher", actor.Id), + Propagation = new EnvelopePropagation + { + CorrelationId = runId, + }, + }; + + await _streamProvider.GetStream(actor.Id).ProduceAsync(envelope, ct); + _logger.LogInformation( + "Accepted deferred LLM reply run for actor inbox: runId={RunId} actorId={ActorId} target={TargetActorId}", + runId, + actor.Id, + request.TargetActorId); + } +} diff --git a/agents/Aevatar.GAgents.NyxidChat/AgentRunGAgent.cs b/agents/Aevatar.GAgents.NyxidChat/AgentRunGAgent.cs new file mode 100644 index 000000000..f22375817 --- /dev/null +++ b/agents/Aevatar.GAgents.NyxidChat/AgentRunGAgent.cs @@ -0,0 +1,808 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Abstractions.Runtime.Callbacks; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.NyxidChat; + +/// +/// Run-scoped continuation owner for one deferred channel LLM reply. +/// +public sealed class AgentRunGAgent : GAgentBase +{ + public const string ActorIdPrefix = "channel-agent-run:"; + + internal const long MaxRunRequestAgeMs = 5 * 60 * 1000; + + /// + /// Hard upper bound on a single LLM reply turn. Mirrors + /// NyxIdRelayOptions.ResponseTimeoutSeconds (default 300s). + /// A configured value of 0 or negative is treated as "disable the cap". + /// + internal const int FallbackTimeoutSecondsDefault = 300; + + /// + /// Standalone budget for metadata enrichment (scope resolve + UserConfig lookup). + /// + internal static readonly TimeSpan MetadataBuildBudget = TimeSpan.FromSeconds(15); + + internal static readonly TimeSpan TerminalCleanupDelay = TimeSpan.FromMinutes(5); + private const string TerminalCleanupCallbackPrefix = "agent-run-terminal-cleanup"; + internal static readonly TimeSpan OutputDispatchRetryDelay = TimeSpan.FromSeconds(5); + private const string OutputDispatchRetryCallbackPrefix = "agent-run-output-dispatch-retry"; + + private readonly IActorRuntime _actorRuntime; + private readonly IActorDispatchPort _actorDispatchPort; + private readonly IConversationReplyGenerator _replyGenerator; + private readonly IInteractiveReplyCollector? _interactiveReplyCollector; + private readonly Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? _relayOptions; + private readonly INyxIdRelayScopeResolver? _scopeResolver; + private readonly IUserConfigQueryPort? _userConfigQueryPort; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public AgentRunGAgent( + IActorRuntime actorRuntime, + IActorDispatchPort actorDispatchPort, + IConversationReplyGenerator replyGenerator, + IInteractiveReplyCollector? interactiveReplyCollector, + Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? relayOptions, + ILogger logger, + INyxIdRelayScopeResolver? scopeResolver = null, + IUserConfigQueryPort? userConfigQueryPort = null, + TimeProvider? timeProvider = null) + { + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); + _replyGenerator = replyGenerator ?? throw new ArgumentNullException(nameof(replyGenerator)); + _interactiveReplyCollector = interactiveReplyCollector; + _relayOptions = relayOptions; + _scopeResolver = scopeResolver; + _userConfigQueryPort = userConfigQueryPort; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public static string BuildActorId(string correlationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); + return ActorIdPrefix + correlationId.Trim(); + } + + protected override AgentRunGAgentState TransitionState(AgentRunGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyStarted) + .On(ApplyReplyProduced) + .On(ApplyDropped) + .On(ApplyFailed) + .OrCurrent(); + + [EventHandler] + public async Task HandleStartAsync(AgentRunStartRequested command) + { + ArgumentNullException.ThrowIfNull(command); + if (command.Request is null) + { + _logger.LogWarning("Dropping malformed agent run start command without request: runActor={RunActorId}", Id); + return; + } + + var request = command.Request.Clone(); + var runId = NormalizeOptional(request.CorrelationId) ?? Id; + var startedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + + if (State.Status is AgentRunStatus.ReplyProduced or AgentRunStatus.Dropped or AgentRunStatus.Failed) + { + _logger.LogInformation( + "Ignoring duplicate terminal agent run start: runId={RunId} status={Status}", + runId, + State.Status); + await ScheduleTerminalCleanupAsync(NormalizeOptional(State.RunId) ?? runId); + return; + } + + if (string.IsNullOrWhiteSpace(State.RunId)) + { + await PersistDomainEventAsync(new AgentRunStartedEvent + { + RunId = runId, + CorrelationId = request.CorrelationId, + TargetActorId = request.TargetActorId, + StartedAtUnixMs = startedAtUnixMs, + }); + } + + try + { + await ProcessAsync(request, runId); + } + catch (AgentRunOutputDispatchException ex) + { + if (!await TryHandleOutputDispatchFailureAsync(request, runId, ex)) + throw; + } + catch (Exception ex) + { + await FailAfterUnexpectedExceptionAsync(request, runId, ex); + } + } + + [EventHandler] + public async Task HandleCleanupAsync(AgentRunCleanupRequested command) + { + ArgumentNullException.ThrowIfNull(command); + if (State.Status is not (AgentRunStatus.ReplyProduced or AgentRunStatus.Dropped or AgentRunStatus.Failed)) + return; + if (!string.IsNullOrWhiteSpace(command.RunId) && + !string.IsNullOrWhiteSpace(State.RunId) && + !string.Equals(command.RunId, State.RunId, StringComparison.Ordinal)) + { + return; + } + + await _actorRuntime.DestroyAsync(Id, CancellationToken.None); + } + + private async Task ProcessAsync(NeedsLlmReplyEvent request, string runId) + { + _logger.LogInformation( + "Processing agent run LLM reply request: runId={RunId} correlation={CorrelationId} target={TargetActorId}", + runId, + request.CorrelationId, + request.TargetActorId); + + if (request.Activity is null || string.IsNullOrWhiteSpace(request.TargetActorId)) + { + _logger.LogWarning( + "Dropping malformed deferred LLM reply request: runId={RunId}, correlation={CorrelationId}, target={TargetActorId}", + runId, + request.CorrelationId, + request.TargetActorId); + await DropAsync(request, runId, "malformed_deferred_llm_reply_request"); + return; + } + + // Stale gate: NyxID relay reply tokens have a ~30 min TTL and the user access + // token used for the LLM call expires inside ~15 min. A request that has been + // delayed past the run window cannot lead to a successful reply. + var nowMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + if (request.RequestedAtUnixMs > 0 && nowMs - request.RequestedAtUnixMs > MaxRunRequestAgeMs) + { + _logger.LogInformation( + "Dropping stale LLM reply request: runId={RunId} correlation={CorrelationId} ageMs={AgeMs}", + runId, + request.CorrelationId, + nowMs - request.RequestedAtUnixMs); + await DropAsync(request, runId, "stale_agent_run_request_dropped"); + return; + } + + // Relay credential gate: relay turns require a fresh reply_token to send the + // outbound. A relay request with no command-carried token cannot be delivered, + // so skip the LLM call entirely. + if (IsRelayRequest(request) && string.IsNullOrWhiteSpace(request.ReplyToken)) + { + _logger.LogWarning( + "Dropping relay LLM reply request without command-carried reply_token: runId={RunId} correlation={CorrelationId}", + runId, + request.CorrelationId); + await DropAsync(request, runId, "missing_relay_reply_token"); + return; + } + + await EnsureTargetActorAsync(request.TargetActorId); + + string replyText; + MessageContent? outboundIntent = null; + var terminalState = LlmReplyTerminalState.Completed; + var errorCode = string.Empty; + var errorSummary = string.Empty; + using TurnStreamingReplySink? streamingSink = TryBuildStreamingSink(request, request.TargetActorId); + + IReadOnlyDictionary effectiveMetadata; + using (var metadataCts = new CancellationTokenSource(MetadataBuildBudget)) + { + try + { + effectiveMetadata = await BuildEffectiveMetadataAsync(request, metadataCts.Token); + } + catch (OperationCanceledException ex) when (metadataCts.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Deferred LLM reply metadata build timed out after {TimeoutSeconds}s: runId={RunId} correlation={CorrelationId}", + (int)MetadataBuildBudget.TotalSeconds, + runId, + request.CorrelationId); + replyText = "Sorry, I couldn't load your model preferences in time. Please try again."; + terminalState = LlmReplyTerminalState.Failed; + errorCode = "llm_reply_metadata_timeout"; + errorSummary = $"Metadata enrichment exceeded {(int)MetadataBuildBudget.TotalSeconds}s budget."; + await FailAndDispatchReadyAsync(request, runId, replyText, outboundIntent, terminalState, errorCode, errorSummary); + return; + } + } + + var fallbackTimeout = ResolveFallbackTimeout(); + using var timeoutCts = fallbackTimeout > TimeSpan.Zero + ? new CancellationTokenSource(fallbackTimeout) + : new CancellationTokenSource(); + + try + { + IDisposable? interactiveReplyScope = null; + try + { + if (ShouldCaptureInteractiveReply(request.Activity)) + interactiveReplyScope = _interactiveReplyCollector?.BeginScope(); + + replyText = await _replyGenerator.GenerateReplyAsync( + request.Activity, + effectiveMetadata, + streamingSink, + timeoutCts.Token) ?? string.Empty; + outboundIntent = _interactiveReplyCollector?.TryTake(); + } + finally + { + interactiveReplyScope?.Dispose(); + } + + if (streamingSink is not null && + outboundIntent is null && + !string.IsNullOrWhiteSpace(replyText)) + { + await streamingSink.FinalizeAsync(replyText, CancellationToken.None); + } + + if (outboundIntent is null && string.IsNullOrWhiteSpace(replyText)) + { + terminalState = LlmReplyTerminalState.Failed; + errorCode = "empty_reply"; + errorSummary = "Reply generator returned an empty response."; + replyText = "Sorry, I wasn't able to generate a response. Please try again."; + } + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) + { + terminalState = LlmReplyTerminalState.Failed; + errorCode = "llm_reply_timeout"; + errorSummary = $"LLM reply generation exceeded {(int)fallbackTimeout.TotalSeconds}s budget."; + replyText = "Sorry, this took too long to process - the model or one of its tools didn't " + + "respond in time. Please try again, or rephrase the request."; + _logger.LogWarning( + ex, + "Deferred LLM reply timed out after {TimeoutSeconds}s: runId={RunId} correlation={CorrelationId}", + (int)fallbackTimeout.TotalSeconds, + runId, + request.CorrelationId); + } + catch (Exception ex) + { + terminalState = LlmReplyTerminalState.Failed; + errorCode = "llm_reply_failed"; + errorSummary = ex.Message; + replyText = NyxIdRelayErrorClassifier.Classify(ex.Message); + _logger.LogWarning( + ex, + "Deferred LLM reply generation failed: runId={RunId} correlation={CorrelationId}", + runId, + request.CorrelationId); + } + + if (terminalState == LlmReplyTerminalState.Failed) + { + await FailAndDispatchReadyAsync( + request, + runId, + replyText, + outboundIntent, + terminalState, + errorCode, + errorSummary); + return; + } + + await DispatchReadyEventAsync(request, replyText, outboundIntent, terminalState, errorCode, errorSummary); + await PersistReplyProducedAsync(request, runId, terminalState, errorCode, errorSummary); + } + + private async Task FailAndDispatchReadyAsync( + NeedsLlmReplyEvent request, + string runId, + string replyText, + MessageContent? outboundIntent, + LlmReplyTerminalState terminalState, + string errorCode, + string errorSummary) + { + await DispatchReadyEventAsync(request, replyText, outboundIntent, terminalState, errorCode, errorSummary); + await PersistFailedAsync(request, runId, errorCode, errorSummary); + } + + private async Task DropAsync(NeedsLlmReplyEvent request, string runId, string reason) + { + if (CanNotifyDrop(request)) + await DispatchDropNotificationAsync(request, reason); + + await PersistDomainEventAsync(new AgentRunDroppedEvent + { + RunId = runId, + CorrelationId = request.CorrelationId, + TargetActorId = request.TargetActorId, + Reason = reason, + DroppedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }); + + await ScheduleTerminalCleanupAsync(runId); + } + + private async Task PersistReplyProducedAsync( + NeedsLlmReplyEvent request, + string runId, + LlmReplyTerminalState terminalState, + string errorCode, + string errorSummary) + { + await PersistDomainEventAsync(new AgentRunReplyProducedEvent + { + RunId = runId, + CorrelationId = request.CorrelationId, + TargetActorId = request.TargetActorId, + TerminalState = terminalState, + ErrorCode = errorCode, + ErrorSummary = errorSummary, + ProducedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }); + + await ScheduleTerminalCleanupAsync(runId); + } + + private async Task PersistFailedAsync( + NeedsLlmReplyEvent request, + string runId, + string errorCode, + string errorSummary) + { + await PersistDomainEventAsync(new AgentRunFailedEvent + { + RunId = runId, + CorrelationId = request.CorrelationId, + TargetActorId = request.TargetActorId, + ErrorCode = errorCode, + ErrorSummary = errorSummary, + FailedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }); + + await ScheduleTerminalCleanupAsync(runId); + } + + private async Task FailAfterUnexpectedExceptionAsync(NeedsLlmReplyEvent request, string runId, Exception ex) + { + const string errorCode = "agent_run_unhandled_exception"; + var errorSummary = ex.Message; + _logger.LogError( + ex, + "Agent run failed with unhandled exception: runId={RunId} correlation={CorrelationId}", + runId, + request.CorrelationId); + + if (request.Activity is not null && !string.IsNullOrWhiteSpace(request.TargetActorId)) + { + try + { + await DispatchReadyEventAsync( + request, + "Sorry, I couldn't complete this reply. Please try again.", + null, + LlmReplyTerminalState.Failed, + errorCode, + errorSummary); + } + catch (AgentRunOutputDispatchException dispatchEx) + { + if (!await TryHandleOutputDispatchFailureAsync(request, runId, dispatchEx)) + throw; + return; + } + } + + await PersistFailedAsync(request, runId, errorCode, errorSummary); + } + + private async Task DispatchReadyEventAsync( + NeedsLlmReplyEvent request, + string replyText, + MessageContent? outboundIntent, + LlmReplyTerminalState terminalState, + string errorCode, + string errorSummary) + { + if (string.IsNullOrWhiteSpace(request.TargetActorId)) + return; + + var ready = new LlmReplyReadyEvent + { + CorrelationId = request.CorrelationId, + RegistrationId = request.RegistrationId, + SourceActorId = Id, + Activity = request.Activity!.Clone(), + Outbound = outboundIntent?.Clone() ?? new MessageContent { Text = replyText }, + TerminalState = terminalState, + ErrorCode = errorCode, + ErrorSummary = errorSummary, + ReadyAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + // Echo the command-only relay credential straight back so ConversationGAgent's + // outbound reply does not depend on its in-memory token dict still having the + // entry. The actor consumes these fields and never persists them. + ReplyToken = request.ReplyToken ?? string.Empty, + ReplyTokenExpiresAtUnixMs = request.ReplyTokenExpiresAtUnixMs, + }; + try + { + await SendToAsync(request.TargetActorId, ready, CancellationToken.None); + } + catch (Exception ex) + { + throw new AgentRunOutputDispatchException( + $"Failed to send LLM reply ready event to conversation actor '{request.TargetActorId}'.", + ex); + } + } + + private TurnStreamingReplySink? TryBuildStreamingSink(NeedsLlmReplyEvent request, string targetActorId) + { + if (_relayOptions is not { StreamingRepliesEnabled: true }) + return null; + if (request.Activity?.OutboundDelivery is not + { + ReplyMessageId.Length: > 0, + CorrelationId.Length: > 0, + }) + { + return null; + } + if (string.IsNullOrWhiteSpace(request.CorrelationId)) + return null; + + var cardMode = _relayOptions.StreamingCardKitEnabled; + var throttle = TimeSpan.FromMilliseconds(Math.Max(0, cardMode + ? _relayOptions.StreamingCardKitFlushIntervalMs + : _relayOptions.StreamingFlushIntervalMs)); + var maxInterimChunks = cardMode + ? int.MaxValue + : Math.Max(0, _relayOptions.StreamingMaxInterimChunks); + return new TurnStreamingReplySink( + _actorDispatchPort, + targetActorId, + request.CorrelationId, + request.RegistrationId, + request.Activity.Clone(), + throttle, + _timeProvider, + _logger, + maxInterimChunks, + cardMode); + } + + private async Task> BuildEffectiveMetadataAsync( + NeedsLlmReplyEvent request, + CancellationToken ct) + { + var metadata = new Dictionary(request.Metadata, StringComparer.Ordinal); + + await ApplyBotOwnerLlmConfigAsync(request, metadata, ct); + + var userAccessToken = request.Activity?.TransportExtras?.NyxUserAccessToken?.Trim(); + if (!string.IsNullOrWhiteSpace(userAccessToken)) + { + metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = userAccessToken; + metadata[LLMRequestMetadataKeys.NyxIdOrgToken] = userAccessToken; + } + + return metadata; + } + + private async Task ApplyBotOwnerLlmConfigAsync( + NeedsLlmReplyEvent request, + IDictionary metadata, + CancellationToken ct) + { + if (_scopeResolver is null || _userConfigQueryPort is null) + return; + + var apiKeyId = request.Activity?.Bot?.Value?.Trim(); + if (string.IsNullOrWhiteSpace(apiKeyId)) + return; + + string? scopeId; + try + { + scopeId = await _scopeResolver.ResolveScopeIdByApiKeyAsync(apiKeyId, ct); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to resolve bot owner scope id for LLM config: runId={RunId} correlation={CorrelationId} apiKeyId={ApiKeyId}", + Id, + request.CorrelationId, + apiKeyId); + return; + } + + if (string.IsNullOrWhiteSpace(scopeId)) + { + _logger.LogDebug( + "No bot owner scope id resolved for LLM config: runId={RunId} correlation={CorrelationId} apiKeyId={ApiKeyId}", + Id, + request.CorrelationId, + apiKeyId); + return; + } + + try + { + var config = await _userConfigQueryPort.GetAsync(scopeId, ct); + if (!string.IsNullOrWhiteSpace(config.DefaultModel)) + metadata[LLMRequestMetadataKeys.ModelOverride] = config.DefaultModel.Trim(); + if (!string.IsNullOrWhiteSpace(config.PreferredLlmRoute)) + metadata[LLMRequestMetadataKeys.NyxIdRoutePreference] = config.PreferredLlmRoute.Trim(); + if (config.MaxToolRounds > 0) + metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride] = + config.MaxToolRounds.ToString(System.Globalization.CultureInfo.InvariantCulture); + + _logger.LogInformation( + "Applied bot owner LLM config: runId={RunId} correlation={CorrelationId} scopeId={ScopeId} model={Model} route={Route}", + Id, + request.CorrelationId, + scopeId, + string.IsNullOrWhiteSpace(config.DefaultModel) ? "" : config.DefaultModel, + string.IsNullOrWhiteSpace(config.PreferredLlmRoute) ? "" : config.PreferredLlmRoute); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to load bot owner LLM config: runId={RunId} correlation={CorrelationId} scopeId={ScopeId}", + Id, + request.CorrelationId, + scopeId); + } + } + + private TimeSpan ResolveFallbackTimeout() + { + if (_relayOptions is null) + return TimeSpan.FromSeconds(FallbackTimeoutSecondsDefault); + var configured = _relayOptions.ResponseTimeoutSeconds; + if (configured <= 0) + return TimeSpan.Zero; + return TimeSpan.FromSeconds(configured); + } + + private static bool IsRelayRequest(NeedsLlmReplyEvent request) => + request.Activity?.OutboundDelivery is + { + ReplyMessageId.Length: > 0, + CorrelationId.Length: > 0, + }; + + private static bool CanNotifyDrop(NeedsLlmReplyEvent request) => + !string.IsNullOrWhiteSpace(request.TargetActorId) && + !string.IsNullOrWhiteSpace(request.CorrelationId); + + private async Task DispatchDropNotificationAsync(NeedsLlmReplyEvent request, string reason) + { + var dropped = new DeferredLlmReplyDroppedEvent + { + CorrelationId = request.CorrelationId, + Reason = reason, + DroppedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }; + + try + { + await SendToAsync(request.TargetActorId, dropped, CancellationToken.None); + } + catch (Exception ex) + { + throw new AgentRunOutputDispatchException( + $"Failed to send deferred LLM reply drop event to conversation actor '{request.TargetActorId}' (reason '{reason}').", + ex); + } + } + + private async Task TryHandleOutputDispatchFailureAsync( + NeedsLlmReplyEvent request, + string runId, + AgentRunOutputDispatchException ex) + { + _logger.LogWarning( + ex, + "Agent run output notification was not accepted; run remains retryable: runId={RunId} correlation={CorrelationId}", + runId, + request.CorrelationId); + + if (await TryScheduleStartRetryAsync(request, runId)) + return true; + + _logger.LogWarning( + ex, + "Agent run output retry could not be scheduled; propagating to runtime retry: runId={RunId} correlation={CorrelationId}", + runId, + request.CorrelationId); + return false; + } + + private async Task TryScheduleStartRetryAsync(NeedsLlmReplyEvent request, string runId) + { + if (Services.GetService() is null) + return false; + + try + { + await ScheduleSelfDurableTimeoutAsync( + BuildOutputDispatchRetryCallbackId(runId), + OutputDispatchRetryDelay, + new AgentRunStartRequested + { + Request = request.Clone(), + }, + ct: CancellationToken.None); + return true; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to schedule agent run output retry: runId={RunId} actorId={ActorId}", + runId, + Id); + return false; + } + } + + private async Task ScheduleTerminalCleanupAsync(string runId) + { + if (Services.GetService() is null) + return; + + try + { + await ScheduleSelfDurableTimeoutAsync( + BuildCleanupCallbackId(runId), + TerminalCleanupDelay, + new AgentRunCleanupRequested + { + RunId = runId, + RequestedAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + }, + ct: CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to schedule terminal agent run cleanup: runId={RunId} actorId={ActorId}", + runId, + Id); + } + } + + private static string BuildCleanupCallbackId(string runId) + { + var normalized = NormalizeOptional(runId) ?? "unknown"; + var chars = normalized + .Select(static ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_') + .Take(96) + .ToArray(); + return $"{TerminalCleanupCallbackPrefix}:{new string(chars)}"; + } + + private static string BuildOutputDispatchRetryCallbackId(string runId) + { + var normalized = NormalizeOptional(runId) ?? "unknown"; + var chars = normalized + .Select(static ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_') + .Take(96) + .ToArray(); + return $"{OutputDispatchRetryCallbackPrefix}:{new string(chars)}"; + } + + private async Task EnsureTargetActorAsync(string targetActorId) + { + if (string.IsNullOrWhiteSpace(targetActorId)) + return; + + var actor = await _actorRuntime.GetAsync(targetActorId); + if (actor is null) + await _actorRuntime.CreateAsync(targetActorId, CancellationToken.None); + } + + private bool ShouldCaptureInteractiveReply(ChatActivity? activity) + { + if (_interactiveReplyCollector is null) + return false; + + if (_relayOptions is { InteractiveRepliesEnabled: false }) + return false; + + return activity?.OutboundDelivery is + { + ReplyMessageId.Length: > 0, + CorrelationId.Length: > 0, + }; + } + + private static AgentRunGAgentState ApplyStarted(AgentRunGAgentState current, AgentRunStartedEvent evt) + { + var next = current.Clone(); + next.RunId = evt.RunId; + next.CorrelationId = evt.CorrelationId; + next.TargetActorId = evt.TargetActorId; + next.Status = AgentRunStatus.Started; + next.StartedAtUnixMs = evt.StartedAtUnixMs; + return next; + } + + private static AgentRunGAgentState ApplyReplyProduced( + AgentRunGAgentState current, + AgentRunReplyProducedEvent evt) + { + var next = current.Clone(); + next.RunId = string.IsNullOrWhiteSpace(next.RunId) ? evt.RunId : next.RunId; + next.CorrelationId = string.IsNullOrWhiteSpace(next.CorrelationId) ? evt.CorrelationId : next.CorrelationId; + next.TargetActorId = string.IsNullOrWhiteSpace(next.TargetActorId) ? evt.TargetActorId : next.TargetActorId; + next.Status = AgentRunStatus.ReplyProduced; + next.CompletedAtUnixMs = evt.ProducedAtUnixMs; + next.ErrorCode = evt.ErrorCode; + next.ErrorSummary = evt.ErrorSummary; + return next; + } + + private static AgentRunGAgentState ApplyDropped(AgentRunGAgentState current, AgentRunDroppedEvent evt) + { + var next = current.Clone(); + next.RunId = string.IsNullOrWhiteSpace(next.RunId) ? evt.RunId : next.RunId; + next.CorrelationId = string.IsNullOrWhiteSpace(next.CorrelationId) ? evt.CorrelationId : next.CorrelationId; + next.TargetActorId = string.IsNullOrWhiteSpace(next.TargetActorId) ? evt.TargetActorId : next.TargetActorId; + next.Status = AgentRunStatus.Dropped; + next.CompletedAtUnixMs = evt.DroppedAtUnixMs; + next.ErrorCode = evt.Reason; + next.ErrorSummary = string.Empty; + return next; + } + + private static AgentRunGAgentState ApplyFailed(AgentRunGAgentState current, AgentRunFailedEvent evt) + { + var next = current.Clone(); + next.RunId = string.IsNullOrWhiteSpace(next.RunId) ? evt.RunId : next.RunId; + next.CorrelationId = string.IsNullOrWhiteSpace(next.CorrelationId) ? evt.CorrelationId : next.CorrelationId; + next.TargetActorId = string.IsNullOrWhiteSpace(next.TargetActorId) ? evt.TargetActorId : next.TargetActorId; + next.Status = AgentRunStatus.Failed; + next.CompletedAtUnixMs = evt.FailedAtUnixMs; + next.ErrorCode = evt.ErrorCode; + next.ErrorSummary = evt.ErrorSummary; + return next; + } + + private static string? NormalizeOptional(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private sealed class AgentRunOutputDispatchException(string message, Exception innerException) + : Exception(message, innerException); +} diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelCardConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelCardConversationTurnRunner.cs new file mode 100644 index 000000000..3e6d38c98 --- /dev/null +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelCardConversationTurnRunner.cs @@ -0,0 +1,398 @@ +using System.Text.Json; +using Aevatar.AI.ToolProviders.Lark; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.Platform.Lark; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.NyxidChat; + +/// +/// Production for the Lark CardKit streaming +/// path. Composes (cardkit/v1/* endpoints) with +/// (im/v1/messages with msg_type=interactive) +/// to drive the create → send → stream → finalize lifecycle. Auth: bot owner's NyxID +/// access token from activity.TransportExtras.NyxUserAccessToken; receive target: +/// nyx_lark_chat_id for groups, falling back to nyx_lark_union_id for p2p +/// DMs (cross-app safe per the proto's documented invariants). +/// +public sealed class ChannelCardConversationTurnRunner : IConversationCardTurnRunner +{ + private static readonly JsonSerializerOptions JsonOptions = new(); + + private readonly ILarkCardKitClient _cardKit; + private readonly ILarkNyxClient _larkClient; + private readonly ILogger _logger; + + public ChannelCardConversationTurnRunner( + ILarkCardKitClient cardKit, + ILarkNyxClient larkClient, + ILogger logger) + { + _cardKit = cardKit ?? throw new ArgumentNullException(nameof(cardKit)); + _larkClient = larkClient ?? throw new ArgumentNullException(nameof(larkClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RunCardCreateAsync( + LlmReplyCardStreamChunkEvent chunk, + string streamingElementId, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(chunk); + if (chunk.Activity is null) + return ConversationCardCreateResult.Failed("activity_required", "Stream chunk event is missing the source activity."); + + var token = ResolveToken(chunk.Activity); + if (token is null) + return ConversationCardCreateResult.Failed("token_missing", "NyxID user access token is missing on the activity's TransportExtras."); + + var receiveTarget = ResolveReceiveTarget(chunk.Activity); + if (receiveTarget is null) + return ConversationCardCreateResult.Failed("receive_target_missing", "Lark chat_id and union_id are both missing on TransportExtras."); + + // 1. Allocate a CardKit entity holding an empty streaming element. The first chunk's + // text lands via StreamElementContentAsync (step 3) so the card_json schema and + // the streaming wire format stay decoupled. + var initialCardJson = LarkStreamingCardShell.BuildInitialCardJson(streamingElementId); + string createResponse; + try + { + createResponse = await _cardKit.CreateCardAsync( + token, + new LarkCardKitCreateRequest("card_json", initialCardJson), + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CardKit card.create threw for correlation={CorrelationId}", chunk.CorrelationId); + return ConversationCardCreateResult.Failed("card_create_threw", ex.Message); + } + + if (LarkProxyResponseParser.TryParseError(createResponse, out var createError)) + return ClassifyCreateFailure("card_create_failed", createError); + + var cardId = ExtractCardId(createResponse); + if (string.IsNullOrWhiteSpace(cardId)) + return ConversationCardCreateResult.Failed("card_id_missing", "card.create response did not include data.card_id."); + + // 2. Bind the card to the chat by sending an interactive message that references it. + var contentJson = JsonSerializer.Serialize( + new { type = "card", data = new { card_id = cardId } }, + JsonOptions); + string sendResponse; + try + { + sendResponse = await _larkClient.SendMessageAsync( + token, + new LarkSendMessageRequest( + TargetType: receiveTarget.Value.ReceiveIdType, + TargetId: receiveTarget.Value.ReceiveId, + MessageType: "interactive", + ContentJson: contentJson, + IdempotencyKey: chunk.CorrelationId), + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Card send-to-chat threw for correlation={CorrelationId}, card_id={CardId}", chunk.CorrelationId, cardId); + return ConversationCardCreateResult.Failed("card_send_threw", ex.Message); + } + + if (LarkProxyResponseParser.TryParseError(sendResponse, out var sendError)) + return ClassifyCreateFailure("card_send_failed", sendError); + + var cardMessageId = LarkProxyResponseParser.ParseSendSuccess(sendResponse).MessageId + ?? string.Empty; + + // 3. Write the first chunk's text into the streaming element. Sequence = 1 (the + // grain pre-allocates this value; subsequent chunks pass sequence+1 each call). + // The card has already been bound to the chat (step 2), so any failure from here + // on is a *post-send* failure: an empty card is visible in the chat. We must + // return PostSendFailed (not Failed) so the actor terminates the turn instead + // of falling back to text-edit and producing a duplicate reply. + string firstStreamResponse; + try + { + firstStreamResponse = await _cardKit.StreamElementContentAsync( + token, + new LarkCardKitStreamElementContentRequest( + CardId: cardId, + ElementId: streamingElementId, + Content: chunk.AccumulatedText, + Sequence: 1, + IdempotencyKey: $"{chunk.CorrelationId}-1"), + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CardKit first stream threw for correlation={CorrelationId}, card_id={CardId}", chunk.CorrelationId, cardId); + await TryBestEffortCloseStreamingAsync(token, cardId, sequence: 2, ct).ConfigureAwait(false); + return ConversationCardCreateResult.PostSendFailed( + cardId, + cardMessageId, + "card_first_stream_threw", + ex.Message); + } + + if (LarkProxyResponseParser.TryParseError(firstStreamResponse, out var firstStreamError)) + { + await TryBestEffortCloseStreamingAsync(token, cardId, sequence: 2, ct).ConfigureAwait(false); + return ClassifyPostSendFailure(cardId, cardMessageId, "card_first_stream_failed", firstStreamError); + } + + return ConversationCardCreateResult.Succeeded(cardId, cardMessageId); + } + + /// + /// Best-effort settings patch to close streaming_mode on a card whose first + /// content write failed. Stops the typewriter cursor on the orphan empty card so the + /// chat does not show a perpetually-loading bubble. Failures are logged and swallowed — + /// the parent operation has already failed; this is a UX cleanup, not a correctness gate. + /// + private async Task TryBestEffortCloseStreamingAsync(string token, string cardId, long sequence, CancellationToken ct) + { + try + { + await _cardKit.SetCardSettingsAsync( + token, + new LarkCardKitSettingsRequest( + CardId: cardId, + SettingsJson: """{"streaming_mode": false}""", + Sequence: sequence, + IdempotencyKey: $"orphan-close-{cardId}"), + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Best-effort close of orphan streaming card failed; cursor may remain visible. card_id={CardId}", cardId); + } + } + + public async Task RunCardStreamAsync( + LlmReplyCardStreamChunkEvent chunk, + string cardId, + string elementId, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(chunk); + if (chunk.Activity is null) + return ConversationCardStreamResult.Failed("activity_required", "Stream chunk event is missing the source activity."); + + var token = ResolveToken(chunk.Activity); + if (token is null) + return ConversationCardStreamResult.Failed("token_missing", "NyxID user access token is missing on the activity's TransportExtras."); + + string response; + try + { + response = await _cardKit.StreamElementContentAsync( + token, + new LarkCardKitStreamElementContentRequest( + CardId: cardId, + ElementId: elementId, + Content: chunk.AccumulatedText, + Sequence: sequence, + IdempotencyKey: $"{chunk.CorrelationId}-{sequence}"), + ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CardKit interim stream threw for correlation={CorrelationId}, card_id={CardId}, seq={Sequence}", chunk.CorrelationId, cardId, sequence); + return ConversationCardStreamResult.Failed("card_stream_threw", ex.Message); + } + + if (LarkProxyResponseParser.TryParseError(response, out var error)) + return ClassifyStreamFailure(error); + + return ConversationCardStreamResult.Succeeded(); + } + + public async Task RunCardFinalizeAsync( + ChatActivity referenceActivity, + string cardId, + string elementId, + string finalText, + bool finalTextDiffersFromLastFlushed, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(referenceActivity); + + var token = ResolveToken(referenceActivity); + if (token is null) + return ConversationCardFinalizeResult.Failed("token_missing", "NyxID user access token is missing on the reference activity's TransportExtras."); + + // 1. If final text drifted from the last flushed interim, write it before closing + // streaming mode. Order matters: closing streaming first would freeze the cursor + // on the stale text. Track whether the trailing write actually landed so the + // actor can pick the right user-visible text on a partial-failure terminal. + long workingSequence = sequence; + var finalTextWritten = !finalTextDiffersFromLastFlushed || string.IsNullOrWhiteSpace(finalText); + if (finalTextDiffersFromLastFlushed && !string.IsNullOrWhiteSpace(finalText)) + { + try + { + var streamFinalResponse = await _cardKit.StreamElementContentAsync( + token, + new LarkCardKitStreamElementContentRequest( + CardId: cardId, + ElementId: elementId, + Content: finalText, + Sequence: workingSequence, + IdempotencyKey: $"final-{cardId}-{workingSequence}"), + ct); + if (LarkProxyResponseParser.TryParseError(streamFinalResponse, out var streamFinalError)) + return ConversationCardFinalizeResult.Failed("card_final_stream_failed", streamFinalError, finalTextWritten: false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CardKit final stream threw for card_id={CardId}, seq={Sequence}", cardId, workingSequence); + return ConversationCardFinalizeResult.Failed("card_final_stream_threw", ex.Message, finalTextWritten: false); + } + finalTextWritten = true; + workingSequence++; + } + + // 2. Close the card's streaming mode so the typewriter cursor disappears. + try + { + var settingsResponse = await _cardKit.SetCardSettingsAsync( + token, + new LarkCardKitSettingsRequest( + CardId: cardId, + SettingsJson: """{"streaming_mode": false}""", + Sequence: workingSequence, + IdempotencyKey: $"close-{cardId}-{workingSequence}"), + ct); + if (LarkProxyResponseParser.TryParseError(settingsResponse, out var settingsError)) + return ConversationCardFinalizeResult.Failed("card_close_streaming_failed", settingsError, finalTextWritten: finalTextWritten); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "CardKit close-streaming threw for card_id={CardId}, seq={Sequence}", cardId, workingSequence); + return ConversationCardFinalizeResult.Failed("card_close_streaming_threw", ex.Message, finalTextWritten: finalTextWritten); + } + + return ConversationCardFinalizeResult.Succeeded(); + } + + private static string? ResolveToken(ChatActivity activity) + { + var token = activity.TransportExtras?.NyxUserAccessToken?.Trim(); + return string.IsNullOrWhiteSpace(token) ? null : token; + } + + private static (string ReceiveIdType, string ReceiveId)? ResolveReceiveTarget(ChatActivity activity) + { + // Group / channel / thread: the relay-side chat_id is cross-app safe within the tenant. + var chatId = activity.TransportExtras?.NyxLarkChatId?.Trim(); + var conversationScope = activity.Conversation?.Scope ?? ConversationScope.Unspecified; + var isGroupLike = conversationScope is ConversationScope.Group + or ConversationScope.Channel + or ConversationScope.Thread; + if (isGroupLike && !string.IsNullOrWhiteSpace(chatId)) + return ("chat_id", chatId); + + // Direct message: the chat_id is bot-specific and not cross-app safe; prefer union_id. + var unionId = activity.TransportExtras?.NyxLarkUnionId?.Trim(); + if (!string.IsNullOrWhiteSpace(unionId)) + return ("union_id", unionId); + + // Fall back to chat_id for DMs only when union_id is unavailable. The relay populates + // union_id whenever it can resolve it, so this branch generally does not fire. + if (!string.IsNullOrWhiteSpace(chatId)) + return ("chat_id", chatId); + + return null; + } + + /// + /// Best-effort extract of data.card_id from the cardkit/v1/cards response. + /// Returns null when the field is missing or malformed; the caller treats null as a + /// terminal create failure. + /// + private static string? ExtractCardId(string response) + { + try + { + using var document = JsonDocument.Parse(response); + if (document.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("card_id", out var cardIdProp) && + cardIdProp.ValueKind == JsonValueKind.String) + { + return cardIdProp.GetString(); + } + } + catch (JsonException) + { + return null; + } + return null; + } + + private static ConversationCardCreateResult ClassifyCreateFailure(string contextErrorCode, string larkError) => + ConversationCardCreateResult.Failed( + errorCode: contextErrorCode, + errorSummary: larkError, + isRateLimited: ContainsLarkCode(larkError, 230020), + isTableLimitExceeded: ContainsLarkCode(larkError, 11310), + isCardUnavailable: ContainsLarkCode(larkError, 230099) || ContainsLarkCode(larkError, 230100)); + + /// + /// Same classification as but threads the + /// already-allocated / through + /// the result so the actor can persist the partial-card terminal record. Used for any + /// failure that occurs after im/v1/messages has bound the card to the chat. + /// + private static ConversationCardCreateResult ClassifyPostSendFailure( + string cardId, + string cardMessageId, + string contextErrorCode, + string larkError) => + ConversationCardCreateResult.PostSendFailed( + cardId: cardId, + cardMessageId: cardMessageId, + errorCode: contextErrorCode, + errorSummary: larkError, + isRateLimited: ContainsLarkCode(larkError, 230020), + isTableLimitExceeded: ContainsLarkCode(larkError, 11310), + isCardUnavailable: ContainsLarkCode(larkError, 230099) || ContainsLarkCode(larkError, 230100)); + + private static ConversationCardStreamResult ClassifyStreamFailure(string larkError) => + ConversationCardStreamResult.Failed( + errorCode: "card_stream_failed", + errorSummary: larkError, + isRateLimited: ContainsLarkCode(larkError, 230020), + isTableLimitExceeded: ContainsLarkCode(larkError, 11310), + isCardUnavailable: ContainsLarkCode(larkError, 230099) || ContainsLarkCode(larkError, 230100)); + + /// + /// Boundary-aware match against 's + /// output shape ("lark_code={n} ..."). The needle's trailing position must be + /// the end of the string OR a non-digit; without the boundary check, looking for + /// lark_code=23002 would falsely match a string containing lark_code=230020. + /// + private static bool ContainsLarkCode(string error, int code) + { + if (string.IsNullOrEmpty(error)) + return false; + var needle = $"lark_code={code}"; + var index = 0; + while (index <= error.Length - needle.Length) + { + var found = error.IndexOf(needle, index, StringComparison.Ordinal); + if (found < 0) + return false; + var endIndex = found + needle.Length; + if (endIndex == error.Length || !char.IsDigit(error[endIndex])) + return true; + index = endIndex; + } + return false; + } +} diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs index 96ed5aa87..6491ead5d 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ChannelConversationTurnRunner.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Text.Json; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; @@ -7,6 +8,7 @@ using Aevatar.GAgents.Authoring.Lark; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Abstractions.Slash; +using Aevatar.GAgents.Channel.Identity; using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgents.Channel.Identity.Slash; using Aevatar.GAgents.Channel.NyxIdRelay; @@ -24,6 +26,8 @@ namespace Aevatar.GAgents.NyxidChat; public sealed class ChannelConversationTurnRunner : IConversationTurnRunner { + private sealed record ResolvedSenderBinding(string BindingId, ExternalSubjectRef Subject); + private readonly IServiceProvider _toolServiceProvider; private readonly IChannelBotRegistrationQueryPort _registrationQueryPort; private readonly IChannelBotRegistrationQueryByNyxIdentityPort? _registrationQueryByNyxIdentityPort; @@ -95,10 +99,10 @@ public async Task RunInboundAsync( return ConversationTurnResult.PermanentFailure("registration_not_found", "Channel registration not found."); // Capture the typing-reaction Task instead of `_ =`-discarding it. The direct-reply - // AgentBuilder path can complete fast enough that the swap fires before Lark has - // persisted the typing reaction; the swap GET would then find nothing to delete and - // leave both Typing + DONE on the message. Threading the task to the swap site lets - // the swap await-with-timeout the typing POST first. The deferred-LLM and streaming + // AgentBuilder path can complete fast enough that the clear fires before Lark has + // persisted the typing reaction; the clear GET would then find nothing to delete and + // leave Typing on the message. Threading the task to the clear site lets the clear + // await-with-timeout the typing POST first. The deferred-LLM and streaming // paths don't get this task (different invocation), but their natural latency is // orders of magnitude greater than the typing POST so the race cannot fire. var typingReactionTask = TrySendImmediateLarkReactionAsync(activity, registration, ct); @@ -113,19 +117,13 @@ public async Task RunInboundAsync( if (await TryHandleSlashCommandAsync(activity, inbound, registration, runtimeContext, ct) is { } slashResult) return slashResult; - // Pre-LLM binding gate: when broker mode is wired, an unbound sender - // MUST be prompted to bind NyxID rather than served by the bot owner's - // credentials (codex L65 security: ADR-0018 §Decision "未绑定 sender - // 一律强制绑定,不回落到 bot owner"). Falls through transparently - // when identity ports are not registered (legacy bot-owner-shared - // deployments). The gate also returns the resolved binding-id so the - // LLM dispatch can apply the sender prefs override chain (issue #513 - // phase 3) without paying for a second projection lookup. - var (bindingGateResult, senderBindingId) = await TryEnforceBindingGateAsync(activity, inbound, registration, runtimeContext, ct).ConfigureAwait(false); - if (bindingGateResult is not null) - return bindingGateResult; - - if (await TryHandleLlmSelectionCardActionAsync(activity, inbound, registration, runtimeContext, senderBindingId, ct).ConfigureAwait(false) is { } llmSelectionResult) + // Normal LLM messages do not force /init. If the sender is bound we + // carry that binding forward so the reply generator can try the + // sender's own NyxID LLM prefs first; otherwise the run actor/generator + // will use the bot owner's ambient LLM config. + var senderBinding = await TryResolveSenderBindingAsync(inbound, registration, ct).ConfigureAwait(false); + + if (await TryHandleLlmSelectionCardActionAsync(activity, inbound, registration, runtimeContext, senderBinding?.BindingId, ct).ConfigureAwait(false) is { } llmSelectionResult) return llmSelectionResult; var inboundEvent = ToInboundEvent(activity, registration, inbound, ResolveUserAccessToken(activity)); @@ -157,7 +155,7 @@ public async Task RunInboundAsync( } return ConversationTurnResult.LlmReplyRequested( - await BuildLlmReplyRequestAsync(activity, registration, inboundEvent, runtimeContext, senderBindingId, ct).ConfigureAwait(false)); + await BuildLlmReplyRequestAsync(activity, registration, inboundEvent, runtimeContext, senderBinding, ct).ConfigureAwait(false)); } public Task RunInboundAsync(ChatActivity activity, CancellationToken ct) => @@ -165,16 +163,16 @@ public Task RunInboundAsync(ChatActivity activity, Cance // ─── Slash command dispatch ─── // - // ADR-0018 §Decision: when per-user binding is enabled, slash commands - // (/init, /unbind, /whoami, /model, ...) are routed before the LLM so the - // bot owner's bot-shared mode is bypassed for unbound senders. Handlers + // Slash commands (/init, /unbind, /whoami, /model, ...) are routed before + // the LLM so binding/configuration commands can own their per-user + // semantics without being swallowed by the chat model. Handlers // are discovered as IEnumerable from DI; // identity ports are constructor-injected as optional capabilities so // deployments that have not enabled binding fall through to the legacy // flow. Phase 6 (issue #513): // each handler declares RequiresBinding so unbound senders trying to use - // a binding-only command (e.g. /model use) get the same hint as the LLM- - // turn binding gate instead of a stack trace. + // a binding-only command (e.g. /model use) get a binding hint instead of + // a stack trace; normal LLM turns still have owner fallback. private async Task TryHandleSlashCommandAsync( ChatActivity activity, InboundMessage inbound, @@ -435,59 +433,73 @@ private static bool TryResolveExternalSubject( return true; } - // Pre-LLM binding gate: when identity is wired, refuse to serve unbound - // senders with the bot owner's credentials (ADR-0018 §Decision). Returns - // (null, null) when binding is not enabled (legacy mode); returns - // (prompt, null) for unbound senders so the caller short-circuits with - // a binding prompt/card; returns (null, bindingId) for bound senders so the LLM - // dispatch can carry the binding-id forward into metadata for the issue - // #513 phase 3 prefs override chain. - private async Task<(ConversationTurnResult? Blocking, string? SenderBindingId)> TryEnforceBindingGateAsync( - ChatActivity activity, + // Normal LLM messages are allowed to use the bot owner's LLM config when + // the sender has no NyxID binding. Binding is only required by commands + // that configure or inspect per-user state (/models, /model use, ...). + private async Task TryResolveSenderBindingAsync( InboundMessage inbound, ChannelBotRegistrationEntry registration, - ConversationTurnRuntimeContext runtimeContext, CancellationToken ct) { var queryPort = _identityBindingQueryPort; if (queryPort is null) - return (null, null); - - if (string.IsNullOrWhiteSpace(inbound.SenderId) || string.IsNullOrWhiteSpace(inbound.Platform)) - return (null, null); - - var tenant = ResolveTenant(inbound, registration); - if (tenant is null) - return (null, null); + return null; - var subject = new ExternalSubjectRef - { - Platform = inbound.Platform.Trim().ToLowerInvariant(), - Tenant = tenant, - ExternalUserId = inbound.SenderId.Trim(), - }; + if (!TryResolveExternalSubject(inbound, registration, out var subject)) + return null; BindingId? existing; try { existing = await queryPort.ResolveAsync(subject, ct); } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (IsTransientBindingLookupFailure(ex)) + { + // Transient infra failures (DB blip, transient HTTP, JSON shape mismatch from + // upstream): degrade to owner credentials and keep the conversation alive. + _logger.LogWarning( + ex, + "Transient sender NyxID binding lookup failure; falling back to bot owner LLM config. subject={Platform}:{Tenant}:{User}", + subject.Platform, + subject.Tenant, + subject.ExternalUserId); + return null; + } catch (Exception ex) { - // Resolve failure should fail closed (refuse to serve with - // bot-owner credentials) rather than fail open. Log and treat as - // unbound. - _logger.LogError(ex, "Binding gate resolve failed for sender {Sender}; treating as unbound", inbound.SenderId); - existing = null; + // Non-transient (programmer error, unexpected NRE, serialization break): surface + // at Error level so ops can distinguish from "sender just isn't bound" — but still + // fall through to owner credentials so the user gets a reply rather than nothing. + _logger.LogError( + ex, + "Sender NyxID binding lookup raised non-transient exception; falling back to bot owner LLM config. subject={Platform}:{Tenant}:{User}", + subject.Platform, + subject.Tenant, + subject.ExternalUserId); + return null; } if (existing is not null) - return (null, existing.Value); // bound — continue with sender binding-id + return new ResolvedSenderBinding(existing.Value, subject.Clone()); - var prompt = await SendBindingPromptAsync(activity, inbound, registration, runtimeContext, ct).ConfigureAwait(false); - return (prompt, null); + return null; } + /// + /// Distinguish infra-shaped binding lookup failures (worth a Warning + owner fallback) + /// from logic/programmer errors (worth an Error log so ops sees them). + /// + private static bool IsTransientBindingLookupFailure(Exception ex) => + ex is HttpRequestException + or TimeoutException + or TaskCanceledException + or System.Text.Json.JsonException + or System.IO.IOException; + // Lark-aware private-chat detection. Other platforms map their direct- // message chat-type strings here as the runner gains support for them. private static bool IsPrivateChat(InboundMessage inbound) @@ -610,8 +622,8 @@ private async Task ExecuteLlmSelectionCardActionAsync( await selectionService.SetByServiceAsync(selectionContext, value.Trim(), modelOverride: null, ct) .ConfigureAwait(false); var updated = await optionsService.GetOptionsAsync(query, ct).ConfigureAwait(false); - var picked = updated.Available.FirstOrDefault(option => - string.Equals(option.ServiceId, value.Trim(), StringComparison.OrdinalIgnoreCase)) ?? updated.Current; + var picked = updated.Current ?? updated.Available.FirstOrDefault(option => + string.Equals(option.ServiceId, value.Trim(), StringComparison.OrdinalIgnoreCase)); return picked is null ? new MessageContent { Text = "已切换 LLM service。下一条消息会用新的设置回复。" } : renderer.RenderSelectionConfirm(picked, picked.DefaultModel); @@ -730,10 +742,10 @@ public async Task RunLlmReplyAsync( var inbound = ToInboundMessage(reply.Activity); // Direct path requires registration to actually send the reply; relay path only wants it - // for the post-reply reaction swap (relay sends use the reply token, not registration). + // for the post-reply reaction clear (relay sends use the reply token, not registration). // So lookup is mandatory on the direct path and best-effort on the relay path — a // transient registration-store error on the relay path must not drop an otherwise valid - // reply, only degrade the swap to a no-op for that turn. + // reply, only degrade the clear to a no-op for that turn. ChannelBotRegistrationEntry? registration; if (HasRelayDelivery(inbound)) { @@ -749,7 +761,7 @@ public async Task RunLlmReplyAsync( { _logger.LogWarning( ex, - "Registration lookup failed on relay reply path; reply will proceed but post-reply reaction swap will be skipped. correlation={CorrelationId}", + "Registration lookup failed on relay reply path; reply will proceed but post-reply reaction clear will be skipped. correlation={CorrelationId}", reply.CorrelationId); registration = null; } @@ -777,7 +789,7 @@ public async Task RunLlmReplyAsync( runtimeContext, ct); if (result.Success) - _ = TrySwapTypingReactionToDoneAsync(inbound, registration, ct); + _ = TryClearTypingReactionAsync(inbound, registration, ct); return result; } @@ -829,9 +841,9 @@ public async Task RunContinueAsync( public async Task OnReplyDeliveredAsync(ChatActivity activity, CancellationToken ct) { // Streaming-completion path in ConversationGAgent calls this hook because it finalizes - // the reply without going through RunLlmReplyAsync (which is where the non-streaming swap - // lives). For non-Lark platforms or activities missing the platform message id, the swap - // helper short-circuits in ShouldSwapTypingReaction. + // the reply without going through RunLlmReplyAsync (which is where the non-streaming clear + // lives). For non-Lark platforms or activities missing the platform message id, the clear + // helper short-circuits in ShouldClearTypingReaction. if (activity is null) return; @@ -840,7 +852,7 @@ public async Task OnReplyDeliveredAsync(ChatActivity activity, CancellationToken return; var inbound = ToInboundMessage(activity); - await TrySwapTypingReactionToDoneAsync(inbound, registration, ct); + await TryClearTypingReactionAsync(inbound, registration, ct); } public async Task RunStreamChunkAsync( @@ -978,7 +990,7 @@ public async Task RunStreamChunkAsync( runtimeContext, ct); if (result.Success) - _ = AwaitTypingReactionThenSwapAsync(typingReactionTask, inbound, registration, ct); + _ = AwaitTypingReactionThenClearAsync(typingReactionTask, inbound, registration, ct); return result.Success ? ConversationTurnResult.Sent( sentActivityId: $"direct-reply:{activity.Id}", @@ -1485,7 +1497,7 @@ private async Task BuildLlmReplyRequestAsync( ChannelBotRegistrationEntry registration, ChannelInboundEvent inboundEvent, ConversationTurnRuntimeContext runtimeContext, - string? senderBindingId, + ResolvedSenderBinding? senderBinding, CancellationToken ct) { var request = new NeedsLlmReplyEvent @@ -1497,9 +1509,9 @@ private async Task BuildLlmReplyRequestAsync( RequestedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - // Carry the relay reply credential through the inbox as transient inbox-only + // Carry the relay reply credential through the run command as transient command-only // fields. ConversationGAgent strips these before persisting NeedsLlmReplyEvent; - // ChannelLlmReplyInboxRuntime echoes them into the LlmReplyReadyEvent so the + // AgentRunGAgent echoes them into the LlmReplyReadyEvent so the // outbound reply does not depend on the actor's in-memory token dict surviving // deactivation. if (runtimeContext.NyxRelayReplyToken is { } token && @@ -1512,15 +1524,57 @@ private async Task BuildLlmReplyRequestAsync( foreach (var pair in await BuildReplyMetadataAsync(inboundEvent, activity, ct)) request.Metadata[pair.Key] = pair.Value; - // Issue #513 phase 3: tag the request with the sender's binding-id so - // the downstream reply generator can apply the prefs override chain - // (sender → bot owner → provider default). - if (!string.IsNullOrWhiteSpace(senderBindingId)) - request.Metadata[LLMRequestMetadataKeys.SenderBindingId] = senderBindingId; + // Tag the request with the sender's binding-id and a short-lived token + // so the downstream reply generator can try the sender's own LLM + // route first. Missing token/binding is not an error: the generator + // falls back to the bot owner's upstream-pinned LLM config. + if (senderBinding is not null) + { + request.Metadata[LLMRequestMetadataKeys.SenderBindingId] = senderBinding.BindingId; + var senderAccessToken = await TryIssueSenderLlmAccessTokenAsync(senderBinding.Subject, ct).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(senderAccessToken)) + request.Metadata[LLMRequestMetadataKeys.SenderNyxIdAccessToken] = senderAccessToken; + } return request; } + private async Task TryIssueSenderLlmAccessTokenAsync( + ExternalSubjectRef subject, + CancellationToken ct) + { + var broker = _capabilityBroker; + if (broker is null) + return null; + + try + { + var handle = await broker + .IssueShortLivedAsync( + subject, + new CapabilityScope { Value = AevatarOAuthClientScopes.Proxy }, + ct) + .ConfigureAwait(false); + return string.IsNullOrWhiteSpace(handle.AccessToken) + ? null + : handle.AccessToken.Trim(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to issue sender NyxID LLM token; falling back to bot owner LLM config. subject={Platform}:{Tenant}:{User}", + subject.Platform, + subject.Tenant, + subject.ExternalUserId); + return null; + } + } + private static string ResolveRoutingConversationId(ConversationReference? conversation) { if (conversation is null) @@ -1629,10 +1683,10 @@ activity.OutboundDelivery is string.Equals(NormalizeOptional(activity.Bot?.Value), nyxAgentApiKeyId, StringComparison.Ordinal); // Lark reaction emoji_type for "hands typing on keyboard" — added immediately on inbound - // so the user sees the bot is working before the LLM reply lands. Swapped to DoneReactionEmojiType - // after the reply succeeds so the same message ends up with a single completion reaction. + // so the user sees the bot is working before the LLM reply lands. After a reply succeeds, + // the reaction is cleared instead of replaced with DONE because DONE reads as task completion, + // while a chat reply can be an intermediate progress update. private const string TypingReactionEmojiType = "Typing"; - private const string DoneReactionEmojiType = "DONE"; private async Task TrySendImmediateLarkReactionAsync( ChatActivity activity, @@ -1698,14 +1752,12 @@ private async Task TrySendImmediateLarkReactionAsync( } // Direct-reply paths (TryHandleAgentBuilderAsync) can complete a slash-command reply faster - // than the typing POST takes to land in Lark, leaving the swap GET to find no Typing reaction - // to delete and the orphaned typing reaction to materialize after DONE was already added — - // both reactions on the same message. Awaiting (with a short cap) the typing task before the - // GET closes that race. The cap protects against a hung POST stalling the swap forever; if it - // expires the swap still proceeds — Lark will at worst end up with both reactions, same as - // before this guard. The deferred-LLM and streaming paths skip this guard because their reply - // latency dwarfs the typing POST and so cannot race. - private async Task AwaitTypingReactionThenSwapAsync( + // than the typing POST takes to land in Lark, leaving the clear GET to find no Typing reaction + // to delete and the orphaned typing reaction to materialize after the clear already ran. + // Awaiting (with a short cap) the typing task before the GET closes that race. The cap protects + // against a hung POST stalling the clear forever. The deferred-LLM and streaming paths skip this + // guard because their reply latency dwarfs the typing POST and so cannot race. + private async Task AwaitTypingReactionThenClearAsync( Task typingReactionTask, InboundMessage inbound, ChannelBotRegistrationEntry registration, @@ -1722,24 +1774,23 @@ private async Task AwaitTypingReactionThenSwapAsync( catch (TimeoutException) { _logger.LogDebug( - "Lark typing reaction task did not complete within timeout before swap; proceeding anyway"); + "Lark typing reaction task did not complete within timeout before clear; proceeding anyway"); } catch (Exception) { - // The typing task already logged its own exception — proceed with the swap so the - // user-visible message still ends up with a DONE reaction whenever possible. + // The typing task already logged its own exception — proceed with the clear so any + // already-visible Typing reaction is still removed whenever possible. } - await TrySwapTypingReactionToDoneAsync(inbound, registration, ct); + await TryClearTypingReactionAsync(inbound, registration, ct); } - // After a successful reply, replace the bot's "Typing" reaction with a "DONE" reaction so the - // same message ends with a single completion marker. Uses list-based discovery (filter by + // After a successful reply, remove the bot's "Typing" reaction. Uses list-based discovery (filter by // emoji_type=Typing AND operator_type=app) instead of caching the immediate reaction's // reaction_id locally — the runner is a singleton and cross-turn state on it would violate the // "中间层进程内缓存作为事实源" rule. Filtering on operator_type=app avoids deleting any user // who happened to add the same Typing reaction. - private async Task TrySwapTypingReactionToDoneAsync( + private async Task TryClearTypingReactionAsync( InboundMessage inbound, ChannelBotRegistrationEntry? registration, CancellationToken ct) @@ -1747,7 +1798,7 @@ private async Task TrySwapTypingReactionToDoneAsync( if (registration is null) return; - if (!ShouldSwapTypingReaction(inbound, registration, out var accessToken, out var providerSlug, out var platformMessageId)) + if (!ShouldClearTypingReaction(inbound, registration, out var accessToken, out var providerSlug, out var platformMessageId)) return; try @@ -1755,7 +1806,7 @@ private async Task TrySwapTypingReactionToDoneAsync( var reactionIds = new List(); string? pageToken = null; // Bound the iteration so a misbehaving Lark response (e.g. always-true `has_more`) - // can't loop the swap forever. 10 pages × 50 per page = 500 Typing reactions on a + // can't loop the clear forever. 10 pages × 50 per page = 500 Typing reactions on a // single message — orders of magnitude more than realistic, since this list is // already scoped to one emoji_type and the bot only adds Typing once per inbound. const int MaxListPages = 10; @@ -1777,7 +1828,7 @@ private async Task TrySwapTypingReactionToDoneAsync( if (LarkProxyResponse.TryGetError(listResponse, out var listCode, out var listDetail)) { _logger.LogDebug( - "Lark typing reaction list failed; skipping swap: provider={ProviderSlug}, message={MessageId}, page={Page}, larkCode={LarkCode}, detail={Detail}", + "Lark typing reaction list failed; skipping clear: provider={ProviderSlug}, message={MessageId}, page={Page}, larkCode={LarkCode}, detail={Detail}", providerSlug, platformMessageId, page, @@ -1835,35 +1886,6 @@ private async Task TrySwapTypingReactionToDoneAsync( } } - var addResponse = await _nyxClient.ProxyRequestAsync( - accessToken!, - providerSlug!, - $"/open-apis/im/v1/messages/{Uri.EscapeDataString(platformMessageId!)}/reactions", - "POST", - $$$"""{"reaction_type":{"emoji_type":"{{{DoneReactionEmojiType}}}"}}""", - null, - ct); - - if (LarkProxyResponse.TryGetError(addResponse, out var addCode, out var addDetail)) - { - if (addCode == LarkBotErrorCodes.NoPermissionToReact) - { - _logger.LogDebug( - "Lark done reaction skipped (missing reaction scope): provider={ProviderSlug}, message={MessageId}, detail={Detail}", - providerSlug, - platformMessageId, - addDetail); - } - else - { - _logger.LogWarning( - "Lark done reaction failed: provider={ProviderSlug}, message={MessageId}, larkCode={LarkCode}, detail={Detail}", - providerSlug, - platformMessageId, - addCode, - addDetail); - } - } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -1873,7 +1895,7 @@ private async Task TrySwapTypingReactionToDoneAsync( { _logger.LogWarning( ex, - "Lark typing→done reaction swap threw: provider={ProviderSlug}, message={MessageId}", + "Lark typing reaction clear threw: provider={ProviderSlug}, message={MessageId}", providerSlug, platformMessageId); } @@ -1930,7 +1952,7 @@ private static (List AppReactionIds, string? NextPageToken) ExtractAppRe continue; // Only delete reactions added by the bot itself (operator_type=app); leave any - // user-added Typing reactions alone so the swap doesn't accidentally erase them. + // user-added Typing reactions alone so the clear doesn't accidentally erase them. if (!item.TryGetProperty("operator", out var operatorProp) || operatorProp.ValueKind != JsonValueKind.Object) { @@ -1958,7 +1980,7 @@ private static (List AppReactionIds, string? NextPageToken) ExtractAppRe return (ids, nextPageToken); } - private static bool ShouldSwapTypingReaction( + private static bool ShouldClearTypingReaction( InboundMessage inbound, ChannelBotRegistrationEntry registration, out string? accessToken, diff --git a/agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs b/agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs deleted file mode 100644 index 493161e01..000000000 --- a/agents/Aevatar.GAgents.NyxidChat/ChannelLlmReplyInboxRuntime.cs +++ /dev/null @@ -1,443 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Channel.NyxIdRelay; -using Aevatar.GAgents.NyxidChat; -using Aevatar.Studio.Application.Studio.Abstractions; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Aevatar.GAgents.NyxidChat; - -public sealed class ChannelLlmReplyInboxRuntime : - IHostedService, - IAsyncDisposable, - IChannelLlmReplyInbox -{ - internal const string InboxStreamId = "channel-runtime:llm-reply:inbox"; - - private readonly IStreamProvider _streamProvider; - private readonly IActorRuntime _actorRuntime; - private readonly IActorDispatchPort _actorDispatchPort; - private readonly IConversationReplyGenerator _replyGenerator; - private readonly IInteractiveReplyCollector? _interactiveReplyCollector; - private readonly Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? _relayOptions; - private readonly INyxIdRelayScopeResolver? _scopeResolver; - private readonly IUserConfigQueryPort? _userConfigQueryPort; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - private IAsyncDisposable? _subscription; - - public ChannelLlmReplyInboxRuntime( - IStreamProvider streamProvider, - IActorRuntime actorRuntime, - IConversationReplyGenerator replyGenerator, - IInteractiveReplyCollector? interactiveReplyCollector, - Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? relayOptions, - ILogger logger, - INyxIdRelayScopeResolver? scopeResolver = null, - IUserConfigQueryPort? userConfigQueryPort = null, - TimeProvider? timeProvider = null, - IActorDispatchPort? actorDispatchPort = null) - { - _streamProvider = streamProvider ?? throw new ArgumentNullException(nameof(streamProvider)); - _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); - _actorDispatchPort = actorDispatchPort - ?? actorRuntime as IActorDispatchPort - ?? throw new ArgumentNullException(nameof(actorDispatchPort)); - _replyGenerator = replyGenerator ?? throw new ArgumentNullException(nameof(replyGenerator)); - _interactiveReplyCollector = interactiveReplyCollector; - _relayOptions = relayOptions; - _scopeResolver = scopeResolver; - _userConfigQueryPort = userConfigQueryPort; - _timeProvider = timeProvider ?? TimeProvider.System; - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task StartAsync(CancellationToken ct) - { - if (_subscription is not null) - return; - - _subscription = await _streamProvider - .GetStream(InboxStreamId) - .SubscribeAsync(ProcessAsync, ct); - - _logger.LogInformation("Started channel LLM reply inbox on {StreamId}", InboxStreamId); - } - - public async Task StopAsync(CancellationToken ct) - { - if (_subscription is null) - return; - - await _subscription.DisposeAsync(); - _subscription = null; - _logger.LogInformation("Stopped channel LLM reply inbox on {StreamId}", InboxStreamId); - } - - public Task EnqueueAsync(NeedsLlmReplyEvent request, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(request); - return _streamProvider.GetStream(InboxStreamId).ProduceAsync(request, ct); - } - - public async ValueTask DisposeAsync() - { - await StopAsync(CancellationToken.None); - } - - internal const long MaxInboxRequestAgeMs = 5 * 60 * 1000; - - internal async Task ProcessAsync(NeedsLlmReplyEvent request) - { - ArgumentNullException.ThrowIfNull(request); - - _logger.LogInformation( - "Processing LLM reply request: correlation={CorrelationId} target={TargetActorId}", - request.CorrelationId, - request.TargetActorId); - - if (request.Activity is null || string.IsNullOrWhiteSpace(request.TargetActorId)) - { - _logger.LogWarning( - "Dropping malformed deferred LLM reply request: correlation={CorrelationId}, target={TargetActorId}", - request.CorrelationId, - request.TargetActorId); - await NotifyActorOfDropAsync(request, "malformed_deferred_llm_reply_request"); - return; - } - - // Stale gate: NyxID relay reply tokens have a ~30 min TTL and the user access - // token used for the LLM call expires inside ~15 min. A request that has been - // sitting in the stream for hours can't lead to a successful reply, so drop it - // here instead of spending an LLM round just to fail at the outbound stage. - var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (request.RequestedAtUnixMs > 0 && nowMs - request.RequestedAtUnixMs > MaxInboxRequestAgeMs) - { - _logger.LogInformation( - "Dropping stale LLM reply request: correlation={CorrelationId} ageMs={AgeMs}", - request.CorrelationId, - nowMs - request.RequestedAtUnixMs); - await NotifyActorOfDropAsync(request, "stale_inbox_request_dropped"); - return; - } - - // Relay credential gate: relay turns require a fresh reply_token to send the - // outbound. A relay request with no inbox-carried token (e.g., rehydrated from - // persisted state after a pod restart that lost the original capture) cannot - // be delivered, so skip the LLM call entirely. - if (IsRelayRequest(request) && string.IsNullOrWhiteSpace(request.ReplyToken)) - { - _logger.LogWarning( - "Dropping relay LLM reply request without inbox-carried reply_token: correlation={CorrelationId}", - request.CorrelationId); - await NotifyActorOfDropAsync(request, "missing_relay_reply_token"); - return; - } - - var actor = await _actorRuntime.GetAsync(request.TargetActorId) - ?? await _actorRuntime.CreateAsync(request.TargetActorId, CancellationToken.None); - - string replyText; - MessageContent? outboundIntent = null; - var terminalState = LlmReplyTerminalState.Completed; - var errorCode = string.Empty; - var errorSummary = string.Empty; - using TurnStreamingReplySink? streamingSink = TryBuildStreamingSink(request, request.TargetActorId); - - try - { - var effectiveMetadata = await BuildEffectiveMetadataAsync(request, CancellationToken.None); - IDisposable? interactiveReplyScope = null; - try - { - if (ShouldCaptureInteractiveReply(request.Activity)) - interactiveReplyScope = _interactiveReplyCollector?.BeginScope(); - - replyText = await _replyGenerator.GenerateReplyAsync( - request.Activity, - effectiveMetadata, - streamingSink, - CancellationToken.None) ?? string.Empty; - outboundIntent = _interactiveReplyCollector?.TryTake(); - } - finally - { - interactiveReplyScope?.Dispose(); - } - - if (streamingSink is not null && - outboundIntent is null && - !string.IsNullOrWhiteSpace(replyText)) - { - await streamingSink.FinalizeAsync(replyText, CancellationToken.None); - } - - if (outboundIntent is null && string.IsNullOrWhiteSpace(replyText)) - { - terminalState = LlmReplyTerminalState.Failed; - errorCode = "empty_reply"; - errorSummary = "Reply generator returned an empty response."; - replyText = "Sorry, I wasn't able to generate a response. Please try again."; - } - } - catch (Exception ex) - { - terminalState = LlmReplyTerminalState.Failed; - errorCode = "llm_reply_failed"; - errorSummary = ex.Message; - replyText = NyxIdRelayErrorClassifier.Classify(ex.Message); - _logger.LogWarning( - ex, - "Deferred LLM reply generation failed: correlation={CorrelationId}", - request.CorrelationId); - } - - var ready = new LlmReplyReadyEvent - { - CorrelationId = request.CorrelationId, - RegistrationId = request.RegistrationId, - SourceActorId = InboxStreamId, - Activity = request.Activity.Clone(), - Outbound = outboundIntent?.Clone() ?? new MessageContent { Text = replyText }, - TerminalState = terminalState, - ErrorCode = errorCode, - ErrorSummary = errorSummary, - ReadyAtUnixMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), - // Echo the inbox-only relay credential straight back so ConversationGAgent's - // outbound reply does not depend on its in-memory token dict still having the - // entry. The actor consumes these fields and never persists them. - ReplyToken = request.ReplyToken ?? string.Empty, - ReplyTokenExpiresAtUnixMs = request.ReplyTokenExpiresAtUnixMs, - }; - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()), - Payload = Any.Pack(ready), - Route = EnvelopeRouteSemantics.CreateDirect(InboxStreamId, request.TargetActorId), - }; - - await _actorDispatchPort.DispatchAsync(request.TargetActorId, envelope, CancellationToken.None); - } - - private TurnStreamingReplySink? TryBuildStreamingSink(NeedsLlmReplyEvent request, string targetActorId) - { - if (_relayOptions is not { StreamingRepliesEnabled: true }) - return null; - if (request.Activity?.OutboundDelivery is not - { - ReplyMessageId.Length: > 0, - CorrelationId.Length: > 0, - }) - { - return null; - } - if (string.IsNullOrWhiteSpace(request.CorrelationId)) - return null; - - var throttle = TimeSpan.FromMilliseconds(Math.Max(0, _relayOptions.StreamingFlushIntervalMs)); - return new TurnStreamingReplySink( - _actorDispatchPort, - targetActorId, - request.CorrelationId, - request.RegistrationId, - request.Activity.Clone(), - throttle, - _timeProvider, - _logger); - } - - private async Task> BuildEffectiveMetadataAsync( - NeedsLlmReplyEvent request, - CancellationToken ct) - { - var metadata = new Dictionary(request.Metadata, StringComparer.Ordinal); - - // Apply the bot owner's pre-configured LLM route + model. The relay callback - // identifies the bot by api_key_id (in activity.Bot.Value); we resolve that to - // the owner's Aevatar scope id and load the same UserConfig the owner uses - // when chatting through nyxid-chat themselves, then pin ModelOverride / - // NyxIdRoutePreference / MaxToolRoundsOverride from that configuration. - await ApplyBotOwnerLlmConfigAsync(request, metadata, ct); - - // The inbound callback's X-NyxID-User-Token is the bot owner's NyxID session - // JWT (freshly issued by NyxID for each callback). It is the bot owner's own - // credential for LLM calls — the same thing that would authorize them in - // nyxid-chat. The short TTL (~15 min) is mitigated by the direct-enqueue - // dispatch (#380), the inbox-echoed token flow (#383), and the stale pending - // request GC, so the token is still valid when the LLM call actually fires - // for any non-stale request. If the downstream provider rejects it, the - // classifier surfaces a real user-facing error via NyxIdRelayErrorClassifier. - var userAccessToken = request.Activity?.TransportExtras?.NyxUserAccessToken?.Trim(); - if (!string.IsNullOrWhiteSpace(userAccessToken)) - { - metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = userAccessToken; - metadata[LLMRequestMetadataKeys.NyxIdOrgToken] = userAccessToken; - } - - return metadata; - } - - private async Task ApplyBotOwnerLlmConfigAsync( - NeedsLlmReplyEvent request, - IDictionary metadata, - CancellationToken ct) - { - if (_scopeResolver is null || _userConfigQueryPort is null) - return; - - var apiKeyId = request.Activity?.Bot?.Value?.Trim(); - if (string.IsNullOrWhiteSpace(apiKeyId)) - return; - - string? scopeId; - try - { - scopeId = await _scopeResolver.ResolveScopeIdByApiKeyAsync(apiKeyId, ct); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to resolve bot owner scope id for LLM config: correlation={CorrelationId} apiKeyId={ApiKeyId}", - request.CorrelationId, - apiKeyId); - return; - } - - if (string.IsNullOrWhiteSpace(scopeId)) - { - _logger.LogDebug( - "No bot owner scope id resolved for LLM config: correlation={CorrelationId} apiKeyId={ApiKeyId}", - request.CorrelationId, - apiKeyId); - return; - } - - try - { - var config = await _userConfigQueryPort.GetAsync(scopeId, ct); - if (!string.IsNullOrWhiteSpace(config.DefaultModel)) - metadata[LLMRequestMetadataKeys.ModelOverride] = config.DefaultModel.Trim(); - if (!string.IsNullOrWhiteSpace(config.PreferredLlmRoute)) - metadata[LLMRequestMetadataKeys.NyxIdRoutePreference] = config.PreferredLlmRoute.Trim(); - if (config.MaxToolRounds > 0) - metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride] = - config.MaxToolRounds.ToString(System.Globalization.CultureInfo.InvariantCulture); - - _logger.LogInformation( - "Applied bot owner LLM config: correlation={CorrelationId} scopeId={ScopeId} model={Model} route={Route}", - request.CorrelationId, - scopeId, - string.IsNullOrWhiteSpace(config.DefaultModel) ? "" : config.DefaultModel, - string.IsNullOrWhiteSpace(config.PreferredLlmRoute) ? "" : config.PreferredLlmRoute); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to load bot owner LLM config: correlation={CorrelationId} scopeId={ScopeId}", - request.CorrelationId, - scopeId); - } - } - - private static bool IsRelayRequest(NeedsLlmReplyEvent request) => - request.Activity?.OutboundDelivery is - { - ReplyMessageId.Length: > 0, - CorrelationId.Length: > 0, - }; - - private async Task NotifyActorOfDropAsync(NeedsLlmReplyEvent request, string reason) - { - if (string.IsNullOrWhiteSpace(request.TargetActorId) || - string.IsNullOrWhiteSpace(request.CorrelationId)) - { - return; - } - - IActor? actor; - try - { - actor = await _actorRuntime.GetAsync(request.TargetActorId); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to resolve actor for inbox drop notification: correlation={CorrelationId} target={TargetActorId}", - request.CorrelationId, - request.TargetActorId); - return; - } - - if (actor is null) - { - // No active actor means there is nothing pending to clean up; the request - // either was never persisted or the actor's state was already retired. - return; - } - - var dropped = new DeferredLlmReplyDroppedEvent - { - CorrelationId = request.CorrelationId, - Reason = reason, - DroppedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(dropped), - Route = EnvelopeRouteSemantics.CreateDirect(InboxStreamId, request.TargetActorId), - }; - - try - { - await _actorDispatchPort.DispatchAsync(request.TargetActorId, envelope, CancellationToken.None); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to deliver inbox drop notification: correlation={CorrelationId} reason={Reason}", - request.CorrelationId, - reason); - } - } - - private bool ShouldCaptureInteractiveReply(ChatActivity? activity) - { - if (_interactiveReplyCollector is null) - return false; - - if (_relayOptions is { InteractiveRepliesEnabled: false }) - return false; - - return activity?.OutboundDelivery is - { - ReplyMessageId.Length: > 0, - CorrelationId.Length: > 0, - }; - } -} - -public sealed class ChannelLlmReplyInboxHostedService : IHostedService -{ - private readonly ChannelLlmReplyInboxRuntime _runtime; - - public ChannelLlmReplyInboxHostedService(ChannelLlmReplyInboxRuntime runtime) - { - _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - } - - public Task StartAsync(CancellationToken ct) => _runtime.StartAsync(ct); - - public Task StopAsync(CancellationToken ct) => _runtime.StopAsync(ct); -} diff --git a/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs index ae0039261..c3c732327 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ConversationReplyGenerator.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Text; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.LLMProviders; @@ -8,6 +9,8 @@ using Aevatar.AI.ToolProviders.Skills; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Runtime; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.GAgents.NyxidChat; @@ -23,9 +26,17 @@ public sealed class NyxIdConversationReplyGenerator : IConversationReplyGenerato private readonly IReadOnlyList _toolMiddlewares; private readonly IReadOnlyList _llmMiddlewares; private readonly SkillRegistry? _skillRegistry; + private readonly IRemoteSkillFetcher? _remoteSkillFetcher; private readonly global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? _relayOptions; private readonly INyxIdUserLlmPreferencesStore? _preferencesStore; private readonly IUserMemoryStore? _userMemoryStore; + private readonly ILogger _logger; + + private sealed record EffectiveMetadataPlan( + IReadOnlyDictionary Primary, + IReadOnlyDictionary? OwnerFallback); + + private sealed record SenderPreferenceApplication(bool AnyApplied, bool RouteApplied); public NyxIdConversationReplyGenerator( ILLMProviderFactory llmProviderFactory, @@ -34,9 +45,11 @@ public NyxIdConversationReplyGenerator( IEnumerable? toolMiddlewares = null, IEnumerable? llmMiddlewares = null, SkillRegistry? skillRegistry = null, + IRemoteSkillFetcher? remoteSkillFetcher = null, global::Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions? relayOptions = null, INyxIdUserLlmPreferencesStore? preferencesStore = null, - IUserMemoryStore? userMemoryStore = null) + IUserMemoryStore? userMemoryStore = null, + ILogger? logger = null) { _llmProviderFactory = llmProviderFactory ?? throw new ArgumentNullException(nameof(llmProviderFactory)); _toolSources = (toolSources ?? []).ToArray(); @@ -44,9 +57,26 @@ public NyxIdConversationReplyGenerator( _toolMiddlewares = (toolMiddlewares ?? []).ToArray(); _llmMiddlewares = (llmMiddlewares ?? []).ToArray(); _skillRegistry = skillRegistry; + _remoteSkillFetcher = remoteSkillFetcher; _relayOptions = relayOptions; _preferencesStore = preferencesStore; _userMemoryStore = userMemoryStore; + _logger = logger ?? NullLogger.Instance; + + // Surface a half-wired skills configuration at startup. When the registry is + // present but the remote fetcher is not, use_skill is still advertised to the + // LLM (BuildTurnToolsAsync registers it from the registry alone) yet any call + // that would have to pull a remote skill silently falls back to "skill not + // found". Logging at construction time gives ops a single line they can grep + // for instead of debugging a flaky use_skill in production. + // (PR #562 review on ConversationReplyGenerator.cs:120, 4-of-5 reviewers.) + if (_skillRegistry is not null && _remoteSkillFetcher is null) + { + _logger.LogWarning( + "NyxIdConversationReplyGenerator wired with SkillRegistry but no IRemoteSkillFetcher: " + + "use_skill will be advertised to the LLM but cannot pull remote skills. " + + "Register an IRemoteSkillFetcher (e.g. AddOrnnSkills) or drop the SkillRegistry to silence this."); + } } public async Task GenerateReplyAsync( @@ -58,15 +88,96 @@ public NyxIdConversationReplyGenerator( ArgumentNullException.ThrowIfNull(activity); ArgumentNullException.ThrowIfNull(metadata); - var effectiveMetadata = await BuildEffectiveMetadataAsync(metadata, ct); - var history = new global::Aevatar.AI.Core.Chat.ChatHistory + // Emit a placeholder immediately so the user sees a message within the outbound RTT, + // regardless of LLM cold-start, router selection, or tool-call latency before the + // first real delta. The first real delta overwrites this placeholder via edit-in-place; + // if no delta ever arrives (tool-only or empty turn), the caller's FinalizeAsync edits + // the placeholder to the final text. Disabled by setting the option to empty/whitespace. + if (streamingSink is not null) { - MaxMessages = MaxHistoryMessages, - }; + var placeholder = _relayOptions?.StreamingPlaceholderText; + if (!string.IsNullOrWhiteSpace(placeholder)) + await streamingSink.OnDeltaAsync(placeholder, ct); + } + + var metadataPlan = await BuildEffectiveMetadataPlanAsync(metadata, ct); + var primaryTools = await BuildTurnToolsAsync(ct); + + try + { + return await GenerateWithMetadataAsync( + activity, + metadataPlan.Primary, + primaryTools, + streamingSink, + ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (metadataPlan.OwnerFallback is not null && IsRetryableSenderRouteFailure(ex)) + { + _logger.LogWarning( + ex, + "Sender LLM route failed; retrying with bot owner LLM config. activity={ActivityId}", + activity.Id); + + var fallbackTools = await BuildTurnToolsAsync(ct); + return await GenerateWithMetadataAsync( + activity, + metadataPlan.OwnerFallback, + fallbackTools, + streamingSink, + ct) + .ConfigureAwait(false); + } + } + + /// + /// Decide whether falling back from sender credentials to owner credentials is worth + /// the retry. Programmer errors (Argument*, NullReference, InvalidCast) are not transient + /// and would only fail the same way with the owner token while burying the original cause + /// behind a second failure. We retry only on infra-shaped failures: network, timeout, JSON + /// parsing of upstream errors, and the InvalidOperationException NyxID emits when an + /// access token is rejected. + /// + private static bool IsRetryableSenderRouteFailure(Exception ex) => + ex is HttpRequestException + or TimeoutException + or System.Text.Json.JsonException + or InvalidOperationException + or TaskCanceledException + or System.IO.IOException; + + private async Task BuildTurnToolsAsync(CancellationToken ct) + { var tools = new ToolManager(); foreach (var tool in await DiscoverToolsAsync(ct)) tools.Register(tool); + // SkillsAgentToolSource (when AddSkills is wired) advertises the same use_skill + // through DiscoverToolsAsync, so this defensive registration only matters for + // minimal hosts that registered AddOrnnSkills (IRemoteSkillFetcher) without + // AddSkills. ToolManager.Register is last-write-wins so the duplicate is harmless. + if (_skillRegistry is not null || _remoteSkillFetcher is not null) + tools.Register(new UseSkillTool(_skillRegistry ?? new SkillRegistry(), _remoteSkillFetcher)); + + return tools; + } + + private async Task GenerateWithMetadataAsync( + ChatActivity activity, + IReadOnlyDictionary effectiveMetadata, + ToolManager tools, + IStreamingReplySink? streamingSink, + CancellationToken ct) + { + var history = new global::Aevatar.AI.Core.Chat.ChatHistory + { + MaxMessages = MaxHistoryMessages, + }; var runtime = new ChatRuntime( providerFactory: ResolveProvider, history: history, @@ -91,18 +202,6 @@ public NyxIdConversationReplyGenerator( agentName: "NyxIdConversationReply", streamBufferCapacity: StreamBufferCapacity); - // Emit a placeholder immediately so the user sees a message within the outbound RTT, - // regardless of LLM cold-start, router selection, or tool-call latency before the - // first real delta. The first real delta overwrites this placeholder via edit-in-place; - // if no delta ever arrives (tool-only or empty turn), the caller's FinalizeAsync edits - // the placeholder to the final text. Disabled by setting the option to empty/whitespace. - if (streamingSink is not null) - { - var placeholder = _relayOptions?.StreamingPlaceholderText; - if (!string.IsNullOrWhiteSpace(placeholder)) - await streamingSink.OnDeltaAsync(placeholder, ct); - } - var output = new StringBuilder(); await foreach (var chunk in runtime.ChatStreamAsync( activity.Content.Text, @@ -122,11 +221,13 @@ public NyxIdConversationReplyGenerator( return output.ToString(); } - private async Task> BuildEffectiveMetadataAsync( + private async Task BuildEffectiveMetadataPlanAsync( IReadOnlyDictionary metadata, CancellationToken ct) { var effective = new Dictionary(metadata, StringComparer.Ordinal); + effective.Remove(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + Dictionary? ownerFallback = null; // Issue #513 phase 3: prefs override chain is sender → bot-owner → // provider default. The bot owner's prefs are already pinned upstream @@ -135,12 +236,33 @@ private async Task> BuildEffectiveMetadataAs // so this generator only has to layer sender overrides on top when // the inbound carries a binding-id. SetIfFilled is field-level, so a // sender who set DefaultModel but not PreferredRoute still inherits - // the bot owner's route from the upstream-pinned metadata. + // the bot owner's route from the upstream-pinned metadata. If a + // sender-owned attempt fails, we retry once with this owner snapshot. if (_preferencesStore is not null && metadata.TryGetValue(LLMRequestMetadataKeys.SenderBindingId, out var senderBindingId) && !string.IsNullOrWhiteSpace(senderBindingId)) { - await ApplyPreferencesAsync(senderBindingId, effective, ct); + var ownerSnapshot = CreateOwnerFallbackSnapshot(effective); + var applied = await ApplyPreferencesAsync(senderBindingId, effective, ct); + if (applied.RouteApplied) + { + if (metadata.TryGetValue(LLMRequestMetadataKeys.SenderNyxIdAccessToken, out var senderAccessToken) && + !string.IsNullOrWhiteSpace(senderAccessToken)) + { + var trimmedToken = senderAccessToken.Trim(); + effective[LLMRequestMetadataKeys.NyxIdAccessToken] = trimmedToken; + effective[LLMRequestMetadataKeys.NyxIdOrgToken] = trimmedToken; + ownerFallback = ownerSnapshot; + } + else + { + effective = ownerSnapshot; + } + } + else if (applied.AnyApplied) + { + ownerFallback = ownerSnapshot; + } } if (_userMemoryStore is not null) @@ -149,7 +271,11 @@ private async Task> BuildEffectiveMetadataAs { var promptSection = await _userMemoryStore.BuildPromptSectionAsync(2000, ct); if (!string.IsNullOrWhiteSpace(promptSection)) + { effective[LLMRequestMetadataKeys.UserMemoryPrompt] = promptSection; + if (ownerFallback is not null) + ownerFallback[LLMRequestMetadataKeys.UserMemoryPrompt] = promptSection; + } } catch (OperationCanceledException) { @@ -161,7 +287,7 @@ private async Task> BuildEffectiveMetadataAs } } - return effective; + return new EffectiveMetadataPlan(effective, ownerFallback); } /// @@ -170,13 +296,13 @@ private async Task> BuildEffectiveMetadataAs /// the bot owner's value stays intact. User-config failures degrade to /// "no sender override" rather than failing the LLM turn. /// - private async Task ApplyPreferencesAsync( + private async Task ApplyPreferencesAsync( string senderBindingId, Dictionary effective, CancellationToken ct) { if (_preferencesStore is null) - return; + return new SenderPreferenceApplication(false, false); NyxIdUserLlmPreferences preferences; try @@ -189,22 +315,32 @@ private async Task ApplyPreferencesAsync( } catch { - return; + return new SenderPreferenceApplication(false, false); } - SetIfFilled(effective, LLMRequestMetadataKeys.ModelOverride, preferences.DefaultModel?.Trim()); - SetIfFilled(effective, LLMRequestMetadataKeys.NyxIdRoutePreference, preferences.PreferredRoute?.Trim()); - SetIfFilled( + var modelApplied = SetIfFilled(effective, LLMRequestMetadataKeys.ModelOverride, preferences.DefaultModel?.Trim()); + var routeApplied = SetIfFilled(effective, LLMRequestMetadataKeys.NyxIdRoutePreference, preferences.PreferredRoute?.Trim()); + var roundsApplied = SetIfFilled( effective, LLMRequestMetadataKeys.MaxToolRoundsOverride, preferences.MaxToolRounds > 0 ? preferences.MaxToolRounds.ToString() : null); + return new SenderPreferenceApplication(modelApplied || routeApplied || roundsApplied, routeApplied); + } + + private static Dictionary CreateOwnerFallbackSnapshot(Dictionary effective) + { + var snapshot = new Dictionary(effective, StringComparer.Ordinal); + snapshot.Remove(LLMRequestMetadataKeys.SenderBindingId); + snapshot.Remove(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + return snapshot; } - private static void SetIfFilled(Dictionary map, string key, string? value) + private static bool SetIfFilled(Dictionary map, string key, string? value) { if (string.IsNullOrWhiteSpace(value)) - return; + return false; map[key] = value; + return true; } private async Task> DiscoverToolsAsync(CancellationToken ct) @@ -248,7 +384,7 @@ private string BuildSystemPrompt() var prompt = LoadBaseSystemPrompt(); prompt += NyxIdRelayPromptConfiguration.BuildChannelRuntimeConfigurationSection(_relayOptions); - if (_skillRegistry != null && _skillRegistry.Count > 0) + if (_skillRegistry is not null && _skillRegistry.Count > 0) { var skillSection = _skillRegistry.BuildSystemPromptSection(); if (!string.IsNullOrEmpty(skillSection)) diff --git a/agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmSelectionService.cs b/agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmSelectionService.cs index d74b1c233..072281262 100644 --- a/agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmSelectionService.cs +++ b/agents/Aevatar.GAgents.NyxidChat/LlmSelection/DefaultUserLlmSelectionService.cs @@ -41,8 +41,7 @@ public async Task SetByServiceAsync( ArgumentException.ThrowIfNullOrWhiteSpace(serviceId); var view = await _optionsService.GetOptionsAsync(ToQuery(context), ct).ConfigureAwait(false); - var option = view.Available.FirstOrDefault(candidate => - string.Equals(candidate.ServiceId, serviceId.Trim(), StringComparison.OrdinalIgnoreCase)); + var option = FindSelectionOption(serviceId.Trim(), view.Available); if (option is null) throw new InvalidOperationException($"LLM service '{serviceId}' is not available for this user."); EnsureSelectable(option); @@ -127,6 +126,32 @@ private static void EnsureSelectable(UserLlmOption option) throw new InvalidOperationException($"LLM service '{option.DisplayName}' is not ready: {option.Status}."); } + private static UserLlmOption? FindSelectionOption(string requested, IReadOnlyList available) + { + var directMatches = available + .Where(option => string.Equals(option.ServiceId, requested, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var directSelectable = directMatches.Where(IsSelectable).Take(2).ToArray(); + if (directSelectable.Length == 1) + return directSelectable[0]; + + var keyMatches = available + .Where(option => + string.Equals(option.ServiceId, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.ServiceSlug, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.RouteValue, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.DisplayName, requested, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var selectable = keyMatches.Where(IsSelectable).Take(2).ToArray(); + if (selectable.Length == 1) + return selectable[0]; + + return directMatches.FirstOrDefault() ?? (keyMatches.Length == 1 ? keyMatches[0] : null); + } + + private static bool IsSelectable(UserLlmOption option) => + option.Allowed && string.Equals(option.Status, "ready", StringComparison.OrdinalIgnoreCase); + public async Task ResetAsync(UserLlmSelectionContext context, CancellationToken ct) { var current = await ReadCurrentAsync(context, ct).ConfigureAwait(false); diff --git a/agents/Aevatar.GAgents.NyxidChat/LlmSelection/NyxIdLlmServiceCatalogClient.cs b/agents/Aevatar.GAgents.NyxidChat/LlmSelection/NyxIdLlmServiceCatalogClient.cs index 9d9828f00..c1d949b10 100644 --- a/agents/Aevatar.GAgents.NyxidChat/LlmSelection/NyxIdLlmServiceCatalogClient.cs +++ b/agents/Aevatar.GAgents.NyxidChat/LlmSelection/NyxIdLlmServiceCatalogClient.cs @@ -1,16 +1,31 @@ +using System.Security.Cryptography; +using System.Text; using Aevatar.AI.ToolProviders.NyxId; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.GAgents.NyxidChat.LlmSelection; public sealed class NyxIdLlmServiceCatalogClient : INyxIdLlmServiceCatalogClient { + private static readonly TimeSpan ProxyServicesCacheTtl = TimeSpan.FromSeconds(30); + private const string ProxyServicesCacheKeyPrefix = "nyxid-llm-svc:proxy-services:"; + private readonly NyxIdApiClient _nyxClient; + private readonly IMemoryCache _proxyServicesCache; + private readonly ILogger _logger; - public NyxIdLlmServiceCatalogClient(NyxIdApiClient nyxClient) + public NyxIdLlmServiceCatalogClient( + NyxIdApiClient nyxClient, + IMemoryCache proxyServicesCache, + ILogger? logger = null) { _nyxClient = nyxClient ?? throw new ArgumentNullException(nameof(nyxClient)); + _proxyServicesCache = proxyServicesCache ?? throw new ArgumentNullException(nameof(proxyServicesCache)); + _logger = logger ?? NullLogger.Instance; } public async Task GetServicesAsync( @@ -22,7 +37,8 @@ public async Task GetServicesAsync( ArgumentException.ThrowIfNullOrWhiteSpace(accessToken); var response = await _nyxClient.GetLlmServicesAsync(accessToken, ct).ConfigureAwait(false); - return NyxIdLlmServiceCatalogParser.ParseServicesResult(response); + var result = NyxIdLlmServiceCatalogParser.ParseServicesResult(response); + return await MergeProxyRouteCandidatesAsync(result, accessToken, ct).ConfigureAwait(false); } public async Task GetSetupHintAsync( @@ -49,4 +65,61 @@ public async Task ProvisionAsync( .ConfigureAwait(false); return NyxIdLlmServiceCatalogParser.ParseProvisionedService(response); } + + private async Task MergeProxyRouteCandidatesAsync( + NyxIdLlmServicesResult result, + string accessToken, + CancellationToken ct) + { + try + { + var proxyServices = await DiscoverProxyServicesCachedAsync(accessToken, ct).ConfigureAwait(false); + return NyxIdLlmServiceCatalogParser.MergeProxyRouteCandidates(result, proxyServices); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge NyxID proxy services into LLM route catalog"); + return result; + } + } + + /// + /// Cache the per-user /api/v1/proxy/services response for a short TTL so a flurry + /// of /model invocations from the same user collapses onto one upstream call. We use + /// rather than a singleton dictionary so the cache backing + /// store is shared, sized, and evicted per the host's standard memory-cache policy + /// (CLAUDE.md §"中间层状态约束" — services don't own per-caller state directly). + /// + private async Task DiscoverProxyServicesCachedAsync( + string accessToken, + CancellationToken ct) + { + var cacheKey = ProxyServicesCacheKeyPrefix + ComputeTokenFingerprint(accessToken); + if (_proxyServicesCache.TryGetValue(cacheKey, out string? cached) && + !string.IsNullOrEmpty(cached)) + { + return cached; + } + + var response = await _nyxClient.DiscoverProxyServicesAsync(accessToken, ct).ConfigureAwait(false); + // Size is not set on the entry — IMemoryCache only enforces Size when the host + // configured a SizeLimit on MemoryCacheOptions. The cache backing store is owned + // by the host (we register IMemoryCache via AddMemoryCache, no per-entry size + // policy from us), so leave eviction to the host's TimeBasedExpiration default. + _proxyServicesCache.Set( + cacheKey, + response, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ProxyServicesCacheTtl, + }); + return response; + } + + private static string ComputeTokenFingerprint(string accessToken) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken))); } diff --git a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs index 151a082ae..000431a20 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Aevatar.AI.Abstractions.Middleware; +using Aevatar.AI.ToolProviders.Lark; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Abstractions.Slash; using Aevatar.GAgents.Channel.NyxIdRelay; @@ -9,7 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.NyxidChat; @@ -19,6 +20,7 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, { ArgumentNullException.ThrowIfNull(services); RuntimeHelpers.RunClassConstructor(typeof(NyxIdChatGAgent).TypeHandle); + RuntimeHelpers.RunClassConstructor(typeof(AgentRunGAgent).TypeHandle); services.AddHttpClient(); services.TryAddSingleton(provider => BindRelayOptions(configuration)); @@ -34,13 +36,27 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, services.TryAddSingleton(); services.TryAddSingleton(); - // ─── Channel LLM reply inbox runtime + hosted service ─── - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // ─── Channel LLM reply run dispatch ─── + services.TryAddSingleton(); // ─── Conversation turn-runner override + reply generator ─── services.Replace(ServiceDescriptor.Singleton()); + // The CardKit runner depends on Aevatar.AI.ToolProviders.Lark services. AddNyxIdChat() + // does not transitively register them — production hosts also call AddLarkTools() — + // so resolve via factory and gracefully fall back to the no-op runner when Lark + // tooling is absent. This keeps CardKit dormant for hosts that opt out of Lark + // instead of failing DI validation at startup. + services.Replace(ServiceDescriptor.Singleton(sp => + { + var cardKit = sp.GetService(); + var lark = sp.GetService(); + if (cardKit is null || lark is null) + return new NullConversationCardTurnRunner(); + return new ChannelCardConversationTurnRunner( + cardKit, + lark, + sp.GetRequiredService>()); + })); services.TryAddSingleton(); // ─── LLM-call middleware that injects channel context into LLM requests ─── @@ -54,6 +70,10 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, // Registered here (not in Channel.Identity) because the handler depends // on Studio.Application UserConfig ports; Channel.Identity intentionally // does not pull Studio dependencies. + // Catalog client uses IMemoryCache for the proxy-services TTL cache. AddMemoryCache + // is idempotent (no-op when already registered) so hosts that already wire it keep + // their configured eviction policy; hosts that didn't register one get the default. + services.AddMemoryCache(); services.TryAddSingleton(); // These are consumed by singleton turn-runner/slash handlers. They create // short scopes internally for UserConfig ports instead of capturing diff --git a/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md b/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md index b8bde68d5..89cafa039 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md +++ b/agents/Aevatar.GAgents.NyxidChat/Skills/system-prompt.md @@ -29,7 +29,40 @@ Rules: - Only ask the user a follow-up question when required inputs are genuinely missing and cannot be inferred. - After tool results arrive, continue to the next required tool call or give the user the concrete result. -## Capability Tools (Doing Things) +## Skills (CRITICAL — NyxID and Ornn knowledge lives here) + +This prompt deliberately keeps the NyxID and Ornn user manuals **out of the system prompt** and on the Ornn skill platform instead, so curators can update those manuals without redeploying the bot. You learn the canonical, up-to-date usage by loading the relevant skill. + +**Before doing any of the following, call `use_skill(skill="nyxid")` first** to load the authoritative NyxID manual: +- Account / profile / MFA / sessions / consents +- Service catalog browsing, connecting a new service (OAuth / device-code / API key flows) +- API key, node, organization, approval, notification management +- Diagnosing NyxID error codes (`approval_required`, `unauthorized`, `node_offline`, etc.) +- Anything that would otherwise need `nyxid_account`, `nyxid_status`, `nyxid_profile`, `nyxid_mfa`, `nyxid_sessions`, `nyxid_catalog`, `nyxid_services`, `nyxid_endpoints`, `nyxid_external_keys`, `nyxid_api_keys`, `nyxid_nodes`, `nyxid_approvals`, `nyxid_notifications`, `nyxid_providers`, `nyxid_orgs`, `nyxid_admin`, `nyxid_search_capabilities`, `nyxid_proxy_execute` + +**Before driving the Ornn API directly via the AI Agent CLI, call `use_skill(skill="ornn-agent-manual-cli")`** to load the Ornn agent manual. + +`use_skill` caches the loaded instructions in-process for ~5 minutes; after that window the next call refetches from Ornn so curator updates land within 5 minutes without a redeploy. + +### Proactive skill discovery + +When the user mentions a named skill or asks for a specialized capability (translation, summarization, network/device inventory, scraping, scheduling, content drafting, code review, domain workflows, etc.), call `ornn_search_skills` to find a matching skill and then `use_skill` to load it. Treat the loaded skill's instructions as authoritative for that task. + +Triggers: +- User quotes a skill name (`'translate-pro'`, `"sg-office-network"`) +- User uses a slug-like or Title Case identifier that could be a skill name +- User issues a `/` slash command that isn't an in-tree relay command (the in-tree ones are `/route`, `/models`, `/model`, `/agents`, `/agent-status`, `/run-agent`, `/disable-agent`, `/enable-agent`, `/delete-agent`) — treat the command name as the skill query (`/daily` → search "daily") +- User says "挂载/mount/use/load this skill" or names a domain workflow + +Only fall back to `nyxid_proxy` / generic API discovery when no skill matches. + +### Quick reference + +- **Search**: `ornn_search_skills` — keywords or skill name; `scope=public|private|mixed` +- **Activate**: `use_skill skill=""` — loads instructions + associated files +- **Follow**: once loaded, the skill's instructions take precedence over generic guidance for that task + +## Capability Tools (the universal primitives) ### code_execute — Run Code Execute Python, JavaScript, TypeScript, or Bash in a sandboxed environment. Returns stdout, stderr, and exit code. Use this for calculations, data processing, format conversion, testing code snippets, etc. @@ -39,45 +72,16 @@ Make HTTP requests to any connected service. NyxID injects credentials automatic - Omit slug → discover all proxyable services with proxy URLs - Provide slug + path + method + body → make the proxied request -**Critical**: Proxy paths are relative to the service's base URL (shown in ``). Do NOT duplicate version prefixes already in the base URL. +**Critical**: Proxy paths are relative to the service's base URL (shown in ``). Do NOT duplicate version prefixes already in the base URL. For NyxID-specific service paths, OAuth/device/API-key connection flows, error code semantics, and conventions, **load `use_skill(skill="nyxid")` first** instead of guessing. ### Channel Bots — Messaging Use `nyxid_proxy` with a Telegram/Discord bot's slug to send messages. For Telegram: POST `/sendMessage` with `{"chat_id":"...","text":"..."}`. -## Account & Service Management Tools - -### Account -- **nyxid_account** — View user profile and account status -- **nyxid_status** — Comprehensive overview (user + services + API keys + nodes) -- **nyxid_profile** — Update display name, delete account, manage OAuth consents -- **nyxid_mfa** — Setup/verify TOTP multi-factor authentication -- **nyxid_sessions** — List active login sessions - -### Services -- **nyxid_catalog** — Browse service templates (list all, or show details for a slug) -- **nyxid_services** — Manage connected services: list, show, create, update, delete, rotate_credential, route -- **nyxid_endpoints** — Manage service base URLs: list, update, delete -- **nyxid_external_keys** — Manage external API credentials: list, rotate, delete - -### Security & Access -- **nyxid_api_keys** — Manage NyxID API keys: list, show, create, rotate, delete, update -- **nyxid_nodes** — Manage on-premise nodes: list, show, delete, register_token, rotate_token -- **nyxid_approvals** — Manage approvals: list/show requests, approve/deny, grants, per-service config -- **nyxid_notifications** — Notification settings & Telegram integration -- **nyxid_llm_status** — Check available LLM providers and models -- **nyxid_providers** — Manage OAuth provider connections: list, connect, disconnect, credentials - -### Organizations -- **nyxid_orgs** — Manage NyxID organizations (shared credentials): list, show, create, update, delete, join, set_primary, member management (list/add/update/remove), invites (list/create/cancel) - -### Channel Bots & Events -- **channel_registrations** — List, provision, rebuild, repair, and delete Aevatar's local Lark relay registrations. Use this for Aevatar-managed Lark setup, for rebuilding the local read model from the authoritative actor state, and for restoring the local mirror when Nyx relay resources already exist -- **agent_delivery_targets** — Manage agent delivery target mappings used by workflow human approval/input cards and other outbound channel delivery -- **agent_builder** — Create and manage Day One persistent automation agents in Feishu private chat. Internal tool actions: `list_templates`, `create_agent`, `list_agents`, `agent_status`, `run_agent`, `disable_agent`, `enable_agent`, `delete_agent`. Internal template names (used only inside `create_agent` arguments): `daily_report`, `social_media`. **When talking to the user, always use the slash-command names — never surface the internal template names `daily_report` / `social_media`.** User-facing slash commands: `/daily [github_username]`, `/social-media `, `/agents`, `/agent-status `, `/run-agent `, `/disable-agent `, `/enable-agent `, `/delete-agent confirm`. -- **nyxid_channel_bots** — NyxID-native channel bot management: inspect/register/verify/delete bots and manage conversation routes directly via NyxID API. Use this to inspect existing Nyx Lark bot/route state or register Nyx-native fields such as `verification_token` -- **nyxid_channel_events** — Push device/analyzer events through the NyxID HTTP Event Gateway to agent conversations - -### LLM Route Selection +## Aevatar-specific tools + +These are **aevatar-internal** tools, not on Ornn's `nyxid` skill — they manage state local to this aevatar deployment. + +### LLM Route Selection (slash commands) The relay handles LLM route selection deterministically, without an LLM round-trip. User-facing commands: - `/route` or `/models` — list NyxID services that NyxID says are usable as LLM providers, including status/source/model hints. @@ -85,235 +89,74 @@ The relay handles LLM route selection deterministically, without an LLM round-tr - `/model use ` — keep the current route and only override the model. - `/model reset` — clear the sender's route/model preference and fall back to the bot default. -### Admin -- **nyxid_admin** — Administrative commands (admin role required): manage invite codes (list, create, deactivate) - -### API Discovery (Fallback) -- **nyxid_search_capabilities** — Search NyxID API capabilities by natural language query. Returns matching operations with method, path, and parameters. Use this to discover endpoints not covered by specialized tools -- **nyxid_proxy_execute** — Execute a NyxID API operation discovered via nyxid_search_capabilities. Validates parameters against cached OpenAPI spec before sending - -## Connecting New Services - -All connection info comes from the catalog entry. Use `nyxid_catalog action=show slug=` and read: - -| Field | Meaning | -|-------|---------| -| `provider_type` | Connection method: `oauth2`, `device_code`, `api_key` | -| `credential_mode` | Who provides OAuth app: `admin` (platform) or `user` (user must provide) | -| `provider_config_id` | Provider ID for OAuth/device-code | -| `api_key_instructions` | How to get an API key (display as-is) | -| `api_key_url` | Where to get the key (clickable link) | -| `requires_gateway_url` | If true, user must also provide endpoint URL | - -### OAuth Flow -1. Check `nyxid_providers action=list` for existing connection -2. If `credential_mode=user`: check/set credentials via `nyxid_providers action=get_credentials/set_credentials` - - Callback URL: `https://nyx-api.chrono-ai.fun/api/v1/providers/callback` -3. `nyxid_providers action=connect_oauth provider_id=` → give user the authorization URL -4. Verify with `nyxid_providers action=list` - -### Device Code Flow -1. `nyxid_providers action=connect_device_code provider_id=` → tell user to visit URL and enter code -2. Poll: `nyxid_providers action=poll_device_code provider_id= state=` -3. Verify with `nyxid_providers action=list` - -### API Key Flow -1. Guide user with catalog's `api_key_instructions` and `api_key_url` -2. `nyxid_services action=create service_slug= credential= label=` -3. Test with a simple read-only proxy request - -If user asks to connect a service and you don't know the slug, browse with `nyxid_catalog action=list`. - -## Channel Bot Setup (Lark via Nyx Relay) +### channel_registrations (Aevatar's local Lark mirror) Aevatar owns the local runtime and registration mirror. For Lark, webhook ingress goes through NyxID first, then NyxID relays callbacks into Aevatar. Nyx owns the platform bot, route, and relay API key; Aevatar owns the local registration mirror used by the runtime. Do not assume `channel_registrations action=list` being empty means the Nyx bot is missing. -### Lark Stage 1: New provisioning - -Use this stage when the user wants the bot connected for inbound Lark messages and basic relay replies. -Do not block this stage on typed Lark tools, delivery target bindings, or proactive outbound setup. - -Register channel bot in Aevatar: +**Stage 1: New provisioning** — when the user wants the bot connected for inbound Lark messages and basic relay replies. Do not block on typed Lark tools or proactive outbound setup. `channel_registrations action=register_lark_via_nyx app_id= app_secret= verification_token= webhook_base_url=https://` -`verification_token` is optional in the tool contract, but when the user has it or the Nyx backend requires it, pass it through. - -→ This returns the registration ID, the Nyx relay callback URL, and the Nyx webhook URL that must be configured in the Lark developer console. - -Configure the platform webhook: - -**Lark/Feishu:** 开发者后台 → 事件与回调 → 事件配置 → 请求地址: -`` - -Add events: -- `im.message.receive_v1` -- `card.action.trigger` - -### Lark Stage 2: Repair an existing bot +→ Returns the registration ID, the Nyx relay callback URL, and the Nyx webhook URL that must be configured in 开发者后台 → 事件与回调 → 事件配置 → 请求地址. -Use this stage when Nyx already has the Lark bot and route, but Aevatar no longer replies or `channel_registrations action=list` is empty. +Add events: `im.message.receive_v1`, `card.action.trigger`. -First try rebuilding the local registration read model from the authoritative actor state: +**Stage 2: Repair an existing bot** — when Nyx already has the Lark bot/route but Aevatar no longer replies or `channel_registrations action=list` is empty. -`channel_registrations action=rebuild_projection` +1. `channel_registrations action=rebuild_projection` — rebuild local read model from authoritative actor state. +2. Inspect Nyx-side first: `nyxid_channel_bots action=list` / `show` / `routes`. (For NyxID-side details, `use_skill(skill="nyxid")`.) +3. If Nyx is healthy but local list still empty, restore the local mirror: + `channel_registrations action=repair_lark_mirror registration_id= credential_ref= webhook_base_url=https:// nyx_channel_bot_id= nyx_agent_api_key_id= nyx_conversation_route_id=` + `repair_lark_mirror` must preserve the existing relay credential reference. Reuse `registration_id` when its `vault://.../relay-hmac` secret still exists, or pass `credential_ref` explicitly. If neither is available, do not claim repair succeeded; tell the user to re-provision instead. -Inspect the Nyx side first: +**Stage 3: Advanced Lark capabilities** — only when the user needs proactive sends, typed Lark tools, delivery target bindings, spreadsheet appends, approval actions, or active chat lookup. Ensure NyxID has a usable Lark outbound provider slug (typically `api-lark-bot`); if not, `use_skill(skill="nyxid")` to drive the catalog connection flow. -- `nyxid_channel_bots action=list` -- `nyxid_channel_bots action=show id=` -- `nyxid_channel_bots action=routes channel_bot_id=` -- `nyxid_api_keys action=show id=` +For advanced Lark API operations outside the current relay reply, prefer typed tools: `lark_messages_send`, `lark_messages_search`, `lark_messages_batch_get`, `lark_messages_reactions_list`, `lark_messages_reactions_delete`, `lark_chats_lookup`, `lark_sheets_append_rows`, `lark_approvals_list`, `lark_approvals_act`. Fall back to `nyxid_proxy_execute` only when typed tools don't cover. -If the Nyx bot, route, and relay callback are correct but rebuild did not restore the local list, restore the local Aevatar mirror: +For inbound Lark relay turns that represent a fresh user message, do **not** call `lark_messages_reply`, `lark_messages_react`, or `nyxid_proxy_execute` to deliver the answer. Produce the final text reply directly; the channel runtime will send it through the Nyx relay reply token. -`channel_registrations action=repair_lark_mirror registration_id= credential_ref= webhook_base_url=https:// nyx_channel_bot_id= nyx_agent_api_key_id= nyx_conversation_route_id=` +Managing registrations: `list`, `rebuild_projection`, `repair_lark_mirror`, `delete id= confirm=true`. -`repair_lark_mirror` must preserve the existing relay credential reference. Reuse the old `registration_id` when its `vault://.../relay-hmac` secret still exists, or pass `credential_ref` explicitly. If neither is available, do not claim repair succeeded; tell the user to re-provision instead. +### agent_delivery_targets -If rebuild and mirror repair both succeed but `channel_registrations action=list` still stays empty, tell the user the local Aevatar registration projection/read model is unhealthy. +Workflow `human_approval`, `human_input`, `secure_input` steps can send Feishu delivery messages when the workflow step includes `delivery_target_id=`. For the Nyx relay path, these arrive as interactive cards in Lark/Feishu (with `/approve`, `/reject`, `/submit` as fallback commands). -### Lark Stage 3: Advanced Lark capabilities +Bind `agent_id` to the real outbound route: +- `agent_delivery_targets action=list` +- `agent_delivery_targets action=upsert agent_id= conversation_id= nyx_provider_slug= nyx_api_key=` +- `agent_delivery_targets action=delete agent_id= confirm=true` -Only use this stage when the user needs proactive sends, typed Lark tools, delivery target bindings, spreadsheet appends, approval actions, or active chat lookup. +`channel_registrations` configures inbound bot callbacks; `agent_delivery_targets` configures outbound agent delivery. Today the human-interaction delivery path supports `lark`. -Ensure NyxID has a usable Lark outbound provider slug, typically `api-lark-bot`: -`nyxid_services action=list` → check if the service exists -If not: `nyxid_catalog action=list` → find the slug → guide user to add it +### agent_builder (Day One persistent automation lifecycle) -For advanced Lark API operations that are not the current inbound relay reply, prefer typed tools such as: -- `lark_messages_send` -- `lark_messages_search` -- `lark_messages_batch_get` -- `lark_messages_reactions_list` -- `lark_messages_reactions_delete` -- `lark_chats_lookup` -- `lark_sheets_append_rows` -- `lark_approvals_list` -- `lark_approvals_act` +`agent_builder` manages the lifecycle of agents the user has already created. Recipes for *new* agents live as Ornn skills — match the user's intent against `ornn_search_skills` and follow the SKILL.md verbatim. `agent_builder` itself does not create agents. -Only call `lark_messages_reply` or `lark_messages_react` when the user explicitly asks you to reply to or react to a specific Lark message outside the current relay turn. +| Intent | Slash command | +|---|---| +| List agents | `/agents` | +| Inspect one agent | `/agent-status ` | +| Manual run | `/run-agent ` | +| Pause schedule | `/disable-agent ` | +| Resume schedule | `/enable-agent ` | +| Delete (two-step) | `/delete-agent confirm` | -Use generic `nyxid_proxy_execute` only when typed tools do not cover the operation. - -For inbound Lark relay turns that represent a fresh user message, do not call `lark_messages_reply`, `lark_messages_react`, or `nyxid_proxy_execute` to deliver the answer. Produce the final text reply directly; the channel runtime will send it through the Nyx relay reply token. - -When binding workflow delivery or proactive agent delivery, use a Lark outbound provider slug such as `api-lark-bot`. - -### Managing registrations - -- List: `channel_registrations action=list` -- Rebuild local registration projection: `channel_registrations action=rebuild_projection` -- Repair existing Lark mirror: `channel_registrations action=repair_lark_mirror registration_id= credential_ref= webhook_base_url=https:// nyx_channel_bot_id= nyx_agent_api_key_id= nyx_conversation_route_id=` -- Delete: `channel_registrations action=delete id= confirm=true` -- Inspect Nyx-native bot state: `nyxid_channel_bots action=show id=` and `nyxid_channel_bots action=routes channel_bot_id=` - -## Agent Delivery Targets - -Workflow `human_approval`, `human_input`, and `secure_input` steps can send Feishu delivery messages when the workflow step includes `delivery_target_id=`. - -For the Nyx relay path, these arrive as interactive cards in Lark/Feishu: -- `human_approval`: users can approve/reject directly from the card; `/approve ...` and `/reject ...` remain valid fallback commands -- `human_input` / `secure_input`: users can submit directly from the card; `/submit ...` remains a valid fallback command - -Use `agent_delivery_targets` to bind that `agent_id` to the real outbound route: -- List: `agent_delivery_targets action=list` -- Upsert: `agent_delivery_targets action=upsert agent_id= conversation_id= nyx_provider_slug= nyx_api_key=` -- Delete: `agent_delivery_targets action=delete agent_id= confirm=true` - -Notes: -- `channel_registrations` configures inbound bot callbacks -- `agent_delivery_targets` configures outbound agent delivery -- Today the human interaction delivery path supports `lark` - -## Agent Builder - -Use `agent_builder` when the user wants a persistent Day One automation agent in Feishu private chat. - -### User-facing vocabulary (critical) - -When you describe Day One to the user — capability summaries, suggested replies, example commands, help text — use the slash commands below, **not** the internal template names. `daily_report` and `social_media` are tool-argument identifiers; they are not commands the user types. If the user says something like "帮我建一个 daily_report" or "create a daily_report", treat that as intent for `/daily` and present your reply using `/daily`. - -| Intent | Slash command users type | Internal `template` (only for tool calls) | -|---|---|---| -| Daily GitHub summary | `/daily [github_username]` | `daily_report` | -| Social media draft + approval | `/social-media ` | `social_media` | -| List agents | `/agents` | — | -| Inspect one agent | `/agent-status ` | — | -| Manual run | `/run-agent ` | — | -| Pause schedule | `/disable-agent ` | — | -| Resume schedule | `/enable-agent ` | — | -| Delete (two-step) | `/delete-agent confirm` | — | - -`/daily` with no arguments pops an interactive card (GitHub username + schedule fields). `/daily ` saves the username as the user's default and runs the first report immediately — the ack message should say the first run is on its way, not just "scheduled for tomorrow". - -### Tool semantics - -- Creation is private-chat only; if the current chat is not `p2p`, tell the user to DM the bot. -- `create_agent` with `template=daily_report` provisions a `SkillRunnerGAgent` that sends plain-text GitHub summaries back into the current private chat, plus a non-expiring NyxID API key for outbound delivery. -- `create_agent` with `template=social_media` provisions a workflow-backed scheduled agent that generates one draft and routes approval through the current supported human-interaction surface. -- `list_agents` and `agent_status` read the registry-backed current state. -- `run_agent` only works when the agent is enabled. -- `disable_agent` pauses scheduled execution without deleting the agent or revoking its API key. -- `enable_agent` resumes scheduled execution for a previously disabled agent. -- `delete_agent` disables the agent, revokes the NyxID API key, and tombstones the registry entry. -- The Nyx relay path handles the slash commands above directly (and renders the `/daily` and `/social-media` cards) without an LLM round-trip. You typically only see these flows when the user asks for them in natural language instead of typing the slash command. - -## Notifications & Approvals - -If a proxy request requires approval: -1. Tell user approval is pending -2. User approves via Telegram notification, NyxID mobile app, or `nyxid_approvals action=approve id=` - -Setup notifications: `nyxid_notifications action=telegram_link` / Mobile app: https://nyxid.onelink.me/REzJ/dql9w8fx - -## Node Agents - -Nodes keep credentials on user's infrastructure. NyxID routes requests through WebSocket. -- Register: `nyxid_nodes action=register_token name=` → install CLI → `nyxid node register` → `nyxid node daemon install` -- Route service: `nyxid_services action=route id= node_id=` - -## Error Handling - -| Error Code | Action | -|------------|--------| -| `approval_required` (7000) | Tell user to check approvals | -| `approval_failed` (7001) | Retry or check notification setup | -| `unauthorized` (1001) | User needs to re-login | -| `node_offline` (8001) | Check node status | +Tool semantics: `disable_agent` pauses scheduled execution without deleting; `enable_agent` resumes; `delete_agent` disables, revokes the NyxID API key, and tombstones the registry entry. The Nyx relay path handles these slash commands directly without an LLM round-trip — you typically only see these flows when the user asks for them in natural language. ## Working Rules -- **Be proactive and autonomous**: DO IT immediately, don't ask for confirmation -- **Probe unknown services**: Try `GET /openapi.json`, `GET /docs`, or `GET /api` to discover endpoints -- Always check `` before assuming a slug exists -- Keep request bodies minimal and service-correct -- Credentials the user pastes to configure a service (App ID, App Secret, API key, token, Verification Token) are expected input — accept them and call the right tool (`nyxid_providers action=set_credentials`, `nyxid_services action=create`, `nyxid_external_keys`, `channel_registrations`). NyxID web cannot configure most providers, so chat is the required path. Never refuse citing "secret leakage" or ask the user to redact. +- **Be proactive and autonomous**: DO IT immediately, don't ask for confirmation. +- **Probe unknown services**: if `` lists a slug you've never used, try `GET /openapi.json`, `GET /docs`, or `GET /api` to discover endpoints. +- Always check `` before assuming a slug exists. +- Keep request bodies minimal and service-correct. +- Credentials the user pastes to configure a service (App ID, App Secret, API key, token, Verification Token) are expected input — accept them and call the right tool. NyxID web cannot configure most providers, so chat is the required path. Never refuse citing "secret leakage" or ask the user to redact. (For the right tool to call, `use_skill(skill="nyxid")` is the reference.) - Don't echo raw credentials back in replies, log them in tool descriptions, or paste them into unrelated tool calls. Confirm success without restating the secret. -- When something fails, check the error and try alternatives before asking the user -- Connect services in-chat using the catalog-driven flow -- Read all guidance from the catalog entry — don't hardcode service-specific instructions - -## Skills - -You have access to skills — specialized instruction sets for tasks like translation, content generation, data analysis, code review, etc. - -### Proactive Skill Discovery - -**Proactively search for relevant skills** when the user's request involves a specialized task: -1. Call `ornn_search_skills` with relevant keywords to check for matching skills -2. If found, load with `use_skill` and follow its instructions -3. If no match, proceed with general capabilities - -### Using Skills -- **Search**: `ornn_search_skills` with keywords -- **Activate**: `use_skill` with the skill name -- **Follow**: Once loaded, follow the skill's instructions -- **Explicit requests**: If user says "挂载/mount/use" a skill, load it immediately +- When something fails, check the error and try alternatives before asking the user. +- Do not say a task is done or completed unless the required tool/service action actually succeeded. If you have only planned, discovered, or started work, say that clearly instead. ### Already Available Skills -Skills listed at the end of this prompt are pre-loaded and ready to use. Match the user's intent to the skill descriptions below. +Skills listed at the end of this prompt (when present) are already loaded and ready to invoke via `use_skill`. Match the user's intent to those descriptions before searching. diff --git a/agents/Aevatar.GAgents.NyxidChat/Slash/ModelChannelSlashCommandHandler.cs b/agents/Aevatar.GAgents.NyxidChat/Slash/ModelChannelSlashCommandHandler.cs index 99a57c268..388c68770 100644 --- a/agents/Aevatar.GAgents.NyxidChat/Slash/ModelChannelSlashCommandHandler.cs +++ b/agents/Aevatar.GAgents.NyxidChat/Slash/ModelChannelSlashCommandHandler.cs @@ -1,8 +1,11 @@ +using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Abstractions.Slash; +using Aevatar.GAgents.Channel.Identity; using Aevatar.GAgents.Channel.Identity.Abstractions; using Aevatar.GAgents.NyxidChat.LlmSelection; using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.NyxidChat.Slash; @@ -14,19 +17,23 @@ namespace Aevatar.GAgents.NyxidChat.Slash; public sealed class ModelChannelSlashCommandHandler : IChannelSlashCommandHandler { private static readonly char[] WhitespaceSeparators = [' ', '\t', '\r', '\n']; + private const string SelfHealPublisherActorId = "nyxid-chat.model.self-heal"; private readonly IUserLlmOptionsService? _optionsService; private readonly IUserLlmSelectionService? _selectionService; private readonly IUserLlmOptionsRenderer? _renderer; + private readonly IActorDispatchPort _actorDispatchPort; private readonly ILogger _logger; public ModelChannelSlashCommandHandler( ILogger logger, + IActorDispatchPort actorDispatchPort, IUserLlmOptionsService? optionsService = null, IUserLlmSelectionService? selectionService = null, IUserLlmOptionsRenderer? renderer = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); _optionsService = optionsService; _selectionService = selectionService; _renderer = renderer; @@ -76,15 +83,30 @@ await _optionsService.GetOptionsAsync(BuildQuery(context, bindingId), ct).Config } catch (BindingNotFoundException) { - return new MessageContent { Text = "当前 NyxID 绑定不可用,请先发送 /init 重新绑定。" }; + return await SelfHealRevokedBindingAsync( + context, + reason: "auto_self_heal_remote_not_found", + submittedMessage: "NyxID 端 binding 已不可用,本地清理已提交。请稍后发送 /init 完成新绑定。", + degradedMessage: "NyxID 端 binding 已不可用,本地清理提交失败。请稍后重试 /models,或发送 /unbind 后再发送 /init 重新绑定。", + ct).ConfigureAwait(false); } catch (BindingRevokedException) { - return new MessageContent { Text = "当前 NyxID 绑定已失效,请先发送 /init 重新绑定。" }; + return await SelfHealRevokedBindingAsync( + context, + reason: "auto_self_heal_remote_revoked", + submittedMessage: "NyxID 端 binding 已失效,本地清理已提交。请稍后发送 /init 完成新绑定。", + degradedMessage: "NyxID 端 binding 已失效,本地清理提交失败。请稍后重试 /models,或发送 /unbind 后再发送 /init 重新绑定。", + ct).ConfigureAwait(false); } catch (BindingScopeMismatchException) { - return new MessageContent { Text = "当前 NyxID 绑定缺少 LLM route 权限,请先发送 /init 重新绑定。" }; + return await SelfHealRevokedBindingAsync( + context, + reason: "auto_self_heal_scope_mismatch", + submittedMessage: "当前 NyxID 绑定缺少 LLM route 权限,本地清理已提交。请稍后发送 /init 完成新绑定。", + degradedMessage: "当前 NyxID 绑定缺少 LLM route 权限,本地清理提交失败。请稍后重试 /models,或发送 /unbind 后再发送 /init 重新绑定。", + ct).ConfigureAwait(false); } catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or HttpRequestException or NotSupportedException) { @@ -93,6 +115,83 @@ await _optionsService.GetOptionsAsync(BuildQuery(context, bindingId), ct).Config } } + /// + /// Submits a local binding revoke when NyxID reports the binding is gone + /// (revoked / not_found / scope-mismatch). This is intentionally dispatch + /// only: the slash request path must not activate projection scopes or + /// wait for read-model materialization. The user is told cleanup has been + /// submitted and can retry /init after the projection catches up. + /// + /// + /// Differs from explicit /unbind because the NyxID-side revoke is + /// already known; we only need to flip the local actor, with one retry for + /// transient dispatch failure. + /// + private async Task SelfHealRevokedBindingAsync( + ChannelSlashCommandContext context, + string reason, + string submittedMessage, + string degradedMessage, + CancellationToken ct) + { + var submitted = await TryDispatchLocalBindingRevokeAsync(context, reason, ct).ConfigureAwait(false); + return new MessageContent { Text = submitted ? submittedMessage : degradedMessage }; + } + + private async Task TryDispatchLocalBindingRevokeAsync( + ChannelSlashCommandContext context, + string reason, + CancellationToken ct) + { + var actorId = context.Subject.ToActorId(); + + // Single retry mirrors /unbind: a one-off dispatch hiccup should not + // leave the user permanently stuck with a stale local binding. + Exception? lastError = null; + for (var attempt = 1; attempt <= 2; attempt++) + { + try + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(new RevokeBindingCommand + { + ExternalSubject = context.Subject.Clone(), + Reason = reason, + }), + Route = EnvelopeRouteSemantics.CreateDirect(SelfHealPublisherActorId, actorId), + }; + await _actorDispatchPort + .DispatchAsync(actorId, envelope, ct) + .ConfigureAwait(false); + _logger.LogWarning( + "/model submitted local binding self-heal actor={ActorId} after NyxID-side rejection: reason={Reason}, attempt={Attempt}/2, subject={Platform}:{Tenant}:{User}", + actorId, + reason, + attempt, + context.Subject.Platform, context.Subject.Tenant, context.Subject.ExternalUserId); + return true; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + lastError = ex; + _logger.LogWarning(ex, + "/model: local binding self-heal dispatch failed on attempt {Attempt}/2 for actor={ActorId}, reason={Reason}", + attempt, + actorId, + reason); + } + } + + _logger.LogError(lastError, + "/model failed to self-heal local binding actor={ActorId} after 2 attempts; reason={Reason}. User has been told to /unbind manually.", + actorId, + reason); + return false; + } + private async Task HandleUseAsync( ChannelSlashCommandContext context, string bindingId, @@ -310,16 +409,32 @@ private static bool TryResolveNumberedOption( .Where(option => option.ServiceSlug.Contains(requested, StringComparison.OrdinalIgnoreCase) || option.DisplayName.Contains(requested, StringComparison.OrdinalIgnoreCase)) - .Take(2) .ToArray(); + var selectable = fuzzy.Where(IsSelectable).Take(2).ToArray(); + if (selectable.Length == 1) + return selectable[0]; + return fuzzy.Length == 1 ? fuzzy[0] : null; } - private static UserLlmOption? FindExactOption(string requested, IReadOnlyList available) => - available.FirstOrDefault(option => - string.Equals(option.ServiceId, requested, StringComparison.OrdinalIgnoreCase) || - string.Equals(option.ServiceSlug, requested, StringComparison.OrdinalIgnoreCase) || - string.Equals(option.DisplayName, requested, StringComparison.OrdinalIgnoreCase)); + private static UserLlmOption? FindExactOption(string requested, IReadOnlyList available) + { + var matches = available + .Where(option => + string.Equals(option.ServiceId, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.ServiceSlug, requested, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.DisplayName, requested, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + var selectable = matches.Where(IsSelectable).Take(2).ToArray(); + if (selectable.Length == 1) + return selectable[0]; + + return matches.FirstOrDefault(); + } + + private static bool IsSelectable(UserLlmOption option) => + option.Allowed && string.Equals(option.Status, "ready", StringComparison.OrdinalIgnoreCase); private static bool TryResolveExactOptionPrefix( string requested, diff --git a/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto b/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto new file mode 100644 index 000000000..2b9af65e5 --- /dev/null +++ b/agents/Aevatar.GAgents.NyxidChat/protos/agent_run.proto @@ -0,0 +1,72 @@ +syntax = "proto3"; + +package aevatar.gagents.nyxid_chat; + +option csharp_namespace = "Aevatar.GAgents.NyxidChat"; + +import "conversation_events.proto"; + +enum AgentRunStatus { + AGENT_RUN_STATUS_UNSPECIFIED = 0; + AGENT_RUN_STATUS_STARTED = 1; + AGENT_RUN_STATUS_REPLY_PRODUCED = 2; + AGENT_RUN_STATUS_DROPPED = 3; + AGENT_RUN_STATUS_FAILED = 4; +} + +message AgentRunGAgentState { + string run_id = 1; + string correlation_id = 2; + string target_actor_id = 3; + AgentRunStatus status = 4; + int64 started_at_unix_ms = 5; + int64 completed_at_unix_ms = 6; + string error_code = 7; + string error_summary = 8; +} + +// Transient command for the run actor. The nested NeedsLlmReplyEvent may carry +// a short-lived relay reply_token; AgentRunGAgent must never persist that +// credential into AgentRunGAgentState or any AgentRun*Event. +message AgentRunStartRequested { + aevatar.gagents.channel.runtime.NeedsLlmReplyEvent request = 1; +} + +message AgentRunCleanupRequested { + string run_id = 1; + int64 requested_at_unix_ms = 2; +} + +message AgentRunStartedEvent { + string run_id = 1; + string correlation_id = 2; + string target_actor_id = 3; + int64 started_at_unix_ms = 4; +} + +message AgentRunReplyProducedEvent { + string run_id = 1; + string correlation_id = 2; + string target_actor_id = 3; + aevatar.gagents.channel.runtime.LlmReplyTerminalState terminal_state = 4; + string error_code = 5; + string error_summary = 6; + int64 produced_at_unix_ms = 7; +} + +message AgentRunDroppedEvent { + string run_id = 1; + string correlation_id = 2; + string target_actor_id = 3; + string reason = 4; + int64 dropped_at_unix_ms = 5; +} + +message AgentRunFailedEvent { + string run_id = 1; + string correlation_id = 2; + string target_actor_id = 3; + string error_code = 4; + string error_summary = 5; + int64 failed_at_unix_ms = 6; +} diff --git a/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs index 9d775237d..4640ffbb3 100644 --- a/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.Scheduled/DependencyInjection/ScheduledServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions.Maintenance; using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Scheduled.WorkflowModules; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -71,7 +70,6 @@ public static IServiceCollection AddScheduledAgents( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); // Caller-scope resolver chain (issue #466 §B). Channel resolver runs first so // a request with channel metadata produces the per-sender scope rather than // the looser nyxid-scoped tuple from the underlying NyxID session. @@ -108,12 +106,6 @@ public static IServiceCollection AddScheduledAgents( static doc => doc.Id, static key => key); } - // Register the scheduled-agent workflow module pack so the social_media template's - // `twitter_publish` step type resolves at workflow run time (issue #216). - // AddWorkflowModulePack uses TryAddEnumerable, so calling alongside AddAevatarWorkflow - // is idempotent. - services.AddScheduledWorkflowExtensions(); - return services; } diff --git a/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs deleted file mode 100644 index a94e97bcf..000000000 --- a/agents/Aevatar.GAgents.Scheduled/IWorkflowAgentCommandPort.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Aevatar.GAgents.Scheduled; - -/// -/// Application-service surface for WorkflowAgent lifecycle. Mirrors -/// : owns actor lifecycle, catalog -/// projection priming, and envelope dispatch through -/// so LLM -/// tools and admin endpoints stop reaching for actor.HandleEventAsync. -/// -public interface IWorkflowAgentCommandPort -{ - Task InitializeAsync( - string agentId, - InitializeWorkflowAgentCommand command, - bool runImmediately, - CancellationToken ct = default); - - Task TriggerAsync( - string agentId, - string reason, - string? revisionFeedback, - CancellationToken ct = default); - - Task DisableAsync(string agentId, string reason, CancellationToken ct = default); - - Task EnableAsync(string agentId, string reason, CancellationToken ct = default); -} diff --git a/agents/Aevatar.GAgents.Scheduled/NyxIdProxyToolFailureCountingMiddleware.cs b/agents/Aevatar.GAgents.Scheduled/NyxIdProxyToolFailureCountingMiddleware.cs index 9927b06f7..9a863118d 100644 --- a/agents/Aevatar.GAgents.Scheduled/NyxIdProxyToolFailureCountingMiddleware.cs +++ b/agents/Aevatar.GAgents.Scheduled/NyxIdProxyToolFailureCountingMiddleware.cs @@ -17,7 +17,7 @@ namespace Aevatar.GAgents.Scheduled; /// /// Only counts nyxid_proxy calls — other tools may have their own success /// semantics (e.g., a search tool that returns 0 hits is not a failure), and the safety -/// net is scoped to the proxy fan-out that powers the daily-report skill. +/// net is scoped to the proxy fan-out that powers fetch-and-summarize skills. /// internal sealed class NyxIdProxyToolFailureCountingMiddleware : IToolCallMiddleware { diff --git a/agents/Aevatar.GAgents.Scheduled/ScheduledRetiredActorSpec.cs b/agents/Aevatar.GAgents.Scheduled/ScheduledRetiredActorSpec.cs index e6ff6d67c..7c70961d6 100644 --- a/agents/Aevatar.GAgents.Scheduled/ScheduledRetiredActorSpec.cs +++ b/agents/Aevatar.GAgents.Scheduled/ScheduledRetiredActorSpec.cs @@ -31,7 +31,14 @@ namespace Aevatar.GAgents.Scheduled; public sealed class ScheduledRetiredActorSpec : RetiredActorSpec { private const string RetiredSkillRunnerType = "Aevatar.GAgents.ChannelRuntime.SkillRunnerGAgent"; + // Retained as a string literal so legacy clusters still clean up workflow_agent + // event streams persisted before the social_media template was removed (issue #598). private const string RetiredWorkflowAgentType = "Aevatar.GAgents.ChannelRuntime.WorkflowAgentGAgent"; + // Mirror of the deleted WorkflowAgentDefaults — kept here so retired-actor discovery + // can still recognize legacy workflow_agent rows persisted in the catalog read model + // and drive their cleanup. New agents never carry these tokens. + private const string LegacyWorkflowAgentType = "workflow_agent"; + private const string LegacyWorkflowAgentActorIdPrefix = "workflow-agent"; private const int ReadModelPageSize = 500; public override string SpecId => "scheduled"; @@ -259,12 +266,12 @@ private static bool IsGeneratedUserAgent(string? agentId, string? agentType) return false; if (string.Equals(agentType, SkillRunnerDefaults.AgentType, StringComparison.Ordinal) || - string.Equals(agentType, WorkflowAgentDefaults.AgentType, StringComparison.Ordinal)) + string.Equals(agentType, LegacyWorkflowAgentType, StringComparison.Ordinal)) { return true; } return normalizedId.StartsWith($"{SkillRunnerDefaults.ActorIdPrefix}-", StringComparison.Ordinal) || - normalizedId.StartsWith($"{WorkflowAgentDefaults.ActorIdPrefix}-", StringComparison.Ordinal); + normalizedId.StartsWith($"{LegacyWorkflowAgentActorIdPrefix}-", StringComparison.Ordinal); } } diff --git a/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs b/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs index 6ed1e9dc4..c56a7a250 100644 --- a/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs +++ b/agents/Aevatar.GAgents.Scheduled/SkillRunnerGAgent.cs @@ -164,6 +164,7 @@ public async Task HandleInitializeAsync(InitializeSkillRunnerCommand command) ScopeId = command.ScopeId?.Trim() ?? string.Empty, ProviderName = NormalizeProviderName(command.ProviderName), Model = command.Model?.Trim() ?? string.Empty, + RequiresNyxidProxySuccess = command.RequiresNyxidProxySuccess, }; if (command.HasTemperature) @@ -316,7 +317,7 @@ private async Task ExecuteSkillAsync(DateTimeOffset now, string? reason, content.Append(chunk.DeltaContent); if (sink is not null) // Per-delta `content.ToString()` is O(n) per call → O(n²) for the whole - // turn. Acceptable for daily-report-sized output (≤30 KB capped, and the + // turn. Acceptable for daily-sized output (≤30 KB capped, and the // sink dedupes against `_lastEmittedText` so most allocations don't even // make it onto the wire). If a future skill produces materially longer // output, switch the sink contract to `(StringBuilder, Range)` snapshots @@ -329,13 +330,25 @@ private async Task ExecuteSkillAsync(DateTimeOffset now, string? reason, if (string.IsNullOrWhiteSpace(output)) output = "No update generated."; - // Issue #439 safety net (PR #471): if EVERY nyxid_proxy tool call in this run - // failed, the LLM's plain-text output is structurally indistinguishable from a - // real "no activity" report. Throw before delivery so HandleTriggerAsync's catch - // path persists `SkillRunnerExecutionFailedEvent` instead of recording a fake - // success — must fire BEFORE chunked dispatch so we don't post part-1 of a - // report that we're about to flag as failed. - EnsureToolStatusAllowsCompletion(_toolFailureCounter.FailureCount, _toolFailureCounter.SuccessCount); + // Issue #439 safety net (PR #471 + this PR): refuse to record fake-success runs. + // Two failure modes are caught here: + // * all-fail — every nyxid_proxy call failed, the LLM's plain-text output is + // structurally indistinguishable from a real "no activity" report; + // * never-called — when State.RequiresNyxidProxySuccess is set, a run that + // completes with zero successful nyxid_proxy calls means the LLM bypassed + // tools entirely and produced text from prior context (the original #439 + // symptom: 52 commits in 24h reported as "No meaningful public GitHub + // activity"). The original safety net only covered the all-fail case + // (failureCount > 0); this gap was flagged in PR #471 review and is closed + // here for fetch-and-summarize templates that opt in. + // Throw before delivery so HandleTriggerAsync's catch path persists + // SkillRunnerExecutionFailedEvent instead of a clean SkillRunnerExecutionCompletedEvent — + // must fire BEFORE chunked dispatch so we don't post part-1 of a report + // we're about to flag as failed. + EnsureToolStatusAllowsCompletion( + _toolFailureCounter.FailureCount, + _toolFailureCounter.SuccessCount, + State.RequiresNyxidProxySuccess); // Issue #423 §C — chunked delivery for outputs that exceed the Lark body cap. // For ≤30 KB outputs the chunker returns a single-element list and the dispatch @@ -401,6 +414,18 @@ private async Task DispatchOutputChunksAsync( /// private SkillRunnerStreamingReplySink? TryCreateStreamingSink() { + // Issue #439 (PR #569 review, codex P1 on EnsureToolStatusAllowsCompletion): when the run + // is gated by EnsureToolStatusAllowsCompletion (RequiresNyxidProxySuccess set), + // streaming each delta would POST/PUT the partial text to Lark live — i.e. a + // hallucinated daily report would already be visible in the user's DM by the + // time the guard fires, and each retry would repost it. Disable live streaming + // for those skills so the message only POSTs through the chunked-dispatch path + // AFTER the guard has confirmed at least one nyxid_proxy success. Trade-off: the + // user no longer sees the report grow live, but output integrity wins over the + // streaming-edit UX for fetch-and-summarize skills. + if (State.RequiresNyxidProxySuccess) + return null; + var client = _nyxIdApiClient ?? Services.GetService(); if (client is null) { @@ -448,19 +473,35 @@ private async Task DispatchOutputChunksAsync( } /// - /// Runner-layer safety net for issue #439: when every nyxid_proxy call in a run failed, - /// the LLM's plain-text output is structurally indistinguishable from a real "no - /// activity" report — the prompt-layer §9 Source health footer can be silently dropped - /// by a weaker model, and the runner has no other way to tell. Throwing here routes - /// through HandleTriggerAsync's existing catch path, which preserves the retry budget - /// and (after retries are exhausted) persists SkillRunnerExecutionFailedEvent so - /// /agent-status reports a non-zero error_count with a meaningful - /// last_error instead of a fake-success run. - /// Mixed runs (any successful nyxid_proxy call) still complete normally — partial data - /// is more useful to the user than a blanket failure, and the prompt-layer Source - /// health footer surfaces the failed queries. + /// Runner-layer safety net for issue #439. Two fake-success modes are caught here: + /// + /// + /// all-fail ( > 0, == 0): + /// every nyxid_proxy call failed, but the LLM's plain-text output is structurally + /// indistinguishable from a real "no activity" report. The prompt-layer §9 Source + /// health footer can be dropped by a weaker model, and the runner has no other way + /// to tell. + /// + /// + /// never-called ( == true, + /// == 0): the LLM bypassed tools entirely and produced + /// text from prior context. For fetch-and-summarize skills like daily this is + /// exactly the original #439 symptom (52 commits in 24h reported as "No meaningful + /// public GitHub activity"). Skills that don't depend on tool data (e.g. pure LLM + /// transformations) leave the flag false and pass through. + /// + /// + /// Throwing here routes through HandleTriggerAsync's existing catch path, which preserves + /// the retry budget and (after retries are exhausted) persists SkillRunnerExecutionFailedEvent + /// so /agent-status reports a non-zero error_count with a meaningful + /// last_error instead of a fake-success run. Mixed runs (any successful nyxid_proxy + /// call) still complete normally — partial data is more useful than a blanket failure, and + /// the prompt-layer Source health footer surfaces the failed queries. /// - internal static void EnsureToolStatusAllowsCompletion(int failureCount, int successCount) + internal static void EnsureToolStatusAllowsCompletion( + int failureCount, + int successCount, + bool requiresNyxidProxySuccess) { if (failureCount > 0 && successCount == 0) { @@ -468,6 +509,14 @@ internal static void EnsureToolStatusAllowsCompletion(int failureCount, int succ $"All {failureCount} nyxid_proxy tool call(s) in this run failed; refusing to record an empty-day report as a successful execution. " + "Inspect the previous attempt's tool output for the underlying NyxID/upstream error envelope."); } + + if (requiresNyxidProxySuccess && successCount == 0) + { + throw new InvalidOperationException( + "Skill requires at least one successful nyxid_proxy tool call but completed with zero. " + + "The LLM produced output without fetching source data (e.g. hallucinated a daily report from prior context). " + + "Refusing to record this run as a successful execution."); + } } private Task SendOutputAsync(string output, CancellationToken ct) => @@ -615,7 +664,7 @@ private static string BuildLarkRejectionMessage(int? larkCode, string detail) return $"Lark message delivery rejected (code={larkCode}): {detail}. " + "This agent was created before cross-app union_id ingress existed; " + - "delete and recreate it (`/agents` → Delete → `/daily`) to pick up the cross-app safe target."; + "delete and recreate it (`/agents` → Delete → recreate) to pick up the cross-app safe target."; } if (larkCode == LarkBotErrorCodes.UserIdCrossTenant) @@ -628,7 +677,7 @@ private static string BuildLarkRejectionMessage(int? larkCode, string detail) $"Lark message delivery rejected (code={larkCode}): {detail}. " + "The outbound Lark app is in a different tenant than the inbound app, so " + "user-id translation is impossible. Delete and recreate the agent " + - "(`/agents` → Delete → `/daily`) so the new chat_id-preferred outbound path " + + "(`/agents` → Delete → recreate) so the new chat_id-preferred outbound path " + "takes effect, or align the NyxID `s/api-lark-bot` proxy with the channel-bot that " + "received the inbound event."; } @@ -702,7 +751,7 @@ private async Task> BuildExecutionMetadataAs metadata["scope_id"] = State.ScopeId; // Pin the bot owner's pre-configured model + NyxID route + tool-round cap onto the - // outbound LLM metadata, the same pattern ChannelLlmReplyInboxRuntime applies for + // outbound LLM metadata, the same pattern AgentRunGAgent applies for // nyxid-chat. Without this, scheduled runs fall through to NyxIdLLMProvider's // compile-time defaults (`gpt-5.4` against `/api/v1/llm/gateway/v1/`), which the // gateway routes to the OpenAI provider — failing for bot owners who pre-configured @@ -794,6 +843,15 @@ private static SkillRunnerState ApplyInitialized(SkillRunnerState current, Skill next.ScopeId = evt.ScopeId ?? string.Empty; next.ProviderName = NormalizeProviderName(evt.ProviderName); next.Model = evt.Model ?? string.Empty; + // Legacy actors created before proto field 16 existed replay an init event whose + // RequiresNyxidProxySuccess deserializes as false, which would let them keep the + // pre-#439 zero-tool-call fake-success path — making post-fix behavior depend on + // creation time rather than template semantics. Derive the effective flag from + // the template name so known fetch-and-summarize skills get the safety net on + // replay regardless of when the actor was created. New templates that need this + // protection should be added to RequiresProxySuccessByTemplate. + next.RequiresNyxidProxySuccess = evt.RequiresNyxidProxySuccess + || RequiresProxySuccessByTemplate(evt.TemplateName); // Missing sampling fields intentionally use upstream model defaults; // missing runner limits fall back to SkillRunner defaults. @@ -852,6 +910,21 @@ private static SkillRunnerState ApplyEnabled(SkillRunnerState current, SkillRunn return next; } + /// + /// Templates whose runs MUST observe at least one successful nyxid_proxy call to be + /// considered successful. Used by as the legacy-actor + /// default when the persisted init event predates proto field 16. Add new templates + /// here when they're fetch-and-summarize style (the LLM bypassing tools and producing + /// text from prior context is a fake-success failure mode for them). + /// + internal static bool RequiresProxySuccessByTemplate(string? templateName) => + // Reserved for future fetch-and-summarize templates that need the runner-layer + // safety net (issue #439). Currently empty: the in-tree daily template was + // removed in favor of the Ornn-hosted skill, and no other template needs the + // legacy proto-field-16-default backfill. Keep the method so tests + the apply + // path don't need to special-case "no templates" — just add new entries here. + templateName is not null && false; + private static string NormalizeProviderName(string? providerName) => string.IsNullOrWhiteSpace(providerName) ? SkillRunnerDefaults.DefaultProviderName : providerName.Trim(); diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs deleted file mode 100644 index 422f2bf21..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentCommandPort.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; - -namespace Aevatar.GAgents.Scheduled; - -internal sealed class WorkflowAgentCommandPort : IWorkflowAgentCommandPort -{ - private const string PublisherActorId = "scheduled.workflow-agent"; - - private readonly IActorRuntime _actorRuntime; - private readonly IActorDispatchPort _actorDispatchPort; - private readonly UserAgentCatalogProjectionPort _catalogProjectionPort; - - public WorkflowAgentCommandPort( - IActorRuntime actorRuntime, - IActorDispatchPort actorDispatchPort, - UserAgentCatalogProjectionPort catalogProjectionPort) - { - _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); - _actorDispatchPort = actorDispatchPort ?? throw new ArgumentNullException(nameof(actorDispatchPort)); - _catalogProjectionPort = catalogProjectionPort ?? throw new ArgumentNullException(nameof(catalogProjectionPort)); - } - - public async Task InitializeAsync( - string agentId, - InitializeWorkflowAgentCommand command, - bool runImmediately, - CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(agentId); - ArgumentNullException.ThrowIfNull(command); - - await EnsureWorkflowAgentActorAsync(agentId, ct); - await _catalogProjectionPort.EnsureProjectionForActorAsync(UserAgentCatalogGAgent.WellKnownId, ct); - - await DispatchAsync(agentId, command, ct); - - if (runImmediately) - { - await DispatchAsync( - agentId, - new TriggerWorkflowAgentExecutionCommand { Reason = "create_agent" }, - ct); - } - } - - public async Task TriggerAsync( - string agentId, - string reason, - string? revisionFeedback, - CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(agentId); - await EnsureWorkflowAgentActorAsync(agentId, ct); - await DispatchAsync( - agentId, - new TriggerWorkflowAgentExecutionCommand - { - Reason = reason ?? string.Empty, - RevisionFeedback = revisionFeedback ?? string.Empty, - }, - ct); - } - - public async Task DisableAsync(string agentId, string reason, CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(agentId); - await EnsureWorkflowAgentActorAsync(agentId, ct); - await DispatchAsync(agentId, new DisableWorkflowAgentCommand { Reason = reason ?? string.Empty }, ct); - } - - public async Task EnableAsync(string agentId, string reason, CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(agentId); - await EnsureWorkflowAgentActorAsync(agentId, ct); - await DispatchAsync(agentId, new EnableWorkflowAgentCommand { Reason = reason ?? string.Empty }, ct); - } - - private async Task EnsureWorkflowAgentActorAsync(string agentId, CancellationToken ct) - { - _ = await _actorRuntime.GetAsync(agentId) - ?? await _actorRuntime.CreateAsync(agentId, ct); - } - - private Task DispatchAsync(string agentId, TCommand command, CancellationToken ct) - where TCommand : class, IMessage - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = EnvelopeRouteSemantics.CreateDirect(PublisherActorId, agentId), - }; - return _actorDispatchPort.DispatchAsync(agentId, envelope, ct); - } -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs deleted file mode 100644 index 715ab830f..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentDefaults.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Aevatar.GAgents.Scheduled; - -public static class WorkflowAgentDefaults -{ - public const string AgentType = "workflow_agent"; - public const string ActorIdPrefix = "workflow-agent"; - public const string TemplateName = "social_media"; - public const string ProviderName = "nyxid"; - public const string DefaultPlatform = "lark"; - public const string DefaultTimezone = "UTC"; - public const string StatusRunning = "running"; - public const string StatusError = "error"; - public const string StatusDisabled = "disabled"; - public const string TriggerCallbackId = "workflow-agent-next-fire"; - - public static string GenerateActorId() => $"{ActorIdPrefix}-{Guid.NewGuid():N}"; -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs deleted file mode 100644 index 9c477dbe7..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentGAgent.cs +++ /dev/null @@ -1,399 +0,0 @@ -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.AI.Core.LLMProviders; -using Aevatar.CQRS.Core.Abstractions.Commands; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.Workflow.Application.Abstractions.Runs; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Aevatar.GAgents.Scheduled; - -public sealed class WorkflowAgentGAgent : GAgentBase -{ - private readonly IOwnerLlmConfigSource? _ownerLlmConfigSource; - private ChannelScheduleRunner? _scheduler; - - public WorkflowAgentGAgent(IOwnerLlmConfigSource? ownerLlmConfigSource = null) - { - _ownerLlmConfigSource = ownerLlmConfigSource; - } - - private ChannelScheduleRunner Scheduler => _scheduler ??= new ChannelScheduleRunner( - callbackId: WorkflowAgentDefaults.TriggerCallbackId, - schedulableSource: () => State, - triggerFactory: () => new TriggerWorkflowAgentExecutionCommand { Reason = "schedule" }, - persistNextRunEventAsync: nextRunUtc => PersistDomainEventAsync(new WorkflowAgentNextRunScheduledEvent - { - NextRunAt = Timestamp.FromDateTimeOffset(nextRunUtc), - }), - scheduleTimeoutAsync: (id, dueTime, evt, ct) => ScheduleSelfDurableTimeoutAsync(id, dueTime, evt, ct: ct), - cancelCallbackAsync: (lease, ct) => CancelDurableCallbackAsync(lease, ct), - logger: Logger, - ownerDescription: $"Workflow agent {Id}"); - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await Scheduler.BootstrapOnActivateAsync(ct); - } - - protected override WorkflowAgentState TransitionState(WorkflowAgentState current, IMessage evt) => - StateTransitionMatcher - .Match(current, evt) - .On(ApplyInitialized) - .On(ApplyNextRunScheduled) - .On(ApplyDispatched) - .On(ApplyFailed) - .On(ApplyDisabled) - .On(ApplyEnabled) - .OrCurrent(); - - [EventHandler] - public async Task HandleInitializeAsync(InitializeWorkflowAgentCommand command) - { - if (string.IsNullOrWhiteSpace(command.WorkflowActorId)) - { - Logger.LogWarning("Workflow agent {ActorId} initialization ignored because workflow_actor_id is empty", Id); - return; - } - -#pragma warning disable CS0612 // legacy fields populated for rollback compat during owner_scope migration - var initializedEvent = new WorkflowAgentInitializedEvent - { - WorkflowId = command.WorkflowId?.Trim() ?? string.Empty, - WorkflowName = command.WorkflowName?.Trim() ?? string.Empty, - WorkflowActorId = command.WorkflowActorId?.Trim() ?? string.Empty, - ExecutionPrompt = command.ExecutionPrompt?.Trim() ?? string.Empty, - ScheduleCron = command.ScheduleCron?.Trim() ?? string.Empty, - ScheduleTimezone = NormalizeTimezone(command.ScheduleTimezone), - ConversationId = command.ConversationId?.Trim() ?? string.Empty, - NyxProviderSlug = command.NyxProviderSlug?.Trim() ?? string.Empty, - NyxApiKey = command.NyxApiKey?.Trim() ?? string.Empty, - OwnerNyxUserId = command.OwnerNyxUserId?.Trim() ?? string.Empty, - ApiKeyId = command.ApiKeyId?.Trim() ?? string.Empty, - Enabled = command.Enabled, - ScopeId = command.ScopeId?.Trim() ?? string.Empty, - Platform = command.Platform?.Trim() ?? string.Empty, - LarkReceiveId = command.LarkReceiveId?.Trim() ?? string.Empty, - LarkReceiveIdType = command.LarkReceiveIdType?.Trim() ?? string.Empty, - LarkReceiveIdFallback = command.LarkReceiveIdFallback?.Trim() ?? string.Empty, - LarkReceiveIdTypeFallback = command.LarkReceiveIdTypeFallback?.Trim() ?? string.Empty, - }; -#pragma warning restore CS0612 - - if (command.OwnerScope is not null) - initializedEvent.OwnerScope = command.OwnerScope.Clone(); - - await PersistDomainEventAsync(initializedEvent); - - await Scheduler.ScheduleNextRunAsync(DateTimeOffset.UtcNow, CancellationToken.None); - await UpsertRegistryAsync(State.Enabled ? WorkflowAgentDefaults.StatusRunning : WorkflowAgentDefaults.StatusDisabled, CancellationToken.None); - } - - [EventHandler(AllowSelfHandling = true)] - public async Task HandleTriggerAsync(TriggerWorkflowAgentExecutionCommand command) - { - if (!State.Enabled) - { - Logger.LogInformation("Workflow agent {ActorId} ignored trigger because it is disabled", Id); - return; - } - - var now = DateTimeOffset.UtcNow; - try - { - var receipt = await DispatchWorkflowRunAsync(command.Reason, command.RevisionFeedback, CancellationToken.None); - await PersistDomainEventAsync(new WorkflowAgentExecutionDispatchedEvent - { - DispatchedAt = Timestamp.FromDateTimeOffset(now), - WorkflowRunActorId = receipt.ActorId, - CommandId = receipt.CommandId, - }); - - await Scheduler.ScheduleNextRunAsync(now, CancellationToken.None); - await UpdateRegistryExecutionAsync( - WorkflowAgentDefaults.StatusRunning, State.LastRunAt, State.NextRunAt, - 0, string.Empty, CancellationToken.None); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Workflow agent {ActorId} execution dispatch failed", Id); - await PersistDomainEventAsync(new WorkflowAgentExecutionFailedEvent - { - FailedAt = Timestamp.FromDateTimeOffset(now), - Error = ex.Message, - }); - - await Scheduler.ScheduleNextRunAsync(now, CancellationToken.None); - await UpdateRegistryExecutionAsync( - WorkflowAgentDefaults.StatusError, State.LastRunAt, State.NextRunAt, - State.ErrorCount, State.LastError, CancellationToken.None); - } - } - - [EventHandler] - public async Task HandleDisableAsync(DisableWorkflowAgentCommand command) - { - await Scheduler.CancelAsync(CancellationToken.None); - - await PersistDomainEventAsync(new WorkflowAgentDisabledEvent - { - Reason = command.Reason?.Trim() ?? string.Empty, - }); - - await UpdateRegistryExecutionAsync( - WorkflowAgentDefaults.StatusDisabled, State.LastRunAt, null, - State.ErrorCount, State.LastError, CancellationToken.None); - } - - [EventHandler] - public async Task HandleEnableAsync(EnableWorkflowAgentCommand command) - { - if (!State.Enabled) - { - await PersistDomainEventAsync(new WorkflowAgentEnabledEvent - { - Reason = command.Reason?.Trim() ?? string.Empty, - }); - } - - await Scheduler.ScheduleNextRunAsync(DateTimeOffset.UtcNow, CancellationToken.None); - await UpdateRegistryExecutionAsync( - WorkflowAgentDefaults.StatusRunning, State.LastRunAt, State.NextRunAt, - State.ErrorCount, State.LastError, CancellationToken.None); - } - - private async Task DispatchWorkflowRunAsync( - string? reason, string? revisionFeedback, CancellationToken ct) - { - var dispatchService = Services.GetService>(); - if (dispatchService is null) - throw new InvalidOperationException("Workflow run dispatch service is not registered."); - - var request = new WorkflowChatRunRequest( - Prompt: BuildExecutionPrompt(reason, revisionFeedback), - WorkflowName: State.WorkflowName, - ActorId: State.WorkflowActorId, - SessionId: null, - InputParts: null, - WorkflowYamls: null, - Metadata: await BuildExecutionMetadataAsync(ct), - ScopeId: State.ScopeId); - - var dispatch = await dispatchService.DispatchAsync(request, ct); - if (!dispatch.Succeeded || dispatch.Receipt is null) - throw new InvalidOperationException(MapDispatchError(dispatch.Error)); - - return dispatch.Receipt; - } - - private async Task> BuildExecutionMetadataAsync(CancellationToken ct) - { - var metadata = new Dictionary(StringComparer.Ordinal) - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = State.NyxApiKey ?? string.Empty, - [ChannelMetadataKeys.ConversationId] = State.ConversationId ?? string.Empty, - }; - if (!string.IsNullOrWhiteSpace(State.ScopeId)) - metadata["scope_id"] = State.ScopeId; - // Propagate the outbound Lark delivery target so workflow modules that need to surface - // their own status messages back into the originating chat (e.g. TwitterPublishModule - // posting "已发布: " or "Twitter OAuth 过期…") can do so via the same api-lark-bot - // proxy this agent already uses, without re-resolving the catalog at run time. - if (!string.IsNullOrWhiteSpace(State.LarkReceiveId)) - metadata[ChannelMetadataKeys.LarkReceiveId] = State.LarkReceiveId; - if (!string.IsNullOrWhiteSpace(State.LarkReceiveIdType)) - metadata[ChannelMetadataKeys.LarkReceiveIdType] = State.LarkReceiveIdType; - if (!string.IsNullOrWhiteSpace(State.NyxProviderSlug)) - metadata[ChannelMetadataKeys.LarkOutboundProxySlug] = State.NyxProviderSlug; - - // Mirror SkillRunnerGAgent.BuildExecutionMetadataAsync — same shared helper, same - // model/route/tool-cap pinning. Workflow-backed agents (e.g. social_media) need the - // same UserConfig discipline so their LLM steps don't fall through to gateway+gpt-5.4 - // when the bot owner pre-configured a custom NyxID service like `chrono-llm`. The - // source is bound once via constructor injection at agent activation time; the - // per-execution Services.GetService<> fallback was dropped per codex's PR #509 - // partial dissent on r3159047120. - await OwnerLlmConfigApplier.ApplyAsync( - metadata, - State.ScopeId, - _ownerLlmConfigSource, - Logger, - actorLabel: "Workflow agent", - actorId: Id, - ct); - return metadata; - } - - private string BuildExecutionPrompt(string? reason, string? revisionFeedback) - { - var prompt = string.IsNullOrWhiteSpace(State.ExecutionPrompt) - ? "Run the configured workflow now." - : State.ExecutionPrompt; - - var lines = new List - { - prompt, - $"Trigger reason: {(string.IsNullOrWhiteSpace(reason) ? "manual" : reason)}", - }; - - var normalized = NormalizeOptional(revisionFeedback); - if (normalized is not null) - lines.Add($"Revision feedback: {normalized}"); - - return string.Join('\n', lines); - } - - private async Task UpsertRegistryAsync(string status, CancellationToken ct) - { -#pragma warning disable CS0612 // legacy field reads/writes during owner_scope migration (issue #466) - var legacyOwnerNyxUserId = State.OwnerNyxUserId ?? string.Empty; - var legacyPlatform = ResolvePlatform(State.Platform); - var ownerScope = State.OwnerScope ?? OwnerScope.FromLegacyFields(legacyOwnerNyxUserId, legacyPlatform); - - var command = new UserAgentCatalogUpsertCommand - { - AgentId = Id, - Platform = legacyPlatform, - ConversationId = State.ConversationId ?? string.Empty, - NyxProviderSlug = State.NyxProviderSlug ?? string.Empty, - NyxApiKey = State.NyxApiKey ?? string.Empty, - OwnerNyxUserId = legacyOwnerNyxUserId, - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - ScopeId = State.ScopeId ?? string.Empty, - ApiKeyId = State.ApiKeyId ?? string.Empty, - ScheduleCron = State.ScheduleCron ?? string.Empty, - ScheduleTimezone = State.ScheduleTimezone ?? string.Empty, - Status = status, - LarkReceiveId = State.LarkReceiveId ?? string.Empty, - LarkReceiveIdType = State.LarkReceiveIdType ?? string.Empty, - LarkReceiveIdFallback = State.LarkReceiveIdFallback ?? string.Empty, - LarkReceiveIdTypeFallback = State.LarkReceiveIdTypeFallback ?? string.Empty, - }; -#pragma warning restore CS0612 - - if (ownerScope is not null) - command.OwnerScope = ownerScope; - - await UserAgentCatalogStoreCommands.DispatchUpsertAsync(Services, Id, command, ct); - await UpdateRegistryExecutionAsync(status, State.LastRunAt, State.NextRunAt, State.ErrorCount, State.LastError, ct); - } - - private async Task UpdateRegistryExecutionAsync( - string status, Timestamp? lastRunAt, Timestamp? nextRunAt, - int errorCount, string? lastError, CancellationToken ct) - { - var command = new UserAgentCatalogExecutionUpdateCommand - { - AgentId = Id, Status = status, - LastRunAt = lastRunAt, NextRunAt = nextRunAt, - ErrorCount = errorCount, LastError = lastError ?? string.Empty, - }; - await UserAgentCatalogStoreCommands.DispatchExecutionUpdateAsync(Services, Id, command, ct); - } - - private static WorkflowAgentState ApplyInitialized(WorkflowAgentState current, WorkflowAgentInitializedEvent evt) - { - var next = current.Clone(); - next.WorkflowId = evt.WorkflowId ?? string.Empty; - next.WorkflowName = evt.WorkflowName ?? string.Empty; - next.WorkflowActorId = evt.WorkflowActorId ?? string.Empty; - next.ExecutionPrompt = evt.ExecutionPrompt ?? string.Empty; - next.ScheduleCron = evt.ScheduleCron ?? string.Empty; - next.ScheduleTimezone = NormalizeTimezone(evt.ScheduleTimezone); - next.ConversationId = evt.ConversationId ?? string.Empty; - next.NyxProviderSlug = evt.NyxProviderSlug ?? string.Empty; - next.NyxApiKey = evt.NyxApiKey ?? string.Empty; -#pragma warning disable CS0612 // legacy fields preserved during owner_scope migration - next.OwnerNyxUserId = evt.OwnerNyxUserId ?? string.Empty; -#pragma warning restore CS0612 - next.ApiKeyId = evt.ApiKeyId ?? string.Empty; - next.Enabled = evt.Enabled; - next.ScopeId = evt.ScopeId ?? string.Empty; -#pragma warning disable CS0612 // legacy field preserved during owner_scope migration - next.Platform = evt.Platform ?? string.Empty; -#pragma warning restore CS0612 - next.LarkReceiveId = evt.LarkReceiveId ?? string.Empty; - next.LarkReceiveIdType = evt.LarkReceiveIdType ?? string.Empty; - next.LarkReceiveIdFallback = evt.LarkReceiveIdFallback ?? string.Empty; - next.LarkReceiveIdTypeFallback = evt.LarkReceiveIdTypeFallback ?? string.Empty; - if (evt.OwnerScope is not null) - next.OwnerScope = evt.OwnerScope.Clone(); - return next; - } - - private static WorkflowAgentState ApplyNextRunScheduled(WorkflowAgentState current, WorkflowAgentNextRunScheduledEvent evt) - { - var next = current.Clone(); - next.NextRunAt = evt.NextRunAt; - return next; - } - - private static WorkflowAgentState ApplyDispatched(WorkflowAgentState current, WorkflowAgentExecutionDispatchedEvent evt) - { - var next = current.Clone(); - next.LastRunAt = evt.DispatchedAt; - next.LastError = string.Empty; - next.ErrorCount = 0; - return next; - } - - private static WorkflowAgentState ApplyFailed(WorkflowAgentState current, WorkflowAgentExecutionFailedEvent evt) - { - var next = current.Clone(); - next.LastRunAt = evt.FailedAt; - next.LastError = evt.Error ?? string.Empty; - next.ErrorCount += 1; - return next; - } - - private static WorkflowAgentState ApplyDisabled(WorkflowAgentState current, WorkflowAgentDisabledEvent _) - { - var next = current.Clone(); - next.Enabled = false; - next.NextRunAt = null; - return next; - } - - private static WorkflowAgentState ApplyEnabled(WorkflowAgentState current, WorkflowAgentEnabledEvent _) - { - var next = current.Clone(); - next.Enabled = true; - return next; - } - - private static string NormalizeTimezone(string? scheduleTimezone) => - string.IsNullOrWhiteSpace(scheduleTimezone) ? WorkflowAgentDefaults.DefaultTimezone : scheduleTimezone.Trim(); - - private static string ResolvePlatform(string? platform) => - string.IsNullOrWhiteSpace(platform) ? WorkflowAgentDefaults.DefaultPlatform : platform.Trim(); - - private static string? NormalizeOptional(string? value) - { - var normalized = (value ?? string.Empty).Trim(); - return normalized.Length == 0 ? null : normalized; - } - - private static string MapDispatchError(WorkflowChatRunStartError error) => error switch - { - WorkflowChatRunStartError.AgentNotFound => "Workflow actor not found.", - WorkflowChatRunStartError.WorkflowNotFound => "Workflow definition not found.", - WorkflowChatRunStartError.AgentTypeNotSupported => "Actor is not workflow-capable.", - WorkflowChatRunStartError.ProjectionDisabled => "Workflow projection is disabled.", - WorkflowChatRunStartError.WorkflowBindingMismatch => "Workflow binding mismatch.", - WorkflowChatRunStartError.AgentWorkflowNotConfigured => "Workflow actor is not bound to a workflow.", - WorkflowChatRunStartError.InvalidWorkflowYaml => "Workflow YAML is invalid.", - WorkflowChatRunStartError.WorkflowNameMismatch => "Workflow name does not match the bound workflow.", - WorkflowChatRunStartError.PromptRequired => "Workflow prompt is required.", - WorkflowChatRunStartError.ConflictingScopeId => "Workflow scope_id is conflicting.", - _ => "Workflow run dispatch failed.", - }; -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs deleted file mode 100644 index 122d20ddb..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentLegacyAliases.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Aevatar.Foundation.Abstractions.Compatibility; - -namespace Aevatar.GAgents.Scheduled; - -internal static class WorkflowAgentLegacyAliases -{ - private const string ProtoPrefix = "aevatar.gagents.channelruntime."; - private const string ClrPrefix = "Aevatar.GAgents.ChannelRuntime."; - - internal const string StateProto = ProtoPrefix + "WorkflowAgentState"; - internal const string InitializeCommandProto = ProtoPrefix + "InitializeWorkflowAgentCommand"; - internal const string InitializedEventProto = ProtoPrefix + "WorkflowAgentInitializedEvent"; - internal const string TriggerCommandProto = ProtoPrefix + "TriggerWorkflowAgentExecutionCommand"; - internal const string NextRunScheduledEventProto = ProtoPrefix + "WorkflowAgentNextRunScheduledEvent"; - internal const string ExecutionDispatchedEventProto = ProtoPrefix + "WorkflowAgentExecutionDispatchedEvent"; - internal const string ExecutionFailedEventProto = ProtoPrefix + "WorkflowAgentExecutionFailedEvent"; - internal const string DisableCommandProto = ProtoPrefix + "DisableWorkflowAgentCommand"; - internal const string EnableCommandProto = ProtoPrefix + "EnableWorkflowAgentCommand"; - internal const string DisabledEventProto = ProtoPrefix + "WorkflowAgentDisabledEvent"; - internal const string EnabledEventProto = ProtoPrefix + "WorkflowAgentEnabledEvent"; - - internal const string StateClr = ClrPrefix + "WorkflowAgentState"; -} - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.StateProto)] -[LegacyClrTypeName(WorkflowAgentLegacyAliases.StateClr)] -public sealed partial class WorkflowAgentState; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.InitializeCommandProto)] -public sealed partial class InitializeWorkflowAgentCommand; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.InitializedEventProto)] -public sealed partial class WorkflowAgentInitializedEvent; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.TriggerCommandProto)] -public sealed partial class TriggerWorkflowAgentExecutionCommand; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.NextRunScheduledEventProto)] -public sealed partial class WorkflowAgentNextRunScheduledEvent; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.ExecutionDispatchedEventProto)] -public sealed partial class WorkflowAgentExecutionDispatchedEvent; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.ExecutionFailedEventProto)] -public sealed partial class WorkflowAgentExecutionFailedEvent; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.DisableCommandProto)] -public sealed partial class DisableWorkflowAgentCommand; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.EnableCommandProto)] -public sealed partial class EnableWorkflowAgentCommand; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.DisabledEventProto)] -public sealed partial class WorkflowAgentDisabledEvent; - -[LegacyProtoFullName(WorkflowAgentLegacyAliases.EnabledEventProto)] -public sealed partial class WorkflowAgentEnabledEvent; diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs deleted file mode 100644 index f6bace9a3..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowAgentState.Partial.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Aevatar.GAgents.Channel.Abstractions; - -namespace Aevatar.GAgents.Scheduled; - -public sealed partial class WorkflowAgentState : ISchedulable -{ - /// - ScheduleState ISchedulable.Schedule => new() - { - Enabled = Enabled, - Cron = ScheduleCron ?? string.Empty, - Timezone = ScheduleTimezone ?? string.Empty, - NextRunAt = NextRunAt, - LastRunAt = LastRunAt, - ErrorCount = ErrorCount, - }; -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ScheduledWorkflowModulePack.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ScheduledWorkflowModulePack.cs deleted file mode 100644 index 044cfc275..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ScheduledWorkflowModulePack.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Aevatar.Workflow.Core; -using Aevatar.Workflow.Core.Composition; - -namespace Aevatar.GAgents.Scheduled.WorkflowModules; - -/// -/// Workflow module pack contributed by the scheduled-agent package — currently registers -/// for the social_media template's -/// twitter_publish step (issue aevatarAI/aevatar#216). Lives next to its dependencies -/// (NyxIdApiClient, ChannelMetadataKeys, LarkProxyResponse) instead of in -/// Aevatar.Workflow.Core so the generic workflow runtime stays free of channel-specific -/// compile-time coupling. -/// -public sealed class ScheduledWorkflowModulePack : IWorkflowModulePack -{ - private static readonly IReadOnlyList ModuleRegistrations = - [ - WorkflowModuleRegistration.Create("twitter_publish"), - ]; - - public string Name => "scheduled.workflow"; - - public IReadOnlyList Modules => ModuleRegistrations; - - public IReadOnlyList DependencyExpanders => []; - - public IReadOnlyList Configurators => []; -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ServiceCollectionExtensions.cs deleted file mode 100644 index 36b364cd1..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Aevatar.Workflow.Core; -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.GAgents.Scheduled.WorkflowModules; - -/// -/// DI extension to register the scheduled-agent workflow module pack. Hosts that compose -/// the social_media template's execution should call this so the twitter_publish -/// step type resolves at workflow run time. -/// -public static class ScheduledWorkflowModuleServiceCollectionExtensions -{ - /// - /// Registers alongside any other module - /// packs already added to the workflow runtime. Idempotent — uses - /// TryAddEnumerable via - /// . - /// - public static IServiceCollection AddScheduledWorkflowExtensions(this IServiceCollection services) => - services.AddWorkflowModulePack(); -} diff --git a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/TwitterPublishModule.cs b/agents/Aevatar.GAgents.Scheduled/WorkflowModules/TwitterPublishModule.cs deleted file mode 100644 index db0e30fbd..000000000 --- a/agents/Aevatar.GAgents.Scheduled/WorkflowModules/TwitterPublishModule.cs +++ /dev/null @@ -1,556 +0,0 @@ -// ───────────────────────────────────────────────────────────── -// TwitterPublishModule — 把 social_media 模板批准后的内容发布到 X (Twitter) -// 通过 NyxID `api-twitter` 代理调用 POST /tweets,结果同步回 Lark。 -// 见 issue aevatarAI/aevatar#216 — 接续 #418 的 PreflightTwitterProxyAsync。 -// ───────────────────────────────────────────────────────────── - -using System.Net; -using System.Text.Json; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.AI.ToolProviders.NyxId; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.EventModules; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Platform.Lark; -using Aevatar.Workflow.Abstractions; -using Aevatar.Workflow.Abstractions.Execution; -using Aevatar.Workflow.Core.Execution; -using Aevatar.Workflow.Core.Primitives; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Aevatar.GAgents.Scheduled.WorkflowModules; - -/// -/// Twitter (X) 发布模块。处理 step_type == "twitter_publish"。 -/// 用 social_media agent 在 NyxID 中预先 mint 的 api-key 调 api-twitter 代理把已批准 -/// 的草稿发布到 Twitter,并把结果(推文 URL 或分类好的错误文案)回写到原始 Lark 会话。 -/// -/// -/// 与 LLM/工具调用路径不同——发布是确定性的:批准的内容直接进入 POST /tweets(NyxID 的 -/// api-twitter 代理 base_url 已含 /2,不能再前缀 /2/,详见 -/// NyxIdServiceApiHints.cs),没有模型重写余地。把这一段建在工作流 module 而不是 LLM -/// step 里也更可重入:模型偶尔丢工具调用、或返回非结构化文本,但发布行为必须严格 1:1。 -/// -public sealed class TwitterPublishModule : IEventModule -{ - public string Name => "twitter_publish"; - public int Priority => 5; - - public bool CanHandle(EventEnvelope envelope) => - envelope.Payload?.Is(StepRequestEvent.Descriptor) == true; - - public async Task HandleAsync(EventEnvelope envelope, IWorkflowExecutionContext ctx, CancellationToken ct) - { - var request = envelope.Payload!.Unpack(); - if (request.StepType != "twitter_publish") return; - - var content = (request.Input ?? string.Empty).Trim(); - if (string.IsNullOrEmpty(content)) - { - await PublishFailureAsync( - ctx, - request, - code: "twitter_publish_empty_content", - message: "Approved content was empty; nothing to publish.", - logger: ctx.Logger, - ct); - return; - } - - var nyxClient = ctx.Services.GetService(); - if (nyxClient is null) - { - await PublishFailureAsync( - ctx, - request, - code: "twitter_publish_client_missing", - message: "NyxIdApiClient is not registered; cannot publish.", - logger: ctx.Logger, - ct); - return; - } - - if (!WorkflowExecutionItemsAccess.TryGetItem( - ctx, - LLMRequestMetadataKeys.NyxIdAccessToken, - out var apiKeyValue) || - string.IsNullOrWhiteSpace(apiKeyValue)) - { - await PublishFailureAsync( - ctx, - request, - code: "twitter_publish_api_key_missing", - message: "Workflow execution context did not carry a NyxID api-key. Re-create the agent so the new outbound config propagates.", - logger: ctx.Logger, - ct); - return; - } - - var requestMetadata = new Dictionary(StringComparer.Ordinal); - WorkflowRequestMetadataItemsAccess.CopyRequestMetadata(ctx, requestMetadata); - - var publishSlug = WorkflowParameterValueParser.GetString( - request.Parameters, - "api-twitter", - "publish_provider_slug", - "nyx_publish_provider_slug", - "publish_slug"); - - var deliveryTargetId = WorkflowParameterValueParser.GetString( - request.Parameters, - string.Empty, - "delivery_target_id"); - - // Twitter v2 endpoint requires `text` payload only for plain-text posts (#216 v1 scope: - // no media, no thread, no poll). Body is JSON, content-type is set by NyxIdApiClient. - // - // Idempotency caveat (PR #461 review item #1): Twitter v2 `POST /tweets` has no - // server-side dedup. If this step is retried (e.g. via a `retry` policy on the YAML, or - // a workflow restart that replays an in-flight `StepRequestEvent`), the same content - // will be posted twice. The social_media template intentionally does NOT define a - // `retry` policy on this step, and the `on_error: skip` policy advances to `done` - // rather than retrying. Authors customizing the YAML should keep this invariant — do - // not add `retry: { max_attempts: > 1 }` here without first wiring a client-side dedup - // key (e.g. hashing run_id+step_id+content into a NyxID-side request idempotency - // header) or accepting duplicate posts as a known risk. - var tweetBody = JsonSerializer.Serialize(new { text = content }); - - string proxyResponse; - try - { - // PR #461 review (commit 781c5bda follow-up): NyxID's `api-twitter` provider seed - // sets `base_url: "https://api.x.com/2"` (provider_service.rs:1728) — the API - // version is already baked into the base URL. Adding `/2/` to the path here would - // produce `https://api.x.com/2/2/tweets` and 404 every publish call in production. - // Mirror what the preflight does (`/users/me`, AgentBuilderTool.cs:1877): use the - // bare resource path. NyxIdServiceApiHints.cs:58 documents this invariant. - proxyResponse = await nyxClient.ProxyRequestAsync( - apiKeyValue!, - publishSlug, - "/tweets", - "POST", - tweetBody, - extraHeaders: null, - ct); - } - catch (Exception ex) - { - ctx.Logger.LogWarning( - ex, - "TwitterPublish: run={RunId} step={StepId} unhandled exception while calling api-twitter", - request.RunId, - request.StepId); - await PublishFailureAsync( - ctx, - request, - code: "twitter_publish_transport_error", - message: $"NyxID proxy transport error: {ex.Message}", - logger: ctx.Logger, - ct); - await TrySendLarkAsync( - nyxClient, - requestMetadata, - apiKeyValue!, - deliveryTargetId, - $"Twitter 发布失败(网络错误):{ex.Message}", - ctx.Logger, - ct); - return; - } - - var outcome = ClassifyTwitterResponse(proxyResponse); - - if (outcome.Success && !string.IsNullOrEmpty(outcome.TweetUrl)) - { - ctx.Logger.LogInformation( - "TwitterPublish: run={RunId} step={StepId} published tweet={TweetUrl}", - request.RunId, - request.StepId, - outcome.TweetUrl); - - var successMessage = $"已发布: {outcome.TweetUrl}"; - await TrySendLarkAsync( - nyxClient, - requestMetadata, - apiKeyValue!, - deliveryTargetId, - successMessage, - ctx.Logger, - ct); - - var completed = new StepCompletedEvent - { - StepId = request.StepId, - RunId = request.RunId, - Success = true, - Output = outcome.TweetUrl!, - }; - await ctx.PublishAsync(completed, TopologyAudience.Self, ct); - return; - } - - ctx.Logger.LogWarning( - "TwitterPublish: run={RunId} step={StepId} publish failed code={Code} status={Status} detail={Detail}", - request.RunId, - request.StepId, - outcome.ErrorCode, - outcome.HttpStatus, - outcome.Detail); - - await TrySendLarkAsync( - nyxClient, - requestMetadata, - apiKeyValue!, - deliveryTargetId, - outcome.LarkMessage, - ctx.Logger, - ct); - - await PublishFailureAsync( - ctx, - request, - code: outcome.ErrorCode, - message: outcome.Detail, - logger: ctx.Logger, - ct); - } - - private static Task PublishFailureAsync( - IWorkflowExecutionContext ctx, - StepRequestEvent request, - string code, - string message, - ILogger logger, - CancellationToken ct) - { - // The social_media template's `publish_to_twitter` step routes its failure into the - // `done` terminal so the run finishes cleanly even if Twitter rejected the post — - // the failure is surfaced to Lark independently. Mark Success=false so callers / - // observability see the failed publish, but emit the error string verbatim so the - // workflow output preserves the categorized code. - var failed = new StepCompletedEvent - { - StepId = request.StepId, - RunId = request.RunId, - Success = false, - Output = $"{code}: {message}", - Error = $"{code}: {message}", - }; - return ctx.PublishAsync(failed, TopologyAudience.Self, ct); - } - - /// - /// Surfaces a status message back to the originating Lark conversation via the same NyxID - /// api-key used to publish the tweet. Best-effort: a Lark delivery failure must never abort - /// the workflow's own bookkeeping (which is what publishes StepCompletedEvent). - /// - /// - /// PR #461 review item #5: this method depends on the api-key carrying both the - /// api-twitter AND the Lark proxy slug (e.g. api-lark-bot) entitlements at - /// mint time — see CreateSocialMediaAgentAsync in AgentBuilderTool.cs, which - /// resolves both slugs through ResolveProxyServiceIdsAsync before - /// CreateApiKeyAsync. If a future change narrows the api-key to only Twitter, the - /// Lark surfacing here will silently 403 — keep the dual-scope mint contract in lock-step - /// with this method, or pass a dedicated Lark api-key through metadata. - /// - private static async Task TrySendLarkAsync( - NyxIdApiClient nyxClient, - IReadOnlyDictionary requestMetadata, - string apiKey, - string fallbackReceiveId, - string text, - ILogger logger, - CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(text)) - return; - - var receiveId = TryGet(requestMetadata, ChannelMetadataKeys.LarkReceiveId); - var receiveIdType = TryGet(requestMetadata, ChannelMetadataKeys.LarkReceiveIdType); - var larkSlug = TryGet(requestMetadata, ChannelMetadataKeys.LarkOutboundProxySlug) ?? "api-lark-bot"; - - // Fallback: when the workflow agent's outbound metadata is unavailable, treat the - // step's `delivery_target_id` (which is the agent_id, i.e. the Lark receive_id under - // open_id naming for p2p chats) as a best-effort target. - if (string.IsNullOrWhiteSpace(receiveId)) - { - receiveId = fallbackReceiveId; - receiveIdType = string.IsNullOrWhiteSpace(receiveIdType) ? "open_id" : receiveIdType; - } - - if (string.IsNullOrWhiteSpace(receiveId) || string.IsNullOrWhiteSpace(receiveIdType)) - { - logger.LogWarning( - "TwitterPublish: skipping Lark surfacing — outbound delivery target metadata missing (receive_id/type empty)."); - return; - } - - try - { - var body = JsonSerializer.Serialize(new - { - receive_id = receiveId, - msg_type = "text", - content = JsonSerializer.Serialize(new { text }), - }); - - var response = await nyxClient.ProxyRequestAsync( - apiKey, - larkSlug, - $"open-apis/im/v1/messages?receive_id_type={receiveIdType}", - "POST", - body, - extraHeaders: null, - ct); - - if (LarkProxyResponse.TryGetError(response, out var larkCode, out var detail)) - { - logger.LogWarning( - "TwitterPublish: Lark surfacing rejected (code={Code}): {Detail}", - larkCode, - detail); - } - } - catch (Exception ex) - { - // Lark surfacing is best-effort: a failure here must not abort the workflow's - // own bookkeeping (which is what publishes StepCompletedEvent). Log and move on. - logger.LogWarning(ex, "TwitterPublish: Lark surfacing threw"); - } - } - - private static string? TryGet(IReadOnlyDictionary map, string key) - { - if (!map.TryGetValue(key, out var value)) - return null; - return string.IsNullOrWhiteSpace(value) ? null : value; - } - - /// - /// Classifies a NyxID proxy response from POST /api/v1/proxy/s/api-twitter/tweets - /// (NyxID's api-twitter base already includes /2, so the path is - /// /tweets, not /2/tweets — see the HandleAsync call site comment) - /// into a publish outcome. Three shapes are recognized: - /// - /// Twitter 2xx success: { "data": { "id": "<tweet-id>" } } (NyxID forwards - /// the body verbatim). - /// NyxID-wrapped non-2xx: { "error": true, "status": <http>, "body": - /// "<raw downstream body>" } (NyxIdApiClient.cs:680). - /// Twitter v2 native error: { "errors": [ { "message": "...", "code": ... } ], - /// "title": "...", "detail": "..." } — Twitter sometimes returns 4xx with this shape - /// at the top level (PR #461 review item #2). NyxID forwards verbatim, so we parse it as - /// a fallback when neither data.id nor the NyxID-wrapped envelope is present. - /// - /// - internal static TwitterPublishOutcome ClassifyTwitterResponse(string? response) - { - if (string.IsNullOrWhiteSpace(response)) - { - return TwitterPublishOutcome.Failure( - "twitter_publish_empty_response", - "NyxID proxy returned an empty response.", - httpStatus: 0, - larkMessage: "Twitter 发布失败:NyxID 代理返回空响应"); - } - - try - { - using var doc = JsonDocument.Parse(response); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - return TwitterPublishOutcome.Failure( - "twitter_publish_unexpected_shape", - "Response root was not a JSON object.", - httpStatus: 0, - larkMessage: "Twitter 发布失败:响应格式异常"); - } - - var hasErrorFlag = root.TryGetProperty("error", out var errorProp) && - (errorProp.ValueKind == JsonValueKind.True || - errorProp.ValueKind == JsonValueKind.String); - - // Success path: Twitter returns `{ "data": { "id": "...", "text": "..." } }`. NyxID - // forwards 2xx bodies verbatim, so the absence of an `error` field combined with a - // present `data.id` is the success signal. - if (!hasErrorFlag && - root.TryGetProperty("data", out var dataProp) && - dataProp.ValueKind == JsonValueKind.Object && - dataProp.TryGetProperty("id", out var idProp) && - idProp.ValueKind == JsonValueKind.String && - !string.IsNullOrWhiteSpace(idProp.GetString())) - { - var tweetId = idProp.GetString()!; - // Twitter accepts `https://x.com/i/web/status/` without a handle; resolves - // to the canonical `/status/` URL after redirect. The issue calls - // for a `users/me` lookup to resolve the handle, but that's an extra round-trip - // that can also 401 (and we already have a tweet id at this point). Fall back - // to the no-handle URL — the user always lands on the right tweet either way. - return TwitterPublishOutcome.Successful($"https://x.com/i/web/status/{tweetId}"); - } - - // Failure path A: NyxID wraps non-2xx as { error: true, status: , body: }. - if (hasErrorFlag) - { - var nyxStatus = TryReadInt32(root, "status") ?? TryReadInt32(root, "code") ?? 0; - var nyxDetail = TryReadString(root, "message") ?? TryReadString(root, "body") ?? "Twitter publish failed"; - var nyxBody = TryReadString(root, "body"); - return ClassifyByStatus(nyxStatus, nyxDetail, nyxBody); - } - - // Failure path B (PR #461 review item #2): Twitter v2 native error shape, forwarded - // by NyxID without a wrap envelope. Common for content-policy and duplicate-tweet - // rejections, e.g. `{"title":"Conflict","detail":"...","errors":[{"message":"...", - // "code":187}]}`. We don't have an HTTP status here (NyxID swallowed it), so the - // classification falls through to a generic `twitter_publish_rejected`, but we - // surface the rich Twitter error text so users can read the actual reason. - if (TryParseTwitterNativeError(root, out var nativeOutcome)) - return nativeOutcome; - - return TwitterPublishOutcome.Failure( - "twitter_publish_unexpected_shape", - "Response did not match success, NyxID-wrapped, or Twitter-native error shapes.", - httpStatus: 0, - larkMessage: "Twitter 发布失败:响应格式异常,请联系 ops 检查 NyxID 代理日志。"); - } - catch (JsonException) - { - return TwitterPublishOutcome.Failure( - "twitter_publish_unparseable_response", - "NyxID proxy returned a non-JSON response.", - httpStatus: 0, - larkMessage: "Twitter 发布失败:响应不是合法 JSON"); - } - } - - /// - /// Parses a Twitter v2 native error shape (no NyxID wrap envelope). Twitter returns these - /// at the top level for some 4xx rejections (content-policy violations, duplicate tweets, - /// permission issues): { "title": "...", "detail": "...", "errors": [ { "message": - /// "...", "code": 187 } ] }. Returns false when the shape doesn't match so the caller - /// can fall through to the unexpected-shape branch. - /// - private static bool TryParseTwitterNativeError(JsonElement root, out TwitterPublishOutcome outcome) - { - outcome = default; - if (!root.TryGetProperty("errors", out var errorsProp) || - errorsProp.ValueKind != JsonValueKind.Array || - errorsProp.GetArrayLength() == 0) - { - // Sometimes Twitter omits the `errors` array but still returns `title`/`detail` - // directly (Problem Details RFC 7807 — what Twitter v2 calls `tweet_create_error`). - // Treat that as a native error too. - var detailText = TryReadString(root, "detail"); - var titleText = TryReadString(root, "title"); - if (string.IsNullOrEmpty(detailText) && string.IsNullOrEmpty(titleText)) - return false; - - var combined = string.IsNullOrEmpty(detailText) ? titleText! : detailText!; - outcome = TwitterPublishOutcome.Failure( - "twitter_publish_rejected", - combined, - httpStatus: 0, - larkMessage: $"Twitter 发布失败:{combined}"); - return true; - } - - var firstError = errorsProp[0]; - var message = TryReadString(firstError, "message") - ?? TryReadString(root, "detail") - ?? TryReadString(root, "title") - ?? "Twitter rejected the publish request."; - var twitterCode = TryReadInt32(firstError, "code"); - var detailWithCode = twitterCode is { } c - ? $"{message} (twitter code={c})" - : message; - - outcome = TwitterPublishOutcome.Failure( - "twitter_publish_rejected", - detailWithCode, - httpStatus: 0, - larkMessage: $"Twitter 发布失败:{detailWithCode}"); - return true; - } - - private static TwitterPublishOutcome ClassifyByStatus(int status, string detail, string? rawBody) - { - // Categorization matches issue #216's surfacing matrix: - // 201 → success (handled in caller) - // 401 → OAuth expired/missing — actionable, no retry - // 403 → scope downgraded or seed misconfig — actionable, no retry - // 429 → rate-limited — could retry, but #216 v1 scope says fail with hint - // 5xx → upstream/proxy fault — could retry; v1 scope: fail with hint - // 4xx other → unknown rejection — surface verbatim so user can debug - return status switch - { - (int)HttpStatusCode.Unauthorized => TwitterPublishOutcome.Failure( - "twitter_oauth_required", - detail, - status, - "Twitter OAuth 过期或未授权,请到 NyxID 重新授权 Twitter(providers/twitter)后再试。"), - (int)HttpStatusCode.Forbidden => TwitterPublishOutcome.Failure( - "twitter_proxy_access_denied", - detail, - status, - "Twitter 拒绝发布(403):scope 不足或推文内容被策略拦截。请联系 ops 检查 tweet.write scope。"), - (int)HttpStatusCode.TooManyRequests => TwitterPublishOutcome.Failure( - "twitter_rate_limited", - detail, - status, - "Twitter 发布命中速率限制(429),请稍后重试。"), - >= 500 and <= 599 => TwitterPublishOutcome.Failure( - "twitter_upstream_error", - detail, - status, - $"Twitter 上游服务异常(HTTP {status}),请稍后重试。"), - _ => TwitterPublishOutcome.Failure( - "twitter_publish_rejected", - detail, - status, - BuildGenericFailureMessage(status, detail, rawBody)), - }; - } - - private static string BuildGenericFailureMessage(int status, string detail, string? rawBody) - { - var truncated = rawBody is { Length: > 200 } ? rawBody.Substring(0, 200) + "…" : rawBody; - return string.IsNullOrEmpty(truncated) - ? $"Twitter 发布失败(HTTP {status}):{detail}" - : $"Twitter 发布失败(HTTP {status}):{detail}(body: {truncated})"; - } - - private static int? TryReadInt32(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var prop) || - prop.ValueKind != JsonValueKind.Number || - !prop.TryGetInt32(out var value)) - { - return null; - } - return value; - } - - private static string? TryReadString(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.String) - return null; - var raw = prop.GetString(); - return string.IsNullOrWhiteSpace(raw) ? null : raw; - } -} - -internal readonly record struct TwitterPublishOutcome( - bool Success, - string? TweetUrl, - string ErrorCode, - string Detail, - int HttpStatus, - string LarkMessage) -{ - public static TwitterPublishOutcome Successful(string tweetUrl) => - new(true, tweetUrl, string.Empty, string.Empty, 201, string.Empty); - - public static TwitterPublishOutcome Failure(string code, string detail, int httpStatus, string larkMessage) => - new(false, null, code, detail, httpStatus, larkMessage); -} diff --git a/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto b/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto index ad2d93e99..ac5522f50 100644 --- a/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto +++ b/agents/Aevatar.GAgents.Scheduled/protos/skill_runner.proto @@ -67,6 +67,13 @@ message SkillRunnerState { optional int32 max_tokens = 18; optional int32 max_tool_rounds = 19; optional int32 max_history_messages = 20; + // When true, a run that completes with zero successful nyxid_proxy calls is + // treated as a failure (the LLM bypassed tools and produced output from prior + // context, which for fetch-and-summarize skills means the + // report was hallucinated). Issue #439 review follow-up — closes the gap left + // by the original safety net, which only fired when ≥1 nyxid_proxy call had + // failed. Skills that don't fan out to nyxid_proxy at all leave this false. + bool requires_nyxid_proxy_success = 21; } message InitializeSkillRunnerCommand { @@ -85,6 +92,8 @@ message InitializeSkillRunnerCommand { optional int32 max_tokens = 13; optional int32 max_tool_rounds = 14; optional int32 max_history_messages = 15; + // See SkillRunnerState.requires_nyxid_proxy_success for semantics. + bool requires_nyxid_proxy_success = 16; } message SkillRunnerInitializedEvent { @@ -103,6 +112,8 @@ message SkillRunnerInitializedEvent { optional int32 max_tokens = 13; optional int32 max_tool_rounds = 14; optional int32 max_history_messages = 15; + // See SkillRunnerState.requires_nyxid_proxy_success for semantics. + bool requires_nyxid_proxy_success = 16; } message TriggerSkillRunnerExecutionCommand { diff --git a/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto b/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto deleted file mode 100644 index 78dd58de8..000000000 --- a/agents/Aevatar.GAgents.Scheduled/protos/workflow_agent.proto +++ /dev/null @@ -1,135 +0,0 @@ -syntax = "proto3"; - -package aevatar.gagents.scheduled; - -option csharp_namespace = "Aevatar.GAgents.Scheduled"; - -import "google/protobuf/timestamp.proto"; -import "user_agent_catalog.proto"; - -// ─── Workflow Agent (persistent scheduled workflow trigger) ─── - -message WorkflowAgentState { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - // Deprecated: superseded by owner_scope.nyx_user_id. Issue #466. - string owner_nyx_user_id = 10 [deprecated = true]; - string api_key_id = 11; - google.protobuf.Timestamp last_run_at = 12; - google.protobuf.Timestamp next_run_at = 13; - int32 error_count = 14; - string last_error = 15; - bool enabled = 16; - string scope_id = 17; - // Deprecated: superseded by owner_scope.platform. Issue #466. - string platform = 18 [deprecated = true]; - // See UserAgentCatalogEntry.lark_receive_id for semantics; copied verbatim - // into the catalog entry on UpsertRegistryAsync so downstream Lark senders - // (e.g. FeishuCardHumanInteractionPort) read the typed target. - string lark_receive_id = 19; - string lark_receive_id_type = 20; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 21; - string lark_receive_id_type_fallback = 22; - // Caller scope captured at create time. Replaces owner_nyx_user_id+platform - // for new agents; the deprecated scattered fields remain for legacy state. - OwnerScope owner_scope = 23; -} - -message InitializeWorkflowAgentCommand { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - // Deprecated: superseded by owner_scope.nyx_user_id. Issue #466. - string owner_nyx_user_id = 10 [deprecated = true]; - string api_key_id = 11; - bool enabled = 12; - string scope_id = 13; - // Deprecated: superseded by owner_scope.platform. Issue #466. - string platform = 14 [deprecated = true]; - string lark_receive_id = 15; - string lark_receive_id_type = 16; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 17; - string lark_receive_id_type_fallback = 18; - // Caller scope captured at create time. Required for new commands. - OwnerScope owner_scope = 19; -} - -message WorkflowAgentInitializedEvent { - string workflow_id = 1; - string workflow_name = 2; - string workflow_actor_id = 3; - string execution_prompt = 4; - string schedule_cron = 5; - string schedule_timezone = 6; - string conversation_id = 7; - string nyx_provider_slug = 8; - string nyx_api_key = 9; - // Deprecated: superseded by owner_scope.nyx_user_id. Issue #466. - string owner_nyx_user_id = 10 [deprecated = true]; - string api_key_id = 11; - bool enabled = 12; - string scope_id = 13; - // Deprecated: superseded by owner_scope.platform. Issue #466. - string platform = 14 [deprecated = true]; - string lark_receive_id = 15; - string lark_receive_id_type = 16; - // Secondary outbound delivery target. See UserAgentCatalogEntry - // .lark_receive_id_fallback for runtime fallback semantics. - string lark_receive_id_fallback = 17; - string lark_receive_id_type_fallback = 18; - // Caller scope captured at create time. Replaces owner_nyx_user_id+platform. - OwnerScope owner_scope = 19; -} - -message TriggerWorkflowAgentExecutionCommand { - string reason = 1; - string revision_feedback = 2; -} - -message WorkflowAgentNextRunScheduledEvent { - google.protobuf.Timestamp next_run_at = 1; -} - -message WorkflowAgentExecutionDispatchedEvent { - google.protobuf.Timestamp dispatched_at = 1; - string workflow_run_actor_id = 2; - string command_id = 3; -} - -message WorkflowAgentExecutionFailedEvent { - google.protobuf.Timestamp failed_at = 1; - string error = 2; -} - -message DisableWorkflowAgentCommand { - string reason = 1; -} - -message EnableWorkflowAgentCommand { - string reason = 1; -} - -message WorkflowAgentDisabledEvent { - string reason = 1; -} - -message WorkflowAgentEnabledEvent { - string reason = 1; -} diff --git a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayOptions.cs b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayOptions.cs index ab5cee467..a3ff998f8 100644 --- a/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayOptions.cs +++ b/agents/channels/Aevatar.GAgents.Channel.NyxIdRelay/NyxIdRelayOptions.cs @@ -5,7 +5,15 @@ namespace Aevatar.GAgents.Channel.NyxIdRelay; /// public class NyxIdRelayOptions { - public int ResponseTimeoutSeconds { get; set; } = 120; + /// + /// Hard upper bound on a single LLM reply turn (LLM thinking + tool rounds + final + /// streaming dispatch). 300s gives margin for multi-step tool chains common in the + /// aevatar Lark bot flow — search a skill, hit a remote endpoint, summarize the result — + /// without letting a genuine hang pin the run actor turn forever. Set to 0 or + /// negative on a deployment that has its own watchdog and prefers no in-process cap; + /// see AgentRunGAgent.ResolveFallbackTimeout. + /// + public int ResponseTimeoutSeconds { get; set; } = 300; public int MaxBufferedResponseChars { get; set; } = 16 * 1024; @@ -44,6 +52,16 @@ public class NyxIdRelayOptions /// public int StreamingFlushIntervalMs { get; set; } = 750; + /// + /// Maximum number of interim (non-final) edit dispatches per turn. Lark refuses message + /// edits beyond a per-message cap (observed ~20 in mainnet, code 230072 + /// "The message has reached the number of times it can be edited"); once that cap is + /// reached, even the final edit is rejected and the user sees a truncated reply. Capping + /// interim chunks here leaves headroom so the final flush always lands. Long replies + /// freeze on the last interim until the final fires — that is preferable to truncation. + /// + public int StreamingMaxInterimChunks { get; set; } = 15; + /// /// Placeholder text emitted as the first streaming chunk before the LLM produces any delta. /// Guarantees a visible "working" state within the outbound RTT even when the LLM suffers @@ -51,4 +69,27 @@ public class NyxIdRelayOptions /// to disable and instead wait for the first real delta (slower time-to-first-visible). /// public string StreamingPlaceholderText { get; set; } = "…"; + + /// + /// Routes streaming replies through Lark CardKit 2.0 streaming cards instead of editing a + /// regular message in place. CardKit element-content updates are not subject to the per- + /// message edit cap (Lark code 230072) so long replies never need to freeze on the last + /// interim chunk. Defaults to true so the modern card path is the standard + /// behaviour for the aevatar Lark bot (Feishu console grants the bot + /// cardkit:card:read + cardkit:card:write). Deployments that have not been + /// granted those scopes are not stuck: watches for the + /// scope-error / rate-limit / table-limit responses returned by card.create and + /// transitions the turn to the legacy edit-message sink for the rest of the chunks (see + /// HandleLarkCardStreamingChunkCoreAsync's CreationFailed branch). Set this + /// to false on a deployment that wants to skip the create-card round-trip entirely + /// (e.g. environments that explicitly want the legacy path or do not run a Lark bot). + /// + public bool StreamingCardKitEnabled { get; set; } = true; + + /// + /// Minimum interval between CardKit element-content dispatches, in milliseconds. Defaults + /// to 200ms — well below the 750ms used by the edit-message path because CardKit accepts + /// far more updates per card than Lark's edit-message cap allows. + /// + public int StreamingCardKitFlushIntervalMs { get; set; } = 200; } diff --git a/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs index 19c5b9b2a..5f1bd4f39 100644 --- a/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkMessageComposer.cs @@ -6,6 +6,9 @@ namespace Aevatar.GAgents.Platform.Lark; public sealed class LarkMessageComposer : IMessageComposer { + public const int DefaultMaxMessageLength = 30_000; + private const string TruncationMarker = "\n\n...[truncated]"; + public static readonly ChannelCapabilities DefaultCapabilities = new() { SupportsEphemeral = false, @@ -14,7 +17,7 @@ public sealed class LarkMessageComposer : IMessageComposer SupportsThread = true, Streaming = StreamingSupport.Native, SupportsFiles = false, - MaxMessageLength = 2000, + MaxMessageLength = DefaultMaxMessageLength, SupportsActionButtons = true, SupportsConfirmDialog = false, SupportsModal = false, @@ -385,6 +388,11 @@ private static string Truncate(string? value, int maxLength) if (textInfo.LengthInTextElements <= maxLength) return text; - return textInfo.SubstringByTextElements(0, maxLength); + var markerInfo = new StringInfo(TruncationMarker); + var markerLength = markerInfo.LengthInTextElements; + if (maxLength <= markerLength) + return textInfo.SubstringByTextElements(0, maxLength); + + return textInfo.SubstringByTextElements(0, maxLength - markerLength) + TruncationMarker; } } diff --git a/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkStreamingCardShell.cs b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkStreamingCardShell.cs new file mode 100644 index 000000000..3b1a1f6a9 --- /dev/null +++ b/agents/platforms/Aevatar.GAgents.Platform.Lark/LarkStreamingCardShell.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace Aevatar.GAgents.Platform.Lark; + +/// +/// Builds the initial CardKit 2.0 card JSON used to seed a streaming card shell: a single +/// markdown element identified by elementId with empty content. Streaming text is +/// written via cardElement.content updates against the live card; this initial JSON only +/// declares the shell. Lives in the Lark platform project so the schema literal stays +/// inside the channel-card-literal guard's allowed boundary. +/// +public static class LarkStreamingCardShell +{ + private static readonly JsonSerializerOptions JsonOptions = new(); + + public static string BuildInitialCardJson(string streamingElementId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(streamingElementId); + + var card = new + { + schema = "2.0", + config = new { streaming_mode = true }, + body = new + { + elements = new object[] + { + new + { + tag = "markdown", + element_id = streamingElementId, + content = string.Empty, + }, + }, + }, + }; + return JsonSerializer.Serialize(card, JsonOptions); + } +} diff --git a/docs/audit-scorecard/2026-04-27-daily-pipeline-architecture-review.md b/docs/audit-scorecard/2026-04-27-daily-pipeline-architecture-review.md index 6f7b9328d..6f4f009d1 100644 --- a/docs/audit-scorecard/2026-04-27-daily-pipeline-architecture-review.md +++ b/docs/audit-scorecard/2026-04-27-daily-pipeline-architecture-review.md @@ -159,7 +159,7 @@ issue #436 当时给了两条路:composite scope(补丁路径)vs. 引入 ` --- -### B3. `AgentBuilderTool.CreateDailyReportAgentAsync` 是 god 函数,违反"命令骨架内聚" +### B3. `AgentBuilderTool.CreateDailyAgentAsync` 是 god 函数,违反"命令骨架内聚" **现状** @@ -182,7 +182,7 @@ issue #436 当时给了两条路:composite scope(补丁路径)vs. 引入 ` > CLAUDE.md "命令骨架内聚: Normalize → Resolve Target → Build Context → Build Envelope → Dispatch → Receipt → Observe" + "ACK 诚实" + "禁止 query-time replay/priming" **修复方向** -- tool 只产出 `CreateDailyReportSubscriptionCommand` 一个 envelope 并 dispatch +- tool 只产出 `CreateDailySubscriptionCommand` 一个 envelope 并 dispatch - 第 2-4 步的 NyxID 调用下沉到 `AgentExecutionCredentialGAgent`(A1 的资源 actor)saga 内 - 第 7-8 步的 dispatch 由订阅 actor 自己 self-continuation 完成 - 第 6 步的 prime / 第 9 步的轮询全部砍掉——committed 即可观察,readmodel 由 projector 异步追上 @@ -197,7 +197,7 @@ issue #436 当时给了两条路:composite scope(补丁路径)vs. 引入 ` **现状** - `SkillRunnerGAgent` 是技术角色名("运行 skill 的东西") -- 它用 `template_name` 字段决定自己是 daily_report 还是 social_media——多态 actor,业务语义全在 `skill_content` 这段冻结的 prompt 字符串里 +- 它用 `template_name` 字段决定自己是 daily 还是 social_media——多态 actor,业务语义全在 `skill_content` 这段冻结的 prompt 字符串里 - `State` 揉了两类东西: - **订阅事实**(cron、target、GitHub binding、skill_content)—— 长期 - **执行历史**(last_run_at, last_output, error_count, retry_attempt)—— 自然 session-scoped @@ -211,7 +211,7 @@ issue #436 当时给了两条路:composite scope(补丁路径)vs. 引入 ` > CLAUDE.md "Actor 即业务实体: 一个 actor = 一个业务实体(数据与方法同住)" **修复方向** -- `DailyReportSubscriptionGAgent`(长期,订阅事实拥有者)+ `DailyReportRunGAgent`(session-scoped,每次执行一个) +- `DailySubscriptionGAgent`(长期,订阅事实拥有者)+ `DailyRunGAgent`(session-scoped,每次执行一个) - 重试逻辑完全塞进 run actor(一个 run 失败 = run actor 进入 retry-scheduled 状态),订阅 actor 不知道"retry"是什么 - 查询订阅历史 = 查 run readmodel - run actor 拆出来后,issue #439 的"假成功"改 run 状态机就够了,不再污染订阅事实 @@ -311,7 +311,7 @@ key 当前的物理位置: > CLAUDE.md "渐进演进: 开发期可用本地/内存实现,但生产语义必须能无缝迁移到分布式与持久化"——prompt 是事实但被当成代码常量 **修复方向** -`DailyReportTemplateCatalog`(actor 或 readmodel 文档),prompt 是有版本号的资源,agent 引用 `template_id + template_version`。issue #423 想做"更丰富的内容"时,在不破坏老 agent 的前提下出新版自然就有了。 +`DailyTemplateCatalog`(actor 或 readmodel 文档),prompt 是有版本号的资源,agent 引用 `template_id + template_version`。issue #423 想做"更丰富的内容"时,在不破坏老 agent 的前提下出新版自然就有了。 > 跟踪:[#450 refactor(daily-prompt): versioned daily report prompt templates](https://github.com/aevatarAI/aevatar/issues/450) @@ -328,7 +328,7 @@ key 当前的物理位置: | 3 | `nyxid_proxy` 工具响应分类(A3) | #439 | 小 | 大 | [#439](https://github.com/aevatarAI/aevatar/issues/439) | | 4 | `AgentExecutionCredentialGAgent`(A1 + C1) | 砍 best-effort revoke + 缩小 key 泄露面 + 与 NyxID 幂等问题解耦 | 中 | 中 | [#445](https://github.com/aevatarAI/aevatar/issues/445) | | 5 | webhook accept-fast + persisted inbox(A2) | aevatar 侧 #398 一类 | 中 | 中 | [#449](https://github.com/aevatarAI/aevatar/issues/449) | -| 6 | 拆 `DailyReportSubscription` + `DailyReportRun`(B4) | retry-定时混跑 / 历史可查 / 命名 | 大 | 中 + 长期复利 | [#447](https://github.com/aevatarAI/aevatar/issues/447) | +| 6 | 拆 `DailySubscription` + `DailyRun`(B4) | retry-定时混跑 / 历史可查 / 命名 | 大 | 中 + 长期复利 | [#447](https://github.com/aevatarAI/aevatar/issues/447) | | 7 | `lark_receive_id` 改运行时迟绑定(B5) | cross-app & chat 改名 | 中 | 中 | [#448](https://github.com/aevatarAI/aevatar/issues/448) | | 8 | `AgentBuilderTool` 解构成 command + saga(B3 + B7) | "ACK 诚实" + 砍 query-time priming | 大 | 中 | [#446](https://github.com/aevatarAI/aevatar/issues/446) | | 9 | prompt 模板版本化(C3) | #423 实施前置 | 小 | 中 | [#450](https://github.com/aevatarAI/aevatar/issues/450) | @@ -347,7 +347,7 @@ key 当前的物理位置: | [#444](https://github.com/aevatarAI/aevatar/issues/444) | refactor(daily-catalog): make UserAgentCatalogGAgent pure set-membership; projector consumes SkillRunner committed events directly | B1 + B6 | | [#445](https://github.com/aevatarAI/aevatar/issues/445) | refactor(daily-credential): introduce AgentExecutionCredentialGAgent — proxy API key as actor-owned resource | A1 + C1 | | [#446](https://github.com/aevatarAI/aevatar/issues/446) | refactor(daily-builder): decompose AgentBuilderTool god function into command + saga; remove query-time projection polling | B3 + B7 | -| [#447](https://github.com/aevatarAI/aevatar/issues/447) | refactor(daily-actor): split SkillRunnerGAgent into DailyReportSubscriptionGAgent + DailyReportRunGAgent | B4 | +| [#447](https://github.com/aevatarAI/aevatar/issues/447) | refactor(daily-actor): split SkillRunnerGAgent into DailySubscriptionGAgent + DailyRunGAgent | B4 | | [#448](https://github.com/aevatarAI/aevatar/issues/448) | refactor(daily-outbound): late-bind lark_receive_id at send time instead of freezing at agent creation | B5 | | [#449](https://github.com/aevatarAI/aevatar/issues/449) | harden(webhook): nyxid-relay handler must accept-fast and persist to inbox before processing | A2 | | [#450](https://github.com/aevatarAI/aevatar/issues/450) | refactor(daily-prompt): versioned daily report prompt templates (prerequisite for #423 enrichment) | C3 | diff --git a/docs/canon/daily-command-pipeline.md b/docs/canon/daily-command-pipeline.md index a9a514865..cab9821e7 100644 --- a/docs/canon/daily-command-pipeline.md +++ b/docs/canon/daily-command-pipeline.md @@ -63,7 +63,7 @@ owner: eanzhao |----|------|------| | ① Lark → NyxID | 入站 | Lark 把 `im.message.receive_v1` 推到 NyxID 的 channel bot relay webhook | | ② NyxID → aevatar | 入站 | NyxID 把规范化后的 payload + 签名 JWT 转发到 aevatar `/api/webhooks/nyxid-relay` | -| ③ aevatar 内部 | 处理 | 鉴权 → 解析 `/daily` → `AgentBuilderTool.CreateDailyReportAgentAsync` → 创建 `SkillRunnerGAgent` | +| ③ aevatar 内部 | 处理 | 鉴权 → 解析 `/daily` → `AgentBuilderTool.CreateDailyAgentAsync` → 创建 `SkillRunnerGAgent` | | ④ aevatar → NyxID | 出站(创建 API key + GitHub 预检) | `POST /api/v1/api-keys`、`GET /api/v1/proxy/s/api-github/...`(preflight) | | ⑤ NyxID → GitHub | LLM 工具调用 | `nyxid_proxy` 工具 → NyxID 注入 GitHub OAuth token → GitHub Search API | | ⑥ GitHub → aevatar | 工具响应 | JSON 结果回到 LLM;LLM 总结成一段文本 | @@ -184,25 +184,25 @@ QA 关注点: - 不在白名单 → 直接回 `BuildUnknownCommandReply()` 文案(不走 LLM) - 非私聊 → 回 `BuildPrivateChatRestrictionReply()`,不创建 agent、不执行 tool -3. `TryResolveDailyReport(tokens, conversationId, out decision)` (NyxRelayAgentBuilderFlow.cs:142) +3. `TryResolveDaily(tokens, conversationId, out decision)` (NyxRelayAgentBuilderFlow.cs:142) - 解析参数(顺序): - `github_username`:先看 `github_username=...`,再看第一个位置参数 - `schedule_time` / `schedule_cron` / `schedule_timezone` → `TryResolveSchedule()` - `repositories` - `run_immediately`(默认 true) - **保存偏好策略**:`save_github_username_preference = (githubUsername is not null)`——只有用户**显式**给了 username 才落库 - - 输出:`AgentBuilderFlowDecision.ToolCall("create_daily_report", json)` + - 输出:`AgentBuilderFlowDecision.ToolCall("create_daily", json)` - JSON 结构:`{action, template, github_username, save_github_username_preference, repositories, schedule_cron, schedule_timezone, run_immediately, conversation_id}` -4. `AgentBuilderTool.ExecuteAsync(argumentsJson, ct)` 派发到 `CreateDailyReportAgentAsync()` +4. `AgentBuilderTool.ExecuteAsync(argumentsJson, ct)` 派发到 `CreateDailyAgentAsync()` - 文件:`agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:178` - 关键步骤(**每步都有"失败时返回 JSON `{error: ...}`"分支,且都是测试覆盖点**): | 步 | 行号 | 行为 | 失败分支 | |----|------|------|----------| | a | 186-187 | 解析 `scope_id`(来自 `AgentToolRequestContext`) | scope 缺失走默认 | -| b | 188-195 | `ResolveDailyReportGithubUsernameAsync`:CLI 参数 → 已存偏好 → GitHub `/user` 接口反查 | 返回 `{error: "..."}` JSON | -| c | 197-204 | `AgentBuilderTemplates.TryBuildDailyReportSpec` 拼 system prompt + execution prompt | `github_username is required` | +| b | 188-195 | `ResolveDailyGithubUsernameAsync`:CLI 参数 → 已存偏好 → GitHub `/user` 接口反查 | 返回 `{error: "..."}` JSON | +| c | 197-204 | `AgentBuilderTemplates.TryBuildDailySpec` 拼 system prompt + execution prompt | `github_username is required` | | d | 206-212 | `ChannelScheduleCalculator.TryGetNextOccurrence`:cron + tz → 下一次执行时间(UTC) | `Invalid schedule: ...` | | e | 214-217 | `conversation_id` 从参数或 metadata 取 | `conversation_id is required` | | f | 219-221 | `ResolveCurrentUserIdAsync` → NyxID `GET /api/v1/users/me` | `Could not resolve current NyxID user id` | @@ -223,7 +223,7 @@ QA 关注点: 5. `NyxRelayAgentBuilderFlow.FormatToolResult(decision, toolResultJson)` - 把 step (t) 的 JSON 渲染成 Lark 可接受的 `MessageContent` - - `create_daily_report` 走 `FormatCreateDailyReportResult()` → `AgentBuilderCardContent.FormatDailyReportToolReply()`,输出文字或卡片 + - `create_daily` 走 `FormatCreateDailyResult()` → `AgentBuilderCardContent.FormatDailyToolReply()`,输出文字或卡片 ### 阶段 ④ aevatar → NyxID(API key + 预检) @@ -337,14 +337,14 @@ string failure_notification_provider_slug = 12; // §C 旁路 proxy slug(入 ``` ### `SkillRunnerState` -- `skill_name="daily_report"`、`template_name="daily_report"` +- `skill_name="daily"`、`template_name="daily"` - `skill_content` / `execution_prompt`:阶段 ③ 拼好后冻在 actor state,**不会再变**——QA 注意:用户改 GitHub 绑定后,已存活的 agent 不会自动重指向;这是 issue #436 acceptance criteria 第 5 条要保留的语义 - `schedule_cron` / `schedule_timezone`、`enabled`、`scope_id` - `provider_name` / `model` / `temperature` / `max_tokens` / `max_tool_rounds=20` / `max_history_messages` - 运行态:`last_run_at`、`next_run_at`、`error_count`、`last_error`、`last_output` ### `UserAgentCatalogEntry`(well-known 注册表条目) -- 关键字段:`agent_id`、`agent_type="skill_runner"`、`template_name="daily_report"`、`platform="lark"`、`conversation_id`、`scope_id`、`status`、`last_run_at`、`next_run_at`、`error_count`、`last_error`、`lark_receive_id*` +- 关键字段:`agent_id`、`agent_type="skill_runner"`、`template_name="daily"`、`platform="lark"`、`conversation_id`、`scope_id`、`status`、`last_run_at`、`next_run_at`、`error_count`、`last_error`、`lark_receive_id*` - `nyx_api_key` / `api_key_id`:actor state 内的 catalog entry 保留这两个字段;公开 `UserAgentCatalogDocument` 不再暴露 `nyx_api_key`,运行时出站读取单独的 `UserAgentCatalogNyxCredentialDocument`。 ### 命令 / 事件 @@ -435,7 +435,7 @@ string failure_notification_provider_slug = 12; // §C 旁路 proxy slug(入 ### 9.1 用户看得到(直接回 Lark 的 JSON `{error:"..."}` 或文案) - `No NyxID access token available. User must be authenticated.` —— NyxID 会话失效 - `Connect GitHub in NyxID, then run /daily again.` —— 没绑 GitHub provider -- `github_username is required for template=daily_report` +- `github_username is required for template=daily` - `schedule_cron is required for create_agent` - `Invalid schedule: {cronError}` - `conversation_id is required when no current channel conversation is available` @@ -460,7 +460,7 @@ string failure_notification_provider_slug = 12; // §C 旁路 proxy slug(入 ## 10. 命令参数与文案矩阵 -完整解析逻辑见 `NyxRelayAgentBuilderFlow.TryResolveDailyReport()`: +完整解析逻辑见 `NyxRelayAgentBuilderFlow.TryResolveDaily()`: | 输入 | github_username 来源 | save_pref | 副作用 | |------|----------------------|-----------|--------| @@ -505,7 +505,7 @@ string failure_notification_provider_slug = 12; // §C 旁路 proxy slug(入 - ✅ `/daily github_username=alice`(命名形式)等价于上面 - ✅ `/daily alice schedule_time=14:30` → `schedule_cron="30 14 * * *"` - ✅ `/daily alice schedule_timezone=Asia/Shanghai` → 透传 tz 字符串 -- ✅ `/daily alice repositories=a/b,c/d` → 透传 `"a/b,c/d"`,由 `TryBuildDailyReportSpec` 拆 +- ✅ `/daily alice repositories=a/b,c/d` → 透传 `"a/b,c/d"`,由 `TryBuildDailySpec` 拆 - ✅ `/daily alice run_immediately=false` → `run_immediately=false` - ✅ 非私聊(`chat_type != "p2p"`)→ `BuildPrivateChatRestrictionReply`,**不**产生 ToolCall - ✅ 未知 slash 命令 `/foo` → `BuildUnknownCommandReply` diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs index 8df442643..5fab5d00d 100644 --- a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs +++ b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs @@ -67,6 +67,9 @@ public sealed class ChatMessage /// Text content; for the tool role, this represents the tool execution result. public string? Content { get; init; } + /// 思考内容(如果适用)。 + public string? ReasoningContent { get; init; } + /// Multimodal content parts (text/image). When present, the provider should construct the message from the parts first. public IReadOnlyList? ContentParts { get; init; } @@ -83,7 +86,7 @@ public sealed class ChatMessage public static ChatMessage User(string content) => new() { Role = "user", Content = content }; /// Creates an assistant-role message. - public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content }; + public static ChatMessage Assistant(string content, string? reasoningContent = null) => new() { Role = "assistant", Content = content, ReasoningContent = reasoningContent }; /// Creates a tool-role message carrying the tool execution result. /// The corresponding tool_call Id. diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequestMetadataKeys.cs b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequestMetadataKeys.cs index 94a3b19fd..36977231a 100644 --- a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequestMetadataKeys.cs +++ b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequestMetadataKeys.cs @@ -20,4 +20,13 @@ public static class LLMRequestMetadataKeys /// caller (Studio API, streaming proxy) — fall back to ambient prefs. /// public const string SenderBindingId = "aevatar.sender_binding_id"; + + /// + /// Short-lived NyxID access token issued for . + /// Channel LLM turns use this only while attempting the sender's own + /// configured LLM route. If that attempt fails or the key is missing, the + /// request falls back to the bot owner's ambient + /// without asking the sender to run /init. + /// + public const string SenderNyxIdAccessToken = "nyxid.sender_access_token"; } diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponse.cs b/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponse.cs index a83fcb0ac..c93fb9346 100644 --- a/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponse.cs +++ b/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponse.cs @@ -11,6 +11,9 @@ public sealed class LLMResponse /// LLM 生成的文本内容。 public string? Content { get; init; } + /// LLM 生成的思考内容(如果适用)。 + public string? ReasoningContent { get; init; } + /// LLM 生成的多模态内容分片。 public IReadOnlyList? ContentParts { get; init; } diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/NyxIdLlmCatalogRoutes.cs b/src/Aevatar.AI.Abstractions/LLMProviders/NyxIdLlmCatalogRoutes.cs new file mode 100644 index 000000000..882d03d10 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/LLMProviders/NyxIdLlmCatalogRoutes.cs @@ -0,0 +1,6 @@ +namespace Aevatar.AI.Abstractions.LLMProviders; + +public static class NyxIdLlmCatalogRoutes +{ + public const string ProxyServicesPath = "/api/v1/proxy/services?per_page=100"; +} diff --git a/src/Aevatar.AI.Core/Chat/ChatHistory.cs b/src/Aevatar.AI.Core/Chat/ChatHistory.cs index 74d6aca9d..5af0f8788 100644 --- a/src/Aevatar.AI.Core/Chat/ChatHistory.cs +++ b/src/Aevatar.AI.Core/Chat/ChatHistory.cs @@ -68,6 +68,7 @@ public List Export() => { Role = m.Role, Content = m.Content, + ReasoningContent = m.ReasoningContent, ContentParts = m.ContentParts?.Select(ClonePart).ToArray(), ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls?.Select(CloneToolCall).ToArray(), @@ -84,6 +85,7 @@ public void Import(IEnumerable messages) { Role = m.Role, Content = m.Content, + ReasoningContent = m.ReasoningContent, ContentParts = m.ContentParts?.Select(ClonePart).ToArray(), ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls?.Select(CloneToolCall).ToArray(), @@ -120,6 +122,7 @@ public sealed class SerializableMessage { public required string Role { get; init; } public string? Content { get; init; } + public string? ReasoningContent { get; init; } public IReadOnlyList? ContentParts { get; init; } public string? ToolCallId { get; init; } public IReadOnlyList? ToolCalls { get; init; } diff --git a/src/Aevatar.AI.Core/Chat/ChatRuntime.cs b/src/Aevatar.AI.Core/Chat/ChatRuntime.cs index a1e7acf34..2f025a76d 100644 --- a/src/Aevatar.AI.Core/Chat/ChatRuntime.cs +++ b/src/Aevatar.AI.Core/Chat/ChatRuntime.cs @@ -315,7 +315,7 @@ await channel.Writer.WriteAsync( if (roundResult.Terminated) { streamingExecutor.Discard(); - AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, roundResult.ToolCalls); + AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, roundResult.ReasoningContent, roundResult.ToolCalls); finalContent = roundResult.Content; break; } @@ -337,6 +337,7 @@ await channel.Writer.WriteAsync( LLMResponse = new LLMResponse { Content = parsed.CleanedContent, + ReasoningContent = roundResult.ReasoningContent, ToolCalls = parsed.ToolCalls, }, }; @@ -352,20 +353,17 @@ await channel.Writer.WriteAsync( if (fallbackBlocked) { - AppendAssistantMessage(messages, pendingHistoryMessages, parsed.CleanedContent, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, parsed.CleanedContent, roundResult.ReasoningContent, toolCalls: null); finalContent = parsed.CleanedContent; break; } - AppendAssistantMessage(messages, pendingHistoryMessages, parsed.CleanedContent, toolCalls: null); - - var textToolCallMsg = new ChatMessage - { - Role = "assistant", - ToolCalls = parsed.ToolCalls, - }; - messages.Add(textToolCallMsg); - pendingHistoryMessages.Add(textToolCallMsg); + AppendAssistantMessage( + messages, + pendingHistoryMessages, + parsed.CleanedContent, + roundResult.ReasoningContent, + parsed.ToolCalls); // Execute parsed tool calls via a fresh executor using var textToolExecutor = new StreamingToolExecutor( @@ -388,7 +386,7 @@ await channel.Writer.WriteAsync( if (ToolCallLoop.IsLengthTruncated(roundResult.FinishReason) && lengthRecoveryCount < ToolCallLoop.MaxLengthRecoveries) { - AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, roundResult.ReasoningContent, toolCalls: null); var nudge = ChatMessage.User(ToolCallLoop.LengthRecoveryNudge); messages.Add(nudge); pendingHistoryMessages.Add(nudge); @@ -396,7 +394,7 @@ await channel.Writer.WriteAsync( continue; } - AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, roundResult.ReasoningContent, toolCalls: null); finalContent = roundResult.Content; break; } @@ -412,6 +410,7 @@ await channel.Writer.WriteAsync( LLMResponse = new LLMResponse { Content = roundResult.Content, + ReasoningContent = roundResult.ReasoningContent, ToolCalls = roundResult.ToolCalls, }, }; @@ -421,7 +420,7 @@ await channel.Writer.WriteAsync( if (postSamplingCtx.Items.TryGetValue("block_tool_calls", out var block) && block is true) { - AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, roundResult.Content, roundResult.ReasoningContent, toolCalls: null); finalContent = roundResult.Content; break; } @@ -437,6 +436,8 @@ await channel.Writer.WriteAsync( var assistantToolCallMessage = new ChatMessage { Role = "assistant", + Content = roundResult.Content, + ReasoningContent = roundResult.ReasoningContent, ToolCalls = roundResult.ToolCalls, }; messages.Add(assistantToolCallMessage); @@ -488,15 +489,12 @@ await channel.Writer.WriteAsync( : null; if (finalParsed?.ToolCalls.Count > 0) { - AppendAssistantMessage(messages, pendingHistoryMessages, finalParsed.CleanedContent, toolCalls: null); - - var finalToolCallMsg = new ChatMessage - { - Role = "assistant", - ToolCalls = finalParsed.ToolCalls, - }; - messages.Add(finalToolCallMsg); - pendingHistoryMessages.Add(finalToolCallMsg); + AppendAssistantMessage( + messages, + pendingHistoryMessages, + finalParsed.CleanedContent, + finalRound.ReasoningContent, + finalParsed.ToolCalls); using var finalToolExecutor = new StreamingToolExecutor( _toolLoop.Tools, _hooks, _toolLoop.ToolMiddlewares, @@ -527,12 +525,12 @@ await channel.Writer.WriteAsync( var summaryRound = await StreamLlmRoundAsync( provider, summaryRequest, channel.Writer, runToken, () => wroteOutput = true); - AppendAssistantMessage(messages, pendingHistoryMessages, summaryRound.Content, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, summaryRound.Content, summaryRound.ReasoningContent, toolCalls: null); finalContent = summaryRound.Content; } else { - AppendAssistantMessage(messages, pendingHistoryMessages, finalRound.Content, toolCalls: null); + AppendAssistantMessage(messages, pendingHistoryMessages, finalRound.Content, finalRound.ReasoningContent, toolCalls: null); finalContent = finalRound.Content; } } @@ -635,6 +633,7 @@ private async Task StreamLlmRoundCoreAsync( AnnotateRequestIdentity(llmCallContext); string? streamedContent = null; + string? streamedReasoningContent = null; TokenUsage? streamedUsage = null; IReadOnlyList? streamedToolCalls = null; string? streamedFinishReason = null; @@ -644,6 +643,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async if (llmCallContext.Terminate) return; var full = new StringBuilder(); + var fullReasoning = new StringBuilder(); TokenUsage? usage = null; string? finishReason = null; var toolCalls = onToolCallCompleted != null @@ -652,7 +652,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async await foreach (var chunk in provider.ChatStreamAsync(llmCallContext.Request, ct)) { - var normalizedChunk = NormalizeStreamChunk(chunk, toolCalls, full, ref usage, ref finishReason); + var normalizedChunk = NormalizeStreamChunk(chunk, toolCalls, full, fullReasoning, ref usage, ref finishReason); if (normalizedChunk == null) continue; @@ -661,6 +661,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async } streamedContent = full.Length > 0 ? full.ToString() : null; + streamedReasoningContent = fullReasoning.Length > 0 ? fullReasoning.ToString() : null; streamedUsage = usage; streamedFinishReason = finishReason; var finalizedToolCalls = toolCalls.BuildToolCalls(); @@ -668,6 +669,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async llmCallContext.Response = new LLMResponse { Content = streamedContent, + ReasoningContent = streamedReasoningContent, Usage = streamedUsage, ToolCalls = streamedToolCalls, FinishReason = finishReason, @@ -677,6 +679,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async if (llmCallContext.Terminate) { streamedContent = llmCallContext.Response?.Content; + streamedReasoningContent = llmCallContext.Response?.ReasoningContent; streamedUsage = llmCallContext.Response?.Usage; streamedToolCalls = llmCallContext.Response?.ToolCalls; @@ -693,6 +696,7 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async var response = llmCallContext.Response ?? new LLMResponse { Content = streamedContent, + ReasoningContent = streamedReasoningContent, Usage = streamedUsage, ToolCalls = streamedToolCalls, }; @@ -700,22 +704,24 @@ await MiddlewarePipeline.RunLLMCallAsync(_llmMiddlewares, llmCallContext, async llmHookContext.LLMResponse = response; if (_hooks != null) await _hooks.RunLLMRequestEndAsync(llmHookContext, ct); - return new StreamingRoundResult(response.Content, response.ToolCalls, llmCallContext.Terminate, response.FinishReason ?? streamedFinishReason); + return new StreamingRoundResult(response.Content, response.ReasoningContent, response.ToolCalls, llmCallContext.Terminate, response.FinishReason ?? streamedFinishReason); } private static void AppendAssistantMessage( List messages, List pendingHistoryMessages, string? content, + string? reasoningContent, IReadOnlyList? toolCalls) { - if (string.IsNullOrEmpty(content) && toolCalls is not { Count: > 0 }) + if (string.IsNullOrEmpty(content) && string.IsNullOrEmpty(reasoningContent) && toolCalls is not { Count: > 0 }) return; var assistantMessage = new ChatMessage { Role = "assistant", Content = content, + ReasoningContent = reasoningContent, ToolCalls = toolCalls, }; messages.Add(assistantMessage); @@ -798,6 +804,7 @@ private static void AnnotateRequestIdentity(LLMCallContext context) LLMStreamChunk chunk, StreamingToolCallAccumulator toolCalls, StringBuilder fullContent, + StringBuilder fullReasoningContent, ref TokenUsage? usage, ref string? finishReason) { @@ -808,6 +815,9 @@ private static void AnnotateRequestIdentity(LLMCallContext context) if (!string.IsNullOrEmpty(chunk.DeltaContent)) fullContent.Append(chunk.DeltaContent); + if (!string.IsNullOrEmpty(chunk.DeltaReasoningContent)) + fullReasoningContent.Append(chunk.DeltaReasoningContent); + if (chunk.Usage != null) usage = chunk.Usage; @@ -839,6 +849,9 @@ private static IReadOnlyList BuildSyntheticChunks(LLMResponse re { var chunks = new List(); + if (!string.IsNullOrEmpty(response.ReasoningContent)) + chunks.Add(new LLMStreamChunk { DeltaReasoningContent = response.ReasoningContent }); + if (!string.IsNullOrEmpty(response.Content)) chunks.Add(new LLMStreamChunk { DeltaContent = response.Content }); @@ -913,6 +926,7 @@ private async Task RunCompressionIfNeededAsync(CancellationToken ct) private sealed record StreamingRoundResult( string? Content, + string? ReasoningContent, IReadOnlyList? ToolCalls, bool Terminated, string? FinishReason); diff --git a/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs b/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs index e30d105e9..b9ebb8015 100644 --- a/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs +++ b/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs @@ -111,7 +111,7 @@ public ToolCallLoop( && block is true) { if (response.Content != null) - messages.Add(ChatMessage.Assistant(response.Content)); + messages.Add(ChatMessage.Assistant(response.Content, response.ReasoningContent)); return response.Content; } } @@ -134,6 +134,7 @@ public ToolCallLoop( LLMResponse = new LLMResponse { Content = parsed.CleanedContent, + ReasoningContent = response.ReasoningContent, ToolCalls = parsed.ToolCalls, }, }; @@ -144,14 +145,15 @@ public ToolCallLoop( && block is true) { if (parsed.CleanedContent != null) - messages.Add(ChatMessage.Assistant(parsed.CleanedContent)); + messages.Add(ChatMessage.Assistant(parsed.CleanedContent, response.ReasoningContent)); return parsed.CleanedContent; } } - if (!string.IsNullOrWhiteSpace(parsed.CleanedContent)) - messages.Add(ChatMessage.Assistant(parsed.CleanedContent)); - messages.Add(new ChatMessage { Role = "assistant", ToolCalls = parsed.ToolCalls }); + messages.Add(BuildAssistantToolCallMessage( + parsed.CleanedContent, + response.ReasoningContent, + parsed.ToolCalls)); await ExecuteToolCallsCoreAsync(parsed.ToolCalls, messages, ct); accumulatedContent = null; continue; @@ -168,7 +170,7 @@ public ToolCallLoop( { accumulatedContent ??= new StringBuilder(); accumulatedContent.Append(response.Content); - messages.Add(ChatMessage.Assistant(response.Content)); + messages.Add(ChatMessage.Assistant(response.Content, response.ReasoningContent)); } messages.Add(ChatMessage.User(LengthRecoveryNudge)); lengthRecoveryCount++; @@ -186,7 +188,7 @@ public ToolCallLoop( } if (resultContent != null) - messages.Add(ChatMessage.Assistant(resultContent)); + messages.Add(ChatMessage.Assistant(resultContent, response.ReasoningContent)); return resultContent; } @@ -194,7 +196,13 @@ public ToolCallLoop( accumulatedContent = null; // 记录 assistant tool_call 消息 - messages.Add(new ChatMessage { Role = "assistant", ToolCalls = response.ToolCalls }); + messages.Add(new ChatMessage + { + Role = "assistant", + Content = response.Content, + ReasoningContent = response.ReasoningContent, + ToolCalls = response.ToolCalls, + }); await ExecuteToolCallsCoreAsync(response.ToolCalls!, messages, ct); } @@ -221,9 +229,10 @@ public ToolCallLoop( var finalParsed = TextToolCallParser.Parse(finalContent); if (finalParsed.ToolCalls.Count > 0) { - if (!string.IsNullOrWhiteSpace(finalParsed.CleanedContent)) - messages.Add(ChatMessage.Assistant(finalParsed.CleanedContent)); - messages.Add(new ChatMessage { Role = "assistant", ToolCalls = finalParsed.ToolCalls }); + messages.Add(BuildAssistantToolCallMessage( + finalParsed.CleanedContent, + finalResponse?.ReasoningContent, + finalParsed.ToolCalls)); await ExecuteToolCallsCoreAsync(finalParsed.ToolCalls, messages, ct); // One more LLM call to summarize @@ -241,11 +250,11 @@ public ToolCallLoop( var (summaryResponse, _) = await InvokeLlmAsync(provider, summaryRequest, ct); var summaryContent = summaryResponse?.Content; if (summaryContent != null) - messages.Add(ChatMessage.Assistant(summaryContent)); + messages.Add(ChatMessage.Assistant(summaryContent, summaryResponse?.ReasoningContent)); return summaryContent; } - messages.Add(ChatMessage.Assistant(finalContent)); + messages.Add(ChatMessage.Assistant(finalContent, finalResponse?.ReasoningContent)); } return finalContent; @@ -568,6 +577,18 @@ private async Task ExecuteToolCallsCoreAsync( messages.Add(BuildToolResultMessage(result.CallId, result.Result)); } + private static ChatMessage BuildAssistantToolCallMessage( + string? content, + string? reasoningContent, + IReadOnlyList toolCalls) => + new() + { + Role = "assistant", + Content = string.IsNullOrWhiteSpace(content) ? null : content, + ReasoningContent = reasoningContent, + ToolCalls = toolCalls, + }; + /// /// Detects whether the LLM response was truncated by the output token limit. /// Different providers use different finish_reason strings for this condition. diff --git a/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs b/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs index bfa985db3..91ee6a9e5 100644 --- a/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs +++ b/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs @@ -13,6 +13,9 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using OpenAIAssistantChatMessage = OpenAI.Chat.AssistantChatMessage; +using OpenAIChatMessageContentPart = OpenAI.Chat.ChatMessageContentPart; +using OpenAIChatToolCall = OpenAI.Chat.ChatToolCall; namespace Aevatar.AI.LLMProviders.MEAI; @@ -242,10 +245,15 @@ public async IAsyncEnumerable ChatStreamAsync( meaiMsg.Contents.Add(new FunctionResultContent(msg.ToolCallId, BuildToolResultPayload(msg))); } + if (msg.Role == "assistant" && !string.IsNullOrEmpty(msg.ReasoningContent)) + meaiMsg.Contents.Add(new TextReasoningContent(msg.ReasoningContent)); + // Handle assistant tool calls if (msg.ToolCalls is { Count: > 0 }) { meaiMsg.Contents.Clear(); + if (!string.IsNullOrEmpty(msg.ReasoningContent)) + meaiMsg.Contents.Add(new TextReasoningContent(msg.ReasoningContent)); if (msg.ContentParts is { Count: > 0 }) AppendContentParts(meaiMsg, msg.ContentParts); else if (msg.Content != null) @@ -268,12 +276,135 @@ public async IAsyncEnumerable ChatStreamAsync( } } + AttachOpenAIRawRepresentationForReasoning(meaiMsg, msg); result.Add(meaiMsg); } return result; } + private static void AttachOpenAIRawRepresentationForReasoning( + Microsoft.Extensions.AI.ChatMessage meaiMessage, + Aevatar.AI.Abstractions.LLMProviders.ChatMessage sourceMessage) + { + if (sourceMessage.Role != "assistant" || string.IsNullOrEmpty(sourceMessage.ReasoningContent)) + return; + + var rawMessage = BuildOpenAIAssistantMessage(sourceMessage); + + // OpenAI SDK currently exposes no typed reasoning_content property for + // assistant history messages, so we fall back to its experimental Patch + // API to inject the field into the serialized payload. The Patch surface + // is marked SCME0001 (model serialization may evolve), and the + // reasoning_content field name is also undocumented — any future SDK + // bump that renames the field, drops Patch, or changes its serializer + // shape would silently strip reasoning context from request history. + // + // Mitigations layered here: + // 1. SDK version is pinned in Directory.Packages.props + // (`OpenAI Version="2.9.1"`) so an unintentional minor/major bump + // cannot land without an explicit dependency-bump PR. + // 2. AIComponentCoverageTests asserts the serialized JSON contains + // `"reasoning_content":"..."` after ConvertMessages — that integration + // test fails the build the moment the patch stops landing in the + // payload, which is the only signal that survives an SDK bump. + // 3. The try/catch below degrades a wire-format break into "no + // reasoning replay" rather than crashing the entire chat call. + // + // Long-term: when the OpenAI SDK exposes a typed reasoning_content + // property (tracked at github.com/openai/openai-dotnet — file an issue + // referencing this code path before bumping), retire the Patch hack + // and remove the SCME0001 suppression. + try + { +#pragma warning disable SCME0001 + rawMessage.Patch.Set("$.reasoning_content"u8, sourceMessage.ReasoningContent); +#pragma warning restore SCME0001 + meaiMessage.RawRepresentation = rawMessage; + } + catch (Exception) + { + // Reasoning continuity is best-effort; on a SDK contract break we + // proceed without it rather than throwing. The source message's + // ReasoningContent stays in our own state and can be re-rendered + // through other paths if needed. + } + } + + private static OpenAIAssistantChatMessage BuildOpenAIAssistantMessage( + Aevatar.AI.Abstractions.LLMProviders.ChatMessage sourceMessage) + { + var contentParts = BuildOpenAITextContentParts(sourceMessage); + var toolCalls = BuildOpenAIToolCalls(sourceMessage); + + OpenAIAssistantChatMessage rawMessage; + if (contentParts.Count > 0) + { + rawMessage = new OpenAIAssistantChatMessage(contentParts); + foreach (var toolCall in toolCalls) + rawMessage.ToolCalls.Add(toolCall); + return rawMessage; + } + + rawMessage = toolCalls.Count > 0 + ? new OpenAIAssistantChatMessage(toolCalls) + : new OpenAIAssistantChatMessage(string.Empty); + return rawMessage; + } + + private static List BuildOpenAITextContentParts( + Aevatar.AI.Abstractions.LLMProviders.ChatMessage sourceMessage) + { + var contentParts = new List(); + if (sourceMessage.ContentParts is { Count: > 0 }) + { + foreach (var part in sourceMessage.ContentParts) + { + if (part.Kind == ContentPartKind.Text && part.Text != null) + contentParts.Add(OpenAIChatMessageContentPart.CreateTextPart(part.Text)); + } + } + + if (contentParts.Count == 0 && sourceMessage.Content != null) + contentParts.Add(OpenAIChatMessageContentPart.CreateTextPart(sourceMessage.Content)); + + return contentParts; + } + + private static List BuildOpenAIToolCalls( + Aevatar.AI.Abstractions.LLMProviders.ChatMessage sourceMessage) + { + var toolCalls = new List(); + if (sourceMessage.ToolCalls is not { Count: > 0 }) + return toolCalls; + + foreach (var toolCall in sourceMessage.ToolCalls) + { + toolCalls.Add(OpenAIChatToolCall.CreateFunctionToolCall( + toolCall.Id, + toolCall.Name, + BinaryData.FromString(NormalizeToolArgumentsJson(toolCall.ArgumentsJson)))); + } + + return toolCalls; + } + + private static string NormalizeToolArgumentsJson(string? argumentsJson) + { + if (string.IsNullOrWhiteSpace(argumentsJson)) + return "{}"; + + try + { + using var _ = JsonDocument.Parse(argumentsJson); + return argumentsJson; + } + catch (JsonException) + { + return "{}"; + } + } + private static void AppendContentParts( Microsoft.Extensions.AI.ChatMessage message, IReadOnlyList parts) @@ -440,6 +571,7 @@ private static LLMResponse ConvertResponse(Microsoft.Extensions.AI.ChatResponse // ChatResponse.Messages contains all reply messages var lastMessage = response.Messages.LastOrDefault(); var content = ExtractMessageText(lastMessage); + var reasoningContent = ExtractReasoningContent(lastMessage); List? toolCalls = null; List? contentParts = null; @@ -478,6 +610,7 @@ private static LLMResponse ConvertResponse(Microsoft.Extensions.AI.ChatResponse return new LLMResponse { Content = content, + ReasoningContent = reasoningContent, ContentParts = contentParts, ToolCalls = toolCalls, Usage = usage, @@ -507,6 +640,22 @@ private static LLMResponse ConvertResponse(Microsoft.Extensions.AI.ChatResponse : string.Concat(textParts); } + private static string? ExtractReasoningContent(Microsoft.Extensions.AI.ChatMessage? message) + { + if (message?.Contents is not { Count: > 0 }) + return null; + + var reasoningParts = message.Contents + .OfType() + .Select(part => part.Text) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToList(); + + return reasoningParts.Count == 0 + ? null + : string.Concat(reasoningParts); + } + private static ToolCall ConvertFunctionCall(FunctionCallContent functionCall) { var argsJson = functionCall.Arguments != null diff --git a/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs b/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs index 20a42b755..7f69a83d2 100644 --- a/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs +++ b/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs @@ -288,6 +288,7 @@ private static Aevatar.AI.Abstractions.LLMProviders.ChatMessage StripNonTextCont { Role = m.Role, Content = fallbackContent, + ReasoningContent = m.ReasoningContent, ContentParts = null, // Tornado doesn't use ContentParts ToolCallId = m.ToolCallId, ToolCalls = m.ToolCalls, diff --git a/src/Aevatar.AI.ToolProviders.Lark/Aevatar.AI.ToolProviders.Lark.csproj b/src/Aevatar.AI.ToolProviders.Lark/Aevatar.AI.ToolProviders.Lark.csproj index 2ded96758..891efe0bc 100644 --- a/src/Aevatar.AI.ToolProviders.Lark/Aevatar.AI.ToolProviders.Lark.csproj +++ b/src/Aevatar.AI.ToolProviders.Lark/Aevatar.AI.ToolProviders.Lark.csproj @@ -6,6 +6,9 @@ Aevatar.AI.ToolProviders.Lark Aevatar.AI.ToolProviders.Lark + + + diff --git a/src/Aevatar.AI.ToolProviders.Lark/ILarkCardKitClient.cs b/src/Aevatar.AI.ToolProviders.Lark/ILarkCardKitClient.cs new file mode 100644 index 000000000..b7c1ef0d0 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Lark/ILarkCardKitClient.cs @@ -0,0 +1,92 @@ +namespace Aevatar.AI.ToolProviders.Lark; + +/// +/// Wrapper over Lark CardKit 2.0 REST endpoints (/open-apis/cardkit/v1/...) routed +/// through the NyxID API-key proxy. Used by the streaming reply sink to render LLM output +/// as a streaming card instead of repeatedly editing a plain message — Lark caps the latter +/// at ~15-20 edits per message (error 230072), CardKit element-content updates have no +/// equivalent cap. +/// +/// +/// All methods return raw JSON response bodies; the caller is responsible for parsing +/// (mirrors 's pattern). Required scopes on the Lark bot app: +/// cardkit:card:read and cardkit:card:write. The actual card_id binding +/// to a chat happens via with +/// msg_type=interactive and content={"type":"card","data":{"card_id":"..."}}. +/// +public interface ILarkCardKitClient +{ + /// + /// Creates a new card entity. Returns raw JSON containing card_id at + /// data.card_id; the caller extracts it before subsequent updates. Endpoint: + /// POST /open-apis/cardkit/v1/cards. + /// + Task CreateCardAsync(string token, LarkCardKitCreateRequest request, CancellationToken ct); + + /// + /// Streams text into a single card element with typewriter rendering on the client. Updates + /// are ordered by ; stale + /// sequences are rejected by Lark deterministically. Endpoint: + /// PUT /open-apis/cardkit/v1/cards/{card_id}/elements/{element_id}/content. + /// + Task StreamElementContentAsync(string token, LarkCardKitStreamElementContentRequest request, CancellationToken ct); + + /// + /// Toggles card-level settings (e.g. close streaming_mode at end-of-turn so the + /// typewriter cursor disappears). Endpoint: + /// PATCH /open-apis/cardkit/v1/cards/{card_id}/settings. + /// + Task SetCardSettingsAsync(string token, LarkCardKitSettingsRequest request, CancellationToken ct); + + /// + /// Replaces the full card content. Used at end-of-turn to swap the streaming + /// element template for a finalized layout (e.g. plain markdown without the cursor). + /// Endpoint: PUT /open-apis/cardkit/v1/cards/{card_id}. + /// + Task UpdateCardAsync(string token, LarkCardKitUpdateRequest request, CancellationToken ct); +} + +/// +/// Card source type. card_json for an inline card definition; template for a +/// stored Lark template id reference. +/// +/// +/// JSON-serialized card payload. For card_json, the inline card schema; for +/// template, the template id and bound variables. +/// +public sealed record LarkCardKitCreateRequest(string Type, string DataJson); + +/// Card entity id returned by CreateCardAsync. +/// +/// Element id within the card to stream into. By convention the card's streaming element +/// is named streaming_main; both producer (this client) and consumer (the card +/// template) must agree on it. +/// +/// Latest accumulated text to render into the element. +/// +/// Monotonically increasing sequence number for ordering. Lark rejects stale writes; +/// the sink owns this counter and pre-increments before every call. +/// +/// Optional uuid for safe retry under network loss. +public sealed record LarkCardKitStreamElementContentRequest( + string CardId, + string ElementId, + string Content, + long Sequence, + string? IdempotencyKey = null); + +/// +/// JSON-serialized settings patch, e.g. {"streaming_mode": false} to close streaming. +/// +public sealed record LarkCardKitSettingsRequest( + string CardId, + string SettingsJson, + long Sequence, + string? IdempotencyKey = null); + +/// JSON-serialized full card replacement. +public sealed record LarkCardKitUpdateRequest( + string CardId, + string CardJson, + long Sequence, + string? IdempotencyKey = null); diff --git a/src/Aevatar.AI.ToolProviders.Lark/LarkCardKitClient.cs b/src/Aevatar.AI.ToolProviders.Lark/LarkCardKitClient.cs new file mode 100644 index 000000000..ea9997904 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.Lark/LarkCardKitClient.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Aevatar.AI.ToolProviders.NyxId; + +namespace Aevatar.AI.ToolProviders.Lark; + +public sealed class LarkCardKitClient : ILarkCardKitClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly LarkToolOptions _options; + private readonly NyxIdApiClient _nyxClient; + + public LarkCardKitClient(LarkToolOptions options, NyxIdApiClient nyxClient) + { + _options = options; + _nyxClient = nyxClient; + } + + public Task CreateCardAsync(string token, LarkCardKitCreateRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.DataJson)) + { + throw new ArgumentException( + $"{nameof(request.DataJson)} must be non-empty.", + nameof(request.DataJson)); + } + + // Lark CardKit 2.0 `data` shape depends on `type`: + // • card_json → a JSON-encoded STRING of the card schema (escape-serialized). + // Lark rejects an inline object here with code 9499 "Invalid parameter + // type in json: Data" (verified against open-apis/cardkit/v1/cards on + // 2026-05-08; an empty stub plus type=card_json with inline object 400s, + // same body with `data` as a string returns 200 + a usable card_id). + // • card_id → a STRING (the existing card_id we want to clone). + // • template → an INLINE object: `{ template_id, template_variable }`. + // + // The earlier implementation always reparsed DataJson as an inline JsonNode, + // which 400s on the card_json path and silently drove the production + // CardKit-streaming default-on rollout into the legacy edit-message fallback + // for every turn (PR #562 / PR #590 incident, 2026-05-08 06:51 UTC). + var body = new Dictionary + { + ["type"] = request.Type, + ["data"] = request.Type switch + { + "card_json" or "card_id" => request.DataJson, + _ => ParseJsonObject(request.DataJson, nameof(request.DataJson)), + }, + }; + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + "open-apis/cardkit/v1/cards", + "POST", + JsonSerializer.Serialize(body, JsonOptions), + extraHeaders: null, + ct); + } + + public Task StreamElementContentAsync(string token, LarkCardKitStreamElementContentRequest request, CancellationToken ct) + { + var body = new Dictionary + { + ["content"] = request.Content, + ["sequence"] = request.Sequence, + }; + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + body["uuid"] = request.IdempotencyKey.Trim(); + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + $"open-apis/cardkit/v1/cards/{Uri.EscapeDataString(request.CardId)}/elements/{Uri.EscapeDataString(request.ElementId)}/content", + "PUT", + JsonSerializer.Serialize(body, JsonOptions), + extraHeaders: null, + ct); + } + + public Task SetCardSettingsAsync(string token, LarkCardKitSettingsRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.SettingsJson)) + { + throw new ArgumentException( + $"{nameof(request.SettingsJson)} must be non-empty.", + nameof(request.SettingsJson)); + } + + // PATCH /cards/{id}/settings expects `settings` to be a JSON-encoded STRING (same + // contract surprise as POST /cards's `data` field — verified against the live + // endpoint on 2026-05-08, inline object 400s with code 9499 "Invalid parameter + // type in json: Settings", same body with stringified settings returns 200). + var body = new Dictionary + { + ["settings"] = request.SettingsJson, + ["sequence"] = request.Sequence, + }; + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + body["uuid"] = request.IdempotencyKey.Trim(); + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + $"open-apis/cardkit/v1/cards/{Uri.EscapeDataString(request.CardId)}/settings", + "PATCH", + JsonSerializer.Serialize(body, JsonOptions), + extraHeaders: null, + ct); + } + + public Task UpdateCardAsync(string token, LarkCardKitUpdateRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.CardJson)) + { + throw new ArgumentException( + $"{nameof(request.CardJson)} must be non-empty.", + nameof(request.CardJson)); + } + // Validate the inner shape parses (we don't keep the parsed tree — Lark wants the + // raw string per the contract below — but malformed JSON should fail here, not as + // a 400 from Lark's parser). Surfaces JsonException to the caller. + _ = JsonNode.Parse(request.CardJson) + ?? throw new ArgumentException( + $"{nameof(request.CardJson)} parsed to null.", + nameof(request.CardJson)); + + // PUT /cards/{id} replaces the card payload with a fresh `{type, data}` envelope + // (Lark's full-card-replace contract — verified 2026-05-08: inline `card: {schema:..., body:...}` + // 400s with `card.type is required`, same body wrapped as `card: {type: "card_json", + // data: ""}` returns 200). Caller passes the inner card body + // as a JSON string in `CardJson`; we wrap it here so the call site stays simple. + var body = new Dictionary + { + ["card"] = new Dictionary + { + ["type"] = "card_json", + ["data"] = request.CardJson, + }, + ["sequence"] = request.Sequence, + }; + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + body["uuid"] = request.IdempotencyKey.Trim(); + + return _nyxClient.ProxyRequestAsync( + token, + _options.ProviderSlug, + $"open-apis/cardkit/v1/cards/{Uri.EscapeDataString(request.CardId)}", + "PUT", + JsonSerializer.Serialize(body, JsonOptions), + extraHeaders: null, + ct); + } + + /// + /// Lark CardKit accepts inline objects for data/settings/card; we + /// take a JSON string from the caller (typed DTOs in the streaming sink) and re-embed + /// as a so System.Text.Json serializes it in line rather than + /// double-encoding as a string. + /// + private static JsonNode? ParseJsonObject(string json, string paramName) + { + if (string.IsNullOrWhiteSpace(json)) + throw new ArgumentException($"{paramName} must be non-empty JSON.", paramName); + return JsonNode.Parse(json) + ?? throw new ArgumentException($"{paramName} parsed to null.", paramName); + } +} diff --git a/src/Aevatar.AI.ToolProviders.Lark/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Lark/ServiceCollectionExtensions.cs index 1376efa0e..7540d06b0 100644 --- a/src/Aevatar.AI.ToolProviders.Lark/ServiceCollectionExtensions.cs +++ b/src/Aevatar.AI.ToolProviders.Lark/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddLarkTools( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; diff --git a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdAgentToolSource.cs b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdAgentToolSource.cs index 2c2249818..f8cb1efe9 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdAgentToolSource.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdAgentToolSource.cs @@ -39,8 +39,8 @@ public Task> DiscoverToolsAsync(CancellationToken ct = return Task.FromResult>([]); } - IReadOnlyList tools = - [ + var tools = new List + { new NyxIdAccountTool(_client), new NyxIdStatusTool(_client), new NyxIdProfileTool(_client), @@ -64,12 +64,21 @@ public Task> DiscoverToolsAsync(CancellationToken ct = new NyxIdAdminTool(_client), new NyxIdSearchCapabilitiesTool(_specCatalog), new NyxIdProxyExecuteTool(_specCatalog, _client, _logger as ILogger), - ]; + }; + + // ssh_exec is opt-in. The tool's Auto/RequiresApproval=true contract relies on the + // host wiring an approval middleware around tool execution; without that middleware, + // a host would let the LLM run remote shell commands directly. Make hosts opt in + // explicitly so that exposure is a deliberate decision. + if (_options.EnableSshExecTool) + { + tools.Add(new NyxIdSshExecTool(_client, _logger)); + } _logger.LogInformation( - "NyxID tools registered ({Count} tools, base URL: {BaseUrl})", - tools.Count, _options.BaseUrl); + "NyxID tools registered ({Count} tools, base URL: {BaseUrl}, ssh_exec={SshEnabled})", + tools.Count, _options.BaseUrl, _options.EnableSshExecTool); - return Task.FromResult(tools); + return Task.FromResult>(tools); } } diff --git a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs index cc50cf001..a16051f1a 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -172,6 +173,22 @@ public async Task ProxyRequestAsync( return await SendAsync(request, ct); } + // ─── SSH ─── + + /// + /// Executes a shell command on a remote SSH host through NyxID's SSH gateway. + /// + /// NyxID service identifier or slug for an SSH-typed service (endpoint registered as ssh://host:port). + /// JSON body matching NyxID's SshExecRequest: { command, principal, timeout_secs }. + /// + /// Mirrors POST /api/v1/ssh/{service_id}/exec. NyxID enforces a 1 MB output cap, a max 300s + /// timeout, an 8192-char command length, and a built-in dangerous-command filter. Non-SSH services + /// reject this route, so callers must filter to SSH-typed slugs before invoking (the agent tool + /// surfaces this in its description so the LLM does not call HTTP-typed services here). + /// + public Task SshExecAsync(string token, string serviceIdOrSlug, string body, CancellationToken ct) => + PostAsync(token, $"/api/v1/ssh/{Uri.EscapeDataString(serviceIdOrSlug)}/exec", body, ct); + // ─── API Keys ─── public Task ListApiKeysAsync(string token, CancellationToken ct) => @@ -245,7 +262,7 @@ public Task UpdateUserServiceAsync(string token, string id, string body, // ─── Proxy (additions) ─── public Task DiscoverProxyServicesAsync(string token, CancellationToken ct) => - GetAsync(token, "/api/v1/proxy/services?per_page=100", ct); + GetAsync(token, NyxIdLlmCatalogRoutes.ProxyServicesPath, ct); // ─── API Keys (additions) ─── @@ -736,6 +753,15 @@ private async Task SendAsync(HttpRequestMessage request, CancellationTok return content; } + catch (OperationCanceledException) + { + // Cancellation is a control-flow signal, not an HTTP failure. Wrapping it as + // {"error":true,"message":"A task was canceled."} would swallow per-call hard + // timeouts that callers (e.g. NyxIdSshExecTool) install on top of the LLM run's + // CT. Let the exception bubble so callers can map their own cancellation source + // to a clearer error payload (PR #562 SSH timeout incident, 2026-05-08). + throw; + } catch (Exception ex) { _logger.LogWarning(ex, "NyxID API request exception: {Method} {Url}", request.Method, request.RequestUri); diff --git a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdToolOptions.cs b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdToolOptions.cs index 9ccf617f9..704c29e2a 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/NyxIdToolOptions.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/NyxIdToolOptions.cs @@ -16,4 +16,14 @@ public sealed class NyxIdToolOptions /// unavailable but specialized NyxID tools continue to work. /// public string? SpecFetchToken { get; set; } + + /// + /// When true, expose the ssh_exec tool to the LLM. Off by default + /// because ssh_exec can run arbitrary commands on a remote host: hosts + /// without an approval middleware in their tool execution pipeline would let + /// the model run shell commands directly. Hosts that have wired the approval + /// middleware (or that explicitly accept the risk for an internal-only deploy + /// like the share-ops Lark bot) opt in by setting this to true. + /// + public bool EnableSshExecTool { get; set; } } diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdCodeExecuteTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdCodeExecuteTool.cs index dc76e6495..bbf5b95be 100644 --- a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdCodeExecuteTool.cs +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdCodeExecuteTool.cs @@ -80,9 +80,50 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c _logger.LogInformation("[code_execute] {Language} via slug={Slug}", language, slug); - var body = JsonSerializer.Serialize(new { language, code }); - var result = await _client.ProxyRequestAsync(token, slug, "/run", "POST", body, null, ct); - return result; + // Current chrono-sandbox-service exposes /execute with body { language, script }. + // Older sandbox builds expose /run with body { language, code }. We POST the modern + // contract first; on a NyxID-proxy 404 (slug exists but upstream returned 404, which + // indicates the path doesn't exist on that backend), retry the legacy contract so a + // host still pinned to the old sandbox keeps working. + var modernBody = JsonSerializer.Serialize(new { language = language, script = code }); + var modernResult = await _client.ProxyRequestAsync(token, slug, "/execute", "POST", modernBody, null, ct); + if (!IsUpstream404(modernResult)) + return modernResult; + + _logger.LogInformation( + "[code_execute] {Slug} returned 404 on /execute; retrying legacy /run contract", slug); + var legacyBody = JsonSerializer.Serialize(new { language = language, code = code }); + return await _client.ProxyRequestAsync(token, slug, "/run", "POST", legacyBody, null, ct); + } + + /// + /// NyxID's proxy wraps non-2xx upstream responses as + /// {"error":true,"status":N,"body":"..."}. A 404 here means "slug exists but the + /// requested path doesn't" — the case where we should retry the legacy contract. + /// Service-not-found / catalog-miss surfaces with a different shape and is left alone. + /// + private static bool IsUpstream404(string proxyResponse) + { + if (string.IsNullOrWhiteSpace(proxyResponse)) + return false; + try + { + using var doc = JsonDocument.Parse(proxyResponse); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + if (!root.TryGetProperty("error", out var errProp) || + errProp.ValueKind != JsonValueKind.True) + { + return false; + } + return root.TryGetProperty("status", out var statusProp) && + statusProp.ValueKind == JsonValueKind.Number && + statusProp.GetInt32() == 404; + } + catch (JsonException) + { + return false; + } } /// diff --git a/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdSshExecTool.cs b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdSshExecTool.cs new file mode 100644 index 000000000..a5a3f1f67 --- /dev/null +++ b/src/Aevatar.AI.ToolProviders.NyxId/Tools/NyxIdSshExecTool.cs @@ -0,0 +1,259 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.AI.ToolProviders.NyxId.Tools; + +/// +/// Execute shell commands on remote SSH-typed NyxID services. The HTTP proxy +/// () cannot reach SSH endpoints — those services +/// are registered as ssh://host:port and require this dedicated tool. +/// +public sealed class NyxIdSshExecTool : IAgentTool +{ + private const int DefaultTimeoutSecs = 30; + private const int MaxTimeoutSecs = 300; + + private readonly NyxIdApiClient _client; + private readonly ILogger _logger; + + public NyxIdSshExecTool(NyxIdApiClient client, ILogger? logger = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? NullLogger.Instance; + } + + public string Name => "ssh_exec"; + + public string Description => + "Execute a shell command on a remote SSH host via a NyxID-bound SSH service. " + + "The target service must be SSH-typed (its endpoint starts with 'ssh://'); " + + "HTTP services use 'nyxid_proxy' instead. Use 'nyxid_proxy' (no slug) or " + + "'nyxid_services' to discover services and read their endpoint scheme. " + + "NyxID enforces an 8 KiB command length, a 1 MiB stdout/stderr cap, a 300s " + + "timeout, and blocks dangerous commands (rm -rf /, mkfs, dd if=, fork bombs)."; + + public ToolApprovalMode ApprovalMode => ToolApprovalMode.Auto; + + /// SSH execution can mutate the remote host arbitrarily; always request approval. + public bool? RequiresApproval(string argumentsJson) => true; + + public string ParametersSchema => """ + { + "type": "object", + "required": ["service", "command", "principal"], + "properties": { + "service": { + "type": "string", + "description": "NyxID service slug or UUID. Must be SSH-typed (endpoint scheme 'ssh://')." + }, + "command": { + "type": "string", + "description": "Shell command to run on the remote host. Max 8192 chars." + }, + "principal": { + "type": "string", + "description": "Unix username on the remote host (e.g. 'ubuntu', 'root')." + }, + "timeout_secs": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "description": "Max execution time in seconds. Defaults to 30, capped at 300." + } + } + } + """; + + public async Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) + { + var token = AgentToolRequestContext.TryGet(LLMRequestMetadataKeys.NyxIdAccessToken); + if (string.IsNullOrWhiteSpace(token)) + return """{"error":"No NyxID access token available. User must be authenticated."}"""; + + var args = ToolArgs.Parse(argumentsJson); + if (args.HasParseError) + return $"{{\"error\":\"Failed to parse tool arguments\",\"detail\":{JsonSerializer.Serialize(args.ParseError)}}}"; + + var service = args.Str("service") ?? args.Str("slug"); + var command = args.Str("command"); + var principal = args.Str("principal"); + var timeoutSecs = ParseTimeoutSecs(args.Str("timeout_secs")); + + if (string.IsNullOrWhiteSpace(service) || + string.IsNullOrWhiteSpace(command) || + string.IsNullOrWhiteSpace(principal)) + { + return """{"error":"'service', 'command', and 'principal' are required."}"""; + } + + // NyxID's /api/v1/ssh/{id}/exec route keys on `catalog_service_id`, NOT on the user + // service slug or user-service id. The CLI's `nyxid ssh exec` does the same hop + // internally (cli/src/commands/ssh.rs:resolve_ssh_service_id). Without this resolve, + // a slug like 'sg-office-network' or its user-service uuid both 404 with NyxID's + // generic "Service not found" envelope, and the LLM gets stuck retrying the same + // wrong path. + var catalogServiceId = await ResolveCatalogServiceIdAsync(token, service, ct); + + _logger.LogInformation( + "[ssh_exec] service={Service} catalogId={CatalogId} principal={Principal} timeoutSecs={Timeout}", + service, catalogServiceId, principal, timeoutSecs); + + var body = JsonSerializer.Serialize(new + { + command, + principal, + timeout_secs = timeoutSecs, + }); + + // Hard wall-clock cap: NyxID's HTTP response *should* arrive within + // `timeout_secs + a few seconds` (the server-side timer kicks in and returns + // `timed_out: true`). In practice we have observed the call hang well past 60s + // (NyxID SSH gateway / NodeAgent stuck on a stale session). Without a hard cap, + // the LLM run sits on a single tool call long enough to blow the run actor's + // turn budget and the user gets the generic "took too long" fallback instead of + // a usable error from this tool. Cap at `timeout_secs + 15s` so NyxID has a + // generous margin to return its own timeout response, then fail this tool with + // a clear "ssh_timeout" payload that the LLM can summarize for the user. + using var sshCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + sshCts.CancelAfter(TimeSpan.FromSeconds(timeoutSecs + 15)); + try + { + return await _client.SshExecAsync(token, catalogServiceId, body, sshCts.Token); + } + catch (OperationCanceledException) when (sshCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + _logger.LogWarning( + "[ssh_exec] hard timeout after {WallClockSecs}s waiting on NyxID for service={Service} catalogId={CatalogId}", + timeoutSecs + 15, service, catalogServiceId); + return $$"""{"error":"ssh_timeout","detail":"NyxID did not return an SSH exec response within {{timeoutSecs + 15}}s. The remote host or NyxID gateway is unresponsive; try again or pick a different host."}"""; + } + } + + /// + /// Resolve a slug or user-service id into the catalog_service_id required by NyxID's SSH + /// route. Mirrors the CLI's resolve_ssh_service_id: prefer the user-service entry's + /// catalog_service_id, otherwise fall back to the input (so a raw catalog id passed + /// directly still works). + /// + private async Task ResolveCatalogServiceIdAsync( + string token, string serviceIdOrSlug, CancellationToken ct) + { + try + { + // Direct lookup by user-service id or slug — NyxID's /keys/{x} accepts either. + var direct = await _client.GetServiceAsync(token, serviceIdOrSlug, ct); + var catalog = TryReadCatalogServiceId(direct); + if (!string.IsNullOrWhiteSpace(catalog)) + return catalog; + } + catch (Exception ex) + { + _logger.LogDebug( + ex, "[ssh_exec] direct /keys/{Service} lookup failed; falling back to list", serviceIdOrSlug); + } + + try + { + // List + match by slug — covers cases where direct lookup returns a wrapper without + // the field surfaced at the top level. + var listJson = await _client.ListServicesAsync(token, ct); + using var doc = JsonDocument.Parse(listJson); + var root = doc.RootElement; + + JsonElement entries = default; + var hasEntries = false; + if (root.ValueKind == JsonValueKind.Array) + { + entries = root; + hasEntries = true; + } + else if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("keys", out var keysProp) && + keysProp.ValueKind == JsonValueKind.Array) + { + entries = keysProp; + hasEntries = true; + } + + if (hasEntries) + { + foreach (var entry in entries.EnumerateArray()) + { + if (!MatchesService(entry, serviceIdOrSlug)) + continue; + if (entry.TryGetProperty("catalog_service_id", out var catalogProp) && + catalogProp.ValueKind == JsonValueKind.String) + { + var candidate = catalogProp.GetString(); + if (!string.IsNullOrWhiteSpace(candidate)) + return candidate; + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "[ssh_exec] /keys list lookup failed for {Service}", serviceIdOrSlug); + } + + // Caller passed a raw catalog id directly (the CLI also falls through this way). + return serviceIdOrSlug; + } + + private static string? TryReadCatalogServiceId(string keyResponse) + { + if (string.IsNullOrWhiteSpace(keyResponse)) + return null; + try + { + using var doc = JsonDocument.Parse(keyResponse); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return null; + // NyxID's /keys/{x} returns either the entry directly or wrapped in { error: ... }. + if (root.TryGetProperty("error", out _)) + return null; + if (root.TryGetProperty("catalog_service_id", out var prop) && + prop.ValueKind == JsonValueKind.String) + { + return prop.GetString(); + } + } + catch (JsonException) + { + return null; + } + return null; + } + + private static bool MatchesService(JsonElement entry, string idOrSlug) + { + if (entry.ValueKind != JsonValueKind.Object) + return false; + foreach (var key in new[] { "id", "_id", "slug", "service_slug" }) + { + if (entry.TryGetProperty(key, out var prop) && + prop.ValueKind == JsonValueKind.String && + string.Equals(prop.GetString(), idOrSlug, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + private static int ParseTimeoutSecs(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return DefaultTimeoutSecs; + if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + { + return DefaultTimeoutSecs; + } + return Math.Clamp(v, 1, MaxTimeoutSecs); + } +} diff --git a/src/Aevatar.AI.ToolProviders.Ornn/Aevatar.AI.ToolProviders.Ornn.csproj b/src/Aevatar.AI.ToolProviders.Ornn/Aevatar.AI.ToolProviders.Ornn.csproj index 2822ab8b2..7da9a2238 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/Aevatar.AI.ToolProviders.Ornn.csproj +++ b/src/Aevatar.AI.ToolProviders.Ornn/Aevatar.AI.ToolProviders.Ornn.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnAgentToolSource.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnAgentToolSource.cs index 255e1a178..191679732 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnAgentToolSource.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnAgentToolSource.cs @@ -26,16 +26,15 @@ public OrnnAgentToolSource( public Task> DiscoverToolsAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_options.BaseUrl)) - { - _logger.LogDebug("Ornn base URL not configured, skipping Ornn skill tools"); - return Task.FromResult>([]); - } - + // ornn_search_skills must always be advertised to the LLM regardless of how the + // deployment configured the Ornn slug, otherwise the model loses the typed entry + // point and resorts to nyxid_proxy path-guessing (issue #530). OrnnSkillClient + // routes through NyxID's proxy, so the slug — not a hardcoded base URL — is what + // determines reachability. IReadOnlyList tools = [new OrnnSearchSkillsTool(_client)]; _logger.LogInformation( - "Ornn search tool registered (base URL: {BaseUrl})", _options.BaseUrl); + "Ornn search tool registered (NyxID slug: {Slug})", _options.NyxIdSlug); return Task.FromResult(tools); } } diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnOptions.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnOptions.cs index 323ec228d..25938edf8 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnOptions.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnOptions.cs @@ -3,6 +3,15 @@ namespace Aevatar.AI.ToolProviders.Ornn; /// Ornn 技能平台配置。 public sealed class OrnnOptions { - /// Ornn API 基础地址(如 https://ornn.example.com)。 - public string? BaseUrl { get; set; } + /// + /// NyxID-bound service slug used to reach the Ornn skill API. Default "ornn-api" + /// matches the slug under which the chrono-ornn HTTP service is currently registered in + /// the production NyxID catalog (verified via nyxid service list). The bare + /// "ornn" slug is the SPA frontend that only serves HTML, not the API. + /// All requests route through NyxID's proxy: + /// {NyxID}/api/v1/proxy/s/{slug}/api/v1/... + /// Deployments using a non-default registration name should set + /// Aevatar:Ornn:NyxIdSlug in configuration. + /// + public string NyxIdSlug { get; set; } = "ornn-api"; } diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSearchSkillsTool.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSearchSkillsTool.cs index c5fe9f58e..1d87cec95 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSearchSkillsTool.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSearchSkillsTool.cs @@ -14,9 +14,13 @@ public sealed class OrnnSearchSkillsTool : IAgentTool public string Name => "ornn_search_skills"; public string Description => - "Search for skills in the user's Ornn skill library. " + - "Proactively search when the user's request involves specialized tasks like translation, content generation, or analysis. " + - "Returns skill names and descriptions. Then use use_skill with the skill name to load and activate a matching skill."; + "Search the user's Ornn skill library for matching skill packages. " + + "Call this FIRST whenever the user mentions a named skill (in quotes, slug-like, or Title Case), " + + "asks for a specialized capability (translation, content generation, analysis, network or device discovery, " + + "domain workflows), or says \"挂载/use/load this skill\". " + + "Prefer this over nyxid_proxy / nyxid_search_capabilities path-guessing — those discover service APIs, " + + "this discovers ready-made instruction packages. " + + "Returns matching skill names + descriptions; follow up with use_skill to load and activate one."; public string ParametersSchema => """ { @@ -64,7 +68,8 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c foreach (var skill in result.Items) { - var tags = skill.Metadata?.Tags != null ? string.Join(", ", skill.Metadata.Tags) : ""; + var rawTags = skill.Tags ?? skill.Metadata?.Tags; + var tags = rawTags != null ? string.Join(", ", rawTags) : ""; var visibility = skill.IsPrivate ? "private" : "public"; lines.Add($"- **{skill.Name}** ({visibility}, {skill.Metadata?.Category ?? "unknown"})"); lines.Add($" {skill.Description}"); diff --git a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs index 4216bfab8..e5f8d5f37 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/OrnnSkillClient.cs @@ -1,16 +1,21 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; +using Aevatar.AI.ToolProviders.NyxId; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.AI.ToolProviders.Ornn; -/// Ornn Web API HTTP 客户端。 +/// +/// Ornn skill API client. Routes through NyxID's proxy so the Ornn upstream URL stays a +/// runtime concern (resolved by NyxID from the user's bound ornn-api service) rather +/// than a hardcoded constant. The public Ornn frontend URL only serves the SPA shell, so +/// direct calls return HTML for any path — the NyxID-routed path is the canonical surface +/// (issue #530 follow-up). +/// public sealed class OrnnSkillClient { - private readonly HttpClient _http; + private readonly NyxIdApiClient _nyxApi; private readonly OrnnOptions _options; private readonly ILogger _logger; @@ -20,10 +25,10 @@ public sealed class OrnnSkillClient DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - public OrnnSkillClient(OrnnOptions options, HttpClient? httpClient = null, ILogger? logger = null) + public OrnnSkillClient(OrnnOptions options, NyxIdApiClient nyxApi, ILogger? logger = null) { - _options = options; - _http = httpClient ?? new HttpClient(); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _nyxApi = nyxApi ?? throw new ArgumentNullException(nameof(nyxApi)); _logger = logger ?? NullLogger.Instance; } @@ -34,22 +39,35 @@ public async Task SearchSkillsAsync( string scope = "mixed", int page = 1, int pageSize = 20, + string mode = "keyword", CancellationToken ct = default) { - var baseUrl = _options.BaseUrl?.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(baseUrl)) - return new OrnnSearchResult { Items = [] }; + var normalizedMode = string.Equals(mode, "semantic", StringComparison.OrdinalIgnoreCase) + ? "semantic" + : "keyword"; + var normalizedScope = scope.ToLowerInvariant() is "public" or "private" or "mixed" + ? scope.ToLowerInvariant() + : "mixed"; + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 100); - var url = $"{baseUrl}/api/web/skill-search?query={Uri.EscapeDataString(query)}&mode=keyword&scope={Uri.EscapeDataString(scope)}&page={page}&pageSize={pageSize}"; - - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var path = $"/api/v1/skill-search?query={Uri.EscapeDataString(query)}&mode={normalizedMode}&scope={Uri.EscapeDataString(normalizedScope)}&page={page}&pageSize={pageSize}"; try { - using var response = await _http.SendAsync(request, ct); - response.EnsureSuccessStatusCode(); - var envelope = await response.Content.ReadFromJsonAsync>(JsonOptions, ct); + var response = await _nyxApi.ProxyRequestAsync( + token: accessToken, + slug: _options.NyxIdSlug, + path: path, + method: "GET", + body: null, + extraHeaders: null, + ct: ct); + + if (TryUnwrapNyxIdProxyError(response, out var proxyError)) + return new OrnnSearchResult { Items = [], Error = proxyError }; + + var envelope = JsonSerializer.Deserialize>(response, JsonOptions); return envelope?.Data ?? new OrnnSearchResult { Items = [] }; } catch (Exception ex) @@ -65,20 +83,23 @@ public async Task SearchSkillsAsync( string idOrName, CancellationToken ct = default) { - var baseUrl = _options.BaseUrl?.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(baseUrl)) - return null; - - var url = $"{baseUrl}/api/web/skills/{Uri.EscapeDataString(idOrName)}/json"; - - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var path = $"/api/v1/skills/{Uri.EscapeDataString(idOrName)}/json"; try { - using var response = await _http.SendAsync(request, ct); - response.EnsureSuccessStatusCode(); - var envelope = await response.Content.ReadFromJsonAsync>(JsonOptions, ct); + var response = await _nyxApi.ProxyRequestAsync( + token: accessToken, + slug: _options.NyxIdSlug, + path: path, + method: "GET", + body: null, + extraHeaders: null, + ct: ct); + + if (TryUnwrapNyxIdProxyError(response, out _)) + return null; + + var envelope = JsonSerializer.Deserialize>(response, JsonOptions); return envelope?.Data; } catch (Exception ex) @@ -87,6 +108,51 @@ public async Task SearchSkillsAsync( return null; } } + + /// + /// Detect the wrapped error envelope NyxIdApiClient.SendAsync emits when the upstream + /// returns non-2xx ({"error": true, "status": N, "body": "..."}) so callers see a + /// concise actionable message instead of a JsonException about the wrapper shape. + /// + private bool TryUnwrapNyxIdProxyError(string response, out string detail) + { + detail = string.Empty; + if (string.IsNullOrWhiteSpace(response)) + return false; + + try + { + using var document = JsonDocument.Parse(response); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("error", out var errorProp) || + errorProp.ValueKind != JsonValueKind.True) + { + return false; + } + + var status = root.TryGetProperty("status", out var statusProp) && + statusProp.ValueKind == JsonValueKind.Number + ? statusProp.GetInt32() + : 0; + + // 404 here means NyxID could not resolve `_options.NyxIdSlug` to an upstream — either + // the user has not bound an Ornn service to this slug, or the deployment's NyxID + // catalog uses a different slug name. The LLM can recover by guiding the user to + // bind the service or by retrying with a different slug; surface that hint instead + // of a bare "status=404". + detail = status == 404 + ? $"Ornn skill API not reachable: NyxID has no service bound to slug '{_options.NyxIdSlug}'. " + + "The user may need to connect their Ornn account via NyxID (nyxid_services action=create), " + + "or the deployment may need to override Aevatar:Ornn:NyxIdSlug." + : $"NyxID proxy returned status={status}."; + return true; + } + catch (JsonException) + { + return false; + } + } } // ─── DTOs ─── @@ -115,6 +181,7 @@ public sealed class OrnnSkillSummary public string? Name { get; set; } public string? Description { get; set; } public bool IsPrivate { get; set; } + public List? Tags { get; set; } public OrnnSkillMetadata? Metadata { get; set; } } diff --git a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs index 74d652929..0d645629c 100644 --- a/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs +++ b/src/Aevatar.AI.ToolProviders.Ornn/ServiceCollectionExtensions.cs @@ -9,15 +9,27 @@ namespace Aevatar.AI.ToolProviders.Ornn; public static class ServiceCollectionExtensions { /// - /// 注册 Ornn 技能工具系统。配置 BaseUrl 后,ornn_search_skills 自动注册, - /// 远程技能获取通过 IRemoteSkillFetcher 集成到统一的 use_skill 工具。 + /// 注册 Ornn 技能工具系统。ornn_search_skills 工具始终注册到 LLM;远程技能按需获取通过 + /// IRemoteSkillFetcher 集成到统一的 use_skill 工具。所有 Ornn API 调用通过 NyxID 的 + /// proxy 路由,因此调用方必须先注册 NyxIdApiClient(一般通过 AddNyxIdTools)。 /// + /// + /// We intentionally do NOT TryAdd a placeholder NyxIdToolOptions/NyxIdApiClient + /// here as a "safety net". Doing so would shadow the real registration when call order is + /// reversed: AddAevatarAIFeatures runs RegisterOrnnSkills early, and the + /// host's AddNyxIdTools (which carries the configured BaseUrl) lands afterwards — + /// since both use TryAddSingleton, the empty default would win and every NyxID call + /// would fail at runtime with "NyxID base URL is not configured" (production incident + /// 2026-05-08 caught the regression). Hosts that enable Ornn skills MUST call + /// AddNyxIdTools; if they don't, DI resolution fails fast at startup, which is the + /// signal we want. + /// public static IServiceCollection AddOrnnSkills( this IServiceCollection services, - Action configure) + Action? configure = null) { var options = new OrnnOptions(); - configure(options); + configure?.Invoke(options); services.TryAddSingleton(options); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.AI.ToolProviders.Skills/SkillRegistry.cs b/src/Aevatar.AI.ToolProviders.Skills/SkillRegistry.cs index ba0d5a0de..e14b9344c 100644 --- a/src/Aevatar.AI.ToolProviders.Skills/SkillRegistry.cs +++ b/src/Aevatar.AI.ToolProviders.Skills/SkillRegistry.cs @@ -9,45 +9,69 @@ namespace Aevatar.AI.ToolProviders.Skills; /// /// 统一技能注册表。管理来自所有来源(本地、远程)的技能。 -/// 线程安全,支持运行时动态注册(如远程技能缓存)。 +/// 线程安全,支持运行时动态注册(如远程技能缓存)以及基于 TTL 的失效语义。 /// public sealed class SkillRegistry { - private readonly Dictionary _skills = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _skills = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); + private readonly TimeProvider _timeProvider; - /// 注册单个技能。同名覆盖。 + public SkillRegistry() + : this(TimeProvider.System) + { + } + + public SkillRegistry(TimeProvider timeProvider) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + private sealed record CachedSkill(SkillDefinition Definition, DateTimeOffset FetchedAt); + + /// 注册单个技能。同名覆盖。FetchedAt 戳记为当前时间。 public void Register(SkillDefinition skill) { lock (_lock) - _skills[skill.Name] = skill; + _skills[skill.Name] = new CachedSkill(skill, _timeProvider.GetUtcNow()); } - /// 批量注册技能。 + /// 批量注册技能。共享同一 FetchedAt 时间戳。 public void RegisterRange(IEnumerable skills) { lock (_lock) { + var now = _timeProvider.GetUtcNow(); foreach (var skill in skills) - _skills[skill.Name] = skill; + _skills[skill.Name] = new CachedSkill(skill, now); } } - /// 按名称查找技能。 - public bool TryGet(string nameOrId, out SkillDefinition? skill) + /// + /// 按名称查找技能。 + /// + /// 技能名称或 RemoteId。 + /// 命中时的技能定义。 + /// 缓存最长有效期。null 表示不检查 TTL(始终算新鲜)。 + /// 命中且未过期返回 true。 + public bool TryGet(string nameOrId, out SkillDefinition? skill, TimeSpan? maxAge = null) { lock (_lock) { - if (_skills.TryGetValue(nameOrId, out skill)) + if (_skills.TryGetValue(nameOrId, out var cached) && IsFresh(cached, maxAge)) + { + skill = cached.Definition; return true; + } // 尝试按 RemoteId 匹配 - foreach (var s in _skills.Values) + foreach (var entry in _skills.Values) { - if (s.RemoteId != null && - s.RemoteId.Equals(nameOrId, StringComparison.OrdinalIgnoreCase)) + if (entry.Definition.RemoteId != null && + entry.Definition.RemoteId.Equals(nameOrId, StringComparison.OrdinalIgnoreCase) && + IsFresh(entry, maxAge)) { - skill = s; + skill = entry.Definition; return true; } } @@ -57,18 +81,31 @@ public bool TryGet(string nameOrId, out SkillDefinition? skill) } } + private bool IsFresh(CachedSkill cached, TimeSpan? maxAge) + { + if (maxAge is null) return true; + // TTL only applies to remote skills — local skills are baked in at registration + // and don't go stale. Without this carve-out, a 5-minute TTL would expire local + // entries too and `use_skill` would silently lose them after the first cache window. + if (cached.Definition.Source != SkillSource.Remote) return true; + return _timeProvider.GetUtcNow() - cached.FetchedAt < maxAge.Value; + } + /// 获取所有已注册技能。 public IReadOnlyList GetAll() { lock (_lock) - return [.. _skills.Values]; + return _skills.Values.Select(c => c.Definition).ToArray(); } /// 获取所有允许 LLM 自动调用的技能。 public IReadOnlyList GetModelInvocable() { lock (_lock) - return _skills.Values.Where(s => s.IsModelInvocable).ToList(); + return _skills.Values + .Select(c => c.Definition) + .Where(s => s.IsModelInvocable) + .ToList(); } /// 已注册技能数量。 @@ -85,7 +122,10 @@ public string BuildSystemPromptSection() { List skills; lock (_lock) - skills = _skills.Values.Where(s => s.IsModelInvocable).ToList(); + skills = _skills.Values + .Select(c => c.Definition) + .Where(s => s.IsModelInvocable) + .ToList(); if (skills.Count == 0) return ""; diff --git a/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs b/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs index 70eab9d82..d55ff6b79 100644 --- a/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs +++ b/src/Aevatar.AI.ToolProviders.Skills/UseSkillTool.cs @@ -17,6 +17,13 @@ namespace Aevatar.AI.ToolProviders.Skills; /// public sealed class UseSkillTool : IAgentTool { + /// + /// 远程技能缓存的最大保留时间。超过该窗口后下一次 use_skill 会重新拉取,确保 Ornn + /// 上的更新最多在该窗口内对 aevatar 可见。窗口太短会让常用 skill 频繁打 NyxID + /// proxy;太长会让 Ornn 上的更新拖很久才生效。5 分钟是当前的折中值。 + /// + public static readonly TimeSpan RemoteSkillCacheTtl = TimeSpan.FromMinutes(5); + private readonly SkillRegistry _registry; private readonly IRemoteSkillFetcher? _remoteFetcher; @@ -67,11 +74,13 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c // ─── 查找技能 ─── SkillDefinition? skill = null; - // 1. 从注册表查找(本地 + 已缓存的远程) - if (_registry.TryGet(skillName, out skill) && skill != null) + // 1. 从注册表查找(本地 + 缓存未过期的远程) + // 远程技能传 maxAge=RemoteSkillCacheTtl 触发 TTL 校验:超过窗口的缓存视为不存在, + // 让下面的 fetcher 路径重拉。本地技能没有 RemoteId,仍然命中(视作永远新鲜)。 + if (_registry.TryGet(skillName, out skill, maxAge: RemoteSkillCacheTtl) && skill != null) return BuildSkillResponse(skill, args); - // 2. 尝试从远程拉取 + // 2. 缓存未命中或已过期 → 从远程拉取 if (_remoteFetcher != null) { var token = AgentToolRequestContext.TryGet(LLMRequestMetadataKeys.NyxIdAccessToken); @@ -80,7 +89,7 @@ public async Task ExecuteAsync(string argumentsJson, CancellationToken c skill = await _remoteFetcher.FetchSkillAsync(token, skillName, ct); if (skill != null) { - // 缓存到注册表,后续调用不再远程拉取 + // Register 会用当前时间刷新 FetchedAt 戳记,下次 TTL 窗口重新计时。 _registry.Register(skill); return BuildSkillResponse(skill, args); } diff --git a/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs b/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs index 322637e36..ed10f08ed 100644 --- a/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs @@ -47,7 +47,12 @@ public sealed class AevatarAIFeatureOptions public bool EnableMCPTools { get; set; } public bool EnableSkills { get; set; } public bool EnableOrnnSkills { get; set; } - public string? OrnnBaseUrl { get; set; } + /// + /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to + /// chrono-ornn's canonical "ornn" when null/empty. Override only if the deployment's + /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service). + /// + public string? OrnnNyxIdSlug { get; set; } public IAevatarSecretsStore? SecretsStore { get; set; } public string? ApiKey { get; set; } public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; } @@ -888,10 +893,17 @@ private static void RegisterServiceInvokeTools(IServiceCollection services, Aeva private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options) { - if (string.IsNullOrWhiteSpace(options.OrnnBaseUrl)) - return; - - services.AddOrnnSkills(o => o.BaseUrl = options.OrnnBaseUrl); + // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy + // (slug defaults to chrono-ornn's canonical "ornn") so the upstream Ornn URL is + // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already + // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog + // entry matching the slug. Deployments override the slug only when their NyxID + // catalog re-registered the service under a non-default name. + services.AddOrnnSkills(o => + { + if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug)) + o.NyxIdSlug = options.OrnnNyxIdSlug; + }); } private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options) diff --git a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs index c200c24c2..63a9a922d 100644 --- a/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs +++ b/src/Aevatar.Mainnet.Host.Api/Hosting/MainnetHostBuilderExtensions.cs @@ -107,6 +107,16 @@ public static WebApplicationBuilder AddAevatarMainnetHost( ?? builder.Configuration["Cli:App:NyxId:Authority"] ?? builder.Configuration["Aevatar:Authentication:Authority"]; o.SpecFetchToken = builder.Configuration["Aevatar:NyxId:SpecFetchToken"]; + // Opt-in: only the mainnet host (which runs the channel relay's approval-aware + // tool execution pipeline) advertises ssh_exec to the LLM. Other hosts that pull + // in NyxId tools (CLI, workflow runner) leave this off so a generic agent can't + // shell into a remote without an approval gate. Defaults to false in + // NyxIdToolOptions; flip via Aevatar:NyxId:EnableSshExecTool=true if a + // deployment opts in. + if (bool.TryParse(builder.Configuration["Aevatar:NyxId:EnableSshExecTool"], out var enableSsh)) + o.EnableSshExecTool = enableSsh; + else + o.EnableSshExecTool = true; // mainnet default: enabled (Lark bot needs it) }); builder.Services.AddLarkTools(o => { diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.json b/src/Aevatar.Mainnet.Host.Api/appsettings.json index 925ceb152..a7f98a046 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.json @@ -13,6 +13,9 @@ "EnableDebugDiagnostics": true, "ResponseTimeoutSeconds": 120 } + }, + "Ornn": { + "NyxIdSlug": "ornn-api" } }, "Ornn": { diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/UserLlmContracts.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/UserLlmContracts.cs index 18f04012c..7fb06fb74 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/UserLlmContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/UserLlmContracts.cs @@ -74,6 +74,7 @@ public static class NyxIdLlmProviderSource { public const string GatewayProvider = "gateway_provider"; public const string UserService = "user_service"; + public const string ProxyService = "proxy_service"; } public interface IUserLlmCatalogPort diff --git a/src/Aevatar.Studio.Application/Studio/Services/NyxIdLlmServiceCatalogParser.cs b/src/Aevatar.Studio.Application/Studio/Services/NyxIdLlmServiceCatalogParser.cs index 79d8ac183..0a75abae8 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/NyxIdLlmServiceCatalogParser.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/NyxIdLlmServiceCatalogParser.cs @@ -5,6 +5,8 @@ namespace Aevatar.Studio.Application.Studio.Services; public static class NyxIdLlmServiceCatalogParser { + private const string ReadyStatus = "ready"; + public static NyxIdLlmServicesResult ParseServicesResult(string response) { using var document = ParseSuccessDocument(response); @@ -29,6 +31,48 @@ public static NyxIdLlmServicesResult ParseServicesResult(string response) return new NyxIdLlmServicesResult(services, setupHint); } + public static NyxIdLlmServicesResult MergeProxyRouteCandidates( + NyxIdLlmServicesResult result, + string proxyServicesResponse) + { + ArgumentNullException.ThrowIfNull(result); + + var proxyCandidates = ParseProxyRouteCandidates(proxyServicesResponse); + if (proxyCandidates.Count == 0) + return result; + + var merged = result.Services.ToList(); + foreach (var candidate in proxyCandidates) + { + var duplicateIndex = FindMatchingServiceIndex(merged, candidate); + if (duplicateIndex >= 0) + { + if (ShouldPreferService(candidate, merged[duplicateIndex])) + merged[duplicateIndex] = candidate; + + continue; + } + + merged.Add(candidate); + } + + return result with { Services = merged }; + } + + public static IReadOnlyList ParseProxyRouteCandidates(string response) + { + using var document = ParseSuccessDocument(response); + var services = new List(); + foreach (var item in EnumerateProxyServiceEntries(document.RootElement)) + { + var service = TryParseProxyRouteCandidate(item); + if (service is not null) + services.Add(service); + } + + return services; + } + public static NyxIdLlmService ParseProvisionedService(string response) { using var document = ParseSuccessDocument(response); @@ -42,6 +86,93 @@ public static NyxIdLlmService ParseProvisionedService(string response) return ParseService(root); } + private static IEnumerable EnumerateProxyServiceEntries(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var item in root.EnumerateArray()) + yield return item; + yield break; + } + + if (root.ValueKind != JsonValueKind.Object) + yield break; + + foreach (var propertyName in new[] { "services", "custom_services", "customServices", "items", "data" }) + { + if (TryGetProperty(root, propertyName) is not { ValueKind: JsonValueKind.Array } array) + continue; + + foreach (var item in array.EnumerateArray()) + yield return item; + } + } + + private static NyxIdLlmService? TryParseProxyRouteCandidate(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + return null; + + var slug = ReadOptionalString( + element, + "slug", + "service_slug", + "serviceSlug", + "provider_slug", + "providerSlug"); + if (string.IsNullOrWhiteSpace(slug)) + return null; + + var displayName = ReadOptionalString( + element, + "display_name", + "displayName", + "name", + "service_name", + "serviceName", + "provider_name", + "providerName") + ?? slug; + + if (!LooksLikeLlmRouteCandidate(element, slug, displayName)) + return null; + + var routeValue = NormalizeProxyRouteValue( + ReadOptionalString( + element, + "proxy_url_slug", + "proxyUrlSlug", + "proxy_url", + "proxyUrl", + "route_value", + "routeValue"), + slug); + if (string.IsNullOrWhiteSpace(routeValue)) + return null; + + var status = ResolveProxyStatus(element); + var explicitAllowed = ReadAllowedOverride(element); + var models = ReadStringArray(element, "models", "available_models", "availableModels"); + return new NyxIdLlmService( + UserServiceId: ReadOptionalString( + element, + "user_service_id", + "userServiceId", + "service_id", + "serviceId", + "id") + ?? slug, + ServiceSlug: slug.Trim(), + DisplayName: displayName.Trim(), + RouteValue: routeValue, + DefaultModel: ReadOptionalString(element, "default_model", "defaultModel"), + Models: models, + Status: status, + Source: NyxIdLlmProviderSource.ProxyService, + Allowed: explicitAllowed ?? string.Equals(status, ReadyStatus, StringComparison.OrdinalIgnoreCase), + Description: ReadOptionalString(element, "description")); + } + public static string NormalizeProvisionEndpointId(string provisionEndpointId) { var candidate = provisionEndpointId.Trim(); @@ -225,6 +356,163 @@ private static UserLlmPresetActivation ParseActivation(JsonElement preset) }; } + private static bool LooksLikeLlmRouteCandidate(JsonElement element, string slug, string displayName) + { + var signals = new[] + { + slug, + displayName, + ReadOptionalString(element, "service_category", "serviceCategory", "category"), + ReadOptionalString(element, "description", "summary"), + ReadOptionalString(element, "docs_url", "docsUrl"), + ReadOptionalString(element, "openapi_url", "openapiUrl"), + }; + + if (signals.Any(ContainsNegativeLlmRouteSignal)) + return false; + + if (signals.Any(ContainsStrongLlmRouteSignal)) + return true; + + return signals + .SelectMany(EnumerateWeakLlmRouteSignals) + .Distinct(StringComparer.Ordinal) + .Count() >= 2; + } + + private static bool ContainsStrongLlmRouteSignal(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + var normalized = value.Trim().ToLowerInvariant(); + return normalized.Contains("llm", StringComparison.Ordinal) || + normalized.Contains("chat/completions", StringComparison.Ordinal) || + normalized.Contains("chat completions", StringComparison.Ordinal) || + normalized.Contains("chat completion", StringComparison.Ordinal) || + normalized.Contains("completions api", StringComparison.Ordinal) || + normalized.Contains("large language model", StringComparison.Ordinal) || + normalized.Contains("language model", StringComparison.Ordinal); + } + + private static IEnumerable EnumerateWeakLlmRouteSignals(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + yield break; + + var normalized = value.Trim().ToLowerInvariant(); + if (normalized.Contains("openai", StringComparison.Ordinal)) + yield return "openai"; + if (normalized.Contains("gpt", StringComparison.Ordinal)) + yield return "gpt"; + if (normalized.Contains("claude", StringComparison.Ordinal)) + yield return "claude"; + } + + private static bool ContainsNegativeLlmRouteSignal(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + var normalized = value.Trim().ToLowerInvariant(); + return normalized.Contains("not an llm", StringComparison.Ordinal) || + normalized.Contains("not a llm", StringComparison.Ordinal) || + normalized.Contains("not llm", StringComparison.Ordinal) || + normalized.Contains("non-llm", StringComparison.Ordinal) || + normalized.Contains("not a language model", StringComparison.Ordinal) || + normalized.Contains("not a large language model", StringComparison.Ordinal); + } + + private static string? NormalizeProxyRouteValue(string? value, string slug) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + normalized = slug.Trim(); + + if (Uri.TryCreate(normalized, UriKind.Absolute, out var absolute)) + normalized = Uri.UnescapeDataString(absolute.AbsolutePath); + + if (normalized.StartsWith("//", StringComparison.Ordinal) || + normalized.Contains("://", StringComparison.Ordinal)) + { + return null; + } + + normalized = StripRouteTemplateSuffix(normalized.Trim()); + if (string.IsNullOrWhiteSpace(normalized)) + return null; + + if (normalized.StartsWith("/", StringComparison.Ordinal)) + return normalized; + + return normalized.Contains('/', StringComparison.Ordinal) + ? "/" + normalized + : $"/api/v1/proxy/s/{normalized}"; + } + + private static string StripRouteTemplateSuffix(string value) + { + var normalized = value.TrimEnd('/'); + var templateIndex = normalized.LastIndexOf("/{", StringComparison.Ordinal); + if (templateIndex >= 0 && normalized.EndsWith("}", StringComparison.Ordinal)) + normalized = normalized[..templateIndex]; + + if (normalized.EndsWith("/*", StringComparison.Ordinal)) + normalized = normalized[..^2]; + + return normalized.TrimEnd('/'); + } + + private static string ResolveProxyStatus(JsonElement element) + { + var status = ReadOptionalString(element, "status"); + if (!string.IsNullOrWhiteSpace(status)) + return status.Trim(); + + var connected = ReadOptionalBool(element, "connected") == true; + var hasNodeBinding = ReadOptionalBool(element, "has_node_binding", "hasNodeBinding") == true; + var requiresConnection = ReadOptionalBool(element, "requires_connection", "requiresConnection"); + return connected || hasNodeBinding || requiresConnection == false + ? ReadyStatus + : "not_connected"; + } + + private static int FindMatchingServiceIndex(IReadOnlyList services, NyxIdLlmService candidate) + { + for (var index = 0; index < services.Count; index++) + { + if (ShareServiceKey(services[index], candidate)) + return index; + } + + return -1; + } + + private static bool ShareServiceKey(NyxIdLlmService left, NyxIdLlmService right) => + EqualIfPresent(left.RouteValue, right.RouteValue) || + EqualIfPresent(left.UserServiceId, right.UserServiceId) || + EqualIfPresent(left.ServiceSlug, right.ServiceSlug); + + private static bool EqualIfPresent(string? left, string? right) => + !string.IsNullOrWhiteSpace(left) && + !string.IsNullOrWhiteSpace(right) && + string.Equals(left.Trim(), right.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool ShouldPreferService(NyxIdLlmService candidate, NyxIdLlmService existing) => + ServiceSelectabilityRank(candidate) > ServiceSelectabilityRank(existing); + + private static int ServiceSelectabilityRank(NyxIdLlmService service) + { + var ready = string.Equals(service.Status, ReadyStatus, StringComparison.OrdinalIgnoreCase); + return (service.Allowed, ready) switch + { + (true, true) => 3, + (true, false) => 2, + (false, true) => 1, + _ => 0, + }; + } + private static JsonElement? TryGetProperty(JsonElement element, params string[] names) { foreach (var name in names) @@ -276,6 +564,18 @@ private static string ReadRequiredString(JsonElement element, params string[] pr return null; } + private static bool? ReadAllowedOverride(JsonElement element) + { + var allowed = ReadOptionalBool(element, "allowed"); + if (allowed is not null) + return allowed; + + if (TryGetProperty(element, "credential_source", "credentialSource") is { ValueKind: JsonValueKind.Object } source) + return ReadOptionalBool(source, "allowed"); + + return null; + } + private static int? TryReadInt(JsonElement element, string propertyName) => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number ? property.GetInt32() diff --git a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj index 097700073..582413743 100644 --- a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj +++ b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Aevatar.Studio.Hosting/NyxId/NyxIdLlmCatalogHttpClient.cs b/src/Aevatar.Studio.Hosting/NyxId/NyxIdLlmCatalogHttpClient.cs index 8cf88fecd..c2e17b274 100644 --- a/src/Aevatar.Studio.Hosting/NyxId/NyxIdLlmCatalogHttpClient.cs +++ b/src/Aevatar.Studio.Hosting/NyxId/NyxIdLlmCatalogHttpClient.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Text; +using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Services; using Microsoft.Extensions.Configuration; @@ -46,7 +47,8 @@ public async Task GetServicesAsync(string bearerToken, C } EnsureSuccess(response, "NyxID LLM services"); - return NyxIdLlmServiceCatalogParser.ParseServicesResult(response.Body); + var result = NyxIdLlmServiceCatalogParser.ParseServicesResult(response.Body); + return await MergeProxyRouteCandidatesAsync(result, bearerToken, ct).ConfigureAwait(false); } public async Task ProvisionAsync( @@ -111,6 +113,41 @@ private void EnsureSuccess(NyxIdHttpResult response, string operation) throw new InvalidOperationException($"{operation} request failed."); } + private async Task MergeProxyRouteCandidatesAsync( + NyxIdLlmServicesResult result, + string bearerToken, + CancellationToken ct) + { + try + { + var response = await SendNyxIdAsync( + HttpMethod.Get, + NyxIdLlmCatalogRoutes.ProxyServicesPath, + bearerToken, + body: null, + ct).ConfigureAwait(false); + if ((int)response.StatusCode is < 200 or > 299) + { + _logger.LogWarning( + "NyxID proxy services endpoint returned {StatusCode}: {Body}", + response.StatusCode, + response.Body.Length > 500 ? response.Body[..500] : response.Body); + return result; + } + + return NyxIdLlmServiceCatalogParser.MergeProxyRouteCandidates(result, response.Body); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge NyxID proxy services into LLM route catalog"); + return result; + } + } + private string? ResolveNyxIdAuthorityBase() { var authority = _configuration["Cli:App:NyxId:Authority"] diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs index 3a490f465..ec82f2105 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -125,11 +125,12 @@ public async Task AddEntryAsync( public async Task RemoveEntryAsync(string id, CancellationToken ct = default) { - var state = await ReadProjectedStateAsync(ct); + var actorId = ResolveWriteActorId(); + var state = await ReadProjectedStateAsync(actorId, ct); if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) return false; - var actor = await EnsureWriteActorAsync(ct); + var actor = await EnsureWriteActorAsync(actorId, ct); var evt = new MemoryEntryRemovedEvent { EntryId = id }; await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, evt, ct); return true; @@ -142,9 +143,13 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat { doc = await GetAsync(ct); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) { - _logger.LogWarning(ex, "Failed to load user memory for prompt injection"); + _logger.LogWarning(ex, "Failed to read user memory prompt section; continuing without user memory."); return string.Empty; } @@ -198,7 +203,15 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat private async Task ReadProjectedStateAsync(CancellationToken ct) { - var actorId = ResolveWriteActorId(); + var actorId = TryResolveWriteActorId(); + if (actorId is null) + return null; + + return await ReadProjectedStateAsync(actorId, ct); + } + + private async Task ReadProjectedStateAsync(string actorId, CancellationToken ct) + { var document = await _documentReader.GetAsync(actorId, ct); if (document?.StateRoot == null || !document.StateRoot.Is(UserMemoryState.Descriptor)) @@ -209,15 +222,27 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat // ── Actor resolution ── + private string? TryResolveScopeId() + => _scopeResolver.Resolve()?.ScopeId; + private string ResolveScopeId() - => _scopeResolver.Resolve()?.ScopeId + => TryResolveScopeId() ?? throw new InvalidOperationException( "User memory store requires an authenticated user scope. No scope could be resolved."); + private string? TryResolveWriteActorId() + { + var scopeId = TryResolveScopeId(); + return scopeId is null ? null : WriteActorIdPrefix + scopeId; + } + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); private Task EnsureWriteActorAsync(CancellationToken ct) => - _bootstrap.EnsureAsync(ResolveWriteActorId(), ct); + EnsureWriteActorAsync(ResolveWriteActorId(), ct); + + private Task EnsureWriteActorAsync(string actorId, CancellationToken ct) => + _bootstrap.EnsureAsync(actorId, ct); private static string GenerateId() { diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/AevatarPlatformHostBuilderExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/AevatarPlatformHostBuilderExtensions.cs index 73baf1cd7..dd0e436b4 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/AevatarPlatformHostBuilderExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/AevatarPlatformHostBuilderExtensions.cs @@ -46,7 +46,7 @@ public static WebApplicationBuilder AddAevatarPlatform( aiOptions.EnableMCPTools = true; aiOptions.EnableSkills = true; aiOptions.EnableOrnnSkills = true; - aiOptions.OrnnBaseUrl = builder.Configuration["Ornn:BaseUrl"]; + aiOptions.OrnnNyxIdSlug = builder.Configuration["Aevatar:Ornn:NyxIdSlug"]; aiOptions.EnableWebTools = true; aiOptions.WebSearchNyxIdSlug = builder.Configuration["Aevatar:WebSearch:NyxIdSlug"]; aiOptions.WebSearchApiBaseUrl = builder.Configuration["Aevatar:WebSearch:ApiBaseUrl"]; diff --git a/test/Aevatar.AI.Tests/AIComponentCoverageTests.cs b/test/Aevatar.AI.Tests/AIComponentCoverageTests.cs index f39dc39ad..d9d09bc69 100644 --- a/test/Aevatar.AI.Tests/AIComponentCoverageTests.cs +++ b/test/Aevatar.AI.Tests/AIComponentCoverageTests.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Runtime.CompilerServices; using System.Reflection; +using System.ClientModel.Primitives; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.Core.Chat; @@ -52,6 +53,114 @@ public void ChatHistory_ShouldTruncateExportImportAndBuildMessages() imported.Count.Should().Be(0); } + [Fact] + public void ChatHistory_ShouldPreserveReasoningContentOnExportImport() + { + var history = new ChatHistory(); + history.Add(new AevatarChatMessage { Role = "assistant", Content = "hello", ReasoningContent = "thinking..." }); + history.Add(new AevatarChatMessage { Role = "user", Content = "follow-up" }); + + var exported = history.Export(); + exported[0].ReasoningContent.Should().Be("thinking..."); + exported[1].ReasoningContent.Should().BeNull(); + + var imported = new ChatHistory(); + imported.Import(exported); + imported.Messages[0].ReasoningContent.Should().Be("thinking..."); + imported.Messages[1].ReasoningContent.Should().BeNull(); + } + + [Fact] + public async Task MEAILLMProvider_ConvertMessages_ShouldIncludeReasoningContent() + { + IReadOnlyList? capturedMessages = null; + var client = new StubChatClient + { + OnGetResponse = (messages, _, _) => + { + capturedMessages = messages.ToList(); + return Task.FromResult(new ChatResponse(new MeaiChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + var provider = new MEAILLMProvider("meai-reasoning-outbound", client); + await provider.ChatAsync(new LLMRequest + { + Messages = + [ + new AevatarChatMessage { Role = "user", Content = "hi" }, + new AevatarChatMessage { Role = "assistant", Content = "thought", ReasoningContent = "reasoning-text" }, + ], + }); + + capturedMessages.Should().NotBeNull(); + capturedMessages!.Should().HaveCount(2); + var assistantMsg = capturedMessages[1]; + assistantMsg.Role.Should().Be(ChatRole.Assistant); + assistantMsg.Contents.OfType().Should().ContainSingle() + .Which.Text.Should().Be("reasoning-text"); + assistantMsg.RawRepresentation.Should().BeAssignableTo(); + + var openAiMessages = OpenAI.Chat.MicrosoftExtensionsAIChatExtensions + .AsOpenAIChatMessages(capturedMessages!) + .ToList(); + var assistantJson = ModelReaderWriter.Write(openAiMessages[1]).ToString(); + assistantJson.Should().Contain("\"reasoning_content\":\"reasoning-text\""); + } + + [Fact] + public async Task MEAILLMProvider_ConvertMessages_ShouldIncludeReasoningContentWithToolCalls() + { + IReadOnlyList? capturedMessages = null; + var client = new StubChatClient + { + OnGetResponse = (messages, _, _) => + { + capturedMessages = messages.ToList(); + return Task.FromResult(new ChatResponse(new MeaiChatMessage(ChatRole.Assistant, "ok"))); + }, + }; + + var provider = new MEAILLMProvider("meai-reasoning-tools", client); + await provider.ChatAsync(new LLMRequest + { + Messages = + [ + new AevatarChatMessage { Role = "user", Content = "hi" }, + new AevatarChatMessage + { + Role = "assistant", + Content = "using tool", + ReasoningContent = "thinking about tools", + ToolCalls = + [ + new Aevatar.AI.Abstractions.LLMProviders.ToolCall + { + Id = "tc1", Name = "search", ArgumentsJson = "{}", + }, + ], + }, + new AevatarChatMessage { Role = "tool", ToolCallId = "tc1", Content = "result" }, + ], + }); + + capturedMessages.Should().NotBeNull(); + var assistantWithTools = capturedMessages![1]; + assistantWithTools.Role.Should().Be(ChatRole.Assistant); + assistantWithTools.Contents.OfType().Should().ContainSingle() + .Which.Text.Should().Be("thinking about tools"); + assistantWithTools.Contents.OfType().Should().ContainSingle(); + assistantWithTools.RawRepresentation.Should().BeAssignableTo(); + + var openAiMessages = OpenAI.Chat.MicrosoftExtensionsAIChatExtensions + .AsOpenAIChatMessages(capturedMessages!) + .ToList(); + var assistantJson = ModelReaderWriter.Write(openAiMessages[1]).ToString(); + assistantJson.Should().Contain("\"reasoning_content\":\"thinking about tools\""); + assistantJson.Should().Contain("\"tool_calls\""); + assistantJson.Should().Contain("\"name\":\"search\""); + } + [Fact] public void PromptTemplate_Render_ShouldApplyDefaultsAndRuntimeAndExamples() { @@ -575,6 +684,40 @@ public void TornadoProvider_MapRequest_ShouldLeaveMetadataAndSamplingUnset_WhenR mappedRequest.MaxTokens.Should().BeNull(); } + [Fact] + public void TornadoProvider_StripNonTextContentParts_ShouldPreserveReasoningAndToolCalls() + { + var stripped = InvokePrivateStatic( + typeof(TornadoLLMProvider), + "StripNonTextContentParts", + new AevatarChatMessage + { + Role = "assistant", + Content = "fallback", + ReasoningContent = "thinking", + ContentParts = + [ + ContentPart.TextPart("visible"), + ContentPart.ImagePart("aW1n", "image/png"), + ], + ToolCalls = + [ + new Aevatar.AI.Abstractions.LLMProviders.ToolCall + { + Id = "tc-1", + Name = "lookup", + ArgumentsJson = "{}", + }, + ], + }); + + stripped.Content.Should().Contain("visible"); + stripped.Content.Should().Contain("image content was attached"); + stripped.ReasoningContent.Should().Be("thinking"); + stripped.ToolCalls.Should().ContainSingle() + .Which.Id.Should().Be("tc-1"); + } + [Fact] public void TornadoProvider_MapResponseAndToolCallConverters_ShouldHandleSparsePayloads() { diff --git a/test/Aevatar.AI.Tests/ChatRuntimeStreamingBufferTests.cs b/test/Aevatar.AI.Tests/ChatRuntimeStreamingBufferTests.cs index 3f76021f4..653689b55 100644 --- a/test/Aevatar.AI.Tests/ChatRuntimeStreamingBufferTests.cs +++ b/test/Aevatar.AI.Tests/ChatRuntimeStreamingBufferTests.cs @@ -157,6 +157,88 @@ public async Task ChatStreamAsync_WhenStreamReturnsToolCall_ShouldExecuteToolAnd m.Content == "RESULT:{\"q\":\"lark\"}").Should().BeTrue(); } + [Fact] + public async Task ChatStreamAsync_WhenToolCallRoundHasReasoning_ShouldPreserveItInFollowUpRequest() + { + var provider = new QueuedStreamingProvider( + [ + [ + new LLMStreamChunk { DeltaReasoningContent = "thinking-before-tool" }, + new LLMStreamChunk { DeltaContent = "checking" }, + new LLMStreamChunk + { + DeltaToolCall = new ToolCall + { + Id = "tc-reasoning", + Name = "lookup", + ArgumentsJson = "{\"q\":\"sg\"}", + }, + }, + ], + [ + new LLMStreamChunk { DeltaContent = "done" }, + ], + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("lookup", args => $"RESULT:{args}")); + var runtime = CreateRuntime(provider, streamBufferCapacity: 2, tools: tools); + + await foreach (var _ in runtime.ChatStreamAsync("hello", maxToolRounds: 2)) + { + } + + provider.StreamRequests.Should().HaveCount(2); + var assistantToolCallMessage = provider.StreamRequests[1].Messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Id == "tc-reasoning"); + assistantToolCallMessage.Content.Should().Be("checking"); + assistantToolCallMessage.ReasoningContent.Should().Be("thinking-before-tool"); + } + + [Fact] + public async Task ChatStreamAsync_WhenTextToolCallRoundHasReasoning_ShouldPreserveItInFollowUpRequest() + { + var provider = new QueuedStreamingProvider( + [ + [ + new LLMStreamChunk { DeltaReasoningContent = "thinking-before-text-tool" }, + new LLMStreamChunk + { + DeltaContent = """ + I will search now. + + + lark + + + """, + }, + ], + [ + new LLMStreamChunk { DeltaContent = "done" }, + ], + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("lookup", args => $"RESULT:{args}")); + var runtime = CreateRuntime(provider, streamBufferCapacity: 2, tools: tools); + + await foreach (var _ in runtime.ChatStreamAsync("hello", maxToolRounds: 2)) + { + } + + provider.StreamRequests.Should().HaveCount(2); + var assistantToolCallMessage = provider.StreamRequests[1].Messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Name == "lookup"); + assistantToolCallMessage.Content.Should().Be("I will search now."); + assistantToolCallMessage.ReasoningContent.Should().Be("thinking-before-text-tool"); + provider.StreamRequests[1].Messages.Count(m => + m.Role == "assistant" && + m.ToolCalls is { Count: > 0 }).Should().Be(1); + } + [Fact] public async Task ChatStreamAsync_WhenFinalRoundParsesTextToolCall_ShouldIncludeToolResultInSummaryRequest() { @@ -174,6 +256,7 @@ public async Task ChatStreamAsync_WhenFinalRoundParsesTextToolCall_ShouldInclude }, ], [ + new LLMStreamChunk { DeltaReasoningContent = "thinking-before-final-text-tool" }, new LLMStreamChunk { DeltaContent = """ @@ -205,6 +288,12 @@ public async Task ChatStreamAsync_WhenFinalRoundParsesTextToolCall_ShouldInclude provider.StreamRequests[2].Messages.Any(m => m.Role == "tool" && m.Content == "RESULT:{\"q\":\"final\"}").Should().BeTrue(); + var assistantToolCallMessage = provider.StreamRequests[2].Messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Name == "lookup" && + m.ReasoningContent == "thinking-before-final-text-tool"); + assistantToolCallMessage.ReasoningContent.Should().Be("thinking-before-final-text-tool"); } [Fact] @@ -349,6 +438,37 @@ public async Task ChatStreamAsync_WhenLlmMiddlewareTerminates_ShouldEmitSyntheti provider.StreamCallCount.Should().Be(0); } + [Fact] + public async Task ChatStreamAsync_WhenLlmMiddlewareTerminates_ShouldEmitReasoningContentChunk() + { + var provider = new StreamingProvider(["ignored"]); + var runtime = CreateRuntime( + provider, + streamBufferCapacity: 2, + llmMiddlewares: + [ + new DelegateLlmCallMiddleware((context, _) => + { + context.Terminate = true; + context.Response = new LLMResponse + { + Content = "answer", + ReasoningContent = "thinking-step", + }; + return Task.CompletedTask; + }), + ]); + var chunks = new List(); + + await foreach (var chunk in runtime.ChatStreamAsync("hello")) + chunks.Add(chunk); + + chunks.Should().Contain(x => x.DeltaReasoningContent == "thinking-step"); + chunks.Should().Contain(x => x.DeltaContent == "answer"); + chunks.Should().Contain(x => x.IsLast); + provider.StreamCallCount.Should().Be(0); + } + [Fact] public async Task ChatStreamAsync_WhenProviderEmitsEmptyNonTerminalChunk_ShouldFilterItOut() { diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index dfcc8349d..50fbc0167 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -1036,7 +1036,7 @@ public async Task HandleRelayWebhookAsync_ShouldDispatchCardAction_ToConversatio "sender":{"platform_id":"ou_user_b","display_name":"Builder User"}, "content":{ "content_type":"card_action", - "text":"{\"value\":{\"agent_builder_action\":\"create_daily_report\"},\"form_value\":{\"github_username\":\"eanzhao\",\"schedule_time\":\"09:00\"}}" + "text":"{\"value\":{\"agent_builder_action\":\"create_daily\"},\"form_value\":{\"github_username\":\"eanzhao\",\"schedule_time\":\"09:00\"}}" } } """; @@ -1077,12 +1077,12 @@ public async Task HandleRelayWebhookAsync_ShouldDispatchCardAction_ToConversatio var cardAction = activity.Content.CardAction; cardAction.Should().NotBeNull(); cardAction!.Arguments.Should().ContainKey("agent_builder_action") - .WhoseValue.Should().Be("create_daily_report"); + .WhoseValue.Should().Be("create_daily"); cardAction.FormFields.Should().ContainKey("github_username") .WhoseValue.Should().Be("eanzhao"); cardAction.FormFields.Should().ContainKey("schedule_time") .WhoseValue.Should().Be("09:00"); - cardAction.ActionId.Should().Be("create_daily_report"); + cardAction.ActionId.Should().Be("create_daily"); } [Fact] diff --git a/test/Aevatar.AI.Tests/NyxIdSshExecToolTests.cs b/test/Aevatar.AI.Tests/NyxIdSshExecToolTests.cs new file mode 100644 index 000000000..fce061ef2 --- /dev/null +++ b/test/Aevatar.AI.Tests/NyxIdSshExecToolTests.cs @@ -0,0 +1,604 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.AI.ToolProviders.NyxId.Tools; +using FluentAssertions; + +namespace Aevatar.AI.Tests; + +public class NyxIdSshExecToolTests +{ + private const string CatalogId = "69b3fbd6-bb62-40ec-9b42-88457a9c75d0"; + private const string SshOk = """{"exit_code":0,"stdout":"ok","stderr":"","duration_ms":42,"timed_out":false}"""; + + [Fact] + public void Name_IsSshExec() + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + tool.Name.Should().Be("ssh_exec"); + } + + [Fact] + public void Constructor_NullClient_Throws() + { + var act = () => new NyxIdSshExecTool(null!); + + act.Should().Throw() + .WithParameterName("client"); + } + + [Fact] + public void Metadata_DescribesSshExecutionContract() + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + + tool.ApprovalMode.Should().Be(ToolApprovalMode.Auto); + tool.Description.Should().Contain("ssh://"); + tool.Description.Should().Contain("nyxid_proxy"); + tool.ParametersSchema.Should().Contain("\"service\""); + tool.ParametersSchema.Should().Contain("\"timeout_secs\""); + } + + [Fact] + public void RequiresApproval_AlwaysTrue() + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + tool.RequiresApproval( + """{"service":"sg-office","command":"uname -a","principal":"ubuntu"}""") + .Should().BeTrue(); + } + + [Fact] + public async Task ExecuteAsync_NoToken_ReturnsError() + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + AgentToolRequestContext.CurrentMetadata = null; + + var result = await tool.ExecuteAsync( + """{"service":"sg-office","command":"uname -a","principal":"ubuntu"}"""); + + result.Should().Contain("No NyxID access token"); + } + + [Theory] + [InlineData("""{"command":"uname -a","principal":"ubuntu"}""")] // missing service + [InlineData("""{"service":"sg-office","principal":"ubuntu"}""")] // missing command + [InlineData("""{"service":"sg-office","command":"uname -a"}""")] // missing principal + public async Task ExecuteAsync_MissingRequiredField_ReturnsError(string args) + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync(args); + result.Should().Contain("'service', 'command', and 'principal' are required"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_InvalidJson_ReturnsParseError() + { + var tool = new NyxIdSshExecTool(CreateDummyClient()); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync("""{"service":"""); + + result.Should().Contain("Failed to parse tool arguments"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_ResolvesSlugToCatalogServiceId_AndPostsToCorrectSshPath() + { + // The /api/v1/ssh/{id}/exec route keys on catalog_service_id, NOT on the user-service + // slug or its uuid. Tool must hop GET /keys/{slug} → take catalog_service_id → POST. + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg-office-network", + $$"""{"id":"70f053b1-9185-4794-a135-5536c7608c19","slug":"sg-office-network","catalog_service_id":"{{CatalogId}}"}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + """{"service":"sg-office-network","command":"uname -a","principal":"ubuntu","timeout_secs":15}"""); + + result.Should().Contain("\"exit_code\":0"); + + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && + r.Path == $"/api/v1/ssh/{CatalogId}/exec"); + + var execRequest = handler.Recorded.Last(r => r.Method == HttpMethod.Post); + execRequest.Authorization.Should().Be("Bearer test-token"); + + using var doc = JsonDocument.Parse(execRequest.Body!); + doc.RootElement.GetProperty("command").GetString().Should().Be("uname -a"); + doc.RootElement.GetProperty("principal").GetString().Should().Be("ubuntu"); + doc.RootElement.GetProperty("timeout_secs").GetInt32().Should().Be(15); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_AcceptsLegacySlugArgument() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg-alias", + $$"""{"id":"u","slug":"sg-alias","catalog_service_id":"{{CatalogId}}"}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + """{"slug":"sg-alias","command":"whoami","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{CatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToListServices_WhenDirectKeyLookupMissesCatalogId() + { + // /keys/{slug} can return a wrapper without `catalog_service_id` surfaced (e.g. some + // builds nest it). The list endpoint always carries it, so the resolver falls back. + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg-office-network", + """{"id":"70f053b1-9185-4794-a135-5536c7608c19","slug":"sg-office-network"}"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", + $$"""{"keys":[{"id":"70f053b1-9185-4794-a135-5536c7608c19","slug":"sg-office-network","catalog_service_id":"{{CatalogId}}"}]}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + """{"service":"sg-office-network","command":"uname -a","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{CatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToArrayListServices_WhenWrappedListDoesNotMatch() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/edge-router", """[]"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", + $$"""[42,{"id":"other","catalog_service_id":"ignored"},{"service_slug":"edge-router","catalog_service_id":"{{CatalogId}}"}]"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + """{"service":"edge-router","command":"uptime","principal":"admin"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{CatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToRawCatalogId_WhenLookupsDoNotResolveCatalogId() + { + var rawCatalogId = "raw-catalog-id"; + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, $"/api/v1/keys/{rawCatalogId}", """{"error":true}"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", """{"keys":[{"slug":"other","catalog_service_id":"other-catalog"}]}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{rawCatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + $$"""{"service":"{{rawCatalogId}}","command":"hostname","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{rawCatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_HardTimesOut_WhenNyxIdHangsOnSshPost() + { + // Production incident 2026-05-08: NyxID's /api/v1/ssh/{id}/exec hung well past + // the user-supplied timeout_secs, dragging the LLM run to its turn budget. The + // tool now caps the wall-clock at timeout_secs + 15s and returns ssh_timeout so + // the LLM can summarize a degraded but real answer rather than the runtime's + // generic "took too long" fallback. + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg-office", + $$"""{"id":"u","slug":"sg-office","catalog_service_id":"{{CatalogId}}"}"""); + handler.MapHanging(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec"); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + // timeout_secs=1 → wall-clock cap = 1 + 15 = 16s. Use a very short timeout + // so the test exits fast; the production cap is timeout_secs + 15 regardless. + var result = await tool.ExecuteAsync( + """{"service":"sg-office","command":"sleep 30","principal":"ubuntu","timeout_secs":1}"""); + + result.Should().Contain("\"error\":\"ssh_timeout\""); + result.Should().Contain("16s"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToRawCatalogId_WhenDirectLookupIsEmpty() + { + var rawCatalogId = "catalog-from-empty-direct"; + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, $"/api/v1/keys/{rawCatalogId}", string.Empty); + handler.Map(HttpMethod.Get, "/api/v1/keys", "{}"); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{rawCatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + $$"""{"service":"{{rawCatalogId}}","command":"date","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{rawCatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToRawCatalogId_WhenDirectLookupIsInvalidJson() + { + var rawCatalogId = "catalog-from-invalid-direct"; + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, $"/api/v1/keys/{rawCatalogId}", "not-json"); + handler.Map(HttpMethod.Get, "/api/v1/keys", "{}"); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{rawCatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + $$"""{"service":"{{rawCatalogId}}","command":"date","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{rawCatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_IgnoresBlankCatalogServiceIdFromMatchedListEntry() + { + var rawCatalogId = "catalog-from-blank-list-match"; + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, $"/api/v1/keys/{rawCatalogId}", """{"id":"u"}"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", + $$"""{"keys":[{"slug":"{{rawCatalogId}}","catalog_service_id":""}]}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{rawCatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + $$"""{"service":"{{rawCatalogId}}","command":"date","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{rawCatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_FallsBackToRawService_WhenListResponseIsInvalidJson() + { + var rawCatalogId = "catalog-from-caller"; + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, $"/api/v1/keys/{rawCatalogId}", """{"id":"u"}"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", "not-json"); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{rawCatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + var result = await tool.ExecuteAsync( + $$"""{"service":"{{rawCatalogId}}","command":"pwd","principal":"ubuntu"}"""); + + result.Should().Contain("\"exit_code\":0"); + handler.Recorded.Should().Contain(r => + r.Method == HttpMethod.Post && r.Path == $"/api/v1/ssh/{rawCatalogId}/exec"); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_ThrowsConfiguredBaseUrlError_AfterResolverLookupsFail() + { + var tool = new NyxIdSshExecTool(new NyxIdApiClient(new NyxIdToolOptions())); + SetMetadata("test-token"); + try + { + var act = () => tool.ExecuteAsync( + """{"service":"raw-catalog","command":"date","principal":"ubuntu"}"""); + + await act.Should().ThrowAsync() + .WithMessage("NyxID base URL is not configured."); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_MatchesUnderscoreIdAndClampsTimeoutToMinimum() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/user-service-id", """{"id":"user-service-id"}"""); + handler.Map(HttpMethod.Get, "/api/v1/keys", + $$"""{"keys":[{"_id":"user-service-id","catalog_service_id":"{{CatalogId}}"}]}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + await tool.ExecuteAsync( + """{"service":"user-service-id","command":"id","principal":"ubuntu","timeout_secs":0}"""); + + var exec = handler.Recorded.Last(r => r.Method == HttpMethod.Post); + using var doc = JsonDocument.Parse(exec.Body!); + doc.RootElement.GetProperty("timeout_secs").GetInt32().Should().Be(1); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_DefaultsTimeoutWhenValueIsNotAnInteger() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg", + $$"""{"id":"u","slug":"sg","catalog_service_id":"{{CatalogId}}"}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + await tool.ExecuteAsync( + """{"service":"sg","command":"sleep 1","principal":"ubuntu","timeout_secs":"soon"}"""); + + var exec = handler.Recorded.Last(r => r.Method == HttpMethod.Post); + using var doc = JsonDocument.Parse(exec.Body!); + doc.RootElement.GetProperty("timeout_secs").GetInt32().Should().Be(30); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_DefaultsTimeoutTo30_WhenOmitted() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg-office", + $$"""{"id":"u","slug":"sg-office","catalog_service_id":"{{CatalogId}}"}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + await tool.ExecuteAsync( + """{"service":"sg-office","command":"echo hi","principal":"ubuntu"}"""); + var exec = handler.Recorded.Last(r => r.Method == HttpMethod.Post); + using var doc = JsonDocument.Parse(exec.Body!); + doc.RootElement.GetProperty("timeout_secs").GetInt32().Should().Be(30); + } + finally + { + ClearMetadata(); + } + } + + [Fact] + public async Task ExecuteAsync_ClampsTimeoutToServerMax() + { + var handler = new PathHandler(); + handler.Map(HttpMethod.Get, "/api/v1/keys/sg", + $$"""{"id":"u","slug":"sg","catalog_service_id":"{{CatalogId}}"}"""); + handler.Map(HttpMethod.Post, $"/api/v1/ssh/{CatalogId}/exec", SshOk); + + var tool = new NyxIdSshExecTool(new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler))); + SetMetadata("test-token"); + try + { + await tool.ExecuteAsync( + """{"service":"sg","command":"sleep 1","principal":"ubuntu","timeout_secs":9999}"""); + var exec = handler.Recorded.Last(r => r.Method == HttpMethod.Post); + using var doc = JsonDocument.Parse(exec.Body!); + doc.RootElement.GetProperty("timeout_secs").GetInt32().Should().Be(300); + } + finally + { + ClearMetadata(); + } + } + + private static NyxIdApiClient CreateDummyClient() => + new(new NyxIdToolOptions { BaseUrl = "https://test.example.com" }); + + private static void SetMetadata(string token) + { + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = token, + }; + } + + private static void ClearMetadata() => AgentToolRequestContext.CurrentMetadata = null; + + private sealed record RecordedRequest(HttpMethod Method, string Path, string? Body, string? Authorization); + + private sealed class PathHandler : HttpMessageHandler + { + private readonly Dictionary<(HttpMethod Method, string Path), string> _routes = new(); + private readonly HashSet<(HttpMethod Method, string Path)> _hangingRoutes = new(); + public List Recorded { get; } = new(); + + public void Map(HttpMethod method, string path, string responseBody) + { + _routes[(method, path)] = responseBody; + } + + /// + /// Mark a route to "hang" — the handler awaits the cancellation token instead of + /// returning a response, simulating a NyxID gateway that never replies. Used to + /// pin the ssh_exec tool's hard wall-clock cap. + /// + public void MapHanging(HttpMethod method, string path) + { + _hangingRoutes.Add((method, path)); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + string? body = null; + if (request.Content is not null) + body = await request.Content.ReadAsStringAsync(cancellationToken); + var path = request.RequestUri!.AbsolutePath; + Recorded.Add(new RecordedRequest( + request.Method, + path, + body, + request.Headers.Authorization?.ToString())); + + if (_hangingRoutes.Contains((request.Method, path))) + { + // Block until the caller's wall-clock cap fires — exactly what the production + // incident looked like (NyxID accepted the POST but never responded). + var pendingResponse = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationRegistration = cancellationToken.Register(() => + pendingResponse.TrySetCanceled(cancellationToken)); + + return await pendingResponse.Task; + } + + if (_routes.TryGetValue((request.Method, path), out var responseBodyText)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBodyText, Encoding.UTF8, "application/json"), + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("""{"error":"not_found"}""", + Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/test/Aevatar.AI.Tests/ToolCallLoopTests.cs b/test/Aevatar.AI.Tests/ToolCallLoopTests.cs index afaf8d745..47ad862ac 100644 --- a/test/Aevatar.AI.Tests/ToolCallLoopTests.cs +++ b/test/Aevatar.AI.Tests/ToolCallLoopTests.cs @@ -470,6 +470,226 @@ public void IsLengthTruncated_ShouldDetectKnownReasons_CaseInsensitive() ToolCallLoop.IsLengthTruncated("").Should().BeFalse(); } + [Fact] + public async Task ExecuteAsync_WhenNoToolCalls_ShouldPropagateReasoningContent() + { + var provider = new QueueLLMProvider( + [ + new LLMResponse { Content = "answer", ReasoningContent = "thinking-step" }, + ]); + var loop = new ToolCallLoop(new ToolManager()); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 2, CancellationToken.None); + + result.Should().Be("answer"); + messages.Should().ContainSingle(m => m.Role == "assistant"); + var assistant = messages.Single(m => m.Role == "assistant"); + assistant.Content.Should().Be("answer"); + assistant.ReasoningContent.Should().Be("thinking-step"); + } + + [Fact] + public async Task ExecuteAsync_WhenToolCallThenFollowUp_ShouldPropagateReasoningOnBothRounds() + { + var provider = new QueueLLMProvider( + [ + new LLMResponse + { + Content = "will use tool", + ReasoningContent = "first-thought", + ToolCalls = + [ + new ToolCall { Id = "tc-1", Name = "echo", ArgumentsJson = "{}" }, + ], + }, + new LLMResponse { Content = "final", ReasoningContent = "second-thought" }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "ok")); + var loop = new ToolCallLoop(tools); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 3, CancellationToken.None); + + result.Should().Be("final"); + var toolCallAssistant = messages.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: 1 }); + toolCallAssistant.Content.Should().Be("will use tool"); + toolCallAssistant.ReasoningContent.Should().Be("first-thought"); + provider.Requests.Should().HaveCount(2); + provider.Requests[1].Messages.Should().Contain(m => + m.Role == "assistant" && + m.ToolCalls != null && + m.ToolCalls.Count == 1 && + m.Content == "will use tool" && + m.ReasoningContent == "first-thought"); + var finalAssistant = messages.Last(m => m.Role == "assistant"); + finalAssistant.ReasoningContent.Should().Be("second-thought"); + } + + [Fact] + public async Task ExecuteAsync_WhenLengthRecovery_ShouldPropagateReasoningContent() + { + var provider = new QueueLLMProvider( + [ + new LLMResponse + { + Content = "partial", + ReasoningContent = "thinking-partial", + FinishReason = "length", + }, + new LLMResponse + { + Content = " continued", + ReasoningContent = "thinking-continued", + }, + ]); + var loop = new ToolCallLoop(new ToolManager()); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 3, CancellationToken.None); + + result.Should().Be("partial continued"); + var partialAssistant = messages.First(m => m.Role == "assistant"); + partialAssistant.ReasoningContent.Should().Be("thinking-partial"); + } + + [Fact] + public async Task ExecuteAsync_WhenMaxRoundsExhausted_ShouldPropagateReasoningInFinalCall() + { + var provider = new QueueLLMProvider( + [ + new LLMResponse + { + ToolCalls = [new ToolCall { Id = "tc-1", Name = "echo", ArgumentsJson = "{}" }], + }, + new LLMResponse { Content = "summary", ReasoningContent = "final-thought" }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "ok")); + var loop = new ToolCallLoop(tools); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 1, CancellationToken.None); + + result.Should().Be("summary"); + var lastAssistant = messages.Last(m => m.Role == "assistant"); + lastAssistant.ReasoningContent.Should().Be("final-thought"); + } + + [Fact] + public async Task ExecuteAsync_WhenHookBlocksToolCalls_ShouldPropagateReasoningContent() + { + var provider = new QueueLLMProvider( + [ + new LLMResponse + { + Content = "blocked-content", + ReasoningContent = "blocked-thinking", + ToolCalls = [new ToolCall { Id = "tc-1", Name = "echo", ArgumentsJson = "{}" }], + }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "ok")); + var hook = new BlockPostSamplingHook(); + var loop = new ToolCallLoop(tools, new AgentHookPipeline([hook])); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 2, CancellationToken.None); + + result.Should().Be("blocked-content"); + var assistant = messages.Single(m => m.Role == "assistant"); + assistant.Content.Should().Be("blocked-content"); + assistant.ReasoningContent.Should().Be("blocked-thinking"); + } + + [Fact] + public async Task ExecuteAsync_WhenDsmlTextToolCalls_ShouldPropagateReasoningContent() + { + var dsmlContent = "I will search now.\ntest"; + var provider = new QueueLLMProvider( + [ + new LLMResponse { Content = dsmlContent, ReasoningContent = "dsml-thinking" }, + new LLMResponse { Content = "final-after-dsml", ReasoningContent = "final-thinking" }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "echo-result")); + var loop = new ToolCallLoop(tools); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 3, CancellationToken.None); + + result.Should().Be("final-after-dsml"); + var dsmlAssistant = messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Name == "echo"); + dsmlAssistant.Content.Should().Be("I will search now."); + dsmlAssistant.ReasoningContent.Should().Be("dsml-thinking"); + var forwardedDsmlAssistant = provider.Requests[1].Messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Name == "echo"); + forwardedDsmlAssistant.ReasoningContent.Should().Be("dsml-thinking"); + var finalAssistant = messages.Last(m => m.Role == "assistant"); + finalAssistant.ReasoningContent.Should().Be("final-thinking"); + } + + [Fact] + public async Task ExecuteAsync_WhenDsmlToolCallBlockedByHook_ShouldPropagateReasoningContent() + { + var dsmlContent = "I will search now.\ntest"; + var provider = new QueueLLMProvider( + [ + new LLMResponse { Content = dsmlContent, ReasoningContent = "blocked-dsml-thinking" }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "ok")); + var hook = new BlockPostSamplingHook(); + var loop = new ToolCallLoop(tools, new AgentHookPipeline([hook])); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 2, CancellationToken.None); + + messages.Should().Contain(m => m.Role == "assistant" && m.ReasoningContent == "blocked-dsml-thinking"); + } + + [Fact] + public async Task ExecuteAsync_WhenMaxRoundsExhaustedAndDsmlInFinalCall_ShouldPropagateReasoning() + { + var dsmlContent = "Final search.\nfinal"; + var provider = new QueueLLMProvider( + [ + new LLMResponse { ToolCalls = [new ToolCall { Id = "tc-1", Name = "echo", ArgumentsJson = "{}" }] }, + new LLMResponse { Content = dsmlContent, ReasoningContent = "final-dsml-thinking" }, + new LLMResponse { Content = "summary", ReasoningContent = "summary-thinking" }, + ]); + var tools = new ToolManager(); + tools.Register(new DelegateTool("echo", _ => "ok")); + var loop = new ToolCallLoop(tools); + var messages = new List { ChatMessage.User("hello") }; + var request = new LLMRequest { Messages = [], Tools = null }; + + var result = await loop.ExecuteAsync(provider, messages, request, maxRounds: 1, CancellationToken.None); + + result.Should().Be("summary"); + var forwardedFinalDsmlAssistant = provider.Requests[2].Messages.Single(m => + m.Role == "assistant" && + m.ToolCalls is { Count: 1 } && + m.ToolCalls[0].Name == "echo" && + m.ReasoningContent == "final-dsml-thinking"); + forwardedFinalDsmlAssistant.ReasoningContent.Should().Be("final-dsml-thinking"); + var lastAssistant = messages.Last(m => m.Role == "assistant"); + lastAssistant.ReasoningContent.Should().Be("summary-thinking"); + } + [Theory] [InlineData("base64")] [InlineData("data")] @@ -603,6 +823,18 @@ public async Task InvokeAsync(LLMCallContext context, Func next) } } + private sealed class BlockPostSamplingHook : IAIGAgentExecutionHook + { + public string Name => "block-post-sampling"; + public int Priority => 0; + + public Task OnPostSamplingAsync(AIGAgentExecutionHookContext ctx, CancellationToken ct) + { + ctx.Items["block_tool_calls"] = true; + return Task.CompletedTask; + } + } + private sealed class RecordingHook : IAIGAgentExecutionHook { public string Name => "rec"; diff --git a/test/Aevatar.AI.ToolProviders.Lark.Tests/LarkCardKitClientTests.cs b/test/Aevatar.AI.ToolProviders.Lark.Tests/LarkCardKitClientTests.cs new file mode 100644 index 000000000..275727e57 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Lark.Tests/LarkCardKitClientTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Aevatar.AI.ToolProviders.Lark; +using Aevatar.AI.ToolProviders.NyxId; +using FluentAssertions; +using Xunit; + +namespace Aevatar.AI.ToolProviders.Lark.Tests; + +public sealed class LarkCardKitClientTests +{ + [Fact] + public async Task CreateCardAsync_CardJson_SerializesDataAsString() + { + var (client, handler) = BuildClient("""{"code":0,"data":{"card_id":"card_x"}}"""); + var dataJson = """{"schema":"2.0","config":{"streaming_mode":true},"body":{"elements":[]}}"""; + + await client.CreateCardAsync( + "tok-1", + new LarkCardKitCreateRequest("card_json", dataJson), + CancellationToken.None); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Post); + handler.LastRequest!.RequestUri!.ToString().Should().Be( + "https://nyx.example.com/api/v1/proxy/s/api-lark-bot/open-apis/cardkit/v1/cards"); + // Lark CardKit 2.0's open-apis/cardkit/v1/cards expects `data` to be a JSON-encoded + // STRING (not an inline object) when `type=card_json`. Inline-object payloads are + // rejected with code 9499 ("Invalid parameter type in json: Data") — verified + // against the real endpoint on 2026-05-08; the legacy unit test pinned the wrong + // contract and the production rollout silently fell back to the text-edit path on + // every turn. + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("type").GetString().Should().Be("card_json"); + body.RootElement.GetProperty("data").ValueKind.Should().Be(JsonValueKind.String); + var roundTrip = JsonDocument.Parse(body.RootElement.GetProperty("data").GetString()!); + roundTrip.RootElement.GetProperty("schema").GetString().Should().Be("2.0"); + roundTrip.RootElement.GetProperty("config").GetProperty("streaming_mode").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task CreateCardAsync_CardId_SerializesDataAsString() + { + // type=card_id clones an existing card; `data` is just the card_id string. + var (client, handler) = BuildClient("""{"code":0,"data":{"card_id":"card_y"}}"""); + + await client.CreateCardAsync( + "tok-1", + new LarkCardKitCreateRequest("card_id", "7637410486966832864"), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("type").GetString().Should().Be("card_id"); + body.RootElement.GetProperty("data").ValueKind.Should().Be(JsonValueKind.String); + body.RootElement.GetProperty("data").GetString().Should().Be("7637410486966832864"); + } + + [Fact] + public async Task CreateCardAsync_Template_SerializesDataAsInlineObject() + { + // type=template wants `data: { template_id, template_variable }` as an inline + // object, not a string — this is the one path where the original ParseJsonObject + // shape was correct. + var (client, handler) = BuildClient("""{"code":0,"data":{"card_id":"card_z"}}"""); + var dataJson = """{"template_id":"AAq01wbtNVnPM","template_variable":{"name":"Aevatar"}}"""; + + await client.CreateCardAsync( + "tok-1", + new LarkCardKitCreateRequest("template", dataJson), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("type").GetString().Should().Be("template"); + body.RootElement.GetProperty("data").ValueKind.Should().Be(JsonValueKind.Object); + body.RootElement.GetProperty("data").GetProperty("template_id").GetString().Should().Be("AAq01wbtNVnPM"); + body.RootElement.GetProperty("data").GetProperty("template_variable").GetProperty("name").GetString().Should().Be("Aevatar"); + } + + [Fact] + public async Task StreamElementContentAsync_PutsToElementContentPath_AndIncludesSequence() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + await client.StreamElementContentAsync( + "tok-1", + new LarkCardKitStreamElementContentRequest( + CardId: "card_x", + ElementId: "streaming_main", + Content: "hello world", + Sequence: 7, + IdempotencyKey: "uuid-7"), + CancellationToken.None); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Put); + handler.LastRequest!.RequestUri!.ToString().Should().Be( + "https://nyx.example.com/api/v1/proxy/s/api-lark-bot/open-apis/cardkit/v1/cards/card_x/elements/streaming_main/content"); + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("content").GetString().Should().Be("hello world"); + body.RootElement.GetProperty("sequence").GetInt64().Should().Be(7L); + body.RootElement.GetProperty("uuid").GetString().Should().Be("uuid-7"); + } + + [Fact] + public async Task StreamElementContentAsync_OmitsUuid_WhenIdempotencyKeyIsBlank() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + await client.StreamElementContentAsync( + "tok-1", + new LarkCardKitStreamElementContentRequest( + CardId: "card_x", + ElementId: "streaming_main", + Content: "content", + Sequence: 1, + IdempotencyKey: " "), + CancellationToken.None); + + // The DTO's IdempotencyKey is whitespace; the client must not emit a `uuid` field + // (Lark rejects empty uuids on some endpoints). + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.TryGetProperty("uuid", out _).Should().BeFalse(); + } + + [Fact] + public async Task StreamElementContentAsync_UrlEncodesIds_ThatContainReservedCharacters() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + // Lark CardKit returns card_ids/element_ids as opaque strings; the client must run + // them through Uri.EscapeDataString or a malformed id would land in the path + // unescaped. We test space encoding (System.Uri preserves %20 in absolute URI + // paths); slash encoding (%2F) is also called but .NET's Uri canonicalization + // unescapes path-segment %2F back to '/' by default, so we only assert what is + // observable on the wire. + await client.StreamElementContentAsync( + "tok-1", + new LarkCardKitStreamElementContentRequest( + CardId: "card with space", + ElementId: "streaming_main", + Content: "x", + Sequence: 1), + CancellationToken.None); + + // Uri.ToString() returns the unescaped form; use AbsoluteUri to inspect the + // percent-encoded path actually placed on the wire. + handler.LastRequest!.RequestUri!.AbsoluteUri.Should().Contain("/cards/card%20with%20space/elements/"); + } + + [Fact] + public async Task SetCardSettingsAsync_SerializesSettingsAsString() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + await client.SetCardSettingsAsync( + "tok-1", + new LarkCardKitSettingsRequest( + CardId: "card_x", + SettingsJson: """{"config":{"streaming_mode":false}}""", + Sequence: 99, + IdempotencyKey: "uuid-end"), + CancellationToken.None); + + handler.LastRequest!.Method.Should().Be(new HttpMethod("PATCH")); + handler.LastRequest!.RequestUri!.ToString().Should().Be( + "https://nyx.example.com/api/v1/proxy/s/api-lark-bot/open-apis/cardkit/v1/cards/card_x/settings"); + using var body = JsonDocument.Parse(handler.LastBody!); + // Same surprise as POST /cards: PATCH /settings expects `settings` to be a + // JSON-encoded string, not an inline object. Lark returns code 9499 "Invalid + // parameter type in json: Settings" for the inline shape — verified live. + body.RootElement.GetProperty("settings").ValueKind.Should().Be(JsonValueKind.String); + var roundTrip = JsonDocument.Parse(body.RootElement.GetProperty("settings").GetString()!); + roundTrip.RootElement.GetProperty("config").GetProperty("streaming_mode").GetBoolean().Should().BeFalse(); + body.RootElement.GetProperty("sequence").GetInt64().Should().Be(99L); + body.RootElement.GetProperty("uuid").GetString().Should().Be("uuid-end"); + } + + [Fact] + public async Task UpdateCardAsync_WrapsCardJsonInTypeDataEnvelope() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + var cardJson = """{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"final"}]}}"""; + + await client.UpdateCardAsync( + "tok-1", + new LarkCardKitUpdateRequest( + CardId: "card_x", + CardJson: cardJson, + Sequence: 42), + CancellationToken.None); + + handler.LastRequest!.Method.Should().Be(HttpMethod.Put); + handler.LastRequest!.RequestUri!.ToString().Should().Be( + "https://nyx.example.com/api/v1/proxy/s/api-lark-bot/open-apis/cardkit/v1/cards/card_x"); + using var body = JsonDocument.Parse(handler.LastBody!); + // PUT /cards/{id} requires `card` to be `{type, data}` where data is the same + // JSON-encoded string used in POST /cards. Inline `{schema, body, ...}` 400s with + // `card.type is required` — the wrapper is what Lark validates. + body.RootElement.GetProperty("card").ValueKind.Should().Be(JsonValueKind.Object); + body.RootElement.GetProperty("card").GetProperty("type").GetString().Should().Be("card_json"); + body.RootElement.GetProperty("card").GetProperty("data").ValueKind.Should().Be(JsonValueKind.String); + var inner = JsonDocument.Parse(body.RootElement.GetProperty("card").GetProperty("data").GetString()!); + inner.RootElement.GetProperty("body").GetProperty("elements")[0] + .GetProperty("content").GetString().Should().Be("final"); + body.RootElement.GetProperty("sequence").GetInt64().Should().Be(42L); + body.RootElement.TryGetProperty("uuid", out _).Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task CreateCardAsync_RejectsBlankDataJson_ForAnyType(string dataJson) + { + // Blank guard fires upfront for every type, so card_json and template both reject + // the empty payload at the boundary instead of letting it 400 at Lark. + var (client, _) = BuildClient(""); + + foreach (var type in new[] { "card_json", "template", "card_id" }) + { + var act = async () => await client.CreateCardAsync( + "tok-1", + new LarkCardKitCreateRequest(type, dataJson), + CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(ex => ex.ParamName == "DataJson"); + } + } + + [Fact] + public async Task UpdateCardAsync_RejectsMalformedCardJson() + { + var (client, _) = BuildClient(""); + + var act = async () => await client.UpdateCardAsync( + "tok-1", + new LarkCardKitUpdateRequest(CardId: "card_x", CardJson: "{not json", Sequence: 1), + CancellationToken.None); + + // ParseJsonObject surfaces the underlying System.Text.Json error rather than letting + // a malformed payload reach Lark with a 400. + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CreateCardAsync_Template_RejectsLiteralNullJson() + { + // For type=template the inline-object embedding still goes through + // ParseJsonObject, so the literal `null` payload (which JsonNode.Parse returns + // as null without throwing) is caught at the boundary. card_json/card_id pass + // through as raw strings, so this guard only applies to template. + var (client, _) = BuildClient(""); + + var act = async () => await client.CreateCardAsync( + "tok-1", + new LarkCardKitCreateRequest("template", "null"), + CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(ex => ex.ParamName == "DataJson" && ex.Message.Contains("parsed to null")); + } + + [Fact] + public async Task SetCardSettingsAsync_OmitsUuid_WhenIdempotencyKeyIsBlank() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + await client.SetCardSettingsAsync( + "tok-1", + new LarkCardKitSettingsRequest( + CardId: "card_x", + SettingsJson: """{"streaming_mode":false}""", + Sequence: 1, + IdempotencyKey: null), + CancellationToken.None); + + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.TryGetProperty("uuid", out _).Should().BeFalse(); + } + + [Fact] + public async Task UpdateCardAsync_PassesIdempotencyKey_WhenProvided() + { + var (client, handler) = BuildClient("""{"code":0,"data":{}}"""); + + await client.UpdateCardAsync( + "tok-1", + new LarkCardKitUpdateRequest( + CardId: "card_x", + CardJson: """{"schema":"2.0"}""", + Sequence: 1, + IdempotencyKey: " uuid-update "), + CancellationToken.None); + + // Idempotency key is trimmed before emission so callers do not have to worry about + // accidental whitespace defeating Lark's dedup. + using var body = JsonDocument.Parse(handler.LastBody!); + body.RootElement.GetProperty("uuid").GetString().Should().Be("uuid-update"); + } + + [Fact] + public async Task LarkCardKitClient_IsRegisteredAsSingleton_AfterAddLarkTools() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + services.AddLarkTools(opts => opts.ProviderSlug = "api-lark-bot"); + + services.Should().ContainSingle(d => d.ServiceType == typeof(ILarkCardKitClient) + && d.ImplementationType == typeof(LarkCardKitClient)); + } + + private static (LarkCardKitClient client, RecordingHandler handler) BuildClient(string responseJson) + { + var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), + }); + var client = new LarkCardKitClient( + new LarkToolOptions { ProviderSlug = "api-lark-bot" }, + new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, + new HttpClient(handler))); + return (client, handler); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func _responder; + + public RecordingHandler(Func responder) + { + _responder = responder; + } + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return _responder(request); + } + } +} diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/Aevatar.AI.ToolProviders.Ornn.Tests.csproj b/test/Aevatar.AI.ToolProviders.Ornn.Tests/Aevatar.AI.ToolProviders.Ornn.Tests.csproj new file mode 100644 index 000000000..4e10c6330 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/Aevatar.AI.ToolProviders.Ornn.Tests.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + enable + false + true + Aevatar.AI.ToolProviders.Ornn.Tests + Aevatar.AI.ToolProviders.Ornn.Tests + + + + + + + + + + + + + + + diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/GlobalUsings.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/GlobalUsings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSearchSkillsToolTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSearchSkillsToolTests.cs new file mode 100644 index 000000000..ac6e61888 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSearchSkillsToolTests.cs @@ -0,0 +1,131 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.Abstractions.ToolProviders; +using FluentAssertions; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +public sealed class OrnnSearchSkillsToolTests +{ + [Fact] + public async Task ExecuteAsync_ReturnsAuthenticationErrorWhenTokenMissing() + { + var previous = AgentToolRequestContext.CurrentMetadata; + try + { + AgentToolRequestContext.CurrentMetadata = null; + var tool = CreateTool(OrnnTestHttpMessageHandler.ReturningJson("""{ "data": { "items": [] } }""")); + + var result = await tool.ExecuteAsync("""{ "query": "translate" }"""); + + result.Should().Contain("No NyxID access token"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = previous; + } + } + + [Fact] + public async Task ExecuteAsync_ReturnsFormattedSearchResults() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson(""" + { + "data": { + "total": 1, + "items": [ + { + "name": "Translate", + "description": "Translate text", + "isPrivate": true, + "metadata": { "category": "text", "tag": ["language"] } + } + ] + } + } + """); + var previous = AgentToolRequestContext.CurrentMetadata; + try + { + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "access-token", + }; + var tool = CreateTool(handler); + + var result = await tool.ExecuteAsync("""{ "query": "translate", "scope": "private" }"""); + + result.Should().Contain("Found 1 skills"); + result.Should().Contain("**Translate** (private, text)"); + result.Should().Contain("Translate text"); + result.Should().Contain("Tags: language"); + + var request = handler.Requests.Should().ContainSingle().Subject; + request.Authorization!.Parameter.Should().Be("access-token"); + request.RequestUri!.ToString().Should().Contain("query=translate"); + request.RequestUri!.ToString().Should().Contain("scope=private"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = previous; + } + } + + [Fact] + public async Task ExecuteAsync_ReturnsSearchFailureWhenClientFails() + { + var previous = AgentToolRequestContext.CurrentMetadata; + try + { + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "access-token", + }; + var tool = CreateTool(OrnnTestHttpMessageHandler.ReturningJson( + """{ "error": "bad" }""", + System.Net.HttpStatusCode.BadGateway)); + + var result = await tool.ExecuteAsync("""{ "query": "translate" }"""); + + result.Should().Contain("Search failed:"); + } + finally + { + AgentToolRequestContext.CurrentMetadata = previous; + } + } + + [Fact] + public async Task ExecuteAsync_UsesDefaultsForMalformedArguments() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson("""{ "data": { "items": [] } }"""); + var previous = AgentToolRequestContext.CurrentMetadata; + try + { + AgentToolRequestContext.CurrentMetadata = new Dictionary + { + [LLMRequestMetadataKeys.NyxIdAccessToken] = "access-token", + }; + var tool = CreateTool(handler); + + var result = await tool.ExecuteAsync("not-json"); + + result.Should().Contain("No skills found for query '' (scope: mixed)."); + } + finally + { + AgentToolRequestContext.CurrentMetadata = previous; + } + } + + private static OrnnSearchSkillsTool CreateTool(OrnnTestHttpMessageHandler handler) + { + var nyxClient = new Aevatar.AI.ToolProviders.NyxId.NyxIdApiClient( + new Aevatar.AI.ToolProviders.NyxId.NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler)); + var client = new OrnnSkillClient( + new OrnnOptions { NyxIdSlug = "ornn" }, + nyxClient); + + return new OrnnSearchSkillsTool(client); + } +} diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs new file mode 100644 index 000000000..14ca95c1c --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnSkillClientTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using Aevatar.AI.ToolProviders.NyxId; +using FluentAssertions; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +public sealed class OrnnSkillClientTests +{ + [Fact] + public async Task SearchSkillsAsync_RoutesThroughNyxIdProxyWithNormalizedQuery() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson(""" + { + "data": { + "total": 1, + "totalPages": 1, + "page": 1, + "pageSize": 100, + "items": [ + { + "guid": "skill-1", + "name": "Translate", + "description": "Translate text", + "isPrivate": true, + "tags": ["language"], + "metadata": { "category": "text", "tag": ["fallback"] } + } + ] + } + } + """); + var client = CreateClient(handler); + + var result = await client.SearchSkillsAsync( + "access-token", + "hello world", + "invalid", + page: 0, + pageSize: 500, + mode: "semantic"); + + result.Total.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].Name.Should().Be("Translate"); + result.Items[0].Tags.Should().Equal("language"); + + var request = handler.Requests.Should().ContainSingle().Subject; + request.Method.Should().Be(HttpMethod.Get); + request.Authorization.Should().NotBeNull(); + request.Authorization!.Scheme.Should().Be("Bearer"); + request.Authorization.Parameter.Should().Be("access-token"); + request.RequestUri!.AbsoluteUri.Should().Be( + "https://nyx.example/api/v1/proxy/s/ornn/api/v1/skill-search?query=hello%20world&mode=semantic&scope=mixed&page=1&pageSize=100"); + } + + [Fact] + public async Task SearchSkillsAsync_HonorsCustomNyxIdSlug() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson("""{ "data": { "items": [] } }"""); + var client = CreateClient(handler, slug: "ornn-tenant-a"); + + await client.SearchSkillsAsync("token", "anything"); + + handler.Requests.Should().ContainSingle() + .Which.RequestUri!.AbsoluteUri.Should().StartWith("https://nyx.example/api/v1/proxy/s/ornn-tenant-a/api/v1/skill-search"); + } + + [Fact] + public async Task SearchSkillsAsync_SurfacesGenericNyxIdProxyErrorWithStatus() + { + // NyxIdApiClient wraps non-2xx responses as {"error":true,"status":N,"body":"..."}. + // The client must surface a concise error rather than a confusing JsonException + // about the wrapper shape. + var handler = OrnnTestHttpMessageHandler.ReturningJson( + """{ "error": "nope" }""", + HttpStatusCode.InternalServerError); + var client = CreateClient(handler); + + var result = await client.SearchSkillsAsync("token", "query"); + + result.Items.Should().BeEmpty(); + result.Error.Should().Contain("status=500"); + } + + [Fact] + public async Task SearchSkillsAsync_OnNyxIdProxy404_SurfacesSlugBindingHint() + { + // 404 from NyxID proxy means the slug isn't resolvable — the user hasn't bound an + // Ornn service or the deployment's slug differs. The LLM-facing error must tell the + // model exactly that so it can guide the user rather than retry mechanically (which + // is what we observed in mainnet after the first NyxID-proxy refactor). + var handler = OrnnTestHttpMessageHandler.ReturningJson( + """{ "error": "missing" }""", + HttpStatusCode.NotFound); + var client = CreateClient(handler, slug: "ornn"); + + var result = await client.SearchSkillsAsync("token", "query"); + + result.Items.Should().BeEmpty(); + result.Error.Should().Contain("slug 'ornn'"); + result.Error.Should().Contain("nyxid_services action=create"); + } + + [Fact] + public async Task GetSkillJsonAsync_RoutesThroughNyxIdProxyAndReturnsSkillFiles() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson(""" + { + "data": { + "name": "Translate", + "description": "Translate text", + "metadata": { "category": "text", "tag": ["language"] }, + "files": { "SKILL.md": "Use this skill." } + } + } + """); + var client = CreateClient(handler); + + var skill = await client.GetSkillJsonAsync("access-token", "Translate Skill"); + + skill.Should().NotBeNull(); + skill!.Name.Should().Be("Translate"); + skill.Metadata!.Tags.Should().Equal("language"); + skill.Files.Should().ContainKey("SKILL.md"); + + var request = handler.Requests.Should().ContainSingle().Subject; + request.Authorization!.Parameter.Should().Be("access-token"); + request.RequestUri!.AbsoluteUri.Should().Be( + "https://nyx.example/api/v1/proxy/s/ornn/api/v1/skills/Translate%20Skill/json"); + } + + [Fact] + public async Task GetSkillJsonAsync_ReturnsNullWhenNyxIdProxyReportsError() + { + var handler = OrnnTestHttpMessageHandler.ReturningJson( + """{ "error": "missing" }""", + HttpStatusCode.NotFound); + var client = CreateClient(handler); + + var skill = await client.GetSkillJsonAsync("token", "missing"); + + skill.Should().BeNull(); + } + + private static OrnnSkillClient CreateClient(OrnnTestHttpMessageHandler handler, string slug = "ornn") + { + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.example" }, + new HttpClient(handler)); + return new OrnnSkillClient(new OrnnOptions { NyxIdSlug = slug }, nyxClient); + } +} diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnTestHttpMessageHandler.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnTestHttpMessageHandler.cs new file mode 100644 index 000000000..fb0be71d2 --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/OrnnTestHttpMessageHandler.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +internal sealed class OrnnTestHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public List Requests { get; } = []; + + public OrnnTestHttpMessageHandler(params Func[] responses) + { + foreach (var response in responses) + _responses.Enqueue(response); + } + + public static OrnnTestHttpMessageHandler ReturningJson(string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new OrnnTestHttpMessageHandler(_ => JsonResponse(json, statusCode)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(CapturedHttpRequest.From(request)); + + var responseFactory = _responses.Count > 0 + ? _responses.Dequeue() + : _ => new HttpResponseMessage(HttpStatusCode.NotFound); + + return Task.FromResult(responseFactory(request)); + } + + public static HttpResponseMessage JsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; + } +} + +internal sealed record CapturedHttpRequest( + HttpMethod Method, + Uri? RequestUri, + AuthenticationHeaderValue? Authorization) +{ + public static CapturedHttpRequest From(HttpRequestMessage request) + { + return new CapturedHttpRequest( + request.Method, + request.RequestUri, + request.Headers.Authorization); + } +} diff --git a/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillRegistryTtlTests.cs b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillRegistryTtlTests.cs new file mode 100644 index 000000000..e2610b90d --- /dev/null +++ b/test/Aevatar.AI.ToolProviders.Ornn.Tests/SkillRegistryTtlTests.cs @@ -0,0 +1,127 @@ +using Aevatar.AI.ToolProviders.Skills; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; + +namespace Aevatar.AI.ToolProviders.Ornn.Tests; + +/// +/// TTL semantics for the skill registry. The whole point of the cache is to let curators +/// update SKILL.md on Ornn and have aevatar pick up the new version within a bounded window +/// without a redeploy — so these tests pin both the "still fresh" and "stale, refetch wanted" +/// branches around the configured TTL. +/// +public sealed class SkillRegistryTtlTests +{ + [Fact] + public void TryGet_WithinTtl_ReturnsCachedSkill() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill("nyxid", instructions: "v1")); + + time.Advance(TimeSpan.FromMinutes(4)); + + registry.TryGet("nyxid", out var skill, maxAge: TimeSpan.FromMinutes(5)) + .Should().BeTrue(); + skill!.Instructions.Should().Be("v1"); + } + + [Fact] + public void TryGet_BeyondTtl_ReturnsFalseSoCallerCanRefetch() + { + // TTL only applies to remote skills (PR #562 review #22) — local skills are + // baked in at registration. Use a remoteId here so the entry is SkillSource.Remote, + // which is the realistic stale-entry scenario the TTL is designed to catch. + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill("nyxid", instructions: "v1", remoteId: "skill-nyxid")); + + time.Advance(TimeSpan.FromMinutes(6)); + + registry.TryGet("nyxid", out var skill, maxAge: TimeSpan.FromMinutes(5)) + .Should().BeFalse("stale remote entries must miss so use_skill drops to the remote fetcher"); + skill.Should().BeNull(); + } + + [Fact] + public void TryGet_LocalSkillBeyondTtl_StillFresh() + { + // PR #562 review #22: local skills are scanned per-process and have no remote + // refresh story. They must NOT expire even past a TTL window — otherwise the + // first 5-minute window would silently lose them from use_skill. + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill("translate-local", instructions: "v1")); + + time.Advance(TimeSpan.FromHours(24)); + + registry.TryGet("translate-local", out var skill, maxAge: TimeSpan.FromMinutes(5)) + .Should().BeTrue("local skills are not subject to TTL"); + skill!.Instructions.Should().Be("v1"); + } + + [Fact] + public void Register_AfterStale_RefreshesFetchedAt() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill("nyxid", instructions: "v1", remoteId: "skill-nyxid")); + + time.Advance(TimeSpan.FromMinutes(6)); + // Simulate UseSkillTool's refetch-on-stale path: fetcher returns a fresher skill, + // registry replaces the entry with a new FetchedAt at "now". + registry.Register(MakeSkill("nyxid", instructions: "v2", remoteId: "skill-nyxid")); + + // Within 5 min of the re-register, lookup must hit the new entry. + time.Advance(TimeSpan.FromMinutes(4)); + registry.TryGet("nyxid", out var skill, maxAge: TimeSpan.FromMinutes(5)) + .Should().BeTrue(); + skill!.Instructions.Should().Be("v2"); + } + + [Fact] + public void TryGet_WithoutMaxAge_TreatsCacheAsAlwaysFresh() + { + // Local skills (scanned per-turn from disk) have no remote refresh story. Calling + // TryGet without a maxAge must not impose a TTL — otherwise local skills would + // disappear after the first window and need to be re-scanned to be visible. + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill("translate-pro")); + + time.Advance(TimeSpan.FromHours(24)); + + registry.TryGet("translate-pro", out var skill).Should().BeTrue(); + skill.Should().NotBeNull(); + } + + [Fact] + public void TryGet_StaleEntryByRemoteId_AlsoMisses() + { + var time = new FakeTimeProvider(new DateTimeOffset(2026, 5, 7, 12, 0, 0, TimeSpan.Zero)); + var registry = new SkillRegistry(time); + registry.Register(MakeSkill( + name: "translate-pro", + instructions: "v1", + remoteId: "skill-guid-1")); + + time.Advance(TimeSpan.FromMinutes(10)); + + // RemoteId fallback path must respect the TTL too — otherwise stale skills could + // sneak through when the LLM passes the GUID instead of the friendly name. + registry.TryGet("skill-guid-1", out _, maxAge: TimeSpan.FromMinutes(5)) + .Should().BeFalse(); + } + + private static SkillDefinition MakeSkill(string name, string instructions = "body", string? remoteId = null) + { + return new SkillDefinition + { + Name = name, + Description = $"{name} description", + Instructions = instructions, + Source = remoteId is null ? SkillSource.Local : SkillSource.Remote, + RemoteId = remoteId, + }; + } +} diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs index edceb7c9e..7de654e02 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/RuntimeActorGrainStateStoreTests.cs @@ -79,7 +79,7 @@ public async Task RuntimeActorGrainStateStore_ShouldLoadLegacyClrTypeName_ForRen { AgentId = "agent-compat-1", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", }, }, }; diff --git a/test/Aevatar.GAgents.Channel.Protocol.Tests/ConversationGAgentDedupTests.cs b/test/Aevatar.GAgents.Channel.Protocol.Tests/ConversationGAgentDedupTests.cs index 2e370409d..1136b53f0 100644 --- a/test/Aevatar.GAgents.Channel.Protocol.Tests/ConversationGAgentDedupTests.cs +++ b/test/Aevatar.GAgents.Channel.Protocol.Tests/ConversationGAgentDedupTests.cs @@ -433,13 +433,13 @@ public async Task HandleContinueCommandAsync_TransientFailureWithoutRetryAfter_S } [Fact] - public async Task HandleInboundActivityAsync_WhenInboxIsRegistered_DispatchesDirectlyWithoutWaitingForReminder() + public async Task HandleInboundActivityAsync_WhenRunDispatcherIsRegistered_DispatchesDirectlyWithoutWaitingForReminder() { // Regression: previously the inbound LlmReplyRequest path scheduled a 100ms durable - // Reminder before EnqueueAsync, which Orleans rounded up to ~1 minute and effectively - // dropped the dispatch in production. The inbound path must call inbox.EnqueueAsync + // Reminder before DispatchAsync, which Orleans rounded up to ~1 minute and effectively + // dropped the dispatch in production. The inbound path must call dispatcher.DispatchAsync // inline so the LLM worker picks it up immediately. - var inbox = new RecordingInbox(); + var dispatcher = new RecordingRunDispatcher(); var runner = new RecordingTurnRunner { InboundResultFactory = activity => ConversationTurnResult.LlmReplyRequested( @@ -452,20 +452,20 @@ public async Task HandleInboundActivityAsync_WhenInboxIsRegistered_DispatchesDir RequestedAtUnixMs = 42, }), }; - var (agent, _) = CreateAgent(runner, "conv-direct-dispatch", inbox); + var (agent, _) = CreateAgent(runner, "conv-direct-dispatch", dispatcher); await agent.HandleInboundActivityAsync(CreateActivity("act-direct", "conv:slack:C1")); - inbox.Enqueued.Count.ShouldBe(1); - inbox.Enqueued[0].CorrelationId.ShouldBe("act-direct"); - inbox.Enqueued[0].TargetActorId.ShouldBe(agent.Id); + dispatcher.Dispatched.Count.ShouldBe(1); + dispatcher.Dispatched[0].CorrelationId.ShouldBe("act-direct"); + dispatcher.Dispatched[0].TargetActorId.ShouldBe(agent.Id); } [Fact] public async Task HandleNyxRelayInboundActivityAsync_NeverPersistsReplyTokenIntoEventStore() { // Issue #366 §4 invariant: relay reply_token must stay actor-owned runtime state. - // The transient inbox envelope NyxRelayInboundActivity carries the token across the + // The transient run command NyxRelayInboundActivity carries the token across the // dispatch boundary, but the actor must not write it into any persisted event payload. const string sentinelReplyToken = "sentinel-reply-token-9f3c5b2e-must-not-persist"; var runner = new RecordingTurnRunner @@ -556,7 +556,7 @@ public async Task HandleDeferredLlmReplyDispatchRequestedAsync_RehydratesRelayTo // requests are tracked by message_id, while reply tokens are keyed by the // callback correlation_id carried in OutboundDelivery. const string sentinelReplyToken = "sentinel-retry-token-7c10"; - var inbox = new RecordingInbox(); + var dispatcher = new RecordingRunDispatcher(); var runner = new RecordingTurnRunner { InboundResultFactory = activity => ConversationTurnResult.LlmReplyRequested( @@ -569,7 +569,7 @@ public async Task HandleDeferredLlmReplyDispatchRequestedAsync_RehydratesRelayTo RequestedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }), }; - var (agent, _) = CreateAgent(runner, "channel-conversation:conv:slack:C1:scope:owner", inbox); + var (agent, _) = CreateAgent(runner, "channel-conversation:conv:slack:C1:scope:owner", dispatcher); var inboundActivity = CreateActivity("nyx-msg-1", "conv:slack:C1"); inboundActivity.OutboundDelivery = new OutboundDeliveryContext @@ -586,10 +586,10 @@ await agent.HandleNyxRelayInboundActivityAsync(new NyxRelayInboundActivity CorrelationId = "legacy-callback-jti-1", }); - inbox.Enqueued.Count.ShouldBe(1); - inbox.Enqueued[0].ReplyToken.ShouldBe(sentinelReplyToken); - inbox.Enqueued[0].TargetActorId.ShouldBe(agent.Id); - inbox.Enqueued.Clear(); + dispatcher.Dispatched.Count.ShouldBe(1); + dispatcher.Dispatched[0].ReplyToken.ShouldBe(sentinelReplyToken); + dispatcher.Dispatched[0].TargetActorId.ShouldBe(agent.Id); + dispatcher.Dispatched.Clear(); await agent.HandleDeferredLlmReplyDispatchRequestedAsync(new DeferredLlmReplyDispatchRequestedEvent { @@ -597,10 +597,10 @@ await agent.HandleDeferredLlmReplyDispatchRequestedAsync(new DeferredLlmReplyDis RequestedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); - inbox.Enqueued.Count.ShouldBe(1); - inbox.Enqueued[0].CorrelationId.ShouldBe("nyx-msg-1"); - inbox.Enqueued[0].ReplyToken.ShouldBe(sentinelReplyToken); - inbox.Enqueued[0].TargetActorId.ShouldBe(agent.Id); + dispatcher.Dispatched.Count.ShouldBe(1); + dispatcher.Dispatched[0].CorrelationId.ShouldBe("nyx-msg-1"); + dispatcher.Dispatched[0].ReplyToken.ShouldBe(sentinelReplyToken); + dispatcher.Dispatched[0].TargetActorId.ShouldBe(agent.Id); } [Fact] @@ -653,13 +653,13 @@ await agent.HandleLlmReplyReadyAsync(new LlmReplyReadyEvent } [Fact] - public async Task HandleInboundActivityAsync_StripsReplyTokenFromPersistedNeedsLlmReplyEvent_ButKeepsItOnInboxCopy() + public async Task HandleInboundActivityAsync_StripsReplyTokenFromPersistedNeedsLlmReplyEvent_ButKeepsItOnRunCommandCopy() { // Strip-on-persist invariant: NeedsLlmReplyEvent must keep reply_token on the - // copy enqueued to inbox so the LLM worker can echo it back, but the persisted + // copy dispatched to the run actor so the LLM worker can echo it back, but the persisted // copy that lands in event store must omit it. const string sentinelReplyToken = "sentinel-strip-on-persist-1f8b3"; - var inbox = new RecordingInbox(); + var dispatcher = new RecordingRunDispatcher(); var runner = new RecordingTurnRunner { InboundResultFactory = activity => ConversationTurnResult.LlmReplyRequested( @@ -674,7 +674,7 @@ public async Task HandleInboundActivityAsync_StripsReplyTokenFromPersistedNeedsL ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(), }), }; - var (agent, store) = CreateAgent(runner, "conv-strip-token", inbox); + var (agent, store) = CreateAgent(runner, "conv-strip-token", dispatcher); var inboundActivity = CreateActivity("act-strip", "conv:slack:C1"); inboundActivity.OutboundDelivery = new OutboundDeliveryContext @@ -684,8 +684,8 @@ public async Task HandleInboundActivityAsync_StripsReplyTokenFromPersistedNeedsL }; await agent.HandleInboundActivityAsync(inboundActivity); - inbox.Enqueued.Count.ShouldBe(1); - inbox.Enqueued[0].ReplyToken.ShouldBe(sentinelReplyToken); + dispatcher.Dispatched.Count.ShouldBe(1); + dispatcher.Dispatched[0].ReplyToken.ShouldBe(sentinelReplyToken); var events = await store.GetEventsAsync(agent.Id); events.ShouldNotBeEmpty(); @@ -703,12 +703,12 @@ public async Task HandleDeferredLlmReplyDispatchRequestedAsync_ReEnrichesStrippe { // Regression for Codex review: the persisted NeedsLlmReplyEvent in // State.PendingLlmReplyRequests always has an empty ReplyToken (strip-on-persist). - // On the retry / durable-reminder path we walk that state, so the inbox must see + // On the retry / durable-reminder path we walk that state, so the run dispatcher must see // the token re-enriched from the actor's in-memory dict while the activation is - // still alive. Without enrichment the inbox subscriber's relay gate would drop + // still alive. Without enrichment the run actor's relay gate would drop // the retry and permanently lose the reply. const string sentinelReplyToken = "sentinel-retry-enrich-b3d7a"; - var inbox = new RecordingInbox(); + var dispatcher = new RecordingRunDispatcher(); var runner = new RecordingTurnRunner { InboundResultFactory = activity => ConversationTurnResult.LlmReplyRequested( @@ -723,7 +723,7 @@ public async Task HandleDeferredLlmReplyDispatchRequestedAsync_ReEnrichesStrippe ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(), }), }; - var (agent, _) = CreateAgent(runner, "conv-retry-enrich", inbox); + var (agent, _) = CreateAgent(runner, "conv-retry-enrich", dispatcher); var inboundActivity = CreateActivity("act-retry", "conv:slack:C1"); inboundActivity.OutboundDelivery = new OutboundDeliveryContext @@ -739,29 +739,29 @@ public async Task HandleDeferredLlmReplyDispatchRequestedAsync_ReEnrichesStrippe CorrelationId = "corr-retry", }; - // Inbound capture populates the actor runtime dict and enqueues with ReplyToken set directly. + // Inbound capture populates the actor runtime dict and dispatches with ReplyToken set directly. await agent.HandleNyxRelayInboundActivityAsync(relayInbound); - inbox.Enqueued.Count.ShouldBe(1); - inbox.Enqueued[0].ReplyToken.ShouldBe(sentinelReplyToken); + dispatcher.Dispatched.Count.ShouldBe(1); + dispatcher.Dispatched[0].ReplyToken.ShouldBe(sentinelReplyToken); // Simulate the durable-reminder retry firing: pendingRequest is read from state // where ReplyToken was stripped. DispatchPendingLlmReplyAsync must re-enrich - // from the actor dict so the inbox still receives the token. + // from the actor dict so the run dispatcher still receives the token. await agent.HandleDeferredLlmReplyDispatchRequestedAsync(new DeferredLlmReplyDispatchRequestedEvent { CorrelationId = "corr-retry", RequestedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); - inbox.Enqueued.Count.ShouldBe(2); - inbox.Enqueued[1].ReplyToken.ShouldBe(sentinelReplyToken); + dispatcher.Dispatched.Count.ShouldBe(2); + dispatcher.Dispatched[1].ReplyToken.ShouldBe(sentinelReplyToken); } [Fact] - public async Task HandleLlmReplyReadyAsync_PrefersInboxEchoedReplyToken_OverActorRuntimeDict() + public async Task HandleLlmReplyReadyAsync_PrefersRunEchoedReplyToken_OverActorRuntimeDict() { // After a pod restart the in-memory _nyxRelayReplyTokens dict is empty, so the - // outbound reply must be able to consume the inbox-echoed reply_token from + // outbound reply must be able to consume the run-echoed reply_token from // LlmReplyReadyEvent directly. Capture the token observed by the runner to confirm. ConversationTurnRuntimeContext? observedContext = null; var runner = new RecordingTurnRunner @@ -777,45 +777,45 @@ public async Task HandleLlmReplyReadyAsync_PrefersInboxEchoedReplyToken_OverActo }), }; runner.LlmReplyContextObserver = ctx => observedContext = ctx; - var (agent, _) = CreateAgent(runner, "conv-inbox-echo"); + var (agent, _) = CreateAgent(runner, "conv-run-echo"); - var activity = CreateActivity("act-inbox-echo", "conv:slack:C1"); + var activity = CreateActivity("act-run-echo", "conv:slack:C1"); activity.OutboundDelivery = new OutboundDeliveryContext { ReplyMessageId = "relay-msg-echo", - CorrelationId = "corr-inbox-echo", + CorrelationId = "corr-run-echo", }; await agent.HandleLlmReplyReadyAsync(new LlmReplyReadyEvent { - CorrelationId = "nyx-msg-inbox-echo", + CorrelationId = "nyx-msg-run-echo", RegistrationId = "reg-1", SourceActorId = "llm-worker-1", Activity = activity.Clone(), Outbound = new MessageContent { Text = "reply-from-llm" }, TerminalState = LlmReplyTerminalState.Completed, ReadyAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - ReplyToken = "inbox-echoed-token", + ReplyToken = "run-echoed-token", ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(), }); observedContext.ShouldNotBeNull(); observedContext!.NyxRelayReplyToken.ShouldNotBeNull(); - observedContext.NyxRelayReplyToken!.ReplyToken.ShouldBe("inbox-echoed-token"); - observedContext.NyxRelayReplyToken.CorrelationId.ShouldBe("corr-inbox-echo"); + observedContext.NyxRelayReplyToken!.ReplyToken.ShouldBe("run-echoed-token"); + observedContext.NyxRelayReplyToken.CorrelationId.ShouldBe("corr-run-echo"); observedContext.NyxRelayReplyToken.ReplyMessageId.ShouldBe("relay-msg-echo"); } [Fact] public async Task HandleDeferredLlmReplyDroppedAsync_RetiresPendingRequestWithNotRetryableFailure() { - // Inbox-side gates (stale-age, missing relay credential, malformed payload) need + // Run actor gates (stale-age, missing relay credential, malformed payload) need // a way to tell the actor "stop tracking this pending request" so it doesn't // silently accumulate in State.PendingLlmReplyRequests until the next // rehydration. The actor's drop handler emits a NotRetryable // ConversationContinueFailedEvent which routes through the existing state // matcher to remove the pending entry. - var inbox = new RecordingInbox(); + var dispatcher = new RecordingRunDispatcher(); var runner = new RecordingTurnRunner { InboundResultFactory = activity => ConversationTurnResult.LlmReplyRequested( @@ -830,7 +830,7 @@ public async Task HandleDeferredLlmReplyDroppedAsync_RetiresPendingRequestWithNo ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeMilliseconds(), }), }; - var (agent, store) = CreateAgent(runner, "conv-drop-clears", inbox); + var (agent, store) = CreateAgent(runner, "conv-drop-clears", dispatcher); var inboundActivity = CreateActivity("act-drop", "conv:slack:C1"); inboundActivity.OutboundDelivery = new OutboundDeliveryContext @@ -844,7 +844,7 @@ public async Task HandleDeferredLlmReplyDroppedAsync_RetiresPendingRequestWithNo await agent.HandleDeferredLlmReplyDroppedAsync(new DeferredLlmReplyDroppedEvent { CorrelationId = "corr-drop", - Reason = "stale_inbox_request_dropped", + Reason = "stale_agent_run_request_dropped", DroppedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); @@ -853,7 +853,7 @@ await agent.HandleDeferredLlmReplyDroppedAsync(new DeferredLlmReplyDroppedEvent var lastEvent = events[^1]; lastEvent.EventType.ShouldContain(nameof(ConversationContinueFailedEvent)); var failed = ConversationContinueFailedEvent.Parser.ParseFrom(lastEvent.EventData.Value); - failed.ErrorCode.ShouldBe("stale_inbox_request_dropped"); + failed.ErrorCode.ShouldBe("stale_agent_run_request_dropped"); failed.RetryPolicyCase.ShouldBe(ConversationContinueFailedEvent.RetryPolicyOneofCase.NotRetryable); } @@ -866,7 +866,7 @@ public async Task HandleDeferredLlmReplyDroppedAsync_IgnoresUnknownCorrelationId await agent.HandleDeferredLlmReplyDroppedAsync(new DeferredLlmReplyDroppedEvent { CorrelationId = "corr-not-pending", - Reason = "stale_inbox_request_dropped", + Reason = "stale_agent_run_request_dropped", DroppedAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); @@ -984,7 +984,7 @@ await agent.HandleLlmReplyStreamChunkAsync( { CorrelationId = "act-stream-sc", RegistrationId = "reg-1", - SourceActorId = "llm-inbox", + SourceActorId = "agent-run", Activity = CreateRelayActivity("act-stream-sc", "relay-msg-1"), Outbound = new MessageContent { Text = "final text" }, TerminalState = LlmReplyTerminalState.Completed, @@ -1026,7 +1026,7 @@ await agent.HandleLlmReplyStreamChunkAsync( { CorrelationId = "act-stream-fb", RegistrationId = "reg-1", - SourceActorId = "llm-inbox", + SourceActorId = "agent-run", Activity = CreateRelayActivity("act-stream-fb", "relay-msg-1"), Outbound = new MessageContent { Text = "final text" }, TerminalState = LlmReplyTerminalState.Completed, @@ -1112,7 +1112,7 @@ await agent.HandleLlmReplyStreamChunkAsync( { CorrelationId = "act-stream-final-retry", RegistrationId = "reg-1", - SourceActorId = "llm-inbox", + SourceActorId = "agent-run", Activity = CreateRelayActivity("act-stream-final-retry", "relay-msg-1"), Outbound = new MessageContent { Text = "hello world final" }, TerminalState = LlmReplyTerminalState.Completed, @@ -1161,7 +1161,7 @@ await agent.HandleLlmReplyStreamChunkAsync( { CorrelationId = "act-stream-final-degraded", RegistrationId = "reg-1", - SourceActorId = "llm-inbox", + SourceActorId = "agent-run", Activity = CreateRelayActivity("act-stream-final-degraded", "relay-msg-1"), Outbound = new MessageContent { Text = "hello partial more final" }, TerminalState = LlmReplyTerminalState.Completed, @@ -1177,6 +1177,117 @@ await agent.HandleLlmReplyStreamChunkAsync( completed.Outbound.Text.ShouldBe("hello partial"); } + [Fact] + public async Task HandleLlmReplyReadyAsync_WhenStreamingStartedThenLlmFailed_EditsPlaceholderInsteadOfReusingToken() + { + // Production scenario (issue observed 2026-05-03): user sends a message, + // streaming sink fires the first chunk via /reply (consuming the reply + // token, placing a "..." placeholder), the LLM call then 429's before + // any real chunk arrives. Pre-fix the failure path fell through to + // RunLlmReplyAsync which issued a fresh /reply against the dead token + // and got 401, leaving the user staring at "..." forever with no error + // text. Self-heal: TryCompleteStreamedReplyAsync's Failed branch must + // EDIT the placeholder via RunStreamChunkAsync with the failure text + // instead of reusing the consumed reply token. + var callCount = 0; + string? lastEditedText = null; + var runner = new RecordingTurnRunner + { + StreamChunkResultFactory = (chunk, pmid) => + { + callCount++; + lastEditedText = chunk.AccumulatedText; + if (callCount == 1) + return ConversationStreamChunkResult.Succeeded("om_placeholder_consumed"); + // Second call is the failure-edit initiated from the Failed + // branch; it succeeds in production because /reply/update + // works on the existing message regardless of the reply token. + return ConversationStreamChunkResult.Succeeded(pmid ?? "om_placeholder_consumed"); + }, + }; + var (agent, store) = CreateAgent(runner, "conv-stream-failed-edit"); + SeedReplyToken(agent, "act-stream-failed", "token-1", "relay-msg-1"); + + // First chunk lands the placeholder + consumes the reply token. + await agent.HandleLlmReplyStreamChunkAsync( + CreateStreamChunk("act-stream-failed", "relay-msg-1", "...")); + + var ready = new LlmReplyReadyEvent + { + CorrelationId = "act-stream-failed", + RegistrationId = "reg-1", + SourceActorId = "agent-run", + Activity = CreateRelayActivity("act-stream-failed", "relay-msg-1"), + // Run actor classifies the LLM exception into a user-facing + // message and stuffs it into Outbound.Text on the Failed event. + Outbound = new MessageContent { Text = "Sorry, the upstream model is rate limited (HTTP 429). Please try again in a moment." }, + TerminalState = LlmReplyTerminalState.Failed, + ErrorCode = "llm_reply_failed", + ErrorSummary = "Upstream LLM rate limited.", + ReadyAtUnixMs = 100, + }; + await agent.HandleLlmReplyReadyAsync(ready); + + // Must NOT fall through to RunLlmReplyAsync (would 401 on the dead token). + runner.LlmReplyCount.ShouldBe(0); + // Two RunStreamChunkAsync calls: first chunk + failure-edit. + callCount.ShouldBe(2); + // The placeholder was edited with the classified failure text. + lastEditedText.ShouldContain("rate limited"); + + var events = await store.GetEventsAsync(agent.Id); + events.Last().EventType.ShouldContain(nameof(ConversationTurnCompletedEvent)); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + completed.Outbound.Text.ShouldContain("rate limited"); + completed.SentActivityId.ShouldStartWith("nyx-relay-stream:"); + } + + [Fact] + public async Task HandleLlmReplyReadyAsync_WhenStreamingStartedAndFailedEditAlsoFails_PersistsLastFlushedAsTerminalWithoutReusingToken() + { + // Defence in depth for the Failed branch: if even the in-place edit + // is rejected (e.g. Lark refuses an edit of a message past its window), + // we still must NOT fall through to RunLlmReplyAsync. Persist what + // the user already sees (the streaming partial / placeholder) and + // stop — anything else would 401 on the dead token. + var callCount = 0; + var runner = new RecordingTurnRunner + { + StreamChunkResultFactory = (_, _) => + { + callCount++; + if (callCount == 1) + return ConversationStreamChunkResult.Succeeded("om_placeholder_consumed"); + return ConversationStreamChunkResult.Failed("relay_reply_edit_unsupported", "lark refused", editUnsupported: true); + }, + }; + var (agent, store) = CreateAgent(runner, "conv-stream-failed-edit-deny"); + SeedReplyToken(agent, "act-stream-failed-deny", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyStreamChunkAsync( + CreateStreamChunk("act-stream-failed-deny", "relay-msg-1", "first partial")); + + var ready = new LlmReplyReadyEvent + { + CorrelationId = "act-stream-failed-deny", + RegistrationId = "reg-1", + SourceActorId = "agent-run", + Activity = CreateRelayActivity("act-stream-failed-deny", "relay-msg-1"), + Outbound = new MessageContent { Text = "Sorry, the LLM call failed." }, + TerminalState = LlmReplyTerminalState.Failed, + ErrorCode = "llm_reply_failed", + ErrorSummary = "Upstream failure.", + ReadyAtUnixMs = 100, + }; + await agent.HandleLlmReplyReadyAsync(ready); + + runner.LlmReplyCount.ShouldBe(0); + var events = await store.GetEventsAsync(agent.Id); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + // User keeps the last flushed partial since the edit attempt failed too. + completed.Outbound.Text.ShouldBe("first partial"); + } + private static LlmReplyStreamChunkEvent CreateStreamChunk(string correlationId, string replyMessageId, string accumulatedText) => new() { @@ -1225,7 +1336,8 @@ private static void SeedReplyToken(ConversationGAgent agent, string correlationI private static (ConversationGAgent agent, IEventStore store) CreateAgent( RecordingTurnRunner runner, string agentId, - IChannelLlmReplyInbox? inbox = null) + IChannelLlmReplyRunDispatcher? dispatcher = null, + IConversationCardTurnRunner? cardRunner = null) { var store = new InMemoryEventStore(); var services = new ServiceCollection(); @@ -1233,8 +1345,10 @@ private static (ConversationGAgent agent, IEventStore store) CreateAgent( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(runner); - if (inbox is not null) - services.AddSingleton(inbox); + if (cardRunner is not null) + services.AddSingleton(cardRunner); + if (dispatcher is not null) + services.AddSingleton(dispatcher); services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); var sp = services.BuildServiceProvider(); @@ -1399,13 +1513,13 @@ public Task OnReplyDeliveredAsync(ChatActivity activity, CancellationToken ct) } } - private sealed class RecordingInbox : IChannelLlmReplyInbox + private sealed class RecordingRunDispatcher : IChannelLlmReplyRunDispatcher { - public List Enqueued { get; } = []; + public List Dispatched { get; } = []; - public Task EnqueueAsync(NeedsLlmReplyEvent request, CancellationToken ct) + public Task DispatchAsync(NeedsLlmReplyEvent request, CancellationToken ct) { - Enqueued.Add(request.Clone()); + Dispatched.Add(request.Clone()); return Task.CompletedTask; } } @@ -1434,4 +1548,324 @@ public Task ScheduleTimerAsync( public Task PurgeActorAsync(string actorId, CancellationToken ct = default) => Task.CompletedTask; } + + // ─── Lark CardKit card-mode streaming tests ─── + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_FirstChunk_RunsCardCreate() + { + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Succeeded("card_xyz", "om_card_msg"), + }; + var (agent, _) = CreateAgent(new RecordingTurnRunner(), "conv-card-first", cardRunner: card); + SeedReplyToken(agent, "act-card-first", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-first", "relay-msg-1", "hello")); + + card.CardCreateCount.ShouldBe(1); + card.CardStreamCount.ShouldBe(0); + } + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_SubsequentChunk_RunsCardStreamWithIncrementingSequence() + { + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Succeeded("card_xyz", "om_card_msg"), + CardStreamResultFactory = (_, _, _, _) => ConversationCardStreamResult.Succeeded(), + }; + var (agent, _) = CreateAgent(new RecordingTurnRunner(), "conv-card-seq", cardRunner: card); + SeedReplyToken(agent, "act-card-seq", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-seq", "relay-msg-1", "first")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-seq", "relay-msg-1", "first plus second")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-seq", "relay-msg-1", "first plus second plus third")); + + card.CardCreateCount.ShouldBe(1); + card.CardStreamCount.ShouldBe(2); + card.LastCardStreamSequence.ShouldBe(3L); + } + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_CreateRateLimited_FallsBackToTextEdit() + { + // Card create reports a fallbackable failure (rate limit / table limit). The actor must + // route the chunk to the legacy text-edit path so the user still sees a reply, and + // every subsequent chunk for the same correlation continues down the text-edit path + // because the card phase is now CreationFailed. + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Failed( + "card_create_failed", + "rate-limited", + isRateLimited: true), + }; + var text = new RecordingTurnRunner + { + StreamChunkResultFactory = (_, currentPmid) => + ConversationStreamChunkResult.Succeeded(currentPmid ?? "om_text_first"), + }; + var (agent, _) = CreateAgent(text, "conv-card-fallback", cardRunner: card); + SeedReplyToken(agent, "act-card-fallback", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-fallback", "relay-msg-1", "hello")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-fallback", "relay-msg-1", "hello world")); + + card.CardCreateCount.ShouldBe(1); + card.CardStreamCount.ShouldBe(0); + text.StreamChunkCount.ShouldBe(2); + } + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_StreamRateLimited_DropsFrameAndKeepsSequence() + { + // Mid-stream rate-limit (Lark 230020) is recoverable: the card path skips the frame + // and the next chunk re-uses the same sequence slot. The card runner should observe + // the same sequence on the failing call and the recovering call (fresh seq=2). + var seenSequences = new List(); + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Succeeded("card_xyz", "om_card_msg"), + CardStreamResultFactory = (_, _, _, sequence) => + { + seenSequences.Add(sequence); + return seenSequences.Count == 1 + ? ConversationCardStreamResult.Failed("card_rate_limit", "slow down", isRateLimited: true) + : ConversationCardStreamResult.Succeeded(); + }, + }; + var (agent, _) = CreateAgent(new RecordingTurnRunner(), "conv-card-rate", cardRunner: card); + SeedReplyToken(agent, "act-card-rate", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-rate", "relay-msg-1", "first")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-rate", "relay-msg-1", "first plus second")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-rate", "relay-msg-1", "first plus second plus third")); + + seenSequences.ShouldBe(new[] { 2L, 2L }); + card.CardStreamCount.ShouldBe(2); + } + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_TableLimit_TerminatesPersistsAndDropsLaterChunks() + { + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Succeeded("card_xyz", "om_card_msg"), + CardStreamResultFactory = (_, _, _, _) => + ConversationCardStreamResult.Failed("card_table_limit", "too big", isTableLimitExceeded: true), + }; + var (agent, store) = CreateAgent(new RecordingTurnRunner(), "conv-card-tl", cardRunner: card); + SeedReplyToken(agent, "act-card-tl", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-tl", "relay-msg-1", "first")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-tl", "relay-msg-1", "first plus second")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-tl", "relay-msg-1", "first plus second plus third")); + + // Only one CardStream call before termination; chunk 3 is dropped by the + // ProcessedCommandIds guard once mid-stream persistence ran. + card.CardStreamCount.ShouldBe(1); + + // Mid-stream Terminated must persist a partial-card terminal record so the event + // store has a terminal entry before LlmReplyReady arrives — otherwise the ready + // event would fall through to RunLlmReplyAsync and post a duplicate text reply. + var events = await store.GetEventsAsync(agent.Id); + events.Last().EventType.ShouldContain(nameof(ConversationTurnCompletedEvent)); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + completed.SentActivityId.ShouldStartWith("lark-card-stream:"); + completed.Outbound.Text.ShouldBe("first"); + } + + [Fact] + public async Task HandleLlmReplyStreamChunkAsync_CardMode_PostSendFirstStreamFailure_TerminatesWithoutTextEditFallback() + { + // Regression for codex P2: when card.create + im.messages.send succeed but the + // first cardElement.content write fails, the card is already visible in the chat. + // The actor must NOT fall back to the legacy text-edit sink (that would post a + // duplicate reply on top of the empty card). It transitions to Terminated, persists + // a partial-card terminal record, and the text-edit runner is never invoked. + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.PostSendFailed( + cardId: "card_orphan", + cardMessageId: "om_orphan", + errorCode: "card_first_stream_failed", + errorSummary: "stream rejected"), + }; + var text = new RecordingTurnRunner(); + var (agent, store) = CreateAgent(text, "conv-card-postsend", cardRunner: card); + SeedReplyToken(agent, "act-card-postsend", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-postsend", "relay-msg-1", "hello")); + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-postsend", "relay-msg-1", "hello world")); + + // Card runner saw create exactly once; text-edit runner never saw a chunk because + // the post-send-failure path terminates instead of falling back. + card.CardCreateCount.ShouldBe(1); + text.StreamChunkCount.ShouldBe(0); + + // Partial-card terminal record persisted with the orphan card_message_id. + var events = await store.GetEventsAsync(agent.Id); + events.Last().EventType.ShouldContain(nameof(ConversationTurnCompletedEvent)); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + completed.SentActivityId.ShouldBe("lark-card-stream:om_orphan"); + } + + [Fact] + public async Task HandleLlmReplyReadyAsync_CardModeStreamingCompleted_PersistsLarkCardStreamPrefix() + { + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Succeeded("card_xyz", "om_card_msg"), + CardFinalizeResultFactory = (_, _, _, _) => ConversationCardFinalizeResult.Succeeded(), + }; + var (agent, store) = CreateAgent(new RecordingTurnRunner(), "conv-card-finalize", cardRunner: card); + SeedReplyToken(agent, "act-card-finalize", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-finalize", "relay-msg-1", "complete answer")); + + var ready = new LlmReplyReadyEvent + { + CorrelationId = "act-card-finalize", + RegistrationId = "reg-1", + SourceActorId = "agent-run", + Activity = CreateRelayActivity("act-card-finalize", "relay-msg-1"), + Outbound = new MessageContent { Text = "complete answer" }, + TerminalState = LlmReplyTerminalState.Completed, + ReadyAtUnixMs = 100, + }; + await agent.HandleLlmReplyReadyAsync(ready); + + card.CardFinalizeCount.ShouldBe(1); + var events = await store.GetEventsAsync(agent.Id); + events.Last().EventType.ShouldContain(nameof(ConversationTurnCompletedEvent)); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + completed.SentActivityId.ShouldStartWith("lark-card-stream:"); + completed.Outbound.Text.ShouldBe("complete answer"); + } + + [Fact] + public async Task HandleLlmReplyReadyAsync_CardCreationFailed_DefersToTextEditFallbackPath() + { + // After CreationFailed, the card finalize must NOT run; the existing edit-message + // finalize path takes over. This guards against a regression where TryComplete + // CardStreamedReplyAsync incorrectly returns true while the card never actually + // streamed, swallowing the legitimate text-edit finalize. + var card = new RecordingCardTurnRunner + { + CardCreateResultFactory = _ => ConversationCardCreateResult.Failed( + "card_create_failed", + "down", + isRateLimited: true), + }; + var text = new RecordingTurnRunner + { + StreamChunkResultFactory = (_, currentPmid) => + ConversationStreamChunkResult.Succeeded(currentPmid ?? "om_text_first"), + }; + var (agent, store) = CreateAgent(text, "conv-card-fb-final", cardRunner: card); + SeedReplyToken(agent, "act-card-fb-final", "token-1", "relay-msg-1"); + + await agent.HandleLlmReplyCardStreamChunkAsync( + CreateCardStreamChunk("act-card-fb-final", "relay-msg-1", "complete answer")); + + var ready = new LlmReplyReadyEvent + { + CorrelationId = "act-card-fb-final", + RegistrationId = "reg-1", + SourceActorId = "agent-run", + Activity = CreateRelayActivity("act-card-fb-final", "relay-msg-1"), + Outbound = new MessageContent { Text = "complete answer" }, + TerminalState = LlmReplyTerminalState.Completed, + ReadyAtUnixMs = 100, + }; + await agent.HandleLlmReplyReadyAsync(ready); + + card.CardFinalizeCount.ShouldBe(0); + // Text-edit finalize lands the ConversationTurnCompletedEvent with the legacy prefix. + var events = await store.GetEventsAsync(agent.Id); + events.Last().EventType.ShouldContain(nameof(ConversationTurnCompletedEvent)); + var completed = ConversationTurnCompletedEvent.Parser.ParseFrom(events.Last().EventData.Value); + completed.SentActivityId.ShouldStartWith("nyx-relay-stream:"); + } + + private static LlmReplyCardStreamChunkEvent CreateCardStreamChunk(string correlationId, string replyMessageId, string accumulatedText) => + new() + { + CorrelationId = correlationId, + RegistrationId = "reg-1", + Activity = CreateRelayActivity(correlationId, replyMessageId), + AccumulatedText = accumulatedText, + ChunkAtUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + private sealed class RecordingCardTurnRunner : IConversationCardTurnRunner + { + public int CardCreateCount; + public int CardStreamCount; + public int CardFinalizeCount; + public long LastCardStreamSequence; + + public Func? CardCreateResultFactory { get; set; } + public Func? CardStreamResultFactory { get; set; } + public Func? CardFinalizeResultFactory { get; set; } + + public Task RunCardCreateAsync( + LlmReplyCardStreamChunkEvent chunk, + string streamingElementId, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + Interlocked.Increment(ref CardCreateCount); + var result = CardCreateResultFactory?.Invoke(chunk) + ?? ConversationCardCreateResult.Succeeded("card_default", "om_card_default"); + return Task.FromResult(result); + } + + public Task RunCardStreamAsync( + LlmReplyCardStreamChunkEvent chunk, + string cardId, + string elementId, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + Interlocked.Increment(ref CardStreamCount); + LastCardStreamSequence = sequence; + var result = CardStreamResultFactory?.Invoke(chunk, cardId, elementId, sequence) + ?? ConversationCardStreamResult.Succeeded(); + return Task.FromResult(result); + } + + public Task RunCardFinalizeAsync( + ChatActivity referenceActivity, + string cardId, + string elementId, + string finalText, + bool finalTextDiffersFromLastFlushed, + long sequence, + ConversationTurnRuntimeContext runtimeContext, + CancellationToken ct) + { + Interlocked.Increment(ref CardFinalizeCount); + var result = CardFinalizeResultFactory?.Invoke(referenceActivity, cardId, elementId, sequence) + ?? ConversationCardFinalizeResult.Succeeded(); + return Task.FromResult(result); + } + } } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs deleted file mode 100644 index 5cdf79d82..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardContentTests.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Linq; -using System.Text.Json; -using Aevatar.GAgents.Channel.Abstractions; -using FluentAssertions; -using Xunit; -using Aevatar.GAgents.Authoring.Lark; - -namespace Aevatar.GAgents.ChannelRuntime.Tests; - -public sealed class AgentBuilderCardContentTests -{ - [Fact] - public void BuildDailyReportForm_EmitsTextInputsAndSubmitButton() - { - var content = AgentBuilderCardContent.BuildDailyReportForm(preferredGithubUsername: null); - - content.Actions.Should().HaveCount(5); - content.Actions.Where(a => a.Kind == ActionElementKind.TextInput) - .Select(a => a.ActionId) - .Should().BeEquivalentTo(new[] - { - "github_username", - "repositories", - "schedule_time", - "schedule_timezone", - }); - - var submit = content.Actions.Single(a => a.Kind == ActionElementKind.FormSubmit); - submit.ActionId.Should().Be("submit_daily_report"); - submit.IsPrimary.Should().BeTrue(); - submit.Arguments["agent_builder_action"].Should().Be("create_daily_report"); - submit.Arguments["run_immediately"].Should().Be("true"); - - content.Cards.Should().HaveCount(1); - content.Cards[0].Title.Should().Be("Create Daily Report Agent"); - } - - [Fact] - public void BuildDailyReportForm_PrefillsSavedGithubUsernameIntoValue_WhenProvided() - { - var content = AgentBuilderCardContent.BuildDailyReportForm("eanzhao"); - - var githubField = content.Actions.Single(a => - a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username"); - // Saved usernames must live in Value so LarkMessageComposer emits default_value and the - // user sees the name as real input text they can edit, not as ghost placeholder that - // disappears on click. - githubField.Value.Should().Be("eanzhao"); - githubField.Placeholder.Should().Be("octocat"); - - content.Cards.Single().Text.Should().Contain("Saved GitHub username: `eanzhao`"); - content.Cards.Single().Text.Should().Contain("already filled in"); - } - - [Fact] - public void BuildDailyReportForm_LeavesValueEmpty_WhenNoSavedUsername() - { - var content = AgentBuilderCardContent.BuildDailyReportForm(preferredGithubUsername: null); - - var githubField = content.Actions.Single(a => - a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username"); - githubField.Value.Should().BeEmpty(); - githubField.Placeholder.Should().Be("octocat"); - } - - [Fact] - public void FormatDailyReportToolReply_OauthRequired_DoesNotDuplicateAuthBlockInTextAndCard() - { - // Regression for the duplicate "GitHub authorization required" block users were seeing - // in Lark: BuildDailyReportCredentialsCard used to set both content.Text (intended as a - // non-card fallback) and content.Cards[0].Text with the same auth block, which Lark's - // form-mode composer concatenated into a single rendered message. The card body is the - // single source of truth — content.Text must stay empty so the composer renders the - // block exactly once. - var toolJson = JsonSerializer.Serialize(new - { - status = "oauth_required", - template = "daily_report", - provider = "GitHub", - provider_id = "provider-github-uuid", - authorization_url = "https://github.com/login/oauth/authorize?client_id=abc", - note = "Connect GitHub in NyxID, then return to Feishu and submit the daily report form again.", - }); - using var doc = JsonDocument.Parse(toolJson); - - var content = AgentBuilderCardContent.FormatDailyReportToolReply(doc.RootElement); - - content.Text.Should().BeEmpty(); - content.Cards.Should().HaveCount(1); - content.Cards[0].Text.Should().Contain("GitHub authorization required"); - content.Cards[0].Text.Should().Contain("provider-github-uuid"); - content.Cards[0].Text.Should().Contain("https://github.com/login/oauth/authorize?client_id=abc"); - } - - [Fact] - public void FormatDailyReportToolReply_OauthRequired_PrefillsSubmittedGithubUsernameInForm() - { - // When the user typed `/daily eanzhao` and the tool returns oauth_required, the - // re-prompt form must pre-fill `eanzhao` into the GitHub Username field — otherwise - // users have to retype after the OAuth round-trip (which is what triggered the - // "fix/2026-04-29_daily-card-auth-prompt" complaint). - var toolJson = JsonSerializer.Serialize(new - { - status = "oauth_required", - template = "daily_report", - provider = "GitHub", - provider_id = "provider-github-uuid", - authorization_url = "https://github.com/login/oauth/authorize?client_id=abc", - github_username = "eanzhao", - note = "Connect GitHub in NyxID, then return to Feishu and submit the daily report form again.", - }); - using var doc = JsonDocument.Parse(toolJson); - - var content = AgentBuilderCardContent.FormatDailyReportToolReply(doc.RootElement); - - var githubField = content.Actions.Single(a => - a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username"); - githubField.Value.Should().Be("eanzhao"); - } - - [Fact] - public void FormatDailyReportToolReply_CredentialsRequired_RendersCredentialsHeading() - { - // The credentials_required branch lacks an authorization_url and uses a "credentials" - // heading instead of "authorization". Same single-render contract as oauth_required. - var toolJson = JsonSerializer.Serialize(new - { - status = "credentials_required", - template = "daily_report", - provider = "GitHub", - provider_id = "provider-github-uuid", - documentation_url = "https://nyxid.example.com/docs/github", - note = "GitHub in NyxID uses user-managed OAuth app credentials.", - }); - using var doc = JsonDocument.Parse(toolJson); - - var content = AgentBuilderCardContent.FormatDailyReportToolReply(doc.RootElement); - - content.Text.Should().BeEmpty(); - content.Cards.Should().HaveCount(1); - content.Cards[0].Text.Should().Contain("GitHub credentials required"); - content.Cards[0].Text.Should().NotContain("GitHub authorization required"); - } - - [Fact] - public void BuildSocialMediaForm_EmitsFormInputsAndSubmitButton() - { - var content = AgentBuilderCardContent.BuildSocialMediaForm(); - - content.Actions.Where(a => a.Kind == ActionElementKind.TextInput) - .Select(a => a.ActionId) - .Should().BeEquivalentTo(new[] - { - "topic", - "audience", - "style", - "schedule_time", - "schedule_timezone", - }); - - var submit = content.Actions.Single(a => a.Kind == ActionElementKind.FormSubmit); - submit.Arguments["agent_builder_action"].Should().Be("create_social_media"); - } -} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs index c7c1592ea..80810735e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderCardFlowTests.cs @@ -1,119 +1,15 @@ using System.Linq; using System.Text.Json; using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgents.Channel.Runtime; using FluentAssertions; using Xunit; using Aevatar.GAgents.Authoring.Lark; -using Aevatar.GAgents.Channel.Runtime; -using StudioUserConfig = Aevatar.Studio.Application.Studio.Abstractions.UserConfig; namespace Aevatar.GAgents.ChannelRuntime.Tests; public sealed class AgentBuilderCardFlowTests { - [Fact] - public async Task TryResolveAsync_DailyReportLaunch_PrefillsSavedGithubUsername() - { - // Inbound carries Platform + SenderId so the prefill query must hit the per-user - // scope (`scope-1:lark:ou_alice`), not the bot-level `scope-1` — otherwise multiple - // Lark users sharing a bot would see each other's saved usernames (issue #436). - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - RegistrationScopeId = "scope-1", - Platform = "lark", - SenderId = "ou_alice", - Text = "/daily", - }; - var queryPort = new MapStubUserConfigQueryPort(); - queryPort.SetGithubUsername("scope-1:lark:ou_alice", "saved-user"); - - var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, queryPort); - - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeFalse(); - decision.ReplyContent.Should().NotBeNull(); - - var githubInput = decision.ReplyContent!.Actions.Single(a => - a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username"); - // Saved usernames belong in Value (rendered as default_value) so the user sees editable text - // rather than placeholder ghost text that disappears on click. - githubInput.Value.Should().Be("saved-user"); - - decision.ReplyContent.Cards.Single().Text.Should().Contain("saved-user"); - decision.ReplyContent.Cards.Single().Text.Should().Contain("already filled in"); - } - - [Fact] - public async Task TryResolveAsync_DailyReportLaunch_TwoLarkUsersInSameBot_SeeIndependentSavedUsernames() - { - // Issue #436: when colleagues share one Lark bot, the prefill must read each - // sender's own saved github_username — not the most recent writer's value. - // Pin that the per-user scope (`{bot}:{platform}:{sender}`) is what reaches the - // query port, so the read isn't accidentally collapsed back to the bot scope. - var queryPort = new MapStubUserConfigQueryPort(); - queryPort.SetGithubUsername("scope-1:lark:ou_alice", "alice"); - queryPort.SetGithubUsername("scope-1:lark:ou_bob", "bob"); - - var aliceInbound = new ChannelInboundEvent - { - ChatType = "p2p", - RegistrationScopeId = "scope-1", - Platform = "lark", - SenderId = "ou_alice", - Text = "/daily", - }; - var bobInbound = new ChannelInboundEvent - { - ChatType = "p2p", - RegistrationScopeId = "scope-1", - Platform = "lark", - SenderId = "ou_bob", - Text = "/daily", - }; - - var aliceDecision = await AgentBuilderCardFlow.TryResolveAsync(aliceInbound, queryPort); - var bobDecision = await AgentBuilderCardFlow.TryResolveAsync(bobInbound, queryPort); - - aliceDecision!.ReplyContent!.Actions - .Single(a => a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username") - .Value.Should().Be("alice"); - bobDecision!.ReplyContent!.Actions - .Single(a => a.Kind == ActionElementKind.TextInput && a.ActionId == "github_username") - .Value.Should().Be("bob"); - - queryPort.QueriedScopes.Should().BeEquivalentTo(new[] - { - "scope-1:lark:ou_alice", - "scope-1:lark:ou_bob", - }); - } - - [Fact] - public async Task TryResolveAsync_TemplatesCardButton_DispatchesListTemplatesTool() - { - // The /agents card surfaces a `Templates` button; PR #409 added it. Without an explicit - // case in the card_action switch the button click would no-op and confuse users who - // navigate by tapping rather than typing /templates. Pin the contract so a refactor can - // not silently drop the routing. - var inbound = new ChannelInboundEvent - { - ChatType = "card_action", - RegistrationScopeId = "scope-1", - }; - inbound.Extra["agent_builder_action"] = "list_templates"; - - var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); - - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("list_templates"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("action").GetString().Should().Be("list_templates"); - } - [Fact] public void FormatToolResult_ListAgents_ReturnsStructuredCardNotJsonText() { @@ -131,7 +27,7 @@ public void FormatToolResult_ListAgents_ReturnsStructuredCardNotJsonText() "agents": [ { "agent_id": "skill-runner-card-click-1", - "template": "daily_report", + "template": "daily", "status": "running", "next_scheduled_run": "2026-04-23T09:00:00Z" } @@ -181,48 +77,6 @@ public void FormatToolResult_DeleteAgent_RendersUpdatedListWithNotice() 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() { @@ -232,7 +86,7 @@ public void FormatToolResult_AgentStatus_ReturnsStructuredCardWithLifecycleButto """ { "agent_id": "skill-runner-1", - "template": "daily_report", + "template": "daily", "status": "running", "schedule_cron": "0 9 * * *", "schedule_timezone": "UTC", @@ -252,7 +106,7 @@ public void FormatToolResult_AgentStatus_ReturnsStructuredCardWithLifecycleButto 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")); + deleteButton.Arguments.Should().Contain(new KeyValuePair("template", "daily")); } [Fact] @@ -264,7 +118,7 @@ public void FormatToolResult_RunAgent_ReturnsStructuredCardNotJsonText() """ { "agent_id": "skill-runner-1", - "template": "daily_report", + "template": "daily", "status": "running", "note": "Manual run dispatched." } @@ -281,29 +135,6 @@ public void FormatToolResult_RunAgent_ReturnsStructuredCardNotJsonText() "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() { @@ -369,7 +200,7 @@ public async Task TryResolveAsync_ConfirmDeleteAgent_ReturnsStructuredCardNotJso }; inbound.Extra["agent_builder_action"] = "confirm_delete_agent"; inbound.Extra["agent_id"] = "skill-runner-1"; - inbound.Extra["template"] = "daily_report"; + inbound.Extra["template"] = "daily"; var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); @@ -379,7 +210,7 @@ public async Task TryResolveAsync_ConfirmDeleteAgent_ReturnsStructuredCardNotJso 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"); + decision.ReplyContent.Cards.Single().Text.Should().Contain("daily"); var confirmButton = decision.ReplyContent.Actions.Should() .Contain(a => a.ActionId == "delete_agent").Subject; confirmButton.IsDanger.Should().BeTrue(); @@ -388,50 +219,4 @@ public async Task TryResolveAsync_ConfirmDeleteAgent_ReturnsStructuredCardNotJso decision.ReplyContent.Actions.Should().Contain(a => a.ActionId == "list_agents"); } - [Fact] - public async Task TryResolveAsync_DailyReportSubmit_AllowsMissingGithubUsername_ForUserConfigFallback() - { - var inbound = new ChannelInboundEvent - { - ChatType = "card_action", - RegistrationScopeId = "scope-1", - }; - inbound.Extra["agent_builder_action"] = "create_daily_report"; - inbound.Extra["schedule_time"] = "09:00"; - - var decision = await AgentBuilderCardFlow.TryResolveAsync(inbound, userConfigQueryPort: null); - - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("action").GetString().Should().Be("create_agent"); - body.RootElement.GetProperty("template").GetString().Should().Be("daily_report"); - body.RootElement.GetProperty("schedule_cron").GetString().Should().Be("0 9 * * *"); - body.RootElement.GetProperty("github_username").ValueKind.Should().Be(JsonValueKind.Null); - } - - private sealed class MapStubUserConfigQueryPort : IUserConfigQueryPort - { - private readonly Dictionary _byScope = new(StringComparer.Ordinal); - private readonly List _queriedScopes = new(); - - public IReadOnlyList QueriedScopes => _queriedScopes; - - public void SetGithubUsername(string scopeId, string githubUsername) - { - _byScope[scopeId] = new StudioUserConfig(DefaultModel: string.Empty, GithubUsername: githubUsername); - } - - public Task GetAsync(CancellationToken ct = default) => - throw new NotSupportedException("Channel paths must call GetAsync(scopeId)."); - - public Task GetAsync(string scopeId, CancellationToken ct = default) - { - _queriedScopes.Add(scopeId); - return Task.FromResult(_byScope.TryGetValue(scopeId, out var config) - ? config - : new StudioUserConfig(DefaultModel: string.Empty, GithubUsername: null)); - } - } } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs index 4901becd2..d9d5d3999 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentBuilderToolTests.cs @@ -23,3033 +23,6 @@ namespace Aevatar.GAgents.ChannelRuntime.Tests; public sealed class AgentBuilderToolTests { - [Fact] - public async Task ExecuteAsync_ListTemplates_ReturnsDailyReportTemplate() - { - var services = new ServiceCollection(); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); - services.AddSingleton(new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(new RoutingJsonHandler()) - { - BaseAddress = new Uri("https://nyx.example.com"), - })); - - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - }; - try - { - var result = await tool.ExecuteAsync("""{"action":"list_templates"}"""); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("templates").EnumerateArray() - .Any(static x => x.GetProperty("name").GetString() == "daily_report") - .Should().BeTrue(); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public void TryBuildDailyReportSpec_SkillContent_PinsStructuredSectionSchema_AndOmitWhenEmptyRule() - { - // Pinning test for issue #423: the daily prompt is treated as a fetch-and-summarize - // SPEC, not a freeform brief. This test fails fast on copy edits that would silently - // regress the multi-section schema, the per-section line budgets, the "omit empty - // section" rule, or the "no measurable activity" empty-day fallback. - var ok = AgentBuilderTemplates.TryBuildDailyReportSpec( - githubUsername: "alice", - repositories: null, - out var spec, - out var error); - - ok.Should().BeTrue(); - error.Should().BeNull(); - spec.Should().NotBeNull(); - - var skillContent = spec!.SkillContent; - - // All nine section slots must be pinned in order — the section position itself is - // load-bearing for the LLM's emission order, even when section 7 (Trend) is optional - // and section 9 (Source health) is conditional. Skipping any number here would let - // copy edits silently drop or reorder a section. - skillContent.Should().Contain("# Output sections"); - skillContent.Should().Contain("1. Title"); - skillContent.Should().Contain("2. Shipped"); - skillContent.Should().Contain("3. In flight"); - skillContent.Should().Contain("4. Reviews"); - skillContent.Should().Contain("5. Issues"); - skillContent.Should().Contain("6. CI"); - skillContent.Should().Contain("7. Trend"); - skillContent.Should().Contain("8. Blockers"); - skillContent.Should().Contain("9. Source health"); - - // Empty-handling rules — the bug we're guarding against is the LLM padding sections - // with "no activity in this area" boilerplate when sources are silent. - skillContent.Should().Contain("OMIT THE SECTION ENTIRELY"); - skillContent.Should().Contain("No measurable activity in the last 24h."); - skillContent.Should().Contain("Do not invent activity."); - - // Section ordering must be unambiguous when both §8 Blockers and §9 Source health are - // present (eanzhao P2 review of PR #458, second pass): the previous "always last" - // qualifier on §8 conflicted with "Source health at the very bottom after Blockers". - // Promote §9 to a real schema slot and pin §8 as position-locked at slot 8 with §9 - // as the only section permitted below. - skillContent.Should().Contain("Position-locked at slot 8"); - skillContent.Should().Contain("the only section that may sit below it is the §9 Source health footer"); - // The empty-day fallback must explicitly forbid both §8 Blockers AND §9 Source health - // emission, so a weaker model cannot synthesize a footer onto a genuine empty day. - skillContent.Should().Contain("do NOT emit Blockers or Source health"); - - // Source-health distinction (eanzhao P1 review of PR #458, refs issue #439): - // collapsing 4xx/5xx/error-shaped tool results into "zero data" silently masks - // revoked OAuth grants and proxy outages as healthy empty-day reports. Pin the - // 2xx-empty vs source-failure distinction AND the rule that the empty-day fallback - // only applies when every source returned 2xx, so a copy edit cannot regress this - // back to the original "4xx/5xx/empty → treat as zero" wording. - skillContent.Should().Contain("2xx with an empty list"); - skillContent.Should().Contain("Source health:"); - skillContent.Should().Contain("ONLY valid when EVERY source returned 2xx"); - skillContent.Should().NotContain("4xx, 5xx, or empty, treat that source as zero"); - - // Substitution-variable documentation must be present and tied to the actual - // username; otherwise the LLM may emit literal `{username}` placeholders in URLs. - skillContent.Should().Contain("`{username}` → `alice`"); - skillContent.Should().Contain("`{iso_date}` → start of the 24h window"); - - // Username substitution must remain intact (other tests check it under the saved-user - // / derived-user paths; this assertion guards the no-args path). - skillContent.Should().Contain("Primary GitHub username: alice"); - - // No-repo mode must include a commit query (Shipped section claims to cover commits) - // and must explicitly skip the CI section (no global Actions run endpoint exists). - skillContent.Should().Contain("/search/commits?q=author:{username}+author-date:>={iso_date}"); - skillContent.Should().Contain("CI section is omitted in no-repo mode"); - } - - [Fact] - public void TryBuildDailyReportSpec_RepoAllowlist_SwitchesToPerRepoQueryGuidance() - { - // Per issue #423: when `repositories=` is provided, the prompt must steer the LLM toward - // per-repo searches and explicitly refuse the collapsed-allowlist global query. PR #458 - // review further required: shipped PRs must filter by author + merge time (search-API - // form, not /pulls?state=closed which is keyed on update time and ignores author), - // commit shipping must have its own source, repo-scoped issues must include the - // commenter case, and the CI query must not embed a {default_branch} placeholder. - var ok = AgentBuilderTemplates.TryBuildDailyReportSpec( - githubUsername: "alice", - repositories: "acme/api, acme/web", - out var spec, - out var error); - - ok.Should().BeTrue(); - error.Should().BeNull(); - spec.Should().NotBeNull(); - - var skillContent = spec!.SkillContent; - skillContent.Should().Contain("Repository scope: acme/api, acme/web"); - skillContent.Should().Contain("Repository allowlist provided"); - skillContent.Should().Contain("do NOT collapse into one global query"); - - // Shipped PRs in repo mode: search-API form keyed on author + merge time. The previous - // /repos/{owner}/{repo}/pulls?state=closed shape (a) returned closed-but-unmerged PRs - // and (b) had no reliable pagination across active repos — codex P1 + eanzhao inline - // both flagged this. Guard against regression. - skillContent.Should().Contain("/search/issues?q=repo:{owner}/{repo}+author:{username}+is:pr+is:merged+merged:>={iso_date}"); - skillContent.Should().NotContain("/repos/{owner}/{repo}/pulls?state=closed"); - - // Shipped commits in repo mode (Shipped section schema includes commits). - skillContent.Should().Contain("/search/commits?q=repo:{owner}/{repo}+author:{username}+author-date:>={iso_date}"); - - // Issues commented on in repo mode (codex P2: schema says "opened, closed, or - // commented on" but author-only query drops the commenter case). - skillContent.Should().Contain("/search/issues?q=repo:{owner}/{repo}+commenter:{username}+is:issue+updated:>={iso_date}"); - - // CI query must NOT embed a {default_branch} placeholder (the LLM has no way to fill - // it without an extra round-trip and a literal `{default_branch}` would land in the - // outbound URL). Filter conclusion + created_at client-side instead. - skillContent.Should().NotContain("{default_branch}"); - skillContent.Should().Contain("/repos/{owner}/{repo}/actions/runs?per_page=10"); - - // The execution prompt is what the runner sends per-trigger; it must echo the - // per-repo constraint so the LLM sees it on every run, not only at agent-create time. - spec.ExecutionPrompt.Should().Contain("acme/api, acme/web"); - spec.ExecutionPrompt.Should().Contain("one pass per repo"); - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_RejectsGroupChats() - { - var services = new ServiceCollection(); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); - services.AddSingleton(Substitute.For()); - services.AddSingleton(new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(new RoutingJsonHandler()) - { - BaseAddress = new Uri("https://nyx.example.com"), - })); - - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "group", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "schedule_cron": "0 9 * * *" - } - """); - - result.Should().Contain("private chat"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DispatchesInitializeAndImmediateTrigger() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-1","full_key":"full-key-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-1", - "github_username": "alice", - "repositories": "aevatarAI/aevatar", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC", - "run_immediately": true - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - doc.RootElement.GetProperty("agent_id").GetString().Should().Be("skill-runner-1"); - doc.RootElement.GetProperty("api_key_id").GetString().Should().Be("key-1"); - doc.RootElement.GetProperty("github_username").GetString().Should().Be("alice"); - doc.RootElement.GetProperty("run_immediately_requested").GetBoolean().Should().BeTrue(); - doc.RootElement.GetProperty("github_username_preference_saved").GetBoolean().Should().BeFalse(); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-1", - Arg.Is(c => - c.TemplateName == "daily_report" && - c.ScopeId == "scope-1" && - c.OutboundConfig.ConversationId == "oc_chat_1" && - c.OutboundConfig.NyxProviderSlug == "api-lark-bot" && - c.OutboundConfig.NyxApiKey == "full-key-1" && - c.OutboundConfig.ApiKeyId == "key-1" && - c.OutboundConfig.OwnerNyxUserId == "user-1" && - // p2p inbound without LarkUnionId in the request context falls back to the - // sender open_id. Lark accepts this only when the relay-side and outbound - // apps match; cross-app deployments must populate LarkUnionId at ingress - // (see test below) to avoid `code:99992361 open_id cross app` rejections. - c.OutboundConfig.LarkReceiveId == "ou_user_1" && - c.OutboundConfig.LarkReceiveIdType == "open_id"), - true, - Arg.Any()); - - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .Should() - .BeEquivalentTo(["svc-github", "svc-lark"]); - // PR #418 review (4175529548): NyxID's `allow_all_services` defaults to `true` - // (api_keys.rs:105) and proxy enforcement only fires when `!allow_all_services` - // (proxy.rs:1030). Pin that the field is *present* and `false` so the resolved - // `allowed_service_ids` actually constrains the key's reach. - apiKeyDoc.RootElement.GetProperty("allow_all_services").GetBoolean().Should().BeFalse(); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_PinsLarkChatId_When_RelayPropagatesIt() - { - // The new outbound priority pins (chat_id, "chat_id") whenever the relay surfaces - // ChannelMetadataKeys.LarkChatId — chat_id is the literal DM thread, no user-id - // translation is needed. This is the integration counterpart of - // LarkConversationTargetsTests.BuildFromInbound_ShouldPreferLarkChatId_ForP2pDirectMessages - // and is what survives both `99992361 open_id cross app` (PR #403/409) and - // `99992364 user id cross tenant` (PR after #409) failure modes in production. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-union-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-union-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-union-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-union-1","full_key":"full-key-union-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_dm_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - [ChannelMetadataKeys.LarkUnionId] = "on_user_1", - [ChannelMetadataKeys.LarkChatId] = "oc_dm_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-union-1", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-union-1", - Arg.Is(c => - c.OutboundConfig.LarkReceiveId == "oc_dm_chat_1" && - c.OutboundConfig.LarkReceiveIdType == "chat_id"), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubProxyDeniedForNewKey() - { - // Issue aevatarAI/aevatar#411 + #417: the create flow preflights GitHub proxy access - // with the freshly minted agent API key. Originally (#411) the failure mode this caught - // was misdiagnosed as a missing api-key→GitHub binding; #417 fixed that root cause by - // populating `allowed_service_ids` with per-user `UserService.id`s instead of catalog - // ids. The probe is retained because GitHub OAuth grants can still be revoked outside - // our control (user clicks "Revoke access" at GitHub, scopes downgraded, account - // temp-banned). Surfacing the 401/403 at create-time avoids persisting an agent that - // would produce empty output on every scheduled run. - // - // Pinned in this test: the structured `github_proxy_access_denied` error is returned - // (no actor invocation), AND the freshly minted api-key IS revoked so retries don't - // accumulate orphan proxy-scoped keys (codex review PR #418 r3141846175). - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-403","full_key":"full-key-403"}"""); - // The preflight: `NyxIdApiClient.SendAsync` wraps any HTTP non-2xx as - // `{"error": true, "status": , "body": ""}` (NyxIdApiClient.cs:680). - // Reviewer (PR #412 r3141699476) caught that the previous handler shape used `"code"` - // but real production uses `"status"` — mirror the actual envelope so the parser is - // exercised against what runtime delivers, not a synthetic shape. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"error": true, "status": 403, "body": "{\"message\":\"Bad credentials\",\"documentation_url\":\"https://docs.github.com/rest\"}"}"""); - // Codex review (PR #418 r3141846175): retries of `/daily` mint a new api-key on every - // run. Without best-effort revoke on preflight failure, the user's NyxID account would - // accumulate one orphan proxy-scoped key per failed retry. Stub the DELETE so the test - // can verify the revoke fires. - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-403", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-github-403", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("github_proxy_access_denied"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(403); - // The hint should point users at re-authorizing the GitHub provider at NyxID, not - // at api-key bindings (which used to be the misdiagnosis under #411 — see #417). - // Match case-insensitively so future hint copy edits (capitalization, punctuation) - // don't require flipping this assertion in lockstep — the *intent* of the assertion - // is "hint mentions re-authorization", not "hint matches one specific prefix". - doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("re-authorize"); - - // The port must NOT be invoked — preflight aborts BEFORE the lifecycle - // dispatch so we don't leave a broken agent in the catalog. - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - // Codex review (PR #418 r3141846175): even though the api-key carries the right - // `allowed_service_ids` under #417, the create flow mints a *new* key per run. - // Without best-effort revoke on preflight failure, every failed `/daily` retry - // would orphan one proxy-scoped key in the user's NyxID account. Pin that the - // DELETE fires so we don't regress on this cleanup. - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-403"); - - // Issue #474 review (eanzhao on PR #479): /rate_limit failure must short-circuit - // ALL of step 2 (global /search/*) and step 3 (per-repo /search/*). Pinning that - // no /search/* call was made guards against a future regression where someone - // re-orders preflight steps or removes the early return on rate_limit failure — - // the 401/403 path is the cheapest fail-fast and should never wastefully fan out - // to scope-stricter endpoints. - handler.Requests.Should().NotContain(r => - r.Path.Contains("/proxy/s/api-github/search/", StringComparison.Ordinal)); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubSearchReturns422() - { - // Issue aevatarAI/aevatar#474: /rate_limit is scope-light — it returns 200 even when the - // bound OAuth grant lacks the scope GitHub's search engine requires (need public_repo - // for public commit/issue search). Pre-#474, that exact gap let agents persist with a - // healthy rate_limit probe, only to 422 every /search/* call at runtime so every - // scheduled run produced an empty daily report. Pin: when /rate_limit returns 200 but - // /search/issues 422s with the production "users... cannot be searched..." body, - // preflight returns the structured `github_search_unauthorized` error, the freshly - // minted api-key IS revoked, and the runner is NOT initialized. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-422","full_key":"full-key-422"}"""); - // /rate_limit is the scope-light step that succeeded in prod — it cannot catch the - // search-API-only failure mode by design. Mirror that: 200 here, fail later on search. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - // Production-shape GitHub 422 body for /search/*. Wrapped in NyxIdApiClient.SendAsync's - // standard `{"error":true,"status":,"body":""}` envelope (NyxIdApiClient.cs:710) - // so the parser is exercised against the runtime envelope, not a synthetic shape. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=author:Yuezh0127&per_page=1", - """{"error":true,"status":422,"body":"{\"message\":\"Validation Failed\",\"errors\":[{\"message\":\"The listed users, organizations or repositories cannot be searched either because the resources do not exist or you do not have permission to view them.\",\"resource\":\"Search\",\"field\":\"q\",\"code\":\"invalid\"}],\"documentation_url\":\"https://docs.github.com/v3/search/\"}"}"""); - // Best-effort revoke must fire on preflight failure so /daily retries don't accumulate - // orphan proxy-scoped keys (same contract as the 403 case under #411 / PR #418). - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-422", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-search-422", - "github_username": "Yuezh0127", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("github_search_unauthorized"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(422); - doc.RootElement.GetProperty("github_path").GetString().Should().Be("/search/issues"); - doc.RootElement.GetProperty("github_username").GetString().Should().Be("Yuezh0127"); - // The body matches GitHub's documented "cannot be searched" surface, which collapses - // user-not-exist and scope-insufficient into one stable code (operators distinguish - // them out of band by checking https://github.com/{username}). - doc.RootElement.GetProperty("reason_code").GetString().Should().Be("scope_insufficient_or_user_not_found"); - // Hint should mention the actionable next step (re-authorize at NyxID with broader - // scope) rather than misdirecting users to fix something else. Match a stable token - // case-insensitively so future copy edits don't snowball into test flips. - doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("re-authorize"); - - // Hard rule: preflight must abort BEFORE any lifecycle dispatch — otherwise we'd - // leave a broken agent in the catalog that runs every cron tick to no effect. - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - // Best-effort revoke fires; mirrors the cleanup contract for the 403 case. - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-422"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubSearchCommitsReturn422_ButIssuesSucceed() - { - // Issue aevatarAI/aevatar#474: production reproduction (issue #473) reported that - // /search/commits failed with the same 422 surface even when other queries returned - // results, and the LLM degraded the section to "unrelated global results, not - // attributable to {user}". Pin: preflight must probe BOTH /search/issues AND - // /search/commits — failing fast on the first one alone leaves the commits-only - // failure undetected at create-time. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-422c","full_key":"full-key-422c"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - // /search/issues happy: the issues surface returns an empty result, so this probe - // passes through. The commits surface still 422s — exercise the second probe. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=author:alice&per_page=1", - """{"total_count":0,"incomplete_results":false,"items":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/commits?q=author:alice&per_page=1", - """{"error":true,"status":422,"body":"{\"message\":\"Validation Failed\",\"errors\":[{\"message\":\"The listed users, organizations or repositories cannot be searched either because the resources do not exist or you do not have permission to view them.\",\"resource\":\"Search\",\"field\":\"q\",\"code\":\"invalid\"}]}"}"""); - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-422c", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-search-422-commits", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("github_search_unauthorized"); - doc.RootElement.GetProperty("github_path").GetString().Should().Be("/search/commits"); - doc.RootElement.GetProperty("reason_code").GetString().Should().Be("scope_insufficient_or_user_not_found"); - - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-422c"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_Succeeds_When_GithubSearchReturnsEmpty200() - { - // Issue aevatarAI/aevatar#474: the new /search/* preflight probes must NOT fail-fast on - // a genuinely empty result — that's the legitimate "user has no recent activity" case - // and is the steady-state for many real users between reports. Pin: when /rate_limit, - // /search/issues, and /search/commits all return 200 (with empty arrays), creation - // proceeds normally and no api-key is revoked. Adding this case alongside the 422 - // tests guards against an over-eager classifier that would treat any non-content - // response as failure. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-search-empty", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-search-empty", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-search-empty", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-empty","full_key":"full-key-empty"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=author:alice&per_page=1", - """{"total_count":0,"incomplete_results":false,"items":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/commits?q=author:alice&per_page=1", - """{"total_count":0,"incomplete_results":false,"items":[]}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-search-empty", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - // No DELETE on the api-key — empty results are not a preflight failure, and - // revoking would leave the just-persisted agent stranded with a dead key. - handler.Requests.Should().NotContain(r => - r.Method == HttpMethod.Delete && - r.Path.StartsWith("/api/v1/api-keys/", StringComparison.Ordinal)); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_GithubSearchReturns422_WithUnknown422Body() - { - // Issue #474 review (eanzhao on PR #479): only `scope_insufficient_or_user_not_found` - // was exercised; pin that a 422 body that does NOT match the "cannot be searched" - // surface (e.g. a malformed query) collapses to the conservative `validation_failed` - // code so callers can still distinguish actionable cases without regex'ing the body - // themselves. This protects the fall-through branch in - // `ClassifyGitHubSearch422Body` from regression: any future heuristic change that - // routes unknown 422 bodies to a more specific code by guessing would fail this. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-422-q","full_key":"full-key-422-q"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - // 422 body without the "cannot be searched" / "permission" markers — represents the - // query-malformed case (e.g. illegal qualifier, exceeded query length). The conservative - // fall-through is `validation_failed`. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=author:alice&per_page=1", - """{"error":true,"status":422,"body":"{\"message\":\"Validation Failed\",\"errors\":[{\"resource\":\"Search\",\"field\":\"q\",\"code\":\"invalid\"}],\"documentation_url\":\"https://docs.github.com/v3/search/\"}"}"""); - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-422-q", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-search-422-q", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("github_search_unauthorized"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(422); - // Conservative fall-through — no specific marker matched. - doc.RootElement.GetProperty("reason_code").GetString().Should().Be("validation_failed"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_RepoScopedSearchReturns422() - { - // Codex review (PR #479 r3152148327, P1): a token can pass the global /search/* - // probes (public_repo lets you search public commits/issues globally) yet 422 every - // repo-qualified call when the configured allowlist contains a private repo the - // token cannot see. Pre-this-fix, the daily-report runtime ran `repo:{owner}/{repo}+ - // author:{username}` queries (per AgentBuilderTemplates.cs repo-mode URLs) and 422'd - // every one, persisting a broken agent. Pin: when global /search/issues and - // /search/commits return 200 but a repo-qualified probe 422s, preflight fails fast - // with `github_search_unauthorized`, the github_path label includes the failing - // repo, and the api-key is revoked. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-422-repo","full_key":"full-key-422-repo"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - // Global probes succeed — token has public_repo scope, can search public globally. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=author:alice&per_page=1", - """{"total_count":0,"incomplete_results":false,"items":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/commits?q=author:alice&per_page=1", - """{"total_count":0,"incomplete_results":false,"items":[]}"""); - // Repo-qualified probe 422s — the configured allowlist points at a private repo the - // token cannot see. This is the new failure mode that global probes don't catch. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/search/issues?q=repo:acme/private-svc+author:alice&per_page=1", - """{"error":true,"status":422,"body":"{\"message\":\"Validation Failed\",\"errors\":[{\"message\":\"The listed users, organizations or repositories cannot be searched either because the resources do not exist or you do not have permission to view them.\",\"resource\":\"Search\",\"field\":\"q\",\"code\":\"invalid\"}]}"}"""); - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-422-repo", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-search-422-repo", - "github_username": "alice", - "repositories": "acme/private-svc", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("github_search_unauthorized"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(422); - // The label carries the failing repo so operators can distinguish "global search - // is broken" from "this specific repo can't be reached" without rerunning. - doc.RootElement.GetProperty("github_path").GetString()!.Should().Contain("acme/private-svc"); - doc.RootElement.GetProperty("reason_code").GetString().Should().Be("scope_insufficient_or_user_not_found"); - - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-422-repo"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_LogsFallbackBreadcrumb_When_LarkUnionIdMissing() - { - // Reviewer (PR #409 r3141562097): when the relay does not surface LarkUnionId at agent - // creation, BuildFromInbound returns (ou_*, open_id, FellBack=true). The flag itself is - // not persisted on OutboundConfig (typed receive id/type only), so a downstream - // LarkConversationTargets.Resolve() at SkillRunner send time sees populated typed fields - // and reports FellBack=false — meaning the cross-app risk is invisible to operators - // unless the agent-create site logs it once. Pin the LogDebug breadcrumb so the - // observability promised in the PR description actually fires in production. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-fallback-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-fallback-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-fallback-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-fallback-1","full_key":"full-key-fallback-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - - var logger = new ListLogger(); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider(), logger); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - // Deliberately NO LarkUnionId / LarkChatId — this is the cross-app risky path. - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-fallback-1", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - // The breadcrumb must capture enough context to correlate with downstream Lark - // `99992361` rejections: agent_id, the missing typed fields, and the chosen receive - // type. Otherwise operators get no signal and the silent-default bug class re-opens. - var fallback = logger.Entries.Should().ContainSingle(entry => - entry.Level == LogLevel.Debug && - entry.Message.Contains("Agent builder fell back to legacy delivery target inference") && - entry.Message.Contains("skill-runner-fallback-1") && - entry.Message.Contains("hasUnionId=False") && - entry.Message.Contains("hasLarkChatId=False") && - entry.Message.Contains("hasSenderId=True") && - entry.Message.Contains("resolvedReceiveIdType=open_id")).Subject; - fallback.Message.Should().Contain("99992361"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_DoesNotLogFallback_When_LarkUnionIdPresent() - { - // Counterpart to the breadcrumb test: when the relay surfaces union_id, the typed - // delivery target is cross-app safe and we must NOT spam Debug logs on every successful - // ingress (otherwise the breadcrumb signal becomes useless noise once /agents traffic - // ramps up). - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-no-fallback-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-no-fallback-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-no-fallback-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-no-fallback-1","full_key":"full-key-no-fallback-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - - var logger = new ListLogger(); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider(), logger); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - [ChannelMetadataKeys.LarkUnionId] = "on_user_1", - [ChannelMetadataKeys.LarkChatId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-no-fallback-1", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - logger.Entries.Should().NotContain(entry => - entry.Message.Contains("fell back to legacy delivery target inference")); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_UsesSavedGithubUsernamePreference_WhenArgumentMissing() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-pref-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-pref-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-pref-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - // Issue #436 PR #438 review: pin that the no-username `/daily` relay path reads the - // saved github_username from the per-end-user composite scope, not the bot's - // RegistrationScopeId. Without sender_id + platform set in the metadata this test - // would silently keep passing if the read accidentally drifted back to `configScopeId`. - var userConfigQueryPort = Substitute.For(); - userConfigQueryPort.GetAsync("scope-1:lark:ou_alice", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty, GithubUsername: "saved-user"))); - // Bot scope alone must NOT resolve a saved username: if the read regressed back to - // `configScopeId`, the prompt assertion below would still pass because both stubs - // would return "saved-user". Stub the bot-scope key with a sentinel so the assertion - // fails loudly on regression. - userConfigQueryPort.GetAsync("scope-1", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty, GithubUsername: "WRONG-bot-scope-leak"))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-pref-1","full_key":"full-key-pref-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(userConfigQueryPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.Platform] = "lark", - [ChannelMetadataKeys.SenderId] = "ou_alice", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-pref-1", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-pref-1", - Arg.Is(c => - c.SkillContent.Contains("Primary GitHub username: saved-user", StringComparison.Ordinal) && - c.ExecutionPrompt.Contains("saved-user", StringComparison.Ordinal)), - Arg.Any(), - Arg.Any()); - - // Direct evidence the per-end-user scope is what reaches the query port. - await userConfigQueryPort.Received(1) - .GetAsync("scope-1:lark:ou_alice", Arg.Any()); - await userConfigQueryPort.DidNotReceive() - .GetAsync("scope-1", Arg.Any()); - - handler.Requests.Should().NotContain(x => x.Path == "/api/v1/proxy/s/api-github/user"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - /// - /// Issue #437: User A binds "alice-gh" via /daily alice-gh; User B runs /daily - /// (no username) in a separate p2p chat with the same bot. User B must NOT see "alice-gh" — - /// the per-end-user composite scope ({bot}:{platform}:{sender}) isolates each user's - /// saved preference. Without isolation, the "last writer wins" on the shared bot scope. - /// - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_CrossUserIsolation_UserBDoesNotSeeUserASavedPreference() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-bob-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-bob-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-bob-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - // User A (ou_alice) has a saved preference; User B (ou_bob) does not. - // Bot scope carries a sentinel to catch regressions that fall back to shared state. - var userConfigQueryPort = Substitute.For(); - userConfigQueryPort.GetAsync("scope-1:lark:ou_alice", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty, GithubUsername: "alice-gh"))); - userConfigQueryPort.GetAsync("scope-1:lark:ou_bob", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty, GithubUsername: null))); - userConfigQueryPort.GetAsync("scope-1", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty, GithubUsername: "WRONG-bot-scope-leak"))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/user", """{"login":"bob-gh-from-nyx"}"""); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-bob-1","full_key":"full-key-bob-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(userConfigQueryPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - // Simulate User B (ou_bob) sending /daily in a separate p2p chat. - // Same bot (scope-1) but different sender_id. - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_bob", - [ChannelMetadataKeys.Platform] = "lark", - [ChannelMetadataKeys.SenderId] = "ou_bob", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-bob-1", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - // The agent must use the NyxID-derived username (bob-gh-from-nyx), NOT - // alice's saved preference (alice-gh) or the bot-scope sentinel. - var resolvedUsername = doc.RootElement.GetProperty("github_username").GetString(); - resolvedUsername.Should().Be("bob-gh-from-nyx", - "User B has no saved preference; the system should fall through to the NyxID proxy, " + - "not leak User A's saved github_username from a different per-user scope."); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-bob-1", - Arg.Is(c => - c.SkillContent.Contains("Primary GitHub username: bob-gh-from-nyx", StringComparison.Ordinal) && - !c.SkillContent.Contains("alice-gh", StringComparison.Ordinal)), - Arg.Any(), - Arg.Any()); - - // User B's scope was queried, NOT User A's or the bot scope. - await userConfigQueryPort.Received(1) - .GetAsync("scope-1:lark:ou_bob", Arg.Any()); - await userConfigQueryPort.DidNotReceive() - .GetAsync("scope-1:lark:ou_alice", Arg.Any()); - await userConfigQueryPort.DidNotReceive() - .GetAsync("scope-1", Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_DerivesGithubUsername_FromNyxProxy_WhenArgumentAndPreferenceMissing() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-derived-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-derived-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-derived-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var userConfigQueryPort = Substitute.For(); - userConfigQueryPort.GetAsync("scope-1", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/user", """{"login":"derived-user"}"""); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-derived-1","full_key":"full-key-derived-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(userConfigQueryPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-derived-1", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-derived-1", - Arg.Is(c => - c.SkillContent.Contains("Primary GitHub username: derived-user", StringComparison.Ordinal) && - c.ExecutionPrompt.Contains("derived-user", StringComparison.Ordinal)), - Arg.Any(), - Arg.Any()); - - handler.Requests.Should().Contain(x => x.Path == "/api/v1/proxy/s/api-github/user"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequired_WhenUsernameCannotBeResolved() - { - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - var userConfigQueryPort = Substitute.For(); - userConfigQueryPort.GetAsync("scope-1", Arg.Any()) - .Returns(Task.FromResult(new StudioUserConfig(string.Empty))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """{"tokens":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/catalog/api-github", """ - { - "slug":"api-github", - "provider_config_id":"provider-github", - "provider_type":"oauth2", - "credential_mode":"user", - "documentation_url":"https://docs.github.com/en/apps/oauth-apps" - } - """); - handler.Add(HttpMethod.Get, "/api/v1/providers/provider-github/credentials", """ - { - "provider_config_id":"provider-github", - "has_credentials":true - } - """); - handler.Add(HttpMethod.Get, "/api/v1/providers/provider-github/connect/oauth", """ - { - "authorization_url":"https://github.example.com/oauth/start" - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(userConfigQueryPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("credentials_required"); - doc.RootElement.GetProperty("authorization_url").GetString().Should().Be("https://github.example.com/oauth/start"); - doc.RootElement.GetProperty("note").GetString().Should().Contain("run /daily again"); - - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_SavesGithubUsernamePreference_WhenRequested() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-save-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-save-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-save-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var userConfigCommandService = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-save-1","full_key":"full-key-save-1"}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(userConfigCommandService); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.Platform] = "lark", - [ChannelMetadataKeys.SenderId] = "ou_alice", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-save-1", - "github_username": "alice", - "save_github_username_preference": true, - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - doc.RootElement.GetProperty("github_username").GetString().Should().Be("alice"); - doc.RootElement.GetProperty("github_username_preference_saved").GetBoolean().Should().BeTrue(); - doc.RootElement.GetProperty("run_immediately_requested").GetBoolean().Should().BeFalse(); - - // Issue #436: the bot's RegistrationScopeId is shared across all Lark users using - // one bot, so the saved github_username must land in a per-end-user actor - // (`{bot}:{platform}:{sender}`), not the bot scope alone. SkillRunner.ScopeId - // (asserted elsewhere) keeps the bot scope for downstream NyxID-tenant tools. - await userConfigCommandService.Received(1) - .SaveGithubUsernameAsync("scope-1:lark:ou_alice", "alice", Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_RequiredProxyServices_AreMissing() - { - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - { - "provider_id":"provider-github", - "provider_name":"GitHub", - "provider_slug":"github", - "provider_type":"oauth2", - "status":"active", - "connected_at":"2026-04-15T00:00:00Z" - } - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - // #417: when a required slug has no UserService row, surface a structured - // `service_not_connected` error naming the slug (was: free-text "Missing required - // Nyx proxy services" wrapped in `{error: "..."}`). The lifecycle dispatch - // must NOT fire and no api-key request should fire. - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("service_not_connected"); - doc.RootElement.GetProperty("slug").GetString().Should().Be("api-github"); - doc.RootElement.GetProperty("hint").GetString().Should().Contain("api-github"); - handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_RequiredSlug_IsInactive() - { - // #417: when the user has a UserService row for the required slug but it's marked - // `is_active: false`, surface `service_inactive` rather than persisting an api-key - // that NyxID's enforcement will reject at proxy time. - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":false,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("service_inactive"); - doc.RootElement.GetProperty("slug").GetString().Should().Be("api-github"); - handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_FailsClosed_When_OrgSharedSlug_IsViewerOnly() - { - // #417: when the only matching UserService row is org-shared with `allowed: false` - // (org viewer role), don't bind it as a proxy target — NyxID would reject the proxy - // call later as `org_role_insufficient`. Surface `service_org_viewer_only` so the - // user knows to ask an admin or connect a personal credential. - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"org","org_id":"org-1","role":"viewer","allowed":false}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("service_org_viewer_only"); - doc.RootElement.GetProperty("slug").GetString().Should().Be("api-github"); - handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_AllowedServiceIds_AreUserServiceIds_NotCatalogIds() - { - // #417 regression pin. The bug: backend used `GET /proxy/services` (catalog list) and - // populated the new api-key's `allowed_service_ids` with `DownstreamService.id` (catalog - // UUIDs). NyxID's proxy enforcement (proxy.rs:1030) compares against `UserService.id` - // (per-user instance UUIDs). The mismatch was silently accepted on api-key create and - // 403'd on every proxy call. The fix routes through `/user-services`, returning per-user - // ids. Stub a response where the per-user `id` is *distinct from* `catalog_service_id` - // and pin that the api-key payload carries the per-user `id` value. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-id-pin", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-id-pin", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-id-pin", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"user-svc-github-instance","slug":"api-github","catalog_service_id":"catalog-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"user-svc-lark-instance","slug":"api-lark-bot","catalog_service_id":"catalog-lark","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-id-pin","full_key":"full-key-id-pin"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-id-pin", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - var allowed = apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .ToArray(); - allowed.Should().BeEquivalentTo(["user-svc-github-instance", "user-svc-lark-instance"]); - allowed.Should().NotContain("catalog-github").And.NotContain("catalog-lark"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_CapturesFailureNotificationSlug_FromInboundChannelBot() - { - // Issue #423 §C: capture the inbound channel-bot's NyxID provider slug at agent-create - // time so SkillRunner.TrySendFailureAsync can route the failure-notification message - // through it after a primary outbound rejection (e.g. cross-tenant 99992364). This - // test pins: - // - the captured slug ends up on OutboundConfig.FailureNotificationProviderSlug - // - the inbound bot's per-user UserService.id is appended to the API key's - // allowed_service_ids so proxy enforcement (#418) actually permits routing - // through it at runtime - // - the primary outbound slug stays unchanged (failure-notification fallback is a - // separate routing path, not a re-route of the main report) - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-fnf", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-fnf", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-fnf", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - // The inbound channel-bot's slug (api-lark-bot-channel-loning) is registered as a - // separate UserService row alongside the global api-lark-bot used for outbound. - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark-global","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark-channel-loning","slug":"api-lark-bot-channel-loning","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-fnf","full_key":"full-key-fnf"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var servicesCollection = new ServiceCollection(); - servicesCollection.AddSingleton(queryPort); - servicesCollection.AddSingleton(skillRunnerPort); - servicesCollection.AddSingleton(workflowAgentPort); - servicesCollection.AddSingleton(catalogCommandPort); - servicesCollection.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - servicesCollection.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(servicesCollection.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - // The inbound channel-bot the user just messaged. Distinct from the outbound - // default (`api-lark-bot`), simulating the cross-tenant scenario. - [ChannelMetadataKeys.InboundChannelBotProxySlug] = "api-lark-bot-channel-loning", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-fnf", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-fnf", - Arg.Is(c => - // Primary slug stays on the caller-provided default — failure-notification - // is a separate routing path, not a re-route of the main report. - c.OutboundConfig.NyxProviderSlug == "api-lark-bot" && - c.OutboundConfig.FailureNotificationProviderSlug == "api-lark-bot-channel-loning"), - Arg.Any(), - Arg.Any()); - - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - var allowed = apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .ToArray(); - // The inbound bot's svc-id MUST be in allowed_service_ids; without it the - // runtime POST through the failure-notification slug would 403 at proxy - // enforcement (#418) and the user still wouldn't see the failure message. - allowed.Should().Contain("svc-lark-channel-loning"); - allowed.Should().Contain("svc-github"); - allowed.Should().Contain("svc-lark-global"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_LeavesFailureNotificationSlugEmpty_When_InboundEqualsPrimary() - { - // Same-proxy fallback gives no recovery benefit — primary rejection at slug X would - // also fail at slug X. Pin that AgentBuilderTool detects this and leaves the field - // empty so SkillRunner.TrySendFailureAsync skips the redundant double-POST. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-same", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-same", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-same", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-same","full_key":"full-key-same"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var servicesCollection = new ServiceCollection(); - servicesCollection.AddSingleton(queryPort); - servicesCollection.AddSingleton(skillRunnerPort); - servicesCollection.AddSingleton(workflowAgentPort); - servicesCollection.AddSingleton(catalogCommandPort); - servicesCollection.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - servicesCollection.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(servicesCollection.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - // Inbound slug equals the default primary slug. - [ChannelMetadataKeys.InboundChannelBotProxySlug] = "api-lark-bot", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-same", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-same", - Arg.Is(c => - c.OutboundConfig.NyxProviderSlug == "api-lark-bot" && - string.IsNullOrEmpty(c.OutboundConfig.FailureNotificationProviderSlug)), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_LeavesFailureNotificationSlugEmpty_When_InboundSlugMissingFromUserServices() - { - // Defense-in-depth: if the inbound slug isn't a registered user-service (e.g. an - // unusual relay setup, or an inbound bot that was disconnected between webhook arrival - // and agent-create), we cannot grant proxy access at API-key creation time. Including - // it would either fail the create with `service_not_connected` (regression) OR pass - // creation but 403 at runtime when SkillRunner tries to use it. Both are worse than - // leaving the failure-notification slug empty and degrading to single-attempt failure - // notification (current behavior). Pin the latter. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-missing", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-missing", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-missing", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - // The inbound slug is NOT among the user's registered services. - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-missing","full_key":"full-key-missing"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var servicesCollection = new ServiceCollection(); - servicesCollection.AddSingleton(queryPort); - servicesCollection.AddSingleton(skillRunnerPort); - servicesCollection.AddSingleton(workflowAgentPort); - servicesCollection.AddSingleton(catalogCommandPort); - servicesCollection.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - servicesCollection.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(servicesCollection.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - // Inbound slug points at a bot that isn't in the user's user-services list. - [ChannelMetadataKeys.InboundChannelBotProxySlug] = "api-lark-bot-channel-disconnected", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-missing", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - // Creation must NOT fail just because an optional fallback slug isn't connected. - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - await skillRunnerPort.Received(1).InitializeAsync( - "skill-runner-missing", - Arg.Is(c => - string.IsNullOrEmpty(c.OutboundConfig.FailureNotificationProviderSlug)), - Arg.Any(), - Arg.Any()); - - // The disconnected slug must not leak into allowed_service_ids — that would - // create a permanent runtime 403 on the failure-notification path AND silently - // expand the agent's proxy reach beyond what the spec requires. - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - var allowed = apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .ToArray(); - allowed.Should().BeEquivalentTo(["svc-github", "svc-lark"]); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_PicksEligibleRow_When_DuplicateSlugRowsExist() - { - // Codex review (PR #418 r3141846173): a user with mixed bindings can have multiple - // UserService rows for the same slug — e.g. an org-shared `allowed:false` row and a - // personal active row. NyxID does not guarantee any ordering, so the resolver must - // pick the *eligible* row regardless of position. Pin the case where the ineligible - // row arrives first; the resolver must still produce the personal id and succeed. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("skill-runner-dup", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("skill-runner-dup", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-dup", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """ - { - "tokens": [ - {"provider_id":"provider-github","provider_name":"GitHub","provider_slug":"github","provider_type":"oauth2","status":"active","connected_at":"2026-04-15T00:00:00Z"} - ] - } - """); - // Two rows for `api-github` (ineligible org-viewer first, eligible personal second) and - // two rows for `api-lark-bot` (inactive first, active second). The resolver must pick - // the eligible rows in both cases, not the first-seen ones. - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-github-org","slug":"api-github","is_active":true,"credential_source":{"type":"org","org_id":"org-1","role":"viewer","allowed":false}}, - {"id":"svc-github-personal","slug":"api-github","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-lark-stale","slug":"api-lark-bot","is_active":false,"credential_source":{"type":"personal"}}, - {"id":"svc-lark-active","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-dup","full_key":"full-key-dup"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-github/rate_limit", - """{"resources":{"core":{"limit":5000,"remaining":4999}}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "agent_id": "skill-runner-dup", - "github_username": "alice", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - var allowed = apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .ToArray(); - allowed.Should().BeEquivalentTo(["svc-github-personal", "svc-lark-active"]); - allowed.Should().NotContain("svc-github-org").And.NotContain("svc-lark-stale"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsOAuthRequirementBeforeCreatingAgent() - { - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """{"tokens":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/catalog/api-github", """ - { - "slug":"api-github", - "provider_config_id":"provider-github", - "provider_type":"oauth2", - "credential_mode":"user", - "documentation_url":"https://docs.github.com/en/apps/oauth-apps" - } - """); - handler.Add(HttpMethod.Get, "/api/v1/providers/provider-github/credentials", """ - { - "provider_config_id":"provider-github", - "has_credentials":true - } - """); - handler.Add(HttpMethod.Get, "/api/v1/providers/provider-github/connect/oauth", """ - { - "authorization_url":"https://github.example.com/oauth/start" - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "repositories": "aevatarAI/aevatar", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC", - "run_immediately": true - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("oauth_required"); - doc.RootElement.GetProperty("provider").GetString().Should().Be("GitHub"); - doc.RootElement.GetProperty("provider_id").GetString().Should().Be("provider-github"); - doc.RootElement.GetProperty("authorization_url").GetString().Should().Be("https://github.example.com/oauth/start"); - // Echo the username the user already submitted so the Lark re-prompt card can pre-fill - // the GitHub Username form field instead of forcing the user to retype after OAuth. - doc.RootElement.GetProperty("github_username").GetString().Should().Be("alice"); - - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_DailyReport_ReturnsCredentialsRequirementBeforeOAuth() - { - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/providers/my-tokens", """{"tokens":[]}"""); - handler.Add(HttpMethod.Get, "/api/v1/catalog/api-github", """ - { - "slug":"api-github", - "provider_config_id":"provider-github", - "provider_type":"oauth2", - "credential_mode":"user", - "documentation_url":"https://docs.github.com/en/apps/oauth-apps" - } - """); - handler.Add(HttpMethod.Get, "/api/v1/providers/provider-github/credentials", """ - { - "provider_config_id":"provider-github", - "has_credentials":false - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "daily_report", - "github_username": "alice", - "repositories": "aevatarAI/aevatar", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC", - "run_immediately": true - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("credentials_required"); - doc.RootElement.GetProperty("provider").GetString().Should().Be("GitHub"); - doc.RootElement.GetProperty("provider_id").GetString().Should().Be("provider-github"); - doc.RootElement.GetProperty("documentation_url").GetString().Should().Be("https://docs.github.com/en/apps/oauth-apps"); - // Same username echo as the oauth_required branch so the re-prompt form pre-fills. - doc.RootElement.GetProperty("github_username").GetString().Should().Be("alice"); - - handler.Requests.Should().NotContain(x => x.Path == "/api/v1/providers/provider-github/connect/oauth"); - handler.Requests.Should().NotContain(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys"); - await skillRunnerPort.DidNotReceive().InitializeAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_SocialMedia_UpsertsWorkflowAndInitializesWorkflowAgent() - { - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync("workflow-agent-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null), Task.FromResult(1)); - queryPort.GetForCallerAsync("workflow-agent-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "workflow-agent-1", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - Status = WorkflowAgentDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var workflowCommandPort = Substitute.For(); - workflowCommandPort.UpsertAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ScopeWorkflowUpsertResult( - new ScopeWorkflowSummary( - "scope-1", - "social-media-workflow-agent-1", - "Social Media Approval workflow-agent-1", - "service-key", - "social_media_workflow_agent_1", - "workflow-actor-1", - "rev-1", - "deploy-1", - "active", - DateTimeOffset.UtcNow), - "rev-1", - "workflow-actor-prefix", - "workflow-actor-1"))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - // Issue #216: social_media now requires both api-lark-bot (delivery) AND api-twitter - // (publish) so the agent api-key carries both entitlements. The api-twitter slug entry - // is what gates `service_not_connected` at create time; without it the user gets a - // structured error pointing them at NyxID's connect-twitter flow. - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-2","full_key":"full-key-2"}"""); - // Twitter preflight (#216 mirror of #418 GitHub preflight): GET /users/me with the - // freshly minted key must succeed before the workflow gets upserted. NyxID forwards - // the Twitter v2 user payload verbatim on success (no `error` envelope). - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", - """{"data":{"id":"123456","name":"Alice","username":"alice"}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(workflowCommandPort); - services.AddSingleton(nyxClient); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "social_media", - "agent_id": "workflow-agent-1", - "topic": "Launch update for the new workflow feature", - "audience": "Developers", - "style": "Confident and concise", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC", - "run_immediately": true - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("created"); - doc.RootElement.GetProperty("agent_id").GetString().Should().Be("workflow-agent-1"); - doc.RootElement.GetProperty("agent_type").GetString().Should().Be(WorkflowAgentDefaults.AgentType); - doc.RootElement.GetProperty("workflow_id").GetString().Should().Be("social-media-workflow-agent-1"); - doc.RootElement.GetProperty("api_key_id").GetString().Should().Be("key-2"); - - await workflowCommandPort.Received(1).UpsertAsync( - Arg.Is(request => - request.ScopeId == "scope-1" && - request.WorkflowId == "social-media-workflow-agent-1" && - request.WorkflowYaml.Contains("provider: nyxid", StringComparison.Ordinal) && - request.WorkflowYaml.Contains("type: human_approval", StringComparison.Ordinal) && - request.WorkflowYaml.Contains("delivery_target_id: \"workflow-agent-1\"", StringComparison.Ordinal)), - Arg.Any()); - - await workflowAgentPort.Received(1).InitializeAsync( - "workflow-agent-1", - Arg.Is(c => - c.WorkflowActorId == "workflow-actor-1" && - c.ConversationId == "oc_chat_1" && - c.NyxApiKey == "full-key-2" && - c.ApiKeyId == "key-2" && - // Mirror of the daily_report p2p assertion: BuildFromInbound must pin the - // sender open_id at delivery-target creation time so FeishuCardHumanInteraction - // Port reads it through the catalog projection without re-deriving the type. - c.LarkReceiveId == "ou_user_1" && - c.LarkReceiveIdType == "open_id"), - false, - Arg.Any()); - - await workflowAgentPort.Received(1).TriggerAsync( - "workflow-agent-1", - "create_agent", - null, - Arg.Any()); - - var apiKeyRequest = handler.Requests.Should() - .ContainSingle(x => x.Method == HttpMethod.Post && x.Path == "/api/v1/api-keys") - .Subject; - using var apiKeyDoc = JsonDocument.Parse(apiKeyRequest.Body!); - // Issue #216: api-key now carries both `svc-lark` (approval delivery) and - // `svc-twitter` (publish). Order is irrelevant — `BeEquivalentTo` ignores it. - apiKeyDoc.RootElement.GetProperty("allowed_service_ids").EnumerateArray() - .Select(static item => item.GetString()) - .Should() - .BeEquivalentTo(["svc-lark", "svc-twitter"]); - // PR #418 review (4175529548): NyxID's `allow_all_services` defaults to `true` - // (api_keys.rs:105) and proxy enforcement only fires when `!allow_all_services` - // (proxy.rs:1030). Pin that the field is *present* and `false` so the resolved - // `allowed_service_ids` actually constrains the key's reach. - apiKeyDoc.RootElement.GetProperty("allow_all_services").GetBoolean().Should().BeFalse(); - - // Workflow YAML must now route the approval `true` branch to the new - // `publish_to_twitter` step instead of straight to `done` — the publish step is - // what fulfills issue #216's "approve → publish to X" path. PR #461 review fix: - // also pin `on_error: skip` so a Twitter-side rejection (401/403/429/5xx) advances - // the run to `done` instead of terminating the entire workflow as failed; the - // module already surfaces categorized errors to Lark independently. - await workflowCommandPort.Received(1).UpsertAsync( - Arg.Is(request => - request.WorkflowYaml.Contains("type: twitter_publish", StringComparison.Ordinal) && - request.WorkflowYaml.Contains("publish_provider_slug: \"api-twitter\"", StringComparison.Ordinal) && - request.WorkflowYaml.Contains("\"true\": publish_to_twitter", StringComparison.Ordinal) && - request.WorkflowYaml.Contains("strategy: skip", StringComparison.Ordinal)), - Arg.Any()); - - // Twitter preflight must fire with the freshly minted api-key against /users/me - // before the workflow is upserted (mirror of GitHub preflight in #418). - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Get && - r.Path == "/api/v1/proxy/s/api-twitter/users/me"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - [Fact] public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombstonesRegistry() { @@ -3060,7 +33,7 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", ApiKeyId = "key-1", OwnerScope = OwnerScope.ForNyxIdNative("user-1"), }), @@ -3069,7 +42,6 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst .Returns(Task.FromResult>(Array.Empty())); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); catalogCommandPort.TombstoneAsync("skill-runner-1", Arg.Any()) .Returns(Task.FromResult(new UserAgentCatalogTombstoneResult(CatalogCommandOutcome.Observed))); @@ -3084,7 +56,6 @@ public async Task ExecuteAsync_DeleteAgent_DisablesActor_RevokesApiKey_AndTombst var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); var callerScopeResolver = Substitute.For(); @@ -3147,7 +118,7 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh { AgentId = "skill-runner-stuck", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", ApiKeyId = "key-stuck", OwnerScope = OwnerScope.ForNyxIdNative("user-1"), })); @@ -3161,7 +132,6 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh [new UserAgentCatalogEntry { AgentId = "skill-runner-stuck", OwnerScope = OwnerScope.ForNyxIdNative("user-1") }])); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); // Tombstone is dispatched but the projection has not yet caught up; the // port surfaces an Accepted outcome and the tool reports the propagating @@ -3178,7 +148,6 @@ public async Task ExecuteAsync_DeleteAgent_ReturnsAcceptedWithPropagatingHint_Wh var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(nyxClient); // Inject a shrunk wait budget per-instance (3 attempts × 1 ms) so the @@ -3240,17 +209,15 @@ public async Task ExecuteAsync_RunAgent_DispatchesManualTrigger() { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", })); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3293,85 +260,24 @@ await skillRunnerPort.Received(1).TriggerAsync( } [Fact] - public async Task ExecuteAsync_RunAgent_RejectsDisabledAgent() - { - var queryPort = Substitute.For(); - queryPort.GetForCallerAsync("skill-runner-1", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "skill-runner-1", - AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", - Status = SkillRunnerDefaults.StatusDisabled, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(new RoutingJsonHandler()) - { - BaseAddress = new Uri("https://nyx.example.com"), - })); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "run_agent", - "agent_id": "skill-runner-1" - } - """); - - result.Should().Contain("is disabled"); - await skillRunnerPort.DidNotReceive().TriggerAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_RunAgent_DispatchesWorkflowTrigger() + public async Task ExecuteAsync_RunAgent_RejectsDisabledAgent() { var queryPort = Substitute.For(); - queryPort.GetForCallerAsync("workflow-agent-1", Arg.Any(), Arg.Any()) + queryPort.GetForCallerAsync("skill-runner-1", Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new UserAgentCatalogEntry { - AgentId = "workflow-agent-1", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - Status = WorkflowAgentDefaults.StatusRunning, + AgentId = "skill-runner-1", + AgentType = SkillRunnerDefaults.AgentType, + TemplateName = "daily", + Status = SkillRunnerDefaults.StatusDisabled, })); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3394,20 +300,14 @@ public async Task ExecuteAsync_RunAgent_DispatchesWorkflowTrigger() var result = await tool.ExecuteAsync(""" { "action": "run_agent", - "agent_id": "workflow-agent-1", - "revision_feedback": "Need stronger hook" + "agent_id": "skill-runner-1" } """); - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("accepted"); - doc.RootElement.GetProperty("agent_id").GetString().Should().Be("workflow-agent-1"); - doc.RootElement.GetProperty("note").GetString().Should().Contain("revision feedback"); - - await workflowAgentPort.Received(1).TriggerAsync( - "workflow-agent-1", - "run_agent", - "Need stronger hook", + result.Should().Contain("is disabled"); + await skillRunnerPort.DidNotReceive().TriggerAsync( + Arg.Any(), + Arg.Any(), Arg.Any()); } finally @@ -3446,7 +346,7 @@ public async Task ExecuteAsync_DisableAgent_ReturnsStatusFast_WhenProjectionAdva { AgentId = "skill-runner-fast", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusRunning, }), // Wait helper's first poll sees the materialized disable. @@ -3454,7 +354,7 @@ public async Task ExecuteAsync_DisableAgent_ReturnsStatusFast_WhenProjectionAdva { AgentId = "skill-runner-fast", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusDisabled, })); // Caller's pre-dispatch baseline read returns 42; helper's post- @@ -3466,13 +366,11 @@ public async Task ExecuteAsync_DisableAgent_ReturnsStatusFast_WhenProjectionAdva Task.FromResult(43L)); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3534,7 +432,7 @@ public async Task ExecuteAsync_DisableAgent_KeepsWaitingWhenStatusMatchesButVers { AgentId = "skill-runner-stale", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusRunning, }), // Helper's terminal fallback (after budget exhausts) returns @@ -3547,7 +445,7 @@ public async Task ExecuteAsync_DisableAgent_KeepsWaitingWhenStatusMatchesButVers { AgentId = "skill-runner-stale", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusDisabled, })); // Caller baseline = 7; replica's view never advances past 7. Helper @@ -3556,13 +454,11 @@ public async Task ExecuteAsync_DisableAgent_KeepsWaitingWhenStatusMatchesButVers .Returns(Task.FromResult(7L)); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3635,7 +531,7 @@ public async Task ExecuteAsync_DisableAgent_DispatchesDisableAndReturnsStatus() { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusRunning, ScheduleCron = "0 9 * * *", ScheduleTimezone = "UTC", @@ -3644,7 +540,7 @@ public async Task ExecuteAsync_DisableAgent_DispatchesDisableAndReturnsStatus() { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusDisabled, ScheduleCron = "0 9 * * *", ScheduleTimezone = "UTC", @@ -3657,13 +553,11 @@ public async Task ExecuteAsync_DisableAgent_DispatchesDisableAndReturnsStatus() Task.FromResult(6L)); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3715,7 +609,7 @@ public async Task ExecuteAsync_EnableAgent_DispatchesEnableAndReturnsStatus() { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusDisabled, ScheduleCron = "0 9 * * *", ScheduleTimezone = "UTC", @@ -3724,7 +618,7 @@ public async Task ExecuteAsync_EnableAgent_DispatchesEnableAndReturnsStatus() { AgentId = "skill-runner-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = SkillRunnerDefaults.StatusRunning, ScheduleCron = "0 9 * * *", ScheduleTimezone = "UTC", @@ -3737,13 +631,11 @@ public async Task ExecuteAsync_EnableAgent_DispatchesEnableAndReturnsStatus() Task.FromResult(6L)); var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); var catalogCommandPort = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton(queryPort); services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); services.AddSingleton(catalogCommandPort); services.AddSingleton(new NyxIdApiClient( new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, @@ -3785,469 +677,6 @@ await skillRunnerPort.Received(1).EnableAsync( } } - [Fact] - public async Task ExecuteAsync_DisableAgent_DispatchesWorkflowDisableAndReturnsStatus() - { - var queryPort = Substitute.For(); - queryPort.GetForCallerAsync("workflow-agent-1", Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "workflow-agent-1", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - Status = WorkflowAgentDefaults.StatusRunning, - ScheduleCron = "0 9 * * *", - ScheduleTimezone = "UTC", - }), - Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "workflow-agent-1", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - Status = WorkflowAgentDefaults.StatusDisabled, - ScheduleCron = "0 9 * * *", - ScheduleTimezone = "UTC", - })); - // Caller's pre-dispatch baseline read returns 5; helper's post-dispatch - // poll sees 6, satisfying the new version+status dual gate. - queryPort.GetStateVersionForCallerAsync("workflow-agent-1", Arg.Any(), Arg.Any()) - .Returns( - Task.FromResult(5L), - Task.FromResult(6L)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(new RoutingJsonHandler()) - { - BaseAddress = new Uri("https://nyx.example.com"), - })); - var callerScopeResolver = Substitute.For(); - callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "disable_agent", - "agent_id": "workflow-agent-1" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().Be(WorkflowAgentDefaults.StatusDisabled); - doc.RootElement.GetProperty("note").GetString().Should().Contain("Scheduling paused"); - - await workflowAgentPort.Received(1).DisableAsync( - "workflow-agent-1", - "disable_agent", - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterProxyReturns401() - { - // Issue aevatarAI/aevatar#216: social_media now publishes approved drafts to Twitter via - // NyxID's api-twitter proxy. Mirror of the GitHub preflight (#418): probe /users/me with - // the freshly minted api-key; if NyxID has no OAuth grant for the user (401), abort - // creation, return a structured `twitter_oauth_required` error, and best-effort revoke - // the orphan key so retries don't accumulate. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var workflowCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-401","full_key":"full-key-401"}"""); - // 401 from /users/me through NyxID — common when the user has not connected Twitter - // yet at NyxID, or when the OAuth grant was revoked at x.com/settings. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", - """{"error": true, "status": 401, "body": "{\"title\":\"Unauthorized\",\"detail\":\"Authenticating with OAuth 2.0 Application-Only is forbidden for this endpoint.\"}"}"""); - // Pin the orphan-key revocation: per #418's pattern, every preflight failure must - // best-effort delete the api-key so retries don't pile up keys in the user's account. - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-401", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(workflowCommandPort); - services.AddSingleton(nyxClient); - var __callerScopeResolver = Substitute.For(); - __callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(__callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "social_media", - "agent_id": "workflow-agent-twitter-401", - "topic": "Launch update", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("twitter_oauth_required"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(401); - doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("re-authorize"); - - // Workflow upsert and agent init must NOT have run — preflight aborts before that. - await workflowCommandPort.DidNotReceiveWithAnyArgs().UpsertAsync(default!, default); - await workflowAgentPort.DidNotReceiveWithAnyArgs().InitializeAsync(default!, default!, default); - - // Orphan-key revocation fires (mirror of #418 r3141846175 for daily_report). - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-401"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterProxyReturns403() - { - // 403 here means "the OAuth token reached Twitter but tweet.write was not in scope". - // Default NyxID seed includes tweet.write (provider_service.rs:405-450), so a 403 in - // production typically means a regression on the seed side or the bound token was - // issued before tweet.write was added — surface this as `twitter_proxy_access_denied` - // (distinct from 401) so the user-facing hint can steer ops vs the user. - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var workflowCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-twitter","slug":"api-twitter","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-403","full_key":"full-key-403"}"""); - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-twitter/users/me", - """{"error": true, "status": 403, "body": "{\"title\":\"Forbidden\",\"detail\":\"Your client app is not configured with the appropriate oauth2 app permissions.\"}"}"""); - handler.Add(HttpMethod.Delete, "/api/v1/api-keys/key-403", """{"deleted":true}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(workflowCommandPort); - services.AddSingleton(nyxClient); - var __callerScopeResolver = Substitute.For(); - __callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(__callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "social_media", - "agent_id": "workflow-agent-twitter-403", - "topic": "Launch update", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("twitter_proxy_access_denied"); - doc.RootElement.GetProperty("http_status").GetInt32().Should().Be(403); - doc.RootElement.GetProperty("hint").GetString()!.ToLowerInvariant().Should().Contain("tweet.write"); - - await workflowCommandPort.DidNotReceiveWithAnyArgs().UpsertAsync(default!, default); - await workflowAgentPort.DidNotReceiveWithAnyArgs().InitializeAsync(default!, default!, default); - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Delete && - r.Path == "/api/v1/api-keys/key-403"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_SocialMedia_FailsClosed_When_TwitterServiceNotConnected() - { - // The flip side of the preflight: if api-twitter is not present in user-services at all, - // the existing ResolveProxyServiceIdsAsync path returns `service_not_connected` BEFORE - // we mint the api-key. This is the "user has not added Twitter at NyxID at all" signal. - var queryPort = Substitute.For(); - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - var workflowCommandPort = Substitute.For(); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - // Notice: no api-twitter row. - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(workflowCommandPort); - services.AddSingleton(nyxClient); - var __callerScopeResolver = Substitute.For(); - __callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(__callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "social_media", - "agent_id": "workflow-agent-no-twitter", - "topic": "Launch update", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("error").GetString().Should().Be("service_not_connected"); - doc.RootElement.GetProperty("slug").GetString().Should().Be("api-twitter"); - // Critical invariant: no api-key was ever minted because the slug check failed up - // front. Catching this here matters because the daily_report tests already pin the - // same invariant for api-github — keep parity. - handler.Requests.Should().NotContain(r => - r.Method == HttpMethod.Post && r.Path == "/api/v1/api-keys"); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - - [Fact] - public async Task ExecuteAsync_CreateAgent_SocialMedia_PreflightProbesConfiguredPublishSlug_NotHardcodedApiTwitter() - { - // PR #461 review (commit d9f6df81 follow-up): when a caller passes a custom - // `publish_provider_slug` (e.g. a tenant-staged Twitter mirror like `api-x-staging`), - // the preflight must validate THAT slug — not the hardcoded `"api-twitter"` default. - // Otherwise we mint a key for the custom slug, generate workflow YAML pointing at the - // custom slug, but green-light the create flow against an unrelated proxy (or 404 on - // the unmocked default route). Pin that the GET probe lands on the configured slug's - // path so this regresses loudly if anyone reverts to a literal "api-twitter". - var queryPort = Substitute.For(); - queryPort.GetStateVersionForCallerAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(null)); - queryPort.GetForCallerAsync("workflow-agent-custom-slug", Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new UserAgentCatalogEntry - { - AgentId = "workflow-agent-custom-slug", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, - Status = WorkflowAgentDefaults.StatusRunning, - })); - - var skillRunnerPort = Substitute.For(); - var workflowAgentPort = Substitute.For(); - var catalogCommandPort = Substitute.For(); - - var workflowCommandPort = Substitute.For(); - workflowCommandPort.UpsertAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(new ScopeWorkflowUpsertResult( - new ScopeWorkflowSummary( - "scope-1", - "social-media-workflow-agent-custom-slug", - "Social Media Approval workflow-agent-custom-slug", - "service-key", - "social_media_workflow_agent_custom_slug", - "workflow-actor-1", - "rev-1", - "deploy-1", - "active", - DateTimeOffset.UtcNow), - "rev-1", - "workflow-actor-prefix", - "workflow-actor-1"))); - - var handler = new RoutingJsonHandler(); - handler.Add(HttpMethod.Get, "/api/v1/users/me", """{"user":{"id":"user-1"}}"""); - handler.Add(HttpMethod.Get, "/api/v1/user-services", """ - { - "services": [ - {"id":"svc-lark","slug":"api-lark-bot","is_active":true,"credential_source":{"type":"personal"}}, - {"id":"svc-x-staging","slug":"api-x-staging","is_active":true,"credential_source":{"type":"personal"}} - ] - } - """); - handler.Add(HttpMethod.Post, "/api/v1/api-keys", """{"id":"key-custom","full_key":"full-key-custom"}"""); - // Mock ONLY the configured slug's preflight path. The default `api-twitter` path - // is intentionally NOT mocked — RoutingJsonHandler returns 404 for unknown routes, - // which would land in the preflight's "non-401/403 → success" branch and silently - // green-light the create. The successful response below proves we hit the right slug. - handler.Add(HttpMethod.Get, "/api/v1/proxy/s/api-x-staging/users/me", - """{"data":{"id":"123456","name":"Alice","username":"alice"}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection(); - services.AddSingleton(queryPort); - services.AddSingleton(skillRunnerPort); - services.AddSingleton(workflowAgentPort); - services.AddSingleton(catalogCommandPort); - services.AddSingleton(workflowCommandPort); - services.AddSingleton(nyxClient); - var __callerScopeResolver = Substitute.For(); - __callerScopeResolver.TryResolveAsync(Arg.Any()) - .Returns(Task.FromResult(OwnerScope.ForNyxIdNative("user-1"))); - services.AddSingleton(__callerScopeResolver); - var tool = new AgentBuilderTool(services.BuildServiceProvider()); - - AgentToolRequestContext.CurrentMetadata = new Dictionary - { - [LLMRequestMetadataKeys.NyxIdAccessToken] = "session-token", - [ChannelMetadataKeys.ChatType] = "p2p", - [ChannelMetadataKeys.ConversationId] = "oc_chat_1", - [ChannelMetadataKeys.SenderId] = "ou_user_1", - ["scope_id"] = "scope-1", - }; - try - { - var result = await tool.ExecuteAsync(""" - { - "action": "create_agent", - "template": "social_media", - "agent_id": "workflow-agent-custom-slug", - "topic": "Launch update", - "schedule_cron": "0 9 * * *", - "schedule_timezone": "UTC", - "publish_provider_slug": "api-x-staging" - } - """); - - using var doc = JsonDocument.Parse(result); - doc.RootElement.GetProperty("status").GetString().Should().BeOneOf("created", "accepted"); - - // The preflight must fire against the configured slug, NOT the default api-twitter. - handler.Requests.Should().Contain(r => - r.Method == HttpMethod.Get && - r.Path == "/api/v1/proxy/s/api-x-staging/users/me"); - handler.Requests.Should().NotContain(r => - r.Method == HttpMethod.Get && - r.Path == "/api/v1/proxy/s/api-twitter/users/me"); - - // Workflow YAML must reference the custom slug end-to-end (not just at preflight). - await workflowCommandPort.Received(1).UpsertAsync( - Arg.Is(request => - request.WorkflowYaml.Contains("publish_provider_slug: \"api-x-staging\"", StringComparison.Ordinal)), - Arg.Any()); - } - finally - { - AgentToolRequestContext.CurrentMetadata = null; - } - } - [Fact] public async Task ToolSource_Always_ReturnsTool() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunGAgentTests.cs new file mode 100644 index 000000000..e76602ad1 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/AgentRunGAgentTests.cs @@ -0,0 +1,1358 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.NyxIdRelay; +using Aevatar.GAgents.Channel.Runtime; +using Aevatar.GAgents.NyxidChat; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Abstractions.Runtime.Callbacks; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.Studio.Application.Studio.Abstractions; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests; + +public sealed class AgentRunGAgentTests +{ + [Fact] + public async Task DispatchAsync_ShouldCreateRunActorAndDispatchStartCommand() + { + var actorRuntime = new DispatchingActorRuntime(); + var streamProvider = new RecordingStreamProvider(); + var dispatcher = new AgentRunDispatcher( + actorRuntime, + streamProvider, + NullLogger.Instance); + + await dispatcher.DispatchAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-dispatch", + TargetActorId = "conversation-actor", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-dispatch", + }, CancellationToken.None); + + streamProvider.Produced.Should().ContainSingle(); + var (actorId, envelope) = streamProvider.Produced.Single(); + actorId.Should().Be(AgentRunGAgent.BuildActorId("corr-dispatch")); + envelope.Propagation.CorrelationId.Should().Be("corr-dispatch"); + var command = envelope.Payload.Unpack(); + command.Request.CorrelationId.Should().Be("corr-dispatch"); + command.Request.TargetActorId.Should().Be("conversation-actor"); + command.Request.ReplyToken.Should().Be("relay-token-dispatch"); + } + + [Fact] + public async Task HandleStartAsync_ShouldIgnoreDuplicateStart_AfterReadyAcceptedAndTerminalPersisted() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "ok" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + var request = new NeedsLlmReplyEvent + { + CorrelationId = "corr-duplicate", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-duplicate", + }; + + await runtime.HandleStartAsync(request); + await runtime.HandleStartAsync(request.Clone()); + + runtime.State.Status.Should().Be(AgentRunStatus.ReplyProduced); + replyGenerator.CallCount.Should().Be(1); + handled.Should().ContainSingle(e => e.Payload.Is(LlmReplyReadyEvent.Descriptor)); + } + + [Fact] + public async Task HandleStartAsync_ShouldScheduleTerminalCleanupAfterReplyProduced() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var scheduler = new RecordingCallbackScheduler(); + var runtime = CreateRunAgent( + actorRuntime, + new RecordingReplyGenerator(() => false) { ReplyText = "ok" }, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + StreamingRepliesEnabled = false, + }); + AttachScheduler(runtime, scheduler); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-cleanup-schedule", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-cleanup-schedule", + }); + + var cleanup = scheduler.Timeouts.Should().ContainSingle( + timeout => timeout.TriggerEnvelope.Payload.Is(AgentRunCleanupRequested.Descriptor)).Subject; + cleanup.ActorId.Should().Be(runtime.Id); + cleanup.DueTime.Should().Be(AgentRunGAgent.TerminalCleanupDelay); + var cleanupCommand = cleanup.TriggerEnvelope.Payload.Unpack(); + cleanupCommand.RunId.Should().Be("corr-cleanup-schedule"); + } + + [Fact] + public async Task HandleCleanupAsync_ShouldDestroyTerminalRunActor() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + new RecordingReplyGenerator(() => false) { ReplyText = "ok" }, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + StreamingRepliesEnabled = false, + }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-cleanup", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-cleanup", + }); + await runtime.HandleCleanupAsync(new AgentRunCleanupRequested + { + RunId = "corr-cleanup", + }); + + actorRuntime.DestroyedIds.Should().Contain(runtime.Id); + } + + [Fact] + public async Task HandleStartAsync_ShouldScheduleRetry_WhenReadySignalIsNotAccepted() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var scheduler = new RecordingCallbackScheduler(); + var publisher = new DispatchingEventPublisher(actorRuntime) + { + FailNextSend = true, + }; + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "ok" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + StreamingRepliesEnabled = false, + }, + eventPublisher: publisher); + AttachScheduler(runtime, scheduler); + var request = new NeedsLlmReplyEvent + { + CorrelationId = "corr-retry-ready", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-retry-ready", + }; + + await runtime.HandleStartAsync(request); + + runtime.State.Status.Should().Be(AgentRunStatus.Started); + handled.Should().BeEmpty(); + + var retry = scheduler.Timeouts.Should().ContainSingle( + timeout => timeout.TriggerEnvelope.Payload.Is(AgentRunStartRequested.Descriptor)).Subject; + retry.ActorId.Should().Be(runtime.Id); + retry.DueTime.Should().Be(AgentRunGAgent.OutputDispatchRetryDelay); + var retryCommand = retry.TriggerEnvelope.Payload.Unpack(); + + await runtime.HandleStartAsync(retryCommand); + + runtime.State.Status.Should().Be(AgentRunStatus.ReplyProduced); + replyGenerator.CallCount.Should().Be(2); + handled.Should().ContainSingle(e => e.Payload.Is(LlmReplyReadyEvent.Descriptor)); + } + + [Fact] + public async Task HandleStartAsync_ShouldScheduleRetry_WhenDropSignalIsNotAccepted() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var scheduler = new RecordingCallbackScheduler(); + var publisher = new DispatchingEventPublisher(actorRuntime) + { + FailNextSend = true, + }; + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + StreamingRepliesEnabled = false, + }, + eventPublisher: publisher); + AttachScheduler(runtime, scheduler); + var request = new NeedsLlmReplyEvent + { + CorrelationId = "corr-retry-drop", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + }; + + await runtime.HandleStartAsync(request); + + runtime.State.Status.Should().Be(AgentRunStatus.Started); + handled.Should().BeEmpty(); + replyGenerator.CallCount.Should().Be(0); + + var retryCommand = scheduler.Timeouts.Should().ContainSingle( + timeout => timeout.TriggerEnvelope.Payload.Is(AgentRunStartRequested.Descriptor)) + .Subject.TriggerEnvelope.Payload.Unpack(); + + await runtime.HandleStartAsync(retryCommand); + + runtime.State.Status.Should().Be(AgentRunStatus.Dropped); + replyGenerator.CallCount.Should().Be(0); + handled.Should().ContainSingle(e => e.Payload.Is(DeferredLlmReplyDroppedEvent.Descriptor)); + } + + [Fact] + public async Task HandleStartAsync_ShouldPersistFailed_WhenUnexpectedExceptionFollowsStartedEvent() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new FailingOnceGetActorRuntime(("actor-1", actor)); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + StreamingRepliesEnabled = false, + }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-unexpected", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-unexpected", + }); + + runtime.State.Status.Should().Be(AgentRunStatus.Failed); + runtime.State.ErrorCode.Should().Be("agent_run_unhandled_exception"); + replyGenerator.CallCount.Should().Be(0); + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); + ready.ErrorCode.Should().Be("agent_run_unhandled_exception"); + } + + [Fact] + public async Task HandleStartAsync_RelayTurnCapturesInteractiveIntentIntoReadyEvent() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => + { + var intent = new MessageContent + { + Text = "Choose one", + }; + intent.Actions.Add(new ActionElement + { + Kind = ActionElementKind.Button, + ActionId = "confirm", + Label = "Confirm", + IsPrimary = true, + }); + return collector.Capture(intent); + }); + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-1", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-1", + }); + + replyGenerator.CaptureSucceeded.Should().BeTrue(); + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.Outbound.Text.Should().Be("Choose one"); + ready.Outbound.Actions.Should().ContainSingle(); + ready.Outbound.Actions[0].ActionId.Should().Be("confirm"); + } + + [Fact] + public async Task HandleStartAsync_NonRelayTurnDoesNotEnableInteractiveScope() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => collector.Capture(new MessageContent { Text = "ignored" })) + { + ReplyText = "plain reply", + }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-2", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = new ChatActivity + { + Id = "msg-2", + Content = new MessageContent { Text = "hello" }, + }, + }); + + replyGenerator.CaptureSucceeded.Should().BeFalse(); + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.Outbound.Text.Should().Be("plain reply"); + ready.Outbound.Actions.Should().BeEmpty(); + } + + [Fact] + public async Task HandleStartAsync_ShouldEmitFailedReply_WhenGeneratorThrows() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new ThrowingReplyGenerator(new InvalidOperationException("boom")); + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-throw", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-throw", + }); + + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); + ready.ErrorCode.Should().Be("llm_reply_failed"); + ready.ErrorSummary.Should().Be("boom"); + ready.Outbound.Text.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task HandleStartAsync_ShouldEmitTimeoutFallbackReply_WhenGeneratorHangsPastBudget() + { + // Without a cancellation budget on the LLM run, a tool that hangs (broken sandbox, + // unreachable proxy upstream, slow remote SSH) would pin the run actor turn indefinitely + // and Lark would stay on the loading reaction forever. The runtime caps each turn at + // the relay ResponseTimeoutSeconds and folds the cancellation into a user-visible + // fallback reply with errorCode=llm_reply_timeout. + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new HangingReplyGenerator(); + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = true, + ResponseTimeoutSeconds = 1, + }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-timeout", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-timeout", + }); + + replyGenerator.WasCancelled.Should().BeTrue(); + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); + ready.ErrorCode.Should().Be("llm_reply_timeout"); + ready.ErrorSummary.Should().Contain("1s budget"); + ready.Outbound.Text.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task HandleStartAsync_ShouldEmitFailedReply_WhenGeneratorReturnsEmpty() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => false) + { + ReplyText = " ", + }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-empty", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-empty", + }); + + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); + ready.ErrorCode.Should().Be("empty_reply"); + ready.Outbound.Text.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task HandleStartAsync_ShouldEchoReplyTokenIntoLlmReplyReadyEvent() + { + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + new RecordingReplyGenerator(() => false) { ReplyText = "ok" }, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + var expiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(); + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-echo", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-echo", + ReplyTokenExpiresAtUnixMs = expiresAtUnixMs, + }); + + handled.Should().NotBeNull(); + var ready = handled!.Payload.Unpack(); + ready.ReplyToken.Should().Be("relay-token-echo"); + ready.ReplyTokenExpiresAtUnixMs.Should().Be(expiresAtUnixMs); + } + + [Fact] + public async Task HandleStartAsync_ShouldDropRelayRequest_WhenRunCommandCarriesNoReplyToken() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + // Relay activity but no command-carried ReplyToken — simulates a request rehydrated + // from persisted state after a pod restart, where the original token capture is gone. + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-no-token", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + }); + + replyGenerator.CaptureSucceeded.Should().BeFalse(); + handled.Should().NotBeNull(); + var dropped = handled!.Payload.Unpack(); + dropped.CorrelationId.Should().Be("corr-no-token"); + dropped.Reason.Should().Be("missing_relay_reply_token"); + } + + [Fact] + public async Task HandleStartAsync_ShouldDropRequest_WhenOlderThanMaxAge() + { + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + var requestedAtUnixMs = DateTimeOffset.UtcNow + .AddMilliseconds(-(AgentRunGAgent.MaxRunRequestAgeMs + 60_000)) + .ToUnixTimeMilliseconds(); + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-stale", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-stale", + RequestedAtUnixMs = requestedAtUnixMs, + }); + + replyGenerator.CaptureSucceeded.Should().BeFalse(); + handled.Should().NotBeNull(); + var dropped = handled!.Payload.Unpack(); + dropped.CorrelationId.Should().Be("corr-stale"); + dropped.Reason.Should().Be("stale_agent_run_request_dropped"); + } + + [Fact] + public async Task HandleStartAsync_ShouldDropSilently_WhenTargetActorIdMissing() + { + var actorRuntime = Substitute.For(); + var runtime = CreateRunAgent( + actorRuntime, + new RecordingReplyGenerator(() => false), + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-missing", + TargetActorId = string.Empty, + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + }); + + await actorRuntime.DidNotReceiveWithAnyArgs().GetAsync(Arg.Any()); + } + + [Fact] + public async Task HandleStartAsync_ShouldNotifyActor_WhenActivityMissing() + { + // Malformed payload (no Activity) should still tell the actor to retire its + // pending entry — the actor decides whether to clean up. Otherwise the entry + // accumulates silently in State.PendingLlmReplyRequests until rehydration. + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + EventEnvelope? handled = null; + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled = call.Arg()); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + new RecordingReplyGenerator(() => false), + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-no-activity", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + }); + + handled.Should().NotBeNull(); + var dropped = handled!.Payload.Unpack(); + dropped.CorrelationId.Should().Be("corr-no-activity"); + dropped.Reason.Should().Be("malformed_deferred_llm_reply_request"); + } + + [Fact] + public async Task HandleStartAsync_StreamingEnabled_DispatchesChunkEventAndReadyEvent() + { + // Pin the legacy edit-message path explicitly: card-mode is now the default + // (StreamingCardKitEnabled=true) and emits a structurally distinct + // LlmReplyCardStreamChunkEvent. This test specifically exercises the + // text-edit chunk shape, so opt out of card mode here. + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "streamed reply" }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = false, + StreamingRepliesEnabled = true, + StreamingFlushIntervalMs = 0, + StreamingCardKitEnabled = false, + }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-stream", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-stream", + ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), + }); + + handled.Any(e => e.Payload.Is(LlmReplyStreamChunkEvent.Descriptor)).Should().BeTrue(); + handled.Any(e => e.Payload.Is(LlmReplyReadyEvent.Descriptor)).Should().BeTrue(); + var chunk = handled.First(e => e.Payload.Is(LlmReplyStreamChunkEvent.Descriptor)) + .Payload.Unpack(); + chunk.AccumulatedText.Should().Be("streamed reply"); + chunk.CorrelationId.Should().Be("corr-stream"); + } + + [Fact] + public async Task HandleStartAsync_StreamingEnabledWithDefaultCardMode_DispatchesCardChunkEvent() + { + // Pinning the new default: StreamingCardKitEnabled=true causes the sink to emit + // the card-mode chunk type, exercising the CardKit lifecycle entrypoint without + // needing a real ChannelCardConversationTurnRunner wired up (the actor is mocked, + // so we only verify the run actor dispatched the right proto type to the actor). + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "card streamed reply" }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_2"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions + { + InteractiveRepliesEnabled = false, + StreamingRepliesEnabled = true, + StreamingCardKitFlushIntervalMs = 0, + // StreamingCardKitEnabled defaults to true. + }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-card-stream", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-card-stream", + ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), + }); + + handled.Any(e => e.Payload.Is(LlmReplyCardStreamChunkEvent.Descriptor)).Should().BeTrue(); + handled.Any(e => e.Payload.Is(LlmReplyReadyEvent.Descriptor)).Should().BeTrue(); + var chunk = handled.First(e => e.Payload.Is(LlmReplyCardStreamChunkEvent.Descriptor)) + .Payload.Unpack(); + chunk.AccumulatedText.Should().Be("card streamed reply"); + chunk.CorrelationId.Should().Be("corr-card-stream"); + } + + [Fact] + public async Task HandleStartAsync_StreamingDisabledFlag_DispatchesOnlyReadyEvent() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "plain reply" }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = false, StreamingRepliesEnabled = false }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-legacy", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = BuildRelayActivity(), + ReplyToken = "relay-token-legacy", + ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), + }); + + handled.Should().ContainSingle(); + handled[0].Payload.Is(LlmReplyReadyEvent.Descriptor).Should().BeTrue(); + } + + [Fact] + public async Task HandleStartAsync_StreamingEnabledButNonRelay_DispatchesOnlyReadyEvent() + { + var collector = new AsyncLocalInteractiveReplyCollector(); + var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "plain reply" }; + var actor = Substitute.For(); + actor.Id.Returns("channel-conversation:lark:dm:user"); + var handled = new List(); + actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) + .Do(call => handled.Add(call.Arg())); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + collector, + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = false, StreamingRepliesEnabled = true }); + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-nonrelay", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = new ChatActivity + { + Id = "msg-nonrelay", + Content = new MessageContent { Text = "hello" }, + // No OutboundDelivery → not a relay turn + }, + }); + + handled.Should().ContainSingle(); + handled[0].Payload.Is(LlmReplyReadyEvent.Descriptor).Should().BeTrue(); + } + + [Fact] + public async Task HandleStartAsync_ShouldApplyBotOwnerLlmConfig_FromUserConfigQueryPort() + { + // Bot owner's LLM model + route comes from UserConfig (the same store that backs + // their nyxid-chat preferences), looked up by the scope id resolved from the + // bot registration. The relay turn uses the inbound user-token as the bearer + // (it is the bot owner's own NyxID session, freshly issued per callback) while + // taking model / route / max-tool-rounds from the owner's pre-configured + // UserConfig. + var capturedMetadata = new Dictionary(StringComparer.Ordinal); + var replyGenerator = new RecordingReplyGenerator(() => false) + { + ReplyText = "ack", + MetadataObserver = m => + { + foreach (var pair in m) + capturedMetadata[pair.Key] = pair.Value; + }, + }; + + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + + var scopeResolver = Substitute.For(); + scopeResolver.ResolveScopeIdByApiKeyAsync("api-key-bot", Arg.Any()) + .Returns(Task.FromResult("scope-bot-owner")); + + var userConfigQueryPort = Substitute.For(); + userConfigQueryPort.GetAsync("scope-bot-owner", Arg.Any()) + .Returns(Task.FromResult(new Aevatar.Studio.Application.Studio.Abstractions.UserConfig( + DefaultModel: "gpt-4o-bot-owner", + PreferredLlmRoute: "/api/v1/proxy/s/anthropic-via-bot-owner", + RuntimeMode: "local", + LocalRuntimeBaseUrl: "http://localhost", + RemoteRuntimeBaseUrl: "https://example.com", + GithubUsername: null, + MaxToolRounds: 11))); + + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, + scopeResolver, + userConfigQueryPort); + + var activity = BuildRelayActivity(); + activity.Bot = BotInstanceId.From("api-key-bot"); + activity.TransportExtras = new TransportExtras + { + NyxUserAccessToken = "bot-owner-session-jwt", + }; + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-bot-owner", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = activity, + ReplyToken = "relay-token-bot-owner", + }); + + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.ModelOverride) + .WhoseValue.Should().Be("gpt-4o-bot-owner"); + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdRoutePreference) + .WhoseValue.Should().Be("/api/v1/proxy/s/anthropic-via-bot-owner"); + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.MaxToolRoundsOverride) + .WhoseValue.Should().Be("11"); + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdAccessToken) + .WhoseValue.Should().Be("bot-owner-session-jwt"); + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdOrgToken) + .WhoseValue.Should().Be("bot-owner-session-jwt"); + } + + [Fact] + public async Task HandleStartAsync_ShouldThreadBotOwnerSessionTokenAsLlmBearer() + { + // The inbound X-NyxID-User-Token is the bot owner's own NyxID session JWT. + // It is the credential that would authorize the owner's LLM calls in + // nyxid-chat, so it is also the correct credential for the bot's relay + // LLM call. The stale-pending GC plus the direct-enqueue + run-echoed + // token flow keeps it fresh through the window where the LLM call actually + // fires. + var capturedMetadata = new Dictionary(StringComparer.Ordinal); + var replyGenerator = new RecordingReplyGenerator(() => false) + { + ReplyText = "ack", + MetadataObserver = m => + { + foreach (var pair in m) + capturedMetadata[pair.Key] = pair.Value; + }, + }; + + var actor = Substitute.For(); + actor.Id.Returns("actor-1"); + var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); + + var runtime = CreateRunAgent( + actorRuntime, + replyGenerator, + new AsyncLocalInteractiveReplyCollector(), + new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }); + + var activity = BuildRelayActivity(); + activity.TransportExtras = new TransportExtras + { + NyxUserAccessToken = "bot-owner-session-jwt", + }; + + await runtime.HandleStartAsync(new NeedsLlmReplyEvent + { + CorrelationId = "corr-bearer", + TargetActorId = "actor-1", + RegistrationId = "reg-1", + Activity = activity, + ReplyToken = "relay-token-1", + }); + + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdAccessToken) + .WhoseValue.Should().Be("bot-owner-session-jwt"); + capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdOrgToken) + .WhoseValue.Should().Be("bot-owner-session-jwt"); + } + + private static AgentRunGAgent CreateRunAgent( + IActorRuntime actorRuntime, + IConversationReplyGenerator replyGenerator, + IInteractiveReplyCollector? collector, + Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions relayOptions, + INyxIdRelayScopeResolver? scopeResolver = null, + IUserConfigQueryPort? userConfigQueryPort = null, + IEventPublisher? eventPublisher = null) + { + var dispatchPort = actorRuntime as IActorDispatchPort ?? Substitute.For(); + var agent = new AgentRunGAgent( + actorRuntime, + dispatchPort, + replyGenerator, + collector, + relayOptions, + NullLogger.Instance, + scopeResolver, + userConfigQueryPort); + SetId(agent, AgentRunGAgent.BuildActorId(Guid.NewGuid().ToString("N"))); + agent.EventSourcing = new StateTransitionEventSourcing((current, evt) => + InvokeAgentTransition(agent, current, evt)); + agent.EventPublisher = eventPublisher ?? new DispatchingEventPublisher(actorRuntime); + return agent; + } + + private static void AttachScheduler(AgentRunGAgent agent, RecordingCallbackScheduler scheduler) + { + agent.Services = new ServiceCollection() + .AddSingleton(scheduler) + .BuildServiceProvider(); + } + + private static AgentRunGAgentState InvokeAgentTransition( + AgentRunGAgent agent, + AgentRunGAgentState current, + IMessage evt) + { + var currentType = agent.GetType(); + while (currentType is not null) + { + var transitionMethod = currentType.GetMethod( + "TransitionState", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (transitionMethod is not null) + return (AgentRunGAgentState)transitionMethod.Invoke(agent, [current, evt])!; + + currentType = currentType.BaseType; + } + + throw new InvalidOperationException("Unable to invoke AgentRunGAgent transition via reflection."); + } + + private static void SetId(object agent, string id) + { + var current = agent.GetType(); + while (current is not null) + { + var setIdMethod = current.GetMethod( + "SetId", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + if (setIdMethod is not null) + { + setIdMethod.Invoke(agent, [id]); + return; + } + + current = current.BaseType; + } + + throw new InvalidOperationException("Unable to set agent id via reflection."); + } + + private static ChatActivity BuildRelayActivity() => + new() + { + Id = "msg-1", + ChannelId = ChannelId.From("lark"), + Conversation = ConversationReference.Create( + ChannelId.From("lark"), + BotInstanceId.From("reg-1"), + ConversationScope.Group, + "oc_group_chat_1", + "group", + "oc_group_chat_1"), + Content = new MessageContent { Text = "hello" }, + OutboundDelivery = new OutboundDeliveryContext + { + ReplyMessageId = "relay-msg-1", + CorrelationId = "corr-1", + }, + }; + + private sealed class DispatchingActorRuntime(params (string Id, IActor Actor)[] actors) : + IActorRuntime, + IActorDispatchPort + { + private readonly Dictionary _actors = actors.ToDictionary( + static pair => pair.Id, + static pair => pair.Actor, + StringComparer.Ordinal); + + public List<(string ActorId, EventEnvelope Envelope)> Dispatches { get; } = []; + + public List DestroyedIds { get; } = []; + + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent + { + var actorId = id ?? Guid.NewGuid().ToString("N"); + if (_actors.TryGetValue(actorId, out var existing)) + return Task.FromResult(existing); + + var actor = Substitute.For(); + actor.Id.Returns(actorId); + _actors[actorId] = actor; + return Task.FromResult(actor); + } + + public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) => + CreateAsync(id, ct); + + public Task DestroyAsync(string id, CancellationToken ct = default) + { + DestroyedIds.Add(id); + _actors.Remove(id); + return Task.CompletedTask; + } + + public Task GetAsync(string id) => + Task.FromResult(_actors.TryGetValue(id, out var actor) ? actor : null); + + public Task ExistsAsync(string id) => Task.FromResult(_actors.ContainsKey(id)); + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => + Task.CompletedTask; + + public Task UnlinkAsync(string childId, CancellationToken ct = default) => + Task.CompletedTask; + + public async Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + Dispatches.Add((actorId, envelope)); + if (!_actors.TryGetValue(actorId, out var actor)) + throw new InvalidOperationException($"Actor {actorId} not found."); + await actor.HandleEventAsync(envelope, ct); + } + } + + private sealed class FailingOnceGetActorRuntime(params (string Id, IActor Actor)[] actors) : IActorRuntime + { + private readonly DispatchingActorRuntime _inner = new(actors); + private bool _failNextGet = true; + + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent => + _inner.CreateAsync(id, ct); + + public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) => + _inner.CreateAsync(agentType, id, ct); + + public Task DestroyAsync(string id, CancellationToken ct = default) => + _inner.DestroyAsync(id, ct); + + public Task GetAsync(string id) + { + if (_failNextGet) + { + _failNextGet = false; + throw new InvalidOperationException("actor runtime lookup failed"); + } + + return _inner.GetAsync(id); + } + + public Task ExistsAsync(string id) => _inner.ExistsAsync(id); + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => + _inner.LinkAsync(parentId, childId, ct); + + public Task UnlinkAsync(string childId, CancellationToken ct = default) => + _inner.UnlinkAsync(childId, ct); + } + + private sealed class StateTransitionEventSourcing(Func transition) + : IEventSourcingBehavior + where TState : class, IMessage, new() + { + private readonly List _pending = []; + + public long CurrentVersion { get; private set; } + + public void RaiseEvent(TEvent evt) where TEvent : IMessage + { + _pending.Add(evt); + } + + public Task ConfirmEventsAsync(CancellationToken ct = default) + { + CurrentVersion += _pending.Count; + _pending.Clear(); + return Task.FromResult(new EventStoreCommitResult + { + LatestVersion = CurrentVersion, + }); + } + + public Task PersistSnapshotAsync(TState currentState, CancellationToken ct = default) => + Task.CompletedTask; + + public Task ReplayAsync(string agentId, CancellationToken ct = default) => + Task.FromResult(null); + + public void DiscardPendingEvents() + { + _pending.Clear(); + } + + public TState TransitionState(TState current, IMessage evt) => transition(current, evt); + } + + private sealed class RecordingCallbackScheduler : IActorRuntimeCallbackScheduler + { + public List Timeouts { get; } = []; + + public List Timers { get; } = []; + + public List Cancelled { get; } = []; + + public List PurgedActorIds { get; } = []; + + public Task ScheduleTimeoutAsync( + RuntimeCallbackTimeoutRequest request, + CancellationToken ct = default) + { + Timeouts.Add(request); + return Task.FromResult(new RuntimeCallbackLease( + request.ActorId, + request.CallbackId, + Timeouts.Count, + RuntimeCallbackBackend.InMemory)); + } + + public Task ScheduleTimerAsync( + RuntimeCallbackTimerRequest request, + CancellationToken ct = default) + { + Timers.Add(request); + return Task.FromResult(new RuntimeCallbackLease( + request.ActorId, + request.CallbackId, + Timers.Count, + RuntimeCallbackBackend.InMemory)); + } + + public Task CancelAsync(RuntimeCallbackLease lease, CancellationToken ct = default) + { + Cancelled.Add(lease); + return Task.CompletedTask; + } + + public Task PurgeActorAsync(string actorId, CancellationToken ct = default) + { + PurgedActorIds.Add(actorId); + return Task.CompletedTask; + } + } + + private sealed class DispatchingEventPublisher(IActorRuntime actorRuntime) : IEventPublisher + { + public bool FailNextSend { get; set; } + + public List<(string TargetActorId, IMessage Event)> Sent { get; } = []; + + public Task PublishAsync( + T e, + TopologyAudience audience = TopologyAudience.Children, + CancellationToken c = default, + EventEnvelope? sourceEnvelope = null, + EventEnvelopePublishOptions? options = null) + where T : IMessage => Task.CompletedTask; + + public async Task SendToAsync( + string targetActorId, + T e, + CancellationToken c = default, + EventEnvelope? sourceEnvelope = null, + EventEnvelopePublishOptions? options = null) + where T : IMessage + { + if (FailNextSend) + { + FailNextSend = false; + throw new InvalidOperationException("send not accepted"); + } + + Sent.Add((targetActorId, e)); + var actor = await actorRuntime.GetAsync(targetActorId) + ?? throw new InvalidOperationException($"Actor {targetActorId} not found."); + await actor.HandleEventAsync(new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(e), + Route = EnvelopeRouteSemantics.CreateDirect("agent-run-test-publisher", targetActorId), + Propagation = new EnvelopePropagation + { + CorrelationId = sourceEnvelope?.Propagation?.CorrelationId ?? string.Empty, + }, + }, c); + } + } + + private sealed class RecordingStreamProvider : IStreamProvider + { + private readonly Dictionary _streams = new(StringComparer.Ordinal); + + public List<(string StreamId, EventEnvelope Envelope)> Produced => + _streams.Values.SelectMany(stream => stream.Produced.Select(envelope => (stream.StreamId, envelope))).ToList(); + + public IStream GetStream(string actorId) + { + if (!_streams.TryGetValue(actorId, out var stream)) + { + stream = new RecordingStream(actorId); + _streams[actorId] = stream; + } + + return stream; + } + } + + private sealed class RecordingStream(string streamId) : IStream + { + public string StreamId { get; } = streamId; + + public List Produced { get; } = []; + + public Task ProduceAsync(T message, CancellationToken ct = default) where T : IMessage + { + if (message is EventEnvelope envelope) + Produced.Add(envelope.Clone()); + return Task.CompletedTask; + } + + public Task SubscribeAsync(Func handler, CancellationToken ct = default) + where T : IMessage, new() => + Task.FromResult(new NoopAsyncDisposable()); + + public Task UpsertRelayAsync(StreamForwardingBinding binding, CancellationToken ct = default) => + Task.CompletedTask; + + public Task RemoveRelayAsync(string targetStreamId, CancellationToken ct = default) => + Task.CompletedTask; + + public Task> ListRelaysAsync(CancellationToken ct = default) => + Task.FromResult>([]); + } + + private sealed class NoopAsyncDisposable : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class RecordingReplyGenerator(Func captureAction) : IConversationReplyGenerator + { + public string ReplyText { get; init; } = string.Empty; + + public int CallCount { get; private set; } + + public bool CaptureSucceeded { get; private set; } + + public Action>? MetadataObserver { get; init; } + + public async Task GenerateReplyAsync( + ChatActivity activity, + IReadOnlyDictionary metadata, + IStreamingReplySink? streamingSink, + CancellationToken ct) + { + CallCount++; + CaptureSucceeded = captureAction(); + MetadataObserver?.Invoke(metadata); + if (streamingSink is not null && !string.IsNullOrEmpty(ReplyText)) + await streamingSink.OnDeltaAsync(ReplyText, ct); + return ReplyText; + } + } + + private sealed class ThrowingReplyGenerator(Exception exception) : IConversationReplyGenerator + { + public Task GenerateReplyAsync( + ChatActivity activity, + IReadOnlyDictionary metadata, + IStreamingReplySink? streamingSink, + CancellationToken ct) => Task.FromException(exception); + } + + /// Generator that never completes on its own; only ends when the runtime cancels it. + private sealed class HangingReplyGenerator : IConversationReplyGenerator + { + public bool WasCancelled { get; private set; } + + public async Task GenerateReplyAsync( + ChatActivity activity, + IReadOnlyDictionary metadata, + IStreamingReplySink? streamingSink, + CancellationToken ct) + { + var pendingReply = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationRegistration = ct.Register(() => + { + WasCancelled = true; + pendingReply.TrySetCanceled(ct); + }); + + return await pendingReply.Task; + } + } +} + +internal static class AgentRunGAgentTestExtensions +{ + public static Task HandleStartAsync(this AgentRunGAgent agent, NeedsLlmReplyEvent request) => + agent.HandleStartAsync(new AgentRunStartRequested + { + Request = request, + }); +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs index 8eaf453d3..f7a560e73 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelConversationTurnRunnerTests.cs @@ -618,7 +618,7 @@ public async Task RunInboundAsync_ShouldRouteAgentBuilderCardAction_WhenCardPayl var runner = CreateRunner(registrationQueryPort, adapter); var activity = BuildCardActionActivity("evt-card-builder-1"); - activity.Content.CardAction.Arguments["agent_builder_action"] = "open_daily_report_form"; + activity.Content.CardAction.Arguments["agent_builder_action"] = "list_agents"; var result = await runner.RunInboundAsync(activity, CancellationToken.None); @@ -627,7 +627,7 @@ public async Task RunInboundAsync_ShouldRouteAgentBuilderCardAction_WhenCardPayl adapter.Replies.Should().ContainSingle(); adapter.Replies[0].Inbound.ChatType.Should().Be("card_action"); adapter.Replies[0].Inbound.Extra.Should().ContainKey("agent_builder_action") - .WhoseValue.Should().Be("open_daily_report_form"); + .WhoseValue.Should().Be("list_agents"); } [Fact] @@ -638,7 +638,7 @@ public async Task RunInboundAsync_ShouldRouteAgentBuilderCardAction_WhenActionId var runner = CreateRunner(registrationQueryPort, adapter); var activity = BuildCardActionActivity("evt-card-builder-action-id-1"); - activity.Content.CardAction.ActionId = "open_daily_report_form"; + activity.Content.CardAction.ActionId = "list_agents"; var result = await runner.RunInboundAsync(activity, CancellationToken.None); @@ -646,7 +646,7 @@ public async Task RunInboundAsync_ShouldRouteAgentBuilderCardAction_WhenActionId result.SentActivityId.Should().Be("direct-reply:evt-card-builder-action-id-1"); adapter.Replies.Should().ContainSingle(); adapter.Replies[0].Inbound.Extra.Should().ContainKey("agent_builder_action") - .WhoseValue.Should().Be("open_daily_report_form"); + .WhoseValue.Should().Be("list_agents"); } [Fact] @@ -817,117 +817,7 @@ public async Task RunInboundAsync_ShouldMapWorkflowResumeDispatchErrors( adapter.Replies.Should().BeEmpty(); } - [Fact] - public async Task RunInboundAsync_ShouldRouteSlashCommand_WhenRegistrationHasNoRelayApiKey() - { - var registrationQueryPort = BuildRegistrationQueryPort(); - var adapter = new RecordingPlatformAdapter(); - var runner = CreateRunner(registrationQueryPort, adapter); - - var result = await runner.RunInboundAsync( - BuildInboundActivity( - "/daily alice", - "msg-slash-1", - ConversationScope.DirectMessage, - "oc_p2p_chat_1"), - CancellationToken.None); - - result.Success.Should().BeTrue(); - result.SentActivityId.Should().Be("direct-reply:msg-slash-1"); - adapter.Replies.Should().ContainSingle(); - adapter.Replies[0].ReplyText.Should().Contain("Create daily report agent failed"); - adapter.Replies[0].ReplyText.Should().Contain("No NyxID access token available"); - } - - [Fact] - public async Task RunInboundAsync_ShouldSendRelayReply_ForDailySlashCommand_WhenRelayDeliveryIsPresent() - { - var registrationQueryPort = BuildRegistrationQueryPort(); - var adapter = new RecordingPlatformAdapter(); - var relayHandler = new RecordingJsonHandler("""{"message_id":"relay-reply-daily"}"""); - var runner = CreateRunner( - registrationQueryPort, - adapter, - relayHandler: relayHandler); - - var result = await runner.RunInboundAsync( - BuildInboundActivity( - "/daily alice", - "msg-daily-relay-1", - ConversationScope.DirectMessage, - "oc_p2p_chat_1", - new OutboundDeliveryContext - { - ReplyMessageId = "relay-msg-daily-1", - CorrelationId = "corr-daily-relay-1", - }, - new TransportExtras - { - NyxPlatform = "lark", - }), - RelayRuntimeContext( - "corr-daily-relay-1", - "relay-token-daily-1", - "relay-msg-daily-1"), - CancellationToken.None); - - result.Success.Should().BeTrue(); - result.SentActivityId.Should().Be("direct-reply:msg-daily-relay-1"); - result.OutboundDelivery?.ReplyMessageId.Should().Be("relay-msg-daily-1"); - result.OutboundDelivery?.CorrelationId.Should().Be("corr-daily-relay-1"); - adapter.Replies.Should().BeEmpty(); - relayHandler.Requests.Should().ContainSingle(); - relayHandler.Requests[0].Path.Should().Be("/api/v1/channel-relay/reply"); - relayHandler.Requests[0].Authorization.Should().Be("Bearer relay-token-daily-1"); - relayHandler.Requests[0].Body.Should().Contain("\"message_id\":\"relay-msg-daily-1\""); - relayHandler.Requests[0].Body.Should().Contain("\"text\":\"Create daily report agent failed"); - relayHandler.Requests[0].Body.Should().Contain("No NyxID access token available"); - } - - [Fact] - public async Task RunInboundAsync_ShouldSendRelayRestriction_ForDailySlashCommandInGroup() - { - var registrationQueryPort = BuildRegistrationQueryPort(); - var adapter = new RecordingPlatformAdapter(); - var relayHandler = new RecordingJsonHandler("""{"message_id":"relay-reply-group"}"""); - var runner = CreateRunner( - registrationQueryPort, - adapter, - relayHandler: relayHandler); - - var result = await runner.RunInboundAsync( - BuildInboundActivity( - "/daily alice", - "msg-daily-group-1", - ConversationScope.Group, - "oc_group_chat_1", - new OutboundDeliveryContext - { - ReplyMessageId = "relay-msg-group-1", - CorrelationId = "corr-daily-group-1", - }, - new TransportExtras - { - NyxPlatform = "lark", - }), - RelayRuntimeContext( - "corr-daily-group-1", - "relay-token-group-1", - "relay-msg-group-1"), - CancellationToken.None); - - result.Success.Should().BeTrue(); - adapter.Replies.Should().BeEmpty(); - relayHandler.Requests.Should().ContainSingle(); - relayHandler.Requests[0].Path.Should().Be("/api/v1/channel-relay/reply"); - relayHandler.Requests[0].Authorization.Should().Be("Bearer relay-token-group-1"); - relayHandler.Requests[0].Body.Should().Contain("\"message_id\":\"relay-msg-group-1\""); - relayHandler.Requests[0].Body.Should().Contain("private chat"); - relayHandler.Requests[0].Body.Should().Contain("/daily"); - } - [Theory] - [InlineData("/daily_report")] [InlineData("/foobar")] public async Task RunInboundAsync_ShouldSendRelayUsage_ForUnknownSlashCommand(string command) { @@ -1041,7 +931,7 @@ public async Task RunInboundAsync_ShouldCarryRelayReplyToken_WhenNormalRelayText } [Fact] - public async Task RunInboundAsync_ShouldSendBindingCard_WhenUnboundPrivateSenderSendsNormalMessage() + public async Task RunInboundAsync_ShouldRequestLlmReply_WhenUnboundPrivateSenderSendsNormalMessage() { var broker = new InMemoryCapabilityBroker(); var services = new ServiceCollection() @@ -1093,28 +983,65 @@ public async Task RunInboundAsync_ShouldSendBindingCard_WhenUnboundPrivateSender CancellationToken.None); result.Success.Should().BeTrue(); - result.SentActivityId.Should().Be("reply-binding-card-1"); - result.LlmReplyRequest.Should().BeNull(); - result.Outbound.Cards.Should().ContainSingle(card => card.Title == "完成 NyxID 绑定"); - result.Outbound.Actions.Should().ContainSingle(action => - action.Kind == ActionElementKind.Link && - action.IsPrimary && - action.Value.Contains("test-nyxid.local/oauth/authorize")); + result.SentActivityId.Should().BeNullOrEmpty(); + result.LlmReplyRequest.Should().NotBeNull(); + result.LlmReplyRequest!.ReplyToken.Should().Be("relay-token-binding-1"); + result.LlmReplyRequest.Metadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderBindingId); + result.LlmReplyRequest.Metadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + result.Outbound.Cards.Should().BeEmpty(); + result.Outbound.Actions.Should().BeEmpty(); + adapter.Replies.Should().BeEmpty(); + await interactiveDispatcher.DidNotReceiveWithAnyArgs().DispatchAsync( + default!, + default!, + default!, + default!, + default!, + default); + } + + [Fact] + public async Task RunInboundAsync_ShouldAttachSenderBindingAndToken_WhenBoundSenderSendsNormalMessage() + { + var broker = new InMemoryCapabilityBroker(); + broker.SeedBinding( + new ExternalSubjectRef + { + Platform = "lark", + Tenant = "scope-1", + ExternalUserId = "ou_user_1", + }, + new BindingId { Value = "bnd-user-1" }); + + var services = new ServiceCollection() + .AddSingleton(broker) + .AddSingleton(broker) + .BuildServiceProvider(); + var registrationQueryPort = BuildRegistrationQueryPort(); + var adapter = new RecordingPlatformAdapter(); + var runner = CreateRunner(registrationQueryPort, adapter, services); + + var result = await runner.RunInboundAsync( + BuildInboundActivity( + "hello", + "msg-bound-private-1", + ConversationScope.DirectMessage, + "oc_p2p_chat_1", + transportExtras: new TransportExtras + { + NyxPlatform = "lark", + }), + CancellationToken.None); + + result.Success.Should().BeTrue(); + result.LlmReplyRequest.Should().NotBeNull(); + result.LlmReplyRequest!.Metadata[LLMRequestMetadataKeys.SenderBindingId].Should().Be("bnd-user-1"); + result.LlmReplyRequest.Metadata[LLMRequestMetadataKeys.SenderNyxIdAccessToken].Should().Be("test-access-token-for-bnd-user-1"); adapter.Replies.Should().BeEmpty(); - await interactiveDispatcher.Received(1).DispatchAsync( - Arg.Is(channel => channel.Value == "lark"), - "relay-msg-binding-1", - "relay-token-binding-1", - Arg.Is(message => - message.Cards.Count == 1 && - message.Actions.Count == 1 && - message.Actions[0].Value.Contains("test-nyxid.local/oauth/authorize")), - Arg.Any(), - Arg.Any()); } [Fact] - public async Task RunInboundAsync_ShouldPromptPrivateChatWithoutSlashCommand_WhenUnboundGroupSender() + public async Task RunInboundAsync_ShouldRequestLlmReply_WhenUnboundGroupSenderSendsNormalMessage() { var broker = new InMemoryCapabilityBroker(); var services = new ServiceCollection() @@ -1130,14 +1057,13 @@ public async Task RunInboundAsync_ShouldPromptPrivateChatWithoutSlashCommand_Whe CancellationToken.None); result.Success.Should().BeTrue(); - result.LlmReplyRequest.Should().BeNull(); - adapter.Replies.Should().ContainSingle(); - adapter.Replies[0].ReplyText.Should().Contain("请与 bot 私聊任意消息以获取 NyxID 绑定卡片。"); - adapter.Replies[0].ReplyText.Should().NotContain("/init"); + result.LlmReplyRequest.Should().NotBeNull(); + result.LlmReplyRequest!.Metadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderBindingId); + result.LlmReplyRequest.Metadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + adapter.Replies.Should().BeEmpty(); } [Theory] - [InlineData("/daily_report")] [InlineData("/foobar")] [InlineData("/")] public async Task RunInboundAsync_ShouldShortCircuitUnknownSlashCommand_WithUsage(string command) @@ -1395,19 +1321,18 @@ public async Task RunLlmReplyAsync_ShouldSendRelayReply_WhenReadyEventArrives() } [Fact] - public async Task RunLlmReplyAsync_ShouldSwapTypingReactionToDone_AfterSuccessfulRelayReply() + public async Task RunLlmReplyAsync_ShouldClearTypingReaction_AfterSuccessfulRelayReply() { var registrationQueryPort = BuildRegistrationQueryPort(); var adapter = new RecordingPlatformAdapter(); var relayHandler = new RecordingJsonHandler("""{"message_id":"reply-swap-1"}"""); - // Expect 3 nyx calls fired by the post-reply swap: list Typing → delete bot's - // Typing reaction → add DONE. The list response carries one bot-owned reaction + // Expect 2 nyx calls fired by the post-reply clear: list Typing → delete bot's + // Typing reaction. The list response carries one bot-owned reaction // ("operator_type":"app") and one user-owned ("operator_type":"user") that the - // swap must leave alone. + // clear must leave alone. var nyxHandler = new SequencedJsonHandler( - expectedCallCount: 3, + expectedCallCount: 2, """{"code":0,"data":{"items":[{"reaction_id":"r-bot-1","operator":{"operator_type":"app","operator_id":"bot-1"},"reaction_type":{"emoji_type":"Typing"}},{"reaction_id":"r-user-1","operator":{"operator_type":"user","operator_id":"u-1"},"reaction_type":{"emoji_type":"Typing"}}],"has_more":false}}""", - """{"code":0,"data":{}}""", """{"code":0,"data":{}}"""); var runner = CreateRunner( registrationQueryPort, @@ -1448,7 +1373,7 @@ public async Task RunLlmReplyAsync_ShouldSwapTypingReactionToDone_AfterSuccessfu result.Success.Should().BeTrue(); await nyxHandler.Completed.Task.WaitAsync(TimeSpan.FromSeconds(2)); - nyxHandler.Requests.Should().HaveCount(3); + nyxHandler.Requests.Should().HaveCount(2); // 1. List the Typing reactions on the inbound message id. nyxHandler.Requests[0].Method.Should().Be("GET"); nyxHandler.Requests[0].Path.Should().Be( @@ -1458,18 +1383,13 @@ public async Task RunLlmReplyAsync_ShouldSwapTypingReactionToDone_AfterSuccessfu nyxHandler.Requests[1].Method.Should().Be("DELETE"); nyxHandler.Requests[1].Path.Should().Be( "/api/v1/proxy/s/api-lark-bot/open-apis/im/v1/messages/om_swap_1/reactions/r-bot-1"); - // 3. DONE reaction is added on the same message. - nyxHandler.Requests[2].Method.Should().Be("POST"); - nyxHandler.Requests[2].Path.Should().Be( - "/api/v1/proxy/s/api-lark-bot/open-apis/im/v1/messages/om_swap_1/reactions"); - nyxHandler.Requests[2].Body.Should().Contain("\"emoji_type\":\"DONE\""); } [Fact] - public async Task OnReplyDeliveredAsync_ShouldRunSwap_WhenStreamingPathInvokesIt() + public async Task OnReplyDeliveredAsync_ShouldClearTypingReaction_WhenStreamingPathInvokesIt() { // The streaming completion path in ConversationGAgent finalizes the reply through - // RunStreamChunkAsync edits and never calls RunLlmReplyAsync, so the swap inside + // RunStreamChunkAsync edits and never calls RunLlmReplyAsync, so the clear inside // RunLlmReplyAsync would be skipped on the most common production path. The GAgent // calls OnReplyDeliveredAsync to plug that gap; this test pins the runner end of the // contract so a refactor that drops the implementation in favor of a no-op default @@ -1477,9 +1397,8 @@ public async Task OnReplyDeliveredAsync_ShouldRunSwap_WhenStreamingPathInvokesIt var registrationQueryPort = BuildRegistrationQueryPort(); var adapter = new RecordingPlatformAdapter(); var nyxHandler = new SequencedJsonHandler( - expectedCallCount: 3, + expectedCallCount: 2, """{"code":0,"data":{"items":[{"reaction_id":"r-bot-stream","operator":{"operator_type":"app","operator_id":"bot-1"},"reaction_type":{"emoji_type":"Typing"}}],"has_more":false}}""", - """{"code":0,"data":{}}""", """{"code":0,"data":{}}"""); var runner = CreateRunner(registrationQueryPort, adapter, nyxHandler: nyxHandler); var activity = BuildInboundActivity( @@ -1495,30 +1414,28 @@ public async Task OnReplyDeliveredAsync_ShouldRunSwap_WhenStreamingPathInvokesIt await ((IConversationTurnRunner)runner).OnReplyDeliveredAsync(activity, CancellationToken.None); await nyxHandler.Completed.Task.WaitAsync(TimeSpan.FromSeconds(2)); - nyxHandler.Requests.Should().HaveCount(3); + nyxHandler.Requests.Should().HaveCount(2); nyxHandler.Requests[0].Method.Should().Be("GET"); nyxHandler.Requests[0].Path.Should().Be( "/api/v1/proxy/s/api-lark-bot/open-apis/im/v1/messages/om_stream_swap_1/reactions?reaction_type=Typing&page_size=50"); nyxHandler.Requests[1].Method.Should().Be("DELETE"); nyxHandler.Requests[1].Path.Should().Be( "/api/v1/proxy/s/api-lark-bot/open-apis/im/v1/messages/om_stream_swap_1/reactions/r-bot-stream"); - nyxHandler.Requests[2].Method.Should().Be("POST"); - nyxHandler.Requests[2].Body.Should().Contain("\"emoji_type\":\"DONE\""); } [Fact] - public async Task RunLlmReplyAsync_RelayPath_ShouldStillReplyAndSkipSwap_WhenRegistrationLookupThrows() + public async Task RunLlmReplyAsync_RelayPath_ShouldStillReplyAndSkipReactionClear_WhenRegistrationLookupThrows() { - // Reviewer guard: the post-reply swap needs registration for NyxProviderSlug, but the + // Reviewer guard: the post-reply clear needs registration for NyxProviderSlug, but the // relay reply itself uses the reply token and never touches the registration store. A // transient registration-store exception must NOT abort the relay reply — it should - // degrade the swap to a no-op for that turn while the user-visible reply still lands. + // degrade the clear to a no-op for that turn while the user-visible reply still lands. var registrationQueryPort = Substitute.For(); registrationQueryPort.GetAsync(Arg.Any(), Arg.Any()) .Returns>(_ => throw new InvalidOperationException("registration store unavailable")); var adapter = new RecordingPlatformAdapter(); var relayHandler = new RecordingJsonHandler("""{"message_id":"reply-relay-no-reg"}"""); - // If the swap were to fire, it'd hit nyxHandler. The assertion below confirms it does NOT. + // If the clear were to fire, it'd hit nyxHandler. The assertion below confirms it does NOT. var nyxHandler = new RecordingJsonHandler("""{"code":0,"data":{}}"""); var runner = CreateRunner( registrationQueryPort, @@ -1561,8 +1478,8 @@ public async Task RunLlmReplyAsync_RelayPath_ShouldStillReplyAndSkipSwap_WhenReg relayHandler.Requests.Should().ContainSingle(); relayHandler.Requests[0].Path.Should().Be("/api/v1/channel-relay/reply"); relayHandler.Requests[0].Body.Should().Contain("\"text\":\"relay reply still lands\""); - // Registration is required for the swap, so when lookup throws on the relay path the swap - // is degraded to a no-op for that turn (no list / delete / DONE calls). + // Registration is required for the clear, so when lookup throws on the relay path the clear + // is degraded to a no-op for that turn (no list / delete calls). nyxHandler.Requests.Should().BeEmpty(); } @@ -1570,19 +1487,18 @@ public async Task RunLlmReplyAsync_RelayPath_ShouldStillReplyAndSkipSwap_WhenReg public async Task RunLlmReplyAsync_ShouldPaginate_WhenTypingReactionListSpansMultiplePages() { // Lark's `list message reactions` is paginated. If the bot's own Typing reaction lands on - // a later page (chat with many users reacting Typing), the original single-page swap would - // miss it and leave Typing alongside DONE. The swap must walk pages until has_more=false. + // a later page (chat with many users reacting Typing), the original single-page clear would + // miss it and leave Typing on the message. The clear must walk pages until has_more=false. var registrationQueryPort = BuildRegistrationQueryPort(); var adapter = new RecordingPlatformAdapter(); var relayHandler = new RecordingJsonHandler("""{"message_id":"reply-paginated"}"""); - // 5 nyx calls expected: list page 1 (user only, has_more=true) → list page 2 (bot, - // has_more=false) → DELETE bot reaction → POST DONE. (No call between pages — the loop + // 3 nyx calls expected: list page 1 (user only, has_more=true) → list page 2 (bot, + // has_more=false) → DELETE bot reaction. (No call between pages — the loop // re-issues GET with page_token.) var nyxHandler = new SequencedJsonHandler( - expectedCallCount: 4, + expectedCallCount: 3, """{"code":0,"data":{"items":[{"reaction_id":"r-user-1","operator":{"operator_type":"user","operator_id":"u-1"},"reaction_type":{"emoji_type":"Typing"}}],"has_more":true,"page_token":"page-2-token"}}""", """{"code":0,"data":{"items":[{"reaction_id":"r-bot-late","operator":{"operator_type":"app","operator_id":"bot-1"},"reaction_type":{"emoji_type":"Typing"}}],"has_more":false}}""", - """{"code":0,"data":{}}""", """{"code":0,"data":{}}"""); var runner = CreateRunner( registrationQueryPort, @@ -1623,7 +1539,7 @@ public async Task RunLlmReplyAsync_ShouldPaginate_WhenTypingReactionListSpansMul result.Success.Should().BeTrue(); await nyxHandler.Completed.Task.WaitAsync(TimeSpan.FromSeconds(2)); - nyxHandler.Requests.Should().HaveCount(4); + nyxHandler.Requests.Should().HaveCount(3); // 1. List page 1 — no page_token query param. nyxHandler.Requests[0].Method.Should().Be("GET"); nyxHandler.Requests[0].Path.Should().Be( @@ -1636,9 +1552,6 @@ public async Task RunLlmReplyAsync_ShouldPaginate_WhenTypingReactionListSpansMul nyxHandler.Requests[2].Method.Should().Be("DELETE"); nyxHandler.Requests[2].Path.Should().Be( "/api/v1/proxy/s/api-lark-bot/open-apis/im/v1/messages/om_paginated_1/reactions/r-bot-late"); - // 4. POST DONE. - nyxHandler.Requests[3].Method.Should().Be("POST"); - nyxHandler.Requests[3].Body.Should().Contain("\"emoji_type\":\"DONE\""); } [Fact] @@ -1649,7 +1562,7 @@ public async Task OnReplyDeliveredAsync_ShouldNoOp_WhenActivityIsNotLark() var nyxHandler = new RecordingJsonHandler("""{"code":0,"data":{}}"""); var runner = CreateRunner(registrationQueryPort, adapter, nyxHandler: nyxHandler); - // Missing NyxPlatformMessageId — the swap helper should short-circuit and never call nyx. + // Missing NyxPlatformMessageId — the clear helper should short-circuit and never call nyx. var activity = BuildInboundActivity("hello", "msg-no-platform-id"); await ((IConversationTurnRunner)runner).OnReplyDeliveredAsync(activity, CancellationToken.None); @@ -1658,28 +1571,27 @@ public async Task OnReplyDeliveredAsync_ShouldNoOp_WhenActivityIsNotLark() } [Fact] - public async Task RunInboundAsync_ShouldAwaitTypingReactionBeforeSwap_ForDirectAgentBuilderReply() + public async Task RunInboundAsync_ShouldAwaitTypingReactionBeforeClear_ForDirectAgentBuilderReply() { // Direct-reply paths (e.g. /daily) can return faster than the typing POST takes to land - // in Lark. Without this guard the GET-list step of the swap would fire before the typing - // reaction is persisted, find nothing to delete, add DONE, and then the typing reaction - // would land orphaned alongside DONE. This test pins the ordering by blocking the typing - // POST until after the swap would have run; assertion is that the swap waited (issued no - // GET) until typing was released, then issued GET → DELETE → POST DONE. + // in Lark. Without this guard the GET-list step of the clear would fire before the typing + // reaction is persisted, find nothing to delete, and then the typing reaction would land + // orphaned. This test pins the ordering by blocking the typing POST until after the clear + // would have run; assertion is that the clear waited (issued no GET) until typing was + // released, then issued GET → DELETE. var registrationQueryPort = BuildRegistrationQueryPort(); var adapter = new RecordingPlatformAdapter(); - // First nyx call is the typing POST (blocked); next 3 are the swap (list / delete / DONE). + // First nyx call is the typing POST (blocked); next 2 are the clear (list / delete). var nyxHandler = new TypingReactionGateHandler( - expectedTotalCallCount: 4, + expectedTotalCallCount: 3, """{"code":0,"data":{"reaction_id":"r-bot-direct"}}""", """{"code":0,"data":{"items":[{"reaction_id":"r-bot-direct","operator":{"operator_type":"app","operator_id":"bot-1"},"reaction_type":{"emoji_type":"Typing"}}],"has_more":false}}""", - """{"code":0,"data":{}}""", """{"code":0,"data":{}}"""); var runner = CreateRunner(registrationQueryPort, adapter, nyxHandler: nyxHandler); // /foobar is an unknown slash command — NyxRelayAgentBuilderFlow returns a DirectReply // decision (no tool execution, no external NyxID calls), so the only nyx traffic on this - // turn is the typing POST + the three swap calls. That keeps the SequencedJsonHandler + // turn is the typing POST + the two clear calls. That keeps the SequencedJsonHandler // bodies aligned with the actual call order. var activity = BuildInboundActivity( "/foobar", @@ -1695,14 +1607,14 @@ public async Task RunInboundAsync_ShouldAwaitTypingReactionBeforeSwap_ForDirectA var inboundTask = runner.RunInboundAsync(activity, CancellationToken.None); - // Wait for the runner to fire the typing POST and reach the swap's await — at that point - // the swap is parked on the typing TaskCompletionSource and has not yet issued the GET. + // Wait for the runner to fire the typing POST and reach the clear's await — at that point + // the clear is parked on the typing TaskCompletionSource and has not yet issued the GET. await nyxHandler.TypingPostStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); var result = await inboundTask; result.Success.Should().BeTrue(); // The handler records each request only AFTER its SendAsync returns — typing is parked - // before recording, so an empty Requests list here means the swap has not raced ahead + // before recording, so an empty Requests list here means the clear has not raced ahead // with the GET while typing was still in-flight. If the guard regressed, a GET would // already be recorded as Request[0] at this point. nyxHandler.Requests.Should().BeEmpty(); @@ -1710,20 +1622,18 @@ public async Task RunInboundAsync_ShouldAwaitTypingReactionBeforeSwap_ForDirectA nyxHandler.ReleaseTypingPost.TrySetResult(); await nyxHandler.Completed.Task.WaitAsync(TimeSpan.FromSeconds(2)); - // After release: POST Typing landed first, then GET → DELETE → POST DONE in order. - nyxHandler.Requests.Should().HaveCount(4); + // After release: POST Typing landed first, then GET → DELETE in order. + nyxHandler.Requests.Should().HaveCount(3); nyxHandler.Requests[0].Method.Should().Be("POST"); nyxHandler.Requests[0].Body.Should().Contain("\"emoji_type\":\"Typing\""); nyxHandler.Requests[1].Method.Should().Be("GET"); nyxHandler.Requests[1].Path.Should().Contain("reaction_type=Typing"); nyxHandler.Requests[2].Method.Should().Be("DELETE"); nyxHandler.Requests[2].Path.Should().Contain("/reactions/r-bot-direct"); - nyxHandler.Requests[3].Method.Should().Be("POST"); - nyxHandler.Requests[3].Body.Should().Contain("\"emoji_type\":\"DONE\""); } [Fact] - public async Task RunLlmReplyAsync_ShouldNotSwapReaction_WhenReplyFails() + public async Task RunLlmReplyAsync_ShouldNotClearReaction_WhenReplyFails() { var registrationQueryPort = BuildRegistrationQueryPort(); var adapter = new RecordingPlatformAdapter @@ -1733,8 +1643,8 @@ public async Task RunLlmReplyAsync_ShouldNotSwapReaction_WhenReplyFails() "recipient blocked bot", PlatformReplyFailureKind.Permanent), }; - // Any nyx call here would be the post-reply swap firing. Fail early on it so - // the test still proves the swap was skipped — Requests.Should().BeEmpty() below + // Any nyx call here would be the post-reply clear firing. Fail early on it so + // the test still proves the clear was skipped — Requests.Should().BeEmpty() below // makes the assertion explicit. var nyxHandler = new RecordingJsonHandler("""{"code":0,"data":{}}"""); var runner = CreateRunner(registrationQueryPort, adapter, nyxHandler: nyxHandler); @@ -2774,8 +2684,8 @@ protected override async Task SendAsync(HttpRequestMessage // Parks the FIRST request (the typing POST that fires from RunInboundAsync) on a // TaskCompletionSource until the test releases it. Used by the race test to confirm that - // the post-reply swap awaits the typing POST before issuing the GET-list — without the - // guard, the swap GET would run while typing is still parked here. + // the post-reply clear awaits the typing POST before issuing the GET-list — without the + // guard, the clear GET would run while typing is still parked here. private sealed class TypingReactionGateHandler : RecordingJsonHandler { private readonly Queue _bodies; @@ -2813,7 +2723,7 @@ protected override async Task SendAsync(HttpRequestMessage // Returns a different body for each successive call; signals Completed once expectedCallCount // requests have been served. Extends RecordingJsonHandler which captures Path, Method, - // Authorization, and Body — the Method field lets swap tests assert GET/DELETE/POST ordering. + // Authorization, and Body — the Method field lets reaction tests assert GET/DELETE ordering. private sealed class SequencedJsonHandler : RecordingJsonHandler { private readonly Queue _bodies; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelLlmReplyInboxRuntimeTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelLlmReplyInboxRuntimeTests.cs deleted file mode 100644 index a4b3cc2d1..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelLlmReplyInboxRuntimeTests.cs +++ /dev/null @@ -1,683 +0,0 @@ -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.GAgents.Channel.Abstractions; -using Aevatar.GAgents.Channel.NyxIdRelay; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.NyxidChat; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Aevatar.Studio.Application.Studio.Abstractions; -using FluentAssertions; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; -using Xunit; - -namespace Aevatar.GAgents.ChannelRuntime.Tests; - -public sealed class ChannelLlmReplyInboxRuntimeTests -{ - [Fact] - public async Task ProcessAsync_RelayTurnCapturesInteractiveIntentIntoReadyEvent() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => - { - var intent = new MessageContent - { - Text = "Choose one", - }; - intent.Actions.Add(new ActionElement - { - Kind = ActionElementKind.Button, - ActionId = "confirm", - Label = "Confirm", - IsPrimary = true, - }); - return collector.Capture(intent); - }); - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-1", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-1", - }); - - replyGenerator.CaptureSucceeded.Should().BeTrue(); - handled.Should().NotBeNull(); - var ready = handled!.Payload.Unpack(); - ready.Outbound.Text.Should().Be("Choose one"); - ready.Outbound.Actions.Should().ContainSingle(); - ready.Outbound.Actions[0].ActionId.Should().Be("confirm"); - } - - [Fact] - public async Task ProcessAsync_NonRelayTurnDoesNotEnableInteractiveScope() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => collector.Capture(new MessageContent { Text = "ignored" })) - { - ReplyText = "plain reply", - }; - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-2", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = new ChatActivity - { - Id = "msg-2", - Content = new MessageContent { Text = "hello" }, - }, - }); - - replyGenerator.CaptureSucceeded.Should().BeFalse(); - handled.Should().NotBeNull(); - var ready = handled!.Payload.Unpack(); - ready.Outbound.Text.Should().Be("plain reply"); - ready.Outbound.Actions.Should().BeEmpty(); - } - - [Fact] - public async Task ProcessAsync_ShouldEmitFailedReply_WhenGeneratorThrows() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new ThrowingReplyGenerator(new InvalidOperationException("boom")); - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-throw", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-throw", - }); - - handled.Should().NotBeNull(); - var ready = handled!.Payload.Unpack(); - ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); - ready.ErrorCode.Should().Be("llm_reply_failed"); - ready.ErrorSummary.Should().Be("boom"); - ready.Outbound.Text.Should().NotBeNullOrWhiteSpace(); - } - - [Fact] - public async Task ProcessAsync_ShouldEmitFailedReply_WhenGeneratorReturnsEmpty() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => false) - { - ReplyText = " ", - }; - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-empty", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-empty", - }); - - handled.Should().NotBeNull(); - var ready = handled!.Payload.Unpack(); - ready.TerminalState.Should().Be(LlmReplyTerminalState.Failed); - ready.ErrorCode.Should().Be("empty_reply"); - ready.Outbound.Text.Should().NotBeNullOrWhiteSpace(); - } - - [Fact] - public async Task ProcessAsync_ShouldEchoReplyTokenIntoLlmReplyReadyEvent() - { - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - new RecordingReplyGenerator(() => false) { ReplyText = "ok" }, - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - var expiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(20).ToUnixTimeMilliseconds(); - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-echo", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-echo", - ReplyTokenExpiresAtUnixMs = expiresAtUnixMs, - }); - - handled.Should().NotBeNull(); - var ready = handled!.Payload.Unpack(); - ready.ReplyToken.Should().Be("relay-token-echo"); - ready.ReplyTokenExpiresAtUnixMs.Should().Be(expiresAtUnixMs); - } - - [Fact] - public async Task ProcessAsync_ShouldDropRelayRequest_WhenInboxCarriesNoReplyToken() - { - var actor = Substitute.For(); - actor.Id.Returns("actor-1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - // Relay activity but no inbox-carried ReplyToken — simulates a request rehydrated - // from persisted state after a pod restart, where the original token capture is gone. - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-no-token", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - }); - - replyGenerator.CaptureSucceeded.Should().BeFalse(); - handled.Should().NotBeNull(); - var dropped = handled!.Payload.Unpack(); - dropped.CorrelationId.Should().Be("corr-no-token"); - dropped.Reason.Should().Be("missing_relay_reply_token"); - } - - [Fact] - public async Task ProcessAsync_ShouldDropRequest_WhenOlderThanMaxAge() - { - var actor = Substitute.For(); - actor.Id.Returns("actor-1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "should not run" }; - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - var requestedAtUnixMs = DateTimeOffset.UtcNow - .AddMilliseconds(-(ChannelLlmReplyInboxRuntime.MaxInboxRequestAgeMs + 60_000)) - .ToUnixTimeMilliseconds(); - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-stale", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-stale", - RequestedAtUnixMs = requestedAtUnixMs, - }); - - replyGenerator.CaptureSucceeded.Should().BeFalse(); - handled.Should().NotBeNull(); - var dropped = handled!.Payload.Unpack(); - dropped.CorrelationId.Should().Be("corr-stale"); - dropped.Reason.Should().Be("stale_inbox_request_dropped"); - } - - [Fact] - public async Task ProcessAsync_ShouldDropSilently_WhenTargetActorIdMissing() - { - var actorRuntime = Substitute.For(); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - new RecordingReplyGenerator(() => false), - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-missing", - TargetActorId = string.Empty, - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - }); - - await actorRuntime.DidNotReceiveWithAnyArgs().GetAsync(Arg.Any()); - } - - [Fact] - public async Task ProcessAsync_ShouldNotifyActor_WhenActivityMissing() - { - // Malformed payload (no Activity) should still tell the actor to retire its - // pending entry — the actor decides whether to clean up. Otherwise the entry - // accumulates silently in State.PendingLlmReplyRequests until rehydration. - var actor = Substitute.For(); - actor.Id.Returns("actor-1"); - EventEnvelope? handled = null; - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled = call.Arg()); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - new RecordingReplyGenerator(() => false), - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-no-activity", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - }); - - handled.Should().NotBeNull(); - var dropped = handled!.Payload.Unpack(); - dropped.CorrelationId.Should().Be("corr-no-activity"); - dropped.Reason.Should().Be("malformed_deferred_llm_reply_request"); - } - - [Fact] - public async Task ProcessAsync_StreamingEnabled_DispatchesChunkEventAndReadyEvent() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "streamed reply" }; - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - var handled = new List(); - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled.Add(call.Arg())); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = false, StreamingRepliesEnabled = true, StreamingFlushIntervalMs = 0 }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-stream", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-stream", - ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), - }); - - handled.Any(e => e.Payload.Is(LlmReplyStreamChunkEvent.Descriptor)).Should().BeTrue(); - handled.Any(e => e.Payload.Is(LlmReplyReadyEvent.Descriptor)).Should().BeTrue(); - var chunk = handled.First(e => e.Payload.Is(LlmReplyStreamChunkEvent.Descriptor)) - .Payload.Unpack(); - chunk.AccumulatedText.Should().Be("streamed reply"); - chunk.CorrelationId.Should().Be("corr-stream"); - } - - [Fact] - public async Task ProcessAsync_StreamingDisabledFlag_DispatchesOnlyReadyEvent() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "plain reply" }; - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:group:oc_group_chat_1"); - var handled = new List(); - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled.Add(call.Arg())); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = false, StreamingRepliesEnabled = false }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-legacy", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = BuildRelayActivity(), - ReplyToken = "relay-token-legacy", - ReplyTokenExpiresAtUnixMs = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds(), - }); - - handled.Should().ContainSingle(); - handled[0].Payload.Is(LlmReplyReadyEvent.Descriptor).Should().BeTrue(); - } - - [Fact] - public async Task ProcessAsync_StreamingEnabledButNonRelay_DispatchesOnlyReadyEvent() - { - var collector = new AsyncLocalInteractiveReplyCollector(); - var replyGenerator = new RecordingReplyGenerator(() => false) { ReplyText = "plain reply" }; - var actor = Substitute.For(); - actor.Id.Returns("channel-conversation:lark:dm:user"); - var handled = new List(); - actor.When(x => x.HandleEventAsync(Arg.Any(), Arg.Any())) - .Do(call => handled.Add(call.Arg())); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - collector, - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = false, StreamingRepliesEnabled = true }, - NullLogger.Instance); - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-nonrelay", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = new ChatActivity - { - Id = "msg-nonrelay", - Content = new MessageContent { Text = "hello" }, - // No OutboundDelivery → not a relay turn - }, - }); - - handled.Should().ContainSingle(); - handled[0].Payload.Is(LlmReplyReadyEvent.Descriptor).Should().BeTrue(); - } - - [Fact] - public async Task ProcessAsync_ShouldApplyBotOwnerLlmConfig_FromUserConfigQueryPort() - { - // Bot owner's LLM model + route comes from UserConfig (the same store that backs - // their nyxid-chat preferences), looked up by the scope id resolved from the - // bot registration. The relay turn uses the inbound user-token as the bearer - // (it is the bot owner's own NyxID session, freshly issued per callback) while - // taking model / route / max-tool-rounds from the owner's pre-configured - // UserConfig. - var capturedMetadata = new Dictionary(StringComparer.Ordinal); - var replyGenerator = new RecordingReplyGenerator(() => false) - { - ReplyText = "ack", - MetadataObserver = m => - { - foreach (var pair in m) - capturedMetadata[pair.Key] = pair.Value; - }, - }; - - var actor = Substitute.For(); - actor.Id.Returns("actor-1"); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - - var scopeResolver = Substitute.For(); - scopeResolver.ResolveScopeIdByApiKeyAsync("api-key-bot", Arg.Any()) - .Returns(Task.FromResult("scope-bot-owner")); - - var userConfigQueryPort = Substitute.For(); - userConfigQueryPort.GetAsync("scope-bot-owner", Arg.Any()) - .Returns(Task.FromResult(new Aevatar.Studio.Application.Studio.Abstractions.UserConfig( - DefaultModel: "gpt-4o-bot-owner", - PreferredLlmRoute: "/api/v1/proxy/s/anthropic-via-bot-owner", - RuntimeMode: "local", - LocalRuntimeBaseUrl: "http://localhost", - RemoteRuntimeBaseUrl: "https://example.com", - GithubUsername: null, - MaxToolRounds: 11))); - - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance, - scopeResolver, - userConfigQueryPort); - - var activity = BuildRelayActivity(); - activity.Bot = BotInstanceId.From("api-key-bot"); - activity.TransportExtras = new TransportExtras - { - NyxUserAccessToken = "bot-owner-session-jwt", - }; - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-bot-owner", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = activity, - ReplyToken = "relay-token-bot-owner", - }); - - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.ModelOverride) - .WhoseValue.Should().Be("gpt-4o-bot-owner"); - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdRoutePreference) - .WhoseValue.Should().Be("/api/v1/proxy/s/anthropic-via-bot-owner"); - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.MaxToolRoundsOverride) - .WhoseValue.Should().Be("11"); - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdAccessToken) - .WhoseValue.Should().Be("bot-owner-session-jwt"); - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdOrgToken) - .WhoseValue.Should().Be("bot-owner-session-jwt"); - } - - [Fact] - public async Task ProcessAsync_ShouldThreadBotOwnerSessionTokenAsLlmBearer() - { - // The inbound X-NyxID-User-Token is the bot owner's own NyxID session JWT. - // It is the credential that would authorize the owner's LLM calls in - // nyxid-chat, so it is also the correct credential for the bot's relay - // LLM call. The stale-pending GC plus the direct-enqueue + inbox-echoed - // token flow keeps it fresh through the window where the LLM call actually - // fires. - var capturedMetadata = new Dictionary(StringComparer.Ordinal); - var replyGenerator = new RecordingReplyGenerator(() => false) - { - ReplyText = "ack", - MetadataObserver = m => - { - foreach (var pair in m) - capturedMetadata[pair.Key] = pair.Value; - }, - }; - - var actor = Substitute.For(); - actor.Id.Returns("actor-1"); - var actorRuntime = new DispatchingActorRuntime(("actor-1", actor)); - - var runtime = new ChannelLlmReplyInboxRuntime( - Substitute.For(), - actorRuntime, - replyGenerator, - new AsyncLocalInteractiveReplyCollector(), - new Aevatar.GAgents.Channel.NyxIdRelay.NyxIdRelayOptions { InteractiveRepliesEnabled = true }, - NullLogger.Instance); - - var activity = BuildRelayActivity(); - activity.TransportExtras = new TransportExtras - { - NyxUserAccessToken = "bot-owner-session-jwt", - }; - - await runtime.ProcessAsync(new NeedsLlmReplyEvent - { - CorrelationId = "corr-bearer", - TargetActorId = "actor-1", - RegistrationId = "reg-1", - Activity = activity, - ReplyToken = "relay-token-1", - }); - - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdAccessToken) - .WhoseValue.Should().Be("bot-owner-session-jwt"); - capturedMetadata.Should().ContainKey(LLMRequestMetadataKeys.NyxIdOrgToken) - .WhoseValue.Should().Be("bot-owner-session-jwt"); - } - - private static ChatActivity BuildRelayActivity() => - new() - { - Id = "msg-1", - ChannelId = ChannelId.From("lark"), - Conversation = ConversationReference.Create( - ChannelId.From("lark"), - BotInstanceId.From("reg-1"), - ConversationScope.Group, - "oc_group_chat_1", - "group", - "oc_group_chat_1"), - Content = new MessageContent { Text = "hello" }, - OutboundDelivery = new OutboundDeliveryContext - { - ReplyMessageId = "relay-msg-1", - CorrelationId = "corr-1", - }, - }; - - private sealed class DispatchingActorRuntime(params (string Id, IActor Actor)[] actors) : - IActorRuntime, - IActorDispatchPort - { - private readonly Dictionary _actors = actors.ToDictionary( - static pair => pair.Id, - static pair => pair.Actor, - StringComparer.Ordinal); - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent - { - var actorId = id ?? Guid.NewGuid().ToString("N"); - if (_actors.TryGetValue(actorId, out var existing)) - return Task.FromResult(existing); - - var actor = Substitute.For(); - actor.Id.Returns(actorId); - _actors[actorId] = actor; - return Task.FromResult(actor); - } - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) => - CreateAsync(id, ct); - - public Task DestroyAsync(string id, CancellationToken ct = default) - { - _actors.Remove(id); - return Task.CompletedTask; - } - - public Task GetAsync(string id) => - Task.FromResult(_actors.TryGetValue(id, out var actor) ? actor : null); - - public Task ExistsAsync(string id) => Task.FromResult(_actors.ContainsKey(id)); - - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => - Task.CompletedTask; - - public Task UnlinkAsync(string childId, CancellationToken ct = default) => - Task.CompletedTask; - - public async Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) - { - if (!_actors.TryGetValue(actorId, out var actor)) - throw new InvalidOperationException($"Actor {actorId} not found."); - await actor.HandleEventAsync(envelope, ct); - } - } - - private sealed class RecordingReplyGenerator(Func captureAction) : IConversationReplyGenerator - { - public string ReplyText { get; init; } = string.Empty; - - public bool CaptureSucceeded { get; private set; } - - public Action>? MetadataObserver { get; init; } - - public async Task GenerateReplyAsync( - ChatActivity activity, - IReadOnlyDictionary metadata, - IStreamingReplySink? streamingSink, - CancellationToken ct) - { - CaptureSucceeded = captureAction(); - MetadataObserver?.Invoke(metadata); - if (streamingSink is not null && !string.IsNullOrEmpty(ReplyText)) - await streamingSink.OnDeltaAsync(ReplyText, ct); - return ReplyText; - } - } - - private sealed class ThrowingReplyGenerator(Exception exception) : IConversationReplyGenerator - { - public Task GenerateReplyAsync( - ChatActivity activity, - IReadOnlyDictionary metadata, - IStreamingReplySink? streamingSink, - CancellationToken ct) => Task.FromException(exception); - } -} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs index 8c96c79ce..1baaf90a9 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/ConversationReplyGeneratorTests.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.ToolProviders.Skills; using Aevatar.GAgents.Channel.Abstractions; using FluentAssertions; using Xunit; @@ -251,6 +252,213 @@ await generator.GenerateReplyAsync( metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("5"); } + [Fact] + public async Task GenerateReplyAsync_RetriesWithOwnerPrefsWhenSenderRouteFails() + { + var providerFactory = new RecordingProviderFactory + { + FailuresBeforeSuccess = 1, + }; + var prefsStore = new ScopedStubPreferencesStore + { + ByBinding = + { + ["bnd_sender"] = new NyxIdUserLlmPreferences( + "sender-model", + "/api/v1/proxy/s/sender", + MaxToolRounds: 7), + }, + }; + var generator = new NyxIdConversationReplyGenerator(providerFactory, preferencesStore: prefsStore); + + var reply = await generator.GenerateReplyAsync( + new ChatActivity + { + Id = "msg-sender-route-failure", + Conversation = new ConversationReference { CanonicalKey = "lark:dm:user-1" }, + Content = new MessageContent { Text = "hello" }, + }, + new Dictionary + { + [LLMRequestMetadataKeys.ModelOverride] = "owner-model", + [LLMRequestMetadataKeys.NyxIdRoutePreference] = "/api/v1/proxy/s/owner", + [LLMRequestMetadataKeys.MaxToolRoundsOverride] = "5", + [LLMRequestMetadataKeys.NyxIdAccessToken] = "owner-token", + [LLMRequestMetadataKeys.NyxIdOrgToken] = "owner-token", + [LLMRequestMetadataKeys.SenderBindingId] = "bnd_sender", + [LLMRequestMetadataKeys.SenderNyxIdAccessToken] = "sender-token", + }, + streamingSink: null, + CancellationToken.None); + + reply.Should().Be("ok"); + providerFactory.Requests.Should().HaveCount(2); + var senderMetadata = providerFactory.Requests[0].Metadata!; + senderMetadata[LLMRequestMetadataKeys.ModelOverride].Should().Be("sender-model"); + senderMetadata[LLMRequestMetadataKeys.NyxIdRoutePreference].Should().Be("/api/v1/proxy/s/sender"); + senderMetadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("7"); + senderMetadata[LLMRequestMetadataKeys.NyxIdAccessToken].Should().Be("sender-token"); + senderMetadata[LLMRequestMetadataKeys.NyxIdOrgToken].Should().Be("sender-token"); + senderMetadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + + var ownerMetadata = providerFactory.Requests[1].Metadata!; + ownerMetadata[LLMRequestMetadataKeys.ModelOverride].Should().Be("owner-model"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdRoutePreference].Should().Be("/api/v1/proxy/s/owner"); + ownerMetadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("5"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdAccessToken].Should().Be("owner-token"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdOrgToken].Should().Be("owner-token"); + ownerMetadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderBindingId); + ownerMetadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + } + + [Fact] + public async Task GenerateReplyAsync_UsesOwnerPrefsImmediatelyWhenSenderRouteHasNoToken() + { + var providerFactory = new RecordingProviderFactory(); + var prefsStore = new ScopedStubPreferencesStore + { + ByBinding = + { + ["bnd_sender"] = new NyxIdUserLlmPreferences( + "sender-model", + "/api/v1/proxy/s/sender", + MaxToolRounds: 7), + }, + }; + var generator = new NyxIdConversationReplyGenerator(providerFactory, preferencesStore: prefsStore); + + await generator.GenerateReplyAsync( + new ChatActivity + { + Id = "msg-no-sender-token", + Conversation = new ConversationReference { CanonicalKey = "lark:dm:user-1" }, + Content = new MessageContent { Text = "hello" }, + }, + new Dictionary + { + [LLMRequestMetadataKeys.ModelOverride] = "owner-model", + [LLMRequestMetadataKeys.NyxIdRoutePreference] = "/api/v1/proxy/s/owner", + [LLMRequestMetadataKeys.MaxToolRoundsOverride] = "5", + [LLMRequestMetadataKeys.NyxIdAccessToken] = "owner-token", + [LLMRequestMetadataKeys.NyxIdOrgToken] = "owner-token", + [LLMRequestMetadataKeys.SenderBindingId] = "bnd_sender", + }, + streamingSink: null, + CancellationToken.None); + + var ownerMetadata = providerFactory.Requests.Should().ContainSingle().Subject.Metadata!; + ownerMetadata[LLMRequestMetadataKeys.ModelOverride].Should().Be("owner-model"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdRoutePreference].Should().Be("/api/v1/proxy/s/owner"); + ownerMetadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("5"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdAccessToken].Should().Be("owner-token"); + ownerMetadata[LLMRequestMetadataKeys.NyxIdOrgToken].Should().Be("owner-token"); + ownerMetadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderBindingId); + ownerMetadata.Should().NotContainKey(LLMRequestMetadataKeys.SenderNyxIdAccessToken); + } + + // ─── Issue #513 phase 3 — explicit 3 binding × 3 owner-prefs override matrix ─── + // + // The four [Fact] tests above pin specific scenarios (owner-only, + // sender-overrides-model, sender-store-throws, route-failure-retry). This + // [Theory] adds the explicit 3×3 matrix the issue calls out: the binding + // axis (unbound / bound-with-empty-prefs / bound-with-model-only) is + // crossed with the owner-prefs axis (none / partial=model-only / full). + // Sender prefs in the bound-set row deliberately set ONLY DefaultModel so + // we exercise the "sender supplies a subset, owner fills the rest" path + // without crossing the route-applied + no-sender-token branch (which + // silently swaps in the owner snapshot — orthogonal to the matrix and + // already covered by UsesOwnerPrefsImmediatelyWhenSenderRouteHasNoToken). + public const string MatrixUnbound = "unbound"; + public const string MatrixBoundEmpty = "bound_empty_prefs"; + public const string MatrixBoundModelOnly = "bound_model_only"; + public const string MatrixOwnerNone = "owner_none"; + public const string MatrixOwnerPartial = "owner_partial_model_only"; + public const string MatrixOwnerFull = "owner_full"; + + [Theory] + [InlineData(MatrixUnbound, MatrixOwnerNone, null, null, null)] + [InlineData(MatrixUnbound, MatrixOwnerPartial, "owner-model", null, null)] + [InlineData(MatrixUnbound, MatrixOwnerFull, "owner-model", "/api/v1/proxy/s/owner", "9")] + [InlineData(MatrixBoundEmpty, MatrixOwnerNone, null, null, null)] + [InlineData(MatrixBoundEmpty, MatrixOwnerPartial, "owner-model", null, null)] + [InlineData(MatrixBoundEmpty, MatrixOwnerFull, "owner-model", "/api/v1/proxy/s/owner", "9")] + [InlineData(MatrixBoundModelOnly, MatrixOwnerNone, "sender-model", null, null)] + [InlineData(MatrixBoundModelOnly, MatrixOwnerPartial, "sender-model", null, null)] + [InlineData(MatrixBoundModelOnly, MatrixOwnerFull, "sender-model", "/api/v1/proxy/s/owner", "9")] + public async Task GenerateReplyAsync_OverrideMatrix_BindingTimesOwnerPrefs( + string bindingState, + string ownerState, + string? expectedModel, + string? expectedRoute, + string? expectedRounds) + { + var providerFactory = new RecordingProviderFactory(); + var prefsStore = new ScopedStubPreferencesStore(); + + switch (bindingState) + { + case MatrixBoundEmpty: + // Lookup returns the default empty record (no entry in + // ByBinding), so SetIfFilled writes nothing. + break; + case MatrixBoundModelOnly: + prefsStore.ByBinding["bnd_sender"] = new NyxIdUserLlmPreferences( + DefaultModel: "sender-model", + PreferredRoute: string.Empty, + MaxToolRounds: 0); + break; + } + + var metadata = new Dictionary(StringComparer.Ordinal); + if (bindingState != MatrixUnbound) + metadata[LLMRequestMetadataKeys.SenderBindingId] = "bnd_sender"; + + switch (ownerState) + { + case MatrixOwnerPartial: + metadata[LLMRequestMetadataKeys.ModelOverride] = "owner-model"; + break; + case MatrixOwnerFull: + metadata[LLMRequestMetadataKeys.ModelOverride] = "owner-model"; + metadata[LLMRequestMetadataKeys.NyxIdRoutePreference] = "/api/v1/proxy/s/owner"; + metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride] = "9"; + break; + } + + var generator = new NyxIdConversationReplyGenerator(providerFactory, preferencesStore: prefsStore); + await generator.GenerateReplyAsync( + new ChatActivity + { + Id = $"msg-{bindingState}-{ownerState}", + Conversation = new ConversationReference { CanonicalKey = "lark:dm:user-1" }, + Content = new MessageContent { Text = "hello" }, + }, + metadata, + streamingSink: null, + CancellationToken.None); + + var request = providerFactory.Requests.Should().ContainSingle().Subject; + var effective = request.Metadata!; + + AssertKey(effective, LLMRequestMetadataKeys.ModelOverride, expectedModel); + AssertKey(effective, LLMRequestMetadataKeys.NyxIdRoutePreference, expectedRoute); + AssertKey(effective, LLMRequestMetadataKeys.MaxToolRoundsOverride, expectedRounds); + + if (bindingState == MatrixUnbound) + prefsStore.Lookups.Should().BeEmpty( + "no binding-id in metadata → generator must not consult the prefs store"); + else + prefsStore.Lookups.Should().ContainSingle().Which.Should().Be("bnd_sender"); + } + + private static void AssertKey(IReadOnlyDictionary metadata, string key, string? expected) + { + if (expected is null) + metadata.Should().NotContainKey(key); + else + metadata.Should().ContainKey(key).WhoseValue.Should().Be(expected); + } + private sealed class ScopedStubPreferencesStore : INyxIdUserLlmPreferencesStore { public Dictionary ByBinding { get; } = new(StringComparer.Ordinal); @@ -293,6 +501,8 @@ private sealed class RecordingProviderFactory : ILLMProviderFactory, ILLMProvide public List Requests { get; } = []; + public int FailuresBeforeSuccess { get; init; } + public ILLMProvider GetProvider(string name) => this; public ILLMProvider GetDefault() => this; @@ -310,6 +520,9 @@ public async IAsyncEnumerable ChatStreamAsync( [EnumeratorCancellation] CancellationToken ct = default) { Requests.Add(request); + if (Requests.Count <= FailuresBeforeSuccess) + throw new InvalidOperationException("simulated sender route failure"); + yield return new LLMStreamChunk { DeltaContent = "ok", diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs index 440c5ca6e..56c80a1c7 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/FeishuCardHumanInteractionPortTests.cs @@ -172,106 +172,6 @@ await act.Should().ThrowAsync() .WithMessage("*Unsupported human interaction platform*"); } - [Fact] - public async Task DeliverApprovalResolutionAsync_ShouldSendResolutionTextThenApprovedContent() - { - var registry = Substitute.For(); - registry.GetAsync("agent-1", Arg.Any()) - .Returns(Task.FromResult(new UserAgentDeliveryTarget( - AgentId: "agent-1", - Platform: "lark", - ConversationId: "oc_chat_1", - NyxProviderSlug: "api-lark-bot", - NyxApiKey: "nyx-api-key-1", - LarkReceiveId: string.Empty, - LarkReceiveIdType: string.Empty, - LarkReceiveIdFallback: string.Empty, - LarkReceiveIdTypeFallback: string.Empty, - TemplateName: "social_media", - AgentType: string.Empty))); - - var handler = new RecordingHandler("""{"data":{"message_id":"om_2"}}"""); - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler)); - var port = new FeishuCardHumanInteractionPort(registry, nyxClient, new LarkMessageComposer(), NullLogger.Instance); - - await port.DeliverApprovalResolutionAsync( - new HumanApprovalResolution - { - ActorId = "workflow-actor-1", - RunId = "run-2", - StepId = "approval-2", - Approved = true, - Feedback = "Looks good", - ResolvedContent = "Launch day update.", - }, - "agent-1", - CancellationToken.None); - - handler.Bodies.Should().HaveCount(2); - - using var summaryBody = JsonDocument.Parse(handler.Bodies[0]); - summaryBody.RootElement.GetProperty("msg_type").GetString().Should().Be("text"); - using var summaryContent = JsonDocument.Parse(summaryBody.RootElement.GetProperty("content").GetString()!); - var summaryText = summaryContent.RootElement.GetProperty("text").GetString(); - summaryText.Should().Contain("Approval recorded."); - summaryText.Should().Contain("Run ID: run-2"); - summaryText.Should().Contain("Feedback: Looks good"); - - using var textBody = JsonDocument.Parse(handler.Bodies[1]); - textBody.RootElement.GetProperty("msg_type").GetString().Should().Be("text"); - using var textContent = JsonDocument.Parse(textBody.RootElement.GetProperty("content").GetString()!); - textContent.RootElement.GetProperty("text").GetString().Should().Be("Launch day update."); - } - - [Fact] - public async Task DeliverApprovalResolutionAsync_ShouldIncludeTextRerunInstructions_ForRejectedSocialMedia() - { - var registry = Substitute.For(); - registry.GetAsync("agent-1", Arg.Any()) - .Returns(Task.FromResult(new UserAgentDeliveryTarget( - AgentId: "agent-1", - Platform: "lark", - ConversationId: "oc_chat_1", - NyxProviderSlug: "api-lark-bot", - NyxApiKey: "nyx-api-key-1", - LarkReceiveId: string.Empty, - LarkReceiveIdType: string.Empty, - LarkReceiveIdFallback: string.Empty, - LarkReceiveIdTypeFallback: string.Empty, - TemplateName: "social_media", - AgentType: string.Empty))); - - var handler = new RecordingHandler("""{"data":{"message_id":"om_3"}}"""); - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler)); - var port = new FeishuCardHumanInteractionPort(registry, nyxClient, new LarkMessageComposer(), NullLogger.Instance); - - await port.DeliverApprovalResolutionAsync( - new HumanApprovalResolution - { - ActorId = "workflow-actor-1", - RunId = "run-3", - StepId = "approval-3", - Approved = false, - Feedback = "Need stronger hook", - }, - "agent-1", - CancellationToken.None); - - handler.Bodies.Should().HaveCount(1); - using var body = JsonDocument.Parse(handler.Bodies[0]); - body.RootElement.GetProperty("msg_type").GetString().Should().Be("text"); - using var content = JsonDocument.Parse(body.RootElement.GetProperty("content").GetString()!); - var text = content.RootElement.GetProperty("text").GetString(); - text.Should().Contain("Rejection recorded."); - text.Should().Contain("Feedback: Need stronger hook"); - text.Should().Contain("/run-agent agent-1"); - text.Should().Contain("/agents"); - } - [Fact] public void BuildSuspensionText_ShouldRenderApprovalCommands_ForHumanApproval() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/AevatarOAuthClientGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/AevatarOAuthClientGAgentTests.cs index d7a60e181..f1912f90b 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/AevatarOAuthClientGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/AevatarOAuthClientGAgentTests.cs @@ -270,6 +270,112 @@ await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand _registrar.Calls.Should().BeEmpty("manual provision must not call DCR"); } + [Fact] + public async Task HandleProvision_PersistsRedirectUriAndScope_FromCommand() + { + // Pin issue #549 operator-rebuild path: when ops calls + // POST /api/oauth/aevatar-client/rebuild after a wedge, the actor + // must persist redirect_uri and oauth_scope so the next bootstrap + // pass observes no drift and does not re-DCR away the pinned + // client_id. + await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand + { + ClientId = "operator-rebuilt-client", + ClientIdIssuedAtUnix = 1700000000, + NyxidAuthority = "https://nyxid.test", + RedirectUri = "https://aevatar.test/api/oauth/nyxid-callback", + OauthScope = AevatarOAuthClientScopes.AuthorizationScope, + }); + + _agent.State.ClientId.Should().Be("operator-rebuilt-client"); + _agent.State.RedirectUri.Should().Be("https://aevatar.test/api/oauth/nyxid-callback"); + _agent.State.OauthScope.Should().Be(AevatarOAuthClientScopes.AuthorizationScope); + } + + [Fact] + public async Task HandleProvision_IsNoOp_WhenSnapshotMatches() + { + // Same-snapshot idempotency: client_id + authority + redirect_uri + + // oauth_scope all unchanged → no Provisioned event written. Only + // HMAC-seed event on first call (subsequent call is a full no-op). + var cmd = new ProvisionAevatarOAuthClientCommand + { + ClientId = "client-x", + NyxidAuthority = "https://nyxid.test", + RedirectUri = "https://aevatar.test/api/oauth/nyxid-callback", + OauthScope = AevatarOAuthClientScopes.AuthorizationScope, + }; + + await _agent.HandleProvision(cmd); + var versionAfterFirst = _agent.EventSourcing!.CurrentVersion; + + await _agent.HandleProvision(cmd); + + _agent.EventSourcing!.CurrentVersion.Should().Be(versionAfterFirst, + "matching snapshot must not append additional events"); + } + + [Fact] + public async Task HandleProvision_PreservesRedirectUriAndScope_WhenCommandFieldsEmpty() + { + // PR #570 Codex P1: legacy / pre-redirect_uri callers (manual operator + // scripts, fixtures using only ClientId + NyxidAuthority) must NOT + // clobber previously-persisted redirect_uri / oauth_scope with empty + // strings — that would let the next bootstrap pass observe an empty + // redirect_uri, detect drift, and re-DCR the freshly-pinned client. + // Empty cmd field = "field not supplied", not "set to empty". + await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand + { + ClientId = "client-x", + NyxidAuthority = "https://nyxid.test", + RedirectUri = "https://aevatar.test/api/oauth/nyxid-callback", + OauthScope = AevatarOAuthClientScopes.AuthorizationScope, + }); + + await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand + { + ClientId = "client-y", + NyxidAuthority = "https://nyxid.test", + }); + + _agent.State.ClientId.Should().Be("client-y"); + _agent.State.RedirectUri.Should().Be( + "https://aevatar.test/api/oauth/nyxid-callback", + "empty cmd.RedirectUri must preserve existing state, not clear it"); + _agent.State.OauthScope.Should().Be( + AevatarOAuthClientScopes.AuthorizationScope, + "empty cmd.OauthScope must preserve existing state, not clear it"); + } + + [Fact] + public async Task HandleProvision_RewritesEvent_WhenRedirectUriChanges() + { + // The same-snapshot check covers redirect_uri so an operator can + // heal a wedged actor that has the right client_id but stale + // redirect_uri without changing client_id. Pre-fix the check was + // (client_id, authority) only and this case silently no-op'd — + // leaving redirect_uri empty meant the next bootstrap detected + // drift and re-DCR'd the pinned client_id away. + await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand + { + ClientId = "client-x", + NyxidAuthority = "https://nyxid.test", + }); + _agent.State.RedirectUri.Should().BeEmpty(); + + await _agent.HandleProvision(new ProvisionAevatarOAuthClientCommand + { + ClientId = "client-x", + NyxidAuthority = "https://nyxid.test", + RedirectUri = "https://aevatar.test/api/oauth/nyxid-callback", + OauthScope = AevatarOAuthClientScopes.AuthorizationScope, + }); + + _agent.State.ClientId.Should().Be("client-x"); + _agent.State.RedirectUri.Should().Be("https://aevatar.test/api/oauth/nyxid-callback"); + _agent.State.OauthScope.Should().Be(AevatarOAuthClientScopes.AuthorizationScope); + } + [Fact] public async Task HandleObserveBrokerCapability_IsIdempotent() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingGAgentTests.cs index 519b895b8..dacba9536 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingGAgentTests.cs @@ -13,8 +13,9 @@ namespace Aevatar.GAgents.ChannelRuntime.Tests.Identity; /// /// Behavior tests for : state -/// transitions, idempotent commit under concurrent /init, and revoke as -/// no-op when no binding exists. Pinned by ADR-0017 §Implementation Notes #2. +/// transitions, idempotent commit under concurrent /init, and revoke-driven +/// projection repair when no binding exists. Pinned by ADR-0017 §Implementation +/// Notes #2. /// /// FOLLOW-UP (tracked at ): /// most tests instantiate the agent directly with a hand-rolled @@ -185,7 +186,7 @@ await _agent.HandleRevokeBinding(new RevokeBindingCommand } [Fact] - public async Task HandleRevokeBinding_IsNoOpWhenNoActiveBinding() + public async Task HandleRevokeBinding_RequestsProjectionRebuildWhenNoActiveBinding() { await _agent.HandleRevokeBinding(new RevokeBindingCommand { @@ -195,6 +196,9 @@ await _agent.HandleRevokeBinding(new RevokeBindingCommand _agent.State.BindingId.Should().BeEmpty(); _agent.State.RevokedAt.Should().BeNull(); + _agent.EventSourcing!.CurrentVersion.Should().Be( + 1, + "a remote-side revoke/self-heal must overwrite any stale active binding readmodel from the actor's empty state"); } [Fact] diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectionReadinessPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectionReadinessPortTests.cs index 4d45ce2dc..bc94d2302 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectionReadinessPortTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectionReadinessPortTests.cs @@ -93,6 +93,20 @@ public async Task WaitForBindingStateAsync_RevokeCaseMatchesEmptyBindingId() await port.WaitForBindingStateAsync(subject, expectedBindingId: null, TimeSpan.FromSeconds(1)); } + [Fact] + public async Task WaitForBindingStateAsync_RevokeCaseMatchesMissingDocument() + { + var subject = SampleSubject(); + var reader = new InMemoryReader(); + var port = new ExternalIdentityBindingProjectionReadinessPort( + reader, + new FakeTimeProvider(DateTimeOffset.UtcNow)); + + await port.WaitForBindingStateAsync(subject, expectedBindingId: null, TimeSpan.FromSeconds(1)); + + reader.GetCalls.Should().Be(1); + } + [Fact] public async Task WaitForBindingStateAsync_ThrowsTimeoutWhenNoMatch() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectorTests.cs index 9f9f29069..3f612f583 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ExternalIdentityBindingProjectorTests.cs @@ -75,7 +75,7 @@ public async Task ProjectAsync_WritesActiveBindingDocument() } [Fact] - public async Task ProjectAsync_WritesRevokedDocumentAsInactive() + public async Task ProjectAsync_DeletesRevokedBindingDocument() { var dispatcher = new RecordingDispatcher(); var projector = new ExternalIdentityBindingProjector(dispatcher, new FixedClock(DateTimeOffset.UtcNow)); @@ -97,11 +97,34 @@ public async Task ProjectAsync_WritesRevokedDocumentAsInactive() await projector.ProjectAsync(context, envelope); - dispatcher.Upserts.Should().HaveCount(1); - var doc = dispatcher.Upserts[0]; - doc.IsActive.Should().BeFalse(); - doc.BindingId.Should().BeEmpty(); - doc.RevokedAtUtcValue.Should().NotBeNull(); + dispatcher.Upserts.Should().BeEmpty(); + dispatcher.Deletes.Should().ContainSingle().Which.Should().Be(subject.ToActorId()); + } + + [Fact] + public async Task ProjectAsync_DeletesEmptyBindingDocument() + { + var dispatcher = new RecordingDispatcher(); + var projector = new ExternalIdentityBindingProjector(dispatcher, new FixedClock(DateTimeOffset.UtcNow)); + var subject = SampleSubject(); + var context = new ExternalIdentityBindingMaterializationContext + { + RootActorId = subject.ToActorId(), + ProjectionKind = "external-identity-binding", + }; + + var state = new ExternalIdentityBindingState + { + ExternalSubject = subject, + BindingId = string.Empty, + BoundAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddMinutes(-5)), + }; + var envelope = TestEnvelopeBuilder.BuildCommittedEnvelope(state, version: 3, eventId: "ev-3"); + + await projector.ProjectAsync(context, envelope); + + dispatcher.Upserts.Should().BeEmpty(); + dispatcher.Deletes.Should().ContainSingle().Which.Should().Be(subject.ToActorId()); } private sealed class FixedClock : IProjectionClock @@ -113,6 +136,7 @@ private sealed class FixedClock : IProjectionClock private sealed class RecordingDispatcher : IProjectionWriteDispatcher { public List Upserts { get; } = new(); + public List Deletes { get; } = new(); public Task UpsertAsync( ExternalIdentityBindingDocument readModel, @@ -123,6 +147,9 @@ public Task UpsertAsync( } public Task DeleteAsync(string id, CancellationToken ct = default) - => Task.FromResult(ProjectionWriteResult.Applied()); + { + Deletes.Add(id); + return Task.FromResult(ProjectionWriteResult.Applied()); + } } } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs index 640f7e230..80edf375b 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthCallbackEndpointTests.cs @@ -58,8 +58,9 @@ public async Task LegacyAlreadyBound_OnReadinessTimeout_RevokesIncomingAndReturn var (result, _) = await InvokeCallbackAsync(broker, queryPort, readiness); await broker.Received(1).RevokeBindingByIdAsync(incoming, Arg.Any()); - await ReadJsonAsync(result).ContinueWith(t => - t.Result.RootElement.GetProperty("status").GetString().Should().Be("already_bound")); + var html = await ReadTextAsync(result); + html.Should().Contain("已绑定"); + html.Should().Contain("/whoami"); } [Fact] @@ -108,8 +109,37 @@ public async Task HappyPath_WaitForBindingSucceeds_ReturnsBound() await broker.DidNotReceive().RevokeBindingByIdAsync(Arg.Any(), Arg.Any()); await queryPort.Received(1).ResolveAsync(Arg.Any(), Arg.Any()); - var doc = await ReadJsonAsync(result); - doc.RootElement.GetProperty("status").GetString().Should().Be("bound"); + var html = await ReadTextAsync(result); + // Issue #513 phase 1 substitute: the success page must name the + // next-step slash commands so the user knows what to type back in + // Lark after the OAuth round-trip. + html.Should().Contain("绑定成功"); + html.Should().Contain("/model"); + html.Should().Contain("/whoami"); + } + + [Fact] + public async Task HappyPath_RendersHtml_ContentTypeIsTextHtml() + { + const string incoming = "bnd_incoming"; + var subject = SampleSubject(); + var broker = NewBroker(subject, incoming); + var queryPort = Substitute.For(); + queryPort.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + var readiness = Substitute.For(); + readiness.WaitForBindingStateAsync( + Arg.Any(), + incoming, + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + + var (result, _) = await InvokeCallbackAsync(broker, queryPort, readiness); + var (text, contentType) = await ReadTextWithContentTypeAsync(result); + + contentType.Should().StartWith("text/html"); + text.Should().Contain(""); } // ─── Test plumbing ─── @@ -177,6 +207,7 @@ private static IActorRuntime NewActorRuntime() code: "auth-code", state: "state-token", error: null, + format: null, brokerCallback: broker, queryPort: queryPort, actorRuntime: actorRuntime, @@ -189,12 +220,24 @@ private static IActorRuntime NewActorRuntime() } private static async Task ReadJsonAsync(IResult result) + { + var (text, _) = await ReadTextWithContentTypeAsync(result); + return JsonDocument.Parse(text); + } + + private static async Task ReadTextAsync(IResult result) + { + var (text, _) = await ReadTextWithContentTypeAsync(result); + return text; + } + + private static async Task<(string Text, string? ContentType)> ReadTextWithContentTypeAsync(IResult result) { var context = NewHttpContext(); await result.ExecuteAsync(context); context.Response.Body.Position = 0; var text = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync(); - return JsonDocument.Parse(text); + return (text, context.Response.ContentType); } private static HttpContext NewHttpContext() diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthClientRebuildEndpointTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthClientRebuildEndpointTests.cs new file mode 100644 index 000000000..bd025f777 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/IdentityOAuthClientRebuildEndpointTests.cs @@ -0,0 +1,368 @@ +using System.Text; +using System.Text.Json; +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Identity; +using Aevatar.GAgents.Channel.Identity.Abstractions; +using Aevatar.GAgents.Channel.Identity.Endpoints; +using FluentAssertions; +using Google.Protobuf; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests.Identity; + +/// +/// Behaviour tests for . +/// Pins issue #549 operator-rebuild path: ops calls this endpoint with a +/// freshly-created (NyxID admin) client_id to heal a wedged cluster +/// without DB access. The endpoint must (a) refuse fail-secure when no +/// admin token is configured, (b) reject without a matching token, (c) +/// validate body fields, (d) dispatch ProvisionAevatarOAuthClientCommand +/// with the canonical redirect_uri + oauth_scope (operator cannot override +/// — see PR #570 review), and (e) wait for the readmodel to reflect the +/// pin before declaring success. +/// +public sealed class IdentityOAuthClientRebuildEndpointTests +{ + private const string AdminToken = "test-admin-token-very-secret"; + private const string OperatorClientId = "17cecaad-214b-4521-9dba-d435462e4095"; + + [Fact] + public async Task Returns503_WhenAdminTokenNotConfigured() + { + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: string.Empty, + adminTokenHeader: AdminToken, + body: SampleBody(), + provider: provider, + actorRuntime: runtime); + + var doc = await ReadJsonAsync(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("rebuild_not_configured"); + } + + [Fact] + public async Task Returns401_WhenAdminTokenHeaderMissing() + { + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: null, + body: SampleBody(), + provider: provider, + actorRuntime: runtime); + + // Results.Unauthorized() renders to status 401. + var ctx = NewHttpContext(); + await result.ExecuteAsync(ctx); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task Returns401_WhenAdminTokenHeaderMismatch() + { + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: "wrong-token", + body: SampleBody(), + provider: provider, + actorRuntime: runtime); + + var ctx = NewHttpContext(); + await result.ExecuteAsync(ctx); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task Returns400_WhenClientIdMissing() + { + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: AdminToken, + body: new IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest( + client_id: null, + client_id_issued_at_unix: null), + provider: provider, + actorRuntime: runtime); + + var doc = await ReadJsonAsync(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("client_id_required"); + } + + [Fact] + public async Task Returns400_WhenIssuedAtUnixOutOfRange() + { + // Pin codex P1: AevatarOAuthClientProjectionProvider.GetAsync + // calls DateTimeOffset.FromUnixTimeSeconds on the persisted value + // and throws ArgumentOutOfRangeException for values like + // long.MaxValue. The endpoint must surface the bad input as 400 + // here so the read path does not crash on the next status poll. + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: AdminToken, + body: new IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest( + client_id: OperatorClientId, + client_id_issued_at_unix: long.MaxValue), + provider: provider, + actorRuntime: runtime); + + var doc = await ReadJsonAsync(result); + doc.RootElement.GetProperty("error").GetString().Should().Be("client_id_issued_at_unix_invalid"); + runtime.Captured.Should().BeEmpty( + "rejected request must not dispatch the actor command"); + } + + [Fact] + public async Task DispatchesProvisionCommand_WithCanonicalSnapshot() + { + var (provider, runtime) = NewProviderReflectingDispatch(); + var result = await InvokeRebuildAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: AdminToken, + body: new IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest( + client_id: OperatorClientId, + client_id_issued_at_unix: 1700000000), + provider: provider, + actorRuntime: runtime); + + runtime.Captured.Should().HaveCount(1); + var envelope = runtime.Captured[0]; + envelope.Route.Direct.TargetActorId.Should().Be(AevatarOAuthClientGAgent.WellKnownId); + var cmd = envelope.Payload.Unpack(); + cmd.ClientId.Should().Be(OperatorClientId); + cmd.ClientIdIssuedAtUnix.Should().Be(1700000000); + // Endpoint always uses the resolver / canonical scope — operator + // cannot override, otherwise the next bootstrap pass would observe + // drift and re-DCR the pinned client (PR #570 review consensus). + cmd.RedirectUri.Should().Be(NyxIdRedirectUriResolver.Resolve()); + cmd.OauthScope.Should().Be(AevatarOAuthClientScopes.AuthorizationScope); + cmd.NyxidAuthority.Should().NotBeNullOrWhiteSpace(); + + var doc = await ReadJsonAsync(result); + doc.RootElement.GetProperty("status").GetString().Should().Be("rebuilt"); + doc.RootElement.GetProperty("client_id").GetString().Should().Be(OperatorClientId); + } + + [Fact] + public async Task Returns202_WhenReadmodelDoesNotReflectRebuildBeforeTimeout() + { + // Provider always returns the OLD snapshot — readmodel never + // catches up. Endpoint must report rebuild_pending_propagation + // instead of waiting forever. Production budget is 15s; the test + // tightens it via the CoreAsync seam so the assertion runs in + // sub-second wall time. + var provider = Substitute.For(); + provider.GetAsync(Arg.Any()) + .Returns(Task.FromResult(StaleSnapshot())); + var runtime = new RecordingActorRuntime(); + var result = await InvokeRebuildCoreAsync( + adminTokenConfigured: AdminToken, + adminTokenHeader: AdminToken, + body: SampleBody(), + provider: provider, + actorRuntime: runtime, + observationTimeout: TimeSpan.FromMilliseconds(150), + observationPollDelay: TimeSpan.FromMilliseconds(20)); + + var ctx = NewHttpContext(); + await result.ExecuteAsync(ctx); + ctx.Response.StatusCode.Should().Be(StatusCodes.Status202Accepted); + ctx.Response.Body.Position = 0; + var text = await new StreamReader(ctx.Response.Body, Encoding.UTF8).ReadToEndAsync(); + var doc = JsonDocument.Parse(text); + doc.RootElement.GetProperty("status").GetString().Should().Be("rebuild_pending_propagation"); + // Pin mimo P1: even the timeout path must have dispatched the + // command — otherwise a regression that drops the dispatch could + // pass with a stale provider and never trigger this assertion. + runtime.Captured.Should().HaveCount(1, + "timeout path must still have dispatched the provision command before the wait loop began"); + } + + // ─── Test plumbing ─── + + private static IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest SampleBody() => + new( + client_id: OperatorClientId, + client_id_issued_at_unix: 1700000000); + + private static AevatarOAuthClientSnapshot SuccessSnapshotFor( + string clientId, + string redirectUri, + string oauthScope) => + new( + ClientId: clientId, + ClientIdIssuedAt: DateTimeOffset.FromUnixTimeSeconds(1700000000), + HmacKid: AevatarOAuthClientGAgent.InitialHmacKid, + HmacKey: new byte[32], + HmacKeyRotatedAt: DateTimeOffset.UtcNow, + NyxIdAuthority: NyxIdAuthorityResolver.Resolve(), + BrokerCapabilityObserved: true, + BrokerCapabilityObservedAt: DateTimeOffset.UtcNow, + PreviousHmacKid: null, + PreviousHmacKey: null, + PreviousHmacDemotedAt: null, + RedirectUri: redirectUri, + OauthScope: oauthScope); + + private static AevatarOAuthClientSnapshot StaleSnapshot() => + new( + ClientId: "stale-old-client", + ClientIdIssuedAt: DateTimeOffset.FromUnixTimeSeconds(1600000000), + HmacKid: AevatarOAuthClientGAgent.InitialHmacKid, + HmacKey: new byte[32], + HmacKeyRotatedAt: DateTimeOffset.UtcNow, + NyxIdAuthority: NyxIdAuthorityResolver.Resolve(), + BrokerCapabilityObserved: false, + BrokerCapabilityObservedAt: null, + PreviousHmacKid: null, + PreviousHmacKey: null, + PreviousHmacDemotedAt: null, + RedirectUri: "https://stale.example.com/callback", + OauthScope: "openid"); + + private static (IAevatarOAuthClientProvider Provider, RecordingActorRuntime Runtime) NewProviderReflectingDispatch() + { + var runtime = new RecordingActorRuntime(); + var provider = Substitute.For(); + provider.GetAsync(Arg.Any()) + .Returns(_ => + { + if (runtime.Captured.Count == 0) + return Task.FromResult(StaleSnapshot()); + var cmd = runtime.Captured[^1].Payload.Unpack(); + return Task.FromResult(SuccessSnapshotFor(cmd.ClientId, cmd.RedirectUri, cmd.OauthScope)); + }); + return (provider, runtime); + } + + private static AevatarOAuthClientProjectionPort NewProjectionPort() + { + var activationService = Substitute.For>(); + activationService.EnsureAsync(Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult( + new AevatarOAuthClientMaterializationRuntimeLease( + new AevatarOAuthClientMaterializationContext + { + RootActorId = AevatarOAuthClientGAgent.WellKnownId, + ProjectionKind = AevatarOAuthClientProjectionPort.ProjectionKind, + }))!); + return new AevatarOAuthClientProjectionPort(activationService); + } + + /// + /// Wraps NSubstitute-built IActorRuntime so test assertions can read the + /// captured envelope without re-querying NSubstitute call queues. + /// + private sealed class RecordingActorRuntime + { + public List Captured { get; } = new(); + public IActorRuntime Runtime { get; } + public IActorDispatchPort DispatchPort { get; } + + public RecordingActorRuntime() + { + var actor = Substitute.For(); + actor.HandleEventAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + Runtime = Substitute.For(); + Runtime.CreateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(actor)); + + // The rebuild endpoint dispatches the provision command via + // IActorDispatchPort (no longer inline actor.HandleEventAsync), so the + // recording happens on the dispatch port. + DispatchPort = Substitute.For(); + DispatchPort + .DispatchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + Captured.Add(callInfo.Arg()); + return Task.CompletedTask; + }); + } + } + + private static Task InvokeRebuildAsync( + string adminTokenConfigured, + string? adminTokenHeader, + IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest body, + IAevatarOAuthClientProvider provider, + RecordingActorRuntime actorRuntime, + CancellationToken ct = default) => + InvokeRebuildCoreAsync( + adminTokenConfigured, + adminTokenHeader, + body, + provider, + actorRuntime, + // Default budget is generous: happy-path tests exit on the + // first provider poll; only the 202 test cares about timeout. + observationTimeout: TimeSpan.FromSeconds(2), + observationPollDelay: TimeSpan.FromMilliseconds(20), + ct); + + private static async Task InvokeRebuildCoreAsync( + string adminTokenConfigured, + string? adminTokenHeader, + IdentityOAuthEndpoints.RebuildAevatarOAuthClientRequest body, + IAevatarOAuthClientProvider provider, + RecordingActorRuntime actorRuntime, + TimeSpan observationTimeout, + TimeSpan observationPollDelay, + CancellationToken ct = default) + { + var http = NewHttpContext(); + if (adminTokenHeader is not null) + http.Request.Headers[AevatarOAuthAdminOptions.RebuildTokenHeader] = adminTokenHeader; + + var options = Options.Create(new AevatarOAuthAdminOptions { RebuildToken = adminTokenConfigured }); + var projectionPort = NewProjectionPort(); + + return await IdentityOAuthEndpoints.HandleAevatarOAuthClientRebuildCoreAsync( + http: http, + body: body, + adminOptions: options, + provider: provider, + projectionPort: projectionPort, + actorRuntime: actorRuntime.Runtime, + actorDispatchPort: actorRuntime.DispatchPort, + loggerFactory: NullLoggerFactory.Instance, + observationTimeout: observationTimeout, + observationPollDelay: observationPollDelay, + ct: ct); + } + + private static async Task ReadJsonAsync(IResult result) + { + var context = NewHttpContext(); + await result.ExecuteAsync(context); + context.Response.Body.Position = 0; + var text = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync(); + return JsonDocument.Parse(text); + } + + private static HttpContext NewHttpContext() + { + var services = new ServiceCollection(); + services.AddLogging(); + var provider = services.BuildServiceProvider(); + return new DefaultHttpContext + { + RequestServices = provider, + Response = + { + Body = new MemoryStream(), + }, + }; + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ModelSlashCommandHandlerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ModelSlashCommandHandlerTests.cs index 08381f3fb..23938ff2e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ModelSlashCommandHandlerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/ModelSlashCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Channel.Abstractions; using Aevatar.GAgents.Channel.Identity; using Aevatar.GAgents.Channel.Identity.Abstractions; @@ -119,16 +120,94 @@ public async Task List_RequestsProxyScope_ForNyxIdLlmApi() } [Fact] - public async Task List_ReturnsRebindMessage_WhenBindingScopeMissing() + public async Task List_SelfHealsAndRebindsMessage_WhenBindingScopeMissing() { - var handler = CreateHandler(broker: new ThrowingCapabilityBroker( - new BindingScopeMismatchException(Context().Subject))); + // NyxID rejects the binding's scope set: the binding was issued before + // aevatar's DCR started requesting `proxy`, so the broker can no longer + // mint LLM-API tokens for it. Self-heal by revoking the local actor so + // /init is unblocked, AND tell the user. + var dispatchPort = new RecordingActorDispatchPort(); + var handler = CreateHandler( + broker: new ThrowingCapabilityBroker(new BindingScopeMismatchException(Context().Subject)), + actorDispatchPort: dispatchPort); var reply = await handler.HandleAsync(Context(), default); reply.Should().NotBeNull(); reply!.Text.Should().Contain("缺少 LLM route 权限"); + reply.Text.Should().Contain("清理已提交"); reply.Text.Should().Contain("/init"); + AssertRevokeBindingDispatched(dispatchPort, expectedReason: "auto_self_heal_scope_mismatch"); + } + + [Fact] + public async Task List_SelfHealsAndRebindsMessage_WhenBindingRevokedRemotely() + { + // NyxID itself returned binding_revoked (e.g. user revoked at NyxID admin + // or the binding tied to a re-DCR'd cluster client_id was invalidated). + // Wipe the local readmodel so /init isn't blocked by stale state. + var dispatchPort = new RecordingActorDispatchPort(); + var handler = CreateHandler( + broker: new ThrowingCapabilityBroker(new BindingRevokedException(Context().Subject)), + actorDispatchPort: dispatchPort); + + var reply = await handler.HandleAsync(Context(), default); + + reply.Should().NotBeNull(); + reply!.Text.Should().Contain("失效"); + reply.Text.Should().Contain("清理已提交"); + reply.Text.Should().Contain("/init"); + AssertRevokeBindingDispatched(dispatchPort, expectedReason: "auto_self_heal_remote_revoked"); + } + + [Fact] + public async Task List_SelfHealsAndRebindsMessage_WhenBindingNotFoundRemotely() + { + var dispatchPort = new RecordingActorDispatchPort(); + var handler = CreateHandler( + broker: new ThrowingCapabilityBroker(new BindingNotFoundException(Context().Subject)), + actorDispatchPort: dispatchPort); + + var reply = await handler.HandleAsync(Context(), default); + + reply.Should().NotBeNull(); + reply!.Text.Should().Contain("不可用"); + reply.Text.Should().Contain("清理已提交"); + reply.Text.Should().Contain("/init"); + AssertRevokeBindingDispatched(dispatchPort, expectedReason: "auto_self_heal_remote_not_found"); + } + + [Fact] + public async Task List_DegradesToUnbindGuidance_WhenSelfHealDispatchKeepsThrowing() + { + var dispatchPort = new ThrowingActorDispatchPort(); + var handler = CreateHandler( + broker: new ThrowingCapabilityBroker(new BindingRevokedException(Context().Subject)), + actorDispatchPort: dispatchPort); + + var reply = await handler.HandleAsync(Context(), default); + + reply.Should().NotBeNull(); + reply!.Text.Should().Contain("失效"); + reply.Text.Should().Contain("清理提交失败"); + reply.Text.Should().Contain("/unbind"); + reply.Text.Should().NotContain("清理已提交"); + dispatchPort.AttemptCount.Should().Be(2, "self-heal must attempt the local revoke twice before degrading"); + } + + private static void AssertRevokeBindingDispatched(RecordingActorDispatchPort dispatchPort, string expectedReason) + { + dispatchPort.Dispatched.Should().ContainSingle("self-heal must dispatch exactly one local revoke"); + var (actorId, envelope) = dispatchPort.Dispatched[0]; + actorId.Should().Be(Context().Subject.ToActorId()); + envelope.Route.Direct.TargetActorId.Should().Be(actorId); + envelope.Route.PublisherActorId.Should().Be("nyxid-chat.model.self-heal"); + + var revoke = envelope.Payload.Unpack(); + revoke.Reason.Should().Be(expectedReason); + revoke.ExternalSubject.Platform.Should().Be("lark"); + revoke.ExternalSubject.Tenant.Should().Be("tenant"); + revoke.ExternalSubject.ExternalUserId.Should().Be("ou_user"); } [Fact] @@ -174,6 +253,62 @@ public async Task Use_ServiceName_WritesMatchingRoute() .Subject.Config.PreferredLlmRoute.Should().Be(OpenAi.RouteValue); } + [Fact] + public async Task Use_ServiceName_PrefersSelectableDuplicate() + { + var disabledGateway = ChronoLlm with + { + UserServiceId = "chrono-llm", + DisplayName = "Chrono LLM", + RouteValue = "/api/v1/llm/chrono-llm/v1", + Status = "not_connected", + Source = NyxIdLlmProviderSource.GatewayProvider, + Allowed = false, + }; + var selectableProxy = ChronoLlm with { DisplayName = "Chrono LLM" }; + var catalog = new StubCatalogClient { Services = [disabledGateway, selectableProxy] }; + var commandService = new StubUserConfigCommandService(); + var handler = CreateHandler(catalog, commandService: commandService); + + var reply = await handler.HandleAsync(Context(subAndArgs: "use Chrono LLM"), default); + + reply.Should().NotBeNull(); + reply!.Text.Should().Contain("Chrono LLM"); + var saved = commandService.SavedConfigs.Should().ContainSingle().Subject; + saved.Config.PreferredLlmRoute.Should().Be(selectableProxy.RouteValue); + saved.Config.DefaultModel.Should().Be(selectableProxy.DefaultModel); + } + + [Fact] + public async Task Selection_SetByService_PrefersSelectableDuplicateForSubmittedServiceId() + { + var disabledGateway = ChronoLlm with + { + UserServiceId = "chrono-llm", + DisplayName = "Chrono LLM", + RouteValue = "/api/v1/llm/chrono-llm/v1", + Status = "not_connected", + Source = NyxIdLlmProviderSource.GatewayProvider, + Allowed = false, + }; + var selectableProxy = ChronoLlm with { DisplayName = "Chrono LLM" }; + var catalog = new StubCatalogClient { Services = [disabledGateway, selectableProxy] }; + var commandService = new StubUserConfigCommandService(); + var provider = new ServiceCollection() + .AddSingleton(new StubUserConfigQueryPort()) + .AddSingleton(commandService) + .BuildServiceProvider(); + var scopeFactory = provider.GetRequiredService(); + var options = new DefaultUserLlmOptionsService(catalog, scopeFactory); + var selection = new DefaultUserLlmSelectionService(options, catalog, scopeFactory); + + await selection.SetByServiceAsync(BuildSelectionContext(), "chrono-llm", null, default); + + var saved = commandService.SavedConfigs.Should().ContainSingle().Subject; + saved.Config.PreferredLlmRoute.Should().Be(selectableProxy.RouteValue); + saved.Config.DefaultModel.Should().Be(selectableProxy.DefaultModel); + } + [Fact] public async Task Use_ServiceNameAndModel_WritesRouteAndModelOverride() { @@ -324,11 +459,13 @@ private static ModelChannelSlashCommandHandler CreateHandler( StubCatalogClient? catalog = null, StubUserConfigQueryPort? queryPort = null, StubUserConfigCommandService? commandService = null, - INyxIdCapabilityBroker? broker = null) + INyxIdCapabilityBroker? broker = null, + IActorDispatchPort? actorDispatchPort = null) { catalog ??= new StubCatalogClient(); queryPort ??= new StubUserConfigQueryPort(); commandService ??= new StubUserConfigCommandService(); + actorDispatchPort ??= new RecordingActorDispatchPort(); var provider = new ServiceCollection() .AddSingleton(queryPort) @@ -339,11 +476,39 @@ private static ModelChannelSlashCommandHandler CreateHandler( var selection = new DefaultUserLlmSelectionService(options, catalog, scopeFactory, broker); return new ModelChannelSlashCommandHandler( NullLogger.Instance, + actorDispatchPort, options, selection, new TextUserLlmOptionsRenderer()); } + private static UserLlmSelectionContext BuildSelectionContext() => new( + new BindingId { Value = "bnd_sender" }, + Context().Subject, + "owner-scope"); + + private sealed class RecordingActorDispatchPort : IActorDispatchPort + { + public List<(string ActorId, EventEnvelope Envelope)> Dispatched { get; } = []; + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + Dispatched.Add((actorId, envelope)); + return Task.CompletedTask; + } + } + + private sealed class ThrowingActorDispatchPort : IActorDispatchPort + { + public int AttemptCount { get; private set; } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + AttemptCount++; + throw new InvalidOperationException("simulated dispatch failure"); + } + } + private static StudioConfig MakeConfig( string defaultModel, string route = UserConfigLlmRouteDefaults.Gateway) => new( diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/NyxIdLlmServiceCatalogClientTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/NyxIdLlmServiceCatalogClientTests.cs new file mode 100644 index 000000000..c0b6858b9 --- /dev/null +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/NyxIdLlmServiceCatalogClientTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.AI.ToolProviders.NyxId; +using Aevatar.GAgents.Channel.Abstractions; +using Aevatar.GAgents.Channel.Identity.Abstractions; +using Aevatar.GAgents.NyxidChat.LlmSelection; +using Aevatar.Studio.Application.Studio.Abstractions; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Aevatar.GAgents.ChannelRuntime.Tests.Identity; + +public sealed class NyxIdLlmServiceCatalogClientTests +{ + [Fact] + public async Task GetServicesAsync_CachesProxyServicesPerAccessToken() + { + var handler = new RecordingHandler(); + var nyxClient = new NyxIdApiClient( + new NyxIdToolOptions { BaseUrl = "https://nyx.test" }, + new HttpClient(handler), + NullLogger.Instance); + var memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + var client = new NyxIdLlmServiceCatalogClient( + nyxClient, + memoryCache, + NullLogger.Instance); + var query = new UserLlmOptionsQuery( + new BindingId { Value = "bnd-1" }, + new ExternalSubjectRef + { + Platform = "lark", + Tenant = "tenant", + ExternalUserId = "user", + }, + RegistrationScopeId: "scope-1"); + + await client.GetServicesAsync(query, "token-a", CancellationToken.None); + await client.GetServicesAsync(query, "token-a", CancellationToken.None); + await client.GetServicesAsync(query, "token-b", CancellationToken.None); + + handler.Paths.Count(path => path == "/api/v1/llm/services").Should().Be(3); + handler.Paths.Count(path => path == NyxIdLlmCatalogRoutes.ProxyServicesPath) + .Should() + .Be(2, "same-token calls should reuse the short-lived proxy-services cache"); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List Paths { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.PathAndQuery; + Paths.Add(path); + var body = path switch + { + "/api/v1/llm/services" => """{"services":[]}""", + _ when path == NyxIdLlmCatalogRoutes.ProxyServicesPath => """ + { + "services": [ + { + "id": "svc-chrono", + "slug": "chrono-llm", + "name": "Chrono LLM", + "description": "Shared LLM route", + "proxy_url_slug": "https://nyx.test/api/v1/proxy/s/chrono-llm/{path}" + } + ] + } + """, + _ => """{"error":true,"status":404}""", + }; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs index 988a4434b..710a3e634 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/Identity/SlashCommandHandlerTests.cs @@ -83,33 +83,65 @@ public async Task Init_TellsAlreadyBoundSender_ToUnbindFirst() } [Fact] - public async Task Whoami_RequiresBinding_AndReturnsMaskedBindingId() + public async Task Whoami_BoundSender_ReturnsMaskedBindingId() { var handler = new WhoamiChannelSlashCommandHandler(); - handler.RequiresBinding.Should().BeTrue(); - var ctx = Context(bindingValue: "bnd_1234567890abcdef"); - ctx = new ChannelSlashCommandContext + var ctx = new ChannelSlashCommandContext { CommandName = "whoami", - ArgumentText = ctx.ArgumentText, - Subject = ctx.Subject, - BindingIdValue = ctx.BindingIdValue, - RegistrationId = ctx.RegistrationId, - RegistrationScopeId = ctx.RegistrationScopeId, - SenderId = ctx.SenderId, - SenderName = ctx.SenderName, - IsPrivateChat = ctx.IsPrivateChat, + ArgumentText = string.Empty, + Subject = Subject(), + BindingIdValue = "bnd_1234567890abcdef", + RegistrationId = "reg-1", + RegistrationScopeId = "scope-1", + SenderId = "ou_user_y", + SenderName = "Eric", + IsPrivateChat = true, }; var reply = await handler.HandleAsync(ctx, default); reply.Should().NotBeNull(); - reply!.Text.Should().Contain("Eric"); + reply!.Text.Should().Contain("已绑定"); + reply.Text.Should().Contain("Eric"); reply.Text.Should().Contain("bnd_…cdef"); reply.Text.Should().NotContain("1234567890abcdef"); } + [Fact] + public async Task Whoami_DoesNotRequireBinding_AndReturnsUnboundHintForUnboundSender() + { + // Issue #513 phase 6: /whoami must reach its handler regardless of + // binding state so the user can introspect "am I bound?" without the + // turn runner short-circuiting them to the /init prompt instead. + var handler = new WhoamiChannelSlashCommandHandler(); + handler.RequiresBinding.Should().BeFalse(); + + var ctx = new ChannelSlashCommandContext + { + CommandName = "whoami", + ArgumentText = string.Empty, + Subject = Subject(), + BindingIdValue = null, + RegistrationId = "reg-1", + RegistrationScopeId = "scope-1", + SenderId = "ou_user_y", + SenderName = "Eric", + IsPrivateChat = true, + }; + + var reply = await handler.HandleAsync(ctx, default); + + reply.Should().NotBeNull(); + reply!.Text.Should().Contain("未绑定"); + reply.Text.Should().Contain("/init"); + reply.Text.Should().Contain("Eric"); + // Must not invent a binding-id placeholder — masked output is only + // for actual bindings. + reply.Text.Should().NotContain("Binding ID"); + } + [Fact] public void InitHandler_DoesNotRequireBinding() { diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayTransportTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayTransportTests.cs index 5f5d3ff0f..d04b7160e 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayTransportTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxIdRelayTransportTests.cs @@ -245,7 +245,7 @@ public void Parse_ShouldPopulateCardAction_ForAgentBuilderFormSubmit() "sender": { "platform_id": "ou_1", "display_name": "User One" }, "content": { "content_type": "card_action", - "text": "{\"value\":{\"agent_builder_action\":\"create_daily_report\"},\"form_value\":{\"github_username\":\"eanzhao\",\"schedule_time\":\"09:00\"}}" + "text": "{\"value\":{\"agent_builder_action\":\"create_daily\"},\"form_value\":{\"github_username\":\"eanzhao\",\"schedule_time\":\"09:00\"}}" } } """; @@ -258,12 +258,12 @@ public void Parse_ShouldPopulateCardAction_ForAgentBuilderFormSubmit() var cardAction = parsed.Activity.Content.CardAction; cardAction.Should().NotBeNull(); cardAction!.Arguments.Should().ContainKey("agent_builder_action") - .WhoseValue.Should().Be("create_daily_report"); + .WhoseValue.Should().Be("create_daily"); cardAction.FormFields.Should().ContainKey("github_username") .WhoseValue.Should().Be("eanzhao"); cardAction.FormFields.Should().ContainKey("schedule_time") .WhoseValue.Should().Be("09:00"); - cardAction.ActionId.Should().Be("create_daily_report"); + cardAction.ActionId.Should().Be("create_daily"); } [Fact] @@ -446,7 +446,7 @@ public void Parse_ShouldExposeLarkUnionIdAndChatId_FromCardActionRawPlatformData "sender": { "platform_id": "ou_user_2", "display_name": "User Two" }, "content": { "content_type": "card_action", - "text": "{\"value\":{\"agent_builder_action\":\"create_daily_report\"}}" + "text": "{\"value\":{\"agent_builder_action\":\"create_daily\"}}" }, "raw_platform_data": { "schema": "2.0", @@ -463,7 +463,7 @@ public void Parse_ShouldExposeLarkUnionIdAndChatId_FromCardActionRawPlatformData }, "action": { "tag": "button", - "value": { "agent_builder_action": "create_daily_report" } + "value": { "agent_builder_action": "create_daily" } } } } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs index d93d4cea0..2867defdf 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/NyxRelayAgentBuilderFlowTests.cs @@ -11,149 +11,6 @@ namespace Aevatar.GAgents.ChannelRuntime.Tests; public sealed class NyxRelayAgentBuilderFlowTests { - [Fact] - public void TryResolve_ShouldBuildDailyReportToolCall_ForDailyWithoutArguments() - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - ConversationId = "oc_default_daily", - Text = "/daily", - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("create_daily_report"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("action").GetString().Should().Be("create_agent"); - body.RootElement.GetProperty("template").GetString().Should().Be("daily_report"); - body.RootElement.GetProperty("github_username").ValueKind.Should().Be(JsonValueKind.Null); - body.RootElement.GetProperty("schedule_cron").GetString().Should().Be("0 9 * * *"); - body.RootElement.GetProperty("conversation_id").GetString().Should().Be("oc_default_daily"); - } - - [Fact] - public void TryResolve_ShouldAcceptPositionalGithubUsername_AndForwardConversationId() - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - ConversationId = "oc_8a70aeefbdb4340e1fa5f575b4c794eb", - Text = "/daily eanzhao", - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("create_daily_report"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("action").GetString().Should().Be("create_agent"); - body.RootElement.GetProperty("template").GetString().Should().Be("daily_report"); - body.RootElement.GetProperty("github_username").GetString().Should().Be("eanzhao"); - body.RootElement.GetProperty("save_github_username_preference").GetBoolean().Should().BeTrue(); - body.RootElement.GetProperty("run_immediately").GetBoolean().Should().BeTrue(); - body.RootElement.GetProperty("conversation_id").GetString().Should().Be("oc_8a70aeefbdb4340e1fa5f575b4c794eb"); - } - - [Fact] - public void TryResolve_ShouldNotRequestPreferenceSave_WhenDailyHasNoUsername() - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - ConversationId = "oc_default_daily", - Text = "/daily", - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("github_username").ValueKind.Should().Be(JsonValueKind.Null); - body.RootElement.GetProperty("save_github_username_preference").GetBoolean().Should().BeFalse(); - } - - [Theory] - [InlineData("/daily =broken")] - [InlineData("/daily github_username=")] - public void TryResolve_ShouldPassThroughNullGithubUsername_WhenMissingOrEmpty(string text) - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - ConversationId = "oc_chat_xyz", - Text = text, - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("create_daily_report"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("github_username").ValueKind.Should().Be(JsonValueKind.Null); - body.RootElement.GetProperty("schedule_cron").GetString().Should().Be("0 9 * * *"); - } - - [Fact] - public void TryResolve_ShouldAcceptPositionalSocialMediaTopic() - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - ConversationId = "oc_chat_abc", - Text = "/social-media \"Launch update\" schedule_time=10:30", - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("create_social_media"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("topic").GetString().Should().Be("Launch update"); - body.RootElement.GetProperty("schedule_cron").GetString().Should().Be("30 10 * * *"); - body.RootElement.GetProperty("conversation_id").GetString().Should().Be("oc_chat_abc"); - } - - [Fact] - public void TryResolve_ShouldBuildCreateSocialMediaToolCall_FromTextCommand() - { - var inbound = new ChannelInboundEvent - { - ChatType = "p2p", - Text = "/social-media topic=\"Launch update\" schedule_time=10:30 audience=\"Developers\" style=\"Confident\"", - }; - - var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); - - matched.Should().BeTrue(); - decision.Should().NotBeNull(); - decision!.RequiresToolExecution.Should().BeTrue(); - decision.ToolAction.Should().Be("create_social_media"); - - using var body = JsonDocument.Parse(decision.ToolArgumentsJson!); - body.RootElement.GetProperty("action").GetString().Should().Be("create_agent"); - body.RootElement.GetProperty("template").GetString().Should().Be("social_media"); - body.RootElement.GetProperty("topic").GetString().Should().Be("Launch update"); - body.RootElement.GetProperty("schedule_cron").GetString().Should().Be("30 10 * * *"); - body.RootElement.GetProperty("audience").GetString().Should().Be("Developers"); - } - [Fact] public void FormatToolResult_ShouldRenderListAgents_AsSingleCardWithoutPerAgentButtons() { @@ -171,16 +28,10 @@ public void FormatToolResult_ShouldRenderListAgents_AsSingleCardWithoutPerAgentB "agents": [ { "agent_id": "skill-runner-94d754dfdfbb416aa5a676cecd0d7a71", - "template": "daily_report", + "template": "legacy-template", "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" } ] } @@ -190,13 +41,11 @@ public void FormatToolResult_ShouldRenderListAgents_AsSingleCardWithoutPerAgentB result.Cards.Should().ContainSingle(); var card = result.Cards.Single(); card.BlockId.Should().Be("agents_list"); - card.Title.Should().Be("Your Agents (2)"); + card.Title.Should().Be("Your Agents (1)"); // Body lists every agent with its identifying fields in markdown. - card.Text.Should().Contain("daily_report"); + card.Text.Should().Contain("legacy-template"); 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 "); @@ -207,14 +56,9 @@ public void FormatToolResult_ShouldRenderListAgents_AsSingleCardWithoutPerAgentB 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", - }); + // Footer is now just a single Refresh button — there are no in-tree creation flows; + // recipes for new agents come from Ornn skills (issue #598). + result.Actions.Select(a => a.ActionId).Should().BeEquivalentTo(new[] { "list_agents" }); } [Fact] @@ -224,9 +68,7 @@ public void FormatToolResult_ShouldRenderEmptyListAgentsAsCallToActionCard() var result = NyxRelayAgentBuilderFlow.FormatToolResult(decision, """{"agents":[]}"""); 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"); + result.Actions.Should().Contain(a => a.ActionId == "list_agents"); } [Fact] @@ -243,7 +85,7 @@ public void FormatToolResult_ShouldRenderAgentStatusAsInteractiveCard_WithLifecy """ { "agent_id": "skill-runner-1", - "template": "daily_report", + "template": "daily", "status": "error", "schedule_cron": "0 9 * * *", "schedule_timezone": "UTC", @@ -299,7 +141,6 @@ public void TryResolve_ShouldRequireDeleteConfirmation() } [Theory] - [InlineData("/daily_report alice", "Unknown command: /daily_report")] [InlineData("/foobar", "Unknown command: /foobar")] [InlineData("/", "Unknown command: /")] public void TryResolve_ShouldReturnUnknownCommandUsage_ForUnknownSlash(string text, string expected) @@ -316,7 +157,7 @@ public void TryResolve_ShouldReturnUnknownCommandUsage_ForUnknownSlash(string te decision.Should().NotBeNull(); decision!.RequiresToolExecution.Should().BeFalse(); decision.ReplyPayload.Should().Contain(expected); - decision.ReplyPayload.Should().Contain("/daily [github_username]"); + decision.ReplyPayload.Should().Contain("/agents"); } [Fact] @@ -347,7 +188,7 @@ public void TryResolve_ShouldReturnPrivateChatRestriction_ForKnownCommandInGroup var inbound = new ChannelInboundEvent { ChatType = "group", - Text = "/daily alice", + Text = "/agents", }; var matched = NyxRelayAgentBuilderFlow.TryResolve(inbound, out var decision); @@ -356,7 +197,7 @@ public void TryResolve_ShouldReturnPrivateChatRestriction_ForKnownCommandInGroup decision.Should().NotBeNull(); decision!.RequiresToolExecution.Should().BeFalse(); decision.ReplyPayload.Should().Contain("private chat"); - decision.ReplyPayload.Should().Contain("/daily"); + decision.ReplyPayload.Should().Contain("/agents"); } [Theory] @@ -391,87 +232,6 @@ public void TryResolve_ShouldFallThrough_ForEmptyText() decision.Should().BeNull(); } - [Fact] - public void FormatToolResult_ShouldReturnCardForm_WhenCredentialsRequired() - { - var decision = AgentBuilderFlowDecision.ToolCall("create_daily_report", "{}"); - var toolResultJson = JsonSerializer.Serialize(new - { - status = "credentials_required", - template = "daily_report", - provider_id = "p-github", - note = "Could not resolve github_username. Provide github_username explicitly, save a default preference, or reconnect GitHub in NyxID.", - }); - - var result = NyxRelayAgentBuilderFlow.FormatToolResult(decision, toolResultJson); - - result.Actions.Should().NotBeEmpty(); - result.Actions.Any(action => action.Kind == ActionElementKind.TextInput && action.ActionId == "github_username") - .Should().BeTrue(); - result.Actions.Any(action => action.Kind == ActionElementKind.FormSubmit && action.ActionId == "submit_daily_report") - .Should().BeTrue(); - result.Cards.Should().HaveCount(1); - result.Cards[0].Title.Should().Be("Create Daily Report Agent"); - result.Cards[0].Text.Should().Contain("GitHub credentials required"); - result.Cards[0].Text.Should().Contain("p-github"); - // The auth body lives in the card only — content.Text must stay empty so Lark's form-mode - // composer (LarkMessageComposer.BuildLeadingMarkdown) doesn't double-render the same - // "GitHub credentials required" block once from Text and once from the card body. The - // earlier assertion that Text was non-empty was codifying the bug it has since fixed. - result.Text.Should().BeEmpty(); - } - - [Fact] - public void FormatToolResult_ShouldAckImmediateRun_WithSavedPreference() - { - var decision = AgentBuilderFlowDecision.ToolCall("create_daily_report", "{}"); - var toolResultJson = JsonSerializer.Serialize(new - { - status = "created", - agent_id = "skill-runner-1ba2e9f3", - agent_type = "skill_runner", - template = "daily_report", - github_username = "eanzhao", - github_username_preference_saved = true, - run_immediately_requested = true, - next_scheduled_run = "2026-04-25T09:00:00+00:00", - conversation_id = "oc_default_daily", - }); - - var result = NyxRelayAgentBuilderFlow.FormatToolResult(decision, toolResultJson); - - result.Actions.Should().BeEmpty(); - result.Cards.Should().BeEmpty(); - result.Text.Should().Contain("Daily report scheduled for `eanzhao`"); - result.Text.Should().Contain("Running first report now"); - result.Text.Should().Contain("I'll reply with the results shortly"); - result.Text.Should().Contain("Saved `eanzhao` as your default GitHub username"); - result.Text.Should().Contain("Next scheduled run: 2026-04-25T09:00:00+00:00"); - result.Text.Should().Contain("skill-runner-1ba2e9f3"); - } - - [Fact] - public void FormatToolResult_ShouldNotMentionSavedPreference_WhenSaveNotRequested() - { - var decision = AgentBuilderFlowDecision.ToolCall("create_daily_report", "{}"); - var toolResultJson = JsonSerializer.Serialize(new - { - status = "created", - agent_id = "skill-runner-1", - template = "daily_report", - github_username = "eanzhao", - github_username_preference_saved = false, - run_immediately_requested = true, - next_scheduled_run = "2026-04-25T09:00:00+00:00", - }); - - var result = NyxRelayAgentBuilderFlow.FormatToolResult(decision, toolResultJson); - - result.Text.Should().Contain("Daily report scheduled for `eanzhao`"); - result.Text.Should().Contain("Running first report now"); - result.Text.Should().NotContain("as your default GitHub username"); - } - private sealed class StubSlashHandler(ChannelSlashCommandUsage usage) : IChannelSlashCommandHandler { public string Name => usage.Name; diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs index bc9737d56..b16409cac 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SchedulableStateTests.cs @@ -31,24 +31,4 @@ public void SkillRunnerState_ExposesScheduleStateThroughISchedulable() schedule.ErrorCount.Should().Be(2); } - [Fact] - public void WorkflowAgentState_ExposesScheduleStateThroughISchedulable() - { - var state = new WorkflowAgentState - { - Enabled = false, - ScheduleCron = "0 * * * *", - ScheduleTimezone = "UTC", - ErrorCount = 0, - }; - - var schedule = ((ISchedulable)state).Schedule; - - schedule.Enabled.Should().BeFalse(); - schedule.Cron.Should().Be("0 * * * *"); - schedule.Timezone.Should().Be("UTC"); - schedule.NextRunAt.Should().BeNull(); - schedule.LastRunAt.Should().BeNull(); - schedule.ErrorCount.Should().Be(0); - } } diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs index 879a28829..dbe80a4cb 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerGAgentTests.cs @@ -90,6 +90,44 @@ public async Task HandleInitializeAsync_WhenMaxTokensIsExplicitZero_ShouldPreser _agent.EffectiveConfig.MaxTokens.Should().BeNull(); } + [Fact] + public async Task HandleInitializeAsync_NonFetchTemplate_DoesNotDeriveProxySuccessFromTemplate() + { + // The legacy default applies only to known fetch-and-summarize templates. Skills + // that don't depend on tool data (future pure-LLM transformations) must not be + // falsely failed when they legitimately fan out zero nyxid_proxy calls. + var command = CreateInitializeCommand(); + command.RequiresNyxidProxySuccess = false; + command.TemplateName = "future_pure_llm_template"; + + await _agent.HandleInitializeAsync(command); + + _agent.State.RequiresNyxidProxySuccess.Should().BeFalse(); + } + + [Fact] + public async Task TryCreateStreamingSink_WhenRequiresNyxidProxySuccess_ReturnsNull() + { + // PR #569 review (codex P1 on SkillRunnerGAgent.cs:351): when the run is gated by + // EnsureToolStatusAllowsCompletion, streaming each delta to Lark would post the + // hallucinated text live before the guard ran, then repost it on each retry. + // TryCreateStreamingSink must short-circuit so chunked dispatch (which only fires + // AFTER the guard) is the only path that reaches Lark for fanout-gated runs. + AttachNyxIdApiClient(_agent, new RecordingHandler("""{"code":0,"msg":"success"}""")); + var command = CreateInitializeCommand(); + command.RequiresNyxidProxySuccess = true; + await _agent.HandleInitializeAsync(command); + _agent.State.RequiresNyxidProxySuccess.Should().BeTrue(); + + var method = typeof(SkillRunnerGAgent).GetMethod( + "TryCreateStreamingSink", + BindingFlags.Instance | BindingFlags.NonPublic); + method.Should().NotBeNull(); + var sink = method!.Invoke(_agent, []); + + sink.Should().BeNull(); + } + [Fact] public async Task HandleInitializeAsync_ShouldAwaitUpsertDispatchBeforeFiringExecutionUpdate() { @@ -404,7 +442,6 @@ public async Task SendOutputAsync_ShouldIncludeRecreateHint_When_LarkRejectsAsCr assertion.WithMessage("*before cross-app union_id ingress existed*"); assertion.WithMessage("*/agents*"); assertion.WithMessage("*Delete*"); - assertion.WithMessage("*/daily*"); } [Fact] @@ -475,7 +512,6 @@ public async Task SendOutputAsync_ShouldThrowCrossTenantHint_When_LarkCodeNested assertion.WithMessage("*different tenant*"); assertion.WithMessage("*/agents*"); assertion.WithMessage("*Delete*"); - assertion.WithMessage("*/daily*"); } [Fact] @@ -576,7 +612,6 @@ public async Task SendOutputAsync_ShouldIncludeRecreateHint_When_LarkRejectsAsCr assertion.WithMessage("*chat_id-preferred*"); assertion.WithMessage("*/agents*"); assertion.WithMessage("*Delete*"); - assertion.WithMessage("*/daily*"); } [Fact] @@ -748,7 +783,7 @@ public async Task BuildExecutionMetadata_ShouldPinOwnerLlmConfigOverrides_WhenSo { // Regression for the "/daily failed: Provider 'openai' not connected" report: // skill runners must honor the bot owner's pre-configured model + NyxID route + tool - // cap — same shape ChannelLlmReplyInboxRuntime applies for nyxid-chat. Without it, + // cap — same shape AgentRunGAgent applies for nyxid-chat. Without it, // every scheduled run falls through to NyxIdLLMProvider's compile-time `gpt-5.4` + // gateway default, which the gateway routes to OpenAI and 400s for bot owners who // wired a custom NyxID service like `chrono-llm` at `/api/v1/proxy/s/chrono-llm`. @@ -966,8 +1001,8 @@ private static ServiceProvider BuildServiceProvider( private static InitializeSkillRunnerCommand CreateInitializeCommand() => new() { - SkillName = "daily_report", - TemplateName = "daily_report", + SkillName = "daily", + TemplateName = "daily", SkillContent = "You are a daily report runner.", ExecutionPrompt = "Run the report.", ScheduleCron = string.Empty, diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerToolFailureSafetyNetTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerToolFailureSafetyNetTests.cs index ec3233642..04c73a18a 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerToolFailureSafetyNetTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/SkillRunnerToolFailureSafetyNetTests.cs @@ -249,7 +249,8 @@ public void Policy_AllFailures_Throws() // InvalidOperationException is what HandleTriggerAsync catches and converts into // SkillRunnerExecutionFailedEvent (after the retry budget is exhausted), so // /agent-status reports a meaningful error_count and last_error. - var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion(failureCount: 3, successCount: 0); + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 3, successCount: 0, requiresNyxidProxySuccess: false); act.Should().Throw() .WithMessage("*All 3 nyxid_proxy tool call(s)*failed*"); @@ -261,7 +262,8 @@ public void Policy_MixedSuccessAndFailure_Allows() // (b) mixed case: partial data is more useful than a blanket failure. The // prompt-layer §9 Source health footer surfaces which queries failed; the runner // simply lets the run complete normally. - var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion(failureCount: 2, successCount: 4); + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 2, successCount: 4, requiresNyxidProxySuccess: false); act.Should().NotThrow(); } @@ -272,25 +274,94 @@ public void Policy_GenuinelyEmpty_Allows() // (c) genuine empty-day case: every nyxid_proxy call returned 2xx with no matching // items, so the runner records the LLM's "No measurable activity" output as a // legitimate success. - var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion(failureCount: 0, successCount: 7); + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 0, successCount: 7, requiresNyxidProxySuccess: false); act.Should().NotThrow(); } [Fact] - public void Policy_NoToolCallsAtAll_Allows() + public void Policy_NoToolCallsAtAll_FlagOff_Allows() { // Skills that don't fan out to nyxid_proxy at all (e.g. pure LLM transformations) - // must not be tripped by the safety net. Note: this also lets a pathological run - // through where the LLM ignored all tools and hallucinated a report. The reviewer - // flagged this for the daily-report skill specifically; addressing "expected tool - // never called" is out of scope for this PR — it would need per-skill policy that - // doesn't generalize to other scheduled skills. - var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion(failureCount: 0, successCount: 0); + // leave RequiresNyxidProxySuccess false and pass through. The flag-on case below + // covers the daily path that was flagged in PR #471 review as the remaining + // hallucinated-report failure mode. + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 0, successCount: 0, requiresNyxidProxySuccess: false); act.Should().NotThrow(); } + [Fact] + public void Policy_NoToolCallsAtAll_FlagOn_Throws() + { + // Closes the gap left by the original safety net (PR #471 review): when a + // fetch-and-summarize skill like daily completes with zero successful + // nyxid_proxy calls, the LLM produced text from prior context — the original + // #439 symptom (52 commits in 24h reported as "No meaningful public GitHub + // activity") with no tool errors to count. + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 0, successCount: 0, requiresNyxidProxySuccess: true); + + act.Should().Throw() + .WithMessage("*requires at least one successful nyxid_proxy tool call*"); + } + + [Fact] + public void Policy_MixedSuccessAndFailure_FlagOn_Allows() + { + // Flag is only consulted when successCount == 0. Any successful nyxid_proxy call + // means the LLM did fetch real source data, so partial-data behavior matches the + // flag-off mixed case (delegated to prompt §9). + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 2, successCount: 4, requiresNyxidProxySuccess: true); + + act.Should().NotThrow(); + } + + [Fact] + public void Policy_GenuinelyEmpty_FlagOn_Allows() + { + // Genuine empty-day stays a success regardless of the flag — every nyxid_proxy + // call returned 2xx with no matching items, the LLM did fetch source data, and + // "No measurable activity" is the correct prompt fallback. + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 0, successCount: 7, requiresNyxidProxySuccess: true); + + act.Should().NotThrow(); + } + + // ─── Legacy actor default ─── + + [Theory] + [InlineData("daily")] + [InlineData("future_pure_llm")] + [InlineData("")] + [InlineData(null)] + public void RequiresProxySuccessByTemplate_AlwaysReturnsFalse(string? templateName) + { + // Issue #598: with /daily migrated to Ornn, no template name carries an auto-opt-in + // semantic anymore. Skills now own their own success criteria; the legacy + // template-name-derived default is reserved for future templates and currently + // returns false for every input. + SkillRunnerGAgent.RequiresProxySuccessByTemplate(templateName).Should().BeFalse(); + } + + [Fact] + public void Policy_AllFailures_FlagOn_AllFailMessageWins() + { + // When both the all-fail and never-called branches would fire (failureCount > 0, + // successCount == 0, flag = true), the all-fail message is more actionable — it + // names the count of failed tool calls so the operator knows where to look. Pin + // that ordering so a future refactor doesn't accidentally swap them. + var act = () => SkillRunnerGAgent.EnsureToolStatusAllowsCompletion( + failureCount: 3, successCount: 0, requiresNyxidProxySuccess: true); + + act.Should().Throw() + .WithMessage("*All 3 nyxid_proxy tool call(s)*failed*"); + } + // ─── End-to-end wiring ─── [Fact] diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/TurnStreamingReplySinkTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/TurnStreamingReplySinkTests.cs index 954993ac2..7d609bc4d 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/TurnStreamingReplySinkTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/TurnStreamingReplySinkTests.cs @@ -10,6 +10,81 @@ namespace Aevatar.GAgents.ChannelRuntime.Tests; public sealed class TurnStreamingReplySinkTests { + [Fact] + public async Task OnDeltaAsync_BeyondInterimCap_StashesButDoesNotDispatchUntilFinal() + { + // Lark caps message edits per om_id (~20 in mainnet, code 230072). Capping interim + // dispatches in the sink keeps headroom so FinalizeAsync's edit always lands — + // long replies freeze on the last interim until the final, which is preferable to + // truncation. This test pins the contract: interim chunks past the cap stash but + // do not dispatch; the final still goes through with the freshest accumulated text. + var (dispatchPort, envelopes) = BuildRecordingDispatchPort(); + var sink = CreateSink(dispatchPort, throttleMs: 0, out _, maxInterimChunks: 2); + + await sink.OnDeltaAsync("chunk 1", CancellationToken.None); + await sink.OnDeltaAsync("chunk 1 + 2", CancellationToken.None); + await sink.OnDeltaAsync("chunk 1 + 2 + 3 (capped, stashed)", CancellationToken.None); + await sink.OnDeltaAsync("chunk 1 + 2 + 4 (still capped)", CancellationToken.None); + + envelopes.Should().HaveCount(2, "interim chunks past the cap must stash, not dispatch"); + envelopes[0].Payload.Unpack().AccumulatedText.Should().Be("chunk 1"); + envelopes[1].Payload.Unpack().AccumulatedText.Should().Be("chunk 1 + 2"); + sink.ChunksEmitted.Should().Be(2); + + await sink.FinalizeAsync("complete final text after cap", CancellationToken.None); + + envelopes.Should().HaveCount(3, "FinalizeAsync must bypass the cap so the user sees the complete text"); + envelopes[2].Payload.Unpack().AccumulatedText + .Should().Be("complete final text after cap"); + } + + [Fact] + public async Task DispatchLoop_StashesDuringDispatch_DefersToTimerInsteadOfDrainingImmediately() + { + // Regression: previously the dispatch loop drained _pendingText at dispatch-rate + // without re-checking _throttle, so streaming token bursts produced one Lark edit + // per token and exhausted the per-message edit cap (~20 in mainnet, code 230072). + // Pin the gate: when more deltas are stashed during a dispatch and the throttle + // window has not elapsed by the time the dispatch completes, the loop must hand + // off to the deferred flush timer instead of dispatching immediately. + var dispatchPort = Substitute.For(); + var envelopes = new List(); + var slowDispatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatchCount = 0; + dispatchPort.DispatchAsync("target-actor", Arg.Any(), Arg.Any()) + .Returns(call => + { + envelopes.Add(call.Arg()); + return Interlocked.Increment(ref dispatchCount) == 1 ? slowDispatch.Task : Task.CompletedTask; + }); + + var sink = CreateSink(dispatchPort, throttleMs: 750, out var time); + + // First delta starts dispatch but is awaiting slowDispatch. + var firstFlush = sink.OnDeltaAsync("chunk 1", CancellationToken.None); + + // Burst additional deltas while dispatch1 is in flight — they stash. + await sink.OnDeltaAsync("chunk 1 + 2", CancellationToken.None); + await sink.OnDeltaAsync("chunk 1 + 2 + 3 (latest)", CancellationToken.None); + + // Release dispatch1. The loop reaches its post-dispatch check, sees pending text, + // observes the throttle window has not elapsed, and exits to arm the timer rather + // than dispatching immediately. Without this gate, all three chunks dispatch in + // rapid succession (the original bug). + slowDispatch.SetResult(true); + await firstFlush; + + envelopes.Should().ContainSingle("loop must defer when throttle window has not elapsed"); + + // Advance across the throttle. The timer fires synchronously inside Advance and + // re-enters DispatchLoopAsync to drain the freshest stashed text. + time.Advance(TimeSpan.FromMilliseconds(800)); + + envelopes.Should().HaveCount(2); + envelopes[1].Payload.Unpack().AccumulatedText + .Should().Be("chunk 1 + 2 + 3 (latest)"); + } + [Fact] public async Task OnDeltaAsync_FirstDelta_DispatchesChunkEventToActor() { @@ -116,7 +191,7 @@ public async Task FinalizeAsync_CancelsPendingFlushTimer() public async Task FinalizeAsync_DispatchInFlight_WaitsForFinalChunkOnWire() { // Regression for the race where FinalizeAsync would return as soon as the final text - // was stashed (while a prior dispatch was still in flight), letting the inbox runtime + // was stashed (while a prior dispatch was still in flight), letting the run actor // send LlmReplyReadyEvent past the late final chunk and triggering the // ConversationGAgent processed-command guard to drop it. var firstDispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -248,7 +323,8 @@ public async Task Dispose_AfterFinalize_IsIdempotent() private static TurnStreamingReplySink CreateSink( IActorDispatchPort dispatchPort, int throttleMs, - out FakeTimeProvider timeProvider) + out FakeTimeProvider timeProvider, + int maxInterimChunks = int.MaxValue) { timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 24, 9, 0, 0, TimeSpan.Zero)); return new TurnStreamingReplySink( @@ -276,7 +352,8 @@ private static TurnStreamingReplySink CreateSink( }, throttle: TimeSpan.FromMilliseconds(throttleMs), timeProvider, - NullLogger.Instance); + NullLogger.Instance, + maxInterimChunks: maxInterimChunks); } private static (IActorDispatchPort dispatchPort, List envelopes) BuildRecordingDispatchPort() diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs index 2a92de539..7643e45d8 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UnifyCallerScopeAcceptanceTests.cs @@ -462,7 +462,7 @@ public async Task LarkCallerIntegration_UpsertActorThenQueryPort_ReturnsAgentFor AgentId = "alice-agent", ConversationId = "oc_chat_alice", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", Status = "running", OwnerScope = aliceScope, }, @@ -471,7 +471,7 @@ public async Task LarkCallerIntegration_UpsertActorThenQueryPort_ReturnsAgentFor AgentId = "bob-agent", ConversationId = "oc_chat_bob", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", Status = "running", OwnerScope = bobScope, }, @@ -507,7 +507,7 @@ private static UserAgentCatalogDocument BuildDocument(string agentId, OwnerScope ConversationId = $"conv-{agentId}", NyxProviderSlug = "api-lark-bot", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", ScopeId = scope.RegistrationScopeId, Status = "running", StateVersion = 1, @@ -524,7 +524,7 @@ private static UserAgentCatalogDocument BuildLegacyNyxidDocument(string agentId, ConversationId = $"conv-{agentId}", NyxProviderSlug = "api-lark-bot", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", Platform = "nyxid", OwnerNyxUserId = nyxUserId, ScopeId = string.Empty, @@ -542,7 +542,7 @@ private static UserAgentCatalogDocument BuildLegacyLarkDocument(string agentId, ConversationId = $"conv-{agentId}", NyxProviderSlug = "api-lark-bot", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", Platform = "lark", OwnerNyxUserId = nyxUserId, ScopeId = "legacy-bot-scope", diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs index 43ea6d745..81f5e1b12 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogCompatibilityTests.cs @@ -28,7 +28,7 @@ public void TryUnpackState_ShouldAcceptLegacyStateTypeUrl() { AgentId = "agent-compat-1", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", }, }, }; @@ -71,8 +71,8 @@ public void StateTransitionMatcher_ShouldAcceptLegacyEventTypeUrl() Entry = new UserAgentCatalogEntry { AgentId = "agent-compat-2", - AgentType = WorkflowAgentDefaults.AgentType, - TemplateName = WorkflowAgentDefaults.TemplateName, + AgentType = SkillRunnerDefaults.AgentType, + TemplateName = "legacy-template", }, }); @@ -89,7 +89,7 @@ public void StateTransitionMatcher_ShouldAcceptLegacyEventTypeUrl() next.Entries.Should().ContainSingle(x => x.AgentId == "agent-compat-2" && - x.TemplateName == WorkflowAgentDefaults.TemplateName); + x.TemplateName == "legacy-template"); } [Fact] @@ -107,7 +107,7 @@ public async Task HandleEventAsync_ShouldDispatchLegacyTypeUrl_ToRenamedEventHan { AgentId = "agent-compat-3", AgentType = SkillRunnerDefaults.AgentType, - TemplateName = "daily_report", + TemplateName = "daily", Status = "running", }, }), @@ -122,7 +122,7 @@ public async Task HandleEventAsync_ShouldDispatchLegacyTypeUrl_ToRenamedEventHan agent.LastHandled.Entry.Status.Should().Be("running"); agent.State.Entries.Should().ContainSingle(x => x.AgentId == "agent-compat-3" && - x.TemplateName == "daily_report"); + x.TemplateName == "daily"); } private static Any CreateLegacyAny(string typeUrl, Google.Protobuf.IMessage message) => diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs index a5bf61722..a8e8a7d20 100644 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs +++ b/test/Aevatar.GAgents.ChannelRuntime.Tests/UserAgentCatalogProjectorTests.cs @@ -44,7 +44,7 @@ public async Task ProjectAsync_WithValidCommittedEvent_UpsertsDocument() NyxApiKey = "nyx-key-1", OwnerNyxUserId = "user-1", AgentType = "skill_runner", - TemplateName = "daily_report", + TemplateName = "daily", ScopeId = "scope-1", ApiKeyId = "key-1", ScheduleCron = "0 9 * * *", @@ -73,7 +73,7 @@ public async Task ProjectAsync_WithValidCommittedEvent_UpsertsDocument() document.NyxProviderSlug.Should().Be("api-lark-bot"); document.OwnerNyxUserId.Should().Be("user-1"); document.AgentType.Should().Be("skill_runner"); - document.TemplateName.Should().Be("daily_report"); + document.TemplateName.Should().Be("daily"); document.ScopeId.Should().Be("scope-1"); document.ApiKeyId.Should().Be("key-1"); document.ScheduleCron.Should().Be("0 9 * * *"); diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs deleted file mode 100644 index adf94d232..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentCommandPortTests.cs +++ /dev/null @@ -1,236 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Abstractions; -using Aevatar.Foundation.Abstractions; -using FluentAssertions; -using NSubstitute; -using Xunit; -using Aevatar.GAgents.Scheduled; - -namespace Aevatar.GAgents.ChannelRuntime.Tests; - -public sealed class WorkflowAgentCommandPortTests -{ - private const string AgentId = "workflow-agent-test-1"; - private const string ExpectedPublisher = "scheduled.workflow-agent"; - - [Fact] - public async Task InitializeAsync_WhenRunImmediatelyFalse_DispatchesSingleEnvelope_AndCreatesActor_AndPrimesProjection() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(null)); - fixture.Runtime.CreateAsync(AgentId, Arg.Any()) - .Returns(Task.FromResult(Substitute.For())); - - var command = new InitializeWorkflowAgentCommand - { - WorkflowId = "wf-1", - WorkflowName = "demo", - ExecutionPrompt = "do the thing", - ScheduleCron = "0 */1 * * *", - }; - - await fixture.Port.InitializeAsync(AgentId, command, runImmediately: false, CancellationToken.None); - - await fixture.Runtime.Received(1).GetAsync(AgentId); - await fixture.Runtime.Received(1).CreateAsync(AgentId, Arg.Any()); - await fixture.Activation.Received(1).EnsureAsync( - Arg.Is(r => - r.RootActorId == UserAgentCatalogGAgent.WellKnownId && - r.ProjectionKind == UserAgentCatalogProjectionPort.ProjectionKind), - Arg.Any()); - - fixture.Captured.Should().HaveCount(1); - var envelope = fixture.Captured[0]; - envelope.Payload.Is(InitializeWorkflowAgentCommand.Descriptor).Should().BeTrue(); - envelope.Route.PublisherActorId.Should().Be(ExpectedPublisher); - envelope.Route.Direct.TargetActorId.Should().Be(AgentId); - } - - [Fact] - public async Task InitializeAsync_WhenRunImmediatelyTrue_DispatchesInitializeThenTrigger_WithCreateAgentReason() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); - - var command = new InitializeWorkflowAgentCommand { WorkflowId = "wf-1" }; - await fixture.Port.InitializeAsync(AgentId, command, runImmediately: true, CancellationToken.None); - - fixture.Captured.Should().HaveCount(2); - fixture.Captured[0].Payload.Is(InitializeWorkflowAgentCommand.Descriptor).Should().BeTrue(); - fixture.Captured[1].Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor).Should().BeTrue(); - var trigger = fixture.Captured[1].Payload.Unpack(); - trigger.Reason.Should().Be("create_agent"); - trigger.RevisionFeedback.Should().Be(string.Empty); - fixture.Captured[1].Route.PublisherActorId.Should().Be(ExpectedPublisher); - fixture.Captured[1].Route.Direct.TargetActorId.Should().Be(AgentId); - - await fixture.Runtime.DidNotReceive().CreateAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task TriggerAsync_DispatchesTriggerCommand_WithReasonAndRevisionFeedback() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); - - await fixture.Port.TriggerAsync(AgentId, "operator_run", "tighten the prompt", CancellationToken.None); - - fixture.Captured.Should().ContainSingle(); - var env = fixture.Captured[0]; - env.Payload.Is(TriggerWorkflowAgentExecutionCommand.Descriptor).Should().BeTrue(); - var trigger = env.Payload.Unpack(); - trigger.Reason.Should().Be("operator_run"); - trigger.RevisionFeedback.Should().Be("tighten the prompt"); - env.Route.PublisherActorId.Should().Be(ExpectedPublisher); - env.Route.Direct.TargetActorId.Should().Be(AgentId); - } - - [Fact] - public async Task TriggerAsync_WithNullArguments_NormalizesToEmptyString() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); - - await fixture.Port.TriggerAsync(AgentId, null!, null, CancellationToken.None); - - fixture.Captured.Should().ContainSingle(); - var trigger = fixture.Captured[0].Payload.Unpack(); - trigger.Reason.Should().Be(string.Empty); - trigger.RevisionFeedback.Should().Be(string.Empty); - } - - [Fact] - public async Task DisableAsync_DispatchesDisableCommandWithReason() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); - - await fixture.Port.DisableAsync(AgentId, "operator_off", CancellationToken.None); - - fixture.Captured.Should().ContainSingle(); - var env = fixture.Captured[0]; - env.Payload.Is(DisableWorkflowAgentCommand.Descriptor).Should().BeTrue(); - env.Payload.Unpack().Reason.Should().Be("operator_off"); - env.Route.PublisherActorId.Should().Be(ExpectedPublisher); - env.Route.Direct.TargetActorId.Should().Be(AgentId); - } - - [Fact] - public async Task EnableAsync_DispatchesEnableCommandWithReason() - { - var fixture = new Fixture(); - fixture.Runtime.GetAsync(AgentId).Returns(Task.FromResult(Substitute.For())); - - await fixture.Port.EnableAsync(AgentId, "operator_on", CancellationToken.None); - - fixture.Captured.Should().ContainSingle(); - var env = fixture.Captured[0]; - env.Payload.Is(EnableWorkflowAgentCommand.Descriptor).Should().BeTrue(); - env.Payload.Unpack().Reason.Should().Be("operator_on"); - env.Route.PublisherActorId.Should().Be(ExpectedPublisher); - env.Route.Direct.TargetActorId.Should().Be(AgentId); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task InitializeAsync_WithInvalidAgentId_Throws(string? agentId) - { - var fixture = new Fixture(); - var command = new InitializeWorkflowAgentCommand(); - var act = () => fixture.Port.InitializeAsync(agentId!, command, runImmediately: false, CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task InitializeAsync_WithNullCommand_Throws() - { - var fixture = new Fixture(); - var act = () => fixture.Port.InitializeAsync(AgentId, null!, runImmediately: false, CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task TriggerAsync_WithInvalidAgentId_Throws(string? agentId) - { - var fixture = new Fixture(); - var act = () => fixture.Port.TriggerAsync(agentId!, "reason", null, CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task DisableAsync_WithInvalidAgentId_Throws(string? agentId) - { - var fixture = new Fixture(); - var act = () => fixture.Port.DisableAsync(agentId!, "reason", CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task EnableAsync_WithInvalidAgentId_Throws(string? agentId) - { - var fixture = new Fixture(); - var act = () => fixture.Port.EnableAsync(agentId!, "reason", CancellationToken.None); - await act.Should().ThrowAsync(); - } - - [Fact] - public void Constructor_NullDependencies_Throws() - { - var dispatch = Substitute.For(); - var runtime = Substitute.For(); - var projection = Fixture.CreateProjectionPort(out _); - - Action ctor1 = () => new WorkflowAgentCommandPort(null!, dispatch, projection); - Action ctor2 = () => new WorkflowAgentCommandPort(runtime, null!, projection); - Action ctor3 = () => new WorkflowAgentCommandPort(runtime, dispatch, null!); - ctor1.Should().Throw(); - ctor2.Should().Throw(); - ctor3.Should().Throw(); - } - - private sealed class Fixture - { - public IActorRuntime Runtime { get; } - public IActorDispatchPort Dispatch { get; } - public UserAgentCatalogProjectionPort Projection { get; } - public IProjectionScopeActivationService Activation { get; } - public List Captured { get; } = new(); - public WorkflowAgentCommandPort Port { get; } - - public Fixture() - { - Runtime = Substitute.For(); - Dispatch = Substitute.For(); - Projection = CreateProjectionPort(out var activation); - Activation = activation; - Dispatch.DispatchAsync(Arg.Any(), Arg.Do(env => Captured.Add(env)), Arg.Any()) - .Returns(Task.CompletedTask); - Port = new WorkflowAgentCommandPort(Runtime, Dispatch, Projection); - } - - public static UserAgentCatalogProjectionPort CreateProjectionPort( - out IProjectionScopeActivationService activation) - { - activation = Substitute.For>(); - var lease = new UserAgentCatalogMaterializationRuntimeLease( - new UserAgentCatalogMaterializationContext - { - RootActorId = UserAgentCatalogGAgent.WellKnownId, - ProjectionKind = UserAgentCatalogProjectionPort.ProjectionKind, - }); - activation.EnsureAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(lease)); - return new UserAgentCatalogProjectionPort(activation); - } - } -} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs deleted file mode 100644 index c01e8de0f..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowAgentGAgentTests.cs +++ /dev/null @@ -1,466 +0,0 @@ -using System.Reflection; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.CQRS.Core.Abstractions.Commands; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Persistence; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Aevatar.Workflow.Application.Abstractions.Runs; -using FluentAssertions; -using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using Xunit; -using Aevatar.GAgents.Channel.Runtime; -using Aevatar.GAgents.Scheduled; - -namespace Aevatar.GAgents.ChannelRuntime.Tests; - -public sealed class WorkflowAgentGAgentTests : IAsyncLifetime -{ - private WorkflowAgentGAgent _agent = null!; - private CapturingWorkflowDispatchService _dispatchService = null!; - private ServiceProvider _serviceProvider = null!; - - public async Task InitializeAsync() - { - _dispatchService = new CapturingWorkflowDispatchService(); - _serviceProvider = BuildServiceProvider(_dispatchService); - _agent = new WorkflowAgentGAgent - { - Services = _serviceProvider, - EventSourcingBehaviorFactory = - _serviceProvider.GetRequiredService>(), - }; - - await _agent.ActivateAsync(); - } - - public async Task DisposeAsync() - { - _serviceProvider.Dispose(); - await Task.CompletedTask; - } - - [Fact] - public async Task HandleTriggerAsync_ShouldIncludeRevisionFeedbackInWorkflowPrompt() - { - await _agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand - { - WorkflowId = "social-media-agent-1", - WorkflowName = "social_media_agent_1", - WorkflowActorId = "workflow-actor-1", - ExecutionPrompt = "Generate the scheduled social media draft for review.", - ConversationId = "oc_chat_1", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "nyx-api-key-1", - Enabled = true, - ScopeId = "scope-1", - }); - - await _agent.HandleTriggerAsync(new TriggerWorkflowAgentExecutionCommand - { - Reason = "run_agent", - RevisionFeedback = "Need a stronger hook and clearer CTA.", - }); - - _dispatchService.LastCommand.Should().NotBeNull(); - _dispatchService.LastCommand!.Prompt.Should().Contain("Trigger reason: run_agent"); - _dispatchService.LastCommand.Prompt.Should().Contain("Revision feedback: Need a stronger hook and clearer CTA."); - _dispatchService.LastCommand.Metadata.Should().Contain(new KeyValuePair(ChannelMetadataKeys.ConversationId, "oc_chat_1")); - _dispatchService.LastCommand.Metadata.Should().Contain(new KeyValuePair("scope_id", "scope-1")); - } - - [Fact] - public async Task HandleInitializeAsync_ShouldAwaitUpsertDispatchBeforeFiringExecutionUpdate() - { - // Issue #440 regression — symmetric with SkillRunnerGAgentTests' - // HandleInitializeAsync_ShouldAwaitUpsertDispatchBeforeFiringExecutionUpdate. - // WorkflowAgent's UpsertRegistryAsync follows the same await-then-await pattern - // against the catalog and is vulnerable to the same race if dispatch ever - // regresses to fire-and-forget. Gate the Upsert dispatch on a - // TaskCompletionSource and assert ExecutionUpdate is not even dispatched until - // the gate releases. - - var upsertGate = new TaskCompletionSource(); - var upsertDispatchStarted = new TaskCompletionSource(); - var executionDispatchStarted = new TaskCompletionSource(); - - var scheduler = Substitute.For(); - scheduler - .ScheduleTimeoutAsync( - Arg.Any(), - Arg.Any()) - .Returns(call => - { - var req = call.Arg(); - return Task.FromResult(new Foundation.Abstractions.Runtime.Callbacks.RuntimeCallbackLease( - req.ActorId, req.CallbackId, 1L, - Foundation.Abstractions.Runtime.Callbacks.RuntimeCallbackBackend.InMemory)); - }); - scheduler.CancelAsync( - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); - - var catalogProxy = Substitute.For(); - var runtime = Substitute.For(); - runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - .Returns(Task.FromResult(catalogProxy)); - - UserAgentCatalogGAgent? catalog = null; - var dispatch = Substitute.For(); - dispatch.DispatchAsync( - UserAgentCatalogGAgent.WellKnownId, - Arg.Any(), - Arg.Any()) - .Returns(call => DispatchGated( - call.Arg(), - call.Arg(), - catalog!, - upsertGate, - upsertDispatchStarted, - executionDispatchStarted)); - - using var provider = BuildServiceProvider( - new CapturingWorkflowDispatchService(), - services => - { - services.AddSingleton(runtime); - services.AddSingleton(dispatch); - services.AddSingleton(scheduler); - }); - - catalog = new UserAgentCatalogGAgent - { - Services = provider, - EventSourcingBehaviorFactory = - provider.GetRequiredService>(), - }; - AssignActorId(catalog, UserAgentCatalogGAgent.WellKnownId); - await catalog.ActivateAsync(); - - var agent = new WorkflowAgentGAgent - { - Services = provider, - EventSourcingBehaviorFactory = - provider.GetRequiredService>(), - }; - AssignActorId(agent, "workflow-agent-440-regression"); - await agent.ActivateAsync(); - - var initTask = agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand - { - WorkflowId = "social-media-agent-1", - WorkflowName = "social_media_agent_1", - WorkflowActorId = "workflow-actor-1", - ExecutionPrompt = "Generate the scheduled social media draft for review.", - ScheduleCron = "0 9 * * *", - ScheduleTimezone = "UTC", - ConversationId = "oc_chat_1", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "nyx-api-key-1", - Enabled = true, - ScopeId = "scope-1", - }); - - await upsertDispatchStarted.Task; - - executionDispatchStarted.Task.IsCompleted.Should().BeFalse( - "the WorkflowAgent must await Upsert's dispatch task before firing ExecutionUpdate; " - + "regressing to fire-and-forget would let ExecutionUpdate race ahead of Upsert " - + "and be dropped by the missing-entry guard in HandleExecutionUpdateAsync"); - - upsertGate.SetResult(); - await initTask; - - executionDispatchStarted.Task.IsCompleted.Should().BeTrue( - "ExecutionUpdate must dispatch after Upsert completes so /agent-status shows Next run"); - catalog.State.Entries.Should().ContainSingle(); - var entry = catalog.State.Entries[0]; - entry.AgentId.Should().Be("workflow-agent-440-regression"); - entry.Status.Should().Be(WorkflowAgentDefaults.StatusRunning); - entry.ScheduleCron.Should().Be("0 9 * * *"); - entry.NextRunAt.Should().NotBeNull( - "init's post-Upsert ExecutionUpdate must land at the catalog so /agent-status shows Next run"); - } - - private static async Task DispatchGated( - EventEnvelope envelope, - CancellationToken ct, - UserAgentCatalogGAgent catalog, - TaskCompletionSource upsertGate, - TaskCompletionSource upsertDispatchStarted, - TaskCompletionSource executionDispatchStarted) - { - if (envelope.Payload.Is(UserAgentCatalogUpsertCommand.Descriptor)) - { - upsertDispatchStarted.TrySetResult(); - await upsertGate.Task; - } - else if (envelope.Payload.Is(UserAgentCatalogExecutionUpdateCommand.Descriptor)) - { - executionDispatchStarted.TrySetResult(); - } - - await catalog.HandleEventAsync(envelope, ct); - } - - [Fact] - public async Task HandleInitializeAsync_ShouldDispatchCatalogCommandsThroughDispatchPort() - { - var catalogActor = Substitute.For(); - var runtime = Substitute.For(); - runtime.GetAsync(UserAgentCatalogGAgent.WellKnownId) - .Returns(Task.FromResult(catalogActor)); - - var dispatch = Substitute.For(); - var captured = new List(); - dispatch.DispatchAsync( - UserAgentCatalogGAgent.WellKnownId, - Arg.Do(captured.Add), - Arg.Any()) - .Returns(Task.CompletedTask); - - using var provider = BuildServiceProvider( - new CapturingWorkflowDispatchService(), - services => - { - services.AddSingleton(runtime); - services.AddSingleton(dispatch); - }); - var agent = new WorkflowAgentGAgent - { - Services = provider, - EventSourcingBehaviorFactory = - provider.GetRequiredService>(), - }; - AssignActorId(agent, "workflow-agent-dispatch-test"); - await agent.ActivateAsync(); - - await agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand - { - WorkflowId = "social-media-agent-1", - WorkflowName = "social_media_agent_1", - WorkflowActorId = "workflow-actor-1", - ExecutionPrompt = "Generate the scheduled social media draft for review.", - ConversationId = "oc_chat_1", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "nyx-api-key-1", - Enabled = true, - ScopeId = "scope-1", - }); - - captured.Should().HaveCount(2); - captured[0].Payload.Is(UserAgentCatalogUpsertCommand.Descriptor).Should().BeTrue(); - captured[1].Payload.Is(UserAgentCatalogExecutionUpdateCommand.Descriptor).Should().BeTrue(); - captured.Should().OnlyContain(envelope => - envelope.Route.PublisherActorId == "workflow-agent-dispatch-test" && - envelope.Route.Direct.TargetActorId == UserAgentCatalogGAgent.WellKnownId); - await catalogActor.DidNotReceive() - .HandleEventAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task HandleTriggerAsync_ShouldPinOwnerLlmConfigOverridesOnDispatchedMetadata() - { - // Symmetric with SkillRunnerGAgentTests' - // BuildExecutionMetadata_ShouldPinOwnerLlmConfigOverrides_WhenSourceReturnsConfig: - // workflow-backed agents (e.g. social_media) honor the bot owner's pre-configured - // model + NyxID route + tool cap exactly the same way. Without this, the workflow's - // LLM steps fall through to NyxIdLLMProvider's compile-time `gpt-5.4` + gateway - // default and 400 when the bot owner pre-configured `chrono-llm` instead of OpenAI. - var source = new SkillRunnerGAgentTests.StubOwnerLlmConfigSource(new OwnerLlmConfig( - DefaultModel: "gpt-5.5", - PreferredLlmRoute: "/api/v1/proxy/s/chrono-llm", - MaxToolRounds: 7)); - - var dispatchService = new CapturingWorkflowDispatchService(); - using var provider = BuildServiceProvider(dispatchService); - var agent = new WorkflowAgentGAgent(ownerLlmConfigSource: source) - { - Services = provider, - EventSourcingBehaviorFactory = - provider.GetRequiredService>(), - }; - AssignActorId(agent, "workflow-agent-userconfig"); - await agent.ActivateAsync(); - - await agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand - { - WorkflowId = "social-media-agent-1", - WorkflowName = "social_media_agent_1", - WorkflowActorId = "workflow-actor-1", - ExecutionPrompt = "Generate the scheduled social media draft for review.", - ConversationId = "oc_chat_1", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "nyx-api-key-1", - Enabled = true, - ScopeId = "scope-1", - }); - - await agent.HandleTriggerAsync(new TriggerWorkflowAgentExecutionCommand { Reason = "schedule" }); - - dispatchService.LastCommand.Should().NotBeNull(); - var metadata = dispatchService.LastCommand!.Metadata; - metadata.Should().NotBeNull(); - metadata![Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.ModelOverride] - .Should().Be("gpt-5.5"); - metadata[Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.NyxIdRoutePreference] - .Should().Be("/api/v1/proxy/s/chrono-llm"); - metadata[Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.MaxToolRoundsOverride] - .Should().Be("7"); - source.RequestedScopeIds.Should().ContainSingle().Which.Should().Be("scope-1"); - } - - [Fact] - public async Task HandleTriggerAsync_ShouldOmitOwnerLlmOverrides_WhenSourceIsAbsent() - { - // Hosts that don't wire IOwnerLlmConfigSource (e.g. the existing test suite, or a - // host without Studio.Application composed in) must still produce a valid dispatched - // metadata bag with no override keys leaking — provider defaults take over. - var dispatchService = new CapturingWorkflowDispatchService(); - using var provider = BuildServiceProvider(dispatchService); - var agent = new WorkflowAgentGAgent - { - Services = provider, - EventSourcingBehaviorFactory = - provider.GetRequiredService>(), - }; - AssignActorId(agent, "workflow-agent-no-source"); - await agent.ActivateAsync(); - - await agent.HandleInitializeAsync(new InitializeWorkflowAgentCommand - { - WorkflowId = "social-media-agent-2", - WorkflowName = "social_media_agent_2", - WorkflowActorId = "workflow-actor-2", - ExecutionPrompt = "Generate the scheduled social media draft for review.", - ConversationId = "oc_chat_2", - NyxProviderSlug = "api-lark-bot", - NyxApiKey = "nyx-api-key-2", - Enabled = true, - ScopeId = "scope-2", - }); - - await agent.HandleTriggerAsync(new TriggerWorkflowAgentExecutionCommand { Reason = "schedule" }); - - dispatchService.LastCommand.Should().NotBeNull(); - var metadata = dispatchService.LastCommand!.Metadata; - metadata.Should().NotBeNull(); - metadata!.Should().NotContainKey(Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.ModelOverride); - metadata.Should().NotContainKey(Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.NyxIdRoutePreference); - metadata.Should().NotContainKey(Aevatar.AI.Abstractions.LLMProviders.LLMRequestMetadataKeys.MaxToolRoundsOverride); - } - - private sealed class CapturingWorkflowDispatchService - : ICommandDispatchService - { - public WorkflowChatRunRequest? LastCommand { get; private set; } - - public Task> DispatchAsync( - WorkflowChatRunRequest command, - CancellationToken ct = default) - { - LastCommand = command; - return Task.FromResult(CommandDispatchResult.Success( - new WorkflowChatRunAcceptedReceipt( - ActorId: "workflow-run-actor-1", - WorkflowName: command.WorkflowName ?? "unknown", - CommandId: "cmd-1", - CorrelationId: "corr-1"))); - } - } - - private static ServiceProvider BuildServiceProvider( - CapturingWorkflowDispatchService dispatchService, - Action? configure = null) - { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient( - typeof(IEventSourcingBehaviorFactory<>), - typeof(DefaultEventSourcingBehaviorFactory<>)); - services.AddSingleton>(dispatchService); - configure?.Invoke(services); - return services.BuildServiceProvider(); - } - - private static void AssignActorId(GAgentBase agent, string actorId) - { - var setIdMethod = typeof(GAgentBase).GetMethod( - "SetId", - BindingFlags.Instance | BindingFlags.NonPublic); - setIdMethod.Should().NotBeNull(); - setIdMethod!.Invoke(agent, [actorId]); - } - - private sealed class InMemoryEventStore : IEventStore - { - private readonly Dictionary> _events = new(StringComparer.Ordinal); - - public Task AppendAsync( - string agentId, - IEnumerable events, - long expectedVersion, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream)) - { - stream = []; - _events[agentId] = stream; - } - - var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; - if (currentVersion != expectedVersion) - throw new InvalidOperationException( - $"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); - - var appended = events.Select(x => x.Clone()).ToList(); - stream.AddRange(appended); - var latest = stream.Count == 0 ? 0 : stream[^1].Version; - return Task.FromResult(new EventStoreCommitResult - { - AgentId = agentId, - LatestVersion = latest, - CommittedEvents = { appended.Select(x => x.Clone()) }, - }); - } - - public Task> GetEventsAsync( - string agentId, - long? fromVersion = null, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream)) - return Task.FromResult>([]); - - IReadOnlyList result = fromVersion.HasValue - ? stream.Where(x => x.Version > fromVersion.Value).Select(x => x.Clone()).ToList() - : stream.Select(x => x.Clone()).ToList(); - return Task.FromResult(result); - } - - public Task GetVersionAsync(string agentId, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream) || stream.Count == 0) - return Task.FromResult(0L); - return Task.FromResult(stream[^1].Version); - } - - public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (toVersion <= 0 || !_events.TryGetValue(agentId, out var stream)) - return Task.FromResult(0L); - - var before = stream.Count; - stream.RemoveAll(x => x.Version <= toVersion); - return Task.FromResult((long)(before - stream.Count)); - } - } -} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishModuleHandleAsyncTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishModuleHandleAsyncTests.cs deleted file mode 100644 index 8210e3e92..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishModuleHandleAsyncTests.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System.Net; -using System.Text; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.AI.ToolProviders.NyxId; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Runtime.Callbacks; -using Aevatar.GAgents.Scheduled.WorkflowModules; -using Aevatar.Workflow.Abstractions; -using Aevatar.Workflow.Abstractions.Execution; -using Aevatar.Workflow.Core.Execution; -using FluentAssertions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Aevatar.GAgents.ChannelRuntime.Tests.WorkflowModules; - -/// -/// End-to-end module-level coverage for TwitterPublishModule.HandleAsync — the -/// classification matrix is in ; this file pins the -/// dispatch contract (path, slug, body) so we don't accidentally regress what goes on the -/// wire to NyxID. -/// -public sealed class TwitterPublishModuleHandleAsyncTests -{ - [Fact] - public async Task HandleAsync_PostsToTweetsPath_WithoutDoublingTheV2Prefix() - { - // PR #461 review (commit 781c5bda follow-up): the api-twitter NyxID provider seed - // sets `base_url: https://api.x.com/2`, with the API version baked into the base URL. - // The publish path must therefore be `/tweets`, NOT `/2/tweets`. Regressing to the - // doubled prefix would produce `https://api.x.com/2/2/tweets` and 404 every approved - // tweet in production. NyxIdServiceApiHints.cs:58 documents the invariant. - // - // The test mocks the NyxID HTTP layer with a routing handler so we capture the exact - // proxy path the module dispatches, plus the request body (`text` field is what - // Twitter v2 expects for plain-text posts). - var handler = new RoutingJsonHandler(); - // Twitter v2 success body — NyxID forwards 2xx verbatim. - handler.Add( - HttpMethod.Post, - "/api/v1/proxy/s/api-twitter/tweets", - """{"data":{"id":"1755555555555555555","text":"hello"}}"""); - - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection().AddSingleton(nyxClient).BuildServiceProvider(); - var ctx = new RecordingExecutionContext(services); - ctx.SetItem(LLMRequestMetadataKeys.NyxIdAccessToken, "agent-key-1"); - - var module = new TwitterPublishModule(); - await module.HandleAsync( - Envelope(new StepRequestEvent - { - StepId = "publish_to_twitter", - StepType = "twitter_publish", - RunId = "run-1", - Input = "Excited to ship #216 today!", - Parameters = - { - ["publish_provider_slug"] = "api-twitter", - }, - }), - ctx, - CancellationToken.None); - - // Path invariant: must be `/tweets` exactly, never `/2/tweets`. - var post = handler.Requests.Should() - .ContainSingle(r => r.Method == HttpMethod.Post) - .Subject; - post.Path.Should().Be("/api/v1/proxy/s/api-twitter/tweets"); - post.Path.Should().NotContain("/2/tweets", - because: "the api-twitter provider already pins https://api.x.com/2 as base_url; doubling /2/ produces 404"); - - // Body sanity: Twitter v2 plain-text post requires only `{"text":"..."}`. Pin the - // shape so we don't accidentally drop the trim or add unsupported fields (#216 v1 - // scope explicitly excludes media / threading / polls). - post.Body.Should().Contain("\"text\""); - post.Body.Should().Contain("Excited to ship"); - - // Module advances the workflow by emitting StepCompletedEvent { Success = true } - // with the canonical no-handle URL form. - var completed = ctx.Published - .Select(p => p.Event) - .OfType() - .Single(); - completed.Success.Should().BeTrue(); - completed.Output.Should().Be("https://x.com/i/web/status/1755555555555555555"); - } - - [Fact] - public async Task HandleAsync_FailsClosed_When_NyxIdAccessTokenMissing() - { - // Sanity: if the workflow runtime fails to propagate the api-key into execution - // items, the module must NOT silently call NyxID with an empty token (would 401 and - // confuse the user-facing surfacing). Emit a categorized failure code and let - // on_error: skip carry the workflow forward. - var handler = new RoutingJsonHandler(); - var nyxClient = new NyxIdApiClient( - new NyxIdToolOptions { BaseUrl = "https://nyx.example.com" }, - new HttpClient(handler) { BaseAddress = new Uri("https://nyx.example.com") }); - - var services = new ServiceCollection().AddSingleton(nyxClient).BuildServiceProvider(); - var ctx = new RecordingExecutionContext(services); - // Note: no SetItem(NyxIdAccessToken, ...) — execution items are empty. - - var module = new TwitterPublishModule(); - await module.HandleAsync( - Envelope(new StepRequestEvent - { - StepId = "publish_to_twitter", - StepType = "twitter_publish", - RunId = "run-1", - Input = "draft", - }), - ctx, - CancellationToken.None); - - handler.Requests.Should().BeEmpty(because: "no api-key means no NyxID call should fire"); - - var completed = ctx.Published - .Select(p => p.Event) - .OfType() - .Single(); - completed.Success.Should().BeFalse(); - completed.Error.Should().Contain("twitter_publish_api_key_missing"); - } - - private static EventEnvelope Envelope(IMessage evt) => new() - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), - Payload = Any.Pack(evt), - Route = EnvelopeRouteSemantics.CreateTopologyPublication("test", TopologyAudience.Self), - }; - - /// - /// Minimal + - /// implementation for unit-testing module HandleAsync. Holds Published events and - /// execution items in-memory; everything else is stubbed. - /// - private sealed class RecordingExecutionContext : IWorkflowExecutionContext, IWorkflowExecutionItemsContext - { - private readonly Dictionary _items = new(StringComparer.Ordinal); - private readonly Dictionary _states = new(StringComparer.Ordinal); - - public RecordingExecutionContext(IServiceProvider services) - { - Services = services; - Logger = NullLogger.Instance; - InboundEnvelope = new EventEnvelope(); - } - - public List<(IMessage Event, TopologyAudience Direction)> Published { get; } = []; - public EventEnvelope InboundEnvelope { get; } - public string AgentId => "test-actor"; - public IServiceProvider Services { get; } - public Microsoft.Extensions.Logging.ILogger Logger { get; } - public string RunId => "test-run"; - - public void SetItem(string itemKey, object? value) => _items[itemKey] = value; - - public bool TryGetItem(string itemKey, out TItem? value) - { - if (_items.TryGetValue(itemKey, out var raw) && raw is TItem typed) - { - value = typed; - return true; - } - value = default; - return false; - } - - public bool RemoveItem(string itemKey) => _items.Remove(itemKey); - - public TState LoadState(string scopeKey) - where TState : class, IMessage, new() - { - if (!_states.TryGetValue(scopeKey, out var packed) || !packed.Is(new TState().Descriptor)) - return new TState(); - return packed.Unpack() ?? new TState(); - } - - public IReadOnlyList> LoadStates(string scopeKeyPrefix = "") - where TState : class, IMessage, new() => []; - - public Task SaveStateAsync(string scopeKey, TState state, CancellationToken ct = default) - where TState : class, IMessage - { - _states[scopeKey] = Any.Pack(state); - return Task.CompletedTask; - } - - public Task ClearStateAsync(string scopeKey, CancellationToken ct = default) - { - _states.Remove(scopeKey); - return Task.CompletedTask; - } - - public Task PublishAsync( - TEvent evt, - TopologyAudience direction = TopologyAudience.Children, - CancellationToken ct = default, - EventEnvelopePublishOptions? options = null) - where TEvent : IMessage - { - _ = options; - Published.Add((evt, direction)); - return Task.CompletedTask; - } - - public Task ScheduleSelfDurableTimeoutAsync( - string callbackId, - TimeSpan dueTime, - IMessage evt, - EventEnvelopePublishOptions? options = null, - CancellationToken ct = default) => - Task.FromResult(new RuntimeCallbackLease(AgentId, callbackId, 1, RuntimeCallbackBackend.InMemory)); - - public Task ScheduleSelfDurableTimerAsync( - string callbackId, - TimeSpan dueTime, - TimeSpan period, - IMessage evt, - EventEnvelopePublishOptions? options = null, - CancellationToken ct = default) => - Task.FromResult(new RuntimeCallbackLease(AgentId, callbackId, 1, RuntimeCallbackBackend.InMemory)); - - public Task CancelDurableCallbackAsync(RuntimeCallbackLease lease, CancellationToken ct = default) => - Task.CompletedTask; - } - - private sealed class RoutingJsonHandler : HttpMessageHandler - { - private readonly Dictionary _responses = new(StringComparer.OrdinalIgnoreCase); - - public List Requests { get; } = []; - - public void Add(HttpMethod method, string path, string json) => - _responses[$"{method.Method}:{path}"] = json; - - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - var path = request.RequestUri?.PathAndQuery ?? string.Empty; - var body = request.Content is null - ? null - : await request.Content.ReadAsStringAsync(cancellationToken); - Requests.Add(new RecordedRequest(request.Method, path, body)); - - if (_responses.TryGetValue($"{request.Method.Method}:{path}", out var json)) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(json, Encoding.UTF8, "application/json"), - }; - } - - return new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent("""{"error":true,"message":"not found"}""", Encoding.UTF8, "application/json"), - }; - } - } - - private sealed record RecordedRequest(HttpMethod Method, string Path, string? Body); -} diff --git a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs b/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs deleted file mode 100644 index fe06f0626..000000000 --- a/test/Aevatar.GAgents.ChannelRuntime.Tests/WorkflowModules/TwitterPublishOutcomeTests.cs +++ /dev/null @@ -1,186 +0,0 @@ -using Aevatar.GAgents.Scheduled.WorkflowModules; -using FluentAssertions; -using Xunit; - -namespace Aevatar.GAgents.ChannelRuntime.Tests.WorkflowModules; - -/// -/// Pins the response classification matrix for against the -/// 5 NyxID-proxy shapes the issue (#216) calls out. The module wiring (item resolution, Lark -/// surfacing) is exercised in higher-level integration tests; this file is the unit-level -/// contract for "given a downstream response, what user-facing classification falls out". -/// -public sealed class TwitterPublishOutcomeTests -{ - [Fact] - public void ClassifyTwitterResponse_ReturnsTweetUrl_When_Twitter201Success() - { - // Twitter v2 returns `{ "data": { "id": "", "text": "..." } }` on success; NyxID - // forwards verbatim, so the absence of `error` plus a present `data.id` is the success - // signal. The URL uses the no-handle form so we don't need a separate /users/me call. - var response = """{"data":{"id":"1234567890","text":"hello world"}}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeTrue(); - outcome.TweetUrl.Should().Be("https://x.com/i/web/status/1234567890"); - outcome.ErrorCode.Should().BeEmpty(); - outcome.HttpStatus.Should().Be(201); - } - - [Fact] - public void ClassifyTwitterResponse_ReturnsOauthRequired_When_Proxy401() - { - // NyxID wraps 4xx as `{ "error": true, "status": , "body": "" }`. 401 is the - // common "user has not connected Twitter at NyxID" path; the Lark message must steer - // them at NyxID's re-authorization flow rather than asking ops to look at scopes. - var response = """{"error": true, "status": 401, "body": "{\"title\":\"Unauthorized\"}"}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_oauth_required"); - outcome.HttpStatus.Should().Be(401); - outcome.LarkMessage.ToLowerInvariant().Should().Contain("oauth"); - } - - [Fact] - public void ClassifyTwitterResponse_ReturnsAccessDenied_When_Proxy403() - { - var response = """{"error": true, "status": 403, "body": "{\"detail\":\"client app missing oauth permissions\"}"}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_proxy_access_denied"); - outcome.HttpStatus.Should().Be(403); - } - - [Fact] - public void ClassifyTwitterResponse_ReturnsRateLimited_When_Proxy429() - { - var response = """{"error": true, "status": 429, "body": "{\"title\":\"Too Many Requests\"}"}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_rate_limited"); - outcome.HttpStatus.Should().Be(429); - // Rate-limit Lark message should include the numerical hint so users self-serve a retry. - outcome.LarkMessage.Should().Contain("429"); - } - - [Theory] - [InlineData(500)] - [InlineData(502)] - [InlineData(503)] - [InlineData(504)] - public void ClassifyTwitterResponse_ReturnsUpstreamError_When_Proxy5xx(int status) - { - var response = $$"""{"error": true, "status": {{status}}, "body": "{\"title\":\"Server Error\"}"}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_upstream_error"); - outcome.HttpStatus.Should().Be(status); - outcome.LarkMessage.Should().Contain(status.ToString()); - } - - [Fact] - public void ClassifyTwitterResponse_ReturnsGenericRejection_When_OtherStatus() - { - // 422 (Unprocessable Entity) is what Twitter returns for things like duplicate-tweet - // and content-policy violations. Don't bucket as 401/403/429/5xx — surface verbatim so - // the user can read the actual rejection reason (e.g. "duplicate content"). - var response = """{"error": true, "status": 422, "body": "{\"title\":\"You attempted to create a Tweet with content that has already been posted recently.\"}"}"""; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_rejected"); - outcome.HttpStatus.Should().Be(422); - outcome.LarkMessage.Should().Contain("422"); - } - - [Fact] - public void ClassifyTwitterResponse_HandlesEmptyResponse() - { - // An empty proxy body should not silently look like success; surface as failure with a - // distinct code so logs don't conflate "Twitter accepted but didn't return a body" with - // "publish actually went through". - var outcome = TwitterPublishModule.ClassifyTwitterResponse(string.Empty); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_empty_response"); - } - - [Fact] - public void ClassifyTwitterResponse_HandlesUnparseableJson() - { - // NyxID is supposed to return JSON, but if a transport-layer error returned plain text - // we should not crash — emit a categorized failure code and the test verifies the - // module's robustness against malformed input. - var outcome = TwitterPublishModule.ClassifyTwitterResponse("internal error"); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_unparseable_response"); - } - - [Fact] - public void ClassifyTwitterResponse_RecognizesTwitterNativeErrorsArrayShape() - { - // PR #461 review item #2: Twitter v2 sometimes returns the native error shape with no - // NyxID-wrap envelope, e.g. duplicate-tweet (code 187) or content-policy violations. - // The classifier must surface the Twitter `message` text in the Lark surfacing so the - // user reads the actual rejection reason, not a generic "publish failed". - var response = """ - { - "title": "Conflict", - "detail": "You attempted to create a Tweet with content that has already been posted recently.", - "errors": [ - {"message": "duplicate content", "code": 187} - ] - } - """; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_rejected"); - outcome.LarkMessage.Should().Contain("duplicate content"); - outcome.LarkMessage.Should().Contain("187"); - } - - [Fact] - public void ClassifyTwitterResponse_RecognizesTwitterNativeRfc7807Shape_WithoutErrorsArray() - { - // RFC 7807 Problem Details — Twitter v2 occasionally omits the `errors` array but - // still provides `title` / `detail`. Don't fall through to "unexpected_shape" in this - // case; treat as a native rejection so the user sees Twitter's text. - var response = """ - { - "title": "tweet_create_error", - "detail": "Your account is temporarily restricted from creating Tweets." - } - """; - - var outcome = TwitterPublishModule.ClassifyTwitterResponse(response); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_rejected"); - outcome.LarkMessage.Should().Contain("temporarily restricted"); - } - - [Fact] - public void ClassifyTwitterResponse_FailsWithUnexpectedShape_When_NoSuccessNoErrorEnvelope() - { - // Empty object — neither success nor any of the recognized error shapes. Must not - // silently look like success; classify as `twitter_publish_unexpected_shape` so logs - // surface the anomaly. - var outcome = TwitterPublishModule.ClassifyTwitterResponse("{}"); - - outcome.Success.Should().BeFalse(); - outcome.ErrorCode.Should().Be("twitter_publish_unexpected_shape"); - } -} diff --git a/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs b/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs index 29cb94f9a..b3cb7b447 100644 --- a/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs +++ b/test/Aevatar.GAgents.Platform.Lark.Tests/LarkMessageComposerTests.cs @@ -67,6 +67,58 @@ public void Compose_WhenTextContainsSurrogatePair_DoesNotSplitTextElement() payload.PlainText.ShouldBe("A🙂"); } + [Fact] + public void Compose_WhenPlainTextExceedsLegacyTwoThousandChars_DoesNotSilentlyTruncate() + { + var text = new string('a', 2_500); + + var payload = CreateComposer().Compose( + new MessageContent + { + Text = text, + }, + new ComposeContext + { + Conversation = ConversationReference.Create( + ChannelId.From("lark"), + BotInstanceId.From("bot-1"), + ConversationScope.DirectMessage, + partition: null, + "user-1"), + Capabilities = LarkMessageComposer.DefaultCapabilities.Clone(), + }); + + payload.PlainText.ShouldBe(text); + using var document = JsonDocument.Parse(payload.ContentJson); + document.RootElement.GetProperty("text").GetString().ShouldBe(text); + } + + [Fact] + public void Compose_WhenTextExceedsConfiguredLimit_AppendsTruncationMarker() + { + var payload = CreateComposer().Compose( + new MessageContent + { + Text = "0123456789ABCDEFGHIJ", + }, + new ComposeContext + { + Conversation = ConversationReference.Create( + ChannelId.From("lark"), + BotInstanceId.From("bot-1"), + ConversationScope.DirectMessage, + partition: null, + "user-1"), + Capabilities = new ChannelCapabilities + { + MaxMessageLength = 18, + }, + }); + + payload.PlainText.Length.ShouldBeLessThanOrEqualTo(18); + payload.PlainText.ShouldEndWith("...[truncated]"); + } + [Fact] public void Compose_WhenRenderingInteractiveCard_UsesLarkV2BodyElements() { @@ -130,7 +182,7 @@ public void Compose_WhenSingleCardSuppliesTitle_DoesNotDuplicateInBody() { BlockId = "agents_list", Title = "Your Agents (1)", - Text = "1. `daily_report` · running", + Text = "1. `daily` · running", }); intent.Actions.Add(new ActionElement { @@ -162,7 +214,7 @@ public void Compose_WhenSingleCardSuppliesTitle_DoesNotDuplicateInBody() var cardMarkdown = bodyElements[0].GetProperty("content").GetString(); cardMarkdown.ShouldNotBeNull(); cardMarkdown.ShouldNotContain("**Your Agents (1)**"); - cardMarkdown.ShouldContain("daily_report"); + cardMarkdown.ShouldContain("daily"); } [Fact] @@ -272,11 +324,11 @@ public void Compose_WhenRenderingFormSubmit_UsesLarkV2CallbackBehavior() var submit = new ActionElement { Kind = ActionElementKind.FormSubmit, - ActionId = "submit_daily_report", + ActionId = "submit_daily", Label = "Create", IsPrimary = true, }; - submit.Arguments["agent_builder_action"] = "create_daily_report"; + submit.Arguments["agent_builder_action"] = "create_daily"; intent.Actions.Add(submit); var payload = CreateComposer().Compose( @@ -303,13 +355,13 @@ public void Compose_WhenRenderingFormSubmit_UsesLarkV2CallbackBehavior() .EnumerateArray() .First(e => e.TryGetProperty("tag", out var tag) && tag.GetString() == "button"); - submitButton.GetProperty("name").GetString().ShouldBe("submit_daily_report"); + submitButton.GetProperty("name").GetString().ShouldBe("submit_daily"); submitButton.GetProperty("form_action_type").GetString().ShouldBe("submit"); submitButton.TryGetProperty("value", out _).ShouldBeFalse(); var behavior = submitButton.GetProperty("behaviors")[0]; behavior.GetProperty("type").GetString().ShouldBe("callback"); var value = behavior.GetProperty("value"); - value.GetProperty("action_id").GetString().ShouldBe("submit_daily_report"); - value.GetProperty("agent_builder_action").GetString().ShouldBe("create_daily_report"); + value.GetProperty("action_id").GetString().ShouldBe("submit_daily"); + value.GetProperty("agent_builder_action").GetString().ShouldBe("create_daily"); } } diff --git a/test/Aevatar.Hosting.Tests/RetiredActorCleanupHostedServiceTests.cs b/test/Aevatar.Hosting.Tests/RetiredActorCleanupHostedServiceTests.cs index c233d1a95..f83ce726b 100644 --- a/test/Aevatar.Hosting.Tests/RetiredActorCleanupHostedServiceTests.cs +++ b/test/Aevatar.Hosting.Tests/RetiredActorCleanupHostedServiceTests.cs @@ -124,7 +124,7 @@ await AppendCatalogEventsAsync(eventStore, new UserAgentCatalogEntry { AgentId = "workflow-agent-old", - AgentType = WorkflowAgentDefaults.AgentType, + AgentType = "workflow_agent", }, new UserAgentCatalogEntry { @@ -231,7 +231,7 @@ public async Task StartAsync_ShouldDiscoverRetiredUserAgentsFromReadModel_WhenCa { Id = "workflow-agent-snapshotted", ActorId = "agent-registry-store", - AgentType = WorkflowAgentDefaults.AgentType, + AgentType = "workflow_agent", }); var typeProbe = new StubActorTypeProbe(new Dictionary { diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 7908b1bbb..46336e327 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -246,10 +246,14 @@ private sealed class FakeProjectionDocumentReader { private readonly Dictionary _docs = new(StringComparer.Ordinal); + public Exception? GetError { get; set; } + public void Set(string key, TDoc document) => _docs[key] = document; - public Task GetAsync(string key, CancellationToken ct = default) - => Task.FromResult(_docs.GetValueOrDefault(key)); + public Task GetAsync(string key, CancellationToken ct = default) => + GetError is null + ? Task.FromResult(_docs.GetValueOrDefault(key)) + : Task.FromException(GetError); public Task> QueryAsync( ProjectionDocumentQuery query, CancellationToken ct = default) @@ -1126,8 +1130,9 @@ public async Task UserMemoryStore_RemoveEntryAsync_MissingEntry_ReturnsFalse() var removed = await store.RemoveEntryAsync("missing"); removed.Should().BeFalse(); - runtime.Actors.Should().NotContainKey("user-memory-user-1", - "no actor should be created when entry is missing"); + runtime.Actors.Should().NotContainKey( + "user-memory-user-1", + "missing deletes should not create or activate a write actor"); } [Fact] @@ -1166,16 +1171,19 @@ public async Task UserMemoryStore_BuildPromptSectionAsync_FormatsGroupsAndTrunca } [Fact] - public async Task UserMemoryStore_BuildPromptSectionAsync_WhenReadFails_ReturnsEmpty() + public async Task UserMemoryStore_BuildPromptSectionAsync_ReadFailure_ReturnsEmpty() { var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver(); + var reader = EmptyReader(); + reader.GetError = new InvalidOperationException("projection unavailable"); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + var store = new ActorBackedUserMemoryStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, reader, logger); var prompt = await store.BuildPromptSectionAsync(); prompt.Should().BeEmpty(); + runtime.Actors.Should().BeEmpty(); } [Fact] @@ -1191,6 +1199,45 @@ public async Task UserMemoryStore_NoScope_Throws() await act.Should().ThrowAsync(); } + [Fact] + public async Task UserMemoryStore_RemoveEntryAsync_NoScope_Throws() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + + var act = () => store.RemoveEntryAsync("some-id"); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UserMemoryStore_GetAsync_NoScope_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task UserMemoryStore_BuildPromptSectionAsync_NoScope_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + + var prompt = await store.BuildPromptSectionAsync(); + + prompt.Should().BeEmpty(); + } + // ════════════════════════════════════════════════════════════ // ConnectorCatalogStore: command dispatch // ════════════════════════════════════════════════════════════ diff --git a/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs index e53934be0..3b714009d 100644 --- a/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs @@ -553,9 +553,10 @@ public async Task UserConfigController_GetLlmOptions_UsesNyxIdLlmServicesEndpoin payload.Available.Should().ContainSingle().Which.ServiceId.Should().Be("svc-openai"); payload.Current.Should().NotBeNull(); payload.Current!.DisplayName.Should().Be("OpenAI Work"); - httpHandler.Requests.Should().ContainSingle(); - httpHandler.Requests[0].Path.Should().Be("/api/v1/llm/services"); - httpHandler.Requests[0].Authorization.Should().Be("Bearer user-token-1"); + httpHandler.Requests.Select(request => request.Path) + .Should() + .Equal("/api/v1/llm/services", "/api/v1/proxy/services?per_page=100"); + httpHandler.Requests.Should().OnlyContain(request => request.Authorization == "Bearer user-token-1"); } [Fact] @@ -594,7 +595,165 @@ public async Task UserConfigController_GetLlmOptions_FallsBackToNyxIdLlmStatusEn option.Allowed.Should().BeTrue(); httpHandler.Requests.Select(request => request.Path) .Should() - .Equal("/api/v1/llm/services", "/api/v1/llm/status"); + .Equal( + "/api/v1/llm/services", + "/api/v1/llm/status", + "/api/v1/proxy/services?per_page=100"); + } + + [Fact] + public async Task UserConfigController_GetLlmOptions_MergesProxyLlmRouteCandidates() + { + var httpHandler = new RecordingHttpHandler( + (HttpStatusCode.OK, """ + { + "services": [ + { + "user_service_id": "svc-openai", + "service_slug": "openai-work", + "display_name": "OpenAI Work", + "route_value": "/api/v1/proxy/s/openai-work", + "default_model": "gpt-5.4", + "models": ["gpt-5.4"], + "status": "ready", + "source": "user", + "allowed": true + } + ] + } + """), + (HttpStatusCode.OK, """ + { + "services": [ + { + "id": "svc-chrono", + "name": "Chrono LLM", + "slug": "chrono-llm", + "description": "Shared OpenAI-compatible route", + "connected": false, + "requires_connection": false, + "has_node_binding": true, + "proxy_url_slug": "https://nyxid.example/api/v1/proxy/s/chrono-llm/{path}" + }, + { + "id": "svc-github", + "name": "GitHub", + "slug": "api-github", + "description": "Code hosting API", + "connected": true, + "requires_connection": false, + "proxy_url_slug": "https://nyxid.example/api/v1/proxy/s/api-github/{path}" + }, + { + "id": "svc-openai-webhook", + "name": "OpenAI webhook management", + "slug": "api-openai-webhook", + "description": "Webhook management API", + "connected": true, + "requires_connection": false, + "proxy_url_slug": "https://nyxid.example/api/v1/proxy/s/api-openai-webhook/{path}" + }, + { + "id": "svc-not-llm", + "name": "OpenAI admin", + "slug": "admin-openai", + "description": "Not an LLM endpoint", + "connected": true, + "requires_connection": false, + "proxy_url_slug": "https://nyxid.example/api/v1/proxy/s/admin-openai/{path}" + } + ] + } + """)); + var controller = CreateController( + new StubUserConfigQueryPort(), + new RecordingUserConfigCommandService(), + new StubHttpClientFactory(httpHandler), + BuildNyxIdConfiguration(), + bearerToken: "user-token-1"); + + var response = await controller.GetLlmOptions(CancellationToken.None); + + var ok = response.Result.Should().BeOfType().Subject; + var payload = ok.Value.Should().BeOfType().Subject; + payload.Available.Should().HaveCount(2); + var chrono = payload.Available.Should() + .Contain(option => option.ServiceSlug == "chrono-llm") + .Which; + chrono.ServiceId.Should().Be("svc-chrono"); + chrono.DisplayName.Should().Be("Chrono LLM"); + chrono.RouteValue.Should().Be("/api/v1/proxy/s/chrono-llm"); + chrono.Source.Should().Be(NyxIdLlmProviderSource.ProxyService); + chrono.Status.Should().Be("ready"); + chrono.Allowed.Should().BeTrue(); + payload.Available.Should().NotContain(option => option.ServiceSlug == "api-github"); + payload.Available.Should().NotContain(option => option.ServiceSlug == "api-openai-webhook"); + payload.Available.Should().NotContain(option => option.ServiceSlug == "admin-openai"); + httpHandler.Requests.Select(request => request.Path) + .Should() + .Equal("/api/v1/llm/services", "/api/v1/proxy/services?per_page=100"); + } + + [Fact] + public async Task UserConfigController_GetLlmOptions_PrefersReadyProxyRouteOverLegacyNotConnectedDuplicate() + { + var httpHandler = new RecordingHttpHandler( + (HttpStatusCode.NotFound, """{"error":"not_found"}"""), + (HttpStatusCode.OK, """ + { + "providers": [ + { + "provider_slug": "chrono-llm", + "provider_name": "Chrono LLM", + "status": "not_connected", + "proxy_url": "https://nyxid.example/api/v1/llm/chrono-llm/v1" + } + ], + "gateway_url": "https://nyxid.example/api/v1/llm/gateway/v1", + "supported_models": ["chrono-default"] + } + """), + (HttpStatusCode.OK, """ + { + "services": [ + { + "id": "svc-chrono", + "name": "Chrono LLM", + "slug": "chrono-llm", + "description": "Shared OpenAI-compatible route", + "connected": false, + "requires_connection": false, + "has_node_binding": true, + "proxy_url_slug": "https://nyxid.example/api/v1/proxy/s/chrono-llm/{path}" + } + ] + } + """)); + var controller = CreateController( + new StubUserConfigQueryPort(), + new RecordingUserConfigCommandService(), + new StubHttpClientFactory(httpHandler), + BuildNyxIdConfiguration(), + bearerToken: "user-token-1"); + + var response = await controller.GetLlmOptions(CancellationToken.None); + + var ok = response.Result.Should().BeOfType().Subject; + var payload = ok.Value.Should().BeOfType().Subject; + var chrono = payload.Available.Should().ContainSingle().Subject; + chrono.ServiceId.Should().Be("svc-chrono"); + chrono.ServiceSlug.Should().Be("chrono-llm"); + chrono.DisplayName.Should().Be("Chrono LLM"); + chrono.RouteValue.Should().Be("/api/v1/proxy/s/chrono-llm"); + chrono.Source.Should().Be(NyxIdLlmProviderSource.ProxyService); + chrono.Status.Should().Be("ready"); + chrono.Allowed.Should().BeTrue(); + httpHandler.Requests.Select(request => request.Path) + .Should() + .Equal( + "/api/v1/llm/services", + "/api/v1/llm/status", + "/api/v1/proxy/services?per_page=100"); } [Fact] @@ -993,9 +1152,12 @@ public async Task UserConfigController_SaveLlmPreference_WithProvisionPreset_Pos commandService.SavedConfig.DefaultModel.Should().Be("chrono-default"); httpHandler.Requests.Select(request => request.Path) .Should() - .Equal("/api/v1/llm/services", "/api/v1/llm/services/chrono-llm%2Fshared"); - httpHandler.Requests[1].Method.Should().Be("POST"); - httpHandler.Requests[1].Body.Should().Be("{}"); + .Equal( + "/api/v1/llm/services", + "/api/v1/proxy/services?per_page=100", + "/api/v1/llm/services/chrono-llm%2Fshared"); + httpHandler.Requests[2].Method.Should().Be("POST"); + httpHandler.Requests[2].Body.Should().Be("{}"); } [Fact] diff --git a/tools/Aevatar.Tools.Cli/Commands/App/OrnnSkillsCommand.cs b/tools/Aevatar.Tools.Cli/Commands/App/OrnnSkillsCommand.cs index dd75b8a7e..6d4df1b2d 100644 --- a/tools/Aevatar.Tools.Cli/Commands/App/OrnnSkillsCommand.cs +++ b/tools/Aevatar.Tools.Cli/Commands/App/OrnnSkillsCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using Aevatar.AI.ToolProviders.NyxId; using Aevatar.AI.ToolProviders.Ornn; using Aevatar.Tools.Cli.Hosting; @@ -6,20 +7,23 @@ namespace Aevatar.Tools.Cli.Commands; internal static class OrnnSkillsCommand { + private const string DefaultNyxIdBaseUrl = "https://nyx-api.chrono-ai.fun"; + public static Command Create() { var command = new Command("skills", "Browse and inspect Ornn skills."); var tokenOption = new Option("--token", "NyxID bearer token.") { IsRequired = true }; - var ornnUrlOption = new Option("--ornn-url", "Ornn base URL override (reads Ornn:BaseUrl from config if not set)."); + var nyxIdUrlOption = new Option("--nyxid-url", "NyxID base URL override (reads Cli:App:NyxId:Authority from config if not set)."); + var slugOption = new Option("--slug", () => "ornn-api", "NyxID-bound Ornn service slug. Bare 'ornn' is the SPA frontend (returns HTML)."); - command.AddCommand(CreateListCommand(tokenOption, ornnUrlOption)); - command.AddCommand(CreateShowCommand(tokenOption, ornnUrlOption)); + command.AddCommand(CreateListCommand(tokenOption, nyxIdUrlOption, slugOption)); + command.AddCommand(CreateShowCommand(tokenOption, nyxIdUrlOption, slugOption)); return command; } - private static Command CreateListCommand(Option tokenOption, Option ornnUrlOption) + private static Command CreateListCommand(Option tokenOption, Option nyxIdUrlOption, Option slugOption) { var command = new Command("list", "Search/list Ornn skills."); @@ -29,39 +33,34 @@ private static Command CreateListCommand(Option tokenOption, Option("--page-size", () => 20, "Results per page."); command.AddOption(tokenOption); - command.AddOption(ornnUrlOption); + command.AddOption(nyxIdUrlOption); + command.AddOption(slugOption); command.AddOption(queryOption); command.AddOption(scopeOption); command.AddOption(pageOption); command.AddOption(pageSizeOption); - command.SetHandler(async (string token, string? ornnUrl, string query, string scope, int page, int pageSize) => + command.SetHandler(async (string token, string? nyxIdUrl, string slug, string query, string scope, int page, int pageSize) => { - var baseUrl = ResolveOrnnUrl(ornnUrl); - if (string.IsNullOrWhiteSpace(baseUrl)) - { - Console.Error.WriteLine("Ornn base URL not configured. Use --ornn-url or run: aevatar config ornn set-url "); + var client = TryCreateClient(nyxIdUrl, slug); + if (client is null) return; - } - - var options = new OrnnOptions { BaseUrl = baseUrl }; - var client = new OrnnSkillClient(options); try { - var result = await client.SearchSkillsAsync(token, query, scope, page, pageSize, CancellationToken.None); + var result = await client.SearchSkillsAsync(token, query, scope, page, pageSize, ct: CancellationToken.None); PrintSearchResults(result); } catch (HttpRequestException ex) { Console.Error.WriteLine($"Request failed: {ex.Message}"); } - }, tokenOption, ornnUrlOption, queryOption, scopeOption, pageOption, pageSizeOption); + }, tokenOption, nyxIdUrlOption, slugOption, queryOption, scopeOption, pageOption, pageSizeOption); return command; } - private static Command CreateShowCommand(Option tokenOption, Option ornnUrlOption) + private static Command CreateShowCommand(Option tokenOption, Option nyxIdUrlOption, Option slugOption) { var command = new Command("show", "Show details of a specific Ornn skill."); @@ -69,19 +68,14 @@ private static Command CreateShowCommand(Option tokenOption, Option + command.SetHandler(async (string nameOrId, string token, string? nyxIdUrl, string slug) => { - var baseUrl = ResolveOrnnUrl(ornnUrl); - if (string.IsNullOrWhiteSpace(baseUrl)) - { - Console.Error.WriteLine("Ornn base URL not configured. Use --ornn-url or run: aevatar config ornn set-url "); + var client = TryCreateClient(nyxIdUrl, slug); + if (client is null) return; - } - - var options = new OrnnOptions { BaseUrl = baseUrl }; - var client = new OrnnSkillClient(options); try { @@ -98,22 +92,49 @@ private static Command CreateShowCommand(Option tokenOption, Option --json"); + return null; + } + + var nyxClient = new NyxIdApiClient(new NyxIdToolOptions { BaseUrl = nyxIdUrl }, new HttpClient()); + return new OrnnSkillClient(new OrnnOptions { NyxIdSlug = slug }, nyxClient); + } + + private static string? ResolveNyxIdUrl(string? nyxIdUrlOverride) { - if (!string.IsNullOrWhiteSpace(ornnUrlOverride)) - return ornnUrlOverride.TrimEnd('/'); + if (!string.IsNullOrWhiteSpace(nyxIdUrlOverride)) + return nyxIdUrlOverride.TrimEnd('/'); + + // CLI's NyxID authority follows the same key the frontend / config UI uses. + var configured = CliAppConfigStore.TryGetConfigValue("Cli:App:NyxId:Authority"); + if (!string.IsNullOrWhiteSpace(configured)) + return configured.TrimEnd('/'); - // Read from ~/.aevatar/config.json at Ornn:BaseUrl - return CliAppConfigStore.TryGetConfigValue("Ornn:BaseUrl"); + // Fall back to the production NyxID host so dev workstations work without explicit + // config — matches the default in tools/Aevatar.Tools.Cli/Frontend/src/auth/nyxid.ts. + return DefaultNyxIdBaseUrl; } private static void PrintSearchResults(OrnnSearchResult result) { + if (!string.IsNullOrEmpty(result.Error)) + { + Console.Error.WriteLine($"Search failed: {result.Error}"); + return; + } + Console.WriteLine($"Skills found: {result.Total} (page {result.Page}/{result.TotalPages})"); Console.WriteLine(); diff --git a/tools/ci/test_polling_allowlist.txt b/tools/ci/test_polling_allowlist.txt index 5c3e61b50..be846cd7e 100644 --- a/tools/ci/test_polling_allowlist.txt +++ b/tools/ci/test_polling_allowlist.txt @@ -18,3 +18,5 @@ test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs test/Aevatar.AI.Tests/StreamingToolExecutorTests.cs # Fake voice transport uses Task.Delay(Infinite, ct) to keep receive loop alive until relay teardown cancels it test/Aevatar.Foundation.VoicePresence.Tests/VoiceTransportRelayTests.cs +# HangingReplyGenerator test double uses Task.Delay(Infinite, ct) to verify runtime cancels reply generation on timeout +test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelLlmReplyInboxRuntimeTests.cs