From 5576f9a5c529a9600918af94ae84a3e91003d3ac Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 5 Nov 2025 16:19:15 +0100 Subject: [PATCH 1/5] Fix the ConversationID handling of LLMGateway --- docs-builder.sln | 256 +++++++++++++++++- .../SearchOrAskAi/AskAi/Chat.tsx | 4 +- .../AskAi/StreamTransformerBase.cs | 4 +- .../Adapters/AskAi/LlmGatewayAskAiGateway.cs | 2 +- .../AskAi/LlmGatewayStreamTransformer.cs | 32 +++ .../Adapters/AskAi/StreamTransformerTests.cs | 200 ++++++++++++-- 6 files changed, 463 insertions(+), 35 deletions(-) diff --git a/docs-builder.sln b/docs-builder.sln index 559deb139..055eccce7 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Markdown", "src\Elastic.Markdown\Elastic.Markdown.csproj", "{4D198E25-C211-41DC-9E84-B15E89BD7048}" EndProject @@ -145,142 +145,387 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Links EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Isolated", "src\services\Elastic.Documentation.Isolated\Elastic.Documentation.Isolated.csproj", "{AABD3EF7-8C86-4981-B1D2-B1F786F33069}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Infrastructure.Tests", "tests\Elastic.Documentation.Api.Infrastructure.Tests\Elastic.Documentation.Api.Infrastructure.Tests.csproj", "{77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x64.Build.0 = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Debug|x86.Build.0 = Debug|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|Any CPU.Build.0 = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x64.ActiveCfg = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x64.Build.0 = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.ActiveCfg = Release|Any CPU + {4D198E25-C211-41DC-9E84-B15E89BD7048}.Release|x86.Build.0 = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x64.Build.0 = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Debug|x86.Build.0 = Debug|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|Any CPU.Build.0 = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.ActiveCfg = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x64.Build.0 = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.ActiveCfg = Release|Any CPU + {13387C19-03EC-41AC-A0FC-B5A7E3761DA6}.Release|x86.Build.0 = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x64.Build.0 = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Debug|x86.Build.0 = Debug|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|Any CPU.Build.0 = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x64.ActiveCfg = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x64.Build.0 = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.ActiveCfg = Release|Any CPU + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0}.Release|x86.Build.0 = Release|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x64.Build.0 = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Debug|x86.Build.0 = Debug|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.ActiveCfg = Release|Any CPU {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|Any CPU.Build.0 = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.ActiveCfg = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x64.Build.0 = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.ActiveCfg = Release|Any CPU + {1A8659C1-222A-4824-B562-ED8F88658C05}.Release|x86.Build.0 = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x64.Build.0 = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Debug|x86.Build.0 = Debug|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|Any CPU.Build.0 = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x64.ActiveCfg = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x64.Build.0 = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.ActiveCfg = Release|Any CPU + {B27C5107-128B-465A-B8F8-8985399E4CFB}.Release|x86.Build.0 = Release|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.ActiveCfg = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x64.Build.0 = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.ActiveCfg = Debug|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Debug|x86.Build.0 = Debug|Any CPU {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x64.Build.0 = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.ActiveCfg = Release|Any CPU + {10857974-6CF1-42B5-B793-AAA988BD7348}.Release|x86.Build.0 = Release|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x64.ActiveCfg = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x64.Build.0 = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x86.ActiveCfg = Debug|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Debug|x86.Build.0 = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.ActiveCfg = Release|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.Build.0 = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x64.ActiveCfg = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x64.Build.0 = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x86.ActiveCfg = Release|Any CPU + {018F959E-824B-4664-B345-066784478D24}.Release|x86.Build.0 = Release|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x64.Build.0 = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|x86.Build.0 = Debug|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.Build.0 = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x64.ActiveCfg = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x64.Build.0 = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x86.ActiveCfg = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|x86.Build.0 = Release|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x64.Build.0 = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Debug|x86.Build.0 = Debug|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|Any CPU.ActiveCfg = Release|Any CPU {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|Any CPU.Build.0 = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x64.ActiveCfg = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x64.Build.0 = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x86.ActiveCfg = Release|Any CPU + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA}.Release|x86.Build.0 = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x64.Build.0 = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Debug|x86.Build.0 = Debug|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|Any CPU.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x64.Build.0 = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.ActiveCfg = Release|Any CPU + {C559D52D-100B-4B2B-BE87-2344D835761D}.Release|x86.Build.0 = Release|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x64.ActiveCfg = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x64.Build.0 = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x86.ActiveCfg = Debug|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Debug|x86.Build.0 = Debug|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|Any CPU.Build.0 = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x64.ActiveCfg = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x64.Build.0 = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x86.ActiveCfg = Release|Any CPU + {09CE30F6-013A-49ED-B3D6-60AFA84682AC}.Release|x86.Build.0 = Release|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x64.Build.0 = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x86.ActiveCfg = Debug|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Debug|x86.Build.0 = Debug|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|Any CPU.Build.0 = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x64.ActiveCfg = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x64.Build.0 = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x86.ActiveCfg = Release|Any CPU + {CD94F9E4-7FCD-4152-81F1-4288C6B75367}.Release|x86.Build.0 = Release|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x64.Build.0 = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Debug|x86.Build.0 = Debug|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|Any CPU.ActiveCfg = Release|Any CPU {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|Any CPU.Build.0 = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x64.ActiveCfg = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x64.Build.0 = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x86.ActiveCfg = Release|Any CPU + {FD1AC230-798B-4AB9-8CE6-A06264885DBC}.Release|x86.Build.0 = Release|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x64.ActiveCfg = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x64.Build.0 = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x86.ActiveCfg = Debug|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Debug|x86.Build.0 = Debug|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|Any CPU.ActiveCfg = Release|Any CPU {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|Any CPU.Build.0 = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x64.ActiveCfg = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x64.Build.0 = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x86.ActiveCfg = Release|Any CPU + {C883AC18-7C6A-482E-A9D7-C44DF8633425}.Release|x86.Build.0 = Release|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x64.Build.0 = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Debug|x86.Build.0 = Debug|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|Any CPU.ActiveCfg = Release|Any CPU {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|Any CPU.Build.0 = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x64.ActiveCfg = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x64.Build.0 = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x86.ActiveCfg = Release|Any CPU + {0331559E-4ED1-4A56-9C35-3EAD4D7E696D}.Release|x86.Build.0 = Release|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x64.Build.0 = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Debug|x86.Build.0 = Debug|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|Any CPU.Build.0 = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x64.ActiveCfg = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x64.Build.0 = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x86.ActiveCfg = Release|Any CPU + {89B83007-71E6-4B57-BA78-2544BFA476DB}.Release|x86.Build.0 = Release|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x64.ActiveCfg = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x64.Build.0 = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x86.ActiveCfg = Debug|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Debug|x86.Build.0 = Debug|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {111E7029-BB29-4039-9B45-04776798A8DD}.Release|Any CPU.Build.0 = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x64.ActiveCfg = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x64.Build.0 = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x86.ActiveCfg = Release|Any CPU + {111E7029-BB29-4039-9B45-04776798A8DD}.Release|x86.Build.0 = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x64.ActiveCfg = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x64.Build.0 = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|x86.Build.0 = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.Build.0 = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x64.ActiveCfg = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x64.Build.0 = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x86.ActiveCfg = Release|Any CPU + {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|x86.Build.0 = Release|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x64.Build.0 = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Debug|x86.Build.0 = Debug|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|Any CPU.ActiveCfg = Release|Any CPU {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|Any CPU.Build.0 = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x64.ActiveCfg = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x64.Build.0 = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x86.ActiveCfg = Release|Any CPU + {A272D3EC-FAAF-4795-A796-302725382AFF}.Release|x86.Build.0 = Release|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x64.ActiveCfg = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x64.Build.0 = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x86.ActiveCfg = Debug|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Debug|x86.Build.0 = Debug|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|Any CPU.ActiveCfg = Release|Any CPU {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|Any CPU.Build.0 = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x64.ActiveCfg = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x64.Build.0 = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x86.ActiveCfg = Release|Any CPU + {4DFECE72-4A1F-4B58-918E-DCD07B585231}.Release|x86.Build.0 = Release|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x64.Build.0 = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Debug|x86.Build.0 = Debug|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|Any CPU.Build.0 = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x64.ActiveCfg = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x64.Build.0 = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x86.ActiveCfg = Release|Any CPU + {2A83ED35-B631-4F02-8D4C-15611D0DB72C}.Release|x86.Build.0 = Release|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x64.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x64.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x86.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|x86.Build.0 = Debug|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.ActiveCfg = Release|Any CPU {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x64.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x64.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x86.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|x86.Build.0 = Release|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x64.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|x86.Build.0 = Debug|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x64.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x64.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x86.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|x86.Build.0 = Release|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x64.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|x86.Build.0 = Debug|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x64.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x64.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x86.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|x86.Build.0 = Release|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x64.Build.0 = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Debug|x86.Build.0 = Debug|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|Any CPU.Build.0 = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x64.ActiveCfg = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x64.Build.0 = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x86.ActiveCfg = Release|Any CPU + {094433A4-504F-4E12-959F-CCB1965C1C9A}.Release|x86.Build.0 = Release|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x64.Build.0 = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Debug|x86.Build.0 = Debug|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|Any CPU.Build.0 = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x64.ActiveCfg = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x64.Build.0 = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x86.ActiveCfg = Release|Any CPU + {E6EA955D-D0A7-4749-9586-0F7256EF5C5E}.Release|x86.Build.0 = Release|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x64.ActiveCfg = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x64.Build.0 = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x86.ActiveCfg = Debug|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Debug|x86.Build.0 = Debug|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|Any CPU.ActiveCfg = Release|Any CPU {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|Any CPU.Build.0 = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x64.ActiveCfg = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x64.Build.0 = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x86.ActiveCfg = Release|Any CPU + {153FC4AD-F5B0-4100-990E-0987C86DBF01}.Release|x86.Build.0 = Release|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x64.ActiveCfg = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x64.Build.0 = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x86.ActiveCfg = Debug|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Debug|x86.Build.0 = Debug|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|Any CPU.ActiveCfg = Release|Any CPU {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|Any CPU.Build.0 = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x64.Build.0 = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.ActiveCfg = Release|Any CPU + {AABD3EF7-8C86-4981-B1D2-B1F786F33069}.Release|x86.Build.0 = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|x64.ActiveCfg = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|x64.Build.0 = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|x86.ActiveCfg = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Debug|x86.Build.0 = Debug|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|Any CPU.Build.0 = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x64.ActiveCfg = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x64.Build.0 = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x86.ActiveCfg = Release|Any CPU + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {01F05AD0-E0E0-401F-A7EC-905928E1E9F0} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {B27C5107-128B-465A-B8F8-8985399E4CFB} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} {CD2887E3-BDA9-434B-A5BF-9ED38DE20332} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {A2A34BBC-CB5E-4100-9529-A12B6ECB769C} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {018F959E-824B-4664-B345-066784478D24} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} + {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {6E2ED6CC-AFC1-4E58-965D-6AEC500EBB46} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {6554F917-73CE-4B3D-9101-F28EAA762C6B} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} - {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {C559D52D-100B-4B2B-BE87-2344D835761D} = {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} + {4894063D-0DEF-4B7E-97D0-0D0A5B85C608} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {BB789671-B262-43DD-91DB-39F9186B8257} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {09CE30F6-013A-49ED-B3D6-60AFA84682AC} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {CD94F9E4-7FCD-4152-81F1-4288C6B75367} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {73ABAE37-118F-4A53-BC2C-F19333555C90} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} - {01F05AD0-E0E0-401F-A7EC-905928E1E9F0} = {73ABAE37-118F-4A53-BC2C-F19333555C90} - {4CCE599A-B9FE-4DF2-8763-34CF0A99D4AA} = {73ABAE37-118F-4A53-BC2C-F19333555C90} {059E787F-85C1-43BE-9DD6-CE319E106383} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} - {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {059E787F-85C1-43BE-9DD6-CE319E106383} {FB1C1954-D8E2-4745-BA62-04DD82FB4792} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {FD1AC230-798B-4AB9-8CE6-A06264885DBC} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {C883AC18-7C6A-482E-A9D7-C44DF8633425} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -300,5 +545,6 @@ Global {E6EA955D-D0A7-4749-9586-0F7256EF5C5E} = {7AACA67B-3C56-4C7C-9891-558589FC52DB} {153FC4AD-F5B0-4100-990E-0987C86DBF01} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {AABD3EF7-8C86-4981-B1D2-B1F786F33069} = {7AACA67B-3C56-4C7C-9891-558589FC52DB} + {77BF3BF2-C78B-4B5D-8E7D-DD3716235BF4} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} EndGlobalSection EndGlobal diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index 367b0fb3f..34552ca71 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -2,7 +2,7 @@ import { AiProviderSelector } from './AiProviderSelector' import { AskAiSuggestions } from './AskAiSuggestions' import { ChatMessageList } from './ChatMessageList' -import { useChatActions, useChatMessages } from './chat.store' +import {useChatActions, useChatMessages, useConversationId} from './chat.store' import { useEuiOverflowScroll, EuiButtonEmpty, @@ -68,6 +68,7 @@ export const Chat = () => { const scrollRef = useRef(null) const lastMessageStatusRef = useRef(null) const [inputValue, setInputValue] = useState('') + const conversationId = useConversationId() const dynamicScrollableStyles = css` ${scrollableStyles} @@ -124,6 +125,7 @@ export const Chat = () => { css={containerStyles} > + ConversationId: {conversationId} {messages.length > 0 && ( diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs index 1a2ca22b7..eefb7160b 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs @@ -129,7 +129,7 @@ private async Task ProcessPipeAsync(PipeReader reader, PipeWriter writer, string /// Default implementation parses SSE events and JSON, then calls TransformJsonEvent. /// /// Stream processing result with metrics and captured output - private async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, string? conversationId, Activity? parentActivity, CancellationToken cancellationToken) + protected virtual async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, string? conversationId, Activity? parentActivity, CancellationToken cancellationToken) { using var activity = StreamTransformerActivitySource.StartActivity(nameof(ProcessStreamAsync)); @@ -255,7 +255,7 @@ private async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, stri /// /// Write a transformed event to the output stream /// - private async Task WriteEventAsync(AskAiEvent? transformedEvent, PipeWriter writer, CancellationToken cancellationToken) + protected async Task WriteEventAsync(AskAiEvent? transformedEvent, PipeWriter writer, CancellationToken cancellationToken) { if (transformedEvent == null) return; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index 8626af230..f7d1cdf70 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -68,7 +68,7 @@ public static LlmGatewayRequest CreateFromRequest(AskAiRequest request) => new ChatInput("user", AskAiRequest.SystemPrompt), new ChatInput("user", request.Message) ], - ThreadId: request.ConversationId ?? "elastic-docs-" + Guid.NewGuid() + ThreadId: request.ConversationId ?? Guid.NewGuid().ToString() ); } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayStreamTransformer.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayStreamTransformer.cs index 2e150bba5..b0c21ec36 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayStreamTransformer.cs @@ -2,6 +2,8 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Diagnostics; +using System.IO.Pipelines; using System.Text.Json; using Elastic.Documentation.Api.Core.AskAi; using Microsoft.Extensions.Logging; @@ -15,6 +17,36 @@ public class LlmGatewayStreamTransformer(ILogger lo { protected override string GetAgentId() => LlmGatewayAskAiGateway.ModelName; protected override string GetAgentProvider() => LlmGatewayAskAiGateway.ProviderName; + + /// + /// Override to emit ConversationStart event when conversationId is null (new conversation) + /// + protected override async Task ProcessStreamAsync(PipeReader reader, PipeWriter writer, string? conversationId, Activity? parentActivity, CancellationToken cancellationToken) + { + // If conversationId is null, generate a new one and emit ConversationStart event + // This matches the ThreadId format used in LlmGatewayAskAiGateway + var actualConversationId = conversationId; + if (conversationId == null) + { + actualConversationId = Guid.NewGuid().ToString(); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var conversationStartEvent = new AskAiEvent.ConversationStart( + Id: Guid.NewGuid().ToString(), + Timestamp: timestamp, + ConversationId: actualConversationId + ); + + // Set activity tags for the new conversation + _ = parentActivity?.SetTag("gen_ai.conversation.id", actualConversationId); + Logger.LogDebug("LLM Gateway conversation started: {ConversationId}", actualConversationId); + + // Write the ConversationStart event to the stream + await WriteEventAsync(conversationStartEvent, writer, cancellationToken); + } + + // Continue with normal stream processing using the actual conversation ID + await base.ProcessStreamAsync(reader, writer, actualConversationId, parentActivity, cancellationToken); + } protected override AskAiEvent? TransformJsonEvent(string? conversationId, string? eventType, JsonElement json) { // LLM Gateway format: ["custom", {type: "...", ...}] diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index df3187920..bd40a313e 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -54,7 +54,7 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); // Assert @@ -67,8 +67,8 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() events.Should().ContainSingle(e => e is AskAiEvent.Reasoning); events.Should().ContainSingle(e => e is AskAiEvent.SearchToolCall); events.Should().ContainSingle(e => e is AskAiEvent.ToolResult); - events.Should().Contain(e => e is AskAiEvent.Chunk); - events.Should().ContainSingle(e => e is AskAiEvent.ChunkComplete); + events.Should().Contain(e => e is AskAiEvent.MessageChunk); + events.Should().ContainSingle(e => e is AskAiEvent.MessageComplete); // Verify specific content var convStart = events.OfType().First(); @@ -87,12 +87,12 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() toolResult.ToolCallId.Should().Be("tooluse_abc123"); toolResult.Result.Should().Contain("semantic-docs-prod-latest"); - var chunks = events.OfType().ToList(); + var chunks = events.OfType().ToList(); chunks.Should().HaveCount(2); chunks[0].Content.Should().Be("Hello"); chunks[1].Content.Should().Be(" world"); - var complete = events.OfType().First(); + var complete = events.OfType().First(); complete.FullContent.Should().Be("Hello world"); } @@ -116,12 +116,12 @@ public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); // Assert - Should have at least 1 event (round_complete might not be written in time) events.Should().HaveCountGreaterOrEqualTo(1); - events[0].Should().BeOfType(); + events[0].Should().BeOfType(); } [Fact] @@ -139,7 +139,7 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); @@ -152,12 +152,12 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() private static async Task> ParseEventsFromStream(Stream stream) { var events = new List(); - + // Copy to memory stream to ensure all data is available var ms = new MemoryStream(); await stream.CopyToAsync(ms); ms.Position = 0; - + using var reader = new StreamReader(ms, Encoding.UTF8); while (!reader.EndOfStream) @@ -216,7 +216,7 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); // Assert @@ -226,16 +226,20 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() events[0].Should().BeOfType(); var convStart = events[0] as AskAiEvent.ConversationStart; convStart!.ConversationId.Should().NotBeNullOrEmpty(); - Guid.TryParse(convStart.ConversationId, out _).Should().BeTrue(); + + // convStart!.ConversationId.Should().Be("1"); + + _ = Guid.TryParse(convStart.ConversationId, out _).Should().BeTrue(); + // Event 2: ai_message_chunk (first) - events[1].Should().BeOfType(); - var chunk1 = events[1] as AskAiEvent.Chunk; + events[1].Should().BeOfType(); + var chunk1 = events[1] as AskAiEvent.MessageChunk; chunk1!.Content.Should().Be("Hello"); // Event 3: ai_message_chunk (second) - events[2].Should().BeOfType(); - var chunk2 = events[2] as AskAiEvent.Chunk; + events[2].Should().BeOfType(); + var chunk2 = events[2] as AskAiEvent.MessageChunk; chunk2!.Content.Should().Be(" world"); // Event 4: tool_call -> Should be SearchToolCall with extracted query @@ -251,8 +255,8 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() toolResult.Result.Should().Contain("Found 10 docs"); // Event 6: ai_message - events[5].Should().BeOfType(); - var complete = events[5] as AskAiEvent.ChunkComplete; + events[5].Should().BeOfType(); + var complete = events[5] as AskAiEvent.MessageComplete; complete!.FullContent.Should().Be("Hello world"); // Event 7: agent_end @@ -265,13 +269,13 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() // Arrange var sseData = """ event: agent_stream_output - data: + data: event: agent_stream_output data: [null, {"type":"agent_start","id":"1","timestamp":1234567890,"data":{}}] event: agent_stream_output - data: + data: event: agent_stream_output data: [null, {"type":"agent_end","id":"2","timestamp":1234567891,"data":{}}] @@ -281,7 +285,7 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); // Assert - Should only have 2 events @@ -306,23 +310,167 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); // Act - var outputStream = await _transformer.TransformAsync(inputStream, CancellationToken.None); + var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); var events = await ParseEventsFromStream(outputStream); // Assert - Should only have the message chunk, model events skipped - events.Should().HaveCount(1); - events[0].Should().BeOfType(); + events.Should().HaveCount(2); + events[0].Should().BeOfType(); + events[1].Should().BeOfType(); } private static async Task> ParseEventsFromStream(Stream stream) { var events = new List(); - + // Copy to memory stream to ensure all data is available var ms = new MemoryStream(); await stream.CopyToAsync(ms); ms.Position = 0; - + + using var reader = new StreamReader(ms, Encoding.UTF8); + + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(); + if (line == null) + break; + + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + var json = line.Substring(6); + var evt = JsonSerializer.Deserialize(json, AskAiEventJsonContext.Default.AskAiEvent); + if (evt != null) + events.Add(evt); + } + } + + return events; + } +} + +/// +/// Parameterized tests for common behaviors expected from all stream transformers. +/// These tests ensure consistency across different transformer implementations. +/// Adding a new transformer? Just add it to StreamTransformerTestCases() and these tests will automatically run against it. +/// +public class StreamTransformerCommonBehaviorTests +{ + public static IEnumerable StreamTransformerTestCases() + { + yield return new object[] + { + "AgentBuilderStreamTransformer", + new AgentBuilderStreamTransformer(NullLogger.Instance), + // Agent Builder SSE format for conversation_id_set + """ + event: conversation_id_set + data: {"data":{"conversation_id":"360222c5-76aa-405a-8316-703e1061b621"}} + + event: message_chunk + data: {"data":{"text_chunk":"test"}} + + """ + }; + yield return new object[] + { + "LlmGatewayStreamTransformer", + new LlmGatewayStreamTransformer(NullLogger.Instance), + // LLM Gateway SSE format - minimal events + """ + event: agent_stream_output + data: [null, {"type":"ai_message_chunk","id":"1","timestamp":1234567890,"data":{"content":"test"}}] + + """ + }; + } + + [Theory] + [MemberData(nameof(StreamTransformerTestCases))] + public async Task TransformAsync_WhenConversationIdIsNull_EmitsConversationStartEvent( + string transformerName, + IStreamTransformer transformer, + string sseData) + { + // Arrange + var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); + + // Act - Pass null conversationId to simulate new conversation + var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); + var events = await ParseEventsFromStream(outputStream); + + // Assert - Should have ConversationStart event + events.Should().ContainSingle(e => e is AskAiEvent.ConversationStart, + $"{transformerName} should emit ConversationStart when conversationId is null"); + + var conversationStart = events.OfType().First(); + conversationStart.ConversationId.Should().NotBeNullOrEmpty( + $"{transformerName} should have a non-empty conversation ID in ConversationStart event"); + + // For LlmGateway, when conversationId is null, we generate it with "elastic-docs-" prefix + // For AgentBuilder, the conversation ID comes from the SSE event and may have a different format + if (transformerName == "LlmGatewayStreamTransformer") + { + conversationStart.ConversationId.Should().StartWith("elastic-docs-", + $"{transformerName} should generate conversation ID with 'elastic-docs-' prefix when conversationId is null"); + } + } + + [Theory] + [MemberData(nameof(StreamTransformerTestCases))] + public async Task TransformAsync_ConversationStartEvent_HasValidTimestamp( + string transformerName, + IStreamTransformer transformer, + string sseData) + { + // Arrange + var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); + + // Act + var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); + var events = await ParseEventsFromStream(outputStream); + + // Assert + var conversationStart = events.OfType().FirstOrDefault(); + conversationStart.Should().NotBeNull( + $"{transformerName} should emit ConversationStart event"); + + conversationStart!.Timestamp.Should().BeGreaterThan(0, + $"{transformerName} ConversationStart should have a valid timestamp"); + } + + [Theory] + [MemberData(nameof(StreamTransformerTestCases))] + public async Task TransformAsync_ConversationStartEvent_HasValidId( + string transformerName, + IStreamTransformer transformer, + string sseData) + { + // Arrange + var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(sseData)); + + // Act + var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); + var events = await ParseEventsFromStream(outputStream); + + // Assert + var conversationStart = events.OfType().FirstOrDefault(); + conversationStart.Should().NotBeNull( + $"{transformerName} should emit ConversationStart event"); + + conversationStart!.Id.Should().NotBeNullOrEmpty( + $"{transformerName} ConversationStart should have a non-empty event ID"); + } + + private static async Task> ParseEventsFromStream(Stream stream) + { + var events = new List(); + + // Copy to memory stream to ensure all data is available + var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + ms.Position = 0; + using var reader = new StreamReader(ms, Encoding.UTF8); while (!reader.EndOfStream) From cba79633b8eadcc80ecf1a2b020e8f96951f843a Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 5 Nov 2025 16:23:18 +0100 Subject: [PATCH 2/5] Run prettier --- .../Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index 34552ca71..dc0352777 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -2,7 +2,11 @@ import { AiProviderSelector } from './AiProviderSelector' import { AskAiSuggestions } from './AskAiSuggestions' import { ChatMessageList } from './ChatMessageList' -import {useChatActions, useChatMessages, useConversationId} from './chat.store' +import { + useChatActions, + useChatMessages, + useConversationId, +} from './chat.store' import { useEuiOverflowScroll, EuiButtonEmpty, @@ -126,11 +130,9 @@ export const Chat = () => { > ConversationId: {conversationId} - {messages.length > 0 && ( )} -
{messages.length === 0 ? ( @@ -169,7 +171,6 @@ export const Chat = () => { )}
- {/* Input */} From d6aea2f0e969d84b78b201189870ee947dc58558 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 5 Nov 2025 16:24:10 +0100 Subject: [PATCH 3/5] Revert Chat.tsxt --- .../web-components/SearchOrAskAi/AskAi/Chat.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx index dc0352777..367b0fb3f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx @@ -2,11 +2,7 @@ import { AiProviderSelector } from './AiProviderSelector' import { AskAiSuggestions } from './AskAiSuggestions' import { ChatMessageList } from './ChatMessageList' -import { - useChatActions, - useChatMessages, - useConversationId, -} from './chat.store' +import { useChatActions, useChatMessages } from './chat.store' import { useEuiOverflowScroll, EuiButtonEmpty, @@ -72,7 +68,6 @@ export const Chat = () => { const scrollRef = useRef(null) const lastMessageStatusRef = useRef(null) const [inputValue, setInputValue] = useState('') - const conversationId = useConversationId() const dynamicScrollableStyles = css` ${scrollableStyles} @@ -129,10 +124,11 @@ export const Chat = () => { css={containerStyles} > - ConversationId: {conversationId} + {messages.length > 0 && ( )} +
{messages.length === 0 ? ( @@ -171,6 +167,7 @@ export const Chat = () => { )}
+ {/* Input */} From ebbbc35aac49b93daf36990bea8d9e30b1510700 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 5 Nov 2025 17:19:22 +0100 Subject: [PATCH 4/5] Formatting --- .../Adapters/AskAi/StreamTransformerTests.cs | 228 ++++++++++-------- 1 file changed, 129 insertions(+), 99 deletions(-) diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index bd40a313e..4a85c2cd2 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -2,6 +2,9 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Buffers; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using Elastic.Documentation.Api.Core.AskAi; @@ -12,6 +15,117 @@ namespace Elastic.Documentation.Api.Infrastructure.Tests.Adapters.AskAi; +/// +/// Shared test helpers for stream transformer tests. +/// Reuses the same SSE parsing approach as StreamTransformerBase for consistency. +/// +public static class StreamTransformerTestHelpers +{ + /// + /// Represents a parsed Server-Sent Event (SSE) - matches StreamTransformerBase.SseEvent + /// + private sealed record SseEvent(string? EventType, string Data); + + /// + /// Parses SSE events from a stream and deserializes them into AskAiEvent objects. + /// Uses the same SSE parsing logic as StreamTransformerBase for consistency. + /// The stream contains Server-Sent Events (SSE) format with lines like "data: {...json...}". + /// + public static async Task> ParseEventsFromStream(Stream stream) + { + var events = new List(); + + // Use PipeReader like the production code does for consistency + var reader = PipeReader.Create(stream); + await foreach (var sseEvent in ParseSseEventsAsync(reader, CancellationToken.None)) + { + // Deserialize the JSON data to AskAiEvent + var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); + if (evt != null) + events.Add(evt); + } + + return events; + } + + /// + /// Parse Server-Sent Events (SSE) from a PipeReader following the W3C SSE specification. + /// This is the same logic as StreamTransformerBase.ParseSseEventsAsync for consistency. + /// + private static async IAsyncEnumerable ParseSseEventsAsync( + PipeReader reader, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + string? currentEvent = null; + var dataBuilder = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + var result = await reader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + + // Process all complete lines in the buffer + while (TryReadLine(ref buffer, out var line)) + { + // SSE comment line - skip + if (line.Length > 0 && line[0] == ':') + continue; + + // Event type line + if (line.StartsWith("event:", StringComparison.Ordinal)) + currentEvent = line[6..].Trim(); + // Data line + else if (line.StartsWith("data:", StringComparison.Ordinal)) + _ = dataBuilder.Append(line[5..].Trim()); + // Empty line - marks end of event + else if (string.IsNullOrEmpty(line)) + { + if (dataBuilder.Length <= 0) + continue; + yield return new SseEvent(currentEvent, dataBuilder.ToString()); + currentEvent = null; + _ = dataBuilder.Clear(); + } + } + + // Tell the PipeReader how much of the buffer we consumed + reader.AdvanceTo(buffer.Start, buffer.End); + + // Stop reading if there's no more data coming + if (!result.IsCompleted) + continue; + + // Yield any remaining event that hasn't been terminated with an empty line + if (dataBuilder.Length > 0) + yield return new SseEvent(currentEvent, dataBuilder.ToString()); + break; + } + } + + /// + /// Try to read a single line from the buffer - same logic as StreamTransformerBase + /// + private static bool TryReadLine(ref ReadOnlySequence buffer, out string line) + { + // Look for a line ending + var position = buffer.PositionOf((byte)'\n'); + + if (position == null) + { + line = string.Empty; + return false; + } + + // Extract the line (excluding the \n) + var lineSlice = buffer.Slice(0, position.Value); + line = Encoding.UTF8.GetString(lineSlice).TrimEnd('\r'); + + // Skip past the line + \n + buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); + return true; + } +} + public class AgentBuilderStreamTransformerTests { private readonly AgentBuilderStreamTransformer _transformer; @@ -55,7 +169,7 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert // Note: Due to async streaming, the final event might not be written before the input stream closes @@ -117,7 +231,7 @@ public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert - Should have at least 1 event (round_complete might not be written in time) events.Should().HaveCountGreaterOrEqualTo(1); @@ -140,7 +254,7 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert - This test has malformed SSE data (missing proper blank line terminator) @@ -149,34 +263,6 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() events.Should().HaveCountGreaterOrEqualTo(0); } - private static async Task> ParseEventsFromStream(Stream stream) - { - var events = new List(); - - // Copy to memory stream to ensure all data is available - var ms = new MemoryStream(); - await stream.CopyToAsync(ms); - ms.Position = 0; - - using var reader = new StreamReader(ms, Encoding.UTF8); - - while (!reader.EndOfStream) - { - var line = await reader.ReadLineAsync(); - if (line == null) - break; - - if (line.StartsWith("data: ", StringComparison.Ordinal)) - { - var json = line.Substring(6); - var evt = JsonSerializer.Deserialize(json, AskAiEventJsonContext.Default.AskAiEvent); - if (evt != null) - events.Add(evt); - } - } - - return events; - } } public class LlmGatewayStreamTransformerTests @@ -217,7 +303,7 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert events.Should().HaveCount(7); @@ -286,7 +372,7 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert - Should only have 2 events events.Should().HaveCount(2); @@ -311,7 +397,7 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert - Should only have the message chunk, model events skipped events.Should().HaveCount(2); @@ -319,34 +405,6 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() events[1].Should().BeOfType(); } - private static async Task> ParseEventsFromStream(Stream stream) - { - var events = new List(); - - // Copy to memory stream to ensure all data is available - var ms = new MemoryStream(); - await stream.CopyToAsync(ms); - ms.Position = 0; - - using var reader = new StreamReader(ms, Encoding.UTF8); - - while (!reader.EndOfStream) - { - var line = await reader.ReadLineAsync(); - if (line == null) - break; - - if (line.StartsWith("data: ", StringComparison.Ordinal)) - { - var json = line.Substring(6); - var evt = JsonSerializer.Deserialize(json, AskAiEventJsonContext.Default.AskAiEvent); - if (evt != null) - events.Add(evt); - } - } - - return events; - } } /// @@ -387,7 +445,7 @@ public static IEnumerable StreamTransformerTestCases() [Theory] [MemberData(nameof(StreamTransformerTestCases))] - public async Task TransformAsync_WhenConversationIdIsNull_EmitsConversationStartEvent( + public async Task TransformAsyncWhenConversationIdIsNullEmitsConversationStartEvent( string transformerName, IStreamTransformer transformer, string sseData) @@ -397,7 +455,7 @@ public async Task TransformAsync_WhenConversationIdIsNull_EmitsConversationStart // Act - Pass null conversationId to simulate new conversation var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert - Should have ConversationStart event events.Should().ContainSingle(e => e is AskAiEvent.ConversationStart, @@ -407,18 +465,18 @@ public async Task TransformAsync_WhenConversationIdIsNull_EmitsConversationStart conversationStart.ConversationId.Should().NotBeNullOrEmpty( $"{transformerName} should have a non-empty conversation ID in ConversationStart event"); - // For LlmGateway, when conversationId is null, we generate it with "elastic-docs-" prefix + // For LlmGateway, when conversationId is null, we generate a pure GUID // For AgentBuilder, the conversation ID comes from the SSE event and may have a different format if (transformerName == "LlmGatewayStreamTransformer") { - conversationStart.ConversationId.Should().StartWith("elastic-docs-", - $"{transformerName} should generate conversation ID with 'elastic-docs-' prefix when conversationId is null"); + Guid.TryParse(conversationStart.ConversationId, out _).Should().BeTrue( + $"{transformerName} should generate a valid GUID as conversation ID when conversationId is null"); } } [Theory] [MemberData(nameof(StreamTransformerTestCases))] - public async Task TransformAsync_ConversationStartEvent_HasValidTimestamp( + public async Task TransformAsyncConversationStartEventHasValidTimestamp( string transformerName, IStreamTransformer transformer, string sseData) @@ -428,7 +486,7 @@ public async Task TransformAsync_ConversationStartEvent_HasValidTimestamp( // Act var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert var conversationStart = events.OfType().FirstOrDefault(); @@ -441,7 +499,7 @@ public async Task TransformAsync_ConversationStartEvent_HasValidTimestamp( [Theory] [MemberData(nameof(StreamTransformerTestCases))] - public async Task TransformAsync_ConversationStartEvent_HasValidId( + public async Task TransformAsyncConversationStartEventHasValidId( string transformerName, IStreamTransformer transformer, string sseData) @@ -451,7 +509,7 @@ public async Task TransformAsync_ConversationStartEvent_HasValidId( // Act var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); // Assert var conversationStart = events.OfType().FirstOrDefault(); @@ -462,32 +520,4 @@ public async Task TransformAsync_ConversationStartEvent_HasValidId( $"{transformerName} ConversationStart should have a non-empty event ID"); } - private static async Task> ParseEventsFromStream(Stream stream) - { - var events = new List(); - - // Copy to memory stream to ensure all data is available - var ms = new MemoryStream(); - await stream.CopyToAsync(ms); - ms.Position = 0; - - using var reader = new StreamReader(ms, Encoding.UTF8); - - while (!reader.EndOfStream) - { - var line = await reader.ReadLineAsync(); - if (line == null) - break; - - if (line.StartsWith("data: ", StringComparison.Ordinal)) - { - var json = line.Substring(6); - var evt = JsonSerializer.Deserialize(json, AskAiEventJsonContext.Default.AskAiEvent); - if (evt != null) - events.Add(evt); - } - } - - return events; - } } From 19ea3d6bffb318a8d8ce221afd31a0c3b1018399 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 5 Nov 2025 23:34:43 +0100 Subject: [PATCH 5/5] Refactor and fix warnings --- .../AskAi/AgentBuilderStreamTransformer.cs | 3 - .../Adapters/AskAi/SseParser.cs | 101 ++++++++++ .../Adapters}/AskAi/StreamTransformerBase.cs | 93 +-------- .../Adapters/AskAi/StreamTransformerTests.cs | 188 ++++++------------ ...umentation.Api.Infrastructure.Tests.csproj | 4 - 5 files changed, 161 insertions(+), 228 deletions(-) create mode 100644 src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/SseParser.cs rename src/api/{Elastic.Documentation.Api.Core => Elastic.Documentation.Api.Infrastructure/Adapters}/AskAi/StreamTransformerBase.cs (78%) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs index 1b5e3d1a3..44b1c5c00 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderStreamTransformer.cs @@ -2,9 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Buffers; -using System.IO.Pipelines; -using System.Text; using System.Text.Json; using Elastic.Documentation.Api.Core.AskAi; using Microsoft.Extensions.Logging; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/SseParser.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/SseParser.cs new file mode 100644 index 000000000..573ce990a --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/SseParser.cs @@ -0,0 +1,101 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Buffers; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; + +/// +/// Represents a parsed Server-Sent Event (SSE) +/// +/// The event type from the "event:" field, or null if not specified +/// The accumulated data from all "data:" fields +public record SseEvent(string? EventType, string Data); + +/// +/// Parser for Server-Sent Events (SSE) following the W3C SSE specification. +/// +public static class SseParser +{ + /// + /// Parse Server-Sent Events (SSE) from a PipeReader following the W3C SSE specification. + /// This method handles the standard SSE format with event:, data:, and comment lines. + /// + public static async IAsyncEnumerable ParseAsync( + PipeReader reader, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + string? currentEvent = null; + var dataBuilder = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + var result = await reader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + + // Process all complete lines in the buffer + while (TryReadLine(ref buffer, out var line)) + { + // SSE comment line - skip + if (line.Length > 0 && line[0] == ':') + continue; + + // Event type line + if (line.StartsWith("event:", StringComparison.Ordinal)) + currentEvent = line[6..].Trim(); + // Data line + else if (line.StartsWith("data:", StringComparison.Ordinal)) + _ = dataBuilder.Append(line[5..].Trim()); + // Empty line - marks end of event + else if (string.IsNullOrEmpty(line)) + { + if (dataBuilder.Length <= 0) + continue; + yield return new SseEvent(currentEvent, dataBuilder.ToString()); + currentEvent = null; + _ = dataBuilder.Clear(); + } + } + + // Tell the PipeReader how much of the buffer we consumed + reader.AdvanceTo(buffer.Start, buffer.End); + + // Stop reading if there's no more data coming + if (!result.IsCompleted) + continue; + + // Yield any remaining event that hasn't been terminated with an empty line + if (dataBuilder.Length > 0) + yield return new SseEvent(currentEvent, dataBuilder.ToString()); + break; + } + } + + /// + /// Try to read a single line from the buffer + /// + private static bool TryReadLine(ref ReadOnlySequence buffer, out string line) + { + // Look for a line ending + var position = buffer.PositionOf((byte)'\n'); + + if (position == null) + { + line = string.Empty; + return false; + } + + // Extract the line (excluding the \n) + var lineSlice = buffer.Slice(0, position.Value); + line = Encoding.UTF8.GetString(lineSlice).TrimEnd('\r'); + + // Skip past the line + \n + buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); + return true; + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs similarity index 78% rename from src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs rename to src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs index eefb7160b..f0193019a 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/StreamTransformerBase.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs @@ -2,22 +2,15 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Buffers; using System.Diagnostics; using System.IO.Pipelines; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.Core.AskAi; using Microsoft.Extensions.Logging; -namespace Elastic.Documentation.Api.Core.AskAi; - -/// -/// Represents a parsed Server-Sent Event (SSE) -/// -/// The event type from the "event:" field, or null if not specified -/// The accumulated data from all "data:" fields -public record SseEvent(string? EventType, string Data); +namespace Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; /// /// Base class for stream transformers that handles common streaming logic @@ -137,7 +130,7 @@ protected virtual async Task ProcessStreamAsync(PipeReader reader, PipeWriter wr _ = activity?.SetParentId(parentActivity.Id); List outputMessageParts = []; - await foreach (var sseEvent in ParseSseEventsAsync(reader, cancellationToken)) + await foreach (var sseEvent in SseParser.ParseAsync(reader, cancellationToken)) { AskAiEvent? transformedEvent; try @@ -277,82 +270,4 @@ protected async Task WriteEventAsync(AskAiEvent? transformedEvent, PipeWriter wr throw; // Re-throw to be handled by caller } } - - /// - /// Parse Server-Sent Events (SSE) from a PipeReader following the W3C SSE specification. - /// This method handles the standard SSE format with event:, data:, and comment lines. - /// - private static async IAsyncEnumerable ParseSseEventsAsync( - PipeReader reader, - [EnumeratorCancellation] Cancel cancellationToken - ) - { - string? currentEvent = null; - var dataBuilder = new StringBuilder(); - - while (!cancellationToken.IsCancellationRequested) - { - var result = await reader.ReadAsync(cancellationToken); - var buffer = result.Buffer; - - // Process all complete lines in the buffer - while (TryReadLine(ref buffer, out var line)) - { - // SSE comment line - skip - if (line.Length > 0 && line[0] == ':') - continue; - - // Event type line - if (line.StartsWith("event:", StringComparison.Ordinal)) - currentEvent = line[6..].Trim(); - // Data line - else if (line.StartsWith("data:", StringComparison.Ordinal)) - _ = dataBuilder.Append(line[5..].Trim()); - // Empty line - marks end of event - else if (string.IsNullOrEmpty(line)) - { - if (dataBuilder.Length <= 0) - continue; - yield return new SseEvent(currentEvent, dataBuilder.ToString()); - currentEvent = null; - _ = dataBuilder.Clear(); - } - } - - // Tell the PipeReader how much of the buffer we consumed - reader.AdvanceTo(buffer.Start, buffer.End); - - // Stop reading if there's no more data coming - if (!result.IsCompleted) - continue; - - // Yield any remaining event that hasn't been terminated with an empty line - if (dataBuilder.Length > 0) - yield return new SseEvent(currentEvent, dataBuilder.ToString()); - break; - } - } - - /// - /// Try to read a single line from the buffer - /// - private static bool TryReadLine(ref ReadOnlySequence buffer, out string line) - { - // Look for a line ending - var position = buffer.PositionOf((byte)'\n'); - - if (position == null) - { - line = string.Empty; - return false; - } - - // Extract the line (excluding the \n) - var lineSlice = buffer.Slice(0, position.Value); - line = Encoding.UTF8.GetString(lineSlice).TrimEnd('\r'); - - // Skip past the line + \n - buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); - return true; - } } diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs index 4a85c2cd2..384ffaaa2 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/AskAi/StreamTransformerTests.cs @@ -2,128 +2,37 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Buffers; using System.IO.Pipelines; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Elastic.Documentation.Api.Infrastructure.Tests.Adapters.AskAi; /// -/// Shared test helpers for stream transformer tests. -/// Reuses the same SSE parsing approach as StreamTransformerBase for consistency. +/// Test helper to parse AskAiEvents from transformer output streams. +/// Uses the production SseParser to read SSE format. /// -public static class StreamTransformerTestHelpers +internal static class StreamTransformerTestHelpers { /// - /// Represents a parsed Server-Sent Event (SSE) - matches StreamTransformerBase.SseEvent + /// Parse AskAiEvents from a transformer output stream for test assertions. /// - private sealed record SseEvent(string? EventType, string Data); - - /// - /// Parses SSE events from a stream and deserializes them into AskAiEvent objects. - /// Uses the same SSE parsing logic as StreamTransformerBase for consistency. - /// The stream contains Server-Sent Events (SSE) format with lines like "data: {...json...}". - /// - public static async Task> ParseEventsFromStream(Stream stream) + public static async Task> ParseAskAiEventsAsync(Stream stream) { var events = new List(); - - // Use PipeReader like the production code does for consistency var reader = PipeReader.Create(stream); - await foreach (var sseEvent in ParseSseEventsAsync(reader, CancellationToken.None)) + await foreach (var sseEvent in SseParser.ParseAsync(reader, CancellationToken.None)) { - // Deserialize the JSON data to AskAiEvent var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); if (evt != null) events.Add(evt); } - return events; } - - /// - /// Parse Server-Sent Events (SSE) from a PipeReader following the W3C SSE specification. - /// This is the same logic as StreamTransformerBase.ParseSseEventsAsync for consistency. - /// - private static async IAsyncEnumerable ParseSseEventsAsync( - PipeReader reader, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - string? currentEvent = null; - var dataBuilder = new StringBuilder(); - - while (!cancellationToken.IsCancellationRequested) - { - var result = await reader.ReadAsync(cancellationToken); - var buffer = result.Buffer; - - // Process all complete lines in the buffer - while (TryReadLine(ref buffer, out var line)) - { - // SSE comment line - skip - if (line.Length > 0 && line[0] == ':') - continue; - - // Event type line - if (line.StartsWith("event:", StringComparison.Ordinal)) - currentEvent = line[6..].Trim(); - // Data line - else if (line.StartsWith("data:", StringComparison.Ordinal)) - _ = dataBuilder.Append(line[5..].Trim()); - // Empty line - marks end of event - else if (string.IsNullOrEmpty(line)) - { - if (dataBuilder.Length <= 0) - continue; - yield return new SseEvent(currentEvent, dataBuilder.ToString()); - currentEvent = null; - _ = dataBuilder.Clear(); - } - } - - // Tell the PipeReader how much of the buffer we consumed - reader.AdvanceTo(buffer.Start, buffer.End); - - // Stop reading if there's no more data coming - if (!result.IsCompleted) - continue; - - // Yield any remaining event that hasn't been terminated with an empty line - if (dataBuilder.Length > 0) - yield return new SseEvent(currentEvent, dataBuilder.ToString()); - break; - } - } - - /// - /// Try to read a single line from the buffer - same logic as StreamTransformerBase - /// - private static bool TryReadLine(ref ReadOnlySequence buffer, out string line) - { - // Look for a line ending - var position = buffer.PositionOf((byte)'\n'); - - if (position == null) - { - line = string.Empty; - return false; - } - - // Extract the line (excluding the \n) - var lineSlice = buffer.Slice(0, position.Value); - line = Encoding.UTF8.GetString(lineSlice).TrimEnd('\r'); - - // Skip past the line + \n - buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); - return true; - } } public class AgentBuilderStreamTransformerTests @@ -169,7 +78,7 @@ public async Task TransformAsyncWithRealAgentBuilderPayloadParsesAllEventTypes() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert // Note: Due to async streaming, the final event might not be written before the input stream closes @@ -231,7 +140,7 @@ public async Task TransformAsyncWithKeepAliveCommentsSkipsThem() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - Should have at least 1 event (round_complete might not be written in time) events.Should().HaveCountGreaterOrEqualTo(1); @@ -254,7 +163,7 @@ public async Task TransformAsyncWithMultilineDataFieldsAccumulatesCorrectly() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - This test has malformed SSE data (missing proper blank line terminator) @@ -303,7 +212,7 @@ public async Task TransformAsyncWithRealLlmGatewayPayloadParsesAllEventTypes() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert events.Should().HaveCount(7); @@ -372,7 +281,7 @@ public async Task TransformAsyncWithEmptyDataLinesSkipsThem() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - Should only have 2 events events.Should().HaveCount(2); @@ -397,7 +306,7 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() // Act var outputStream = await _transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - Should only have the message chunk, model events skipped events.Should().HaveCount(2); @@ -412,36 +321,36 @@ public async Task TransformAsyncSkipsModelLifecycleEvents() /// These tests ensure consistency across different transformer implementations. /// Adding a new transformer? Just add it to StreamTransformerTestCases() and these tests will automatically run against it. /// +#pragma warning disable xUnit1045 // IStreamTransformer is not serializable but tests work fine public class StreamTransformerCommonBehaviorTests { - public static IEnumerable StreamTransformerTestCases() - { - yield return new object[] - { - "AgentBuilderStreamTransformer", - new AgentBuilderStreamTransformer(NullLogger.Instance), - // Agent Builder SSE format for conversation_id_set - """ - event: conversation_id_set - data: {"data":{"conversation_id":"360222c5-76aa-405a-8316-703e1061b621"}} - - event: message_chunk - data: {"data":{"text_chunk":"test"}} - - """ - }; - yield return new object[] + public static TheoryData StreamTransformerTestCases() => + new() { - "LlmGatewayStreamTransformer", - new LlmGatewayStreamTransformer(NullLogger.Instance), - // LLM Gateway SSE format - minimal events - """ - event: agent_stream_output - data: [null, {"type":"ai_message_chunk","id":"1","timestamp":1234567890,"data":{"content":"test"}}] - - """ + { + "AgentBuilderStreamTransformer", + new AgentBuilderStreamTransformer(NullLogger.Instance), + // Agent Builder SSE format for conversation_id_set + """ + event: conversation_id_set + data: {"data":{"conversation_id":"360222c5-76aa-405a-8316-703e1061b621"}} + + event: message_chunk + data: {"data":{"text_chunk":"test"}} + + """ + }, + { + "LlmGatewayStreamTransformer", + new LlmGatewayStreamTransformer(NullLogger.Instance), + // LLM Gateway SSE format - minimal events + """ + event: agent_stream_output + data: [null, {"type":"ai_message_chunk","id":"1","timestamp":1234567890,"data":{"content":"test"}}] + + """ + } }; - } [Theory] [MemberData(nameof(StreamTransformerTestCases))] @@ -455,7 +364,7 @@ public async Task TransformAsyncWhenConversationIdIsNullEmitsConversationStartEv // Act - Pass null conversationId to simulate new conversation var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = await StreamTransformerTestHelpers.ParseAskAiEventsAsync(outputStream); // Assert - Should have ConversationStart event events.Should().ContainSingle(e => e is AskAiEvent.ConversationStart, @@ -486,7 +395,14 @@ public async Task TransformAsyncConversationStartEventHasValidTimestamp( // Act var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = new List(); + var reader = PipeReader.Create(outputStream); + await foreach (var sseEvent in SseParser.ParseAsync(reader, CancellationToken.None)) + { + var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); + if (evt != null) + events.Add(evt); + } // Assert var conversationStart = events.OfType().FirstOrDefault(); @@ -509,7 +425,14 @@ public async Task TransformAsyncConversationStartEventHasValidId( // Act var outputStream = await transformer.TransformAsync(inputStream, null, null, CancellationToken.None); - var events = await StreamTransformerTestHelpers.ParseEventsFromStream(outputStream); + var events = new List(); + var reader = PipeReader.Create(outputStream); + await foreach (var sseEvent in SseParser.ParseAsync(reader, CancellationToken.None)) + { + var evt = JsonSerializer.Deserialize(sseEvent.Data, AskAiEventJsonContext.Default.AskAiEvent); + if (evt != null) + events.Add(evt); + } // Assert var conversationStart = events.OfType().FirstOrDefault(); @@ -521,3 +444,4 @@ public async Task TransformAsyncConversationStartEventHasValidId( } } +#pragma warning restore xUnit1045 diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj index 159c06712..7743c944a 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj @@ -4,10 +4,6 @@ net9.0 - - - -