From 7d7adb8171b09b842929e3a742d3456ef64f8b0f Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 10 Jul 2025 16:03:43 -0700 Subject: [PATCH 01/41] delete `text` from typing activities which are not streaming --- packages/core/src/reducers/createActivitiesReducer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/reducers/createActivitiesReducer.ts b/packages/core/src/reducers/createActivitiesReducer.ts index 4cd7a2ab79..e1ef073ebd 100644 --- a/packages/core/src/reducers/createActivitiesReducer.ts +++ b/packages/core/src/reducers/createActivitiesReducer.ts @@ -102,6 +102,10 @@ function patchActivity( return 'unknown'; }); } + // if !metadata && type == typing + else if (activity.type === 'typing') { + activity = updateIn(activity, ['text'], () => ''); + } let sequenceId: number; From 5860d069e54419859e889625fdbdd8037ea2bb46 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 10 Jul 2025 16:05:00 -0700 Subject: [PATCH 02/41] add support for reading streaming data in `entities` --- .../utils/getActivityLivestreamingMetadata.ts | 126 ++++++++++-------- 1 file changed, 68 insertions(+), 58 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index ae18142bdb..3d2865bd95 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,7 +1,9 @@ import { any, array, + empty, integer, + length, literal, minValue, nonEmpty, @@ -11,75 +13,73 @@ import { pipe, safeParse, string, + transform, undefinedable, - union + union, + unknown } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; const EMPTY_ARRAY = Object.freeze([]); +type StreamData = { + streamId?: string; + streamSequence?: number; + streamType?: string; +}; + const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -const livestreamingActivitySchema = union([ - // Interim. +const streamInfoSchema = union([ object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('streaming') - }), - id: string(), - // "text" is optional. If not set or empty, it presents a contentless activity. - text: optional(undefinedable(string())), - type: literal('typing') - }), - // Informative message. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('informative') - }), - id: string(), - // Informative message must have "text". - text: string(), - type: literal('typing') + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: union([literal('streaming'), literal('informative')]) }), - // Conclude with a message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }), - id: string(), - // If "text" is empty, it represents "regretting" the livestream. - text: optional(undefinedable(string())), - type: literal('message') - }), - // Conclude without a message. - object({ - attachments: optional(array(any()), EMPTY_ARRAY), - channelData: object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }), - id: string(), - // If "text" is not set or empty, it represents "regretting" the livestream. - text: optional(undefinedable(literal(''))), - type: literal('typing') + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') }) ]); +const validEntitiesSchema = union([ + pipe( + array(streamInfoSchema), + length(1), + transform(data => data[0]) + ), + pipe( + array(unknown()), + empty(), + transform(() => undefined) + ) +]); + +const validChannelDataSchema = union([ + streamInfoSchema, + pipe( + object({ + webChat: object({ + receivedAt: number() + }) + }), + transform(() => undefined) + ) +]); + +const livestreamingActivitySchema = object({ + entities: validEntitiesSchema, + channelData: validChannelDataSchema, + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + text: optional(undefinedable(string())), + type: union([literal('typing'), literal('message')]) +}); + /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. * @@ -109,22 +109,32 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi if (result.success) { const { output } = result; + let streamData: StreamData; + + if (output.entities !== undefined) { + streamData = output.entities; + } else if (output.channelData !== undefined) { + streamData = output.channelData; + } else { + return undefined; + } + // If the activity is the first in the session, session ID should be the activity ID. - const sessionId = output.channelData.streamId || output.id; + const sessionId = streamData.streamId || output.id; return Object.freeze( - output.channelData.streamType === 'final' + streamData.streamType === 'final' ? { sequenceNumber: Infinity, sessionId, type: 'final activity' } : { - sequenceNumber: output.channelData.streamSequence, + sequenceNumber: streamData.streamSequence, sessionId, type: !(output.text || output.attachments?.length) ? 'contentless' - : output.channelData.streamType === 'informative' + : streamData.streamType === 'informative' ? 'informative message' : 'interim activity' } From e680dd48619ff03718971f3d9886f4acde7fe83c Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 15 Jul 2025 16:06:28 -0700 Subject: [PATCH 03/41] refactoring to pass existing html2 test cases --- .../src/reducers/createActivitiesReducer.ts | 4 - .../utils/getActivityLivestreamingMetadata.ts | 159 ++++++++++++------ 2 files changed, 107 insertions(+), 56 deletions(-) diff --git a/packages/core/src/reducers/createActivitiesReducer.ts b/packages/core/src/reducers/createActivitiesReducer.ts index e1ef073ebd..4cd7a2ab79 100644 --- a/packages/core/src/reducers/createActivitiesReducer.ts +++ b/packages/core/src/reducers/createActivitiesReducer.ts @@ -102,10 +102,6 @@ function patchActivity( return 'unknown'; }); } - // if !metadata && type == typing - else if (activity.type === 'typing') { - activity = updateIn(activity, ['text'], () => ''); - } let sequenceId: number; diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 38eb246ce2..fdc885a6b2 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,22 +1,17 @@ import { any, array, - empty, integer, - length, literal, minValue, - nonEmpty, number, object, optional, pipe, safeParse, string, - transform, undefinedable, - union, - unknown + union } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; @@ -24,62 +19,122 @@ import getOrgSchemaMessage from './getOrgSchemaMessage'; const EMPTY_ARRAY = Object.freeze([]); +const streamSequenceSchema = pipe(number(), integer(), minValue(1)); + type StreamData = { streamId?: string; streamSequence?: number; streamType?: string; }; -const streamSequenceSchema = pipe(number(), integer(), minValue(1)); +const streamDataSchema = object({ + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: union([literal('streaming'), literal('informative'), literal('final')]) +}); -const streamInfoSchema = union([ +const livestreamingActivitySchema = union([ + // Interim. object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: union([literal('streaming'), literal('informative')]) + attachments: optional(array(any()), EMPTY_ARRAY), + channelData: any(), + id: string(), + // "text" is optional. If not set or empty, it presents a contentless activity. + text: optional(undefinedable(string())), + type: literal('typing'), + entities: optional(array(any()), EMPTY_ARRAY) }), + // Informative message. object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') + attachments: optional(array(any()), EMPTY_ARRAY), + channelData: any(), + id: string(), + // Informative may not have "text", but should have abstract instead (checked later) + text: optional(undefinedable(string())), + type: literal('typing'), + entities: optional(array(any()), EMPTY_ARRAY) + }), + // Conclude with a message. + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + channelData: any(), + id: string(), + // If "text" is empty, it represents "regretting" the livestream. + text: optional(undefinedable(string())), + type: literal('message'), + entities: optional(array(any()), EMPTY_ARRAY) + }), + // Conclude without a message. + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + channelData: any(), + id: string(), + // If "text" is not set or empty, it represents "regretting" the livestream. + text: optional(undefinedable(literal(''))), + type: literal('typing'), + entities: optional(array(any()), EMPTY_ARRAY) }) ]); -const validEntitiesSchema = union([ - pipe( - array(streamInfoSchema), - length(1), - transform(data => data[0]) - ), - pipe( - array(unknown()), - empty(), - transform(() => undefined) - ) -]); - -const validChannelDataSchema = union([ - streamInfoSchema, - pipe( - object({ - webChat: object({ - receivedAt: number() - }) - }), - transform(() => undefined) - ) -]); - -const livestreamingActivitySchema = object({ - entities: validEntitiesSchema, - channelData: validChannelDataSchema, - attachments: optional(array(any()), EMPTY_ARRAY), - id: string(), - text: optional(undefinedable(string())), - type: union([literal('typing'), literal('message')]) -}); +// const livestreamingActivitySchema = union([ +// // Interim. +// object({ +// attachments: optional(array(any()), EMPTY_ARRAY), +// channelData: object({ +// // "streamId" is optional for the very first activity in the session. +// streamId: optional(undefinedable(string())), +// streamSequence: streamSequenceSchema, +// streamType: literal('streaming') +// }), +// id: string(), +// // "text" is optional. If not set or empty, it presents a contentless activity. +// text: optional(undefinedable(string())), +// type: literal('typing') +// }), +// // Informative message. +// object({ +// attachments: optional(array(any()), EMPTY_ARRAY), +// channelData: object({ +// // "streamId" is optional for the very first activity in the session. +// streamId: optional(undefinedable(string())), +// streamSequence: streamSequenceSchema, +// streamType: literal('informative') +// }), +// id: string(), +// // Informative may not have "text", but should have abstract instead (checked later) +// text: optional(undefinedable(string())), +// type: literal('typing'), +// entities: optional(array(any()), EMPTY_ARRAY) +// }), +// // Conclude with a message. +// object({ +// attachments: optional(array(any()), EMPTY_ARRAY), +// channelData: object({ +// // "streamId" is required for the final activity in the session. +// // The final activity must not be the sole activity in the session. +// streamId: pipe(string(), nonEmpty()), +// streamType: literal('final') +// }), +// id: string(), +// // If "text" is empty, it represents "regretting" the livestream. +// text: optional(undefinedable(string())), +// type: literal('message') +// }), +// // Conclude without a message. +// object({ +// attachments: optional(array(any()), EMPTY_ARRAY), +// channelData: object({ +// // "streamId" is required for the final activity in the session. +// // The final activity must not be the sole activity in the session. +// streamId: pipe(string(), nonEmpty()), +// streamType: literal('final') +// }), +// id: string(), +// // If "text" is not set or empty, it represents "regretting" the livestream. +// text: optional(undefinedable(literal(''))), +// type: literal('typing') +// }) +// ]); /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. @@ -112,9 +167,9 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi let streamData: StreamData; - if (output.entities !== undefined) { - streamData = output.entities; - } else if (output.channelData !== undefined) { + if (safeParse(streamDataSchema, output.entities[0]).success) { + [streamData] = output.entities; + } else if (safeParse(streamDataSchema, output.channelData).success) { streamData = output.channelData; } else { return undefined; From 684a78befb41731bf09e852dc0ce681865184b25 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 16 Jul 2025 14:49:35 -0700 Subject: [PATCH 04/41] minimal implementation of entities livestreaming --- .../utils/getActivityLivestreamingMetadata.ts | 93 ++++++------------- 1 file changed, 27 insertions(+), 66 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index fdc885a6b2..dffef873e1 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -4,6 +4,7 @@ import { integer, literal, minValue, + nonEmpty, number, object, optional, @@ -29,7 +30,7 @@ type StreamData = { const streamDataSchema = object({ streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, + streamSequence: optional(streamSequenceSchema), streamType: union([literal('streaming'), literal('informative'), literal('final')]) }); @@ -37,7 +38,12 @@ const livestreamingActivitySchema = union([ // Interim. object({ attachments: optional(array(any()), EMPTY_ARRAY), - channelData: any(), + channelData: object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: literal('streaming') + }), id: string(), // "text" is optional. If not set or empty, it presents a contentless activity. text: optional(undefinedable(string())), @@ -47,7 +53,12 @@ const livestreamingActivitySchema = union([ // Informative message. object({ attachments: optional(array(any()), EMPTY_ARRAY), - channelData: any(), + channelData: object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: literal('informative') + }), id: string(), // Informative may not have "text", but should have abstract instead (checked later) text: optional(undefinedable(string())), @@ -57,7 +68,12 @@ const livestreamingActivitySchema = union([ // Conclude with a message. object({ attachments: optional(array(any()), EMPTY_ARRAY), - channelData: any(), + channelData: object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }), id: string(), // If "text" is empty, it represents "regretting" the livestream. text: optional(undefinedable(string())), @@ -67,7 +83,12 @@ const livestreamingActivitySchema = union([ // Conclude without a message. object({ attachments: optional(array(any()), EMPTY_ARRAY), - channelData: any(), + channelData: object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }), id: string(), // If "text" is not set or empty, it represents "regretting" the livestream. text: optional(undefinedable(literal(''))), @@ -76,66 +97,6 @@ const livestreamingActivitySchema = union([ }) ]); -// const livestreamingActivitySchema = union([ -// // Interim. -// object({ -// attachments: optional(array(any()), EMPTY_ARRAY), -// channelData: object({ -// // "streamId" is optional for the very first activity in the session. -// streamId: optional(undefinedable(string())), -// streamSequence: streamSequenceSchema, -// streamType: literal('streaming') -// }), -// id: string(), -// // "text" is optional. If not set or empty, it presents a contentless activity. -// text: optional(undefinedable(string())), -// type: literal('typing') -// }), -// // Informative message. -// object({ -// attachments: optional(array(any()), EMPTY_ARRAY), -// channelData: object({ -// // "streamId" is optional for the very first activity in the session. -// streamId: optional(undefinedable(string())), -// streamSequence: streamSequenceSchema, -// streamType: literal('informative') -// }), -// id: string(), -// // Informative may not have "text", but should have abstract instead (checked later) -// text: optional(undefinedable(string())), -// type: literal('typing'), -// entities: optional(array(any()), EMPTY_ARRAY) -// }), -// // Conclude with a message. -// object({ -// attachments: optional(array(any()), EMPTY_ARRAY), -// channelData: object({ -// // "streamId" is required for the final activity in the session. -// // The final activity must not be the sole activity in the session. -// streamId: pipe(string(), nonEmpty()), -// streamType: literal('final') -// }), -// id: string(), -// // If "text" is empty, it represents "regretting" the livestream. -// text: optional(undefinedable(string())), -// type: literal('message') -// }), -// // Conclude without a message. -// object({ -// attachments: optional(array(any()), EMPTY_ARRAY), -// channelData: object({ -// // "streamId" is required for the final activity in the session. -// // The final activity must not be the sole activity in the session. -// streamId: pipe(string(), nonEmpty()), -// streamType: literal('final') -// }), -// id: string(), -// // If "text" is not set or empty, it represents "regretting" the livestream. -// text: optional(undefinedable(literal(''))), -// type: literal('typing') -// }) -// ]); - /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. * @@ -191,7 +152,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: !( output.text || output.attachments?.length || - ('entities' in output && getOrgSchemaMessage(output.entities)?.abstract) + ('entities' in output && getOrgSchemaMessage(output.entities)) ) ? 'contentless' : streamData.streamType === 'informative' From 1d569a1e90b2b7fcaf9b609c754b22a7356f092e Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 16 Jul 2025 23:52:24 -0700 Subject: [PATCH 05/41] Rewrite code flow to check for streamingData first -- draft version (this is pretty rough) --- .../utils/getActivityLivestreamingMetadata.ts | 114 +++++++++++++++--- 1 file changed, 95 insertions(+), 19 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index dffef873e1..1b943c144a 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -22,19 +22,14 @@ const EMPTY_ARRAY = Object.freeze([]); const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -type StreamData = { - streamId?: string; - streamSequence?: number; - streamType?: string; -}; - -const streamDataSchema = object({ +const streamingDataSchema = object({ streamId: optional(undefinedable(string())), streamSequence: optional(streamSequenceSchema), - streamType: union([literal('streaming'), literal('informative'), literal('final')]) + streamType: union([literal('streaming'), literal('informative'), literal('final')]), + type: optional(string()) }); -const livestreamingActivitySchema = union([ +const channelDataStreamingActivitySchema = union([ // Interim. object({ attachments: optional(array(any()), EMPTY_ARRAY), @@ -97,6 +92,77 @@ const livestreamingActivitySchema = union([ }) ]); +const entitiesStreamingActivitySchema = union([ + // Same thing but for entities + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + entities: array( + object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: literal('streaming') + }) + ), + channelData: any(), + id: string(), + // "text" is optional. If not set or empty, it presents a contentless activity. + text: optional(undefinedable(string())), + type: literal('typing') + }), + // Informative message. + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + entities: array( + object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: literal('informative') + }) + ), + channelData: any(), + id: string(), + // Informative may not have "text", but should have abstract instead (checked later) + text: optional(undefinedable(string())), + type: literal('typing') + }), + // Conclude with a message. + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + entities: array( + object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }) + ), + channelData: any(), + id: string(), + // If "text" is empty, it represents "regretting" the livestream. + text: optional(undefinedable(string())), + type: literal('message') + }), + // Conclude without a message. + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + entities: array( + object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }) + ), + channelData: any(), + id: string(), + // If "text" is not set or empty, it represents "regretting" the livestream. + text: optional(undefinedable(literal(''))), + type: literal('typing') + }) +]); + /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. * @@ -121,20 +187,30 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: 'contentless' | 'final activity' | 'informative message' | 'interim activity'; }> | undefined { - const result = safeParse(livestreamingActivitySchema, activity); + let activityResult; + let streamingDataResult; - if (result.success) { - const { output } = result; - - let streamData: StreamData; - - if (safeParse(streamDataSchema, output.entities[0]).success) { - [streamData] = output.entities; - } else if (safeParse(streamDataSchema, output.channelData).success) { - streamData = output.channelData; + if (activity.entities) { + streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); + if (streamingDataResult.success) { + activityResult = safeParse(entitiesStreamingActivitySchema, activity); + } else if (activity.channelData) { + streamingDataResult = safeParse(streamingDataSchema, activity.channelData); + if (streamingDataResult.success) { + activityResult = safeParse(channelDataStreamingActivitySchema, activity); + } else { + return undefined; + } } else { return undefined; } + } else { + return undefined; + } + + if (activityResult.success && streamingDataResult.success) { + const { output } = activityResult; + const { output: streamData } = streamingDataResult; // If the activity is the first in the session, session ID should be the activity ID. const sessionId = streamData.streamId || output.id; From d5cd92632336e45fb3d61e7d1a0cfa72d00d92a0 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 08:59:07 -0700 Subject: [PATCH 06/41] Change conditionals to be more readable and actually check channelData --- .../utils/getActivityLivestreamingMetadata.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 1b943c144a..dde3becf5f 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -191,21 +191,13 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi let streamingDataResult; if (activity.entities) { + activityResult = safeParse(entitiesStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); - if (streamingDataResult.success) { - activityResult = safeParse(entitiesStreamingActivitySchema, activity); - } else if (activity.channelData) { - streamingDataResult = safeParse(streamingDataSchema, activity.channelData); - if (streamingDataResult.success) { - activityResult = safeParse(channelDataStreamingActivitySchema, activity); - } else { - return undefined; - } - } else { - return undefined; - } - } else { - return undefined; + } + + if (!(activityResult.success && streamingDataResult.success) && activity.channelData) { + activityResult = safeParse(channelDataStreamingActivitySchema, activity); + streamingDataResult = safeParse(streamingDataSchema, activity.channelData); } if (activityResult.success && streamingDataResult.success) { From 87442e27bb1d544e71261cdc190d534c271435b6 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 09:36:07 -0700 Subject: [PATCH 07/41] handle case of entities being undefined --- .../src/utils/getActivityLivestreamingMetadata.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index dde3becf5f..89578ddb53 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -187,12 +187,19 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: 'contentless' | 'final activity' | 'informative message' | 'interim activity'; }> | undefined { - let activityResult; - let streamingDataResult; + let activityResult: any; + let streamingDataResult: any; if (activity.entities) { activityResult = safeParse(entitiesStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); + } else { + activityResult = { + success: false + }; + streamingDataResult = { + success: false + }; } if (!(activityResult.success && streamingDataResult.success) && activity.channelData) { From c72ad0fa04ee137cf37e9344ce29ec0182adff59 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 10:45:19 -0700 Subject: [PATCH 08/41] Make channelData optional in entitiesStreamingActivitySchema --- .../core/src/utils/getActivityLivestreamingMetadata.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 89578ddb53..1b612e38bb 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -104,7 +104,7 @@ const entitiesStreamingActivitySchema = union([ streamType: literal('streaming') }) ), - channelData: any(), + channelData: optional(any()), id: string(), // "text" is optional. If not set or empty, it presents a contentless activity. text: optional(undefinedable(string())), @@ -121,7 +121,7 @@ const entitiesStreamingActivitySchema = union([ streamType: literal('informative') }) ), - channelData: any(), + channelData: optional(any()), id: string(), // Informative may not have "text", but should have abstract instead (checked later) text: optional(undefinedable(string())), @@ -138,7 +138,7 @@ const entitiesStreamingActivitySchema = union([ streamType: literal('final') }) ), - channelData: any(), + channelData: optional(any()), id: string(), // If "text" is empty, it represents "regretting" the livestream. text: optional(undefinedable(string())), @@ -155,7 +155,7 @@ const entitiesStreamingActivitySchema = union([ streamType: literal('final') }) ), - channelData: any(), + channelData: optional(any()), id: string(), // If "text" is not set or empty, it represents "regretting" the livestream. text: optional(undefinedable(literal(''))), @@ -227,7 +227,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: !( output.text || output.attachments?.length || - ('entities' in output && getOrgSchemaMessage(output.entities)) + ('entities' in output && getOrgSchemaMessage(output.entities)?.citation) ) ? 'contentless' : streamData.streamType === 'informative' From e34df98c81487815cc5ff025f5932281bad72718 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 11:26:54 -0700 Subject: [PATCH 09/41] make channelData not optional for entitiesStreamingActivitySchema --- .../utils/getActivityLivestreamingMetadata.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 1b612e38bb..9d4e2cd365 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -3,6 +3,7 @@ import { array, integer, literal, + looseObject, minValue, nonEmpty, number, @@ -22,11 +23,10 @@ const EMPTY_ARRAY = Object.freeze([]); const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -const streamingDataSchema = object({ +const streamingDataSchema = looseObject({ streamId: optional(undefinedable(string())), streamSequence: optional(streamSequenceSchema), - streamType: union([literal('streaming'), literal('informative'), literal('final')]), - type: optional(string()) + streamType: union([literal('streaming'), literal('informative'), literal('final')]) }); const channelDataStreamingActivitySchema = union([ @@ -97,14 +97,14 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - object({ + looseObject({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('streaming') }) ), - channelData: optional(any()), + channelData: any(), id: string(), // "text" is optional. If not set or empty, it presents a contentless activity. text: optional(undefinedable(string())), @@ -114,14 +114,14 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - object({ + looseObject({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('informative') }) ), - channelData: optional(any()), + channelData: any(), id: string(), // Informative may not have "text", but should have abstract instead (checked later) text: optional(undefinedable(string())), @@ -131,14 +131,14 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - object({ + looseObject({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') }) ), - channelData: optional(any()), + channelData: any(), id: string(), // If "text" is empty, it represents "regretting" the livestream. text: optional(undefinedable(string())), @@ -148,14 +148,14 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - object({ + looseObject({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') }) ), - channelData: optional(any()), + channelData: any(), id: string(), // If "text" is not set or empty, it represents "regretting" the livestream. text: optional(undefinedable(literal(''))), @@ -193,21 +193,14 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi if (activity.entities) { activityResult = safeParse(entitiesStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); - } else { - activityResult = { - success: false - }; - streamingDataResult = { - success: false - }; } - if (!(activityResult.success && streamingDataResult.success) && activity.channelData) { + if (!(activityResult?.success && streamingDataResult?.success) && activity.channelData) { activityResult = safeParse(channelDataStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.channelData); } - if (activityResult.success && streamingDataResult.success) { + if (activityResult?.success && streamingDataResult?.success) { const { output } = activityResult; const { output: streamData } = streamingDataResult; From 211a8a7a9ea17457c06b78851465325b1209c0f6 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 11:49:26 -0700 Subject: [PATCH 10/41] change looseObject back to object --- .../src/utils/getActivityLivestreamingMetadata.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 9d4e2cd365..b837c4e073 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -3,7 +3,6 @@ import { array, integer, literal, - looseObject, minValue, nonEmpty, number, @@ -23,10 +22,11 @@ const EMPTY_ARRAY = Object.freeze([]); const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -const streamingDataSchema = looseObject({ +const streamingDataSchema = object({ streamId: optional(undefinedable(string())), streamSequence: optional(streamSequenceSchema), - streamType: union([literal('streaming'), literal('informative'), literal('final')]) + streamType: union([literal('streaming'), literal('informative'), literal('final')]), + type: optional(string()) }); const channelDataStreamingActivitySchema = union([ @@ -97,7 +97,7 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - looseObject({ + object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, @@ -114,7 +114,7 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - looseObject({ + object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, @@ -131,7 +131,7 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - looseObject({ + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), @@ -148,7 +148,7 @@ const entitiesStreamingActivitySchema = union([ object({ attachments: optional(array(any()), EMPTY_ARRAY), entities: array( - looseObject({ + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), From f6596cfb621abb4bf11b30a095fe0b45420ec933 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 13:27:51 -0700 Subject: [PATCH 11/41] handle entities being undefined again --- .../src/utils/getActivityLivestreamingMetadata.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index b837c4e073..89578ddb53 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -193,14 +193,21 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi if (activity.entities) { activityResult = safeParse(entitiesStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); + } else { + activityResult = { + success: false + }; + streamingDataResult = { + success: false + }; } - if (!(activityResult?.success && streamingDataResult?.success) && activity.channelData) { + if (!(activityResult.success && streamingDataResult.success) && activity.channelData) { activityResult = safeParse(channelDataStreamingActivitySchema, activity); streamingDataResult = safeParse(streamingDataSchema, activity.channelData); } - if (activityResult?.success && streamingDataResult?.success) { + if (activityResult.success && streamingDataResult.success) { const { output } = activityResult; const { output: streamData } = streamingDataResult; @@ -220,7 +227,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: !( output.text || output.attachments?.length || - ('entities' in output && getOrgSchemaMessage(output.entities)?.citation) + ('entities' in output && getOrgSchemaMessage(output.entities)) ) ? 'contentless' : streamData.streamType === 'informative' From 3b0da487a2ca9dd764815485bb6688c65fd3ba18 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 14:41:02 -0700 Subject: [PATCH 12/41] clean up logical flow and include more typing --- .../utils/getActivityLivestreamingMetadata.ts | 61 +++++++++---------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 89578ddb53..aef92bef35 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -14,20 +14,21 @@ import { undefinedable, union } from 'valibot'; +import type { InferOutput } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; import getOrgSchemaMessage from './getOrgSchemaMessage'; const EMPTY_ARRAY = Object.freeze([]); -const streamSequenceSchema = pipe(number(), integer(), minValue(1)); +interface StreamingData { + streamId?: string; + streamSequence?: number; + streamType: string; + [key: string]: any; +} -const streamingDataSchema = object({ - streamId: optional(undefinedable(string())), - streamSequence: optional(streamSequenceSchema), - streamType: union([literal('streaming'), literal('informative'), literal('final')]), - type: optional(string()) -}); +const streamSequenceSchema = pipe(number(), integer(), minValue(1)); const channelDataStreamingActivitySchema = union([ // Interim. @@ -163,6 +164,9 @@ const entitiesStreamingActivitySchema = union([ }) ]); +type EntitiesStreamingActivity = InferOutput; +type ChannelDataStreamingActivity = InferOutput; + /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. * @@ -187,50 +191,43 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: 'contentless' | 'final activity' | 'informative message' | 'interim activity'; }> | undefined { - let activityResult: any; - let streamingDataResult: any; + let activityData: EntitiesStreamingActivity | ChannelDataStreamingActivity | undefined; + + let streamingData: StreamingData | undefined; if (activity.entities) { - activityResult = safeParse(entitiesStreamingActivitySchema, activity); - streamingDataResult = safeParse(streamingDataSchema, activity.entities[0]); - } else { - activityResult = { - success: false - }; - streamingDataResult = { - success: false - }; + const result = safeParse(entitiesStreamingActivitySchema, activity); + activityData = result.success ? result.output : undefined; + streamingData = result.success ? activityData.entities[0] : undefined; } - if (!(activityResult.success && streamingDataResult.success) && activity.channelData) { - activityResult = safeParse(channelDataStreamingActivitySchema, activity); - streamingDataResult = safeParse(streamingDataSchema, activity.channelData); + if (!activityData && activity.channelData) { + const result = safeParse(channelDataStreamingActivitySchema, activity); + activityData = result.success ? result.output : undefined; + streamingData = result.success ? activityData.entities[0] : undefined; } - if (activityResult.success && streamingDataResult.success) { - const { output } = activityResult; - const { output: streamData } = streamingDataResult; - + if (activityData && streamingData) { // If the activity is the first in the session, session ID should be the activity ID. - const sessionId = streamData.streamId || output.id; + const sessionId = streamingData.streamId || activityData.id; return Object.freeze( - streamData.streamType === 'final' + streamingData.streamType === 'final' ? { sequenceNumber: Infinity, sessionId, type: 'final activity' } : { - sequenceNumber: streamData.streamSequence, + sequenceNumber: streamingData.streamSequence, sessionId, type: !( - output.text || - output.attachments?.length || - ('entities' in output && getOrgSchemaMessage(output.entities)) + activityData.text || + activityData.attachments?.length || + ('entities' in activityData && getOrgSchemaMessage(activity.entities)) ) ? 'contentless' - : streamData.streamType === 'informative' + : streamingData.streamType === 'informative' ? 'informative message' : 'interim activity' } From fcc551202d1eefa932f9ecbd1e7edf41a66549b2 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 15:03:24 -0700 Subject: [PATCH 13/41] Removed common data from schemas for reusability --- .../utils/getActivityLivestreamingMetadata.ts | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index aef92bef35..1f2133d715 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -30,73 +30,74 @@ interface StreamingData { const streamSequenceSchema = pipe(number(), integer(), minValue(1)); +// Extra fields required for each activity +const activityExtras = { + // "text" is optional. If not set or empty, it presents a contentless activity. + text: optional(undefinedable(string())), + attachments: optional(array(any()), EMPTY_ARRAY), + id: string() +}; + const channelDataStreamingActivitySchema = union([ // Interim. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + type: literal('typing'), channelData: object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('streaming') }), - id: string(), - // "text" is optional. If not set or empty, it presents a contentless activity. - text: optional(undefinedable(string())), - type: literal('typing'), - entities: optional(array(any()), EMPTY_ARRAY) + entities: optional(array(any()), EMPTY_ARRAY), + ...activityExtras }), // Informative message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // Informative may not have "text", but should have abstract instead (checked later) + type: literal('typing'), channelData: object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: literal('informative') }), - id: string(), - // Informative may not have "text", but should have abstract instead (checked later) - text: optional(undefinedable(string())), - type: literal('typing'), - entities: optional(array(any()), EMPTY_ARRAY) + entities: optional(array(any()), EMPTY_ARRAY), + ...activityExtras }), // Conclude with a message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // If "text" is empty, it represents "regretting" the livestream. + type: literal('message'), channelData: object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') }), - id: string(), - // If "text" is empty, it represents "regretting" the livestream. - text: optional(undefinedable(string())), - type: literal('message'), - entities: optional(array(any()), EMPTY_ARRAY) + entities: optional(array(any()), EMPTY_ARRAY), + ...activityExtras }), // Conclude without a message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // If "text" is not set or empty, it represents "regretting" the livestream. + type: literal('typing'), channelData: object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') }), - id: string(), - // If "text" is not set or empty, it represents "regretting" the livestream. - text: optional(undefinedable(literal(''))), - type: literal('typing'), - entities: optional(array(any()), EMPTY_ARRAY) + entities: optional(array(any()), EMPTY_ARRAY), + ...activityExtras }) ]); +// Extra Activity + const entitiesStreamingActivitySchema = union([ // Same thing but for entities object({ - attachments: optional(array(any()), EMPTY_ARRAY), + type: literal('typing'), entities: array( object({ // "streamId" is optional for the very first activity in the session. @@ -106,14 +107,12 @@ const entitiesStreamingActivitySchema = union([ }) ), channelData: any(), - id: string(), - // "text" is optional. If not set or empty, it presents a contentless activity. - text: optional(undefinedable(string())), - type: literal('typing') + ...activityExtras }), // Informative message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // Informative may not have "text", but should have abstract instead (checked later) + type: literal('typing'), entities: array( object({ // "streamId" is optional for the very first activity in the session. @@ -123,14 +122,12 @@ const entitiesStreamingActivitySchema = union([ }) ), channelData: any(), - id: string(), - // Informative may not have "text", but should have abstract instead (checked later) - text: optional(undefinedable(string())), - type: literal('typing') + ...activityExtras }), // Conclude with a message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // If "text" is empty, it represents "regretting" the livestream. + type: literal('message'), entities: array( object({ // "streamId" is required for the final activity in the session. @@ -140,14 +137,12 @@ const entitiesStreamingActivitySchema = union([ }) ), channelData: any(), - id: string(), - // If "text" is empty, it represents "regretting" the livestream. - text: optional(undefinedable(string())), - type: literal('message') + ...activityExtras }), // Conclude without a message. object({ - attachments: optional(array(any()), EMPTY_ARRAY), + // If "text" is empty, it represents "regretting" the livestream. + type: literal('typing'), entities: array( object({ // "streamId" is required for the final activity in the session. @@ -157,10 +152,7 @@ const entitiesStreamingActivitySchema = union([ }) ), channelData: any(), - id: string(), - // If "text" is not set or empty, it represents "regretting" the livestream. - text: optional(undefinedable(literal(''))), - type: literal('typing') + ...activityExtras }) ]); @@ -224,7 +216,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: !( activityData.text || activityData.attachments?.length || - ('entities' in activityData && getOrgSchemaMessage(activity.entities)) + ('entities' in activityData && getOrgSchemaMessage(activity.entities)?.abstract) ) ? 'contentless' : streamingData.streamType === 'informative' From f881517c69915e35af2593d7857f6a651751ed41 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 15:06:33 -0700 Subject: [PATCH 14/41] read channelData instead of entities --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 1f2133d715..0d1f714500 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -196,7 +196,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi if (!activityData && activity.channelData) { const result = safeParse(channelDataStreamingActivitySchema, activity); activityData = result.success ? result.output : undefined; - streamingData = result.success ? activityData.entities[0] : undefined; + streamingData = result.success ? activityData.channelData : undefined; } if (activityData && streamingData) { From 5f5fe92970d97b1a76f3a79429d4da02402f1cc0 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 15:23:18 -0700 Subject: [PATCH 15/41] remove unnecessary field from StreamingData --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 0d1f714500..a8be1ab4c2 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -25,7 +25,6 @@ interface StreamingData { streamId?: string; streamSequence?: number; streamType: string; - [key: string]: any; } const streamSequenceSchema = pipe(number(), integer(), minValue(1)); From 8f01f686e4bb4c1b140e3ab4855c13794258a17f Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 15:37:30 -0700 Subject: [PATCH 16/41] typing+final+text activity returns undefined --- .../src/utils/getActivityLivestreamingMetadata.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index a8be1ab4c2..d538824028 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -37,6 +37,8 @@ const activityExtras = { id: string() }; +const { text: _text, ...activityExtrasFinal } = activityExtras; + const channelDataStreamingActivitySchema = union([ // Interim. object({ @@ -87,14 +89,12 @@ const channelDataStreamingActivitySchema = union([ streamType: literal('final') }), entities: optional(array(any()), EMPTY_ARRAY), - ...activityExtras + text: optional(undefinedable(literal(''))), + ...activityExtrasFinal }) ]); -// Extra Activity - const entitiesStreamingActivitySchema = union([ - // Same thing but for entities object({ type: literal('typing'), entities: array( @@ -151,7 +151,8 @@ const entitiesStreamingActivitySchema = union([ }) ), channelData: any(), - ...activityExtras + text: optional(undefinedable(literal(''))), + ...activityExtrasFinal }) ]); From 07797eb8419221af7bf2b2beacc95b3853ab3ee2 Mon Sep 17 00:00:00 2001 From: = <=> Date: Thu, 17 Jul 2025 16:26:30 -0700 Subject: [PATCH 17/41] further abstraction of schemas --- .../utils/getActivityLivestreamingMetadata.ts | 97 +++++-------------- 1 file changed, 26 insertions(+), 71 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index d538824028..37afec8171 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -39,29 +39,28 @@ const activityExtras = { const { text: _text, ...activityExtrasFinal } = activityExtras; +// Interim or Informative Activities +const activeSchema = object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: union([literal('streaming'), literal('informative')]) +}); + +// Final Activities +const finalActivitySchema = object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') +}); + const channelDataStreamingActivitySchema = union([ - // Interim. - object({ - type: literal('typing'), - channelData: object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('streaming') - }), - entities: optional(array(any()), EMPTY_ARRAY), - ...activityExtras - }), - // Informative message. + // Interim or Informative message + // Informative may not have "text", but should have abstract instead (checked later) object({ - // Informative may not have "text", but should have abstract instead (checked later) type: literal('typing'), - channelData: object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('informative') - }), + channelData: activeSchema, entities: optional(array(any()), EMPTY_ARRAY), ...activityExtras }), @@ -69,12 +68,7 @@ const channelDataStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - channelData: object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }), + channelData: finalActivitySchema, entities: optional(array(any()), EMPTY_ARRAY), ...activityExtras }), @@ -82,12 +76,7 @@ const channelDataStreamingActivitySchema = union([ object({ // If "text" is not set or empty, it represents "regretting" the livestream. type: literal('typing'), - channelData: object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }), + channelData: finalActivitySchema, entities: optional(array(any()), EMPTY_ARRAY), text: optional(undefinedable(literal(''))), ...activityExtrasFinal @@ -95,31 +84,11 @@ const channelDataStreamingActivitySchema = union([ ]); const entitiesStreamingActivitySchema = union([ + // Interim or Informative message + // Informative may not have "text", but should have abstract instead (checked later) object({ type: literal('typing'), - entities: array( - object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('streaming') - }) - ), - channelData: any(), - ...activityExtras - }), - // Informative message. - object({ - // Informative may not have "text", but should have abstract instead (checked later) - type: literal('typing'), - entities: array( - object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: literal('informative') - }) - ), + entities: array(activeSchema), channelData: any(), ...activityExtras }), @@ -127,14 +96,7 @@ const entitiesStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - entities: array( - object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }) - ), + entities: array(finalActivitySchema), channelData: any(), ...activityExtras }), @@ -142,14 +104,7 @@ const entitiesStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('typing'), - entities: array( - object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') - }) - ), + entities: array(finalActivitySchema), channelData: any(), text: optional(undefinedable(literal(''))), ...activityExtrasFinal From 53980b56eaceb0983320ba38b661907e2618876b Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 21 Jul 2025 10:23:22 -0700 Subject: [PATCH 18/41] update unit tests --- .../getActivityLivestreamingMetadata.spec.ts | 176 +++++++++++++++++- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 4969bfdc76..58dd11a955 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -2,7 +2,7 @@ import type { WebChatActivity } from '../types/WebChatActivity'; import getActivityLivestreamingMetadata from './getActivityLivestreamingMetadata'; describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { - describe('activity with "streamType" of "streaming"', () => { + describe('activity with "streamType" of "streaming" (channelData)', () => { let activity: WebChatActivity; beforeEach(() => { @@ -32,7 +32,7 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' } }); - describe('activity with "streamType" of "informative message"', () => { + describe('activity with "streamType" of "informative message" (channelData)', () => { let activity: WebChatActivity; beforeEach(() => { @@ -62,7 +62,7 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' } }); - describe('activity with "streamType" of "final"', () => { + describe('activity with "streamType" of "final" (channelData)', () => { let activity: WebChatActivity; beforeEach(() => { @@ -91,10 +91,109 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' }); }); +describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { + describe('activity with "streamType" of "streaming" (entities)', () => { + let activity: WebChatActivity; + + beforeEach(() => { + activity = { + entities: [ + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'streaming' + } + ], + channelData: {}, + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } as any; + }); + + test('should return type of "interim activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + + if (variant === 'with "streamId"') { + test('should return session ID with value from "entities.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } + }); + + describe('activity with "streamType" of "informative message" (entities)', () => { + let activity: WebChatActivity; + + beforeEach(() => { + activity = { + entities: [ + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'informative' + } + ], + channelData: {}, + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } as any; + }); + + test('should return type of "informative message"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + + if (variant === 'with "streamId"') { + test('should return session ID with value from "entities.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } + }); + + describe('activity with "streamType" of "final" (entities)', () => { + let activity: WebChatActivity; + + beforeEach(() => { + activity = { + entities: [ + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamType: 'final' + } + ], + channelData: {}, + id: 'a-00002', + text: 'Hello, World!', + type: 'message' + } as any; + }); + + if (variant === 'with "streamId"') { + test('should return type of "final activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); + test('should return sequence number of Infinity', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); + test('should return session ID', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. + test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); + } + }); +}); + test('invalid activity should return undefined', () => expect(getActivityLivestreamingMetadata('invalid' as any)).toBeUndefined()); -test('activity with "streamType" of "streaming" without critical fields should return undefined', () => +test('activity with "streamType" of "streaming" without critical fields should return undefined (channelData)', () => expect( getActivityLivestreamingMetadata({ channelData: { streamType: 'streaming' }, @@ -102,11 +201,20 @@ test('activity with "streamType" of "streaming" without critical fields should r } as any) ).toBeUndefined()); +test('activity with "streamType" of "streaming" without critical fields should return undefined (entities)', () => + expect( + getActivityLivestreamingMetadata({ + entities: [{ streamType: 'streaming' }], + channelData: {}, + type: 'typing' + } as any) + ).toBeUndefined()); + describe.each([ ['integer', 1, true], ['zero', 0, false], ['decimal', 1.234, false] -])('activity with %s "streamSequence" should return undefined', (_, streamSequence, isValid) => { +])('activity with %s "streamSequence" should return undefined (channelData)', (_, streamSequence, isValid) => { const activity = { channelData: { streamSequence, streamType: 'streaming' }, id: 'a-00001', @@ -121,7 +229,27 @@ describe.each([ } }); -describe('"typing" activity with "streamType" of "final"', () => { +describe.each([ + ['integer', 1, true], + ['zero', 0, false], + ['decimal', 1.234, false] +])('activity with %s "streamSequence" should return undefined (entities)', (_, streamSequence, isValid) => { + const activity = { + entities: [{ streamSequence, streamType: 'streaming' }], + channelData: {}, + id: 'a-00001', + text: '', + type: 'typing' + } as any; + + if (isValid) { + expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); + } else { + expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); + } +}); + +describe('"typing" activity with "streamType" of "final" (channelData)', () => { test('should return undefined if "text" field is defined', () => expect( getActivityLivestreamingMetadata({ @@ -143,7 +271,31 @@ describe('"typing" activity with "streamType" of "final"', () => { ).toHaveProperty('type', 'final activity')); }); -test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => +describe('"typing" activity with "streamType" of "final" (entities)', () => { + test('should return undefined if "text" field is defined', () => + expect( + getActivityLivestreamingMetadata({ + entities: [{ streamId: 'a-00001', streamType: 'final' }], + channelData: {}, + id: 'a-00002', + text: 'Final "typing" activity, must not have "text".', + type: 'typing' + } as any) + ).toBeUndefined()); + + test('should return truthy if "text" field is not defined', () => + expect( + getActivityLivestreamingMetadata({ + entities: [{ streamId: 'a-00001', streamType: 'final' }], + channelData: {}, + id: 'a-00002', + // Final activity can be "typing" if it does not have "text". + type: 'typing' + } as any) + ).toHaveProperty('type', 'final activity')); +}); + +test('activity with "streamType" of "streaming" without "content" should return type of "contentless" (channelData)', () => expect( getActivityLivestreamingMetadata({ channelData: { streamSequence: 1, streamType: 'streaming' }, @@ -151,3 +303,13 @@ test('activity with "streamType" of "streaming" without "content" should return type: 'typing' } as any) ).toHaveProperty('type', 'contentless')); + +test('activity with "streamType" of "streaming" without "content" should return type of "contentless" (entities)', () => + expect( + getActivityLivestreamingMetadata({ + entities: [{ streamSequence: 1, streamType: 'streaming' }], + channelData: {}, + id: 'a-00001', + type: 'typing' + } as any) + ).toHaveProperty('type', 'contentless')); From 07e041899a45aa2c2b42840bb0df2403faf9e23f Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 21 Jul 2025 11:09:26 -0700 Subject: [PATCH 19/41] edit changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac56b2a5e..14de807d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Attaching files will no longer remove previously attached files - Updated Fluent theme to use the new attachment preview feature - Added collapsible activity and activity with abstract handling, in PR [#5506](https://github.com/microsoft/BotFramework-WebChat/pull/5506), in PR [#5513](https://github.com/microsoft/BotFramework-WebChat/pull/5513), by [@OEvgeny](https://github.com/OEvgeny) +- Added livestreaming support for livestreaming data in `entities` in PR []() by [@kylerohn](https://github.com/kylerohn) ### Changed From 0778a90256dac967090ee2ea0e7c55dc89ca0c66 Mon Sep 17 00:00:00 2001 From: Kyle Rohn <108435516+kylerohn@users.noreply.github.com> Date: Mon, 21 Jul 2025 18:42:57 +0000 Subject: [PATCH 20/41] finish changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50840e451f..6a8ac1005e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Attaching files will no longer remove previously attached files - Updated Fluent theme to use the new attachment preview feature - Added collapsible activity and activity with abstract handling, in PR [#5506](https://github.com/microsoft/BotFramework-WebChat/pull/5506), in PR [#5513](https://github.com/microsoft/BotFramework-WebChat/pull/5513), by [@OEvgeny](https://github.com/OEvgeny) -- Added livestreaming support for livestreaming data in `entities` in PR []() by [@kylerohn](https://github.com/kylerohn) +- Added livestreaming support for livestreaming data in `entities` in PR [#5517](https://github.com/microsoft/BotFramework-WebChat/pull/5517) by [@kylerohn](https://github.com/kylerohn) ### Changed From ed212deaa67abb81a00edc72bb983e53d383e3ae Mon Sep 17 00:00:00 2001 From: Kyle Rohn <108435516+kylerohn@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:58:06 +0000 Subject: [PATCH 21/41] edit docs --- docs/LIVESTREAMING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/LIVESTREAMING.md b/docs/LIVESTREAMING.md index 7917c9df1d..d2b3a54e13 100644 --- a/docs/LIVESTREAMING.md +++ b/docs/LIVESTREAMING.md @@ -64,6 +64,9 @@ To simplify this documentation, we are using the term "bot" instead of "copilot" Bot developers would need to implement the livestreaming as outlined in this section. The implementation below will enable livestreaming to both Azure Bot Services and Teams. +> [!NOTE] +> In the scenarios below, the livestream metadata is inside the `channelData` field. BotFramework-WebChat checks both `channelData` and the first element of the `entities` field for livestreaming metadata. It will appear in different places depending on the platform used to communicate with BotFramework-WebChat + ### Scenario 1: Livestream from start to end > In this example, we assume the bot is livestreaming the following sentence to the user: From 1e89aeb5f4e459cb3f425b85552f113869191210 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 21 Jul 2025 14:53:29 -0700 Subject: [PATCH 22/41] allow for optional channelData in entities streaming schema --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 37afec8171..8ef5be61d4 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -89,7 +89,7 @@ const entitiesStreamingActivitySchema = union([ object({ type: literal('typing'), entities: array(activeSchema), - channelData: any(), + channelData: optional(any()), ...activityExtras }), // Conclude with a message. @@ -97,7 +97,7 @@ const entitiesStreamingActivitySchema = union([ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), entities: array(finalActivitySchema), - channelData: any(), + channelData: optional(any()), ...activityExtras }), // Conclude without a message. @@ -105,7 +105,7 @@ const entitiesStreamingActivitySchema = union([ // If "text" is empty, it represents "regretting" the livestream. type: literal('typing'), entities: array(finalActivitySchema), - channelData: any(), + channelData: optional(any()), text: optional(undefinedable(literal(''))), ...activityExtrasFinal }) From 0c29c8f5c5eb889b29bedc61b305561deaac426b Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 4 Aug 2025 13:34:39 -0700 Subject: [PATCH 23/41] added jsdoc to StreamingData interface --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 8ef5be61d4..1638e69796 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -21,9 +21,15 @@ import getOrgSchemaMessage from './getOrgSchemaMessage'; const EMPTY_ARRAY = Object.freeze([]); +/** + * Represents streaming data fields in a streaming Activity + */ interface StreamingData { + /** The unique ID of the stream */ streamId?: string; + /** The sequence number of the streaming message */ streamSequence?: number; + /** The type of streaming message (streaming, informative, or final) */ streamType: string; } From d335e2efcae301ec7f4efe69ee2ba11df31c73df Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 4 Aug 2025 13:46:11 -0700 Subject: [PATCH 24/41] fix naming issues --- .../utils/getActivityLivestreamingMetadata.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 1638e69796..3ed0cd667c 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -35,18 +35,19 @@ interface StreamingData { const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -// Extra fields required for each activity -const activityExtras = { +// Fields required for every activity +const activityFieldsSchema = { // "text" is optional. If not set or empty, it presents a contentless activity. text: optional(undefinedable(string())), attachments: optional(array(any()), EMPTY_ARRAY), id: string() }; -const { text: _text, ...activityExtrasFinal } = activityExtras; +// Final Activities have different requirements for "text" fields +const { text: _text, ...activityFieldsFinalSchema } = activityFieldsSchema; // Interim or Informative Activities -const activeSchema = object({ +const ongoingStreamSchema = object({ // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, @@ -54,7 +55,7 @@ const activeSchema = object({ }); // Final Activities -const finalActivitySchema = object({ +const finalStreamSchema = object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), @@ -66,26 +67,26 @@ const channelDataStreamingActivitySchema = union([ // Informative may not have "text", but should have abstract instead (checked later) object({ type: literal('typing'), - channelData: activeSchema, + channelData: ongoingStreamSchema, entities: optional(array(any()), EMPTY_ARRAY), - ...activityExtras + ...activityFieldsSchema }), // Conclude with a message. object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - channelData: finalActivitySchema, + channelData: finalStreamSchema, entities: optional(array(any()), EMPTY_ARRAY), - ...activityExtras + ...activityFieldsSchema }), // Conclude without a message. object({ // If "text" is not set or empty, it represents "regretting" the livestream. type: literal('typing'), - channelData: finalActivitySchema, + channelData: finalStreamSchema, entities: optional(array(any()), EMPTY_ARRAY), text: optional(undefinedable(literal(''))), - ...activityExtrasFinal + ...activityFieldsFinalSchema }) ]); @@ -94,26 +95,26 @@ const entitiesStreamingActivitySchema = union([ // Informative may not have "text", but should have abstract instead (checked later) object({ type: literal('typing'), - entities: array(activeSchema), + entities: array(ongoingStreamSchema), channelData: optional(any()), - ...activityExtras + ...activityFieldsSchema }), // Conclude with a message. object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - entities: array(finalActivitySchema), + entities: array(finalStreamSchema), channelData: optional(any()), - ...activityExtras + ...activityFieldsSchema }), // Conclude without a message. object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('typing'), - entities: array(finalActivitySchema), + entities: array(finalStreamSchema), channelData: optional(any()), text: optional(undefinedable(literal(''))), - ...activityExtrasFinal + ...activityFieldsFinalSchema }) ]); From b8a886d89a46691db3be86923faf662aa2179a63 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 4 Aug 2025 14:04:10 -0700 Subject: [PATCH 25/41] add sample --- docs/LIVESTREAMING.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/LIVESTREAMING.md b/docs/LIVESTREAMING.md index d2b3a54e13..b9b07287c9 100644 --- a/docs/LIVESTREAMING.md +++ b/docs/LIVESTREAMING.md @@ -65,7 +65,20 @@ To simplify this documentation, we are using the term "bot" instead of "copilot" Bot developers would need to implement the livestreaming as outlined in this section. The implementation below will enable livestreaming to both Azure Bot Services and Teams. > [!NOTE] -> In the scenarios below, the livestream metadata is inside the `channelData` field. BotFramework-WebChat checks both `channelData` and the first element of the `entities` field for livestreaming metadata. It will appear in different places depending on the platform used to communicate with BotFramework-WebChat +> In the scenarios below, the livestream metadata is inside the `channelData` field. BotFramework-WebChat checks both `channelData` and the first element of the `entities` field for livestreaming metadata. It will appear in different places depending on the platform used to communicate with BotFramework-WebChat: +> +> ```json +> { +> "entities": [ +> { +> "streamSequence": 1, +> "streamType": "streaming" +> } +> ], +> "text": "A quick", +> "type": "typing" +> } +> ``` ### Scenario 1: Livestream from start to end From 5515771a07cd317e131c2b231a57354eb2c53a1f Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 4 Aug 2025 14:46:54 -0700 Subject: [PATCH 26/41] trigger CI From 3f905ad47c4f9e61076035a4474b4265521f5684 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 5 Aug 2025 09:39:37 -0700 Subject: [PATCH 27/41] fix sample --- docs/LIVESTREAMING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/LIVESTREAMING.md b/docs/LIVESTREAMING.md index b9b07287c9..ccda4ea234 100644 --- a/docs/LIVESTREAMING.md +++ b/docs/LIVESTREAMING.md @@ -72,7 +72,8 @@ Bot developers would need to implement the livestreaming as outlined in this sec > "entities": [ > { > "streamSequence": 1, -> "streamType": "streaming" +> "streamType": "streaming", +> "type": "streaminfo" > } > ], > "text": "A quick", From ef54b41a9ed945c98e38443151ad7fecdae59fdd Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 5 Aug 2025 10:42:53 -0700 Subject: [PATCH 28/41] require type: streaminfo field for streaming data in entities --- .../utils/getActivityLivestreamingMetadata.ts | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 3ed0cd667c..f5ce435daa 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -46,28 +46,17 @@ const activityFieldsSchema = { // Final Activities have different requirements for "text" fields const { text: _text, ...activityFieldsFinalSchema } = activityFieldsSchema; -// Interim or Informative Activities -const ongoingStreamSchema = object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: union([literal('streaming'), literal('informative')]) -}); - -// Final Activities -const finalStreamSchema = object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final') -}); - const channelDataStreamingActivitySchema = union([ // Interim or Informative message // Informative may not have "text", but should have abstract instead (checked later) object({ type: literal('typing'), - channelData: ongoingStreamSchema, + channelData: object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: union([literal('streaming'), literal('informative')]) + }), entities: optional(array(any()), EMPTY_ARRAY), ...activityFieldsSchema }), @@ -75,7 +64,12 @@ const channelDataStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - channelData: finalStreamSchema, + channelData: object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }), entities: optional(array(any()), EMPTY_ARRAY), ...activityFieldsSchema }), @@ -83,7 +77,12 @@ const channelDataStreamingActivitySchema = union([ object({ // If "text" is not set or empty, it represents "regretting" the livestream. type: literal('typing'), - channelData: finalStreamSchema, + channelData: object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final') + }), entities: optional(array(any()), EMPTY_ARRAY), text: optional(undefinedable(literal(''))), ...activityFieldsFinalSchema @@ -95,7 +94,15 @@ const entitiesStreamingActivitySchema = union([ // Informative may not have "text", but should have abstract instead (checked later) object({ type: literal('typing'), - entities: array(ongoingStreamSchema), + entities: array( + object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: union([literal('streaming'), literal('informative')]), + type: literal('streaminfo') + }) + ), channelData: optional(any()), ...activityFieldsSchema }), @@ -103,7 +110,15 @@ const entitiesStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('message'), - entities: array(finalStreamSchema), + entities: array( + object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final'), + type: literal('streaminfo') + }) + ), channelData: optional(any()), ...activityFieldsSchema }), @@ -111,7 +126,15 @@ const entitiesStreamingActivitySchema = union([ object({ // If "text" is empty, it represents "regretting" the livestream. type: literal('typing'), - entities: array(finalStreamSchema), + entities: array( + object({ + // "streamId" is required for the final activity in the session. + // The final activity must not be the sole activity in the session. + streamId: pipe(string(), nonEmpty()), + streamType: literal('final'), + type: literal('streaminfo') + }) + ), channelData: optional(any()), text: optional(undefinedable(literal(''))), ...activityFieldsFinalSchema From 5abefa2033ac5c6103941ed968b0246539fffd70 Mon Sep 17 00:00:00 2001 From: = <=> Date: Tue, 5 Aug 2025 14:20:16 -0700 Subject: [PATCH 29/41] fix unit tests to reflect added `entities` field --- .../getActivityLivestreamingMetadata.spec.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 58dd11a955..df7e6caf2b 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -101,7 +101,8 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), streamSequence: 1, - streamType: 'streaming' + streamType: 'streaming', + type: 'streaminfo' } ], channelData: {}, @@ -134,7 +135,8 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), streamSequence: 1, - streamType: 'informative' + streamType: 'informative', + type: 'streaminfo' } ], channelData: {}, @@ -166,7 +168,8 @@ describe.each([['with "streamId"' as const], ['without "streamId"' as const]])(' entities: [ { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamType: 'final' + streamType: 'final', + type: 'streaminfo' } ], channelData: {}, @@ -204,7 +207,7 @@ test('activity with "streamType" of "streaming" without critical fields should r test('activity with "streamType" of "streaming" without critical fields should return undefined (entities)', () => expect( getActivityLivestreamingMetadata({ - entities: [{ streamType: 'streaming' }], + entities: [{ streamType: 'streaming', type: 'streaminfo' }], channelData: {}, type: 'typing' } as any) @@ -235,7 +238,7 @@ describe.each([ ['decimal', 1.234, false] ])('activity with %s "streamSequence" should return undefined (entities)', (_, streamSequence, isValid) => { const activity = { - entities: [{ streamSequence, streamType: 'streaming' }], + entities: [{ streamSequence, streamType: 'streaming', type: 'streaminfo' }], channelData: {}, id: 'a-00001', text: '', @@ -275,7 +278,7 @@ describe('"typing" activity with "streamType" of "final" (entities)', () => { test('should return undefined if "text" field is defined', () => expect( getActivityLivestreamingMetadata({ - entities: [{ streamId: 'a-00001', streamType: 'final' }], + entities: [{ streamId: 'a-00001', streamType: 'final', type: 'streaminfo' }], channelData: {}, id: 'a-00002', text: 'Final "typing" activity, must not have "text".', @@ -286,7 +289,7 @@ describe('"typing" activity with "streamType" of "final" (entities)', () => { test('should return truthy if "text" field is not defined', () => expect( getActivityLivestreamingMetadata({ - entities: [{ streamId: 'a-00001', streamType: 'final' }], + entities: [{ streamId: 'a-00001', streamType: 'final', type: 'streaminfo' }], channelData: {}, id: 'a-00002', // Final activity can be "typing" if it does not have "text". @@ -307,7 +310,7 @@ test('activity with "streamType" of "streaming" without "content" should return test('activity with "streamType" of "streaming" without "content" should return type of "contentless" (entities)', () => expect( getActivityLivestreamingMetadata({ - entities: [{ streamSequence: 1, streamType: 'streaming' }], + entities: [{ streamSequence: 1, streamType: 'streaming', type: 'streaminfo' }], channelData: {}, id: 'a-00001', type: 'typing' From a7a166536249af443d8d27a95c32b4bfcb913d4f Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 08:29:40 +0000 Subject: [PATCH 30/41] Use valibot properly --- .../utils/getActivityLivestreamingMetadata.ts | 200 ++++++++---------- 1 file changed, 84 insertions(+), 116 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index f5ce435daa..82b358d722 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,6 +1,8 @@ +import type { ErrorMessage, InferOutput, ObjectEntries, ObjectIssue, ObjectSchema } from 'valibot'; import { any, array, + findItem, integer, literal, minValue, @@ -11,28 +13,16 @@ import { pipe, safeParse, string, + transform, undefinedable, union } from 'valibot'; -import type { InferOutput } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; import getOrgSchemaMessage from './getOrgSchemaMessage'; const EMPTY_ARRAY = Object.freeze([]); -/** - * Represents streaming data fields in a streaming Activity - */ -interface StreamingData { - /** The unique ID of the stream */ - streamId?: string; - /** The sequence number of the streaming message */ - streamSequence?: number; - /** The type of streaming message (streaming, informative, or final) */ - streamType: string; -} - const streamSequenceSchema = pipe(number(), integer(), minValue(1)); // Fields required for every activity @@ -46,104 +36,93 @@ const activityFieldsSchema = { // Final Activities have different requirements for "text" fields const { text: _text, ...activityFieldsFinalSchema } = activityFieldsSchema; -const channelDataStreamingActivitySchema = union([ +function eitherChannelDataOrEntities< + TActivityEntries extends ObjectEntries, + TActivityMessage extends ErrorMessage | undefined, + TMetadataEntries extends ObjectEntries, + TMetadataMessage extends ErrorMessage | undefined +>( + activitySchema: ObjectSchema, + metadataSchema: ObjectSchema + // metadataSchema: TMetadataSchema +) { + const metadataInEntitiesSchema = object({ + ...metadataSchema.entries, + type: literal('streaminfo') + }); + + function isStreamInfoEntity(value: unknown): value is InferOutput { + return safeParse(metadataInEntitiesSchema, value).success; + } + + return union([ + object({ + ...activitySchema.entries, + channelData: metadataSchema + }), + pipe( + object({ + ...activitySchema.entries, + // We use `findItem`/`filterItem` than `variant`/`someItem` because the output of the latter is an union type. + // Consider `{ type: string } | { streamId: string; type: 'streaminfo' }`, it turns into `{ type: string }` immediately. + + // TODO: [P2] valibot@1.1.0 did not infer output type for `filterItem()`, only infer for `findItem()`. + // Bump valibot@latest and see if they solved the issue. + entities: pipe(array(any()), findItem(isStreamInfoEntity)) + }), + transform(({ entities, ...value }) => ({ ...value, streamInfoEntity: entities })) + ) + ]); +} + +const livestreamingActivitySchema = union([ // Interim or Informative message // Informative may not have "text", but should have abstract instead (checked later) - object({ - type: literal('typing'), - channelData: object({ - // "streamId" is optional for the very first activity in the session. + eitherChannelDataOrEntities( + object({ + type: literal('typing'), + ...activityFieldsSchema + }), + // "streamId" is optional for the very first activity in the session. + object({ streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, streamType: union([literal('streaming'), literal('informative')]) - }), - entities: optional(array(any()), EMPTY_ARRAY), - ...activityFieldsSchema - }), + }) + ), + // Conclude with a message. - object({ - // If "text" is empty, it represents "regretting" the livestream. - type: literal('message'), - channelData: object({ + eitherChannelDataOrEntities( + object({ + // If "text" is empty, it represents "regretting" the livestream. + type: literal('message'), + ...activityFieldsSchema + }), + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') - }), - entities: optional(array(any()), EMPTY_ARRAY), - ...activityFieldsSchema - }), + }) + ), + // Conclude without a message. - object({ - // If "text" is not set or empty, it represents "regretting" the livestream. - type: literal('typing'), - channelData: object({ + eitherChannelDataOrEntities( + object({ + // If "text" is not set or empty, it represents "regretting" the livestream. + type: literal('typing'), + text: optional(undefinedable(literal(''))), + ...activityFieldsFinalSchema + }), + object({ // "streamId" is required for the final activity in the session. // The final activity must not be the sole activity in the session. streamId: pipe(string(), nonEmpty()), streamType: literal('final') - }), - entities: optional(array(any()), EMPTY_ARRAY), - text: optional(undefinedable(literal(''))), - ...activityFieldsFinalSchema - }) -]); - -const entitiesStreamingActivitySchema = union([ - // Interim or Informative message - // Informative may not have "text", but should have abstract instead (checked later) - object({ - type: literal('typing'), - entities: array( - object({ - // "streamId" is optional for the very first activity in the session. - streamId: optional(undefinedable(string())), - streamSequence: streamSequenceSchema, - streamType: union([literal('streaming'), literal('informative')]), - type: literal('streaminfo') - }) - ), - channelData: optional(any()), - ...activityFieldsSchema - }), - // Conclude with a message. - object({ - // If "text" is empty, it represents "regretting" the livestream. - type: literal('message'), - entities: array( - object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final'), - type: literal('streaminfo') - }) - ), - channelData: optional(any()), - ...activityFieldsSchema - }), - // Conclude without a message. - object({ - // If "text" is empty, it represents "regretting" the livestream. - type: literal('typing'), - entities: array( - object({ - // "streamId" is required for the final activity in the session. - // The final activity must not be the sole activity in the session. - streamId: pipe(string(), nonEmpty()), - streamType: literal('final'), - type: literal('streaminfo') - }) - ), - channelData: optional(any()), - text: optional(undefinedable(literal(''))), - ...activityFieldsFinalSchema - }) + }) + ) ]); -type EntitiesStreamingActivity = InferOutput; -type ChannelDataStreamingActivity = InferOutput; - /** * Gets the livestreaming metadata of the activity, or `undefined` if the activity is not participating in a livestreaming session. * @@ -168,43 +147,32 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: 'contentless' | 'final activity' | 'informative message' | 'interim activity'; }> | undefined { - let activityData: EntitiesStreamingActivity | ChannelDataStreamingActivity | undefined; + const result = safeParse(livestreamingActivitySchema, activity); - let streamingData: StreamingData | undefined; - - if (activity.entities) { - const result = safeParse(entitiesStreamingActivitySchema, activity); - activityData = result.success ? result.output : undefined; - streamingData = result.success ? activityData.entities[0] : undefined; - } - - if (!activityData && activity.channelData) { - const result = safeParse(channelDataStreamingActivitySchema, activity); - activityData = result.success ? result.output : undefined; - streamingData = result.success ? activityData.channelData : undefined; - } + if (result.success) { + const { output } = result; + const livestreamMetadata = 'channelData' in output ? output.channelData : output.streamInfoEntity; - if (activityData && streamingData) { // If the activity is the first in the session, session ID should be the activity ID. - const sessionId = streamingData.streamId || activityData.id; + const sessionId = livestreamMetadata.streamId || output.id; return Object.freeze( - streamingData.streamType === 'final' + livestreamMetadata.streamType === 'final' ? { sequenceNumber: Infinity, sessionId, type: 'final activity' } : { - sequenceNumber: streamingData.streamSequence, + sequenceNumber: livestreamMetadata.streamSequence, sessionId, type: !( - activityData.text || - activityData.attachments?.length || - ('entities' in activityData && getOrgSchemaMessage(activity.entities)?.abstract) + output.text || + output.attachments?.length || + ('entities' in output && getOrgSchemaMessage(activity.entities)?.abstract) ) ? 'contentless' - : streamingData.streamType === 'informative' + : livestreamMetadata.streamType === 'informative' ? 'informative message' : 'interim activity' } From 1cb311531cf623ea25f4b2bfc439d6dfb329e925 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 08:29:59 +0000 Subject: [PATCH 31/41] Verbiage --- docs/LIVESTREAMING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/LIVESTREAMING.md b/docs/LIVESTREAMING.md index ccda4ea234..021c5b66d1 100644 --- a/docs/LIVESTREAMING.md +++ b/docs/LIVESTREAMING.md @@ -65,7 +65,8 @@ To simplify this documentation, we are using the term "bot" instead of "copilot" Bot developers would need to implement the livestreaming as outlined in this section. The implementation below will enable livestreaming to both Azure Bot Services and Teams. > [!NOTE] -> In the scenarios below, the livestream metadata is inside the `channelData` field. BotFramework-WebChat checks both `channelData` and the first element of the `entities` field for livestreaming metadata. It will appear in different places depending on the platform used to communicate with BotFramework-WebChat: +> +> Web Chat supports livestreaming data in both `channelData` or `entities[type="streaminfo"]` field and can be used interchangeably. The following code snippet shows the livestream data in `entities` field. > > ```json > { @@ -76,7 +77,7 @@ Bot developers would need to implement the livestreaming as outlined in this sec > "type": "streaminfo" > } > ], -> "text": "A quick", +> "text": "...", > "type": "typing" > } > ``` From b9ad0003007d12affd99a7ed9e2360fba7cf4d22 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 08:46:46 +0000 Subject: [PATCH 32/41] Clean up --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 82b358d722..384e72e5f0 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -44,14 +44,13 @@ function eitherChannelDataOrEntities< >( activitySchema: ObjectSchema, metadataSchema: ObjectSchema - // metadataSchema: TMetadataSchema ) { const metadataInEntitiesSchema = object({ ...metadataSchema.entries, type: literal('streaminfo') }); - function isStreamInfoEntity(value: unknown): value is InferOutput { + function isStreamInfoEntity(value: unknown): value is InferOutput { return safeParse(metadataInEntitiesSchema, value).success; } From ff4d88814443b7886c211fb2ee88079d328d4c89 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 08:48:42 +0000 Subject: [PATCH 33/41] Simplify --- .../utils/getActivityLivestreamingMetadata.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 384e72e5f0..6cca782648 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,9 +1,9 @@ -import type { ErrorMessage, InferOutput, ObjectEntries, ObjectIssue, ObjectSchema } from 'valibot'; import { any, array, findItem, integer, + is, literal, minValue, nonEmpty, @@ -15,7 +15,11 @@ import { string, transform, undefinedable, - union + union, + type ErrorMessage, + type ObjectEntries, + type ObjectIssue, + type ObjectSchema } from 'valibot'; import { type WebChatActivity } from '../types/WebChatActivity'; @@ -50,10 +54,6 @@ function eitherChannelDataOrEntities< type: literal('streaminfo') }); - function isStreamInfoEntity(value: unknown): value is InferOutput { - return safeParse(metadataInEntitiesSchema, value).success; - } - return union([ object({ ...activitySchema.entries, @@ -67,7 +67,10 @@ function eitherChannelDataOrEntities< // TODO: [P2] valibot@1.1.0 did not infer output type for `filterItem()`, only infer for `findItem()`. // Bump valibot@latest and see if they solved the issue. - entities: pipe(array(any()), findItem(isStreamInfoEntity)) + entities: pipe( + array(any()), + findItem(value => is(metadataInEntitiesSchema, value)) + ) }), transform(({ entities, ...value }) => ({ ...value, streamInfoEntity: entities })) ) From 4b69274ed18784cca39db00c5be4e1124728cbb0 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 08:55:52 +0000 Subject: [PATCH 34/41] Revert back to original --- .../utils/getActivityLivestreamingMetadata.ts | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 6cca782648..123d210360 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -29,17 +29,6 @@ const EMPTY_ARRAY = Object.freeze([]); const streamSequenceSchema = pipe(number(), integer(), minValue(1)); -// Fields required for every activity -const activityFieldsSchema = { - // "text" is optional. If not set or empty, it presents a contentless activity. - text: optional(undefinedable(string())), - attachments: optional(array(any()), EMPTY_ARRAY), - id: string() -}; - -// Final Activities have different requirements for "text" fields -const { text: _text, ...activityFieldsFinalSchema } = activityFieldsSchema; - function eitherChannelDataOrEntities< TActivityEntries extends ObjectEntries, TActivityMessage extends ErrorMessage | undefined, @@ -78,27 +67,47 @@ function eitherChannelDataOrEntities< } const livestreamingActivitySchema = union([ - // Interim or Informative message - // Informative may not have "text", but should have abstract instead (checked later) + // Interim. eitherChannelDataOrEntities( object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // "text" is optional. If not set or empty, it presents a contentless activity. + text: optional(undefinedable(string())), + type: literal('typing') + }), + object({ + // "streamId" is optional for the very first activity in the session. + streamId: optional(undefinedable(string())), + streamSequence: streamSequenceSchema, + streamType: literal('streaming') + }) + ), + // Informative message. + eitherChannelDataOrEntities( + object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), + // Informative may not have "text", but should have abstract instead (checked later) + text: optional(undefinedable(string())), type: literal('typing'), - ...activityFieldsSchema + entities: optional(array(any()), EMPTY_ARRAY) }), - // "streamId" is optional for the very first activity in the session. object({ + // "streamId" is optional for the very first activity in the session. streamId: optional(undefinedable(string())), streamSequence: streamSequenceSchema, - streamType: union([literal('streaming'), literal('informative')]) + streamType: literal('informative') }) ), - // Conclude with a message. eitherChannelDataOrEntities( object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), // If "text" is empty, it represents "regretting" the livestream. - type: literal('message'), - ...activityFieldsSchema + text: optional(undefinedable(string())), + type: literal('message') }), object({ // "streamId" is required for the final activity in the session. @@ -107,14 +116,14 @@ const livestreamingActivitySchema = union([ streamType: literal('final') }) ), - // Conclude without a message. eitherChannelDataOrEntities( object({ + attachments: optional(array(any()), EMPTY_ARRAY), + id: string(), // If "text" is not set or empty, it represents "regretting" the livestream. - type: literal('typing'), text: optional(undefinedable(literal(''))), - ...activityFieldsFinalSchema + type: literal('typing') }), object({ // "streamId" is required for the final activity in the session. From 067a49ff8b94ef22ce1d38cb48416e9b107c7e30 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 19:50:17 +0000 Subject: [PATCH 35/41] Revert test and use permutation --- .../getActivityLivestreamingMetadata.spec.ts | 413 ++++++------------ .../utils/getActivityLivestreamingMetadata.ts | 4 +- 2 files changed, 138 insertions(+), 279 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index df7e6caf2b..181682a443 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -1,318 +1,175 @@ -import type { WebChatActivity } from '../types/WebChatActivity'; +import { type WebChatActivity } from '../types/WebChatActivity'; import getActivityLivestreamingMetadata from './getActivityLivestreamingMetadata'; -describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { - describe('activity with "streamType" of "streaming" (channelData)', () => { - let activity: WebChatActivity; +describe.each([['channelData' as const], ['entities' as const]])('using %s', where => { + describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { + describe('activity with "streamType" of "streaming"', () => { + let activity: WebChatActivity; - beforeEach(() => { - activity = { - channelData: { + beforeEach(() => { + const metadata = { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), streamSequence: 1, streamType: 'streaming' - }, - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; + }; + + activity = { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } as any; + }); + + test('should return type of "interim activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + + if (variant === 'with "streamId"') { + test('should return session ID with value from "channelData.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } }); - test('should return type of "interim activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); - - if (variant === 'with "streamId"') { - test('should return session ID with value from "channelData.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); - } - }); + describe('activity with "streamType" of "informative message"', () => { + let activity: WebChatActivity; - describe('activity with "streamType" of "informative message" (channelData)', () => { - let activity: WebChatActivity; - - beforeEach(() => { - activity = { - channelData: { + beforeEach(() => { + const metadata = { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), streamSequence: 1, streamType: 'informative' - }, - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; + }; + + activity = { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } as any; + }); + + test('should return type of "informative message"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); + test('should return sequence number', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + + if (variant === 'with "streamId"') { + test('should return session ID with value from "channelData.streamId"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + test('should return session ID with value of "activity.id"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + } }); - test('should return type of "informative message"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + describe('activity with "streamType" of "final"', () => { + let activity: WebChatActivity; - if (variant === 'with "streamId"') { - test('should return session ID with value from "channelData.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); - } - }); - - describe('activity with "streamType" of "final" (channelData)', () => { - let activity: WebChatActivity; - - beforeEach(() => { - activity = { - channelData: { + beforeEach(() => { + const metadata = { ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), streamType: 'final' - }, - id: 'a-00002', - text: 'Hello, World!', - type: 'message' - } as any; + }; + + activity = { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + text: 'Hello, World!', + type: 'message' + } as any; + }); + + if (variant === 'with "streamId"') { + test('should return type of "final activity"', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); + test('should return sequence number of Infinity', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); + test('should return session ID', () => + expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + } else { + // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. + test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); + } }); - - if (variant === 'with "streamId"') { - test('should return type of "final activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); - test('should return sequence number of Infinity', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); - test('should return session ID', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. - test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); - } }); -}); -describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { - describe('activity with "streamType" of "streaming" (entities)', () => { - let activity: WebChatActivity; + test('activity with "streamType" of "streaming" without critical fields should return undefined', () => { + const metadata = { streamType: 'streaming' }; - beforeEach(() => { - activity = { - entities: [ - { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'streaming', - type: 'streaminfo' - } - ], - channelData: {}, - id: 'a-00002', - text: 'Hello, World!', + expect( + getActivityLivestreamingMetadata({ + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), type: 'typing' - } as any; - }); - - test('should return type of "interim activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'interim activity')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); - - if (variant === 'with "streamId"') { - test('should return session ID with value from "entities.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); - } + } as any) + ).toBeUndefined(); }); - describe('activity with "streamType" of "informative message" (entities)', () => { - let activity: WebChatActivity; - - beforeEach(() => { - activity = { - entities: [ - { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'informative', - type: 'streaminfo' - } - ], - channelData: {}, - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; - }); - - test('should return type of "informative message"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'informative message')); - test('should return sequence number', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', 1)); + test.each([ + ['integer', 1, true], + ['zero', 0, false], + ['decimal', 1.234, false] + ])('activity with %s "streamSequence" should return undefined', (_, streamSequence, isValid) => { + const metadata = { streamId: 'a-00001', streamSequence, streamType: 'streaming' }; + + const activity = { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + text: '', + type: 'typing' + } as any; - if (variant === 'with "streamId"') { - test('should return session ID with value from "entities.streamId"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); + if (isValid) { + expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); } else { - test('should return session ID with value of "activity.id"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00002')); + expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); } }); - describe('activity with "streamType" of "final" (entities)', () => { - let activity: WebChatActivity; - - beforeEach(() => { - activity = { - entities: [ - { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamType: 'final', - type: 'streaminfo' - } - ], - channelData: {}, - id: 'a-00002', - text: 'Hello, World!', - type: 'message' - } as any; + describe('"typing" activity with "streamType" of "final"', () => { + test('should return undefined if "text" field is defined', () => { + const metadata = { streamId: 'a-00001', streamType: 'final' }; + + expect( + getActivityLivestreamingMetadata({ + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + text: 'Final "typing" activity, must not have "text".', + type: 'typing' + } as any) + ).toBeUndefined(); }); - if (variant === 'with "streamId"') { - test('should return type of "final activity"', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('type', 'final activity')); - test('should return sequence number of Infinity', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sequenceNumber', Infinity)); - test('should return session ID', () => - expect(getActivityLivestreamingMetadata(activity)).toHaveProperty('sessionId', 'a-00001')); - } else { - // Final activity must have "streamId". Final activity without "streamId" is not a valid livestream activity. - test('should return undefined', () => expect(getActivityLivestreamingMetadata(activity)).toBeUndefined()); - } + test('should return truthy if "text" field is not defined', () => { + const metadata = { streamId: 'a-00001', streamType: 'final' }; + + expect( + getActivityLivestreamingMetadata({ + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00002', + // Final activity can be "typing" if it does not have "text". + type: 'typing' + } as any) + ).toHaveProperty('type', 'final activity'); + }); }); -}); -test('invalid activity should return undefined', () => - expect(getActivityLivestreamingMetadata('invalid' as any)).toBeUndefined()); - -test('activity with "streamType" of "streaming" without critical fields should return undefined (channelData)', () => - expect( - getActivityLivestreamingMetadata({ - channelData: { streamType: 'streaming' }, - type: 'typing' - } as any) - ).toBeUndefined()); - -test('activity with "streamType" of "streaming" without critical fields should return undefined (entities)', () => - expect( - getActivityLivestreamingMetadata({ - entities: [{ streamType: 'streaming', type: 'streaminfo' }], - channelData: {}, - type: 'typing' - } as any) - ).toBeUndefined()); - -describe.each([ - ['integer', 1, true], - ['zero', 0, false], - ['decimal', 1.234, false] -])('activity with %s "streamSequence" should return undefined (channelData)', (_, streamSequence, isValid) => { - const activity = { - channelData: { streamSequence, streamType: 'streaming' }, - id: 'a-00001', - text: '', - type: 'typing' - } as any; - - if (isValid) { - expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); - } else { - expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); - } -}); - -describe.each([ - ['integer', 1, true], - ['zero', 0, false], - ['decimal', 1.234, false] -])('activity with %s "streamSequence" should return undefined (entities)', (_, streamSequence, isValid) => { - const activity = { - entities: [{ streamSequence, streamType: 'streaming', type: 'streaminfo' }], - channelData: {}, - id: 'a-00001', - text: '', - type: 'typing' - } as any; - - if (isValid) { - expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); - } else { - expect(getActivityLivestreamingMetadata(activity)).toBeUndefined(); - } -}); - -describe('"typing" activity with "streamType" of "final" (channelData)', () => { - test('should return undefined if "text" field is defined', () => - expect( - getActivityLivestreamingMetadata({ - channelData: { streamId: 'a-00001', streamType: 'final' }, - id: 'a-00002', - text: 'Final "typing" activity, must not have "text".', - type: 'typing' - } as any) - ).toBeUndefined()); + test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => { + const metadata = { streamSequence: 1, streamType: 'streaming' }; - test('should return truthy if "text" field is not defined', () => expect( getActivityLivestreamingMetadata({ - channelData: { streamId: 'a-00001', streamType: 'final' }, - id: 'a-00002', - // Final activity can be "typing" if it does not have "text". + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + id: 'a-00001', type: 'typing' } as any) - ).toHaveProperty('type', 'final activity')); -}); - -describe('"typing" activity with "streamType" of "final" (entities)', () => { - test('should return undefined if "text" field is defined', () => - expect( - getActivityLivestreamingMetadata({ - entities: [{ streamId: 'a-00001', streamType: 'final', type: 'streaminfo' }], - channelData: {}, - id: 'a-00002', - text: 'Final "typing" activity, must not have "text".', - type: 'typing' - } as any) - ).toBeUndefined()); - - test('should return truthy if "text" field is not defined', () => - expect( - getActivityLivestreamingMetadata({ - entities: [{ streamId: 'a-00001', streamType: 'final', type: 'streaminfo' }], - channelData: {}, - id: 'a-00002', - // Final activity can be "typing" if it does not have "text". - type: 'typing' - } as any) - ).toHaveProperty('type', 'final activity')); + ).toHaveProperty('type', 'contentless'); + }); }); -test('activity with "streamType" of "streaming" without "content" should return type of "contentless" (channelData)', () => - expect( - getActivityLivestreamingMetadata({ - channelData: { streamSequence: 1, streamType: 'streaming' }, - id: 'a-00001', - type: 'typing' - } as any) - ).toHaveProperty('type', 'contentless')); - -test('activity with "streamType" of "streaming" without "content" should return type of "contentless" (entities)', () => - expect( - getActivityLivestreamingMetadata({ - entities: [{ streamSequence: 1, streamType: 'streaming', type: 'streaminfo' }], - channelData: {}, - id: 'a-00001', - type: 'typing' - } as any) - ).toHaveProperty('type', 'contentless')); +test('invalid activity should return undefined', () => + expect(getActivityLivestreamingMetadata('invalid' as any)).toBeUndefined()); diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index 123d210360..d778249b20 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -1,6 +1,7 @@ import { any, array, + check, findItem, integer, is, @@ -58,7 +59,8 @@ function eitherChannelDataOrEntities< // Bump valibot@latest and see if they solved the issue. entities: pipe( array(any()), - findItem(value => is(metadataInEntitiesSchema, value)) + findItem(value => is(metadataInEntitiesSchema, value)), + check(value => !!value) ) }), transform(({ entities, ...value }) => ({ ...value, streamInfoEntity: entities })) From d64d26682baf6376074a70ae6421e992bcc70b54 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 19:51:40 +0000 Subject: [PATCH 36/41] Update entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f7bab26b..3470ec1853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,7 +107,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added `disableFileUpload` flag to completelly disable file upload feature, in PR [#5508](https://github.com/microsoft/BotFramework-WebChat/pull/5508), by [@JamesNewbyAtMicrosoft](https://github.com/JamesNewbyAtMicrosoft) - Deprecated `hideUploadButton` in favor of `disableFileUpload`. - Updated `BasicSendBoxToolbar` to rely solely on `disableFileUpload`. -- Added livestreaming support for livestreaming data in `entities` in PR [#5517](https://github.com/microsoft/BotFramework-WebChat/pull/5517) by [@kylerohn](https://github.com/kylerohn) +- Added support for livestreaming via `entities[type="streaminfo"]` in PR [#5517](https://github.com/microsoft/BotFramework-WebChat/pull/5517) by [@kylerohn](https://github.com/kylerohn) and [@compulim](https://github.com/compulim) ### Changed From fba71c15f46d31afce712d7ede47188ee4bd57db Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 20:08:46 +0000 Subject: [PATCH 37/41] Add `injectInto` --- .../getActivityLivestreamingMetadata.spec.ts | 169 +++++++++++------- 1 file changed, 103 insertions(+), 66 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 181682a443..51e51d4e66 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -1,24 +1,42 @@ +import type { ArraySlice } from 'type-fest'; import { type WebChatActivity } from '../types/WebChatActivity'; import getActivityLivestreamingMetadata from './getActivityLivestreamingMetadata'; +function injectInto( + where: 'channelData' | 'entities', + metadata: T, + activity: Partial +): WebChatActivity { + return { + ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), + ...activity + } as WebChatActivity; +} + describe.each([['channelData' as const], ['entities' as const]])('using %s', where => { + let inject: (...args: ArraySlice, 1>) => ReturnType; + + beforeEach(() => { + inject = injectInto.bind(undefined, where); + }); + describe.each([['with "streamId"' as const], ['without "streamId"' as const]])('activity %s', variant => { describe('activity with "streamType" of "streaming"', () => { let activity: WebChatActivity; beforeEach(() => { - const metadata = { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'streaming' - }; - - activity = { - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'streaming' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } + ); }); test('should return type of "interim activity"', () => @@ -39,18 +57,18 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe let activity: WebChatActivity; beforeEach(() => { - const metadata = { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamSequence: 1, - streamType: 'informative' - }; - - activity = { - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - text: 'Hello, World!', - type: 'typing' - } as any; + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamSequence: 1, + streamType: 'informative' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'typing' + } + ); }); test('should return type of "informative message"', () => @@ -71,17 +89,17 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe let activity: WebChatActivity; beforeEach(() => { - const metadata = { - ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), - streamType: 'final' - }; - - activity = { - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - text: 'Hello, World!', - type: 'message' - } as any; + activity = inject( + { + ...(variant === 'with "streamId"' ? { streamId: 'a-00001' } : {}), + streamType: 'final' + }, + { + id: 'a-00002', + text: 'Hello, World!', + type: 'message' + } + ); }); if (variant === 'with "streamId"') { @@ -114,14 +132,18 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe ['zero', 0, false], ['decimal', 1.234, false] ])('activity with %s "streamSequence" should return undefined', (_, streamSequence, isValid) => { - const metadata = { streamId: 'a-00001', streamSequence, streamType: 'streaming' }; - - const activity = { - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - text: '', - type: 'typing' - } as any; + const activity = inject( + { + streamId: 'a-00001', + streamSequence, + streamType: 'streaming' + }, + { + id: 'a-00002', + text: '', + type: 'typing' + } + ); if (isValid) { expect(getActivityLivestreamingMetadata(activity)).toBeTruthy(); @@ -132,41 +154,56 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe describe('"typing" activity with "streamType" of "final"', () => { test('should return undefined if "text" field is defined', () => { - const metadata = { streamId: 'a-00001', streamType: 'final' }; - expect( - getActivityLivestreamingMetadata({ - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - text: 'Final "typing" activity, must not have "text".', - type: 'typing' - } as any) + getActivityLivestreamingMetadata( + inject( + { + streamId: 'a-00001', + streamType: 'final' + }, + { + id: 'a-00002', + text: 'Final "typing" activity, must not have "text".', + type: 'typing' + } + ) + ) ).toBeUndefined(); }); test('should return truthy if "text" field is not defined', () => { - const metadata = { streamId: 'a-00001', streamType: 'final' }; - expect( - getActivityLivestreamingMetadata({ - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00002', - // Final activity can be "typing" if it does not have "text". - type: 'typing' - } as any) + getActivityLivestreamingMetadata( + inject( + { + streamId: 'a-00001', + streamType: 'final' + }, + { + id: 'a-00002', + // Final activity can be "typing" if it does not have "text". + type: 'typing' + } + ) + ) ).toHaveProperty('type', 'final activity'); }); }); test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => { - const metadata = { streamSequence: 1, streamType: 'streaming' }; - expect( - getActivityLivestreamingMetadata({ - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - id: 'a-00001', - type: 'typing' - } as any) + getActivityLivestreamingMetadata( + inject( + { + streamSequence: 1, + streamType: 'streaming' + }, + { + id: 'a-00001', + type: 'typing' + } + ) + ) ).toHaveProperty('type', 'contentless'); }); }); From 834e621bfac4483ad657fa303faebf37095dc64f Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 20:15:40 +0000 Subject: [PATCH 38/41] Clean up --- .../core/src/utils/getActivityLivestreamingMetadata.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 51e51d4e66..71303bd4a1 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -1,4 +1,5 @@ -import type { ArraySlice } from 'type-fest'; +import { type ArraySlice } from 'type-fest'; + import { type WebChatActivity } from '../types/WebChatActivity'; import getActivityLivestreamingMetadata from './getActivityLivestreamingMetadata'; From 6f8798d5d872dc1a26de94aa87c9e4efaf7c822d Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 20:18:23 +0000 Subject: [PATCH 39/41] Clean up --- .../getActivityLivestreamingMetadata.spec.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 71303bd4a1..2cdd37d7e0 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -118,13 +118,17 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe }); test('activity with "streamType" of "streaming" without critical fields should return undefined', () => { - const metadata = { streamType: 'streaming' }; - expect( - getActivityLivestreamingMetadata({ - ...(where === 'entities' ? { entities: [{ ...metadata, type: 'streaminfo' }] } : { channelData: metadata }), - type: 'typing' - } as any) + getActivityLivestreamingMetadata( + inject( + { + streamType: 'streaming' + }, + { + type: 'typing' + } + ) + ) ).toBeUndefined(); }); From 4ec58afb3a8ff95b6e41a6a9a139d67d4befae15 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 20:25:35 +0000 Subject: [PATCH 40/41] Add tests --- .../getActivityLivestreamingMetadata.spec.ts | 104 ++++++++++++++++-- 1 file changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts index 2cdd37d7e0..d3cb78ceaa 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.spec.ts @@ -117,7 +117,7 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe }); }); - test('activity with "streamType" of "streaming" without critical fields should return undefined', () => { + test('activity with "streamType" of "streaming" without critical fields should return undefined', () => expect( getActivityLivestreamingMetadata( inject( @@ -129,8 +129,7 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe } ) ) - ).toBeUndefined(); - }); + ).toBeUndefined()); test.each([ ['integer', 1, true], @@ -158,7 +157,7 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe }); describe('"typing" activity with "streamType" of "final"', () => { - test('should return undefined if "text" field is defined', () => { + test('should return undefined if "text" field is defined', () => expect( getActivityLivestreamingMetadata( inject( @@ -173,10 +172,9 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe } ) ) - ).toBeUndefined(); - }); + ).toBeUndefined()); - test('should return truthy if "text" field is not defined', () => { + test('should return truthy if "text" field is not defined', () => expect( getActivityLivestreamingMetadata( inject( @@ -191,11 +189,10 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe } ) ) - ).toHaveProperty('type', 'final activity'); - }); + ).toHaveProperty('type', 'final activity')); }); - test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => { + test('activity with "streamType" of "streaming" without "content" should return type of "contentless"', () => expect( getActivityLivestreamingMetadata( inject( @@ -209,9 +206,92 @@ describe.each([['channelData' as const], ['entities' as const]])('using %s', whe } ) ) - ).toHaveProperty('type', 'contentless'); - }); + ).toHaveProperty('type', 'contentless')); }); test('invalid activity should return undefined', () => expect(getActivityLivestreamingMetadata('invalid' as any)).toBeUndefined()); + +test('should prefer channelData over entities', () => + expect( + getActivityLivestreamingMetadata({ + channelData: { + streamId: 'a-channelData', + streamSequence: 2, + streamType: 'streaming' + }, + entities: [ + { + streamId: 'a-entities', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + } + ], + id: 'a-00002', + type: 'typing' + } as any) + ).toHaveProperty('sessionId', 'a-channelData')); + +test('should prefer first entity', () => + expect( + getActivityLivestreamingMetadata({ + entities: [ + { + streamId: 'a-first', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + }, + { + streamId: 'a-second', + streamSequence: 2, + streamType: 'streaming', + type: 'streaminfo' + } + ], + id: 'a-00002', + type: 'typing' + } as any) + ).toHaveProperty('sessionId', 'a-first')); + +test('channelData-based livestreaming metadata should be harmony with other entities', () => + expect( + getActivityLivestreamingMetadata({ + channelData: { + streamSequence: 1, + streamType: 'streaming' + }, + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message' + } + ], + id: 'a-00001', + type: 'typing' + } as any) + ).toHaveProperty('sequenceNumber', 1)); + +test('entity-based livestreaming metadata should be harmony with other entities', () => + expect( + getActivityLivestreamingMetadata({ + entities: [ + { + '@context': 'https://schema.org', + '@id': '', + '@type': 'Message', + type: 'https://schema.org/Message' + }, + { + streamSequence: 1, + streamType: 'streaming', + type: 'streaminfo' + } + ], + id: 'a-00001', + type: 'typing' + } as any) + ).toHaveProperty('sequenceNumber', 1)); From ad5fd91ef1602d4b3e75991937d15fc05f6047a0 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 22 Aug 2025 20:28:45 +0000 Subject: [PATCH 41/41] Reverting --- packages/core/src/utils/getActivityLivestreamingMetadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/getActivityLivestreamingMetadata.ts b/packages/core/src/utils/getActivityLivestreamingMetadata.ts index d778249b20..0a2e6cd918 100644 --- a/packages/core/src/utils/getActivityLivestreamingMetadata.ts +++ b/packages/core/src/utils/getActivityLivestreamingMetadata.ts @@ -182,7 +182,7 @@ export default function getActivityLivestreamingMetadata(activity: WebChatActivi type: !( output.text || output.attachments?.length || - ('entities' in output && getOrgSchemaMessage(activity.entities)?.abstract) + ('entities' in output && getOrgSchemaMessage(output.entities)?.abstract) ) ? 'contentless' : livestreamMetadata.streamType === 'informative'