@stigmer/react 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/AgentPicker.d.ts +10 -1
- package/agent/AgentPicker.d.ts.map +1 -1
- package/agent/AgentPicker.js +2 -2
- package/agent/AgentPicker.js.map +1 -1
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +22 -5
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelTemplatesDialog.d.ts +53 -0
- package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
- package/channel/ChannelTemplatesDialog.js +150 -0
- package/channel/ChannelTemplatesDialog.js.map +1 -0
- package/channel/index.d.ts +8 -0
- package/channel/index.d.ts.map +1 -1
- package/channel/index.js +4 -0
- package/channel/index.js.map +1 -1
- package/channel/providers.d.ts +12 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +14 -2
- package/channel/providers.js.map +1 -1
- package/channel/templatePresentation.d.ts +37 -0
- package/channel/templatePresentation.d.ts.map +1 -0
- package/channel/templatePresentation.js +69 -0
- package/channel/templatePresentation.js.map +1 -0
- package/channel/useChannelTemplateList.d.ts +64 -0
- package/channel/useChannelTemplateList.d.ts.map +1 -0
- package/channel/useChannelTemplateList.js +69 -0
- package/channel/useChannelTemplateList.js.map +1 -0
- package/channel/useChannelTemplateReadiness.d.ts +53 -0
- package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
- package/channel/useChannelTemplateReadiness.js +42 -0
- package/channel/useChannelTemplateReadiness.js.map +1 -0
- package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
- package/dashboard/useDashboardFailedRuns.js +2 -0
- package/dashboard/useDashboardFailedRuns.js.map +1 -1
- package/index.d.ts +4 -2
- package/index.d.ts.map +1 -1
- package/index.js +9 -1
- package/index.js.map +1 -1
- package/library/useExportResource.d.ts +4 -3
- package/library/useExportResource.d.ts.map +1 -1
- package/library/useExportResource.js +1 -1
- package/library/useExportResource.js.map +1 -1
- package/organization/OrgProvider.d.ts +11 -4
- package/organization/OrgProvider.d.ts.map +1 -1
- package/organization/OrgProvider.js +28 -4
- package/organization/OrgProvider.js.map +1 -1
- package/package.json +4 -4
- package/resource-detail/useDeleteResource.d.ts +1 -1
- package/resource-detail/useDeleteResource.d.ts.map +1 -1
- package/resource-detail/useDeleteResource.js +5 -0
- package/resource-detail/useDeleteResource.js.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
- package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
- package/resource-workbench/components/ResourceWorkbench.js +6 -2
- package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
- package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
- package/resource-workbench/hooks/useResourceCollection.js +2 -2
- package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
- package/schedule/CadenceField.d.ts +31 -0
- package/schedule/CadenceField.d.ts.map +1 -0
- package/schedule/CadenceField.js +116 -0
- package/schedule/CadenceField.js.map +1 -0
- package/schedule/ScheduleDetailView.d.ts +58 -0
- package/schedule/ScheduleDetailView.d.ts.map +1 -0
- package/schedule/ScheduleDetailView.js +236 -0
- package/schedule/ScheduleDetailView.js.map +1 -0
- package/schedule/ScheduleForm.d.ts +43 -0
- package/schedule/ScheduleForm.d.ts.map +1 -0
- package/schedule/ScheduleForm.js +129 -0
- package/schedule/ScheduleForm.js.map +1 -0
- package/schedule/ScheduleRowActions.d.ts +20 -0
- package/schedule/ScheduleRowActions.d.ts.map +1 -0
- package/schedule/ScheduleRowActions.js +54 -0
- package/schedule/ScheduleRowActions.js.map +1 -0
- package/schedule/TimeZoneField.d.ts +32 -0
- package/schedule/TimeZoneField.d.ts.map +1 -0
- package/schedule/TimeZoneField.js +104 -0
- package/schedule/TimeZoneField.js.map +1 -0
- package/schedule/cadence.d.ts +95 -0
- package/schedule/cadence.d.ts.map +1 -0
- package/schedule/cadence.js +242 -0
- package/schedule/cadence.js.map +1 -0
- package/schedule/index.d.ts +31 -0
- package/schedule/index.d.ts.map +1 -0
- package/schedule/index.js +16 -0
- package/schedule/index.js.map +1 -0
- package/schedule/scheduleColumns.d.ts +26 -0
- package/schedule/scheduleColumns.d.ts.map +1 -0
- package/schedule/scheduleColumns.js +77 -0
- package/schedule/scheduleColumns.js.map +1 -0
- package/schedule/scheduleListFn.d.ts +50 -0
- package/schedule/scheduleListFn.d.ts.map +1 -0
- package/schedule/scheduleListFn.js +65 -0
- package/schedule/scheduleListFn.js.map +1 -0
- package/schedule/scheduleState.d.ts +43 -0
- package/schedule/scheduleState.d.ts.map +1 -0
- package/schedule/scheduleState.js +67 -0
- package/schedule/scheduleState.js.map +1 -0
- package/schedule/useCreateSchedule.d.ts +57 -0
- package/schedule/useCreateSchedule.d.ts.map +1 -0
- package/schedule/useCreateSchedule.js +61 -0
- package/schedule/useCreateSchedule.js.map +1 -0
- package/schedule/useResumeSchedule.d.ts +30 -0
- package/schedule/useResumeSchedule.d.ts.map +1 -0
- package/schedule/useResumeSchedule.js +45 -0
- package/schedule/useResumeSchedule.js.map +1 -0
- package/schedule/useSchedule.d.ts +39 -0
- package/schedule/useSchedule.d.ts.map +1 -0
- package/schedule/useSchedule.js +44 -0
- package/schedule/useSchedule.js.map +1 -0
- package/schedule/useScheduleCount.d.ts +38 -0
- package/schedule/useScheduleCount.d.ts.map +1 -0
- package/schedule/useScheduleCount.js +30 -0
- package/schedule/useScheduleCount.js.map +1 -0
- package/schedule/useScheduleList.d.ts +48 -0
- package/schedule/useScheduleList.d.ts.map +1 -0
- package/schedule/useScheduleList.js +43 -0
- package/schedule/useScheduleList.js.map +1 -0
- package/schedule/useSetScheduleEnabled.d.ts +31 -0
- package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
- package/schedule/useSetScheduleEnabled.js +53 -0
- package/schedule/useSetScheduleEnabled.js.map +1 -0
- package/schedule/useTriggerSchedule.d.ts +28 -0
- package/schedule/useTriggerSchedule.d.ts.map +1 -0
- package/schedule/useTriggerSchedule.js +43 -0
- package/schedule/useTriggerSchedule.js.map +1 -0
- package/search/useResourceCount.d.ts +2 -1
- package/search/useResourceCount.d.ts.map +1 -1
- package/search/useResourceCount.js +6 -1
- package/search/useResourceCount.js.map +1 -1
- package/src/agent/AgentPicker.tsx +13 -1
- package/src/channel/AgentChannelsPanel.tsx +43 -1
- package/src/channel/ChannelTemplatesDialog.tsx +433 -0
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
- package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
- package/src/channel/__tests__/providers.test.ts +9 -0
- package/src/channel/__tests__/templatePresentation.test.ts +96 -0
- package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
- package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
- package/src/channel/index.ts +15 -0
- package/src/channel/providers.ts +26 -2
- package/src/channel/templatePresentation.ts +82 -0
- package/src/channel/useChannelTemplateList.ts +123 -0
- package/src/channel/useChannelTemplateReadiness.ts +69 -0
- package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
- package/src/dashboard/useDashboardFailedRuns.ts +2 -0
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +23 -2
- package/src/index.ts +65 -0
- package/src/library/useExportResource.ts +4 -3
- package/src/organization/OrgProvider.tsx +29 -4
- package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
- package/src/resource-detail/useDeleteResource.ts +11 -1
- package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
- package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
- package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
- package/src/schedule/CadenceField.tsx +365 -0
- package/src/schedule/ScheduleDetailView.tsx +583 -0
- package/src/schedule/ScheduleForm.tsx +405 -0
- package/src/schedule/ScheduleRowActions.tsx +124 -0
- package/src/schedule/TimeZoneField.tsx +199 -0
- package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
- package/src/schedule/__tests__/cadence.test.ts +278 -0
- package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
- package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
- package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
- package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
- package/src/schedule/__tests__/scheduleState.test.ts +133 -0
- package/src/schedule/cadence.ts +298 -0
- package/src/schedule/index.ts +57 -0
- package/src/schedule/scheduleColumns.tsx +118 -0
- package/src/schedule/scheduleListFn.ts +108 -0
- package/src/schedule/scheduleState.ts +96 -0
- package/src/schedule/useCreateSchedule.ts +88 -0
- package/src/schedule/useResumeSchedule.ts +67 -0
- package/src/schedule/useSchedule.ts +67 -0
- package/src/schedule/useScheduleCount.ts +57 -0
- package/src/schedule/useScheduleList.ts +83 -0
- package/src/schedule/useSetScheduleEnabled.ts +84 -0
- package/src/schedule/useTriggerSchedule.ts +65 -0
- package/src/search/__tests__/useResourceCount.test.tsx +89 -0
- package/src/search/useResourceCount.ts +8 -2
- package/src/workflow/WorkflowDashboard.tsx +1 -0
- package/src/workflow/useWorkflowExecutionList.ts +13 -2
- package/styles.css +1 -1
- package/workflow/CanvasTransitionEdge.d.ts +1 -1
- package/workflow/WorkflowDashboard.d.ts.map +1 -1
- package/workflow/WorkflowDashboard.js +1 -0
- package/workflow/WorkflowDashboard.js.map +1 -1
- package/workflow/useWorkflowExecutionList.d.ts +10 -0
- package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionList.js +3 -2
- package/workflow/useWorkflowExecutionList.js.map +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderHook } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
|
|
5
|
+
import { AgentChannelInstallState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/status_pb";
|
|
6
|
+
import { DeploymentModeContext } from "../../deployment-mode";
|
|
7
|
+
import { useChannelTemplateReadiness } from "../useChannelTemplateReadiness";
|
|
8
|
+
|
|
9
|
+
function makeChannel(overrides?: {
|
|
10
|
+
installState?: AgentChannelInstallState;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
proactiveMessagingEnabled?: boolean;
|
|
13
|
+
}): AgentChannel {
|
|
14
|
+
return {
|
|
15
|
+
metadata: { id: "ach_1", org: "acme", slug: "wa-main" },
|
|
16
|
+
spec: {
|
|
17
|
+
enabled: overrides?.enabled ?? true,
|
|
18
|
+
proactiveMessagingEnabled: overrides?.proactiveMessagingEnabled ?? true,
|
|
19
|
+
},
|
|
20
|
+
status: {
|
|
21
|
+
installState:
|
|
22
|
+
overrides?.installState ?? AgentChannelInstallState.installed,
|
|
23
|
+
},
|
|
24
|
+
} as unknown as AgentChannel;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wrapper(mode: "cloud" | "local" = "cloud") {
|
|
28
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
29
|
+
return (
|
|
30
|
+
<DeploymentModeContext.Provider value={mode}>
|
|
31
|
+
{children}
|
|
32
|
+
</DeploymentModeContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readinessOf(
|
|
38
|
+
channel: AgentChannel,
|
|
39
|
+
mode: "cloud" | "local" = "cloud",
|
|
40
|
+
) {
|
|
41
|
+
const { result } = renderHook(() => useChannelTemplateReadiness(channel), {
|
|
42
|
+
wrapper: wrapper(mode),
|
|
43
|
+
});
|
|
44
|
+
return result.current;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("useChannelTemplateReadiness", () => {
|
|
48
|
+
it("is ready for an installed, enabled, proactive channel on cloud", () => {
|
|
49
|
+
expect(readinessOf(makeChannel())).toEqual({ status: "ready" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is cloud-only on a local deployment — the template registry is cloud runtime", () => {
|
|
53
|
+
expect(readinessOf(makeChannel(), "local")).toEqual({
|
|
54
|
+
status: "cloud-only",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is not-installed for a pending install", () => {
|
|
59
|
+
expect(
|
|
60
|
+
readinessOf(
|
|
61
|
+
makeChannel({
|
|
62
|
+
installState: AgentChannelInstallState.pending_install,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
).toEqual({ status: "not-installed" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is not-installed for a revoked install — coarse on purpose, describeChannel carries the distinction", () => {
|
|
69
|
+
expect(
|
|
70
|
+
readinessOf(
|
|
71
|
+
makeChannel({ installState: AgentChannelInstallState.revoked }),
|
|
72
|
+
),
|
|
73
|
+
).toEqual({ status: "not-installed" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is channel-off when the serving switch is off", () => {
|
|
77
|
+
expect(readinessOf(makeChannel({ enabled: false }))).toEqual({
|
|
78
|
+
status: "channel-off",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is not-proactive when the grant is missing", () => {
|
|
83
|
+
expect(
|
|
84
|
+
readinessOf(makeChannel({ proactiveMessagingEnabled: false })),
|
|
85
|
+
).toEqual({ status: "not-proactive" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// The gate order is the server's (ChannelMessagingReach.resolveDirect:
|
|
89
|
+
// installed → enabled → proactive). A channel failing several gates
|
|
90
|
+
// must report the FIRST, so the teaching state names the next fix.
|
|
91
|
+
it("resolves gates in the server's order — first failure wins", () => {
|
|
92
|
+
expect(
|
|
93
|
+
readinessOf(
|
|
94
|
+
makeChannel({
|
|
95
|
+
installState: AgentChannelInstallState.pending_install,
|
|
96
|
+
enabled: false,
|
|
97
|
+
proactiveMessagingEnabled: false,
|
|
98
|
+
}),
|
|
99
|
+
"local",
|
|
100
|
+
),
|
|
101
|
+
).toEqual({ status: "cloud-only" });
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
readinessOf(
|
|
105
|
+
makeChannel({
|
|
106
|
+
installState: AgentChannelInstallState.pending_install,
|
|
107
|
+
enabled: false,
|
|
108
|
+
proactiveMessagingEnabled: false,
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
).toEqual({ status: "not-installed" });
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
readinessOf(
|
|
115
|
+
makeChannel({ enabled: false, proactiveMessagingEnabled: false }),
|
|
116
|
+
),
|
|
117
|
+
).toEqual({ status: "channel-off" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("treats a channel with no status/spec as not yet installed", () => {
|
|
121
|
+
const bare = { metadata: { id: "ach_2" } } as unknown as AgentChannel;
|
|
122
|
+
expect(readinessOf(bare)).toEqual({ status: "not-installed" });
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/channel/index.ts
CHANGED
|
@@ -17,6 +17,9 @@ export {
|
|
|
17
17
|
} from "./providerPresentation.js";
|
|
18
18
|
export type { ChannelProviderPresentation } from "./providerPresentation.js";
|
|
19
19
|
|
|
20
|
+
export { templateStatusPhase, splitTemplateBody } from "./templatePresentation.js";
|
|
21
|
+
export type { TemplateBodySegment } from "./templatePresentation.js";
|
|
22
|
+
|
|
20
23
|
export { useAgentChannel } from "./useAgentChannel.js";
|
|
21
24
|
export type { UseAgentChannelReturn } from "./useAgentChannel.js";
|
|
22
25
|
|
|
@@ -43,9 +46,18 @@ export type {
|
|
|
43
46
|
|
|
44
47
|
export { useChannelToolReadiness } from "./useChannelToolReadiness.js";
|
|
45
48
|
|
|
49
|
+
export { useChannelTemplateReadiness } from "./useChannelTemplateReadiness.js";
|
|
50
|
+
export type { ChannelTemplateReadiness } from "./useChannelTemplateReadiness.js";
|
|
51
|
+
|
|
46
52
|
export { useChannelSessions } from "./useChannelSessions.js";
|
|
47
53
|
export type { UseChannelSessionsReturn } from "./useChannelSessions.js";
|
|
48
54
|
|
|
55
|
+
export { useChannelTemplateList } from "./useChannelTemplateList.js";
|
|
56
|
+
export type {
|
|
57
|
+
UseChannelTemplateListOptions,
|
|
58
|
+
UseChannelTemplateListReturn,
|
|
59
|
+
} from "./useChannelTemplateList.js";
|
|
60
|
+
|
|
49
61
|
export { AgentChannelsPanel } from "./AgentChannelsPanel.js";
|
|
50
62
|
export type { AgentChannelsPanelProps } from "./AgentChannelsPanel.js";
|
|
51
63
|
|
|
@@ -63,3 +75,6 @@ export type { ChannelToolCredentialsProps } from "./ChannelToolCredentials.js";
|
|
|
63
75
|
|
|
64
76
|
export { ChannelConversationsDialog } from "./ChannelConversationsDialog.js";
|
|
65
77
|
export type { ChannelConversationsDialogProps } from "./ChannelConversationsDialog.js";
|
|
78
|
+
|
|
79
|
+
export { ChannelTemplatesDialog } from "./ChannelTemplatesDialog.js";
|
|
80
|
+
export type { ChannelTemplatesDialogProps } from "./ChannelTemplatesDialog.js";
|
package/src/channel/providers.ts
CHANGED
|
@@ -43,6 +43,18 @@ export interface ChannelProviderDescriptor {
|
|
|
43
43
|
readonly Icon: ComponentType<{ readonly className?: string }>;
|
|
44
44
|
/** How this provider's install flow runs. */
|
|
45
45
|
readonly installStyle: ChannelInstallStyle;
|
|
46
|
+
/**
|
|
47
|
+
* Whether this provider has a message-template registry the console
|
|
48
|
+
* can list (`listTemplates`). Mirrors the server's proactive-sender
|
|
49
|
+
* registry, which is the de-facto provider filter behind that RPC —
|
|
50
|
+
* a provider with no registered sender answers `PROVIDER_UNSUPPORTED`.
|
|
51
|
+
*
|
|
52
|
+
* `false` for Slack is not a gap: Slack has no template concept at
|
|
53
|
+
* all, so consumers hide the Templates affordance entirely rather
|
|
54
|
+
* than disable it — a disabled item would falsely imply it may work
|
|
55
|
+
* there one day.
|
|
56
|
+
*/
|
|
57
|
+
readonly supportsMessageTemplates: boolean;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/**
|
|
@@ -55,8 +67,20 @@ export interface ChannelProviderDescriptor {
|
|
|
55
67
|
* demos. A third entry is the signal to fold these into a provider menu.
|
|
56
68
|
*/
|
|
57
69
|
export const CHANNEL_PROVIDERS: readonly ChannelProviderDescriptor[] = [
|
|
58
|
-
{
|
|
59
|
-
|
|
70
|
+
{
|
|
71
|
+
id: "slack",
|
|
72
|
+
label: "Slack",
|
|
73
|
+
Icon: SlackMarkIcon,
|
|
74
|
+
installStyle: "redirect",
|
|
75
|
+
supportsMessageTemplates: false,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "whatsapp",
|
|
79
|
+
label: "WhatsApp",
|
|
80
|
+
Icon: WhatsAppMarkIcon,
|
|
81
|
+
installStyle: "direct",
|
|
82
|
+
supportsMessageTemplates: true,
|
|
83
|
+
},
|
|
60
84
|
];
|
|
61
85
|
|
|
62
86
|
/**
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { StatusPhase } from "../resource-workbench/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Display helpers for provider message templates — the template
|
|
5
|
+
* counterpart of {@link ChannelProviderPresentation}'s module split:
|
|
6
|
+
* `ChannelTemplatesDialog` renders, this module decides how a
|
|
7
|
+
* provider-verbatim template projects onto the SDK's display
|
|
8
|
+
* vocabulary. Pure functions, one writer each.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a provider-verbatim template status onto a {@link StatusPhase}
|
|
13
|
+
* for `StatusBadge` coloring.
|
|
14
|
+
*
|
|
15
|
+
* The wire carries the provider's own vocabulary, never a Stigmer enum
|
|
16
|
+
* (DD-003 D6) — so the caller renders the verbatim status string as the
|
|
17
|
+
* badge label and uses this phase only for the color. WhatsApp's
|
|
18
|
+
* observed set exceeds the documented five (IN_APPEAL, LIMIT_EXCEEDED,
|
|
19
|
+
* PENDING_DELETION, DELETED occur, and Meta can add more), so unknown
|
|
20
|
+
* statuses deliberately land on `draft` — a neutral tone that renders
|
|
21
|
+
* the provider's word without claiming to understand it.
|
|
22
|
+
*/
|
|
23
|
+
export function templateStatusPhase(status: string): StatusPhase {
|
|
24
|
+
switch (status) {
|
|
25
|
+
case "APPROVED":
|
|
26
|
+
return "ready";
|
|
27
|
+
case "PENDING":
|
|
28
|
+
case "IN_APPEAL":
|
|
29
|
+
return "pending";
|
|
30
|
+
case "PAUSED":
|
|
31
|
+
case "LIMIT_EXCEEDED":
|
|
32
|
+
return "degraded";
|
|
33
|
+
case "REJECTED":
|
|
34
|
+
case "DISABLED":
|
|
35
|
+
return "failed";
|
|
36
|
+
case "PENDING_DELETION":
|
|
37
|
+
case "DELETED":
|
|
38
|
+
return "disabled";
|
|
39
|
+
default:
|
|
40
|
+
return "draft";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One segment of a template body: literal text or a `{{...}}` placeholder. */
|
|
45
|
+
export interface TemplateBodySegment {
|
|
46
|
+
readonly kind: "text" | "placeholder";
|
|
47
|
+
/** The verbatim slice, braces included for placeholders (`"{{1}}"`). */
|
|
48
|
+
readonly value: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The provider's placeholder shape: `{{1}}` (positional) or
|
|
53
|
+
* `{{customer_name}}` (named). Single braces and unbalanced braces are
|
|
54
|
+
* literal text — the provider would not have accepted them either.
|
|
55
|
+
*/
|
|
56
|
+
const PLACEHOLDER = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/g;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split a template body into text and placeholder segments so the
|
|
60
|
+
* placeholders — the parts the agent fills at send time — can render
|
|
61
|
+
* visually distinct from the literal copy.
|
|
62
|
+
*
|
|
63
|
+
* The concatenation of all segment values is always the input,
|
|
64
|
+
* verbatim: this function classifies, it never rewrites.
|
|
65
|
+
*/
|
|
66
|
+
export function splitTemplateBody(
|
|
67
|
+
body: string,
|
|
68
|
+
): readonly TemplateBodySegment[] {
|
|
69
|
+
const segments: TemplateBodySegment[] = [];
|
|
70
|
+
let lastIndex = 0;
|
|
71
|
+
for (const match of body.matchAll(PLACEHOLDER)) {
|
|
72
|
+
if (match.index > lastIndex) {
|
|
73
|
+
segments.push({ kind: "text", value: body.slice(lastIndex, match.index) });
|
|
74
|
+
}
|
|
75
|
+
segments.push({ kind: "placeholder", value: match[0] });
|
|
76
|
+
lastIndex = match.index + match[0].length;
|
|
77
|
+
}
|
|
78
|
+
if (lastIndex < body.length) {
|
|
79
|
+
segments.push({ kind: "text", value: body.slice(lastIndex) });
|
|
80
|
+
}
|
|
81
|
+
return segments;
|
|
82
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import type { ChannelTemplate } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
6
|
+
import { ListChannelTemplatesInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
7
|
+
import { useStigmer } from "../hooks.js";
|
|
8
|
+
import { useFetch } from "../internal/useFetch.js";
|
|
9
|
+
|
|
10
|
+
/** Options for {@link useChannelTemplateList}. */
|
|
11
|
+
export interface UseChannelTemplateListOptions {
|
|
12
|
+
/**
|
|
13
|
+
* When `true`, only templates the provider will accept for sending
|
|
14
|
+
* are returned (WhatsApp: status APPROVED). The console leaves it
|
|
15
|
+
* unset to render every status with its badge; agent-composition
|
|
16
|
+
* surfaces set it so a model never drafts against a paused or
|
|
17
|
+
* pending template.
|
|
18
|
+
*/
|
|
19
|
+
readonly approvedOnly?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Return value of {@link useChannelTemplateList}. */
|
|
23
|
+
export interface UseChannelTemplateListReturn {
|
|
24
|
+
/**
|
|
25
|
+
* The channel provider's templates, verbatim, in provider order.
|
|
26
|
+
* Empty while loading, on error, and when the registry answered with
|
|
27
|
+
* no templates — `templates.length === 0 && !isLoading && !error`
|
|
28
|
+
* means the provider reported none, which callers should phrase as
|
|
29
|
+
* "none found" rather than "none exist" (an empty success is
|
|
30
|
+
* indistinguishable from a filtered or truncated one).
|
|
31
|
+
*/
|
|
32
|
+
readonly templates: readonly ChannelTemplate[];
|
|
33
|
+
/** `true` while the initial fetch is in flight and no data is shown. */
|
|
34
|
+
readonly isLoading: boolean;
|
|
35
|
+
/** `true` while a background refetch is in flight and stale data is shown. */
|
|
36
|
+
readonly isRefetching: boolean;
|
|
37
|
+
/** Error from the last failed request, or `null` when healthy. */
|
|
38
|
+
readonly error: Error | null;
|
|
39
|
+
/** Re-fetch the templates from the server. */
|
|
40
|
+
readonly refetch: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How often to re-read the registry while any template is PENDING.
|
|
45
|
+
* Matches the server's template-cache TTL (60s) — polling faster only
|
|
46
|
+
* re-reads a cached answer.
|
|
47
|
+
*/
|
|
48
|
+
const PENDING_POLL_INTERVAL_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Data hook that lists a channel's message templates as the channel's
|
|
52
|
+
* provider registry reports them (`listTemplates`) — every field
|
|
53
|
+
* provider-verbatim, including approval `status`, the provider's
|
|
54
|
+
* `rejectionReason`, and Stigmer's own `unsupportedReason` (why this
|
|
55
|
+
* platform version cannot send an otherwise-approved template).
|
|
56
|
+
*
|
|
57
|
+
* The channel is addressed by `slug` + `org` (not id), because that is
|
|
58
|
+
* the RPC's key — the provider registry is derived from the channel's
|
|
59
|
+
* phone number, not stored on any Stigmer resource. Pass an empty
|
|
60
|
+
* `channelSlug` or `org` to skip fetching (stable no-op).
|
|
61
|
+
*
|
|
62
|
+
* While any entry is PENDING the hook polls every 60 seconds — the
|
|
63
|
+
* server's registry-cache TTL, so a faster interval would only re-read
|
|
64
|
+
* a cached answer. There is deliberately no `refetchInterval` option:
|
|
65
|
+
* the pending-only conditional poll is the hook's policy, and
|
|
66
|
+
* `approvedOnly` (a wire field) is the only knob exposed.
|
|
67
|
+
*
|
|
68
|
+
* Results are never cached across mounts: a template's approval status
|
|
69
|
+
* can change on the provider's side at any time, and rendering a stale
|
|
70
|
+
* "APPROVED" is exactly what this surface must not do.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```tsx
|
|
74
|
+
* const { templates, isLoading, error } = useChannelTemplateList(
|
|
75
|
+
* channel.metadata?.slug ?? "",
|
|
76
|
+
* channel.metadata?.org ?? "",
|
|
77
|
+
* );
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function useChannelTemplateList(
|
|
81
|
+
channelSlug: string,
|
|
82
|
+
org: string,
|
|
83
|
+
options?: UseChannelTemplateListOptions,
|
|
84
|
+
): UseChannelTemplateListReturn {
|
|
85
|
+
const stigmer = useStigmer();
|
|
86
|
+
const approvedOnly = options?.approvedOnly ?? false;
|
|
87
|
+
|
|
88
|
+
// The pending-only poll rides useFetch's own guarded interval (the
|
|
89
|
+
// ONE polling implementation) rather than a second setInterval here.
|
|
90
|
+
// The interval cannot be derived inline — it depends on the data the
|
|
91
|
+
// same useFetch call returns — so it round-trips through state: data
|
|
92
|
+
// arrives, the effect below flips the interval, useFetch re-arms.
|
|
93
|
+
const [pollInterval, setPollInterval] = useState<number | false>(false);
|
|
94
|
+
|
|
95
|
+
const fetchFn =
|
|
96
|
+
channelSlug && org
|
|
97
|
+
? async () => {
|
|
98
|
+
const result = await stigmer.agentChannel.listTemplates(
|
|
99
|
+
create(ListChannelTemplatesInputSchema, {
|
|
100
|
+
channel: channelSlug,
|
|
101
|
+
org,
|
|
102
|
+
approvedOnly,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
return result.entries;
|
|
106
|
+
}
|
|
107
|
+
: null;
|
|
108
|
+
|
|
109
|
+
const { data: templates, isLoading, isRefetching, error, refetch } =
|
|
110
|
+
useFetch(
|
|
111
|
+
fetchFn,
|
|
112
|
+
[channelSlug, org, approvedOnly, stigmer],
|
|
113
|
+
[] as ChannelTemplate[],
|
|
114
|
+
{ refetchInterval: pollInterval },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const hasPending = templates.some((t) => t.status === "PENDING");
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
setPollInterval(hasPending ? PENDING_POLL_INTERVAL_MS : false);
|
|
120
|
+
}, [hasPending]);
|
|
121
|
+
|
|
122
|
+
return { templates, isLoading, isRefetching, error, refetch };
|
|
123
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
|
|
4
|
+
import { AgentChannelInstallState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/status_pb";
|
|
5
|
+
import { useDeploymentMode } from "../deployment-mode.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether a channel can answer a template-registry read
|
|
9
|
+
* (`listTemplates`), and if not, which precondition fails first.
|
|
10
|
+
*
|
|
11
|
+
* - `cloud-only` — the deployment is not cloud; the template registry
|
|
12
|
+
* is part of the cloud channel runtime.
|
|
13
|
+
* - `not-installed` — the provider install has not completed (covers
|
|
14
|
+
* both pending and revoked; render the per-provider
|
|
15
|
+
* `describeChannel` sentence, which already distinguishes them).
|
|
16
|
+
* - `channel-off` — the owner's serving switch (`spec.enabled`) is off.
|
|
17
|
+
* - `not-proactive` — `spec.proactive_messaging_enabled` is not set;
|
|
18
|
+
* business-initiated messaging (and with it the template surface)
|
|
19
|
+
* is not granted on this channel.
|
|
20
|
+
* - `ready` — the read can be made.
|
|
21
|
+
*/
|
|
22
|
+
export type ChannelTemplateReadiness =
|
|
23
|
+
| { readonly status: "cloud-only" }
|
|
24
|
+
| { readonly status: "not-installed" }
|
|
25
|
+
| { readonly status: "channel-off" }
|
|
26
|
+
| { readonly status: "not-proactive" }
|
|
27
|
+
| { readonly status: "ready" };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve whether a channel's template registry can be read, without a
|
|
31
|
+
* network call — the courtesy pre-check for the Templates surface.
|
|
32
|
+
*
|
|
33
|
+
* Mirrors the server's own gate order for `listTemplates`
|
|
34
|
+
* (`ChannelMessagingReach.resolveDirect`: installed → enabled →
|
|
35
|
+
* proactive grant), answering from fields already on the fetched
|
|
36
|
+
* {@link AgentChannel}. The server stays authoritative: a `ready`
|
|
37
|
+
* verdict here only means the call is worth making, and whatever the
|
|
38
|
+
* server then says wins.
|
|
39
|
+
*
|
|
40
|
+
* Two shape choices are deliberate. `channel-off` and `not-proactive`
|
|
41
|
+
* are split although the server collapses them into one refusal —
|
|
42
|
+
* their fixes differ (flip the card's serving switch vs. grant
|
|
43
|
+
* `proactive_messaging_enabled`). And `not-installed` stays coarse
|
|
44
|
+
* across pending/revoked, because `providerPresentation`'s
|
|
45
|
+
* `describeChannel` already renders the right sentence for each.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const readiness = useChannelTemplateReadiness(channel);
|
|
50
|
+
* if (readiness.status !== "ready") return <TeachingState ... />;
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function useChannelTemplateReadiness(
|
|
54
|
+
channel: AgentChannel,
|
|
55
|
+
): ChannelTemplateReadiness {
|
|
56
|
+
const deploymentMode = useDeploymentMode();
|
|
57
|
+
|
|
58
|
+
if (deploymentMode !== "cloud") return { status: "cloud-only" };
|
|
59
|
+
if (
|
|
60
|
+
channel.status?.installState !== AgentChannelInstallState.installed
|
|
61
|
+
) {
|
|
62
|
+
return { status: "not-installed" };
|
|
63
|
+
}
|
|
64
|
+
if (!channel.spec?.enabled) return { status: "channel-off" };
|
|
65
|
+
if (!channel.spec.proactiveMessagingEnabled) {
|
|
66
|
+
return { status: "not-proactive" };
|
|
67
|
+
}
|
|
68
|
+
return { status: "ready" };
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ExecutionPhase as AgentPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import { ExecutionPhase as WorkflowPhase } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/enum_pb";
|
|
6
|
+
import { StigmerContext } from "../../context";
|
|
7
|
+
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
8
|
+
import { useDashboardFailedRuns } from "../useDashboardFailedRuns";
|
|
9
|
+
|
|
10
|
+
function createMockStigmer(overrides: {
|
|
11
|
+
agentList?: (...args: unknown[]) => Promise<unknown>;
|
|
12
|
+
workflowList?: (...args: unknown[]) => Promise<unknown>;
|
|
13
|
+
} = {}) {
|
|
14
|
+
return {
|
|
15
|
+
agentExecution: {
|
|
16
|
+
list: overrides.agentList ?? vi.fn().mockResolvedValue({ entries: [] }),
|
|
17
|
+
},
|
|
18
|
+
workflowExecution: {
|
|
19
|
+
list: overrides.workflowList ?? vi.fn().mockResolvedValue({ entries: [] }),
|
|
20
|
+
},
|
|
21
|
+
} as never;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wrapper(client: unknown) {
|
|
25
|
+
return function Wrapper({ children }: { children: ReactNode }) {
|
|
26
|
+
return (
|
|
27
|
+
<FetchCacheContext.Provider value={null}>
|
|
28
|
+
<StigmerContext.Provider value={client as never}>
|
|
29
|
+
{children}
|
|
30
|
+
</StigmerContext.Provider>
|
|
31
|
+
</FetchCacheContext.Provider>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("useDashboardFailedRuns", () => {
|
|
37
|
+
it("scopes both list requests to the active org and the FAILED phase", async () => {
|
|
38
|
+
const agentList = vi.fn().mockResolvedValue({ entries: [] });
|
|
39
|
+
const workflowList = vi.fn().mockResolvedValue({ entries: [] });
|
|
40
|
+
const client = createMockStigmer({ agentList, workflowList });
|
|
41
|
+
|
|
42
|
+
const { result } = renderHook(() => useDashboardFailedRuns("acme"), {
|
|
43
|
+
wrapper: wrapper(client),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
47
|
+
|
|
48
|
+
// Regression guard: the pre-fix hook used org only as a fetch gate and
|
|
49
|
+
// sent no org on the wire, so a brand-new org's dashboard showed other
|
|
50
|
+
// orgs' failures.
|
|
51
|
+
const agentRequest = agentList.mock.calls[0][0];
|
|
52
|
+
expect(agentRequest.org).toBe("acme");
|
|
53
|
+
expect(agentRequest.phase).toBe(AgentPhase.EXECUTION_FAILED);
|
|
54
|
+
|
|
55
|
+
const workflowRequest = workflowList.mock.calls[0][0];
|
|
56
|
+
expect(workflowRequest.org).toBe("acme");
|
|
57
|
+
expect(workflowRequest.phase).toBe(WorkflowPhase.EXECUTION_FAILED);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not fetch until an org is available", async () => {
|
|
61
|
+
const agentList = vi.fn().mockResolvedValue({ entries: [] });
|
|
62
|
+
const workflowList = vi.fn().mockResolvedValue({ entries: [] });
|
|
63
|
+
const client = createMockStigmer({ agentList, workflowList });
|
|
64
|
+
|
|
65
|
+
renderHook(() => useDashboardFailedRuns(null), {
|
|
66
|
+
wrapper: wrapper(client),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Give any wrongly-armed fetch a tick to fire.
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
71
|
+
|
|
72
|
+
expect(agentList).not.toHaveBeenCalled();
|
|
73
|
+
expect(workflowList).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("interleaves agent and workflow failures newest-first, capped at 5", async () => {
|
|
77
|
+
const ts = (secs: number) => ({ seconds: BigInt(secs), nanos: 0 });
|
|
78
|
+
const agentEntries = [1, 3, 5].map((n) => ({
|
|
79
|
+
metadata: { id: `aex_${n}`, name: `agent ${n}` },
|
|
80
|
+
spec: { agentId: `agt_${n}` },
|
|
81
|
+
status: { error: "boom", audit: { specAudit: { createdAt: ts(n) } } },
|
|
82
|
+
}));
|
|
83
|
+
const workflowEntries = [2, 4, 6].map((n) => ({
|
|
84
|
+
metadata: { id: `wex_${n}`, name: `workflow ${n}`, slug: `wf-${n}` },
|
|
85
|
+
status: { error: "boom", audit: { specAudit: { createdAt: ts(n) } } },
|
|
86
|
+
}));
|
|
87
|
+
const client = createMockStigmer({
|
|
88
|
+
agentList: vi.fn().mockResolvedValue({ entries: agentEntries }),
|
|
89
|
+
workflowList: vi.fn().mockResolvedValue({ entries: workflowEntries }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { result } = renderHook(() => useDashboardFailedRuns("acme"), {
|
|
93
|
+
wrapper: wrapper(client),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await waitFor(() => expect(result.current.failedRuns).toHaveLength(5));
|
|
97
|
+
|
|
98
|
+
expect(result.current.failedRuns.map((run) => run.id)).toEqual([
|
|
99
|
+
"wex_6",
|
|
100
|
+
"aex_5",
|
|
101
|
+
"wex_4",
|
|
102
|
+
"aex_3",
|
|
103
|
+
"wex_2",
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -48,6 +48,7 @@ export function useDashboardFailedRuns(
|
|
|
48
48
|
create(ListAgentExecutionsRequestSchema, {
|
|
49
49
|
pageSize: PAGE_SIZE,
|
|
50
50
|
phase: AgentPhase.EXECUTION_FAILED,
|
|
51
|
+
org: orgVal,
|
|
51
52
|
}),
|
|
52
53
|
);
|
|
53
54
|
return [...resp.entries] as readonly AgentExecution[];
|
|
@@ -64,6 +65,7 @@ export function useDashboardFailedRuns(
|
|
|
64
65
|
create(ListWorkflowExecutionsRequestSchema, {
|
|
65
66
|
pageSize: PAGE_SIZE,
|
|
66
67
|
phase: WorkflowPhase.EXECUTION_FAILED,
|
|
68
|
+
org: orgVal,
|
|
67
69
|
}),
|
|
68
70
|
);
|
|
69
71
|
return [...resp.entries] as readonly WorkflowExecution[];
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
fireEvent,
|
|
6
|
+
cleanup,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from "@testing-library/react";
|
|
3
9
|
import type { ReactNode } from "react";
|
|
4
10
|
import { create } from "@bufbuild/protobuf";
|
|
5
11
|
import type { Stigmer } from "@stigmer/sdk";
|
|
@@ -47,6 +53,17 @@ function renderCard(ui: ReactNode) {
|
|
|
47
53
|
|
|
48
54
|
afterEach(cleanup);
|
|
49
55
|
|
|
56
|
+
// The download flow ends in a transient-anchor click with target="_blank".
|
|
57
|
+
// Left unstubbed, happy-dom treats that click as a real popup navigation and
|
|
58
|
+
// fetches the fixture URL over the actual network — after this test has
|
|
59
|
+
// already returned, so the DNS failure gets attributed to whichever test the
|
|
60
|
+
// worker is running by then (the flake in issue #334).
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.spyOn(HTMLAnchorElement.prototype, "click")
|
|
63
|
+
.mockImplementation(() => {})
|
|
64
|
+
.mockClear();
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
describe("PlanArtifactCard — compact document card", () => {
|
|
51
68
|
it("is an accessible region with the plan's title, filename, and size", () => {
|
|
52
69
|
renderCard(
|
|
@@ -178,6 +195,7 @@ describe("PlanArtifactCard — compact document card", () => {
|
|
|
178
195
|
|
|
179
196
|
it("downloads the artifact on demand via a freshly minted URL", async () => {
|
|
180
197
|
const { stigmer, getArtifactDownloadUrl } = createStigmerMock();
|
|
198
|
+
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click");
|
|
181
199
|
render(
|
|
182
200
|
withStigmer(
|
|
183
201
|
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
@@ -191,6 +209,9 @@ describe("PlanArtifactCard — compact document card", () => {
|
|
|
191
209
|
const req = getArtifactDownloadUrl.mock.calls[0][0];
|
|
192
210
|
expect(req.executionId).toBe("aex_1");
|
|
193
211
|
expect(req.storageKey).toBe("artifacts/aex_1/plan.md");
|
|
212
|
+
// Wait for the mint-URL → anchor-click continuation INSIDE the test body;
|
|
213
|
+
// returning while it is still in flight is what let it escape.
|
|
214
|
+
await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
|
|
194
215
|
});
|
|
195
216
|
});
|
|
196
217
|
|