diff --git a/.changeset/fn-8870-structural-mail-contract.md b/.changeset/fn-8870-structural-mail-contract.md new file mode 100644 index 0000000000..a1131968d9 --- /dev/null +++ b/.changeset/fn-8870-structural-mail-contract.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add structural reports and approval items to agent mail. +category: feature +dev: Adds mailKind/report/approvalRequestId metadata, fn_send_message report params, and approval-mail: idempotency. diff --git a/docs/agents.md b/docs/agents.md index 32b743cbf1..a6d9638d27 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -946,6 +946,8 @@ Messaging is available in dashboard mailbox UI and CLI. In dashboard Mailbox → Agent-backed dashboard chat sessions (including plugin-runtime agents such as Hermes/OpenClaw/Paperclip) also expose mailbox tools (`fn_send_message`, `fn_read_messages`) when a `MessageStore` is wired for that project. Model-only chats without an attached agent do not expose these tools. +Mail has an optional structural metadata contract: `mailKind` distinguishes ordinary messages, reports, and approvals; reports carry a small serializable title/section writeup, while `approvalRequestId` is a reference to live approval state rather than a copied snapshot. `fn_send_message` can send reports with `mail_kind: "report"` and `report`; use Chat for quick back-and-forth. Approval items are engine-emitted only. + ### Dashboard Chat workspace tools Dashboard Chat, Chat Room responders, and task-detail Planner Chat run at the interactive project checkout with coding workspace tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls`. Use them for user-directed file changes and shell investigation. When a durable agent is bound, its permanent-agent permission policy still governs file writes/deletes and command execution; unbound model Chat has no durable-principal policy gate. Chat must keep the checkout branch sticky: inspect Git freely, but do not use `git checkout` or `git switch` unless the operator explicitly requests it. diff --git a/docs/architecture.md b/docs/architecture.md index 581d608989..934233e300 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,10 @@ This document describes the actual architecture of Fusion as implemented in this --- +## Structural approval mail + +Action gates emit a best-effort, idempotent system-mail item for each pending approval request. The item uses `mailKind: "approval"` and references its live `approvalRequestId`; `sendMessageOnce` keys it as `approval-mail:`, so repeated deduped gate attempts do not spam the inbox and a mailbox failure cannot interrupt the safety pause. + ## Terminal task wedge notifications Actionable terminal task updates are classified into bounded reasons such as a named merge gate, retry exhaustion, or a completion blocker. The PostgreSQL-backed task row persists an active/resolved episode with an opaque identity, so `NotificationService` sends one `task-wedged` provider event and one dashboard system-mailbox message per active reason even across restarts. Repeated observations remain quiet until an authoritative non-wedge task update resolves the episode; changed and resolved-then-reentered reasons notify again. diff --git a/packages/core/src/__tests__/message-metadata-structural-mail.test.ts b/packages/core/src/__tests__/message-metadata-structural-mail.test.ts new file mode 100644 index 0000000000..a50ca1c47f --- /dev/null +++ b/packages/core/src/__tests__/message-metadata-structural-mail.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { validateMessageMetadata } from "../types.js"; + +describe("validateMessageMetadata structural mail", () => { + it("accepts absent, legacy, report, approval, and mail-kind-only metadata", () => { + expect(() => validateMessageMetadata(undefined)).not.toThrow(); + expect(() => validateMessageMetadata({ replyTo: { messageId: "m-1" }, wakeRecipient: true, nativeStructures: [{ kind: "mission", id: "M-1" }], kind: "task-proposal", proposedTask: { title: "Title", description: "Description" }, proposalIdempotencyKey: "proposal-1" })).not.toThrow(); + expect(() => validateMessageMetadata({ mailKind: "report", report: { title: "Brief", sections: [{ heading: "Summary", body: "Ready" }] } })).not.toThrow(); + expect(() => validateMessageMetadata({ mailKind: "approval", approvalRequestId: "approval-1" })).not.toThrow(); + expect(() => validateMessageMetadata({ mailKind: "message" })).not.toThrow(); + }); + + it.each([ + [{ mailKind: "other" }, "metadata.mailKind"], + [{ report: "bad" }, "metadata.report must be an object"], + [{ report: { title: " ", sections: [{ heading: "H", body: "B" }] } }, "metadata.report.title"], + [{ report: { title: "T", sections: "bad" } }, "metadata.report.sections must be an array"], + [{ report: { title: "T", sections: [] } }, "metadata.report.sections must not be empty"], + [{ report: { title: "T", sections: [{ body: "B" }] } }, "metadata.report.sections[].heading"], + [{ report: { title: "T", sections: [{ heading: "H", body: " " }] } }, "metadata.report.sections[].body"], + [{ approvalRequestId: " " }, "metadata.approvalRequestId"], + ])("rejects malformed structural mail %#", (metadata, message) => { + expect(() => validateMessageMetadata(metadata as never)).toThrow(message); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 38d5c21cf5..10de7d7a6c 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -43,7 +43,7 @@ etc. pulled in by production modules, not test files). */ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError, normalizeAgentRoles } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afae7f87ec..af9e4222f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, APPROVAL_REQUEST_PENDING_TTL_MS, APPROVAL_REQUEST_GRANT_TTL_MS, isApprovalRequestExpired, configureApprovalRequestTtls, getApprovalRequestGrantTtlMs, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, REVIEW_ARTIFACTS_MODES, LIVE_DEMO_ARTIFACT_MIME_TYPE, isReviewArtifact, parseReviewArtifactsModeOverride, resolveReviewArtifactsMode, classifyReviewArtifactTask, isReviewArtifactGenerationEligible, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; -export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, TaskDeleteClosureContext, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; +export type { VoiceInputSettings, Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, ReviewArtifactsMode, ReviewArtifactTaskClassification, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, TaskDeleteClosureContext, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowReviewFinding, WorkflowReviewFindingSeverity, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, MailKind, MailReportSection, MailReport, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, ExecutorOverseerSignalMemory, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; export type { NativeStructureRef, NativeStructureEmbed, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult } from "./types.js"; export type { SymbolLockStatus, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 624e82d728..5dfe07f357 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1404,6 +1404,9 @@ import type { EphemeralTaskCreationPolicy, ProposedTaskMetadata, NativeStructureEmbed, + MailKind, + MailReportSection, + MailReport, MessageMetadata, Message, MessageCreateInput, @@ -1416,6 +1419,9 @@ export type { EphemeralTaskCreationPolicy, ProposedTaskMetadata, NativeStructureEmbed, + MailKind, + MailReportSection, + MailReport, MessageMetadata, Message, MessageCreateInput, @@ -1448,6 +1454,30 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined): plugin-owned read adapter at render time, so attachment metadata remains a ref rather than a duplicated persistence snapshot; labels are optional attach-time fallbacks. */ + /* + FNXC:StructuralMail 2026-08-09-07:16: + Report sections are required: an empty report is a quick message wearing a report label and + would render as an empty shell for the structural-mail consumer. + */ + if (metadata.mailKind !== undefined && !["message", "report", "approval"].includes(metadata.mailKind)) { + throw new Error("metadata.mailKind is invalid"); + } + if (metadata.report !== undefined) { + const report = metadata.report; + if (typeof report !== "object" || report === null || Array.isArray(report)) throw new Error("metadata.report must be an object"); + if (typeof report.title !== "string" || !report.title.trim()) throw new Error("metadata.report.title must be a non-empty string"); + if (!Array.isArray(report.sections)) throw new Error("metadata.report.sections must be an array"); + if (report.sections.length === 0) throw new Error("metadata.report.sections must not be empty"); + for (const section of report.sections) { + if (typeof section !== "object" || section === null || Array.isArray(section)) throw new Error("metadata.report.sections entries must be objects"); + if (typeof section.heading !== "string" || !section.heading.trim()) throw new Error("metadata.report.sections[].heading must be a non-empty string"); + if (typeof section.body !== "string" || !section.body.trim()) throw new Error("metadata.report.sections[].body must be a non-empty string"); + } + } + if (metadata.approvalRequestId !== undefined && (typeof metadata.approvalRequestId !== "string" || !metadata.approvalRequestId.trim())) { + throw new Error("metadata.approvalRequestId must be a non-empty string"); + } + if (metadata.nativeStructures !== undefined) { if (!Array.isArray(metadata.nativeStructures)) { throw new Error("metadata.nativeStructures must be an array"); diff --git a/packages/core/src/types/messaging/messages.ts b/packages/core/src/types/messaging/messages.ts index e4177691cf..923c5550a9 100644 --- a/packages/core/src/types/messaging/messages.ts +++ b/packages/core/src/types/messaging/messages.ts @@ -72,6 +72,18 @@ export interface ProposedTaskMetadata { */ export type NativeStructureEmbed = NativeStructureRef & { label?: string }; +export type MailKind = "message" | "report" | "approval"; + +export interface MailReportSection { + heading: string; + body: string; +} + +export interface MailReport { + title: string; + sections: MailReportSection[]; +} + export interface MessageMetadata extends Record { /** Optional link to the original message when this message is a reply. */ replyTo?: MessageReplyReference; @@ -110,6 +122,15 @@ export interface MessageMetadata extends Record { * unavailable target a human-readable fallback after lazy preview resolution. */ nativeStructures?: NativeStructureEmbed[]; + /* + FNXC:StructuralMail 2026-08-09-07:16: + Absent mailKind preserves every legacy row's quick-message semantics. Reports are a small + serializable writeup alongside (not instead of) nativeStructures; approvalRequestId is a + reference only, never an approval snapshot, so live state resolves from ApprovalRequestStore. + */ + mailKind?: MailKind; + report?: MailReport; + approvalRequestId?: string; } /** Message record stored in the system */ diff --git a/packages/engine/src/__tests__/agent-tools-send-message-structural-mail.test.ts b/packages/engine/src/__tests__/agent-tools-send-message-structural-mail.test.ts new file mode 100644 index 0000000000..698f57f5b1 --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-send-message-structural-mail.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSendMessageTool } from "../agent-tools.js"; + +const execute = async (params: Record, parent?: Record) => { + const sendMessage = vi.fn(async () => ({ id: "msg-1" })); + const tool = createSendMessageTool({ sendMessage, getMessage: vi.fn(async () => parent ?? null) } as never, "agent-a"); + return { result: await tool.execute("1", params as never), sendMessage }; +}; +const text = (result: any) => result.content[0].text as string; + +describe("fn_send_message structural mail", () => { + it("persists report metadata and merges a reply reference", async () => { + const report = { title: "Brief", sections: [{ heading: "Summary", body: "Done" }] }; + const { result, sendMessage } = await execute({ to_id: "dashboard", type: "agent-to-user", content: "See report", mail_kind: "report", report }); + expect(text(result)).toContain("Message sent"); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ metadata: { mailKind: "report", report } })); + const reply = await execute({ content: "See report", reply_to_message_id: "parent", mail_kind: "report", report }, { fromId: "dashboard", fromType: "user", toId: "agent-a", toType: "agent" }); + expect(reply.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ metadata: { replyTo: { messageId: "parent" }, mailKind: "report", report } })); + }); + it("keeps plain sends metadata-free", async () => { + const { sendMessage } = await execute({ to_id: "agent-b", content: "hello" }); + expect(sendMessage).toHaveBeenCalledWith(expect.not.objectContaining({ metadata: expect.anything() })); + }); + it.each([ + { to_id: "agent-b", content: "x", mail_kind: "report" }, + { to_id: "agent-b", content: "x", report: { title: " ", sections: [{ heading: "H", body: "B" }] } }, + { to_id: "agent-b", content: "x", report: { title: "T", sections: [] } }, + { to_id: "agent-b", content: "x", report: { title: "T", sections: [{ heading: " ", body: "B" }] } }, + { to_id: "agent-b", content: "x", mail_kind: "approval" }, + ])("rejects malformed structural input", async (params) => { + const { result, sendMessage } = await execute(params); + expect(text(result)).toMatch(/^ERROR:/); expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/approval-mail-emission.test.ts b/packages/engine/src/__tests__/approval-mail-emission.test.ts new file mode 100644 index 0000000000..ce5a03717c --- /dev/null +++ b/packages/engine/src/__tests__/approval-mail-emission.test.ts @@ -0,0 +1,137 @@ +import "./executor-test-helpers.js"; +import { describe, expect, it, vi } from "vitest"; +import { DASHBOARD_USER_ID } from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; +import { HeartbeatMonitor } from "../agent-heartbeat.js"; +import { emitApprovalMail } from "../agents/approval-mail.js"; + +function createMessageStore(options: { reject?: boolean } = {}) { + const persisted = new Map(); + const sendMessageOnce = vi.fn(async (message: unknown, key: string) => { + if (options.reject) throw new Error("mail store unavailable"); + const inserted = !persisted.has(key); + if (inserted) persisted.set(key, message); + return { message: { id: key }, inserted }; + }); + return { sendMessageOnce, persisted }; +} + +function createExecutorStore() { + const listeners = new Map void>>(); + return { + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + const handlers = listeners.get(event) ?? new Set(); + handlers.add(listener); + listeners.set(event, handlers); + }), + off: vi.fn(), + getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }), + listTasks: vi.fn().mockResolvedValue([]), + pauseTask: vi.fn().mockResolvedValue(undefined), + logEntry: vi.fn().mockResolvedValue(undefined), + }; +} + +const actionDecision = { + disposition: "require-approval", + category: "command_execution", + toolName: "bash", + operation: "shell command", + summary: "bash: shell command", + resourceType: "command", + approvalDedupeKey: "approval-dedupe-key", + metadata: {}, +}; + +const heartbeatAgent = { id: "agent-1", name: "Ada", permissionPolicy: undefined }; + +/** + * FNXC:StructuralMail 2026-08-09-07:16: + * Approval mail is produced by four independently dispatched gate closures. Exercise those + * closures, rather than only scanning their source, so future refactors cannot silently drop a + * mailbox write from one executor or heartbeat gate path. + */ +describe("approval-mail emission", () => { + it("writes one reference-only approval mailbox item with a deterministic key", async () => { + const messageStore = createMessageStore(); + await emitApprovalMail({ messageStore: messageStore as never, approvalRequestId: "approval-1", toolName: "fn_bash", taskId: "FN-1", agentName: "Ada" }); + + expect(messageStore.sendMessageOnce).toHaveBeenCalledWith( + expect.objectContaining({ + toId: DASHBOARD_USER_ID, + metadata: { mailKind: "approval", approvalRequestId: "approval-1", taskId: "FN-1" }, + }), + "approval-mail:approval-1", + ); + const message = messageStore.persisted.get("approval-mail:approval-1") as { metadata: Record }; + expect(message.metadata).not.toHaveProperty("status"); + expect(message.metadata).not.toHaveProperty("decision"); + }); + + it("keeps a deduped re-raised approval to one durable mailbox row", async () => { + const messageStore = createMessageStore(); + const input = { messageStore: messageStore as never, approvalRequestId: "approval-repeat", toolName: "bash" }; + + await emitApprovalMail(input); + await emitApprovalMail(input); + + expect(messageStore.sendMessageOnce).toHaveBeenCalledTimes(2); + expect(messageStore.persisted).toHaveLength(1); + }); + + it("swallows mail failures and supports no store", async () => { + await expect(emitApprovalMail({ messageStore: createMessageStore({ reject: true }) as never, approvalRequestId: "a", toolName: "tool" })).resolves.toBeUndefined(); + await expect(emitApprovalMail({ approvalRequestId: "a", toolName: "tool" })).resolves.toBeUndefined(); + }); + + it("emits approval mail from both executor gate closures without interrupting their pauses", async () => { + const messageStore = createMessageStore(); + const store = createExecutorStore(); + const executor = new TaskExecutor(store as never, "/tmp/test", { messageStore: messageStore as never }); + vi.spyOn(executor, "awaitAbortInFlightTaskWork").mockResolvedValue(undefined); + + const actionContext = (executor as any).buildActionGateContext("FN-action", null, undefined); + const permanentContext = (executor as any).buildPermanentAgentGatingContext("FN-permanent", null, undefined); + await actionContext.pauseForApproval({ approvalRequestId: "executor-action", decision: actionDecision }); + await permanentContext.pauseForApproval({ approvalRequestId: "executor-permanent", toolName: "git_push" }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(store.pauseTask).toHaveBeenCalledTimes(2); + expect(messageStore.persisted).toHaveLength(2); + expect([...messageStore.persisted.values()]).toEqual(expect.arrayContaining([ + expect.objectContaining({ toId: DASHBOARD_USER_ID, metadata: expect.objectContaining({ mailKind: "approval", approvalRequestId: "executor-action" }) }), + expect.objectContaining({ toId: DASHBOARD_USER_ID, metadata: expect.objectContaining({ mailKind: "approval", approvalRequestId: "executor-permanent" }) }), + ])); + }); + + it("emits approval mail from both heartbeat gate closures and tolerates mailbox failure", async () => { + const messageStore = createMessageStore({ reject: true }); + const taskStore = { pauseTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) }; + const agentStore = { + updateAgentState: vi.fn().mockResolvedValue(undefined), + updateAgent: vi.fn().mockResolvedValue(undefined), + }; + const monitor = new HeartbeatMonitor({ store: agentStore as never, taskStore: taskStore as never, messageStore: messageStore as never, rootDir: "/tmp/test" }); + + const actionContext = (monitor as any).buildActionGateContext(heartbeatAgent, "FN-heartbeat-action", "run-1"); + const permanentContext = (monitor as any).buildPermanentAgentGatingContext(heartbeatAgent, "FN-heartbeat-permanent", "run-1"); + await expect(actionContext.pauseForApproval({ approvalRequestId: "heartbeat-action", decision: actionDecision })).resolves.toBeUndefined(); + await expect(permanentContext.pauseForApproval({ approvalRequestId: "heartbeat-permanent", toolName: "git_push" })).resolves.toBeUndefined(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(taskStore.pauseTask).toHaveBeenCalledTimes(2); + expect(agentStore.updateAgentState).toHaveBeenCalledTimes(2); + expect(messageStore.sendMessageOnce).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ mailKind: "approval", approvalRequestId: "heartbeat-action" }) }), "approval-mail:heartbeat-action"); + expect(messageStore.sendMessageOnce).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ mailKind: "approval", approvalRequestId: "heartbeat-permanent" }) }), "approval-mail:heartbeat-permanent"); + }); + + it("keeps all gate paths usable without a message store", async () => { + const taskStore = { pauseTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) }; + const agentStore = { updateAgentState: vi.fn().mockResolvedValue(undefined), updateAgent: vi.fn().mockResolvedValue(undefined) }; + const monitor = new HeartbeatMonitor({ store: agentStore as never, taskStore: taskStore as never, rootDir: "/tmp/test" }); + + await expect((monitor as any).buildActionGateContext(heartbeatAgent, "FN-no-mail", "run-1").pauseForApproval({ approvalRequestId: "no-mail", decision: actionDecision })).resolves.toBeUndefined(); + expect(taskStore.pauseTask).toHaveBeenCalledOnce(); + expect(agentStore.updateAgentState).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index e099999bde..0d2af9f3b7 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -44,6 +44,7 @@ import { Type, type Static } from "@earendil-works/pi-ai"; import { createHash } from "node:crypto"; import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createMissionTools, createIdeationTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; import { AgentLogger } from "./agents/agent-logger.js"; +import { emitApprovalMail } from "./agents/approval-mail.js"; import { resolveAgentInstructionsWithRatings, buildPluginPromptSection, @@ -1099,6 +1100,7 @@ export class HeartbeatMonitor { `Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`, ); } + void emitApprovalMail({ messageStore: this.messageStore, approvalRequestId, toolName: decision.toolName, taskId, agentId: agent.id, agentName: agent.name }); await this.store.updateAgentState(agent.id, "paused"); await this.store.updateAgent(agent.id, { pauseReason: "awaiting-approval" }); }, @@ -1171,6 +1173,7 @@ export class HeartbeatMonitor { } await this.store.updateAgentState(agent.id, "paused"); await this.store.updateAgent(agent.id, { pauseReason: "awaiting-approval" }); + void emitApprovalMail({ messageStore: this.messageStore, approvalRequestId, toolName, taskId, agentId: agent.id, agentName: agent.name }); }, }; } diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 0a81872ed2..8d868a514d 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -560,6 +560,13 @@ export const sendMessageParams = Type.Object({ reply_to_message_id: Type.Optional( Type.String({ description: "Optional ID of the message you are replying to. Parent-based recipient inference is allowed only when that message was addressed to you." }), ), + mail_kind: Type.Optional(Type.Union([ + Type.Literal("message"), Type.Literal("report"), Type.Literal("approval"), + ], { description: "Structural mail kind. Use report for a composed writeup; approval is engine-managed." })), + report: Type.Optional(Type.Object({ + title: Type.String({ description: "Report title" }), + sections: Type.Array(Type.Object({ heading: Type.String(), body: Type.String() }), { description: "Non-empty report sections" }), + }, { description: "Structured report payload for mail" })), }); export const readMessagesParams = Type.Object({ @@ -5648,7 +5655,7 @@ export function createSendMessageTool( "Send a message to another agent or user. The recipient will be woken if they have " + "`messageResponseMode: 'immediate'` configured. When replying, include `reply_to_message_id`; omit " + "`to_id` to reply to that message's sender only when the parent was addressed to you. Otherwise provide " + - "the exact recipient ID and appropriate type explicitly.", + "the exact recipient ID and appropriate type explicitly. Use mail for structured reports and approval items; use chat for quick back-and-forth.", parameters: sendMessageParams, // eslint-disable-next-line @typescript-eslint/no-explicit-any execute: async (_id: string, params: Static, _signal?: any, _onUpdate?: any, _ctx?: any) => { @@ -5667,6 +5674,17 @@ export function createSendMessageTool( } try { + if (params.mail_kind === "approval") { + return { content: [{ type: "text" as const, text: "ERROR: approval mail is emitted by the engine only" }], details: {} }; + } + if (params.mail_kind === "report" && !params.report) { + return { content: [{ type: "text" as const, text: "ERROR: mail_kind report requires report" }], details: {} }; + } + if (params.report) { + if (!params.report.title.trim()) return { content: [{ type: "text" as const, text: "ERROR: report.title must be a non-empty string" }], details: {} }; + if (params.report.sections.length === 0) return { content: [{ type: "text" as const, text: "ERROR: report.sections must not be empty" }], details: {} }; + if (params.report.sections.some((section) => !section.heading.trim() || !section.body.trim())) return { content: [{ type: "text" as const, text: "ERROR: report sections require non-empty heading and body" }], details: {} }; + } const replyToMessageId = params.reply_to_message_id?.trim(); if (params.reply_to_message_id !== undefined && !replyToMessageId) { @@ -5753,7 +5771,13 @@ export function createSendMessageTool( toType: recipient.type, content, type: messageType, - ...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}), + ...((replyToMessageId || params.mail_kind || params.report) ? { + metadata: { + ...(replyToMessageId ? { replyTo: { messageId: replyToMessageId } } : {}), + ...(params.mail_kind ? { mailKind: params.mail_kind } : {}), + ...(params.report ? { report: params.report } : {}), + }, + } : {}), }), correlation: { kind: "direct", fromAgentId, toId: recipient.id }, }, options?.autoRecovery ?? { mode: "deterministic-only", maxRetries: 3 }, async () => { diff --git a/packages/engine/src/agents/approval-mail.ts b/packages/engine/src/agents/approval-mail.ts new file mode 100644 index 0000000000..bc146e8aea --- /dev/null +++ b/packages/engine/src/agents/approval-mail.ts @@ -0,0 +1,40 @@ +import { DASHBOARD_USER_ID, type MessageCreateInput, type MessageStore } from "@fusion/core"; +import { createLogger } from "../logger.js"; + +const approvalMailLog = createLogger("approval-mail"); + +/** + * FNXC:StructuralMail 2026-08-09-07:16: + * Pending-approval lookup deliberately re-invokes its pause callbacks, so mailbox delivery is + * idempotent by approvalRequestId. The write is fail-soft: mailbox persistence must never break + * the approval pause that protects the gated action. + */ +export async function emitApprovalMail(input: { + messageStore?: Pick; + approvalRequestId: string; + toolName: string; + taskId?: string; + agentId?: string; + agentName?: string; +}): Promise { + try { + if (!input.messageStore?.sendMessageOnce) return; + const requester = input.agentName ?? input.agentId ?? "An agent"; + const message: MessageCreateInput = { + fromId: "system", + fromType: "system", + toId: DASHBOARD_USER_ID, + toType: "user", + type: "system", + content: `**Approval required for ${input.toolName}**\n\n${requester} requested approval before this action can continue.`, + metadata: { + mailKind: "approval", + approvalRequestId: input.approvalRequestId, + ...(input.taskId ? { taskId: input.taskId } : {}), + }, + }; + await input.messageStore.sendMessageOnce(message, `approval-mail:${input.approvalRequestId}`); + } catch (error) { + approvalMailLog.warn(`approval mailbox message failed: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 25054cdd48..d0fce685dc 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -204,6 +204,7 @@ import { import { BranchAttributionError, filterFilesToOwnTaskCommits } from "./execution/branch-attribution.js"; import { resolveIntegrationBranch } from "./merge/integration-branch.js"; import { AgentLogger } from "./agents/agent-logger.js"; +import { emitApprovalMail } from "./agents/approval-mail.js"; import { createLogger, executorLog, reviewerLog, formatError } from "./logger.js"; import { TokenCapDetector } from "./errors/token-cap-detector.js"; import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./errors/usage-limit-detector.js"; @@ -2937,6 +2938,7 @@ export class TaskExecutor { executorLog.warn(`${taskId}: failed to suspend in-flight session while awaiting approval: ${error instanceof Error ? error.message : String(error)}`); }); } + void emitApprovalMail({ messageStore: this.options.messageStore, approvalRequestId, toolName: decision.toolName, taskId, agentId: actorId, agentName: actorName }); if (agent && this.options.agentStore) { await this.options.agentStore.updateAgentState(agent.id, "paused"); await this.options.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" }); @@ -3028,6 +3030,7 @@ export class TaskExecutor { this.approvalSuspended.delete(taskId); throw error; } + void emitApprovalMail({ messageStore: this.options.messageStore, approvalRequestId, toolName, taskId, agentId: actorId, agentName: actorName }); }, }; }