From 836f43ed67a8810f4ca7f278e7d1710dd729eca4 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 16:23:51 -0700 Subject: [PATCH 1/6] Remove an updated activity from its previous part grouping An update with the same ID that leaves, joins, or moves between HowTo groupings stayed in its previous grouping and was inserted again, so it was rendered twice. --- .../activities/sort/upsert.howTo.spec.ts | 202 ++++++++++++++++++ .../src/reducers/activities/sort/upsert.ts | 13 ++ 2 files changed, 215 insertions(+) diff --git a/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts b/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts index 85f69cf3df..d15c55653e 100644 --- a/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts +++ b/packages/core/src/reducers/activities/sort/upsert.howTo.spec.ts @@ -366,3 +366,205 @@ scenario('upserting plain activity in two different grouping', bdd => { ]); }); }); + +scenario('updating an activity out of its grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + const activity3 = buildActivity( + { id: 'a-00003', text: 'Bonjour!', timestamp: new Date(3_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 3 } + ); + + // Same ID as activity2, without `isPartOf`. + const nextActivity2 = buildActivity( + { id: 'a-00002', text: 'Aloha! (updated)', timestamp: new Date(2_000).toISOString() }, + undefined + ); + + bdd + .given('3 activities in a grouping', () => + [activity1, activity2, activity3].reduce((state, activity) => upsert({ Date }, state, activity), INITIAL_STATE) + ) + .when('the second activity is updated without `isPartOf`', state => upsert({ Date }, state, nextActivity2)) + .then('should remove it from the grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 3_000, + partList: [ + { activityLocalId: '_:a-00001' as LocalId, logicalTimestamp: 1_000, position: 1, type: 'activity' }, + { activityLocalId: '_:a-00003' as LocalId, logicalTimestamp: 3_000, position: 3, type: 'activity' } + ] + } + ] + ]) + ); + }) + .and('should appear in `sortedChatHistoryList` on its own, before the grouping', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { activityLocalId: '_:a-00002' as LocalId, logicalTimestamp: 2_000, type: 'activity' }, + { howToGroupingId: '_:how-to:00001' as HowToGroupingId, logicalTimestamp: 3_000, type: 'how to grouping' } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should have every activity once', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(nextActivity2), + activityToExpectation(activity1), + activityToExpectation(activity3) + ]); + }); +}); + +scenario('updating an activity into another grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00002', '@type': 'HowTo' }], position: 1 } + ); + + // Same ID as activity1, moved to the second grouping. + const nextActivity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World! (updated)', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00002', '@type': 'HowTo' }], position: 2 } + ); + + bdd + .given('2 groupings with an activity each', () => + [activity1, activity2].reduce((state, activity) => upsert({ Date }, state, activity), INITIAL_STATE) + ) + .when('the first activity is updated into the second grouping', state => upsert({ Date }, state, nextActivity1)) + .then('should drop the emptied grouping and add it to the second grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00002' as HowToGroupingId, + { + logicalTimestamp: 2_000, + partList: [ + { activityLocalId: '_:a-00002' as LocalId, logicalTimestamp: 2_000, position: 1, type: 'activity' }, + { activityLocalId: '_:a-00001' as LocalId, logicalTimestamp: 1_000, position: 2, type: 'activity' } + ] + } + ] + ]) + ); + }) + .and('should only have the second grouping in `sortedChatHistoryList`', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { howToGroupingId: '_:how-to:00002' as HowToGroupingId, logicalTimestamp: 2_000, type: 'how to grouping' } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should have every activity once', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity2), activityToExpectation(nextActivity1)]); + }); +}); + +scenario('updating an activity into a grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + undefined + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + // Same ID as activity1, joining the grouping before activity2. + const nextActivity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World! (updated)', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('an activity and a grouping', () => + [activity1, activity2].reduce((state, activity) => upsert({ Date }, state, activity), INITIAL_STATE) + ) + .when('the activity is updated into the grouping', state => upsert({ Date }, state, nextActivity1)) + .then('should add it to the grouping', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 2_000, + partList: [ + { activityLocalId: '_:a-00001' as LocalId, logicalTimestamp: 1_000, position: 1, type: 'activity' }, + { activityLocalId: '_:a-00002' as LocalId, logicalTimestamp: 2_000, position: 2, type: 'activity' } + ] + } + ] + ]) + ); + }) + .and('should no longer appear in `sortedChatHistoryList` on its own', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { howToGroupingId: '_:how-to:00001' as HowToGroupingId, logicalTimestamp: 2_000, type: 'how to grouping' } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should have every activity once', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(nextActivity1), activityToExpectation(activity2)]); + }); +}); + +scenario('updating an activity in the same grouping', bdd => { + const activity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World!', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { id: 'a-00002', text: 'Aloha!', timestamp: new Date(2_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 2 } + ); + + // Same ID and grouping as activity1. + const nextActivity1 = buildActivity( + { id: 'a-00001', text: 'Hello, World! (updated)', timestamp: new Date(1_000).toISOString() }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('2 activities in a grouping', () => + [activity1, activity2].reduce((state, activity) => upsert({ Date }, state, activity), INITIAL_STATE) + ) + .when('the first activity is updated', state => upsert({ Date }, state, nextActivity1)) + .then('should keep the grouping as-is', (_, state) => { + expect(state.howToGroupingMap).toEqual( + new Map([ + [ + '_:how-to:00001' as HowToGroupingId, + { + logicalTimestamp: 2_000, + partList: [ + { activityLocalId: '_:a-00001' as LocalId, logicalTimestamp: 1_000, position: 1, type: 'activity' }, + { activityLocalId: '_:a-00002' as LocalId, logicalTimestamp: 2_000, position: 2, type: 'activity' } + ] + } + ] + ]) + ); + }) + .and('`sortedChatHistoryList` should have the grouping once', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { howToGroupingId: '_:how-to:00001' as HowToGroupingId, logicalTimestamp: 2_000, type: 'how to grouping' } + ] satisfies SortedChatHistory); + }) + .and('`sortedActivities` should have the updated activity in place', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(nextActivity1), activityToExpectation(activity2)]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index a0ee2d8ded..f7bc58a3fa 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -1,6 +1,7 @@ /* eslint-disable complexity */ import type { GlobalScopePonyfill } from '../../../types/GlobalScopePonyfill'; import getActivityLivestreamingMetadata from '../../../utils/getActivityLivestreamingMetadata'; +import deleteActivityByLocalId from './deleteActivityByLocalId'; import computePartListTimestamp from './private/computePartListTimestamp'; import computeSortedActivities from './private/computeSortedActivities'; import getLogicalTimestamp from './private/getLogicalTimestamp'; @@ -62,6 +63,18 @@ const INITIAL_STATE = Object.freeze({ function upsert(ponyfill: Pick, state: State, activity: Activity): State { const activityLocalId = getLocalIdFromActivity(activity); + const existingActivity = state.activityMap.get(activityLocalId)?.activity; + + // An update can leave, join, or move between HowTo groupings. + // Remove the previous revision first, otherwise it stays in its previous grouping and is shown twice. + if ( + existingActivity && + getPartGroupingMetadataMap(existingActivity).get('HowTo')?.groupingId !== + getPartGroupingMetadataMap(activity).get('HowTo')?.groupingId + ) { + return upsert(ponyfill, deleteActivityByLocalId(state, activityLocalId), activity); + } + const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); From 398404cf612cb1ded0d54a9196e6959593f5944e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 16:51:34 -0700 Subject: [PATCH 2/6] Keep livestream revisions out of the part grouping removal on update --- .../sort/upsert.howToWithLivestream.spec.ts | 131 ++++++++++++++++++ .../src/reducers/activities/sort/upsert.ts | 7 +- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts index e8a20641d1..682f33afe5 100644 --- a/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts +++ b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts @@ -382,3 +382,134 @@ scenario('upserting plain activity in the same grouping', bdd => { expect(state.sortedActivities).toEqual([activityToExpectation(activity3, 1_000)]); }); }); + +scenario('re-sending the final revision of a livestream in a grouping without `isPartOf`', bdd => { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamType: 'final' }, + id: 'a-00002', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(2_000).toISOString(), + type: 'message' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + // Same ID as the final revision, without `isPartOf`. + const activity2WithoutGrouping = buildActivity( + { + channelData: { streamId: 'a-00001', streamType: 'final' }, + id: 'a-00002', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(2_000).toISOString(), + type: 'message' + }, + undefined + ); + + bdd + .given('a finalized livestream session in a part grouping', () => + upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2) + ) + .when('the final revision is re-sent without `isPartOf`', state => + upsert({ Date }, state, activity2WithoutGrouping) + ) + .then('the livestream session should stay finalized', (_, state) => { + expect(state.livestreamSessionMap.get('a-00001' as LivestreamSessionId)).toHaveProperty('finalized', true); + }) + .and('`sortedChatHistoryList` should only have the part grouping', (_, state) => { + expect(state.sortedChatHistoryList).toEqual([ + { + howToGroupingId: '_:how-to:00001' as HowToGroupingId, + logicalTimestamp: 2_000, + type: 'how to grouping' + } + ] satisfies SortedChatHistory); + }) + .and('the final revision should be rendered once', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity2, 1_000)]); + }); +}); + +scenario('re-sending an older revision of a finalized livestream in a grouping without `isPartOf`', bdd => { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity3 = buildActivity( + { + channelData: { streamId: 'a-00001', streamType: 'final' }, + id: 'a-00003', + text: 'A quick brown fox jumped over the lazy dogs.', + timestamp: new Date(3_000).toISOString(), + type: 'message' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + // Same ID as the second revision, without `isPartOf`. + const activity2WithoutGrouping = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + undefined + ); + + bdd + .given('a finalized livestream session in a part grouping', () => + upsert({ Date }, upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2), activity3) + ) + .when('the second revision is re-sent without `isPartOf`', state => + upsert({ Date }, state, activity2WithoutGrouping) + ) + .then('should keep the second revision in `activityMap`', (_, state) => { + expect(state.activityMap.get('_:a-00002' as LocalId)?.activity).toEqual(activityToExpectation(activity2)); + }) + .and('should keep the livestream session finalized with all revisions', (_, state) => { + expect(state.livestreamSessionMap.get('a-00001' as LivestreamSessionId)).toEqual({ + activities: [ + expect.objectContaining({ activityLocalId: '_:a-00001' as LocalId }), + expect.objectContaining({ activityLocalId: '_:a-00002' as LocalId }), + expect.objectContaining({ activityLocalId: '_:a-00003' as LocalId }) + ], + finalized: true, + logicalTimestamp: 3_000 + } satisfies LivestreamSessionMapEntry); + }) + .and('the final revision should be rendered once', (_, state) => { + expect(state.sortedActivities).toEqual([activityToExpectation(activity3, 1_000)]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index f7bc58a3fa..61e9d616e3 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -64,10 +64,14 @@ const INITIAL_STATE = Object.freeze({ function upsert(ponyfill: Pick, state: State, activity: Activity): State { const activityLocalId = getLocalIdFromActivity(activity); const existingActivity = state.activityMap.get(activityLocalId)?.activity; + const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); + const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); // An update can leave, join, or move between HowTo groupings. // Remove the previous revision first, otherwise it stays in its previous grouping and is shown twice. + // Livestream revisions are excluded: their grouping belongs to the session, deleting one revision would break it. if ( + !activityLivestreamingMetadata && existingActivity && getPartGroupingMetadataMap(existingActivity).get('HowTo')?.groupingId !== getPartGroupingMetadataMap(activity).get('HowTo')?.groupingId @@ -75,9 +79,6 @@ function upsert(ponyfill: Pick, state: State, activ return upsert(ponyfill, deleteActivityByLocalId(state, activityLocalId), activity); } - const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); - const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); - // #region Streaming fast path // // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping: From 1704d37a35746256670c96415be5569779d87868 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 16:57:37 -0700 Subject: [PATCH 3/6] Add the changelog fragment --- .changelog/5967-fix-activity-leaving-part-group.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5967-fix-activity-leaving-part-group.md diff --git a/.changelog/5967-fix-activity-leaving-part-group.md b/.changelog/5967-fix-activity-leaving-part-group.md new file mode 100644 index 0000000000..95432f5579 --- /dev/null +++ b/.changelog/5967-fix-activity-leaving-part-group.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed activities updated into, out of, or between part groupings rendered twice, in PR [#5967](https://github.com/microsoft/BotFramework-WebChat/pull/5967), by [@OEvgeny](https://github.com/OEvgeny) From 44e46d6026545caf5f492ca8f6ceb50d676875cf Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 17:45:37 -0700 Subject: [PATCH 4/6] Replace a re-sent livestream revision instead of adding it again A revision received again with the same ID (e.g. after a reconnect) was inserted into its livestream session a second time, so it appeared twice in the sorted activities (and the development build threw "Activities from graph and Redux are of different size"). The fast path now skips known activities, and the slow path removes the previous entry before inserting. --- .../livestream/resentRevision.entity.html | 10 ++ .../resentRevision.entity.html.snap-1.png | Bin 0 -> 6128 bytes .../html2/livestream/resentRevision.html | 142 ++++++++++++++++++ .../livestream/resentRevision.html.snap-1.png | Bin 0 -> 6128 bytes .../sort/upsert.howToWithLivestream.spec.ts | 47 ++++++ .../activities/sort/upsert.livestream.spec.ts | 58 +++++++ .../src/reducers/activities/sort/upsert.ts | 7 +- 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 __tests__/html2/livestream/resentRevision.entity.html create mode 100644 __tests__/html2/livestream/resentRevision.entity.html.snap-1.png create mode 100644 __tests__/html2/livestream/resentRevision.html create mode 100644 __tests__/html2/livestream/resentRevision.html.snap-1.png diff --git a/__tests__/html2/livestream/resentRevision.entity.html b/__tests__/html2/livestream/resentRevision.entity.html new file mode 100644 index 0000000000..65b27bd06c --- /dev/null +++ b/__tests__/html2/livestream/resentRevision.entity.html @@ -0,0 +1,10 @@ + + + + resentRevision (entity): redirects to ?streamingFormat=entity + + + + diff --git a/__tests__/html2/livestream/resentRevision.entity.html.snap-1.png b/__tests__/html2/livestream/resentRevision.entity.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..5c66fcaefb14454b4988d89f9556cec2ad143504 GIT binary patch literal 6128 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 + + + + + + + + + + + +
+ + + + diff --git a/__tests__/html2/livestream/resentRevision.html.snap-1.png b/__tests__/html2/livestream/resentRevision.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..5c66fcaefb14454b4988d89f9556cec2ad143504 GIT binary patch literal 6128 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 { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('an ongoing livestream session with 2 revisions in a part grouping', () => + upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2) + ) + // Same ID, e.g. delivered again after a reconnect. + .when('the second revision is re-sent', state => upsert({ Date }, state, activity2)) + .then('should keep 2 revisions in the livestream session', (_, state) => { + expect(state.livestreamSessionMap.get('a-00001' as LivestreamSessionId)).toEqual({ + activities: [ + expect.objectContaining({ activityLocalId: '_:a-00001' as LocalId }), + expect.objectContaining({ activityLocalId: '_:a-00002' as LocalId }) + ], + finalized: false, + logicalTimestamp: 1_000 + } satisfies LivestreamSessionMapEntry); + }) + .and('`sortedActivities` should have each revision once', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts index 9d9cc9524b..18a57b9cef 100644 --- a/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts +++ b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts @@ -416,3 +416,61 @@ scenario('upserting a livestream session', bdd => { expect(state.sortedActivities).toEqual([activityToExpectation(activity4, 1_000)]); }); }); + +scenario('re-sending a revision of an ongoing livestream session', bdd => { + const activity1 = buildActivity({ + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }); + + const activity2 = buildActivity({ + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + + bdd + .given('an ongoing livestream session with 2 revisions', () => + upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2) + ) + // Same ID, e.g. delivered again after a reconnect. + .when('the second revision is re-sent', state => upsert({ Date }, state, activity2)) + .then('should keep 2 revisions in the livestream session', (_, state) => { + expect(state.livestreamSessionMap).toEqual( + new Map([ + [ + 'a-00001' as LivestreamSessionId, + { + activities: [ + { + activityLocalId: '_:a-00001' as LocalId, + logicalTimestamp: 1_000, + sequenceNumber: 1, + type: 'activity' + }, + { + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + sequenceNumber: 2, + type: 'activity' + } + ], + finalized: false, + logicalTimestamp: 1_000 + } + ] + ]) + ); + }) + .and('`sortedActivities` should have each revision once', (_, state) => { + expect(state.sortedActivities).toEqual([ + activityToExpectation(activity1, 1_000), + activityToExpectation(activity2, 2_000) + ]); + }); +}); diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index 61e9d616e3..9d4d43f505 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -91,9 +91,11 @@ function upsert(ponyfill: Pick, state: State, activ const existingSession = state.livestreamSessionMap.get(sessionId); const finalized = activityLivestreamingMetadata.type === 'final activity'; + // A re-sent revision (same local ID) takes the slow path, which replaces its previous entry. if ( existingSession && !existingSession.finalized && + !existingActivity && !finalized && !getPartGroupingMetadataMap(activity).has('HowTo') ) { @@ -292,7 +294,10 @@ function upsert(ponyfill: Pick, state: State, activ const nextLivestreamSessionMapEntry = { activities: Object.freeze( insertSorted( - livestreamSessionMapEntry ? livestreamSessionMapEntry.activities : [], + // Remove the previous entry of a re-sent revision, otherwise it is shown twice. + livestreamSessionMapEntry + ? livestreamSessionMapEntry.activities.filter(entry => entry.activityLocalId !== activityLocalId) + : [], Object.freeze({ activityLocalId, logicalTimestamp, From 8a476a894b98ff2dc66fc28dd9e5b633ae4c8bb1 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 17:59:58 -0700 Subject: [PATCH 5/6] Assert re-sent livestream revisions carry the changed text, and clarify fast path comments --- .../sort/upsert.howToWithLivestream.spec.ts | 98 ++++++++++++++++++- .../activities/sort/upsert.livestream.spec.ts | 83 +++++++++++++++- .../src/reducers/activities/sort/upsert.ts | 5 +- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts index f21f0330e5..e5616eacf8 100644 --- a/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts +++ b/packages/core/src/reducers/activities/sort/upsert.howToWithLivestream.spec.ts @@ -537,13 +537,32 @@ scenario('re-sending a revision of an ongoing livestream in a grouping', bdd => { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } ); + // Same ID with a changed text, e.g. delivered again after a reconnect. + const activity2Changed = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'a-00002:CHANGED', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + bdd .given('an ongoing livestream session with 2 revisions in a part grouping', () => upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2) ) - // Same ID, e.g. delivered again after a reconnect. - .when('the second revision is re-sent', state => upsert({ Date }, state, activity2)) - .then('should keep 2 revisions in the livestream session', (_, state) => { + .when('the second revision is re-sent with a changed text', state => upsert({ Date }, state, activity2Changed)) + .then('`activityMap` should have the changed revision', (_, state) => { + expect(state.activityMap.get('_:a-00002' as LocalId)).toEqual({ + activity: activityToExpectation(activity2Changed), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies ActivityMapEntry); + }) + .and('should keep 2 revisions in the livestream session', (_, state) => { expect(state.livestreamSessionMap.get('a-00001' as LivestreamSessionId)).toEqual({ activities: [ expect.objectContaining({ activityLocalId: '_:a-00001' as LocalId }), @@ -553,10 +572,79 @@ scenario('re-sending a revision of an ongoing livestream in a grouping', bdd => logicalTimestamp: 1_000 } satisfies LivestreamSessionMapEntry); }) - .and('`sortedActivities` should have each revision once', (_, state) => { + .and('`sortedActivities` should have the changed revision once', (_, state) => { expect(state.sortedActivities).toEqual([ activityToExpectation(activity1, 1_000), - activityToExpectation(activity2, 2_000) + activityToExpectation(activity2Changed, 2_000) + ]); + }); +}); + +scenario('re-sending a middle revision of an ongoing livestream in a grouping', bdd => { + const activity1 = buildActivity( + { + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity2 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + const activity3 = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 3, streamType: 'streaming' }, + id: 'a-00003', + text: 'A quick brown fox jumped', + timestamp: new Date(3_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + // Same ID as the middle revision with a changed text, e.g. delivered again after a reconnect. + const activity2Changed = buildActivity( + { + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'a-00002:CHANGED', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }, + { isPartOf: [{ '@id': '_:how-to:00001', '@type': 'HowTo' }], position: 1 } + ); + + bdd + .given('an ongoing livestream session with 3 revisions in a part grouping', () => + upsert({ Date }, upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2), activity3) + ) + .when('the second revision is re-sent with a changed text', state => upsert({ Date }, state, activity2Changed)) + .then('`activityMap` should have the changed revision', (_, state) => { + expect(state.activityMap.get('_:a-00002' as LocalId)).toEqual({ + activity: activityToExpectation(activity2Changed), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies ActivityMapEntry); + }) + .and('`sortedActivities` should have the changed revision once, before the third revision', (_, state) => { + expect(state.sortedActivities).toEqual([ + // Positions are not asserted: the slow path may resequence them, only the order matters. + activityToExpectation(activity1), + activityToExpectation(activity2Changed), + activityToExpectation(activity3) ]); }); }); diff --git a/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts index 18a57b9cef..edb69ec4d0 100644 --- a/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts +++ b/packages/core/src/reducers/activities/sort/upsert.livestream.spec.ts @@ -434,13 +434,29 @@ scenario('re-sending a revision of an ongoing livestream session', bdd => { type: 'typing' }); + // Same ID with a changed text, e.g. delivered again after a reconnect. + const activity2Changed = buildActivity({ + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'a-00002:CHANGED', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + bdd .given('an ongoing livestream session with 2 revisions', () => upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2) ) - // Same ID, e.g. delivered again after a reconnect. - .when('the second revision is re-sent', state => upsert({ Date }, state, activity2)) - .then('should keep 2 revisions in the livestream session', (_, state) => { + .when('the second revision is re-sent with a changed text', state => upsert({ Date }, state, activity2Changed)) + .then('`activityMap` should have the changed revision', (_, state) => { + expect(state.activityMap.get('_:a-00002' as LocalId)).toEqual({ + activity: activityToExpectation(activity2Changed), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies ActivityMapEntry); + }) + .and('should keep 2 revisions in the livestream session', (_, state) => { expect(state.livestreamSessionMap).toEqual( new Map([ [ @@ -467,10 +483,67 @@ scenario('re-sending a revision of an ongoing livestream session', bdd => { ]) ); }) - .and('`sortedActivities` should have each revision once', (_, state) => { + .and('`sortedActivities` should have the changed revision once', (_, state) => { expect(state.sortedActivities).toEqual([ activityToExpectation(activity1, 1_000), - activityToExpectation(activity2, 2_000) + activityToExpectation(activity2Changed, 2_000) + ]); + }); +}); + +scenario('re-sending a middle revision of an ongoing livestream session', bdd => { + const activity1 = buildActivity({ + channelData: { streamSequence: 1, streamType: 'streaming' }, + id: 'a-00001', + text: 'A quick', + timestamp: new Date(1_000).toISOString(), + type: 'typing' + }); + + const activity2 = buildActivity({ + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'A quick brown fox', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + + const activity3 = buildActivity({ + channelData: { streamId: 'a-00001', streamSequence: 3, streamType: 'streaming' }, + id: 'a-00003', + text: 'A quick brown fox jumped', + timestamp: new Date(3_000).toISOString(), + type: 'typing' + }); + + // Same ID as the middle revision with a changed text, e.g. delivered again after a reconnect. + const activity2Changed = buildActivity({ + channelData: { streamId: 'a-00001', streamSequence: 2, streamType: 'streaming' }, + id: 'a-00002', + text: 'a-00002:CHANGED', + timestamp: new Date(2_000).toISOString(), + type: 'typing' + }); + + bdd + .given('an ongoing livestream session with 3 revisions', () => + upsert({ Date }, upsert({ Date }, upsert({ Date }, INITIAL_STATE, activity1), activity2), activity3) + ) + .when('the second revision is re-sent with a changed text', state => upsert({ Date }, state, activity2Changed)) + .then('`activityMap` should have the changed revision', (_, state) => { + expect(state.activityMap.get('_:a-00002' as LocalId)).toEqual({ + activity: activityToExpectation(activity2Changed), + activityLocalId: '_:a-00002' as LocalId, + logicalTimestamp: 2_000, + type: 'activity' + } satisfies ActivityMapEntry); + }) + .and('`sortedActivities` should have the changed revision once, before the third revision', (_, state) => { + expect(state.sortedActivities).toEqual([ + // Positions are not asserted: the slow path may resequence them, only the order matters. + activityToExpectation(activity1), + activityToExpectation(activity2Changed), + activityToExpectation(activity3) ]); }); }); diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index 9d4d43f505..a424ffe771 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -81,7 +81,7 @@ function upsert(ponyfill: Pick, state: State, activ // #region Streaming fast path // - // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping: + // For a new revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping: // avoid the heavier full rebuild path, including sortedChatHistoryList recomputation, // computeSortedActivities, and full position resequencing. This is still not constant-time: // the fast path continues to clone Maps and update sortedActivities, but it avoids the @@ -233,7 +233,8 @@ function upsert(ponyfill: Pick, state: State, activ // #endregion - // Slow path: full recalculation for non-streaming, first/final revisions, reorders, or HowTo grouping. + // Slow path: full recalculation for non-streaming, first/final revisions, re-sent revisions, reorders, + // or HowTo grouping. const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); const nextActivityMap = new Map(state.activityMap); const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); From 2d0c4ec83c3bb1686f9052a85aade81a35da2ead Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 18:04:32 -0700 Subject: [PATCH 6/6] Add the changelog fragment --- .changelog/5969-fix-livestream-resent-revision.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5969-fix-livestream-resent-revision.md diff --git a/.changelog/5969-fix-livestream-resent-revision.md b/.changelog/5969-fix-livestream-resent-revision.md new file mode 100644 index 0000000000..5e024a99ed --- /dev/null +++ b/.changelog/5969-fix-livestream-resent-revision.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed livestream revisions re-sent with the same ID shown twice, in PR [#5969](https://github.com/microsoft/BotFramework-WebChat/pull/5969), by [@OEvgeny](https://github.com/OEvgeny)