@microsoft/m365agentsplayground-cli 0.2.25-alpha.20260507-efe1416.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.
Files changed (51) hide show
  1. package/README.md +341 -0
  2. package/build/cardValidator.d.ts +18 -0
  3. package/build/cardValidator.d.ts.map +1 -0
  4. package/build/cardValidator.js +47 -0
  5. package/build/conversationServer.d.ts +29 -0
  6. package/build/conversationServer.d.ts.map +1 -0
  7. package/build/conversationServer.js +127 -0
  8. package/build/conversationTypes.d.ts +146 -0
  9. package/build/conversationTypes.d.ts.map +1 -0
  10. package/build/conversationTypes.js +5 -0
  11. package/build/index.d.ts +14 -0
  12. package/build/index.d.ts.map +1 -0
  13. package/build/index.js +25 -0
  14. package/build/notificationSender.d.ts +16 -0
  15. package/build/notificationSender.d.ts.map +1 -0
  16. package/build/notificationSender.js +120 -0
  17. package/build/responseCapture.d.ts +29 -0
  18. package/build/responseCapture.d.ts.map +1 -0
  19. package/build/responseCapture.js +119 -0
  20. package/build/runConversation.d.ts +17 -0
  21. package/build/runConversation.d.ts.map +1 -0
  22. package/build/runConversation.js +338 -0
  23. package/build/serverManager.d.ts +46 -0
  24. package/build/serverManager.d.ts.map +1 -0
  25. package/build/serverManager.js +149 -0
  26. package/build/start-server.d.ts +9 -0
  27. package/build/start-server.d.ts.map +1 -0
  28. package/build/start-server.js +23 -0
  29. package/build/testClient.d.ts +146 -0
  30. package/build/testClient.d.ts.map +1 -0
  31. package/build/testClient.js +434 -0
  32. package/build/types.d.ts +125 -0
  33. package/build/types.d.ts.map +1 -0
  34. package/build/types.js +7 -0
  35. package/build/websocketClient.d.ts +56 -0
  36. package/build/websocketClient.d.ts.map +1 -0
  37. package/build/websocketClient.js +129 -0
  38. package/package.json +36 -0
  39. package/src/cardValidator.ts +56 -0
  40. package/src/conversationServer.ts +147 -0
  41. package/src/conversationTypes.ts +169 -0
  42. package/src/index.ts +37 -0
  43. package/src/notificationSender.ts +135 -0
  44. package/src/responseCapture.ts +145 -0
  45. package/src/runConversation.ts +379 -0
  46. package/src/serverManager.ts +172 -0
  47. package/src/start-server.ts +26 -0
  48. package/src/testClient.ts +515 -0
  49. package/src/types.ts +155 -0
  50. package/src/websocketClient.ts +153 -0
  51. package/tsconfig.json +16 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Type definitions for multi-turn conversation execution.
3
+ */
4
+
5
+ import type { BotConfig } from "./types";
6
+ import type { CustomActivityTemplateType } from "schema";
7
+ import type { CardValidationError } from "./cardValidator";
8
+
9
+ /**
10
+ * Identity for a user persona in notification activities.
11
+ */
12
+ export interface PersonaConfig {
13
+ id: string;
14
+ name: string;
15
+ email?: string;
16
+ }
17
+
18
+ /**
19
+ * Server configuration passed per /run-conversation request.
20
+ */
21
+ export interface E2EConfig {
22
+ /** Bot endpoint URL (e.g., "http://localhost:3978/api/messages") */
23
+ botEndpoint: string;
24
+
25
+ /** Response timeout in milliseconds (default: 120000) */
26
+ timeout?: number;
27
+
28
+ /** Activity delivery mode (default: "expectReplies") */
29
+ deliveryMode?: "expectReplies" | "default";
30
+
31
+ /** Bot identity configuration */
32
+ bot?: BotConfig;
33
+
34
+ /** Named personas for notification activities */
35
+ personas?: Record<string, PersonaConfig>;
36
+
37
+ /**
38
+ * Default chat context for all "chat" turns in this conversation.
39
+ * Can be overridden per-turn via turn.chat_type.
40
+ * Default: "personal"
41
+ */
42
+ chatType?: "personal" | "group" | "channel";
43
+
44
+ /**
45
+ * Quiet-period fallback (ms) for streaming responses.
46
+ * Primary: resolves on streamType:"final" event (teams-ai / teams.ts SDK).
47
+ * Fallback: resolves after this many ms with no new updateActivity calls.
48
+ * Default: 800. Has no effect for bots with stream: false.
49
+ */
50
+ streamingSettleDelayMs?: number;
51
+ }
52
+
53
+ /**
54
+ * A single turn to execute in a conversation.
55
+ */
56
+ export interface Turn {
57
+ /** Unique identifier for this turn */
58
+ test_id: string;
59
+
60
+ /** Message text or notification body to send */
61
+ prompt: string;
62
+
63
+ /**
64
+ * Activity type. Default: "chat" (standard message).
65
+ * Any CustomActivityTemplateType value (e.g., "sendEmail", "mentionInWord", "meetingStart")
66
+ * sends the corresponding notification activity.
67
+ */
68
+ turn_type?: "chat" | "card_action" | CustomActivityTemplateType;
69
+
70
+ /**
71
+ * Chat context for "chat" turn_type. Default: "personal".
72
+ * - "personal": 1:1 personal chat (default)
73
+ * - "group": group chat
74
+ * - "channel": team channel
75
+ */
76
+ chat_type?: "personal" | "group" | "channel";
77
+
78
+ /** Persona name override for this turn (looks up in config.personas) */
79
+ persona?: string;
80
+
81
+ /** Extra metadata for the turn */
82
+ prompt_metadata?: Record<string, string>;
83
+
84
+ /**
85
+ * For "messageReaction" turn_type: the reaction emoji.
86
+ * Defaults to "like". Options: like, heart, laugh, surprised, sad, angry.
87
+ */
88
+ reaction_type?: "like" | "heart" | "laugh" | "surprised" | "sad" | "angry";
89
+
90
+ /**
91
+ * For "messageReaction" turn_type: the message ID to react to.
92
+ * If omitted, reacts to the last bot message in the conversation.
93
+ */
94
+ reply_to_id?: string;
95
+
96
+ /**
97
+ * For "card_action" turn_type: simulate clicking an Adaptive Card button.
98
+ * The server must already have a card message in this conversation to target.
99
+ */
100
+ card_action?: {
101
+ /**
102
+ * The verb of the Action.Execute button to click (required for Action.Execute).
103
+ */
104
+ verb?: string;
105
+ /**
106
+ * Data payload to send with the action (merged with button's own data).
107
+ */
108
+ data?: Record<string, unknown>;
109
+ /**
110
+ * test_id of the turn whose first bot response card to target.
111
+ * If omitted, uses the last bot message that has an attachment.
112
+ */
113
+ reply_to_turn?: string;
114
+ /**
115
+ * Action type. Default: "Action.Execute".
116
+ * Use "Action.Submit" for legacy bots.
117
+ */
118
+ action_type?: "Action.Execute" | "Action.Submit";
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Input for a single conversation: a list of turns to execute sequentially.
124
+ */
125
+ export interface ConversationInput {
126
+ /** Default persona for all turns */
127
+ persona?: string;
128
+
129
+ /** Ordered turns to execute */
130
+ turns: Turn[];
131
+ }
132
+
133
+ /**
134
+ * A single validated Adaptive Card attachment returned with a turn result.
135
+ */
136
+ export interface TurnAttachment {
137
+ /** Attachment content type (e.g. "application/vnd.microsoft.card.adaptive") */
138
+ contentType: string;
139
+ /** The raw card JSON content */
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ content: Record<string, unknown>;
142
+ /** Schema validation errors for this card. Empty array means valid. */
143
+ card_errors: CardValidationError[];
144
+ }
145
+
146
+ /**
147
+ * Result of a single turn execution.
148
+ */
149
+ export interface TurnResult {
150
+ test_id: string;
151
+ prompt: string;
152
+ actual_response: string | null;
153
+ /** Adaptive Card attachments returned by the bot this turn, with validation results */
154
+ attachments: TurnAttachment[];
155
+ status: "Completed" | "TimedOut" | "Errored" | "Skipped";
156
+ error_message?: string;
157
+ duration_seconds: number;
158
+ }
159
+
160
+ /**
161
+ * Result of a full conversation execution.
162
+ */
163
+ export interface ConversationResult {
164
+ type: "conversation_result";
165
+ scenario: string;
166
+ status: "Completed" | "TimedOut" | "Errored";
167
+ duration_seconds: number;
168
+ turns: TurnResult[];
169
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export { TestClient } from "./testClient";
2
+ export { ServerManager } from "./serverManager";
3
+ export { ResponseCapture } from "./responseCapture";
4
+ export type { TestClientConfig, BotConfig, BotResponse, PendingResponse } from "./types";
5
+
6
+ // Re-export WebSocket event types for consumers
7
+ export { ActionType, LogActionType } from "./types";
8
+ export type {
9
+ IAction,
10
+ ICreateMessageAction,
11
+ IUpdateMessageAction,
12
+ ITypingAction,
13
+ IActionMessage,
14
+ // Log action types
15
+ ILogAction,
16
+ IAppendLogAction,
17
+ LogItem,
18
+ } from "./types";
19
+
20
+ // Conversation runner
21
+ export { runConversation, log, logError } from "./runConversation";
22
+ export { sendNotificationAndWait } from "./notificationSender";
23
+ export { createConversationServer } from "./conversationServer";
24
+ export type { ConversationServer, ConversationServerOptions } from "./conversationServer";
25
+ export type {
26
+ E2EConfig,
27
+ PersonaConfig,
28
+ Turn,
29
+ TurnAttachment,
30
+ ConversationInput,
31
+ TurnResult,
32
+ ConversationResult,
33
+ } from "./conversationTypes";
34
+
35
+ // Adaptive Card validation
36
+ export { validateAdaptiveCard } from "./cardValidator";
37
+ export type { CardValidationError } from "./cardValidator";
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Notification sender for multi-turn conversation execution.
3
+ *
4
+ * Sends custom notification activities through the playground's Factory
5
+ * infrastructure and polls ConversationManager for bot responses.
6
+ */
7
+
8
+ import { Factory } from "server";
9
+ import type { CustomActivityTemplateType } from "schema";
10
+ import type { PersonaConfig, Turn } from "./conversationTypes";
11
+
12
+ const POLL_INTERVAL_MS = 200;
13
+
14
+ function log(msg: string): void {
15
+ process.stderr.write(`[notification] ${msg}\n`);
16
+ }
17
+
18
+ /**
19
+ * Send a notification activity and wait for the bot's response.
20
+ *
21
+ * The turn's turn_type must be a valid CustomActivityTemplateType
22
+ * (e.g., "sendEmail", "mentionInWord"). It is passed directly to
23
+ * the playground's CustomActivityService.
24
+ */
25
+ export async function sendNotificationAndWait(
26
+ conversationId: string,
27
+ turn: Turn,
28
+ persona: PersonaConfig | undefined,
29
+ timeout: number
30
+ ): Promise<string> {
31
+ const templateType = turn.turn_type as CustomActivityTemplateType;
32
+ const customActivityService = Factory.getCustomActivityService();
33
+ const accessor = Factory.getBaseUserBotAccessor();
34
+ const conversationManager = Factory.getConversationManager();
35
+
36
+ // Count existing messages before sending.
37
+ const messagesBefore = conversationManager.listMessages(conversationId);
38
+ const countBefore = messagesBefore.length;
39
+
40
+ // Generate the activity template as a mutable plain object.
41
+ const template = customActivityService.generateTemplate(conversationId, templateType) as Record<
42
+ string,
43
+ unknown
44
+ >;
45
+
46
+ // Override the from field if a persona is provided.
47
+ if (persona) {
48
+ const from = (template.from ?? {}) as Record<string, unknown>;
49
+ template.from = {
50
+ ...from,
51
+ id: persona.email ?? persona.id,
52
+ name: persona.name,
53
+ };
54
+ }
55
+
56
+ // Customize the activity body based on turn type.
57
+ if (templateType === "sendEmail") {
58
+ const entities = template.entities as Array<Record<string, unknown>> | undefined;
59
+ const emailEntity = entities?.find((e) => e.type === "emailNotification");
60
+ if (emailEntity) {
61
+ emailEntity.htmlBody = turn.prompt;
62
+ }
63
+ } else if (templateType === "mentionInWord") {
64
+ template.text = turn.prompt;
65
+ if (turn.prompt_metadata?.documentUrl) {
66
+ const attachments = template.attachments as Array<Record<string, unknown>> | undefined;
67
+ if (attachments?.[0]) {
68
+ attachments[0].contentUrl = turn.prompt_metadata.documentUrl;
69
+ }
70
+ }
71
+ if (turn.prompt_metadata?.documentName) {
72
+ const attachments = template.attachments as Array<Record<string, unknown>> | undefined;
73
+ if (attachments?.[0]) {
74
+ attachments[0].name = turn.prompt_metadata.documentName;
75
+ }
76
+ }
77
+ } else if (templateType === "meetingStart" || templateType === "meetingEnd") {
78
+ if (turn.prompt_metadata?.meetingTitle) {
79
+ const value = (template.value ?? {}) as Record<string, unknown>;
80
+ value.Title = turn.prompt_metadata.meetingTitle;
81
+ template.value = value;
82
+ }
83
+ } else if (templateType === "participantJoin" || templateType === "participantLeave") {
84
+ if (turn.prompt) {
85
+ const value = (template.value ?? {}) as Record<string, unknown>;
86
+ const members = (value.members ?? []) as Array<Record<string, unknown>>;
87
+ if (members[0]) {
88
+ const user = (members[0].user ?? {}) as Record<string, unknown>;
89
+ user.name = turn.prompt;
90
+ members[0].user = user;
91
+ value.members = members;
92
+ template.value = value;
93
+ }
94
+ }
95
+ } else if (templateType === "messageReaction") {
96
+ const reaction = turn.reaction_type ?? "like";
97
+ template.reactionsAdded = [{ type: reaction }];
98
+ template.reactionsRemoved = [];
99
+ if (turn.reply_to_id) {
100
+ template.replyToId = turn.reply_to_id;
101
+ } else {
102
+ // Default: react to last bot message
103
+ const messages = conversationManager.listMessages(conversationId);
104
+ const lastBotMsg = [...messages].reverse().find((m) => m.createdBy === "bot");
105
+ if (lastBotMsg) {
106
+ template.replyToId = lastBotMsg.content.id;
107
+ }
108
+ }
109
+ }
110
+
111
+ log(`Sending ${templateType} activity to conversation ${conversationId}`);
112
+
113
+ // sendActivity expects the Activity class from the server's dependency tree.
114
+ // We cast through unknown to bridge the inferred template type.
115
+ await accessor.sendActivity(template as Parameters<typeof accessor.sendActivity>[0]);
116
+
117
+ // Poll for new bot messages.
118
+ const deadline = Date.now() + timeout;
119
+ while (Date.now() < deadline) {
120
+ const messages = conversationManager.listMessages(conversationId);
121
+ const newBotMessages = messages.slice(countBefore).filter((m) => m.createdBy === "bot");
122
+
123
+ if (newBotMessages.length > 0) {
124
+ const responseText = newBotMessages
125
+ .map((m) => m.content.text ?? "")
126
+ .filter((t) => t.length > 0)
127
+ .join("\n\n");
128
+ return responseText;
129
+ }
130
+
131
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
132
+ }
133
+
134
+ throw new Error(`Timeout waiting for bot response to ${templateType} after ${timeout}ms`);
135
+ }
@@ -0,0 +1,145 @@
1
+ import { BotConnectorService, ConversationManager, Message } from "server";
2
+ import type { CreateActivity } from "server";
3
+ import { PendingResponse } from "./types";
4
+
5
+ /**
6
+ * Captures bot responses by hooking into BotConnectorService.processActivity
7
+ */
8
+ export class ResponseCapture {
9
+ private pendingResponses: Map<string, PendingResponse> = new Map();
10
+ private originalProcessActivity?: typeof BotConnectorService.prototype.processActivity;
11
+
12
+ /**
13
+ * Hook into BotConnectorService to intercept responses
14
+ */
15
+ hookIntoBotConnectorService(service: BotConnectorService): void {
16
+ if (this.originalProcessActivity) {
17
+ // Already hooked
18
+ return;
19
+ }
20
+
21
+ this.originalProcessActivity = service.processActivity.bind(service);
22
+
23
+ service.processActivity = async (
24
+ conversationId: string,
25
+ activity: CreateActivity
26
+ ): Promise<{ resIds: string[]; shouldRespond: boolean; statusCode: number }> => {
27
+ if (this.originalProcessActivity === undefined) {
28
+ throw new Error("BotConnectorService.processActivity is undefined");
29
+ }
30
+
31
+ try {
32
+ const result = await this.originalProcessActivity(conversationId, activity);
33
+
34
+ // Only resolve waiting when an actual message was created (resIds non-empty).
35
+ // Typing indicators and trace activities return resIds:[] and must NOT
36
+ // resolve the waiting promise prematurely.
37
+ if (result.resIds.length > 0) {
38
+ this.resolveWaiting(conversationId);
39
+ }
40
+
41
+ return result;
42
+ } catch (error) {
43
+ // Log the error for debugging — this is the actual error that causes 500s
44
+ // when the bot POSTs back to the connector.
45
+ console.error(
46
+ `[ResponseCapture] processActivity error for conversation ${conversationId}:`,
47
+ error
48
+ );
49
+ const activityAny = activity as Record<string, unknown>;
50
+ const entities = Array.isArray(activityAny.entities) ? activityAny.entities : [];
51
+ console.error(
52
+ `[ResponseCapture] Activity type: ${String(activityAny.type)}, ` +
53
+ `entities: ${JSON.stringify(entities.map((e: Record<string, unknown>) => e.type))}`
54
+ );
55
+
56
+ // Still resolve waiting on error — the test should see whatever messages
57
+ // were created before the error, rather than timing out silently.
58
+ this.resolveWaiting(conversationId);
59
+
60
+ throw error;
61
+ }
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Wait for a response in the given conversation
67
+ */
68
+ async waitForResponse(
69
+ conversationId: string,
70
+ conversationManager: ConversationManager,
71
+ timeout: number
72
+ ): Promise<Message[]> {
73
+ const messageCountBefore = conversationManager.listMessages(conversationId).length;
74
+
75
+ return new Promise<Message[]>((resolve, reject) => {
76
+ const timer = setTimeout(() => {
77
+ this.pendingResponses.delete(conversationId);
78
+ // Check one more time before rejecting
79
+ const messages = conversationManager.listMessages(conversationId);
80
+ const newMessages = messages.slice(messageCountBefore);
81
+ const botMessages = newMessages.filter((m) => m.createdBy === "bot");
82
+ if (botMessages.length > 0) {
83
+ resolve(botMessages);
84
+ } else {
85
+ reject(new Error(`Timeout waiting for bot response after ${timeout}ms`));
86
+ }
87
+ }, timeout);
88
+
89
+ this.pendingResponses.set(conversationId, {
90
+ resolve: (messages: Message[]) => {
91
+ clearTimeout(timer);
92
+ resolve(messages);
93
+ },
94
+ reject,
95
+ timer,
96
+ messageCountBefore,
97
+ });
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Resolve waiting promises when a response arrives
103
+ */
104
+ private resolveWaiting(conversationId: string): void {
105
+ const pending = this.pendingResponses.get(conversationId);
106
+ if (!pending) {
107
+ return;
108
+ }
109
+
110
+ // Use setImmediate to allow the message to be stored first
111
+ setImmediate(() => {
112
+ // The pending might have been removed by timeout
113
+ if (!this.pendingResponses.has(conversationId)) {
114
+ return;
115
+ }
116
+
117
+ // We don't have direct access to conversationManager here,
118
+ // so we just signal completion. The TestClient will check for new messages.
119
+ this.pendingResponses.delete(conversationId);
120
+ clearTimeout(pending.timer);
121
+ pending.resolve([]);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Clear all pending responses
127
+ */
128
+ clear(): void {
129
+ this.pendingResponses.forEach((pending) => {
130
+ clearTimeout(pending.timer);
131
+ });
132
+ this.pendingResponses.clear();
133
+ }
134
+
135
+ /**
136
+ * Clear pending responses for a specific conversation only
137
+ */
138
+ clearConversation(conversationId: string): void {
139
+ const pending = this.pendingResponses.get(conversationId);
140
+ if (pending) {
141
+ clearTimeout(pending.timer);
142
+ this.pendingResponses.delete(conversationId);
143
+ }
144
+ }
145
+ }