From 7956cdc78e0452354acc2467539d74b419443174 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Fri, 9 Oct 2026 12:59:52 +0200 Subject: [PATCH 1/2] Port Abstractions behavior and structured output Align session state concurrency, history, and compaction behavior with Abstractions; add behavioral tests for agents, sessions, context providers, responses, and messages. Support non-object typed JSON output across providers and reconcile .NET API and test mappings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- _catalog/dotnet-go-sdk-symbol-mapping.json | 651 +++++++-------- agent/agent.go | 2 +- agent/agent_test.go | 150 ++++ agent/compaction/compaction_test.go | 67 ++ agent/compaction/historyprovider.go | 7 +- agent/compaction/historyprovider_test.go | 101 +++ agent/compaction/provider.go | 5 + agent/context_test.go | 269 +++++- agent/format/jsonformat/encoding_test.go | 219 +++++ agent/format/jsonformat/structuredoutput.go | 120 +++ agent/harness/todo/todo.go | 6 +- agent/harness/todo/todo_test.go | 51 ++ agent/history.go | 59 +- agent/history_test.go | 592 ++++++++++++- agent/options.go | 6 +- agent/response_test.go | 319 +++++++ agent/session.go | 99 ++- agent/session_test.go | 781 +++++++++++++++++- agent/structuredoutput_test.go | 46 ++ agent/value.go | 43 +- .../agents/step18_compaction_pipeline/main.go | 10 + message/message_test.go | 54 ++ provider/aguiprovider/agui.go | 9 + provider/aguiprovider/agui_test.go | 62 ++ provider/anthropicprovider/agent.go | 8 +- provider/anthropicprovider/agent_test.go | 80 ++ provider/geminiprovider/agent.go | 8 +- provider/geminiprovider/agent_test.go | 69 ++ provider/openaiprovider/chat.go | 8 +- provider/openaiprovider/chat_test.go | 290 +++++++ provider/openaiprovider/responses.go | 8 +- provider/openaiprovider/strict_schema_test.go | 14 + 32 files changed, 3797 insertions(+), 416 deletions(-) create mode 100644 agent/format/jsonformat/structuredoutput.go diff --git a/_catalog/dotnet-go-sdk-symbol-mapping.json b/_catalog/dotnet-go-sdk-symbol-mapping.json index b39940fd..6dc9e09d 100644 --- a/_catalog/dotnet-go-sdk-symbol-mapping.json +++ b/_catalog/dotnet-go-sdk-symbol-mapping.json @@ -772,7 +772,7 @@ "Microsoft.Agents.AI.Abstractions.UnitTests.AIAgentMetadataTests": { "Constructor_WithNoArguments_SetsProviderNameToNull": null, "Constructor_WithNullProviderName_SetsProviderNameToNull": null, - "Constructor_WithProviderName_SetsProperty": null + "Constructor_WithProviderName_SetsProperty": "agent.TestAgentMetadata_Constructor_WithProviderName_SetsProperty" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AIAgentStructuredOutputTests": { "AgentResponseGeneric_Result_DeserializesObjectWithoutUnwrapping": null, @@ -782,10 +782,10 @@ "AgentResponseGeneric_Result_UnwrapsArrayFromDataProperty": null, "AgentResponseGeneric_Result_UnwrapsEnumFromDataProperty": null, "AgentResponseGeneric_Result_UnwrapsPrimitiveFromDataProperty": null, - "RunAsyncGeneric_ArrayEndToEnd_WrapsAndDeserializesCorrectlyAsync": null, - "RunAsyncGeneric_EnumEndToEnd_WrapsAndDeserializesCorrectlyAsync": null, - "RunAsyncGeneric_ObjectEndToEnd_NoWrappingAndDeserializesCorrectlyAsync": null, - "RunAsyncGeneric_PrimitiveEndToEnd_WrapsAndDeserializesCorrectlyAsync": null, + "RunAsyncGeneric_ArrayEndToEnd_WrapsAndDeserializesCorrectlyAsync": "provider/openaiprovider.TestStructuredOutputArrayEndToEnd", + "RunAsyncGeneric_EnumEndToEnd_WrapsAndDeserializesCorrectlyAsync": "provider/openaiprovider.TestStructuredOutputEnumEndToEnd", + "RunAsyncGeneric_ObjectEndToEnd_NoWrappingAndDeserializesCorrectlyAsync": "provider/openaiprovider.TestStructuredOutputObjectEndToEnd", + "RunAsyncGeneric_PrimitiveEndToEnd_WrapsAndDeserializesCorrectlyAsync": "provider/openaiprovider.TestStructuredOutputPrimitiveEndToEnd", "RunAsyncGeneric_WithArrayType_WrapsSchemaAsync": null, "RunAsyncGeneric_WithEnumType_WrapsSchemaAsync": null, "RunAsyncGeneric_WithObjectType_DoesNotWrapSchemaAsync": null, @@ -793,7 +793,7 @@ }, "Microsoft.Agents.AI.Abstractions.UnitTests.AIAgentTests": { "Description_ReturnsNullByDefault": null, - "Description_ReturnsValueFromDerivedClass": null, + "Description_ReturnsValueFromDerivedClass": "agent.TestAgent_Description_ReturnsValueFromConfig", "GetService_Generic_ReturnsCorrectType": null, "GetService_Generic_ReturnsNullForUnrelatedType": null, "GetService_RequestingAIAgentType_ReturnsAgent": null, @@ -801,14 +801,14 @@ "GetService_RequestingUnrelatedType_ReturnsNull": null, "GetService_WithNullServiceType_ThrowsArgumentNullException": null, "GetService_WithServiceKey_ReturnsNull": null, - "InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync": null, - "InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync": null, - "InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync": null, - "InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync": null, - "InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync": null, - "InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync": null, + "InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync": "agent.TestAgent_InvokeStreamingWithSingleMessageCallsRunWithSameMessage", + "InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync": "agent.TestAgent_InvokeStreamingWithStringMessageCallsRunWithOneMessage", + "InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync": "agent.TestAgent_InvokeStreamingWithoutMessageCallsRunWithEmptyMessages", + "InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync": "agent.TestAgent_InvokeWithSingleMessageCallsRunWithSameMessage", + "InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync": "agent.TestAgent_InvokeWithStringMessageCallsRunWithOneMessage", + "InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync": "agent.TestAgent_InvokeWithoutMessageCallsRunWithEmptyMessages", "Name_ReturnsNullByDefault": null, - "Name_ReturnsValueFromDerivedClass": null, + "Name_ReturnsValueFromDerivedClass": "agent.TestAgent_Name_ReturnsValueFromConfig", "RunAsync_Cancellation_RestoresPreviousCurrentRunContextAsync": null, "RunAsync_Exception_RestoresPreviousCurrentRunContextAsync": null, "RunAsync_IncompleteRun_RestoresCallerContextBeforeAndAfterAwaitAsync": null, @@ -816,8 +816,8 @@ "RunAsync_SetsCurrentRunContext_AccessibleFromRunCoreAsync": null, "RunStreamingAsync_EarlyDisposal_RestoresPreviousCurrentRunContextAsync": null, "RunStreamingAsync_SetsCurrentRunContext_AccessibleFromRunCoreStreamingAsync": null, - "ValidateAgentIDCanBeProvidedByDerivedAgentClass": null, - "ValidateAgentIDIsIdempotent": null + "ValidateAgentIDCanBeProvidedByDerivedAgentClass": "agent.TestAgent_ID_UsesConfiguredID", + "ValidateAgentIDIsIdempotent": "agent.TestAgent_ID_IsStable" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AIContextProviderTests": { "GetService_Generic_ReturnsCorrectType": null, @@ -839,12 +839,12 @@ "InvokedContext_Session_ReturnsConstructorValue": null, "InvokedContext_SuccessConstructor_ThrowsForNullResponseMessages": null, "InvokedCoreAsync_CallsStoreAIContextWithFilteredMessagesAsync": "agent.TestContextProvider_Invoked_CallsStoreAndIncludesExternalRequestMessagesByDefault", - "InvokedCoreAsync_DefaultFilterExcludesNonExternalMessagesAsync": null, - "InvokedCoreAsync_DefaultResponseFilterPassesAllResponseMessagesAsync": null, - "InvokedCoreAsync_RequestAndResponseFiltersOperateIndependentlyAsync": null, - "InvokedCoreAsync_SkipsStorageWhenInvokeExceptionIsNotNullAsync": null, - "InvokedCoreAsync_UsesCustomResponseFilterAsync": null, - "InvokedCoreAsync_UsesCustomStoreInputFilterAsync": null, + "InvokedCoreAsync_DefaultFilterExcludesNonExternalMessagesAsync": "agent.TestContextProvider_Invoked_DefaultFilterExcludesNonExternalMessages", + "InvokedCoreAsync_DefaultResponseFilterPassesAllResponseMessagesAsync": "agent.TestContextProvider_Invoked_DefaultResponseFilterPassesAllMessages", + "InvokedCoreAsync_RequestAndResponseFiltersOperateIndependentlyAsync": "agent.TestContextProvider_Invoked_RequestAndResponseFiltersOperateIndependently", + "InvokedCoreAsync_SkipsStorageWhenInvokeExceptionIsNotNullAsync": "agent.TestContextProvider_Invoked_SkipsStorageWhenRunFails", + "InvokedCoreAsync_UsesCustomResponseFilterAsync": "agent.TestContextProvider_Invoked_UsesCustomResponseFilter", + "InvokedCoreAsync_UsesCustomStoreInputFilterAsync": "agent.TestContextProvider_Invoked_UsesCustomStoreInputFilters", "InvokingAsync_NullContext_ThrowsArgumentNullExceptionAsync": null, "InvokingContext_AIContext_ConstructorValueRoundtrips": null, "InvokingContext_Agent_ReturnsConstructorValue": null, @@ -857,10 +857,10 @@ "InvokingCoreAsync_FiltersInputToExternalOnlyByDefaultAsync": "agent.TestContextProvider_Invoking_FiltersInputMessagesBeforeProvide", "InvokingCoreAsync_MergesInstructionsAsync": null, "InvokingCoreAsync_MergesToolsAsync": "agent.TestContextProvider_InvokingContext_ReturnsProvidedFields", - "InvokingCoreAsync_MergesWithOriginalUnfilteredMessagesAsync": null, + "InvokingCoreAsync_MergesWithOriginalUnfilteredMessagesAsync": "agent.TestContextProvider_Invoking_MergesWithOriginalUnfilteredMessages", "InvokingCoreAsync_ReturnsEmptyContextByDefaultAsync": "agent.TestContextProvider_Invoking_WithoutProvide_ReturnsNoAdditions", "InvokingCoreAsync_StampsProvidedMessagesWithAIContextProviderSourceAsync": "agent.TestContextProvider_Invoking_UsesCustomSourceID", - "InvokingCoreAsync_UsesCustomProvideInputFilterAsync": null + "InvokingCoreAsync_UsesCustomProvideInputFilterAsync": "agent.TestContextProvider_Invoking_UsesCustomProvideInputMessageFilter" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AIContextTests": { "SetAIFunctionsRoundtrips": null, @@ -989,7 +989,7 @@ "ToAgentResponseUpdatesWithAdditionalPropertiesOnlyProducesSingleUpdate": "agent.TestResponse_ToUpdates_WithAdditionalPropertiesOnlyProducesSingleUpdate", "ToAgentResponseUpdatesWithNoMessagesProducesEmptyArray": "agent.TestResponse_ToUpdates_WithNoMessagesProducesEmptySlice", "ToAgentResponseUpdatesWithUsageOnlyProducesSingleUpdate": "agent.TestResponse_ToUpdates_WithUsageOnlyProducesSingleUpdate", - "ToStringOutputsText": null + "ToStringOutputsText": "agent.TestResponse_ToStringOutputsText" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AgentResponseUpdateExtensionsTests": { "AsChatResponseUpdate_WithNullArgument_ThrowsArgumentNullException": null, @@ -1002,20 +1002,20 @@ "AsChatResponse_WithNullArgument_ThrowsArgumentNullException": null, "AsChatResponse_WithRawRepresentationAsChatResponse_ReturnsSameInstance": null, "AsChatResponse_WithoutRawRepresentation_CreatesNewChatResponse": null, - "ToAgentResponseCoalescesTextContentAndTextReasoningContentSeparatelyAsync": null, - "ToAgentResponseCoalescesVariousSequenceAndGapLengthsAsync": null, + "ToAgentResponseCoalescesTextContentAndTextReasoningContentSeparatelyAsync": "agent.TestResponse_Collect_CoalescesTextAndReasoningSeparately", + "ToAgentResponseCoalescesVariousSequenceAndGapLengthsAsync": "agent.TestResponse_Collect_CoalescesTextAcrossDataGaps", "ToAgentResponseSuccessfullyCreatesResponseAsync": null, "ToAgentResponseUsesContentExtractedFromContentsAsync": null, "ToAgentResponseWithInvalidArgsThrows": null, - "ToAgentResponse_AlternativeTimestampsAsync": null, - "ToAgentResponse_TimestampFoldingAsync": null + "ToAgentResponse_AlternativeTimestampsAsync": "agent.TestResponse_Collect_AlternativeTimestamps", + "ToAgentResponse_TimestampFoldingAsync": "agent.TestResponse_Collect_TimestampFolding" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AgentResponseUpdateTests": { "ConstructorPropsDefaulted": null, "ConstructorWithChatResponseUpdateRoundtrips": null, - "JsonSerializationRoundtrips": null, + "JsonSerializationRoundtrips": "agent.TestResponseUpdate_JsonSerializationRoundtrips", "PropertiesRoundtrip": null, - "TextGetUsesAllTextContent": null + "TextGetUsesAllTextContent": "agent.TestResponseUpdate_TextGetUsesAllTextContent" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AgentRunContextTests": { "Agent_ReturnsValueFromConstructor": null, @@ -1035,23 +1035,23 @@ "JsonSerializationRoundtrips": null }, "Microsoft.Agents.AI.Abstractions.UnitTests.AgentSessionExtensionsTests": { - "SetInMemoryChatHistory_WhenExistingState_ReplacesMessages": null, - "SetInMemoryChatHistory_WhenNoExistingState_CreatesNewState": null, - "SetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey": null, - "SetInMemoryChatHistory_WithEmptyList_SetsEmptyList": null, - "SetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException": null, - "TryGetInMemoryChatHistory_WhenStateDoesNotExist_ReturnsFalse": null, - "TryGetInMemoryChatHistory_WhenStateExistsWithNullMessages_ReturnsFalse": null, - "TryGetInMemoryChatHistory_WhenStateExists_ReturnsTrueAndMessages": null, - "TryGetInMemoryChatHistory_WithCustomStateKey_DoesNotFindDefaultKey": null, - "TryGetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey": null, - "TryGetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException": null + "SetInMemoryChatHistory_WhenExistingState_ReplacesMessages": "agent.TestSessionHistory_Set_WhenExistingState_ReplacesMessages", + "SetInMemoryChatHistory_WhenNoExistingState_CreatesNewState": "agent.TestSessionHistory_Set_WhenNoExistingState_CreatesNewState", + "SetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey": "agent.TestSessionHistory_Set_WithCustomStateKey_UsesCustomKey", + "SetInMemoryChatHistory_WithEmptyList_SetsEmptyList": "agent.TestSessionHistory_Set_WithEmptyList_SetsEmptyList", + "SetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException": "agent.TestSessionHistory_Set_WithNullSession_ReturnsError", + "TryGetInMemoryChatHistory_WhenStateDoesNotExist_ReturnsFalse": "agent.TestSessionHistory_TryGet_WhenStateDoesNotExist_ReturnsFalse", + "TryGetInMemoryChatHistory_WhenStateExistsWithNullMessages_ReturnsFalse": "agent.TestSessionHistory_TryGet_WhenStateExistsWithNullMessages_ReturnsFalse", + "TryGetInMemoryChatHistory_WhenStateExists_ReturnsTrueAndMessages": "agent.TestSessionHistory_TryGet_WhenStateExists_ReturnsTrueAndMessages", + "TryGetInMemoryChatHistory_WithCustomStateKey_DoesNotFindDefaultKey": "agent.TestSessionHistory_TryGet_WithCustomStateKey_DoesNotFindDefaultKey", + "TryGetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey": "agent.TestSessionHistory_TryGet_WithCustomStateKey_UsesCustomKey", + "TryGetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException": "agent.TestSessionHistory_TryGet_WithNullSession_ReturnsError" }, "Microsoft.Agents.AI.Abstractions.UnitTests.AgentSessionStateBagTests": { - "ConcurrentReadsAndWrites_DoesNotThrowAsync": null, - "ConcurrentWritesAndSerialize_DoesNotThrowAsync": null, + "ConcurrentReadsAndWrites_DoesNotThrowAsync": "agent.TestSessionState_ConcurrentReadsAndWrites", + "ConcurrentWritesAndSerialize_DoesNotThrowAsync": "agent.TestSessionState_ConcurrentWritesAndSerialize", "Constructor_Default_CreatesEmptyStateBag": "agent.TestSessionState_Get_NonexistentKey_ReturnsNotFound", - "Deserialize_FromJsonDocument_ReturnsEmptyStateBag": null, + "Deserialize_FromJsonDocument_ReturnsEmptyStateBag": "agent.TestSessionStateBag_Deserialize_FromJsonDocument_ReturnsEmptyStateBag", "Deserialize_NullElement_ReturnsEmptyStateBag": null, "GetValue_CachesDeserializedValue": null, "GetValue_TypeMismatch_ExceptionMessageContainsBothTypeNames": null, @@ -1059,45 +1059,45 @@ "GetValue_WithDifferentTypeAfterDeserializedRoundtrip_ThrowsInvalidOperationException": null, "GetValue_WithDifferentTypeAfterSet_ThrowsInvalidOperationException": null, "GetValue_WithEmptyKey_ThrowsArgumentException": null, - "GetValue_WithExistingKey_ReturnsValue": null, - "GetValue_WithNonexistentKey_ReturnsNull": null, + "GetValue_WithExistingKey_ReturnsValue": "agent.TestSessionStateBag_GetValue_WithExistingKey_ReturnsValue", + "GetValue_WithNonexistentKey_ReturnsNull": "agent.TestSessionStateBag_GetValue_WithNonexistentKey_ReturnsNull", "GetValue_WithNullKey_ThrowsArgumentException": null, - "GetValue_WithNullValue_ReturnsNull": null, - "JsonSerializerDeserialize_NullJson_ReturnsNull": null, - "JsonSerializerRoundtrip_WithComplexObject_PreservesData": null, - "JsonSerializerRoundtrip_WithStringValue_PreservesData": null, + "GetValue_WithNullValue_ReturnsNull": "agent.TestSessionStateBag_GetValue_WithNullValue_ReturnsNull", + "JsonSerializerDeserialize_NullJson_ReturnsNull": "agent.TestSessionStateBag_JsonSerializerDeserialize_NullJson_ReturnsNull", + "JsonSerializerRoundtrip_WithComplexObject_PreservesData": "agent.TestSessionStateBag_JsonSerializerRoundtrip_WithComplexObject_PreservesData", + "JsonSerializerRoundtrip_WithStringValue_PreservesData": "agent.TestSessionStateBag_JsonSerializerRoundtrip_WithStringValue_PreservesData", "JsonSerializerSerialize_EmptyStateBag_ReturnsEmptyObject": null, "JsonSerializerSerialize_WithStringValue_ProducesSameOutputAsSerializeMethod": null, "JsonSerializerSerialize_WithUnknownType_Throws": null, - "SerializeDeserialize_WithComplexObject_Roundtrips": null, - "SerializeDeserialize_WithNullValue_SerializesAsNull": null, - "SerializeDeserialize_WithStringValue_Roundtrips": null, - "Serialize_EmptyStateBag_ReturnsEmptyObject": null, - "Serialize_WithComplexObject_ReturnsJsonWithProperties": null, - "Serialize_WithStringValue_ReturnsJsonWithValue": null, - "SetValue_MultipleConcurrentWrites_DoesNotThrowAsync": null, - "SetValue_OverwriteNullWithValue_ReturnsValue": null, - "SetValue_OverwriteWithNull_ReturnsNull": null, + "SerializeDeserialize_WithComplexObject_Roundtrips": "agent.TestSessionStateBag_SerializeDeserialize_WithComplexObject_Roundtrips", + "SerializeDeserialize_WithNullValue_SerializesAsNull": "agent.TestSessionStateBag_SerializeDeserialize_WithNullValue_SerializesAsNull", + "SerializeDeserialize_WithStringValue_Roundtrips": "agent.TestSessionStateBag_SerializeDeserialize_WithStringValue_Roundtrips", + "Serialize_EmptyStateBag_ReturnsEmptyObject": "agent.TestSessionStateBag_Serialize_EmptyStateBag_ReturnsEmptyObject", + "Serialize_WithComplexObject_ReturnsJsonWithProperties": "agent.TestSessionStateBag_Serialize_WithComplexObject_ReturnsJsonWithProperties", + "Serialize_WithStringValue_ReturnsJsonWithValue": "agent.TestSessionStateBag_Serialize_WithStringValue_ReturnsJsonWithValue", + "SetValue_MultipleConcurrentWrites_DoesNotThrowAsync": "agent.TestSession_ConcurrentFirstWritesPreserveState", + "SetValue_OverwriteNullWithValue_ReturnsValue": "agent.TestSessionStateBag_SetValue_OverwriteNullWithValue_ReturnsValue", + "SetValue_OverwriteWithNull_ReturnsNull": "agent.TestSessionStateBag_SetValue_OverwriteWithNull_ReturnsNull", "SetValue_OverwritesExistingValue": "agent.TestSessionState_Set_OverwritesExistingValue", - "SetValue_WithComplexObject_StoresValue": null, + "SetValue_WithComplexObject_StoresValue": "agent.TestSessionStateBag_SetValue_WithComplexObject_StoresValue", "SetValue_WithEmptyKey_ThrowsArgumentException": null, "SetValue_WithNullKey_ThrowsArgumentException": null, - "SetValue_WithNullValue_StoresNull": null, + "SetValue_WithNullValue_StoresNull": "agent.TestSessionStateBag_SetValue_WithNullValue_StoresNull", "SetValue_WithValidKeyAndValue_StoresValue": "agent.TestSessionState_Set_And_Get_Roundtrips", "SetValue_WithWhitespaceKey_ThrowsArgumentException": null, - "TryGetValue_ComplexTypeAfterSetString_ReturnsFalse": null, - "TryGetValue_WithComplexObject_ReturnsTrueAndValue": null, - "TryGetValue_WithDifferentTypeAfterDeserializedRead_ReturnsFalse": null, - "TryGetValue_WithDifferentTypeAfterSet_ReturnsFalse": "agent.TestSessionState_Get_TypeMismatchReturnsError", + "TryGetValue_ComplexTypeAfterSetString_ReturnsFalse": "agent.TestSessionStateBag_TryGetValue_ComplexTypeAfterSetString_ReturnsFalse", + "TryGetValue_WithComplexObject_ReturnsTrueAndValue": "agent.TestSessionStateBag_TryGetValue_WithComplexObject_ReturnsTrueAndValue", + "TryGetValue_WithDifferentTypeAfterDeserializedRead_ReturnsFalse": "agent.TestSessionStateBag_TryGetValue_WithDifferentTypeAfterDeserializedRead_ReturnsFalse", + "TryGetValue_WithDifferentTypeAfterSet_ReturnsFalse": "agent.TestSessionStateBag_TryGetValue_WithDifferentTypeAfterSet_ReturnsFalse", "TryGetValue_WithEmptyKey_ThrowsArgumentException": null, - "TryGetValue_WithExistingKey_ReturnsTrueAndValue": null, - "TryGetValue_WithNonexistentKey_ReturnsFalseAndNull": null, + "TryGetValue_WithExistingKey_ReturnsTrueAndValue": "agent.TestSessionStateBag_TryGetValue_WithExistingKey_ReturnsTrueAndValue", + "TryGetValue_WithNonexistentKey_ReturnsFalseAndNull": "agent.TestSessionStateBag_TryGetValue_WithNonexistentKey_ReturnsFalseAndNull", "TryGetValue_WithNullKey_ThrowsArgumentException": null, "TryGetValue_WithNullValue_ReturnsTrueAndNull": "agent.TestSessionState_Set_NilPointerValue_RoundtripsForSameType", - "TryRemoveValue_DoesNotAffectOtherKeys": null, + "TryRemoveValue_DoesNotAffectOtherKeys": "agent.TestSessionStateBag_TryRemoveValue_DoesNotAffectOtherKeys", "TryRemoveValue_ExistingKey_ReturnsTrueAndRemoves": "agent.TestSessionState_Delete_ExistingKey", "TryRemoveValue_NonexistentKey_ReturnsFalse": "agent.TestSessionState_Delete_NonexistentKey_ReturnsFalse", - "TryRemoveValue_ThenSetValue_Works": null, + "TryRemoveValue_ThenSetValue_Works": "agent.TestSessionStateBag_TryRemoveValue_ThenSetValue_Works", "TryRemoveValue_WithEmptyKey_ThrowsArgumentException": null, "TryRemoveValue_WithNullKey_ThrowsArgumentException": null, "TryRemoveValue_WithWhitespaceKey_ThrowsArgumentException": null @@ -1131,9 +1131,9 @@ "GetService_WithNullServiceType_ThrowsArgumentNullException": null, "GetService_WithServiceKey_ReturnsNull": null, "StateBag_Default_IsEmpty": null, - "StateBag_MultipleKeys_StoreAndRetrieveIndependently": "agent.TestSessionState_Set_DifferentValueTypes", - "StateBag_OverwriteValue_ReturnsUpdatedValue": null, - "StateBag_Values_Roundtrips": null + "StateBag_MultipleKeys_StoreAndRetrieveIndependently": "agent.TestAgentSession_StateBag_MultipleKeys_StoreAndRetrieveIndependently", + "StateBag_OverwriteValue_ReturnsUpdatedValue": "agent.TestAgentSession_StateBag_OverwriteValue_ReturnsUpdatedValue", + "StateBag_Values_Roundtrips": "agent.TestAgentSession_StateBag_Values_Roundtrips" }, "Microsoft.Agents.AI.Abstractions.UnitTests.ChatHistoryProviderTests": { "GetService_Generic_ReturnsCorrectType": null, @@ -1153,11 +1153,11 @@ "InvokedContext_Session_CanBeNull": null, "InvokedContext_Session_ReturnsConstructorValue": null, "InvokedContext_SuccessConstructor_ThrowsForNullResponseMessages": null, - "InvokedCoreAsync_CallsStoreChatHistoryWithFilteredMessagesAsync": null, - "InvokedCoreAsync_DefaultFilterExcludesChatHistorySourcedMessagesAsync": null, - "InvokedCoreAsync_PassesResponseMessagesToStoreAsync": null, - "InvokedCoreAsync_SkipsStorageWhenInvokeExceptionIsNotNullAsync": null, - "InvokedCoreAsync_UsesCustomStoreInputFilterAsync": null, + "InvokedCoreAsync_CallsStoreChatHistoryWithFilteredMessagesAsync": "agent.TestHistoryProvider_Invoked_StoresFilteredRequestAndResponse", + "InvokedCoreAsync_DefaultFilterExcludesChatHistorySourcedMessagesAsync": "agent.TestHistoryProvider_Invoked_DefaultFilterExcludesHistoryMessages", + "InvokedCoreAsync_PassesResponseMessagesToStoreAsync": "agent.TestHistoryProvider_Invoked_PassesResponseMessagesToStore", + "InvokedCoreAsync_SkipsStorageWhenInvokeExceptionIsNotNullAsync": "agent.TestHistoryProvider_Invoked_SkipsStorageOnRunError", + "InvokedCoreAsync_UsesCustomStoreInputFilterAsync": "agent.TestHistoryProvider_Invoked_UsesCustomStoreFilters", "InvokingAsync_NullContext_ThrowsArgumentNullExceptionAsync": null, "InvokingContext_Agent_ReturnsConstructorValue": null, "InvokingContext_Constructor_ThrowsForNullAgent": null, @@ -1166,22 +1166,22 @@ "InvokingContext_RequestMessages_SetterThrowsForNull": null, "InvokingContext_Session_CanBeNull": null, "InvokingContext_Session_ReturnsConstructorValue": null, - "InvokingCoreAsync_AppliesProvideOutputFilterWhenProvidedAsync": null, - "InvokingCoreAsync_CallsProvideChatHistoryAndReturnsMessagesAsync": null, - "InvokingCoreAsync_HistoryAppearsBeforeRequestMessagesAsync": null, - "InvokingCoreAsync_NoFilterAppliedWhenProvideOutputFilterIsNullAsync": null, - "InvokingCoreAsync_ReturnsEmptyHistoryByDefaultAsync": null, - "InvokingCoreAsync_StampsHistoryMessagesWithChatHistorySourceAsync": null + "InvokingCoreAsync_AppliesProvideOutputFilterWhenProvidedAsync": "agent.TestHistoryProvider_Invoking_AppliesOutputFilter", + "InvokingCoreAsync_CallsProvideChatHistoryAndReturnsMessagesAsync": "agent.TestHistoryProvider_Invoking_ProvidesHistoryAndRequest", + "InvokingCoreAsync_HistoryAppearsBeforeRequestMessagesAsync": "agent.TestHistoryProvider_Invoking_HistoryPrecedesRequest", + "InvokingCoreAsync_NoFilterAppliedWhenProvideOutputFilterIsNullAsync": "agent.TestHistoryProvider_Invoking_WithoutOutputFilterKeepsAllHistory", + "InvokingCoreAsync_ReturnsEmptyHistoryByDefaultAsync": "agent.TestHistoryProvider_Invoking_WithoutProvideReturnsRequest", + "InvokingCoreAsync_StampsHistoryMessagesWithChatHistorySourceAsync": "agent.TestHistoryProvider_Invoking_StampsHistorySource" }, "Microsoft.Agents.AI.Abstractions.UnitTests.ChatMessageExtensionsTests": { "AsAgentRequestMessageSourcedMessage_DoesNotModifyOriginalMessage": null, "AsAgentRequestMessageSourcedMessage_PreservesMessageContent": "message.TestMessage_WithSource_ClonesWhenSourceChanges", - "AsAgentRequestMessageSourcedMessage_WithDefaultNullSourceId_ReturnsClonesMessageWithNullSourceId": null, - "AsAgentRequestMessageSourcedMessage_WithDifferentSourceId_ReturnsClonesMessageWithNewAttribution": null, - "AsAgentRequestMessageSourcedMessage_WithDifferentSourceType_ReturnsClonesMessageWithNewAttribution": null, + "AsAgentRequestMessageSourcedMessage_WithDefaultNullSourceId_ReturnsClonesMessageWithNullSourceId": "message.TestMessage_WithSource_ClonesWithSourceTypeAndNoID", + "AsAgentRequestMessageSourcedMessage_WithDifferentSourceId_ReturnsClonesMessageWithNewAttribution": "message.TestMessage_WithSource_ClonesWhenSourceIDChanges", + "AsAgentRequestMessageSourcedMessage_WithDifferentSourceType_ReturnsClonesMessageWithNewAttribution": "message.TestMessage_WithSource_ClonesWhenSourceTypeChanges", "AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndNullSourceId_ReturnsSameInstance": null, "AsAgentRequestMessageSourcedMessage_WithMatchingSourceTypeAndSourceId_ReturnsSameInstance": "message.TestMessage_WithSource_ReturnsOriginalWhenUnchanged", - "AsAgentRequestMessageSourcedMessage_WithNoAdditionalProperties_ReturnsClonesMessageWithAttribution": null, + "AsAgentRequestMessageSourcedMessage_WithNoAdditionalProperties_ReturnsClonesMessageWithAttribution": "message.TestMessage_WithSource_ClonesWhenFirstAssigned", "AsAgentRequestMessageSourcedMessage_WithNullAdditionalProperties_ReturnsClonesMessageWithAttribution": null, "AsAgentRequestMessageSourcedMessage_WithWrongAttributionType_ReturnsClonesMessageWithNewAttribution": null, "GetAgentRequestMessageSourceId_WithAttribution_ReturnsSourceId": null, @@ -1238,26 +1238,26 @@ "PublicSurface_IsHiddenAndExperimental": null }, "Microsoft.Agents.AI.Abstractions.UnitTests.InMemoryChatHistoryProviderTests": { - "AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerAsync": null, - "AddMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync": null, + "AddMessagesAsync_WithReducer_AfterMessageAdded_InvokesReducerAsync": "agent/compaction.TestHistoryProvider_ReducesAfterMessageAdded", + "AddMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync": "agent/compaction.TestHistoryProvider_DoesNotReduceOnAddWhenTriggeredBeforeRetrieval", "Constructor_Arguments_SetOnPropertiesCorrectly": null, "Constructor_DefaultsToBeforeMessageRetrieval_ForNotProvidedTriggerEvent": null, - "GetMessagesAsync_WithReducer_BeforeMessagesRetrieval_InvokesReducerAsync": null, - "GetMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync": null, + "GetMessagesAsync_WithReducer_BeforeMessagesRetrieval_InvokesReducerAsync": "agent/compaction.TestHistoryProvider_ReducesBeforeMessagesRetrieval", + "GetMessagesAsync_WithReducer_ButWrongTrigger_DoesNotInvokeReducerAsync": "agent/compaction.TestHistoryProvider_DoesNotReduceOnRetrievalWhenTriggeredAfterAdd", "GetMessages_ReturnsEmptyList_WhenNullSession": null, - "InvokedAsyncAddsMessagesAsync": null, - "InvokedAsyncWithEmptyDoesNotFailAsync": null, + "InvokedAsyncAddsMessagesAsync": "agent.TestNewInMemoryHistoryProvider_AddsRequestsAndResponses", + "InvokedAsyncWithEmptyDoesNotFailAsync": "agent.TestNewInMemoryHistoryProvider_InvokedWithEmptyMessages", "InvokedAsyncWithEmptyMessagesDoesNotChangeProviderAsync": null, - "InvokedAsync_CustomFilter_OverridesDefaultAsync": null, + "InvokedAsync_CustomFilter_OverridesDefaultAsync": "agent.TestNewInMemoryHistoryProvider_CustomRequestFilterOverridesDefault", "InvokedAsync_DefaultFilter_ExcludesChatHistoryMessagesAsync": "agent.TestNewInMemoryHistoryProvider_DefaultStoreInputRequestMessageFilter_ExcludesHistoryMessages", - "InvokedAsync_WithException_DoesNotAddMessagesAsync": null, + "InvokedAsync_WithException_DoesNotAddMessagesAsync": "agent.TestNewInMemoryHistoryProvider_InvokedErrorDoesNotAddMessages", "InvokedAsync_WithNullContext_ThrowsArgumentNullExceptionAsync": null, - "InvokingAsyncReturnsAllMessagesAsync": null, - "InvokingAsync_OutputFilter_FiltersOutputMessagesAsync": null, + "InvokingAsyncReturnsAllMessagesAsync": "agent.TestNewInMemoryHistoryProvider_InvokingReturnsAllMessages", + "InvokingAsync_OutputFilter_FiltersOutputMessagesAsync": "agent.TestNewInMemoryHistoryProvider_OutputFilterSelectsUserMessages", "InvokingAsync_WithNullContext_ThrowsArgumentNullExceptionAsync": null, "SetMessages_ThrowsForNullMessages": null, - "SetMessages_UpdatesState": null, - "StateInitializer_IsInvoked_WhenSessionHasNoState": null, + "SetMessages_UpdatesState": "agent.TestNewInMemoryHistoryProvider_SetMessagesUpdatesState", + "StateInitializer_IsInvoked_WhenSessionHasNoState": "agent.TestNewInMemoryHistoryProvider_StateInitializer_SeedsMissingState", "StateKeys_ReturnsCustomKey_WhenSetViaOptions": null, "StateKeys_ReturnsDefaultKey_WhenNoOptionsProvided": null }, @@ -1276,7 +1276,7 @@ "InvokingContext_Properties_Roundtrip": null, "InvokingContext_RequestMessages_SetterAcceptsValidValue": null, "InvokingContext_RequestMessages_SetterThrowsForNull": null, - "ProvideAIContextAsync_PreservesInstructionsAndToolsAsync": null, + "ProvideAIContextAsync_PreservesInstructionsAndToolsAsync": "agent.TestContextProvider_Invoking_MessageOnlyPreservesInstructionsAndTools", "ProvideAIContextAsync_PreservesNullInstructionsAndToolsAsync": null }, "Microsoft.Agents.AI.Abstractions.UnitTests.ProviderSessionStateTests": { @@ -8715,10 +8715,10 @@ }, "constructors": { "AIContext()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokingContext{}", + "go_symbols": ["agent.InvokingContext"], + "status": "adapted", + "note": "Go passes messages and options as an InvokingContext and returns provider additions separately, rather than constructing an AIContext result object." } } }, @@ -8737,7 +8737,7 @@ "StateKeys": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go ContextProvider has no public state-key enumeration; SourceID identifies provided messages but does not enumerate session state keys." } }, "methods": { @@ -8745,13 +8745,13 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go ContextProvider has no type/key-based service lookup; implementations retain or capture their own dependencies." }, "GetService(object) -> TService": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go ContextProvider has no generic service lookup; dependencies are passed or captured directly." }, "InvokedAsync(AIContextProvider.InvokedContext, CancellationToken) -> ValueTask": { "go": "provider.Invoked(ctx, invoked)", @@ -8822,10 +8822,10 @@ }, "constructors": { "InvokedContext(AIAgent, AgentSession, IEnumerable, Exception)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokedContext{RequestMessages: messages, Err: runErr}", + "go_symbols": ["agent.InvokedContext", "agent.InvokedContext.Err"], + "status": "adapted", + "note": "Construct a post-run context value with request messages and Err; agent identity comes from context.Context and session from Options. Go does not enforce the .NET constructor's null checks." }, "InvokedContext(AIAgent, AgentSession, IEnumerable, IEnumerable)": { "go": "", @@ -8994,38 +8994,38 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go uses ordinary map[string]any properties and has no SDK helper that keys values by their type name." }, "methods": { "Add(AdditionalPropertiesDictionary, T) -> void": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no typed additional-property insertion helper that chooses the type's full name as key and rejects a duplicate key." }, "Contains(AdditionalPropertiesDictionary) -> bool": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no SDK helper to look up an additional-property key derived from a type; callers use explicit string map keys." }, "Remove(AdditionalPropertiesDictionary) -> bool": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no typed additional-property removal helper; callers delete an explicit string key from a map." }, "TryAdd(AdditionalPropertiesDictionary, T) -> bool": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no type-name-keyed conditional insertion helper preserving the original value on duplicate types." }, "TryGetValue(AdditionalPropertiesDictionary, out T) -> bool": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no typed additional-property retrieval helper that derives the key from T and checks the stored value's type." } } }, @@ -9036,13 +9036,13 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go uses encoding/json and type-specific JSON methods; no exported SDK utility owns the shared .NET resolver and serializer-options configuration." }, "properties": { "DefaultOptions": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no public shared serializer-options instance combining agent and Microsoft.Extensions.AI contracts; encoding/json and type-specific JSON methods provide serialization." } } }, @@ -9854,40 +9854,40 @@ }, "methods": { "Equals(AgentRequestMessageSourceAttribution) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.Source; _ = left == right", + "go_symbols": ["message.Source"], + "status": "adapted", + "note": "Source is a comparable Go value: == compares its type and ID exactly rather than invoking an Equals method." }, "Equals(object) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left message.Source; var candidate any = message.Source{}; right, ok := candidate.(message.Source); _ = ok && left == right", + "go_symbols": ["message.Source"], + "status": "adapted", + "note": "Type-assert an arbitrary value to Source before comparing; Go has no object-taking Equals method." }, "GetHashCode() -> int": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Source is usable as a Go map key, but Go exposes no integer hash code for it." }, "ToString() -> string": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go Source has no formatter returning the .NET type:sourceId representation; its zero and empty source IDs are not distinct." }, "op_Equality(AgentRequestMessageSourceAttribution, AgentRequestMessageSourceAttribution) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.Source; _ = left == right", + "go_symbols": ["message.Source"], + "status": "adapted", + "note": "The comparable Source struct uses Go's built-in == operator to compare type and ID." }, "op_Inequality(AgentRequestMessageSourceAttribution, AgentRequestMessageSourceAttribution) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.Source; _ = left != right", + "go_symbols": ["message.Source"], + "status": "adapted", + "note": "The comparable Source struct uses Go's built-in != operator to compare type and ID." } }, "constructors": { @@ -9952,40 +9952,40 @@ }, "methods": { "Equals(AgentRequestMessageSourceType) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.SourceType; _ = left == right", + "go_symbols": ["message.SourceType"], + "status": "adapted", + "note": "Compare Go SourceType values directly; their local category strings need not match the .NET Value spellings." }, "Equals(object) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left message.SourceType; var candidate any = message.SourceType(\"custom\"); right, ok := candidate.(message.SourceType); _ = ok && left == right", + "go_symbols": ["message.SourceType"], + "status": "adapted", + "note": "Type-assert an arbitrary value to SourceType before comparing; Go has no object-taking Equals method." }, "GetHashCode() -> int": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go SourceType is a comparable string-backed map key, but Go exposes no integer hash-code method." }, "ToString() -> string": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "string(message.SourceType(\"custom\"))", + "go_symbols": ["message.SourceType"], + "status": "adapted", + "note": "Convert SourceType to string; Go uses local category values and an empty string for external input, unlike the .NET External spelling." }, "op_Equality(AgentRequestMessageSourceType, AgentRequestMessageSourceType) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.SourceType; _ = left == right", + "go_symbols": ["message.SourceType"], + "status": "adapted", + "note": "Go's string-backed SourceType compares directly with ==." }, "op_Inequality(AgentRequestMessageSourceType, AgentRequestMessageSourceType) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "var left, right message.SourceType; _ = left != right", + "go_symbols": ["message.SourceType"], + "status": "adapted", + "note": "Go's string-backed SourceType compares directly with !=." } }, "constructors": { @@ -10082,36 +10082,35 @@ "status": "mapped" }, "ToString() -> string": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "response.String()", + "go_symbols": ["agent.Response.String"], + "status": "mapped" } }, "constructors": { "AgentResponse()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.Response{}", + "go_symbols": ["agent.Response"], + "status": "mapped", + "note": "The Go zero value has a nil Messages slice instead of an allocated empty list; both contain no messages." }, "AgentResponse(ChatMessage)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.Response{Messages: []*message.Message{msg}}", + "go_symbols": ["agent.Response", "agent.Response.Messages"], + "status": "adapted", + "note": "Initialize the Messages slice with the one message instead of calling a constructor." }, "AgentResponse(ChatResponse)": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no separate Microsoft.Extensions.AI ChatResponse type to convert; providers return agent.Response directly." }, "AgentResponse(IList)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.Response{Messages: messages}", + "go_symbols": ["agent.Response", "agent.Response.Messages"], + "status": "adapted", + "note": "Supply a Go message slice directly; the response retains the slice rather than wrapping an IList." } } }, @@ -10129,18 +10128,22 @@ }, "properties": { "IsWrappedInObject": { - "go_symbols": [], - "status": "unmapped", - "note": "No wrapped-response flag or automatic non-object unwrapping counterpart was located. Go's OpenAI strict-schema path rejects non-object roots instead of adding this wrapper." + "go_symbols": [ + "agent.WithStructuredOutput", + "agent/format/jsonformat.WrapNonObjectSchema", + "agent/format/jsonformat.Format.UnmarshalStructuredOutput" + ], + "status": "partial", + "note": "Built-in providers use shared jsonformat helpers to wrap non-object JSON schemas and decode the data property; agent middleware is format-agnostic. Go does not expose a per-response IsWrappedInObject flag or a way to query the wrapper state of a returned Response." }, "Result": { "go_symbols": [ "agent.WithStructuredOutput", "agent.ResponseStream.Collect", - "agent/format/jsonformat.Format.Unmarshal" + "agent/format/jsonformat.Format.UnmarshalStructuredOutput" ], "status": "adapted", - "note": "Read the destination after successful collection, or explicitly unmarshal existing response text; no generic Result property is needed." + "note": "Read the caller-owned destination after successful collection, or use Format.UnmarshalStructuredOutput with the matching response format to decode existing text. Go decodes eagerly and has no generic Result property." } }, "constructors": { @@ -10161,29 +10164,29 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "stream.Collect()", + "go_symbols": ["agent.ResponseStream.Collect", "agent.Response.Update"], + "status": "adapted", + "note": "Go folds streaming updates through Collect or Response.Update; there is no extension-method class or conversion to Microsoft.Extensions.AI response types." }, "methods": { "AsChatResponse(AgentResponse) -> ChatResponse": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no separate ChatResponse object model or conversion preserving an original ChatResponse in RawRepresentation." }, "AsChatResponseUpdate(AgentResponseUpdate) -> ChatResponseUpdate": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no separate ChatResponseUpdate object model to reconstruct from an agent.ResponseUpdate." }, "AsChatResponseUpdatesAsync(IAsyncEnumerable) -> IAsyncEnumerable": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has one ResponseUpdate stream type, not a distinct ChatResponseUpdate stream requiring element conversion." }, "ToAgentResponse(IEnumerable) -> AgentResponse": { "go": "var response agent.Response; for _, update := range updates { response.Update(update) }; response.Coalesce()", @@ -10290,36 +10293,34 @@ }, "methods": { "ToString() -> string": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "update.String()", + "go_symbols": ["agent.ResponseUpdate.String"], + "status": "mapped" } }, "constructors": { "AgentResponseUpdate()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.ResponseUpdate{}", + "go_symbols": ["agent.ResponseUpdate"], + "status": "mapped" }, "AgentResponseUpdate(ChatResponseUpdate)": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go has no separate ChatResponseUpdate type from which to seed an agent response update." }, "AgentResponseUpdate(ChatRole?, IList)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.ResponseUpdate{Role: role, Contents: contents}", + "go_symbols": ["agent.ResponseUpdate", "agent.ResponseUpdate.Contents", "agent.ResponseUpdate.Role"], + "status": "adapted", + "note": "Use a struct literal; the zero Go role means no explicit role and Contents is a Go slice." }, "AgentResponseUpdate(ChatRole?, string)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.ResponseUpdate{Role: role, Contents: message.Contents{&message.TextContent{Text: text}}}", + "go_symbols": ["agent.ResponseUpdate", "message.Contents", "message.TextContent"], + "status": "adapted", + "note": "Wrap non-nil text in a TextContent item in the Contents slice; Go does not distinguish a null role from the zero role." } } }, @@ -10371,10 +10372,10 @@ }, "constructors": { "AgentRunContext(AIAgent, AgentSession, IReadOnlyCollection, AgentRunOptions)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokingContext{Messages: messages, Options: options}", + "go_symbols": ["agent.InvokingContext", "agent.AgentFromContext", "agent.WithSession"], + "status": "adapted", + "note": "Go passes run messages and typed options explicitly; retrieve agent identity from context.Context and the session from options. No aggregate run-context constructor or .NET null checks are needed." } } }, @@ -10423,15 +10424,15 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "There is no exported clone of a run-options object. Copying a []agent.Option slice does not clone option payloads or the .NET AdditionalProperties dictionary." } }, "constructors": { "AgentRunOptions()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "[]agent.Option{}", + "go_symbols": ["agent.Option"], + "status": "adapted", + "note": "Go uses variadic typed options rather than allocating an AgentRunOptions object." } } }, @@ -10462,13 +10463,13 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go Session has no type/key service-discovery method; its state is accessed by Get, Set, and Delete." }, "GetService(object) -> TService": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go Session has no generic service lookup; callers retain the session pointer and access state explicitly." } } }, @@ -10476,23 +10477,23 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "session.InMemoryHistory(\"\")", + "go_symbols": ["agent.Session.InMemoryHistory", "agent.Session.SetInMemoryHistory"], + "status": "adapted", + "note": "The history extension methods are ordinary methods on Go Session; this type mapping does not imply a separate extension-method container." }, "methods": { "SetInMemoryChatHistory(AgentSession, List, string, JsonSerializerOptions) -> void": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "note": "No dedicated public replacement helper for existing in-memory history was located. Direct Session.Set cannot supply the provider's private cached state type; round-tripping a compatible serialized shape would depend on the provider's representation. StateInitializer only seeds first use." + "go": "session.SetInMemoryHistory(\"\", messages)", + "go_symbols": ["agent.Session.SetInMemoryHistory"], + "status": "mapped", + "note": "Go uses encoding/json without per-call JsonSerializerOptions; an empty state key selects the Go provider's default. Call SetInMemoryHistory after editing retrieved history to persist changes." }, "TryGetInMemoryChatHistory(AgentSession, out List, string, JsonSerializerOptions) -> bool": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "note": "No dedicated public raw-history getter with a presence result was located. Live cached history uses a private state type; after serialization, Session.Get can decode a compatible shape, but that depends on the provider's state representation. Invoking the provider can initialize and filter history instead." + "go": "session.InMemoryHistory(\"\")", + "go_symbols": ["agent.Session.InMemoryHistory"], + "status": "mapped", + "note": "Go returns messages and error rather than a bool/out parameter; a nil slice represents missing or null history. It uses encoding/json without per-call JsonSerializerOptions, and an empty state key selects the Go provider's default." } } }, @@ -10500,36 +10501,36 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.Session{}", + "go_symbols": ["agent.Session", "agent.Session.Get", "agent.Session.Set", "agent.Session.Delete"], + "status": "partial", + "note": "Session owns concurrent state directly rather than exporting a separate bag. Individual operations and lazy decoding are synchronized; JSON enumeration can reflect overlapping writes, while UnmarshalJSON atomically replaces state and service ID. Go preserves raw JSON after a read rather than reserializing cached values as .NET does; call Set after edits." }, "properties": { "Count": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Session's state map is private and exposes no public key-count accessor." } }, "methods": { "Deserialize(JsonElement) -> AgentSessionStateBag": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "json.Unmarshal(data, session)", + "go_symbols": ["agent.Session.UnmarshalJSON"], + "status": "partial", + "note": "Go deserializes a complete Session envelope with a State field, not a standalone state-bag JsonElement." }, "GetValue(string, JsonSerializerOptions) -> T": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "session.Get(\"key\", &value)", + "go_symbols": ["agent.Session.Get"], + "status": "partial", + "note": "Go Get writes to a destination pointer but reports unreadable stored values as not found instead of throwing. A deserialized session preserves raw JSON after reads, and Go has no per-call JsonSerializerOptions." }, "Serialize() -> JsonElement": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "json.Marshal(session)", + "go_symbols": ["agent.Session.MarshalJSON"], + "status": "partial", + "note": "Go serializes the whole Session envelope as JSON bytes, not a standalone state-bag JsonElement." }, "SetValue(string, T, JsonSerializerOptions) -> void": { "go": "session.Set(\"topic\", \"Go\")", @@ -10553,15 +10554,15 @@ "agent.Session.Delete" ], "status": "adapted", - "note": "Go stores state directly on Session and Delete now returns whether the key existed and was removed. The Go method has no per-call JSON options and does not reject an empty key." + "note": "Go stores state directly on Session; Delete reports whether the key existed and rejects blank keys. There is no separate state-bag object." } }, "constructors": { "AgentSessionStateBag()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.Session{}", + "go_symbols": ["agent.Session"], + "status": "adapted", + "note": "The zero-value Session owns an initially empty state map; Go has no separate state-bag object." } } }, @@ -10569,23 +10570,23 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "json.Marshal(session)", + "go_symbols": ["agent.Session.MarshalJSON", "agent.Session.UnmarshalJSON"], + "status": "partial", + "note": "Go serializes state through Session JSON methods without registering a converter; a standalone bag-only JSON converter is not exported." }, "methods": { "Read(ref Utf8JsonReader, Type, JsonSerializerOptions) -> AgentSessionStateBag": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "json.Unmarshal(data, session)", + "go_symbols": ["agent.Session.UnmarshalJSON"], + "status": "partial", + "note": "Go decodes the enclosing Session JSON with encoding/json; it has no standalone state-bag converter or per-call serializer options." }, "Write(Utf8JsonWriter, AgentSessionStateBag, JsonSerializerOptions) -> void": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "json.Marshal(session)", + "go_symbols": ["agent.Session.MarshalJSON"], + "status": "partial", + "note": "Go encodes the Session envelope, not a standalone bag JSON object; JSON settings are controlled by Go types rather than per-call options." } }, "constructors": { @@ -10593,7 +10594,7 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go Session owns its JSON encoding/decoding; there is no public converter instance to construct." } } }, @@ -12273,7 +12274,7 @@ "StateKeys": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go HistoryProvider has no public state-key enumeration; SourceID and StateKey configuration do not reveal every active session key." } }, "methods": { @@ -12281,13 +12282,13 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go HistoryProvider has no type/key-based service lookup; dependencies are retained by provider implementations." }, "GetService(object) -> TService": { "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go HistoryProvider has no generic service-discovery method; consumers use explicit provider references." }, "InvokedAsync(ChatHistoryProvider.InvokedContext, CancellationToken) -> ValueTask": { "go": "provider.Invoked(ctx, invoked)", @@ -12309,10 +12310,10 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokedContext{}", + "go_symbols": ["agent.InvokedContext", "agent.AgentFromContext", "agent.GetOption"], + "status": "adapted", + "note": "History and AI context providers share Go InvokedContext; agent identity is in context.Context and session in Options." }, "properties": { "Agent": { @@ -12352,10 +12353,10 @@ }, "constructors": { "InvokedContext(AIAgent, AgentSession, IEnumerable, Exception)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokedContext{RequestMessages: messages, Err: runErr}", + "go_symbols": ["agent.InvokedContext", "agent.InvokedContext.Err"], + "status": "adapted", + "note": "Set Err and request messages in the shared Go context value; agent/session come from context.Context and Options, without .NET constructor validation." }, "InvokedContext(AIAgent, AgentSession, IEnumerable, IEnumerable)": { "go": "", @@ -12370,10 +12371,10 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokingContext{Messages: messages, Options: options}", + "go_symbols": ["agent.InvokingContext", "agent.GetOption"], + "status": "adapted", + "note": "History and AI context providers share Go InvokingContext; session is an option and agent identity comes from context.Context." }, "properties": { "Agent": { @@ -12414,10 +12415,10 @@ "area": "messages", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "msg.WithSource(message.Source{Type: sourceType, ID: sourceID})", + "go_symbols": ["message.Message.WithSource", "message.Source"], + "status": "adapted", + "note": "Go stores attribution in Message.Source and uses WithSource to clone and stamp messages, rather than an extension-method container." }, "methods": { "GetAgentRequestMessageSourceId(ChatMessage) -> string": { @@ -12638,14 +12639,14 @@ }, "properties": { "Description": { - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go_symbols": ["agent.Agent.Description", "agent.Config.Middlewares"], + "status": "adapted", + "note": "Middleware decorates the same Agent, so Description remains its configured value rather than forwarding from an inner agent." }, "Name": { - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go_symbols": ["agent.Agent.Name", "agent.Config.Middlewares"], + "status": "adapted", + "note": "Middleware decorates the same Agent, so Name remains its configured value rather than forwarding from an inner agent." } }, "methods": { @@ -12653,7 +12654,7 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go middleware has no wrapper-agent service lookup or keyed delegation to an inner agent." } } }, @@ -14111,40 +14112,40 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{})", + "go_symbols": ["agent.NewInMemoryHistoryProvider", "agent.InMemoryHistoryProviderConfig", "agent/compaction.NewHistoryProvider"], + "status": "adapted", + "note": "Go returns a HistoryProvider interface from a config factory. Reducer-backed history uses the separate compaction provider, and history access lives on Session." }, "properties": { "ChatReducer": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go configures a compaction Strategy through HistoryProviderConfig but exposes no runtime getter for the active reducer or strategy." }, "ReducerTriggerEvent": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go configures the trigger on compaction.HistoryProviderConfig but exposes no getter on the constructed history provider." }, "StateKeys": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go HistoryProvider has no runtime StateKeys accessor; caller-held SourceID and StateKey configuration select its storage key." } }, "methods": { "GetMessages(AgentSession) -> List": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "session.InMemoryHistory(\"\")", + "go_symbols": ["agent.Session.InMemoryHistory"], + "status": "adapted", + "note": "Read history from the session instead of the provider. Unlike GetMessages with a null session, a nil Go session returns an error; missing or null history returns a nil slice." }, "SetMessages(AgentSession, List) -> void": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "session.SetInMemoryHistory(\"\", messages)", + "go_symbols": ["agent.Session.SetInMemoryHistory"], + "status": "adapted", + "note": "Replace history through the session instead of the provider. Go accepts a nil message slice and returns an error for a nil session; .NET rejects null messages and allows a null session." } }, "constructors": { @@ -14170,9 +14171,9 @@ }, "properties": { "Messages": { - "go_symbols": [], - "status": "unmapped", - "note": "No exported field or live history accessor exposes the built-in message list. StateInitializer only seeds it. Session.Get can decode a caller-defined shape after JSON restoration, but cannot read live cached state as a different concrete type." + "go_symbols": ["agent.Session.InMemoryHistory", "agent.Session.SetInMemoryHistory"], + "status": "adapted", + "note": "Read or replace messages through the session's history methods; the internal state container remains private. Call SetInMemoryHistory after editing returned history to persist changes across serialization." } }, "constructors": { @@ -14180,7 +14181,7 @@ "go": "", "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "The Go history state struct is private; callers use Session.InMemoryHistory and SetInMemoryHistory instead of constructing a public State." } } }, @@ -14207,7 +14208,7 @@ "JsonSerializerOptions": { "go_symbols": [], "status": "unmapped", - "unreviewed": true + "note": "Go history serialization uses encoding/json and built-in message content codecs; no per-provider serializer-options or custom AIContent converter configuration is exposed." }, "ProvideOutputMessageFilter": { "go_symbols": [ @@ -14238,7 +14239,7 @@ "agent/compaction.HistoryProviderConfig.StateKey" ], "status": "adapted", - "note": "Selects the session storage key; Go defaults to SourceID (in-memory for the plain provider, CompactionHistoryProvider for the compaction provider), not the C# provider type name." + "note": "Selects the session storage key; Go defaults to SourceID (in-memory for the plain provider, CompactionHistoryProvider for the compaction provider), not the C# provider type name. Resolved blank keys panic at provider construction." }, "StorageInputRequestMessageFilter": { "go_symbols": [ @@ -14259,10 +14260,10 @@ }, "constructors": { "InMemoryChatHistoryProviderOptions()": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InMemoryHistoryProviderConfig{}", + "go_symbols": ["agent.InMemoryHistoryProviderConfig"], + "status": "adapted", + "note": "A zero Go config supplies plain in-memory history defaults; reducer-backed storage uses compaction.HistoryProviderConfig with a required Strategy." } } }, @@ -14660,10 +14661,10 @@ "area": "agents", "assembly": "Microsoft.Agents.AI.Abstractions", "mapping": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "agent.InvokingContext{Messages: messages, Options: options}", + "go_symbols": ["agent.InvokingContext", "agent.GetOption"], + "status": "adapted", + "note": "Message and general AI context providers share Go InvokingContext; agent identity comes from context.Context and session from Options." }, "properties": { "Agent": { @@ -14922,9 +14923,9 @@ }, "properties": { "StateKey": { - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go_symbols": ["agent.Session.Get", "agent.Session.Set"], + "status": "adapted", + "note": "The caller holds the state key and supplies it to Session.Get and Session.Set; Go has no constructed generic provider-state helper with a StateKey accessor." } }, "methods": { @@ -14943,15 +14944,15 @@ "agent.Session.Set" ], "status": "adapted", - "note": "Set overwrites the key and is a no-op on a nil session; it does not deep-copy the value. After modifying restored state, Set it again so serialization does not retain the original raw JSON." + "note": "Set overwrites the key and is a no-op on a nil session; it does not deep-copy the value. After modifying restored state, call Set so serialization does not retain the original raw JSON." } }, "constructors": { "ProviderSessionState(Func, string, JsonSerializerOptions)": { - "go": "", - "go_symbols": [], - "status": "unmapped", - "unreviewed": true + "go": "session.Set(key, initialState)", + "go_symbols": ["agent.Session.Get", "agent.Session.Set"], + "status": "adapted", + "note": "Go composes Session.Get and Set with a caller-held key and initializer instead of constructing a configured generic state object; there are no per-helper JSON serializer options." } } }, diff --git a/agent/agent.go b/agent/agent.go index 8928fc4c..b2060633 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -42,7 +42,7 @@ type ProviderConfig struct { // retained originals. The provider remains responsible for execution and approvals. ManagesToolExecution bool - // Format creates a provider response format for a structured output value. + // Format creates a provider response format for the structured output value. Format func(v any) (ResponseFormat, error) // Unmarshal decodes provider structured output into v using format. diff --git a/agent/agent_test.go b/agent/agent_test.go index f6a2d0ee..d29c7f1b 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -26,6 +26,156 @@ type stubTool struct { name string } +func TestAgentMetadata_Constructor_WithProviderName_SetsProperty(t *testing.T) { + var runner agenttest.Runner + a := agent.New(agent.ProviderConfig{ + ProviderName: "TestProvider", + Run: runner.Run, + }, agent.Config{}) + if got := a.ProviderName(); got != "TestProvider" { + t.Fatalf("ProviderName() = %q, want TestProvider", got) + } +} + +func TestAgent_Name_ReturnsValueFromConfig(t *testing.T) { + var runner agenttest.Runner + a := agent.New(agent.ProviderConfig{Run: runner.Run}, agent.Config{ + Name: "TestAgentName", Description: "TestAgentDescription", + }) + if got := a.Name(); got != "TestAgentName" { + t.Fatalf("Name() = %q, want TestAgentName", got) + } +} + +func TestAgent_Description_ReturnsValueFromConfig(t *testing.T) { + var runner agenttest.Runner + a := agent.New(agent.ProviderConfig{Run: runner.Run}, agent.Config{ + Name: "TestAgentName", Description: "TestAgentDescription", + }) + if got := a.Description(); got != "TestAgentDescription" { + t.Fatalf("Description() = %q, want TestAgentDescription", got) + } +} + +func TestAgent_ID_IsStable(t *testing.T) { + var runner agenttest.Runner + a := agent.New(agent.ProviderConfig{Run: runner.Run}, agent.Config{}) + id := a.ID() + if id == "" || a.ID() != id { + t.Fatalf("ID() = %q, subsequent ID() = %q; want stable non-empty ID", id, a.ID()) + } +} + +func TestAgent_ID_UsesConfiguredID(t *testing.T) { + var runner agenttest.Runner + a := agent.New(agent.ProviderConfig{Run: runner.Run}, agent.Config{ID: "test-agent-id"}) + if got := a.ID(); got != "test-agent-id" { + t.Fatalf("ID() = %q, want test-agent-id", got) + } +} + +func TestAgent_InvokeWithoutMessageCallsRunWithEmptyMessages(t *testing.T) { + testAgentInvocation(t, "none", false) +} + +func TestAgent_InvokeWithStringMessageCallsRunWithOneMessage(t *testing.T) { + testAgentInvocation(t, "text", false) +} + +func TestAgent_InvokeWithSingleMessageCallsRunWithSameMessage(t *testing.T) { + testAgentInvocation(t, "message", false) +} + +func TestAgent_InvokeStreamingWithoutMessageCallsRunWithEmptyMessages(t *testing.T) { + testAgentInvocation(t, "none", true) +} + +func TestAgent_InvokeStreamingWithStringMessageCallsRunWithOneMessage(t *testing.T) { + testAgentInvocation(t, "text", true) +} + +func TestAgent_InvokeStreamingWithSingleMessageCallsRunWithSameMessage(t *testing.T) { + testAgentInvocation(t, "message", true) +} + +func testAgentInvocation(t *testing.T, inputKind string, streaming bool) { + t.Helper() + session := agenttest.CreateSession() + input := message.NewText("Hello, Agent!") + providerCalls := 0 + a := agent.New(agent.ProviderConfig{ + Run: func(_ context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + gotSession, ok := agent.GetOption(options, agent.WithSession) + if !ok || gotSession != session { + t.Errorf("provider session = %p, want %p", gotSession, session) + } + gotStream, ok := agent.GetOption(options, agent.Stream) + if !ok || gotStream != streaming { + t.Errorf("provider stream = (%v, %v), want (%v, true)", gotStream, ok, streaming) + } + switch inputKind { + case "none": + if len(messages) != 0 { + t.Errorf("provider messages = %v, want empty", messages) + } + case "text": + if len(messages) != 1 || messages[0].Role != message.RoleUser || messages[0].String() != "Hello, Agent!" { + t.Errorf("provider messages = %v, want one user text message", messages) + } + case "message": + if len(messages) != 1 || messages[0] != input { + t.Errorf("provider messages = %v, want original message", messages) + } + default: + t.Fatalf("unknown input kind %q", inputKind) + } + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, Contents: message.Contents{&message.TextContent{Text: "Hi"}}, + }, nil) + } + }, + }, agent.Config{}) + + options := []agent.Option{agent.WithSession(session), agent.Stream(streaming)} + var updates agent.ResponseStream + switch inputKind { + case "none": + updates = a.Run(t.Context(), nil, options...) + case "text": + updates = a.RunText(t.Context(), input.String(), options...) + case "message": + updates = a.RunMessage(t.Context(), input, options...) + } + if streaming { + count := 0 + for update, err := range updates { + if err != nil { + t.Fatal(err) + } + count++ + if got := update.String(); got != "Hi" { + t.Errorf("streamed text = %q, want Hi", got) + } + } + if count != 1 { + t.Errorf("streamed updates = %d, want 1", count) + } + } else { + resp, err := updates.Collect() + if err != nil { + t.Fatal(err) + } + if got := resp.String(); got != "Hi" { + t.Errorf("response text = %q, want Hi", got) + } + } + if providerCalls != 1 { + t.Errorf("provider calls = %d, want 1", providerCalls) + } +} + func TestFunctionInvocationMiddleware_Composition(t *testing.T) { toolFailure := errors.New("tool failed") for _, tc := range []struct { diff --git a/agent/compaction/compaction_test.go b/agent/compaction/compaction_test.go index 67363b53..3119e40f 100644 --- a/agent/compaction/compaction_test.go +++ b/agent/compaction/compaction_test.go @@ -6,7 +6,11 @@ import ( "context" "encoding/json" "errors" + "fmt" + "runtime" "slices" + "strconv" + "sync" "testing" "github.com/microsoft/agent-framework-go/agent" @@ -725,6 +729,69 @@ func TestNewProvider_CompactsAndPersistsIndex(t *testing.T) { } } +func TestNewProvider_ConcurrentInvokingPreservesState(t *testing.T) { + session := agenttest.CreateSession() + const invocations = 64 + provider := compaction.NewContextProvider(compaction.ContextProviderConfig{ + SourceID: "concurrent-compaction", + Strategy: strategyFunc(func(_ context.Context, index *compaction.MessageIndex) (bool, error) { + if len(index.Groups) != 1 { + return false, fmt.Errorf("groups = %d, want one user group", len(index.Groups)) + } + previous := 0 + if index.Groups[0].ExcludeReason != "" { + var err error + previous, err = strconv.Atoi(index.Groups[0].ExcludeReason) + if err != nil { + return false, err + } + } + runtime.Gosched() + index.Groups[0].ExcludeReason = strconv.Itoa(previous + 1) + return true, nil + }), + }) + + start := make(chan struct{}) + errs := make(chan error, invocations) + var wg sync.WaitGroup + wg.Add(invocations) + for range invocations { + go func() { + defer wg.Done() + <-start + _, _, err := invokeProvider(provider, context.Background(), []*message.Message{textMessage(message.RoleUser, "request")}, agent.WithSession(session)) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + var state struct { + MessageGroups []*compaction.MessageGroup `json:"messagegroups"` + } + if ok, err := restored.Get("concurrent-compaction", &state); err != nil || !ok || len(state.MessageGroups) != 1 { + t.Fatalf("stored groups = %v, found = %v, err = %v; want one group", state.MessageGroups, ok, err) + } + if got := state.MessageGroups[0].ExcludeReason; got != strconv.Itoa(invocations) { + t.Fatalf("compaction updates = %q, want %d", got, invocations) + } +} + func TestNewProvider_SourceStampsGeneratedMessages(t *testing.T) { provider := compaction.NewContextProvider(compaction.ContextProviderConfig{ Strategy: &compaction.SummarizationStrategy{ diff --git a/agent/compaction/historyprovider.go b/agent/compaction/historyprovider.go index c216572b..3a0660ec 100644 --- a/agent/compaction/historyprovider.go +++ b/agent/compaction/historyprovider.go @@ -8,6 +8,7 @@ import ( "log/slog" "runtime" "slices" + "strings" "sync" "weak" @@ -43,7 +44,7 @@ type HistoryProviderConfig struct { SourceID string // StateKey identifies where provider state is stored in the session. - // When empty, SourceID is used. + // When empty, SourceID is used. Whitespace-only keys are invalid. StateKey string // StateInitializer returns initial messages on first use. @@ -107,6 +108,7 @@ func (l *historyProviderSessionLocks) forOptions(options []agent.Option) *sync.M // The provider stores conversation history in the session like [agent.NewInMemoryHistoryProvider], // but it automatically applies Strategy at the configured TriggerEvent. This gives history providers // first-class reducer-trigger behavior without requiring a separate context provider. +// It panics if the resolved StateKey is blank. func NewHistoryProvider(cfg HistoryProviderConfig) agent.HistoryProvider { if cfg.Strategy == nil { panic("Strategy is required") @@ -117,6 +119,9 @@ func NewHistoryProvider(cfg HistoryProviderConfig) agent.HistoryProvider { } cfg.SourceID = cmp.Or(cfg.SourceID, defaultHistoryProviderSourceID) cfg.StateKey = cmp.Or(cfg.StateKey, cfg.SourceID) + if strings.TrimSpace(cfg.StateKey) == "" { + panic("StateKey must not be blank") + } return &historyProvider{config: cfg, locks: new(historyProviderSessionLocks)} } diff --git a/agent/compaction/historyprovider_test.go b/agent/compaction/historyprovider_test.go index 5e1b774f..4d50f017 100644 --- a/agent/compaction/historyprovider_test.go +++ b/agent/compaction/historyprovider_test.go @@ -25,6 +25,14 @@ func (s *countingStrategy) Compact(ctx context.Context, index *compaction.Messag return s.strategy.Compact(ctx, index) } +type reducingHistoryStrategy struct{ calls int } + +func (s *reducingHistoryStrategy) Compact(_ context.Context, index *compaction.MessageIndex) (bool, error) { + s.calls++ + index.Update([]*message.Message{textMessage(message.RoleUser, "Reduced")}) + return true, nil +} + func invokeHistoryProvider(provider agent.HistoryProvider, ctx context.Context, messages []*message.Message, options ...agent.Option) ([]*message.Message, error) { return provider.Invoking(ctx, agent.InvokingContext{Messages: messages, Options: options}) } @@ -33,6 +41,26 @@ func invokeHistoryProviderInvoked(provider agent.HistoryProvider, ctx context.Co return provider.Invoked(ctx, agent.InvokedContext{RequestMessages: requestMessages, ResponseMessages: responseMessages, Options: options}) } +func TestNewHistoryProvider_PanicsWithBlankStateKey(t *testing.T) { + for _, tc := range []struct { + name string + config compaction.HistoryProviderConfig + }{ + {name: "explicit state key", config: compaction.HistoryProviderConfig{StateKey: " "}}, + {name: "default state key from source ID", config: compaction.HistoryProviderConfig{SourceID: "\t"}}, + } { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected blank history state key to panic at construction") + } + }() + tc.config.Strategy = new(reducingHistoryStrategy) + compaction.NewHistoryProvider(tc.config) + }) + } +} + func TestNewHistoryProvider_CompactsBeforeMessagesRetrievalByDefault(t *testing.T) { session := agenttest.CreateSession() minimumPreservedGroups := 2 @@ -119,6 +147,79 @@ func TestNewHistoryProvider_CompactsAfterMessageAdded(t *testing.T) { } } +func TestHistoryProvider_ReducesAfterMessageAdded(t *testing.T) { + session := agenttest.CreateSession() + strategy := new(reducingHistoryStrategy) + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + Strategy: strategy, TriggerEvent: compaction.HistoryProviderTriggerEventAfterMessageAdded, + }) + request := textMessage(message.RoleUser, "Hello") + response := textMessage(message.RoleAssistant, "Hi there!") + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{request}, []*message.Message{response}, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + messages, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || !slices.Equal(messageTexts(messages), []string{"Reduced"}) || strategy.calls != 1 { + t.Fatalf("history = %v, reducer calls = %d, err = %v; want Reduced, once", messageTexts(messages), strategy.calls, err) + } +} + +func TestHistoryProvider_ReducesBeforeMessagesRetrieval(t *testing.T) { + session := agenttest.CreateSession() + strategy := new(reducingHistoryStrategy) + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + Strategy: strategy, + StateInitializer: func(*agent.Session) []*message.Message { + return []*message.Message{textMessage(message.RoleUser, "Hello"), textMessage(message.RoleAssistant, "Hi there!")} + }, + }) + messages, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || !slices.Equal(messageTexts(messages), []string{"Reduced"}) || strategy.calls != 1 { + t.Fatalf("history = %v, reducer calls = %d, err = %v; want Reduced, once", messageTexts(messages), strategy.calls, err) + } +} + +func TestHistoryProvider_DoesNotReduceOnAddWhenTriggeredBeforeRetrieval(t *testing.T) { + session := agenttest.CreateSession() + strategy := new(reducingHistoryStrategy) + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{Strategy: strategy}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "Hello")}, nil, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + if strategy.calls != 0 { + t.Fatalf("reducer calls on add = %d, want 0", strategy.calls) + } + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + var state struct { + Messages []*message.Message `json:"messages"` + } + if ok, err := restored.Get("CompactionHistoryProvider", &state); err != nil || !ok || !slices.Equal(messageTexts(state.Messages), []string{"Hello"}) { + t.Fatalf("stored history = %v, found = %v, err = %v; want Hello", messageTexts(state.Messages), ok, err) + } +} + +func TestHistoryProvider_DoesNotReduceOnRetrievalWhenTriggeredAfterAdd(t *testing.T) { + session := agenttest.CreateSession() + strategy := new(reducingHistoryStrategy) + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + Strategy: strategy, TriggerEvent: compaction.HistoryProviderTriggerEventAfterMessageAdded, + StateInitializer: func(*agent.Session) []*message.Message { + return []*message.Message{textMessage(message.RoleUser, "Hello")} + }, + }) + messages, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || !slices.Equal(messageTexts(messages), []string{"Hello"}) || strategy.calls != 0 { + t.Fatalf("history = %v, reducer calls = %d, err = %v; want Hello, zero calls", messageTexts(messages), strategy.calls, err) + } +} + func TestNewHistoryProvider_FiltersHistoryBeforeSummarization(t *testing.T) { session := agenttest.CreateSession() minimumPreservedGroups := 2 diff --git a/agent/compaction/provider.go b/agent/compaction/provider.go index 59e624a4..62294358 100644 --- a/agent/compaction/provider.go +++ b/agent/compaction/provider.go @@ -45,6 +45,7 @@ type contextProvider struct { stateKey string tokenCounter TokenCounter logger *slog.Logger + locks *historyProviderSessionLocks } // NewContextProvider creates a context provider that applies compaction before each agent run. @@ -65,6 +66,7 @@ func NewContextProvider(cfg ContextProviderConfig) agent.ContextProvider { stateKey: cfg.StateKey, tokenCounter: cfg.TokenCounter, logger: cfg.Logger, + locks: new(historyProviderSessionLocks), } } @@ -86,6 +88,9 @@ func (p *contextProvider) Invoking(ctx context.Context, invoking agent.InvokingC } return p.markGeneratedMessages(compactedMessages, messages), options, nil } + mu := p.locks.forOptions(options) + mu.Lock() + defer mu.Unlock() if session.ServiceID() != "" { if p.logger != nil { p.logger.DebugContext(ctx, "compaction provider skipped", slog.String("reason", "session managed by remote service")) diff --git a/agent/context_test.go b/agent/context_test.go index 57bcc8e2..09960fd1 100644 --- a/agent/context_test.go +++ b/agent/context_test.go @@ -420,8 +420,13 @@ func TestContextProvider_Invoking_DoesNotMutateProvidedMessageSlice(t *testing.T } func TestContextProvider_Invoking_DoesNotMutateInputBackingArrays(t *testing.T) { + replacement := message.NewText("replacement") provider := agent.NewContextProvider(agent.ContextProviderConfig{ SourceID: "ctx", + ProvideInputMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + messages[0] = replacement + return messages, nil + }, Provide: func(_ context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { return []*message.Message{message.NewText("provided")}, []agent.Option{agent.WithInstructions("provided")}, nil }, @@ -443,6 +448,9 @@ func TestContextProvider_Invoking_DoesNotMutateInputBackingArrays(t *testing.T) if messageBacking[1] != messageSentinel { t.Fatal("expected context provider not to modify the input message backing array") } + if messages[0].String() != "request" { + t.Fatal("expected custom filter not to replace the original message") + } if optionBacking[1] != optionSentinel { t.Fatal("expected context provider not to modify the input option backing array") } @@ -517,33 +525,26 @@ func TestContextProvider_Invoking_FiltersInputMessagesBeforeProvide(t *testing.T } func TestContextProvider_Invoking_UsesCustomProvideInputMessageFilter(t *testing.T) { - request := message.NewText("request") - replacement := message.NewText("replacement") - ctxMessage := message.NewText("ctx") - ctxMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "other"} + request := message.NewText("External") + historyMessage := message.NewText("History") + historyMessage.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "src"} var providedInput []*message.Message provider := agent.NewContextProvider(agent.ContextProviderConfig{ - SourceID: "ctx", - ProvideInputMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { - messages[0] = replacement - return messages, nil - }, + SourceID: "ctx", + ProvideInputMessageFilter: messagefilter.PassThrough, Provide: func(_ context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { providedInput = invoking.Messages return nil, nil, nil }, }) - inputMessages := []*message.Message{request, ctxMessage} + inputMessages := []*message.Message{request, historyMessage} _, _, err := invokeContextProvider(provider, t.Context(), inputMessages, agent.WithSession(agenttest.CreateSession())) if err != nil { t.Fatalf("unexpected error: %v", err) } - if got := messageStrings(providedInput); !slices.Equal(got, []string{"replacement", "ctx"}) { - t.Fatalf("provided input messages = %v, want custom-filtered input", got) - } - if inputMessages[0] != request { - t.Fatal("expected custom filter not to mutate the original input slice") + if !slices.Equal(providedInput, inputMessages) { + t.Fatalf("provided input = %v, want both external and history messages", messageStrings(providedInput)) } } @@ -576,39 +577,58 @@ func TestContextProvider_Invoking_ReturnsProvidedMessagesAndSetsSourceID(t *test } } -func TestContextProvider_Invoking_AppendsProvidedMessages(t *testing.T) { - provided := message.NewText("ctx") +func TestContextProvider_Invoking_MergesWithOriginalUnfilteredMessages(t *testing.T) { + provided := message.NewText("Provided") provided.Role = message.RoleSystem - request := message.NewText("request") + request := message.NewText("External") + historyMessage := message.NewText("History") + historyMessage.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "src"} provider := agent.NewContextProvider(agent.ContextProviderConfig{ SourceID: "ctx", Provide: func(_ context.Context, invoking agent.InvokingContext) ([]*message.Message, []agent.Option, error) { + if !slices.Equal(invoking.Messages, []*message.Message{request}) { + t.Errorf("Provide input = %v, want only external request", messageStrings(invoking.Messages)) + } return []*message.Message{provided}, nil, nil }, }) - messages, _, err := invokeContextProvider(provider, t.Context(), []*message.Message{request}, agent.WithSession(agenttest.CreateSession())) + messages, _, err := invokeContextProvider(provider, t.Context(), []*message.Message{request, historyMessage}, agent.WithSession(agenttest.CreateSession())) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(messages) != 2 { - t.Fatalf("expected original and provided messages, got %d", len(messages)) + if len(messages) != 3 || messages[0] != request || messages[1] != historyMessage || messages[2].String() != "Provided" { + t.Fatalf("merged messages = %v, want external, history, provided", messageStrings(messages)) } - if got := messages[0].String(); got != "request" { - t.Fatalf("original message text = %q, want request", got) + if messages[2] == provided { + t.Fatal("expected provided message to be cloned") } - if got := messages[1].String(); got != "ctx" { - t.Fatalf("provided message text = %q, want ctx", got) + if messages[2].Source != (message.Source{Type: agent.SourceTypeContextProvider, ID: "ctx"}) { + t.Fatalf("provided source = %+v, want context provider ctx", messages[2].Source) } - if messages[0] != request { - t.Fatal("expected original message to be preserved first") +} + +func TestContextProvider_Invoking_AppendsProvidedMessages(t *testing.T) { + provided := message.NewText("ctx") + provided.Role = message.RoleSystem + request := message.NewText("request") + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + Provide: func(_ context.Context, _ agent.InvokingContext) ([]*message.Message, []agent.Option, error) { + return []*message.Message{provided}, nil, nil + }, + }) + + messages, _, err := invokeContextProvider(provider, t.Context(), []*message.Message{request}, agent.WithSession(agenttest.CreateSession())) + if err != nil { + t.Fatal(err) } - if messages[1] == provided { - t.Fatal("expected provided message to be cloned") + if len(messages) != 2 || messages[0] != request || messages[1].String() != "ctx" { + t.Fatalf("merged messages = %v, want request, ctx", messageStrings(messages)) } - if messages[1].Source.ID != "ctx" { - t.Fatalf("expected SourceID=ctx, got %q", messages[1].Source.ID) + if messages[1] == provided || messages[1].Source != (message.Source{Type: agent.SourceTypeContextProvider, ID: "ctx"}) { + t.Fatalf("provided message = %+v, want sourced clone", messages[1]) } } @@ -683,6 +703,156 @@ func TestContextProvider_Invoked_CallsStoreAndIncludesExternalRequestMessagesByD } } +func TestContextProvider_Invoked_SkipsStorageWhenRunFails(t *testing.T) { + called := false + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + Store: func(context.Context, agent.InvokedContext) error { + called = true + return nil + }, + }) + err := provider.Invoked(t.Context(), agent.InvokedContext{ + RequestMessages: []*message.Message{message.NewText("msg")}, + Err: errors.New("failed"), + }) + if err != nil || called { + t.Fatalf("Invoked error = %v, Store called = %v; want nil and false", err, called) + } +} + +func TestContextProvider_Invoked_UsesCustomStoreInputFilters(t *testing.T) { + user := message.NewText("User msg") + system := message.NewText("System msg") + system.Role = message.RoleSystem + assistant := message.NewText("Response") + assistant.Role = message.RoleAssistant + toolMessage := message.NewText("Response") + toolMessage.Role = message.RoleTool + var stored agent.InvokedContext + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + StoreInputRequestMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleSystem }), nil + }, + StoreInputResponseMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleAssistant }), nil + }, + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked + return nil + }, + }) + if err := invokeContextProviderInvoked(provider, t.Context(), []*message.Message{user, system}, []*message.Message{assistant, toolMessage}); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored.RequestMessages, []*message.Message{system}) || !slices.Equal(stored.ResponseMessages, []*message.Message{assistant}) { + t.Fatalf("stored request/response = %v / %v, want system / assistant", messageStrings(stored.RequestMessages), messageStrings(stored.ResponseMessages)) + } +} + +func TestContextProvider_Invoked_DefaultFilterExcludesNonExternalMessages(t *testing.T) { + external := message.NewText("External") + history := message.NewText("History") + history.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "src"} + contextMessage := message.NewText("Context") + contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "src"} + var stored []*message.Message + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked.RequestMessages + return nil + }, + }) + if err := invokeContextProviderInvoked(provider, t.Context(), []*message.Message{external, history, contextMessage}, nil); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored, []*message.Message{external}) { + t.Fatalf("stored requests = %v, want only external", messageStrings(stored)) + } +} + +func TestContextProvider_Invoked_DefaultResponseFilterPassesAllMessages(t *testing.T) { + external := message.NewText("ExternalResp") + external.Role = message.RoleAssistant + history := message.NewText("HistoryResp") + history.Role = message.RoleAssistant + history.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "src"} + contextMessage := message.NewText("ContextResp") + contextMessage.Role = message.RoleAssistant + contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "src"} + responses := []*message.Message{external, history, contextMessage} + var stored []*message.Message + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked.ResponseMessages + return nil + }, + }) + if err := invokeContextProviderInvoked(provider, t.Context(), []*message.Message{message.NewText("Request")}, responses); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored, responses) { + t.Fatalf("stored responses = %v, want all three in order", messageStrings(stored)) + } +} + +func TestContextProvider_Invoked_UsesCustomResponseFilter(t *testing.T) { + keep := message.NewText("Keep") + keep.Role = message.RoleAssistant + drop := message.NewText("Drop") + drop.Role = message.RoleAssistant + var stored []*message.Message + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + StoreInputResponseMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.String() != "Keep" }), nil + }, + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked.ResponseMessages + return nil + }, + }) + if err := invokeContextProviderInvoked(provider, t.Context(), []*message.Message{message.NewText("Request")}, []*message.Message{keep, drop}); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored, []*message.Message{keep}) { + t.Fatalf("stored responses = %v, want Keep", messageStrings(stored)) + } +} + +func TestContextProvider_Invoked_RequestAndResponseFiltersOperateIndependently(t *testing.T) { + user := message.NewText("User") + system := message.NewText("System") + system.Role = message.RoleSystem + first := message.NewText("Resp1") + first.Role = message.RoleAssistant + second := message.NewText("Resp2") + second.Role = message.RoleAssistant + var stored agent.InvokedContext + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + StoreInputRequestMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleSystem }), nil + }, + StoreInputResponseMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.String() != "Resp1" }), nil + }, + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked + return nil + }, + }) + if err := invokeContextProviderInvoked(provider, t.Context(), []*message.Message{user, system}, []*message.Message{first, second}); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored.RequestMessages, []*message.Message{system}) || !slices.Equal(stored.ResponseMessages, []*message.Message{first}) { + t.Fatalf("stored request/response = %v / %v, want System / Resp1", messageStrings(stored.RequestMessages), messageStrings(stored.ResponseMessages)) + } +} + func TestContextProvider_Invoked_FiltersDoNotMutateInputSlices(t *testing.T) { contextMessage := message.NewText("context") contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "ctx"} @@ -764,3 +934,40 @@ func TestContextProvider_InvokingContext_ReturnsProvidedFields(t *testing.T) { t.Fatalf("expected input and provided tools, got %v", got) } } + +func TestContextProvider_Invoking_MessageOnlyPreservesInstructionsAndTools(t *testing.T) { + request := message.NewText("Hello") + provided := message.NewText("Context") + provided.Role = message.RoleSystem + inputTool := functool.MustNew(functool.Config{Name: "inputTool"}, func(_ context.Context, _ struct{}) (string, error) { + return "a", nil + }) + provider := agent.NewContextProvider(agent.ContextProviderConfig{ + SourceID: "ctx", + Provide: func(_ context.Context, _ agent.InvokingContext) ([]*message.Message, []agent.Option, error) { + return []*message.Message{provided}, nil, nil + }, + }) + session := agenttest.CreateSession() + messages, options, err := invokeContextProvider( + provider, t.Context(), []*message.Message{request}, + agent.WithSession(session), agent.WithInstructions("Be helpful"), agent.WithTool(inputTool), + ) + if err != nil { + t.Fatal(err) + } + if got := slices.Collect(agent.AllOptions(options, agent.WithInstructions)); !slices.Equal(got, []string{"Be helpful"}) { + t.Errorf("instructions = %v, want [Be helpful]", got) + } + if got := slices.Collect(agent.AllOptions(options, agent.WithTool)); !slices.Equal(got, []tool.Tool{inputTool}) { + t.Errorf("tools = %v, want original inputTool", got) + } + if got, ok := agent.GetOption(options, agent.WithSession); !ok || got != session { + t.Errorf("session = (%p, %v), want original session", got, ok) + } + if len(messages) != 2 || messages[0] != request || messages[0].String() != "Hello" || + messages[1].String() != "Context" || messages[1].Role != message.RoleSystem || + messages[1].Source.Type != agent.SourceTypeContextProvider { + t.Fatalf("messages = %v, want original request and sourced system context", messages) + } +} diff --git a/agent/format/jsonformat/encoding_test.go b/agent/format/jsonformat/encoding_test.go index e8dc610e..00ffd3f5 100644 --- a/agent/format/jsonformat/encoding_test.go +++ b/agent/format/jsonformat/encoding_test.go @@ -5,6 +5,7 @@ package jsonformat_test import ( "encoding/json" "reflect" + "slices" "testing" "github.com/google/jsonschema-go/jsonschema" @@ -85,6 +86,224 @@ func TestEncodingRoundtrip(t *testing.T) { } } +func TestWrapNonObjectSchema(t *testing.T) { + type object struct { + Name string `json:"name"` + } + type species string + type nested struct { + Label string `json:"label"` + } + for _, tc := range []struct { + name, property, propertyType string + target any + }{ + {name: "object", property: "name", propertyType: "string", target: new(object)}, + {name: "integer", property: "data", propertyType: "integer", target: new(int)}, + {name: "array", property: "data", propertyType: "array", target: new([]string)}, + {name: "enum-like string", property: "data", propertyType: "string", target: new(species)}, + {name: "nested array", property: "data", propertyType: "array", target: new([]nested)}, + } { + t.Run(tc.name, func(t *testing.T) { + original, err := jsonformat.ForType(reflect.TypeOf(tc.target)) + if err != nil { + t.Fatal(err) + } + before, err := json.Marshal(original.Schema) + if err != nil { + t.Fatal(err) + } + wrapped, err := jsonformat.WrapNonObjectSchema(original) + if err != nil { + t.Fatal(err) + } + schema, ok := wrapped.Schema.(*jsonschema.Schema) + if !ok || schema.Type != "object" || wrapped.Name != original.Name { + t.Fatalf("wrapped format = %+v, want named object schema", wrapped) + } + property := schema.Properties[tc.property] + if property == nil || property.Type != tc.propertyType { + t.Errorf("schema property %q = %+v, want %s", tc.property, property, tc.propertyType) + } + if tc.property == "data" { + if !slices.Equal(schema.Required, []string{"data"}) { + t.Errorf("required properties = %v, want [data]", schema.Required) + } + closed, err := json.Marshal(schema.AdditionalProperties) + if err != nil { + t.Fatal(err) + } + if string(closed) != "false" { + t.Errorf("additionalProperties = %s, want false", closed) + } + } else if _, wrapped := schema.Properties["data"]; wrapped { + t.Error("object schema unexpectedly wrapped") + } + after, err := json.Marshal(original.Schema) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Errorf("original schema mutated: %s -> %s", before, after) + } + }) + } +} + +func TestUnmarshalStructuredOutput(t *testing.T) { + type object struct { + Name string `json:"name"` + } + type species string + type nested struct { + Label string `json:"label"` + } + pointerValue := new(42) + for _, tc := range []struct { + name, response string + newTarget func() any + want any + }{ + {name: "object", response: `{"name":"Tiger"}`, + newTarget: func() any { return new(object) }, want: object{Name: "Tiger"}}, + {name: "object with multiple JSON values", response: `{"name":"First"} {"name":"Second"}`, + newTarget: func() any { return new(object) }, want: object{Name: "First"}}, + {name: "integer", response: `{"data":42}`, + newTarget: func() any { return new(int) }, want: 42}, + {name: "wrapped result with extra property", response: `{"data":42,"extra":true}`, + newTarget: func() any { return new(int) }, want: 42}, + {name: "array", response: `{"data":["a","b"]}`, + newTarget: func() any { return new([]string) }, want: []string{"a", "b"}}, + {name: "enum-like string", response: `{"data":"Tiger"}`, + newTarget: func() any { return new(species) }, want: species("Tiger")}, + {name: "nested array", response: `{"data":[{"label":"value"}]}`, + newTarget: func() any { return new([]nested) }, want: []nested{{Label: "value"}}}, + {name: "pointer destination", response: `{"data":42}`, + newTarget: func() any { return new(*int) }, want: pointerValue}, + {name: "bare integer fallback", response: `42`, + newTarget: func() any { return new(int) }, want: 42}, + } { + t.Run(tc.name, func(t *testing.T) { + output := tc.newTarget() + original, err := jsonformat.ForType(reflect.TypeOf(output)) + if err != nil { + t.Fatal(err) + } + wrapped, err := jsonformat.WrapNonObjectSchema(original) + if err != nil { + t.Fatal(err) + } + if err := requireFormat(t, wrapped).UnmarshalStructuredOutput([]byte(tc.response), output); err != nil { + t.Fatal(err) + } + if got := reflect.ValueOf(output).Elem().Interface(); !reflect.DeepEqual(got, tc.want) { + t.Errorf("decoded result = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestUnmarshalStructuredOutputRejectsInvalidEnvelope(t *testing.T) { + for _, tc := range []struct { + name, response string + newTarget func() any + }{ + {name: "missing data", response: `{"other":42}`}, + {name: "wrongly cased data property", response: `{"DATA":42}`}, + {name: "wrong data type", response: `{"data":"bad"}`}, + {name: "null data", response: `{"data":null}`}, + {name: "wrapped multiple JSON values", response: `{"data":42} {"data":99}`}, + {name: "bare multiple JSON values", response: `42 99`}, + {name: "empty response"}, + {name: "empty object response", newTarget: func() any { return new(struct{}) }}, + {name: "bare null array", response: `null`, newTarget: func() any { return new([]string) }}, + } { + t.Run(tc.name, func(t *testing.T) { + output := any(new(int)) + if tc.newTarget != nil { + output = tc.newTarget() + } + original, err := jsonformat.ForType(reflect.TypeOf(output)) + if err != nil { + t.Fatal(err) + } + wrapped, err := jsonformat.WrapNonObjectSchema(original) + if err != nil { + t.Fatal(err) + } + if err := requireFormat(t, wrapped).UnmarshalStructuredOutput([]byte(tc.response), output); err == nil { + t.Fatalf("UnmarshalStructuredOutput(%q) succeeded, want error", tc.response) + } + }) + } +} + +func TestWrapNonObjectSchemaPreservesConstraints(t *testing.T) { + original, err := jsonformat.For[int]() + if err != nil { + t.Fatal(err) + } + schema := original.Schema.(*jsonschema.Schema) + schema.Minimum = new(float64(10)) + wrapped, err := jsonformat.WrapNonObjectSchema(original) + if err != nil { + t.Fatal(err) + } + dataSchema := wrapped.Schema.(*jsonschema.Schema).Properties["data"] + if dataSchema == nil || dataSchema.Minimum == nil || *dataSchema.Minimum != 10 { + t.Fatalf("wrapped data schema = %+v, want minimum 10", dataSchema) + } + if schema.Type != "integer" || schema.Minimum == nil || *schema.Minimum != 10 { + t.Fatalf("original schema mutated: %+v", schema) + } + + for _, tc := range []struct { + response string + wantErr bool + }{ + {response: `{"data":42}`}, + {response: `{"data":5}`, wantErr: true}, + {response: `42`}, + {response: `5`, wantErr: true}, + } { + var output int + err := requireFormat(t, wrapped).UnmarshalStructuredOutput([]byte(tc.response), &output) + if (err != nil) != tc.wantErr { + t.Errorf("UnmarshalStructuredOutput(%s) error = %v, want error %v", tc.response, err, tc.wantErr) + } + if !tc.wantErr && output != 42 { + t.Errorf("decoded result = %d, want 42", output) + } + } +} + +func TestWrapNonObjectSchemaPreservesReferences(t *testing.T) { + original := jsonformat.New("constrained", "", &jsonschema.Schema{ + Ref: "#/$defs/positive", + Defs: map[string]*jsonschema.Schema{ + "positive": {Type: "integer", Minimum: new(float64(10))}, + }, + }) + wrapped, err := jsonformat.WrapNonObjectSchema(original) + if err != nil { + t.Fatal(err) + } + var output int + if err := requireFormat(t, wrapped).UnmarshalStructuredOutput([]byte(`{"data":42}`), &output); err != nil { + t.Fatalf("decode referenced schema: %v", err) + } + if output != 42 { + t.Errorf("decoded result = %d, want 42", output) + } + if err := requireFormat(t, wrapped).UnmarshalStructuredOutput([]byte(`{"data":5}`), &output); err == nil { + t.Fatal("value below referenced minimum was accepted") + } + schema := original.Schema.(*jsonschema.Schema) + if schema.Ref != "#/$defs/positive" || schema.Defs["positive"] == nil { + t.Fatalf("original references mutated: %+v", schema) + } +} + func TestNormalizePreservesInterfaceValues(t *testing.T) { format := requireFormat(t, jsonformat.Any()) var value any = map[string]any{"status": "ok", "value": 42} diff --git a/agent/format/jsonformat/structuredoutput.go b/agent/format/jsonformat/structuredoutput.go new file mode 100644 index 00000000..7ec20ddd --- /dev/null +++ b/agent/format/jsonformat/structuredoutput.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +package jsonformat + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "slices" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/microsoft/agent-framework-go/agent" +) + +// WrapNonObjectSchema returns a response format with an object-root schema. +// A non-object JSON schema is copied into a required "data" property. The +// supplied format and its schema are not modified; [ForType] still describes +// the unwrapped value for local validation. +func WrapNonObjectSchema(format agent.ResponseFormat) (agent.ResponseFormat, error) { + if format.Kind != "json" { + return agent.ResponseFormat{}, fmt.Errorf("response format kind %q, want json", format.Kind) + } + schema, ok := format.Schema.(*jsonschema.Schema) + if !ok { + return agent.ResponseFormat{}, fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", format.Schema) + } + if schema == nil { + return agent.ResponseFormat{}, fmt.Errorf("response format schema cannot be nil") + } + if schema.Type == "object" { + return format, nil + } + + dataSchema := schema.CloneSchemas() + // Typed array results cannot be null, and some providers require a single schema type. + if slices.Contains(dataSchema.Types, "array") && + (len(dataSchema.Types) == 1 || len(dataSchema.Types) == 2 && slices.Contains(dataSchema.Types, "null")) { + dataSchema.Type = "array" + dataSchema.Types = nil + } + wrappedSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"data": dataSchema}, + Required: []string{"data"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } + wrappedSchema.Schema, dataSchema.Schema = dataSchema.Schema, "" + wrappedSchema.ID, dataSchema.ID = dataSchema.ID, "" + // Root references must still resolve after nesting the original schema. + wrappedSchema.Defs, dataSchema.Defs = dataSchema.Defs, nil + wrappedSchema.Definitions, dataSchema.Definitions = dataSchema.Definitions, nil + format.Schema = wrappedSchema + return format, nil +} + +// UnmarshalStructuredOutput validates and decodes a response formatted by +// [WrapNonObjectSchema]. It unwraps non-object results from "data", while +// accepting valid bare JSON as a fallback. Extra envelope properties are +// ignored. Object results use the first top-level JSON value; wrapped results +// require a single top-level value. [Format.Unmarshal] uses its schema as supplied. +func (f *Format) UnmarshalStructuredOutput(data []byte, target any) error { + if target == nil { + return fmt.Errorf("structured output target cannot be nil") + } + targetFormat, err := ForType(reflect.TypeOf(target)) + if err != nil { + return err + } + schema, ok := f.Schema.(*jsonschema.Schema) + if !ok || schema == nil { + return fmt.Errorf("structured output schema has type %T, want *jsonschema.Schema", f.Schema) + } + if targetFormat.Schema.(*jsonschema.Schema).Type == "object" || schema.Type != "object" { + return f.unmarshalFirstValue(data, target) + } + + dataSchema := schema.Properties["data"] + if dataSchema == nil || !slices.Contains(schema.Required, "data") { + return fmt.Errorf("structured output schema must have a required data property") + } + valueSchema := dataSchema.CloneSchemas() + valueSchema.Schema = schema.Schema + valueSchema.ID = schema.ID + valueSchema.Defs = schema.Defs + valueSchema.Definitions = schema.Definitions + valueFormat := newFormat(f.Name, f.Description, valueSchema) + + data = bytes.TrimSpace(data) + if len(data) == 0 { + return fmt.Errorf("structured output is empty") + } + if bytes.Equal(data, []byte("null")) { + return fmt.Errorf("structured output is null") + } + if data[0] != '{' { + return valueFormat.Unmarshal(data, target) + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return err + } + wrappedData, ok := envelope["data"] + if !ok { + return valueFormat.Unmarshal(data, target) + } + if bytes.Equal(bytes.TrimSpace(wrappedData), []byte("null")) { + return fmt.Errorf("structured output data is null") + } + return valueFormat.Unmarshal(wrappedData, target) +} + +func (f *Format) unmarshalFirstValue(data []byte, target any) error { + var first json.RawMessage + if err := json.NewDecoder(bytes.NewReader(data)).Decode(&first); err != nil { + return fmt.Errorf("decoding structured output: %w", err) + } + return f.Unmarshal(first, target) +} diff --git a/agent/harness/todo/todo.go b/agent/harness/todo/todo.go index d2df1e2f..a30e9c96 100644 --- a/agent/harness/todo/todo.go +++ b/agent/harness/todo/todo.go @@ -222,14 +222,14 @@ func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) if !p.suppressTodoMessage { mu := p.getSessionLock(session) mu.Lock() - st := p.loadState(session) + items := slices.Clone(p.loadState(session).Items) mu.Unlock() var todoMsg string if p.todoListMessageBuilder != nil { - todoMsg = p.todoListMessageBuilder(st.Items) + todoMsg = p.todoListMessageBuilder(items) } else { - todoMsg = formatTodoListMessage(st.Items) + todoMsg = formatTodoListMessage(items) } outMessages = append(outMessages, message.NewText(todoMsg)) } diff --git a/agent/harness/todo/todo_test.go b/agent/harness/todo/todo_test.go index 9014399a..1534d22b 100644 --- a/agent/harness/todo/todo_test.go +++ b/agent/harness/todo/todo_test.go @@ -651,6 +651,57 @@ func TestProvide_CustomTodoListMessageBuilder(t *testing.T) { } } +func TestProvide_CustomBuilderReadsStableTodoSnapshot(t *testing.T) { + ready := make(chan struct{}) + release := make(chan struct{}) + block := false + p := todo.New(&todo.Options{ + TodoListMessageBuilder: func(items []todo.Item) string { + if !block { + return "initial" + } + close(ready) + <-release + if len(items) != 1 { + return fmt.Sprintf("unexpected todo count: %d", len(items)) + } + return fmt.Sprintf("complete: %v", items[0].IsComplete) + }, + }) + opts := sessionOpts() + _, outOpts, err := invokeProvider(p, t.Context(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + callTool(t, outOpts, "todos_add", `{"Arg0":[{"title":"Task A"}]}`) + + block = true + type result struct { + messages []*message.Message + err error + } + done := make(chan result, 1) + go func() { + messages, _, err := invokeProvider(p, t.Context(), newMessages("next"), opts...) + done <- result{messages: messages, err: err} + }() + <-ready + func() { + defer close(release) + callTool(t, outOpts, "todos_complete", `{"Arg0":[{"id":1,"reason":"done"}]}`) + }() + received := <-done + if received.err != nil { + t.Fatal(received.err) + } + if len(received.messages) != 2 || received.messages[1].String() != "complete: false" { + t.Fatalf("todo summary = %v, want snapshot before completion", received.messages) + } + if remaining := p.RemainingTodos(mustSession(t, opts)); len(remaining) != 0 { + t.Fatalf("remaining todos = %v, want none", remaining) + } +} + // 22. ProvideAIContextAsync_SuppressWinsOverBuilder func TestProvide_SuppressWinsOverBuilder(t *testing.T) { var builderCalled bool diff --git a/agent/history.go b/agent/history.go index 3c07c60b..2f91c30f 100644 --- a/agent/history.go +++ b/agent/history.go @@ -4,6 +4,7 @@ package agent import ( "context" + "encoding/json" "errors" "iter" "runtime" @@ -175,7 +176,7 @@ type InMemoryHistoryProviderConfig struct { SourceID string // StateKey identifies where provider state is stored in the session. - // When empty, SourceID is used. + // When empty, SourceID is used. Whitespace-only keys are invalid. StateKey string // StateInitializer returns initial messages on first use. @@ -196,7 +197,59 @@ type InMemoryHistoryProviderConfig struct { } type inMemoryHistoryProviderState struct { - Messages []*message.Message `json:"messages,omitempty"` + Messages []*message.Message `json:"messages"` +} + +func (s inMemoryHistoryProviderState) MarshalJSON() ([]byte, error) { + var state struct { + Messages *[]*message.Message `json:"messages,omitempty"` + } + if s.Messages != nil { + state.Messages = &s.Messages + } + return json.Marshal(state) +} + +// InMemoryHistory returns the messages stored by [NewInMemoryHistoryProvider] +// under stateKey. A nil result means history is missing or its messages are nil; +// an empty, non-nil slice means an empty history is stored. An empty stateKey +// selects the default in-memory history key; whitespace-only keys panic. +// A nil session returns an error. +// The returned slice and messages are borrowed from the session. To edit history, +// build a replacement slice with any modified messages and call +// [Session.SetInMemoryHistory]. In-place edits may not survive serialization of +// a deserialized session, and appending does not update the stored slice length. +// Do not modify session history while an agent run uses the session. +func (s *Session) InMemoryHistory(stateKey string) ([]*message.Message, error) { + if s == nil { + return nil, errors.New("session must not be nil") + } + if stateKey == "" { + stateKey = defaultInMemoryHistorySourceID + } + var state inMemoryHistoryProviderState + ok, err := s.Get(stateKey, &state) + if err != nil || !ok { + return nil, err + } + return state.Messages, nil +} + +// SetInMemoryHistory replaces the messages stored by [NewInMemoryHistoryProvider] +// under stateKey. An empty stateKey selects the default in-memory history key; +// whitespace-only keys panic. +// A nil session returns an error. The session retains the supplied slice; +// call SetInMemoryHistory again after subsequent edits so they survive session +// serialization. Do not modify it while an agent run uses the session. +func (s *Session) SetInMemoryHistory(stateKey string, messages []*message.Message) error { + if s == nil { + return errors.New("session must not be nil") + } + if stateKey == "" { + stateKey = defaultInMemoryHistorySourceID + } + s.Set(stateKey, inMemoryHistoryProviderState{Messages: messages}) + return nil } type historySessionLocks struct { @@ -223,6 +276,7 @@ func (l *historySessionLocks) forOptions(options []Option) *sync.Mutex { } // NewInMemoryHistoryProvider creates a history provider that stores conversation history in the session. +// It panics if the resolved StateKey is blank. func NewInMemoryHistoryProvider(config InMemoryHistoryProviderConfig) HistoryProvider { locks := new(historySessionLocks) sourceID := config.SourceID @@ -233,6 +287,7 @@ func NewInMemoryHistoryProvider(config InMemoryHistoryProviderConfig) HistoryPro if stateKey == "" { stateKey = sourceID } + validateSessionStateKey(stateKey) historyConfig := HistoryProviderConfig{ SourceID: sourceID, ProvideOutputMessageFilter: config.ProvideOutputMessageFilter, diff --git a/agent/history_test.go b/agent/history_test.go index 98668dbe..867520d2 100644 --- a/agent/history_test.go +++ b/agent/history_test.go @@ -4,6 +4,7 @@ package agent_test import ( "context" + "encoding/json" "fmt" "slices" "sync" @@ -22,6 +23,213 @@ func invokeHistoryProviderInvoked(provider agent.HistoryProvider, ctx context.Co return provider.Invoked(ctx, agent.InvokedContext{RequestMessages: requestMessages, ResponseMessages: responseMessages, Options: options}) } +func TestSessionHistory_TryGet_WithNullSession_ReturnsError(t *testing.T) { + var session *agent.Session + if messages, err := session.InMemoryHistory(""); err == nil || messages != nil { + t.Fatalf("InMemoryHistory(nil) = (%v, %v), want error", messages, err) + } +} + +func TestSessionHistory_TryGet_WhenStateExists_ReturnsTrueAndMessages(t *testing.T) { + session := agenttest.CreateSession() + expected := []*message.Message{message.NewText("Hello"), message.NewText("Hi there!")} + expected[1].Role = message.RoleAssistant + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), expected, nil, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + + got, err := session.InMemoryHistory("") + if err != nil || len(got) != 2 || got[0] != expected[0] || got[1] != expected[1] { + t.Fatalf("history = (%v, %v), want original messages", got, err) + } +} + +func TestSessionHistory_TryGet_WhenStateDoesNotExist_ReturnsFalse(t *testing.T) { + session := agenttest.CreateSession() + got, err := session.InMemoryHistory("") + if err != nil || got != nil { + t.Fatalf("history = (%v, %v), want (nil, nil)", got, err) + } +} + +func TestSessionHistory_TryGet_WithCustomStateKey_UsesCustomKey(t *testing.T) { + session := agenttest.CreateSession() + expected := message.NewText("Test message") + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{StateKey: "custom-history-key"}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{expected}, nil, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + + got, err := session.InMemoryHistory("custom-history-key") + if err != nil || len(got) != 1 || got[0] != expected { + t.Fatalf("custom history = (%v, %v), want original message", got, err) + } +} + +func TestSessionHistory_TryGet_WithCustomStateKey_DoesNotFindDefaultKey(t *testing.T) { + session := agenttest.CreateSession() + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{message.NewText("Test message")}, nil, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + + got, err := session.InMemoryHistory("other-key") + if err != nil || got != nil { + t.Fatalf("other-key history = (%v, %v), want (nil, nil)", got, err) + } +} + +func TestSessionHistory_TryGet_WhenStateExistsWithNullMessages_ReturnsFalse(t *testing.T) { + var session agent.Session + if err := json.Unmarshal([]byte(`{"State":{"in-memory":{"messages":null}}}`), &session); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("") + if err != nil || got != nil { + t.Fatalf("null history = (%v, %v), want (nil, nil)", got, err) + } +} + +func TestSessionHistory_Set_WithNullSession_ReturnsError(t *testing.T) { + var session *agent.Session + if err := session.SetInMemoryHistory("", []*message.Message{}); err == nil { + t.Fatal("SetInMemoryHistory(nil) succeeded, want error") + } +} + +func TestSessionHistory_Set_WhenNoExistingState_CreatesNewState(t *testing.T) { + session := agenttest.CreateSession() + messages := []*message.Message{message.NewText("Hello"), message.NewText("Hi!")} + messages[1].Role = message.RoleAssistant + + if err := session.SetInMemoryHistory("", messages); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("") + if err != nil || len(got) != 2 || &got[0] != &messages[0] { + t.Fatalf("history = (%v, %v), want original messages", got, err) + } +} + +func TestSessionHistory_Set_WhenExistingState_ReplacesMessages(t *testing.T) { + session := agenttest.CreateSession() + if err := session.SetInMemoryHistory("", []*message.Message{message.NewText("Original")}); err != nil { + t.Fatal(err) + } + messages := []*message.Message{message.NewText("New message"), message.NewText("New response")} + messages[1].Role = message.RoleAssistant + + if err := session.SetInMemoryHistory("", messages); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("") + if err != nil || len(got) != 2 || &got[0] != &messages[0] { + t.Fatalf("replaced history = (%v, %v), want new messages", got, err) + } + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + loaded, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || len(loaded) != 2 || loaded[0].String() != "New message" || loaded[1].String() != "New response" { + t.Fatalf("provider history = (%v, %v), want replacement", loaded, err) + } +} + +func TestSessionHistory_Set_WithCustomStateKey_UsesCustomKey(t *testing.T) { + session := agenttest.CreateSession() + messages := []*message.Message{message.NewText("Test")} + if err := session.SetInMemoryHistory("custom-history-key", messages); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("custom-history-key") + if err != nil || len(got) != 1 || &got[0] != &messages[0] { + t.Fatalf("custom history = (%v, %v), want original message", got, err) + } + if got, err := session.InMemoryHistory(""); err != nil || got != nil { + t.Fatalf("default history = (%v, %v), want missing", got, err) + } + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{StateKey: "custom-history-key"}) + loaded, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || len(loaded) != 1 || loaded[0].String() != "Test" { + t.Fatalf("provider history = (%v, %v), want custom history", loaded, err) + } +} + +func TestSessionHistory_Set_WithEmptyList_SetsEmptyList(t *testing.T) { + session := agenttest.CreateSession() + if err := session.SetInMemoryHistory("", []*message.Message{}); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + got, err := restored.InMemoryHistory("") + if err != nil || got == nil || len(got) != 0 { + t.Fatalf("empty history = (%v, %v), want non-nil empty slice", got, err) + } +} + +func TestSessionHistory_Set_EditsToDeserializedHistorySurviveSerialization(t *testing.T) { + original := agenttest.CreateSession() + if err := original.SetInMemoryHistory("", []*message.Message{message.NewText("original")}); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var session agent.Session + if err := json.Unmarshal(data, &session); err != nil { + t.Fatal(err) + } + + history, err := session.InMemoryHistory("") + if err != nil || len(history) != 1 || history[0].String() != "original" { + t.Fatalf("loaded history = %v, err = %v; want original", messageStrings(history), err) + } + replacement := slices.Clone(history) + replacement[0] = message.NewText("updated") + replacement = append(replacement, message.NewText("added")) + if err := session.SetInMemoryHistory("", replacement); err != nil { + t.Fatal(err) + } + + data, err = json.Marshal(&session) + if err != nil { + t.Fatal(err) + } + var resumed agent.Session + if err := json.Unmarshal(data, &resumed); err != nil { + t.Fatal(err) + } + got, err := resumed.InMemoryHistory("") + if err != nil || !slices.Equal(messageStrings(got), []string{"updated", "added"}) { + t.Fatalf("resumed history = %v, err = %v; want updated and added", messageStrings(got), err) + } +} + +func TestSessionHistory_Set_NilHistoryPreservesOmittedMessages(t *testing.T) { + session := agenttest.CreateSession() + if err := session.SetInMemoryHistory("", nil); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if got := string(payload.State["in-memory"]); got != "{}" { + t.Fatalf("nil history JSON = %s, want {}", got) + } +} + func TestNewInMemoryHistoryProvider_DefaultConfig_RoundTripsHistory(t *testing.T) { provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) @@ -76,6 +284,151 @@ func TestNewHistoryProvider_PanicsWithoutSourceID(t *testing.T) { agent.NewHistoryProvider(agent.HistoryProviderConfig{}) } +func TestNewInMemoryHistoryProvider_PanicsWithBlankStateKey(t *testing.T) { + for _, tc := range []struct { + name string + config agent.InMemoryHistoryProviderConfig + }{ + {name: "explicit state key", config: agent.InMemoryHistoryProviderConfig{StateKey: " "}}, + {name: "default state key from source ID", config: agent.InMemoryHistoryProviderConfig{SourceID: "\t"}}, + } { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected blank history state key to panic at construction") + } + }() + agent.NewInMemoryHistoryProvider(tc.config) + }) + } +} + +func TestNewInMemoryHistoryProvider_AddsRequestsAndResponses(t *testing.T) { + session := agenttest.CreateSession() + if err := session.SetInMemoryHistory("", []*message.Message{message.NewText("original instructions")}); err != nil { + t.Fatal(err) + } + user := message.NewText("Hello") + contextMessage := message.NewText("additional context") + contextMessage.Role = message.RoleSystem + contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "TestSource"} + response := message.NewText("Hi there!") + response.Role = message.RoleAssistant + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{user, contextMessage}, []*message.Message{response}, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("") + if err != nil || !slices.Equal(messageStrings(got), []string{"original instructions", "Hello", "additional context", "Hi there!"}) { + t.Fatalf("history = %v, err = %v; want original and three added messages", messageStrings(got), err) + } +} + +func TestNewInMemoryHistoryProvider_InvokedWithEmptyMessages(t *testing.T) { + session := agenttest.CreateSession() + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + if err := invokeHistoryProviderInvoked(provider, t.Context(), nil, nil, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + got, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || len(got) != 0 { + t.Fatalf("history = %v, err = %v; want empty", messageStrings(got), err) + } +} + +func TestNewInMemoryHistoryProvider_InvokingReturnsAllMessages(t *testing.T) { + session := agenttest.CreateSession() + history := []*message.Message{message.NewText("Test1"), message.NewText("Test2")} + history[1].Role = message.RoleAssistant + if err := session.SetInMemoryHistory("", history); err != nil { + t.Fatal(err) + } + request := message.NewText("Hello") + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + got, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{request}, agent.WithSession(session)) + if err != nil || !slices.Equal(messageStrings(got), []string{"Test1", "Test2", "Hello"}) { + t.Fatalf("messages = %v, err = %v; want history then request", messageStrings(got), err) + } + if got[0].Source.Type != agent.SourceTypeHistoryProvider || got[1].Source.Type != agent.SourceTypeHistoryProvider || + got[2].Source.Type != message.SourceTypeExternal { + t.Fatalf("sources = [%+v %+v %+v], want history, history, external", got[0].Source, got[1].Source, got[2].Source) + } +} + +func TestNewInMemoryHistoryProvider_SetMessagesUpdatesState(t *testing.T) { + session := agenttest.CreateSession() + messages := []*message.Message{message.NewText("Hello"), message.NewText("World")} + messages[1].Role = message.RoleAssistant + if err := session.SetInMemoryHistory("", messages); err != nil { + t.Fatal(err) + } + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + got, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || !slices.Equal(messageStrings(got), []string{"Hello", "World"}) { + t.Fatalf("history = %v, err = %v; want Hello, World", messageStrings(got), err) + } +} + +func TestNewInMemoryHistoryProvider_InvokedErrorDoesNotAddMessages(t *testing.T) { + session := agenttest.CreateSession() + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{}) + if err := provider.Invoked(t.Context(), agent.InvokedContext{ + RequestMessages: []*message.Message{message.NewText("Hello")}, + Options: []agent.Option{agent.WithSession(session)}, + Err: fmt.Errorf("test exception"), + }); err != nil { + t.Fatal(err) + } + got, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || len(got) != 0 { + t.Fatalf("history after failed run = %v, err = %v; want empty", messageStrings(got), err) + } +} + +func TestNewInMemoryHistoryProvider_CustomRequestFilterOverridesDefault(t *testing.T) { + session := agenttest.CreateSession() + external := message.NewText("External message") + history := message.NewText("From history") + history.Role = message.RoleSystem + history.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "HistorySource"} + contextMessage := message.NewText("From context provider") + contextMessage.Role = message.RoleSystem + contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "ContextSource"} + response := message.NewText("Response") + response.Role = message.RoleAssistant + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{ + StoreInputRequestMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Source.Type != message.SourceTypeExternal }), nil + }, + }) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{external, history, contextMessage}, []*message.Message{response}, agent.WithSession(session)); err != nil { + t.Fatal(err) + } + got, err := session.InMemoryHistory("") + if err != nil || !slices.Equal(messageStrings(got), []string{"External message", "Response"}) { + t.Fatalf("stored history = %v, err = %v; want only external and response", messageStrings(got), err) + } +} + +func TestNewInMemoryHistoryProvider_OutputFilterSelectsUserMessages(t *testing.T) { + session := agenttest.CreateSession() + messages := []*message.Message{message.NewText("User message"), message.NewText("Assistant message"), message.NewText("System message")} + messages[1].Role = message.RoleAssistant + messages[2].Role = message.RoleSystem + if err := session.SetInMemoryHistory("", messages); err != nil { + t.Fatal(err) + } + provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{ + ProvideOutputMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleUser }), nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + if err != nil || !slices.Equal(messageStrings(got), []string{"User message"}) { + t.Fatalf("filtered history = %v, err = %v; want only user message", messageStrings(got), err) + } +} + // Agent runs may share a conversation session; concurrent stores must preserve // every turn without racing on session state. Run with -race. func TestNewInMemoryHistoryProvider_ConcurrentStoresOnSameSession_NoDataRace(t *testing.T) { @@ -220,29 +573,29 @@ func TestNewInMemoryHistoryProvider_StateInitializer_SeedsMissingState(t *testin }, }) - messages, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{message.NewText("request")}, agent.WithSession(session)) + messages, err := invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) if err != nil { t.Fatalf("unexpected error reading history: %v", err) } if initializerCalls != 1 { t.Fatalf("expected initializer to be called once, got %d", initializerCalls) } - if got := messageStrings(messages); len(got) != 2 || got[0] != "seed" || got[1] != "request" { - t.Fatalf("messages = %v, want [seed request]", got) + if got := messageStrings(messages); !slices.Equal(got, []string{"seed"}) { + t.Fatalf("messages = %v, want [seed]", got) } if messages[0].Source.ID != "k" { t.Fatalf("expected seed message source ID k, got %q", messages[0].Source.ID) } - messages, err = invokeHistoryProvider(provider, t.Context(), nil, agent.WithSession(session)) + messages, err = invokeHistoryProvider(provider, t.Context(), []*message.Message{message.NewText("request")}, agent.WithSession(session)) if err != nil { t.Fatalf("unexpected error reading initialized history: %v", err) } if initializerCalls != 1 { t.Fatalf("expected initialized state to be reused, got %d initializer calls", initializerCalls) } - if got := messageStrings(messages); len(got) != 1 || got[0] != "seed" { - t.Fatalf("initialized history = %v, want [seed]", got) + if got := messageStrings(messages); !slices.Equal(got, []string{"seed", "request"}) { + t.Fatalf("initialized history = %v, want [seed request]", got) } } @@ -323,6 +676,117 @@ func TestHistoryProvider_Invoking_DoesNotMutateProvidedMessageSlice(t *testing.T } } +func TestHistoryProvider_Invoking_ProvidesHistoryAndRequest(t *testing.T) { + history := message.NewText("History message") + request := message.NewText("Request message") + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Provide: func(context.Context, agent.InvokingContext) ([]*message.Message, error) { + return []*message.Message{history}, nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{request}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].String() != "History message" || got[1] != request { + t.Fatalf("messages = %v, want history then request", messageStrings(got)) + } +} + +func TestHistoryProvider_Invoking_HistoryPrecedesRequest(t *testing.T) { + hist1 := message.NewText("Hist1") + hist2 := message.NewText("Hist2") + hist2.Role = message.RoleAssistant + request := message.NewText("Req1") + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Provide: func(context.Context, agent.InvokingContext) ([]*message.Message, error) { + return []*message.Message{hist1, hist2}, nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{request}) + if err != nil { + t.Fatal(err) + } + if strings := messageStrings(got); !slices.Equal(strings, []string{"Hist1", "Hist2", "Req1"}) { + t.Fatalf("messages = %v, want [Hist1 Hist2 Req1]", strings) + } +} + +func TestHistoryProvider_Invoking_StampsHistorySource(t *testing.T) { + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Provide: func(context.Context, agent.InvokingContext) ([]*message.Message, error) { + return []*message.Message{message.NewText("History")}, nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Source.Type != agent.SourceTypeHistoryProvider { + t.Fatalf("history source = %v, want history-provider", got) + } +} + +func TestHistoryProvider_Invoking_WithoutOutputFilterKeepsAllHistory(t *testing.T) { + user := message.NewText("User msg") + system := message.NewText("System msg") + system.Role = message.RoleSystem + assistant := message.NewText("Assistant msg") + assistant.Role = message.RoleAssistant + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Provide: func(context.Context, agent.InvokingContext) ([]*message.Message, error) { + return []*message.Message{user, system, assistant}, nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), nil) + if err != nil { + t.Fatal(err) + } + if texts := messageStrings(got); !slices.Equal(texts, []string{"User msg", "System msg", "Assistant msg"}) { + t.Fatalf("history = %v, want all three messages", texts) + } +} + +func TestHistoryProvider_Invoking_AppliesOutputFilter(t *testing.T) { + user := message.NewText("User msg") + system := message.NewText("System msg") + system.Role = message.RoleSystem + assistant := message.NewText("Assistant msg") + assistant.Role = message.RoleAssistant + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Provide: func(context.Context, agent.InvokingContext) ([]*message.Message, error) { + return []*message.Message{user, system, assistant}, nil + }, + ProvideOutputMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleUser }), nil + }, + }) + got, err := invokeHistoryProvider(provider, t.Context(), nil) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].String() != "User msg" { + t.Fatalf("history = %v, want only User msg", messageStrings(got)) + } +} + +func TestHistoryProvider_Invoking_WithoutProvideReturnsRequest(t *testing.T) { + request := message.NewText("Hello") + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{SourceID: "history"}) + got, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{request}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0] != request { + t.Fatalf("messages = %v, want only original request", messageStrings(got)) + } +} + func TestHistoryProvider_Invoked_FiltersDoNotMutateInputSlices(t *testing.T) { historyMessage := message.NewText("history") historyMessage.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "history"} @@ -356,6 +820,122 @@ func TestHistoryProvider_Invoked_FiltersDoNotMutateInputSlices(t *testing.T) { } } +func TestHistoryProvider_Invoked_StoresFilteredRequestAndResponse(t *testing.T) { + external := message.NewText("External") + history := message.NewText("From history") + history.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "source"} + response := message.NewText("Response") + response.Role = message.RoleAssistant + var stored agent.InvokedContext + called := false + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Store: func(_ context.Context, invoked agent.InvokedContext) error { + called = true + stored = invoked + return nil + }, + }) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{external, history}, []*message.Message{response}); err != nil { + t.Fatal(err) + } + if !called || !slices.Equal(stored.RequestMessages, []*message.Message{external}) || + !slices.Equal(stored.ResponseMessages, []*message.Message{response}) { + t.Fatalf("stored request/response = %v / %v, want External / Response", messageStrings(stored.RequestMessages), messageStrings(stored.ResponseMessages)) + } +} + +func TestHistoryProvider_Invoked_SkipsStorageOnRunError(t *testing.T) { + called := false + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Store: func(context.Context, agent.InvokedContext) error { + called = true + return nil + }, + }) + err := provider.Invoked(t.Context(), agent.InvokedContext{ + RequestMessages: []*message.Message{message.NewText("msg")}, + Err: fmt.Errorf("failed"), + }) + if err != nil || called { + t.Fatalf("Invoked error = %v, Store called = %v; want nil and false", err, called) + } +} + +func TestHistoryProvider_Invoked_UsesCustomStoreFilters(t *testing.T) { + user := message.NewText("User msg") + system := message.NewText("System msg") + system.Role = message.RoleSystem + assistant := message.NewText("Response") + assistant.Role = message.RoleAssistant + toolMessage := message.NewText("Response") + toolMessage.Role = message.RoleTool + var stored agent.InvokedContext + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + StoreInputRequestMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleSystem }), nil + }, + StoreInputResponseMessageFilter: func(_ context.Context, messages []*message.Message) ([]*message.Message, error) { + return slices.DeleteFunc(messages, func(msg *message.Message) bool { return msg.Role != message.RoleAssistant }), nil + }, + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked + return nil + }, + }) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{user, system}, []*message.Message{assistant, toolMessage}); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored.RequestMessages, []*message.Message{system}) || + !slices.Equal(stored.ResponseMessages, []*message.Message{assistant}) { + t.Fatalf("stored request/response = %v / %v, want System msg / Response", messageStrings(stored.RequestMessages), messageStrings(stored.ResponseMessages)) + } +} + +func TestHistoryProvider_Invoked_DefaultFilterExcludesHistoryMessages(t *testing.T) { + external := message.NewText("External") + history := message.NewText("History") + history.Source = message.Source{Type: agent.SourceTypeHistoryProvider, ID: "src"} + contextMessage := message.NewText("Context") + contextMessage.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "src"} + var stored []*message.Message + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked.RequestMessages + return nil + }, + }) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{external, history, contextMessage}, nil); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored, []*message.Message{external, contextMessage}) { + t.Fatalf("stored requests = %v, want External and Context", messageStrings(stored)) + } +} + +func TestHistoryProvider_Invoked_PassesResponseMessagesToStore(t *testing.T) { + responses := []*message.Message{message.NewText("Resp1"), message.NewText("Resp2")} + responses[0].Role = message.RoleAssistant + responses[1].Role = message.RoleAssistant + var stored []*message.Message + provider := agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: "history", + Store: func(_ context.Context, invoked agent.InvokedContext) error { + stored = invoked.ResponseMessages + return nil + }, + }) + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{message.NewText("msg")}, responses); err != nil { + t.Fatal(err) + } + if !slices.Equal(stored, responses) || &stored[0] != &responses[0] { + t.Fatalf("stored responses = %v, want original slice", messageStrings(stored)) + } +} + func TestNewInMemoryHistoryProvider_SkipStoreRequestMessages(t *testing.T) { provider := agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{ SourceID: "k", diff --git a/agent/options.go b/agent/options.go index bb4b61ea..ea94b346 100644 --- a/agent/options.go +++ b/agent/options.go @@ -104,7 +104,11 @@ func WithServiceID(id string) Option { return serviceIDOpt(id) } -// WithStructuredOutput sets the variable pointed to by v to the structured output produced by the agent. +// WithStructuredOutput sets the variable pointed to by v to the structured +// output produced by the agent. Providers that require object-root schemas wrap +// non-object results in a "data" property on the wire, but assign the unwrapped +// value to v. The response text retains the provider's JSON. Use +// [WithResponseFormat] to pass an explicit schema without this wrapping. func WithStructuredOutput(v any) Option { return structuredOutputOpt{v} } diff --git a/agent/response_test.go b/agent/response_test.go index af57bca8..0a4ad7d4 100644 --- a/agent/response_test.go +++ b/agent/response_test.go @@ -4,8 +4,10 @@ package agent_test import ( "encoding/json" + "fmt" "maps" "reflect" + "slices" "testing" "time" @@ -13,6 +15,91 @@ import ( "github.com/microsoft/agent-framework-go/message" ) +func TestResponseUpdate_TextGetUsesAllTextContent(t *testing.T) { + first := &message.TextContent{Text: "text-1"} + update := &agent.ResponseUpdate{ + Role: message.RoleUser, + Contents: message.Contents{ + &message.DataContent{Data: "aGVsbG8=", MediaType: "audio/mpeg"}, + &message.DataContent{Data: "aGVsbG8=", MediaType: "image/png"}, + &message.FunctionCallContent{CallID: "callId1", Name: "fc1"}, + first, + &message.TextContent{Text: "text-2"}, + &message.FunctionResultContent{CallID: "callId1", Result: "result"}, + }, + } + if got := update.String(); got != "text-1text-2" { + t.Fatalf("String() = %q, want text-1text-2", got) + } + first.Text = "text-3" + if update.Contents[3] != first || update.String() != "text-3text-2" { + t.Fatalf("changed text = %q, want text-3text-2", update.String()) + } +} + +func TestResponseUpdate_JsonSerializationRoundtrips(t *testing.T) { + createdAt := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + original := &agent.ResponseUpdate{ + AuthorName: "author", + Role: message.RoleAssistant, + Contents: message.Contents{ + &message.TextContent{Text: "text-1"}, + &message.DataContent{Data: "aGVsbG8=", MediaType: "image/png"}, + &message.FunctionCallContent{CallID: "callId1", Name: "fc1"}, + &message.DataContent{Data: "ZGF0YQ==", MediaType: "text/plain"}, + &message.TextContent{Text: "text-2"}, + }, + RawRepresentation: new(int), + ResponseID: "id", + MessageID: "messageid", + CreatedAt: createdAt, + AdditionalProperties: map[string]any{"key": "value"}, + ContinuationToken: "AQID", + } + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var result agent.ResponseUpdate + if err := json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if len(result.Contents) != 5 || result.String() != "text-1text-2" { + t.Fatalf("roundtripped contents = %v, want 5 items and both text fragments", result.Contents) + } + if _, ok := result.Contents[0].(*message.TextContent); !ok { + t.Fatalf("first content = %T, want text", result.Contents[0]) + } + if content, ok := result.Contents[1].(*message.DataContent); !ok || content.Data != "aGVsbG8=" || content.MediaType != "image/png" { + t.Fatalf("second content = %v, want image data", result.Contents[1]) + } + if content, ok := result.Contents[2].(*message.FunctionCallContent); !ok || content.Name != "fc1" { + t.Fatalf("third content = %v, want function call fc1", result.Contents[2]) + } + if content, ok := result.Contents[3].(*message.DataContent); !ok || content.Data != "ZGF0YQ==" || content.MediaType != "text/plain" { + t.Fatalf("fourth content = %v, want text data", result.Contents[3]) + } + if _, ok := result.Contents[4].(*message.TextContent); !ok { + t.Fatalf("fifth content = %T, want text", result.Contents[4]) + } + if result.AuthorName != "author" || result.Role != message.RoleAssistant || + result.ResponseID != "id" || result.MessageID != "messageid" || + !result.CreatedAt.Equal(createdAt) || result.AdditionalProperties["key"] != "value" || + result.ContinuationToken != "AQID" { + t.Fatalf("roundtripped metadata = %+v", result) + } +} + +func TestResponse_ToStringOutputsText(t *testing.T) { + text := "This is a test.\nIt's multiple lines." + response := &agent.Response{Messages: []*message.Message{ + {Role: message.RoleAssistant, Contents: message.Contents{&message.TextContent{Text: text}}}, + }} + if got := response.String(); got != text { + t.Fatalf("String() = %q, want %q", got, text) + } +} + func TestResponse_Update_NilUpdate(t *testing.T) { resp := &agent.Response{} resp.Update(nil) @@ -1180,3 +1267,235 @@ func TestResponse_ToUpdates_PropagatesContinuationToken(t *testing.T) { t.Errorf("expected ContinuationToken tok-123, got %q", roundTripped.ContinuationToken) } } + +func collectPortedResponseUpdates(t *testing.T, updates []*agent.ResponseUpdate, streaming bool) *agent.Response { + t.Helper() + if streaming { + stream := agent.ResponseStream(func(yield func(*agent.ResponseUpdate, error) bool) { + for _, update := range updates { + if !yield(update, nil) { + return + } + } + }) + response, err := stream.Collect() + if err != nil { + t.Fatal(err) + } + return response + } + response := new(agent.Response) + for _, update := range updates { + response.Update(update) + } + response.Coalesce() + return response +} + +func TestResponse_Collect_CombinesMessagesMetadataAndUsage(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + createdAt := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC) + updates := []*agent.ResponseUpdate{ + {AgentID: "agentId", ResponseID: "someResponse", MessageID: "12345", Role: message.RoleAssistant, CreatedAt: createdAt, + Contents: message.Contents{&message.TextContent{Text: "Hello"}}}, + {AuthorName: "Someone", Role: message.Role("human"), AdditionalProperties: map[string]any{"a": "b"}, + Contents: message.Contents{&message.TextContent{Text: ", "}}}, + {CreatedAt: createdAt.AddDate(1, 0, 0), AdditionalProperties: map[string]any{"c": "d"}, + Contents: message.Contents{&message.TextContent{Text: "world!"}}}, + {Contents: message.Contents{&message.UsageContent{Details: message.UsageDetails{InputTokenCount: 1, OutputTokenCount: 2}}}}, + {Contents: message.Contents{&message.UsageContent{Details: message.UsageDetails{InputTokenCount: 4, OutputTokenCount: 5}}}}, + } + response := collectPortedResponseUpdates(t, updates, streaming) + if response.AgentID != "agentId" || response.ID != "someResponse" || !response.CreatedAt.Equal(createdAt) { + t.Fatalf("response metadata = %+v", response) + } + if got := response.Usage(); got.InputTokenCount != 5 || got.OutputTokenCount != 7 { + t.Fatalf("usage = %+v, want input 5 output 7", got) + } + if len(response.Messages) != 2 { + t.Fatalf("messages = %d, want 2", len(response.Messages)) + } + first, second := response.Messages[0], response.Messages[1] + if first.ID != "12345" || first.Role != message.RoleAssistant || first.AuthorName != "" || first.String() != "Hello" || + second.ID != "" || second.Role != message.Role("human") || second.AuthorName != "Someone" || second.String() != ", world!" { + t.Fatalf("messages = %+v", response.Messages) + } + if !maps.Equal(response.AdditionalProperties, map[string]any{"a": "b", "c": "d"}) || response.String() != "Hello\n, world!" { + t.Fatalf("response properties/text = %v / %q", response.AdditionalProperties, response.String()) + } + }) + } +} + +func TestResponse_Collect_CoalescesTextAcrossDataGaps(t *testing.T) { + for _, streaming := range []bool{false, true} { + for sequences := 1; sequences <= 3; sequences++ { + for length := 1; length <= 3; length++ { + for gapLength := 1; gapLength <= 3; gapLength++ { + for _, ends := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%v/sequences=%d/length=%d/gap=%d/ends=%v", streaming, sequences, length, gapLength, ends), func(t *testing.T) { + var updates []*agent.ResponseUpdate + var expected []string + addGap := func() { + for range gapLength { + updates = append(updates, &agent.ResponseUpdate{Contents: message.Contents{ + &message.DataContent{Data: "aGVsbG8=", MediaType: "image/png"}, + }}) + } + } + if ends { + addGap() + } + for seq := range sequences { + var text string + for i := range length { + part := fmt.Sprintf("%c%d", 'A'+seq, i) + updates = append(updates, &agent.ResponseUpdate{Contents: message.Contents{&message.TextContent{Text: part}}}) + text += part + } + expected = append(expected, text) + if seq < sequences-1 { + addGap() + } + } + if ends { + addGap() + } + response := collectPortedResponseUpdates(t, updates, streaming) + if len(response.Messages) != 1 { + t.Fatalf("messages = %d, want 1", len(response.Messages)) + } + wantContents := sequences + gapLength*(sequences-1) + if ends { + wantContents += 2 * gapLength + } + var texts []string + for _, content := range response.Messages[0].Contents { + if text, ok := content.(*message.TextContent); ok { + texts = append(texts, text.Text) + } + } + if len(response.Messages[0].Contents) != wantContents || !slices.Equal(texts, expected) { + t.Fatalf("contents = %v, texts = %v; want %d contents and %v", response.Messages[0].Contents, texts, wantContents, expected) + } + }) + } + } + } + } + } +} + +func TestResponse_Collect_CoalescesTextAndReasoningSeparately(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + var updates []*agent.ResponseUpdate + for _, part := range []struct { + text string + reasoning bool + }{ + {"A", false}, {"B", false}, {"C", false}, + {"D", true}, {"E", true}, {"F", true}, + {"G", false}, {"H", false}, + {"I", true}, {"J", true}, + {"K", false}, {"L", true}, + {"M", false}, {"N", false}, + {"O", true}, {"P", true}, + } { + var content message.Content = &message.TextContent{Text: part.text} + if part.reasoning { + content = &message.TextReasoningContent{Text: part.text} + } + updates = append(updates, &agent.ResponseUpdate{Contents: message.Contents{content}}) + } + response := collectPortedResponseUpdates(t, updates, streaming) + if len(response.Messages) != 1 { + t.Fatalf("messages = %d, want 1", len(response.Messages)) + } + contents := response.Messages[0].Contents + if len(contents) != 8 { + t.Fatalf("contents = %v, want 8 alternating text and reasoning runs", contents) + } + for i, want := range []string{"ABC", "DEF", "GH", "IJ", "K", "L", "MN", "OP"} { + if i%2 == 0 { + text, ok := contents[i].(*message.TextContent) + if !ok || text.Text != want { + t.Errorf("contents[%d] = %v, want text %q", i, contents[i], want) + } + } else { + reasoning, ok := contents[i].(*message.TextReasoningContent) + if !ok || reasoning.Text != want { + t.Errorf("contents[%d] = %v, want reasoning %q", i, contents[i], want) + } + } + } + }) + } +} + +func TestResponse_Collect_ExtractsTextAndUsageContent(t *testing.T) { + response := collectPortedResponseUpdates(t, []*agent.ResponseUpdate{ + {Contents: message.Contents{&message.TextContent{Text: "Hello, "}}}, + {Contents: message.Contents{&message.TextContent{Text: "world!"}}}, + {Contents: message.Contents{&message.UsageContent{Details: message.UsageDetails{TotalTokenCount: 42}}}}, + }, true) + if len(response.Messages) != 1 || response.String() != "Hello, world!" || response.Usage().TotalTokenCount != 42 { + t.Fatalf("response = %+v, want one text message and 42 tokens", response) + } + if len(response.Messages[0].Contents) != 2 { + t.Fatalf("contents = %v, want text plus usage content", response.Messages[0].Contents) + } +} + +func TestResponse_Collect_AlternativeTimestamps(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + early := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + updates := []*agent.ResponseUpdate{ + {Role: message.RoleTool, MessageID: "4", CreatedAt: early, Contents: message.Contents{&message.TextContent{Text: "a"}}}, + {CreatedAt: time.Unix(0, 0), Contents: message.Contents{&message.TextContent{Text: "b"}}}, + {CreatedAt: early.Add(time.Hour), Contents: message.Contents{&message.TextContent{Text: "c"}}}, + {CreatedAt: early, Contents: message.Contents{&message.TextContent{Text: "d"}}}, + {CreatedAt: early.Add(2 * time.Hour), Contents: message.Contents{&message.TextContent{Text: "e"}}}, + {CreatedAt: time.Unix(0, 0), Contents: message.Contents{&message.TextContent{Text: "f"}}}, + {Contents: message.Contents{&message.TextContent{Text: "g"}}}, + } + response := collectPortedResponseUpdates(t, updates, streaming) + if len(response.Messages) != 1 || response.Messages[0].String() != "abcdefg" || + response.Messages[0].Role != message.RoleTool || !response.Messages[0].CreatedAt.Equal(early) || !response.CreatedAt.Equal(early) { + t.Fatalf("response = %+v, want single tool message at %v", response, early) + } + }) + } +} + +func TestResponse_Collect_TimestampFolding(t *testing.T) { + first := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + second := first.Add(time.Hour) + for _, tc := range []struct { + name string + a, b, want time.Time + }{ + {name: "both absent"}, + {name: "first only", a: first, want: first}, + {name: "second only", b: first, want: first}, + {name: "first wins over later", a: first, b: second, want: first}, + {name: "first wins over earlier", a: second, b: first, want: second}, + {name: "first wins over epoch", a: first, b: time.Unix(0, 0), want: first}, + {name: "epoch ignored", a: time.Unix(0, 0), b: first, want: first}, + } { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + response := collectPortedResponseUpdates(t, []*agent.ResponseUpdate{ + {Role: message.RoleAssistant, CreatedAt: tc.a, Contents: message.Contents{&message.TextContent{Text: "a"}}}, + {CreatedAt: tc.b, Contents: message.Contents{&message.TextContent{Text: "b"}}}, + }, streaming) + if len(response.Messages) != 1 || response.Messages[0].String() != "ab" || + !response.Messages[0].CreatedAt.Equal(tc.want) || !response.CreatedAt.Equal(tc.want) { + t.Fatalf("response = %+v, want single 'ab' message at %v", response, tc.want) + } + }) + } + } +} diff --git a/agent/session.go b/agent/session.go index a4c5457a..0eeae6e1 100644 --- a/agent/session.go +++ b/agent/session.go @@ -4,7 +4,12 @@ package agent import ( "encoding/json" + "maps" "slices" + "strings" + "sync/atomic" + + "github.com/microsoft/agent-framework-go/internal/concurrent" ) // Session contains the state of a specific conversation with an agent which may include: @@ -23,10 +28,37 @@ import ( // To support conversations that may need to survive application restarts or separate service requests, // a Session can be serialized and deserialized directly with encoding/json, so that it can be saved in a // persistent store. +// Session methods support concurrent access to the state map and service ID. A +// JSON snapshot can reflect writes from different points during marshaling; +// UnmarshalJSON replaces the state and service ID together after decoding. +// Values stored in the session are not cloned; callers must coordinate mutations +// to their own values. Call Set after editing a value read from a deserialized +// session to persist the edit. Do not copy a Session after first use. Marshal +// a *Session, not a Session value, to preserve its JSON format. type Session struct { + current atomic.Pointer[sessionState] +} + +type sessionState struct { serviceID string + state *concurrent.Map[string, *stateValue] +} - state map[string]*stateValue +func (s *Session) stateForWrite() *sessionState { + if current := s.current.Load(); current != nil { + return current + } + initial := &sessionState{state: new(concurrent.Map[string, *stateValue])} + if s.current.CompareAndSwap(nil, initial) { + return initial + } + return s.current.Load() +} + +func validateSessionStateKey(key string) { + if strings.TrimSpace(key) == "" { + panic("session state key cannot be blank") + } } // Get attempts to read the value associated with key into value. @@ -36,11 +68,17 @@ type Session struct { // requested type. // // value must be a non-nil pointer to the desired destination type. +// It panics if key is empty or whitespace. func (s *Session) Get(key string, value any) (bool, error) { + validateSessionStateKey(key) if s == nil { return false, nil } - wrapped, ok := s.state[key] + current := s.current.Load() + if current == nil { + return false, nil + } + wrapped, ok := current.state.Load(key) if !ok { return false, nil } @@ -49,7 +87,9 @@ func (s *Session) Get(key string, value any) (bool, error) { // Set stores a value in the session state under the given key. // If the key already exists, its value is overwritten. +// It panics if key is empty or whitespace. func (s *Session) Set(key string, value any) { + validateSessionStateKey(key) if s == nil { return } @@ -58,21 +98,21 @@ func (s *Session) Set(key string, value any) { wrapped = newStateValue(value) } - if s.state == nil { - s.state = make(map[string]*stateValue) - } - s.state[key] = wrapped + s.stateForWrite().state.Store(key, wrapped) } // Delete removes the value with the given key and reports whether it existed. +// It panics if key is empty or whitespace. func (s *Session) Delete(key string) bool { + validateSessionStateKey(key) if s == nil { return false } - _, ok := s.state[key] - if ok { - delete(s.state, key) + current := s.current.Load() + if current == nil { + return false } + _, ok := current.state.LoadAndDelete(key) return ok } @@ -83,7 +123,10 @@ func (s *Session) ServiceID() string { if s == nil { return "" } - return s.serviceID + if current := s.current.Load(); current != nil { + return current.serviceID + } + return "" } // SetServiceID sets the provider-specific identifier associated with the session. @@ -91,17 +134,32 @@ func (s *Session) SetServiceID(id string) { if s == nil { return } - s.serviceID = id + for { + current := s.current.Load() + var state *concurrent.Map[string, *stateValue] + if current == nil { + state = new(concurrent.Map[string, *stateValue]) + } else { + state = current.state + } + next := &sessionState{serviceID: id, state: state} + if s.current.CompareAndSwap(current, next) { + return + } + } } -func (s Session) MarshalJSON() ([]byte, error) { +func (s *Session) MarshalJSON() ([]byte, error) { + if s == nil { + return []byte("null"), nil + } + tmp := sessionData{ - ServiceID: s.serviceID, + State: make(map[string]*stateValue), } - if s.state == nil { - tmp.State = make(map[string]*stateValue) - } else { - tmp.State = s.state + if current := s.current.Load(); current != nil { + tmp.ServiceID = current.serviceID + maps.Insert(tmp.State, current.state.All()) } return json.Marshal(tmp) } @@ -115,12 +173,11 @@ func (s *Session) UnmarshalJSON(data []byte) error { return err } - state := make(map[string]*stateValue, len(tmp.State)) + state := new(concurrent.Map[string, *stateValue]) for key, raw := range tmp.State { - state[key] = &stateValue{raw: slices.Clone(raw)} + state.Store(key, &stateValue{raw: slices.Clone(raw)}) } - s.serviceID = tmp.ServiceID - s.state = state + s.current.Store(&sessionState{serviceID: tmp.ServiceID, state: state}) return nil } diff --git a/agent/session_test.go b/agent/session_test.go index d17280b5..da6d6617 100644 --- a/agent/session_test.go +++ b/agent/session_test.go @@ -4,6 +4,9 @@ package agent_test import ( "encoding/json" + "fmt" + "strings" + "sync" "testing" "github.com/microsoft/agent-framework-go/agent" @@ -18,6 +21,270 @@ type nullablePerson struct { Name string } +type sessionMarshalCallback struct { + session *agent.Session +} + +func (v sessionMarshalCallback) MarshalJSON() ([]byte, error) { + v.session.Set("from-marshaler", "saved") + return []byte(`"callback"`), nil +} + +type sessionUnmarshalCallback struct { + session *agent.Session + value string +} + +func (v *sessionUnmarshalCallback) UnmarshalJSON(data []byte) error { + v.session.Set("from-unmarshaler", "saved") + return json.Unmarshal(data, &v.value) +} + +func TestSessionState_ConcurrentReadsAndWrites(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key", "initial") + const operations = 200 + start := make(chan struct{}) + errs := make(chan error, operations) + var wg sync.WaitGroup + wg.Add(operations) + for i := range operations { + go func(index int) { + defer wg.Done() + <-start + if index%2 == 0 { + var got string + if ok, err := session.Get("key", &got); err != nil || !ok || got == "" { + errs <- fmt.Errorf("Get(key) = (%q, %v, %v)", got, ok, err) + } + return + } + session.Set("key", fmt.Sprintf("value%d", index)) + }(i) + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + var got string + if ok, err := session.Get("key", &got); err != nil || !ok || got == "" { + t.Fatalf("final Get(key) = (%q, %v, %v)", got, ok, err) + } +} + +func TestSessionState_ConcurrentWritesAndSerialize(t *testing.T) { + session := agenttest.CreateSession() + session.Set("shared", "initial") + const operations = 100 + start := make(chan struct{}) + errs := make(chan error, operations) + var wg sync.WaitGroup + wg.Add(operations) + for i := range operations { + go func(index int) { + defer wg.Done() + <-start + session.Set("shared", fmt.Sprintf("value%d", index)) + data, err := json.Marshal(session) + if err != nil { + errs <- err + return + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil { + errs <- err + } else if len(payload.State["shared"]) == 0 { + errs <- fmt.Errorf("serialized state missing shared key: %s", data) + } + }(i) + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + var got string + if ok, err := session.Get("shared", &got); err != nil || !ok || got == "" { + t.Fatalf("final Get(shared) = (%q, %v, %v)", got, ok, err) + } +} + +func TestSession_ConcurrentStateAndJSONReplacement(t *testing.T) { + session := agenttest.CreateSession() + const operations = 100 + start := make(chan struct{}) + errs := make(chan error, 2*operations) + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + <-start + for i := range operations { + session.SetServiceID(fmt.Sprintf("service%d", i)) + session.Set("key", fmt.Sprintf("value%d", i)) + session.Delete("other") + } + }() + go func() { + defer wg.Done() + <-start + for range operations { + if err := json.Unmarshal([]byte(`{"State":{"other":"restored"},"ServiceID":"restored"}`), session); err != nil { + errs <- err + } + } + }() + go func() { + defer wg.Done() + <-start + for range operations { + _ = session.ServiceID() + var value string + if _, err := session.Get("other", &value); err != nil { + errs <- err + } + data, err := json.Marshal(session) + if err != nil { + errs <- err + continue + } + if !json.Valid(data) { + errs <- fmt.Errorf("invalid session JSON: %s", data) + } + } + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + +func TestSession_ConcurrentJSONReplacementKeepsServiceIDAndStateTogether(t *testing.T) { + var session agent.Session + first := []byte(`{"State":{"first":1},"ServiceID":"first"}`) + second := []byte(`{"State":{"second":2},"ServiceID":"second"}`) + if err := json.Unmarshal(first, &session); err != nil { + t.Fatal(err) + } + + const operations = 300 + start := make(chan struct{}) + errs := make(chan error, operations) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + for i := range operations { + data := first + if i%2 == 0 { + data = second + } + if err := json.Unmarshal(data, &session); err != nil { + errs <- err + } + } + }() + go func() { + defer wg.Done() + <-start + for range operations { + data, err := json.Marshal(&session) + if err != nil { + errs <- err + continue + } + var payload struct { + State map[string]json.RawMessage + ServiceID string + } + if err := json.Unmarshal(data, &payload); err != nil { + errs <- err + continue + } + if len(payload.State) != 1 || len(payload.State[payload.ServiceID]) == 0 { + errs <- fmt.Errorf("session snapshot mixed state and service ID: %s", data) + } + } + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + +func TestSession_ConcurrentFirstWritesPreserveState(t *testing.T) { + var session agent.Session + const keys = 100 + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(keys + 1) + for i := range keys { + go func(index int) { + defer wg.Done() + <-start + session.Set(fmt.Sprintf("key%d", index), fmt.Sprintf("value%d", index)) + }(i) + } + go func() { + defer wg.Done() + <-start + session.SetServiceID("service") + }() + close(start) + wg.Wait() + + if got := session.ServiceID(); got != "service" { + t.Fatalf("ServiceID() = %q, want service", got) + } + for i := range keys { + var got string + want := fmt.Sprintf("value%d", i) + if ok, err := session.Get(fmt.Sprintf("key%d", i), &got); err != nil || !ok || got != want { + t.Fatalf("Get(key%d) = (%q, %v, %v), want (%q, true, nil)", i, got, ok, err, want) + } + } +} + +func TestSession_JSONCallbacksMayAccessSession(t *testing.T) { + session := agenttest.CreateSession() + session.Set("callback", sessionMarshalCallback{session: session}) + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if string(payload.State["callback"]) != `"callback"` || payload.State["from-marshaler"] != nil { + t.Fatalf("serialized snapshot = %s, want callback without later write", data) + } + var saved string + if ok, err := session.Get("from-marshaler", &saved); err != nil || !ok || saved != "saved" { + t.Fatalf("reentrant Set not retained: (%q, %v, %v)", saved, ok, err) + } + + var decoded agent.Session + if err := json.Unmarshal([]byte(`{"State":{"callback":"decoded"}}`), &decoded); err != nil { + t.Fatal(err) + } + v := sessionUnmarshalCallback{session: &decoded} + if ok, err := decoded.Get("callback", &v); err != nil || !ok || v.value != "decoded" { + t.Fatalf("reentrant Get = (%q, %v, %v)", v.value, ok, err) + } + if ok, err := decoded.Get("from-unmarshaler", &saved); err != nil || !ok || saved != "saved" { + t.Fatalf("reentrant UnmarshalJSON Set not retained: (%q, %v, %v)", saved, ok, err) + } +} + func TestSessionState_Get_NonexistentKey_ReturnsNotFound(t *testing.T) { session := agenttest.CreateSession() var v string @@ -177,6 +444,34 @@ func TestSessionState_Get_InvalidDestinationReturnsError(t *testing.T) { } } +func TestSessionState_BlankKeysPanic(t *testing.T) { + session := agenttest.CreateSession() + for _, key := range []string{"", " ", "\t"} { + for name, call := range map[string]func(){ + "Get": func() { var value string; _, _ = session.Get(key, &value) }, + "Set": func() { session.Set(key, "value") }, + "Delete": func() { session.Delete(key) }, + } { + t.Run(fmt.Sprintf("%s/%q", name, key), func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected blank session state key to panic") + } + }() + call() + }) + } + } + session.Set(" key ", "value") + var value string + if ok, err := session.Get(" key ", &value); err != nil || !ok || value != "value" { + t.Fatalf("nonblank key = (%q, %v, %v), want value", value, ok, err) + } + if ok, err := session.Get("key", &value); err != nil || ok { + t.Fatalf("whitespace was trimmed from state key: (%v, %v)", ok, err) + } +} + func TestSession_MarshalJSON_EmptyState(t *testing.T) { session := agenttest.CreateSession() data, err := json.Marshal(session) @@ -277,7 +572,7 @@ func TestSession_UnmarshalJSON_IntoZeroValueSession(t *testing.T) { func TestSession_MarshalJSON_ZeroValueSession(t *testing.T) { var session agent.Session - data, err := json.Marshal(session) + data, err := json.Marshal(&session) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -347,9 +642,8 @@ func mustUnmarshalJSON(t *testing.T, data []byte, session *agent.Session) { } func TestSession_MarshalAfterGet_PreservesUnreadFields(t *testing.T) { - // A Session round-trips through encoding/json for persistence. Reading a key - // with a partial/narrower struct must not cause the unread fields to be - // dropped when the session is later re-serialized. + // Reading a key into a narrower struct must not discard fields that were + // never decoded when the session is saved again. const original = `{"State":{"k":{"A":1,"B":2}},"ServiceID":""}` var s agent.Session if err := json.Unmarshal([]byte(original), &s); err != nil { @@ -369,3 +663,482 @@ func TestSession_MarshalAfterGet_PreservesUnreadFields(t *testing.T) { t.Errorf("round-trip corrupted after Get:\n got: %s\n want: %s", out, original) } } + +func TestSession_MarshalAfterGet_RequiresSetToPersistCachedObjectEdits(t *testing.T) { + var session agent.Session + if err := json.Unmarshal([]byte(`{"State":{"person":{"Name":"Ada"}}}`), &session); err != nil { + t.Fatal(err) + } + var value *person + if ok, err := session.Get("person", &value); err != nil || !ok || value == nil { + t.Fatalf("Get(person) = (%v, %v, %v), want decoded person", value, ok, err) + } + value.Name = "Grace" + data, err := json.Marshal(&session) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "Grace") || !strings.Contains(string(data), "Ada") { + t.Fatalf("cached edit replaced original raw JSON without Set: %s", data) + } + session.Set("person", value) + data, err = json.Marshal(&session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + var got *person + if ok, err := restored.Get("person", &got); err != nil || !ok || got == nil || got.Name != "Grace" { + t.Fatalf("restored person = (%+v, %v, %v), want Grace", got, ok, err) + } +} + +func TestSessionStateBag_GetValue_WithExistingKey_ReturnsValue(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "value1") + + var result string + ok, err := session.Get("key1", &result) + if err != nil || !ok || result != "value1" { + t.Fatalf("Get(key1) = (%q, %v, %v), want (value1, true, nil)", result, ok, err) + } +} + +func TestSessionStateBag_GetValue_WithNonexistentKey_ReturnsNull(t *testing.T) { + session := agenttest.CreateSession() + + var result *string + ok, err := session.Get("nonexistent", &result) + if err != nil || ok || result != nil { + t.Fatalf("Get(nonexistent) = (%v, %v, %v), want (nil, false, nil)", result, ok, err) + } +} + +func TestSessionStateBag_TryGetValue_WithExistingKey_ReturnsTrueAndValue(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "value1") + + var result string + found, err := session.Get("key1", &result) + if err != nil || !found || result != "value1" { + t.Fatalf("Get(key1) = (%q, %v, %v), want (value1, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_TryGetValue_WithNonexistentKey_ReturnsFalseAndNull(t *testing.T) { + session := agenttest.CreateSession() + + var result *string + found, err := session.Get("nonexistent", &result) + if err != nil || found || result != nil { + t.Fatalf("Get(nonexistent) = (%v, %v, %v), want (nil, false, nil)", result, found, err) + } +} + +func TestSessionStateBag_SetValue_OverwriteWithNull_ReturnsNull(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "value1") + + var value *string + session.Set("key1", value) + + var result *string + found, err := session.Get("key1", &result) + if err != nil || !found || result != nil { + t.Fatalf("Get(key1) = (%v, %v, %v), want (nil, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_SetValue_OverwriteNullWithValue_ReturnsValue(t *testing.T) { + session := agenttest.CreateSession() + var value *string + session.Set("key1", value) + + session.Set("key1", "newValue") + + var result string + found, err := session.Get("key1", &result) + if err != nil || !found || result != "newValue" { + t.Fatalf("Get(key1) = (%q, %v, %v), want (newValue, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_TryRemoveValue_DoesNotAffectOtherKeys(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "value1") + session.Set("key2", "value2") + + session.Delete("key1") + + var removed string + if found, err := session.Get("key1", &removed); err != nil || found { + t.Fatalf("removed key found=%v, err=%v", found, err) + } + var remaining string + if found, err := session.Get("key2", &remaining); err != nil || !found || remaining != "value2" { + t.Fatalf("remaining key = (%q, %v, %v), want (value2, true, nil)", remaining, found, err) + } + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil || len(payload.State) != 1 { + t.Fatalf("remaining state = %v, err=%v; want one key", payload.State, err) + } +} + +func TestSessionStateBag_TryRemoveValue_ThenSetValue_Works(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "original") + + session.Delete("key1") + session.Set("key1", "replacement") + + var result string + found, err := session.Get("key1", &result) + if err != nil || !found || result != "replacement" { + t.Fatalf("Get(key1) = (%q, %v, %v), want (replacement, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_Serialize_EmptyStateBag_ReturnsEmptyObject(t *testing.T) { + session := agenttest.CreateSession() + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil || string(payload.State) != "{}" { + t.Fatalf("serialized State = %s, err=%v; want {}", payload.State, err) + } +} + +func TestSessionStateBag_Serialize_WithStringValue_ReturnsJsonWithValue(t *testing.T) { + session := agenttest.CreateSession() + session.Set("stringKey", "stringValue") + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if string(payload.State["stringKey"]) != `"stringValue"` { + t.Fatalf("serialized State[stringKey] = %s", payload.State["stringKey"]) + } +} + +func TestSessionStateBag_Deserialize_FromJsonDocument_ReturnsEmptyStateBag(t *testing.T) { + var session agent.Session + + if err := json.Unmarshal([]byte(`{"State":{}}`), &session); err != nil { + t.Fatal(err) + } + + var value string + if found, err := session.Get("nonexistent", &value); err != nil || found { + t.Fatalf("Get(nonexistent) = (%v, %v), want (false, nil)", found, err) + } +} + +func TestSessionStateBag_SerializeDeserialize_WithStringValue_Roundtrips(t *testing.T) { + original := agenttest.CreateSession() + original.Set("stringKey", "stringValue") + + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + + var result string + if found, err := restored.Get("stringKey", &result); err != nil || !found || result != "stringValue" { + t.Fatalf("restored value = (%q, %v, %v), want (stringValue, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_SerializeDeserialize_WithComplexObject_Roundtrips(t *testing.T) { + type animal struct { + ID int + FullName string + Species string + } + original := agenttest.CreateSession() + original.Set("animal", animal{ID: 4, FullName: "Polly", Species: "Bear"}) + + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + + var result animal + if found, err := restored.Get("animal", &result); err != nil || !found || result != (animal{ID: 4, FullName: "Polly", Species: "Bear"}) { + t.Fatalf("restored animal = (%+v, %v, %v)", result, found, err) + } +} + +func TestSessionStateBag_JsonSerializerRoundtrip_WithStringValue_PreservesData(t *testing.T) { + session := agenttest.CreateSession() + session.Set("greeting", "hello world") + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + + var result string + if found, err := restored.Get("greeting", &result); err != nil || !found || result != "hello world" { + t.Fatalf("restored greeting = (%q, %v, %v)", result, found, err) + } +} + +func TestSessionStateBag_TryGetValue_WithDifferentTypeAfterDeserializedRead_ReturnsFalse(t *testing.T) { + original := agenttest.CreateSession() + original.Set("key1", "hello") + data, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + var cached string + if found, err := restored.Get("key1", &cached); err != nil || !found || cached != "hello" { + t.Fatalf("first read = (%q, %v, %v)", cached, found, err) + } + + var result person + if found, err := restored.Get("key1", &result); err != nil || found || result != (person{}) { + t.Fatalf("mismatched read = (%+v, %v, %v), want zero, false, nil", result, found, err) + } +} + +func TestSessionStateBag_TryGetValue_WithDifferentTypeAfterSet_ReturnsFalse(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "hello") + + var result person + if found, err := session.Get("key1", &result); err != nil || found || result != (person{}) { + t.Fatalf("mismatched read = (%+v, %v, %v), want zero, false, nil", result, found, err) + } +} + +func TestAgentSession_StateBag_MultipleKeys_StoreAndRetrieveIndependently(t *testing.T) { + session := agenttest.CreateSession() + + session.Set("key1", "value1") + session.Set("key2", "value2") + + for key, want := range map[string]string{"key1": "value1", "key2": "value2"} { + var got string + if found, err := session.Get(key, &got); err != nil || !found || got != want { + t.Errorf("Get(%s) = (%q, %v, %v), want %q", key, got, found, err, want) + } + } +} + +func TestAgentSession_StateBag_Values_Roundtrips(t *testing.T) { + session := agenttest.CreateSession() + + session.Set("key1", "value1") + var got string + if found, err := session.Get("key1", &got); err != nil || !found || got != "value1" { + t.Fatalf("Get(key1) = (%q, %v, %v), want value1", got, found, err) + } +} + +func TestAgentSession_StateBag_OverwriteValue_ReturnsUpdatedValue(t *testing.T) { + session := agenttest.CreateSession() + session.Set("key1", "original") + + session.Set("key1", "updated") + + var got string + if found, err := session.Get("key1", &got); err != nil || !found || got != "updated" { + t.Fatalf("Get(key1) = (%q, %v, %v), want updated", got, found, err) + } +} + +func TestSessionStateBag_SetValue_WithComplexObject_StoresValue(t *testing.T) { + type animal struct { + ID int + FullName string + Species string + } + session := agenttest.CreateSession() + value := &animal{ID: 1, FullName: "Buddy", Species: "Bear"} + + session.Set("animal", value) + + var result *animal + if found, err := session.Get("animal", &result); err != nil || !found || result == nil || *result != *value { + t.Fatalf("Get(animal) = (%+v, %v, %v), want %+v", result, found, err, value) + } +} + +func TestSessionStateBag_TryGetValue_WithComplexObject_ReturnsTrueAndValue(t *testing.T) { + type animal struct { + ID int + FullName string + Species string + } + session := agenttest.CreateSession() + session.Set("animal", &animal{ID: 3, FullName: "Goldie", Species: "Walrus"}) + + var result *animal + found, err := session.Get("animal", &result) + if err != nil || !found || result == nil || *result != (animal{ID: 3, FullName: "Goldie", Species: "Walrus"}) { + t.Fatalf("Get(animal) = (%+v, %v, %v)", result, found, err) + } +} + +func TestSessionStateBag_Serialize_WithComplexObject_ReturnsJsonWithProperties(t *testing.T) { + type animal struct { + ID int `json:"id"` + FullName string `json:"fullName"` + Species string `json:"species"` + } + session := agenttest.CreateSession() + session.Set("animal", animal{ID: 7, FullName: "Spot", Species: "Walrus"}) + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct { + State map[string]json.RawMessage + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + var result map[string]json.RawMessage + if err := json.Unmarshal(payload.State["animal"], &result); err != nil { + t.Fatal(err) + } + if string(result["id"]) != "7" || string(result["fullName"]) != `"Spot"` || string(result["species"]) != `"Walrus"` { + t.Fatalf("serialized animal = %s", payload.State["animal"]) + } +} + +func TestSessionStateBag_SetValue_WithNullValue_StoresNull(t *testing.T) { + session := agenttest.CreateSession() + + var value *string + session.Set("key1", value) + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil || len(payload.State) != 1 || string(payload.State["key1"]) != "null" { + t.Fatalf("State = %v, err=%v; want key1: null", payload.State, err) + } +} + +func TestSessionStateBag_SerializeDeserialize_WithNullValue_SerializesAsNull(t *testing.T) { + session := agenttest.CreateSession() + var value *string + session.Set("nullKey", value) + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var payload struct{ State map[string]json.RawMessage } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if len(payload.State) != 1 || string(payload.State["nullKey"]) != "null" { + t.Fatalf("serialized State = %v, want nullKey: null", payload.State) + } + + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + var got *string + if found, err := restored.Get("nullKey", &got); err != nil || !found || got != nil { + t.Fatalf("restored nullKey = (%v, %v, %v), want (nil, true, nil)", got, found, err) + } +} + +func TestSessionStateBag_GetValue_WithNullValue_ReturnsNull(t *testing.T) { + session := agenttest.CreateSession() + var value *string + session.Set("key1", value) + + var result *string + found, err := session.Get("key1", &result) + if err != nil || !found || result != nil { + t.Fatalf("Get(key1) = (%v, %v, %v), want (nil, true, nil)", result, found, err) + } +} + +func TestSessionStateBag_TryGetValue_ComplexTypeAfterSetString_ReturnsFalse(t *testing.T) { + session := agenttest.CreateSession() + session.Set("animal", "not an animal") + + var result *person + found, err := session.Get("animal", &result) + if err != nil || found || result != nil { + t.Fatalf("Get(animal) = (%v, %v, %v), want (nil, false, nil)", result, found, err) + } +} + +func TestSessionStateBag_JsonSerializerRoundtrip_WithComplexObject_PreservesData(t *testing.T) { + type animal struct { + ID int + FullName string + Species string + } + session := agenttest.CreateSession() + session.Set("animal", animal{ID: 10, FullName: "Rex", Species: "Tiger"}) + + data, err := json.Marshal(session) + if err != nil { + t.Fatal(err) + } + var restored agent.Session + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + + var result animal + found, err := restored.Get("animal", &result) + if err != nil || !found || result != (animal{ID: 10, FullName: "Rex", Species: "Tiger"}) { + t.Fatalf("restored animal = (%+v, %v, %v)", result, found, err) + } +} + +func TestSessionStateBag_JsonSerializerDeserialize_NullJson_ReturnsNull(t *testing.T) { + var session *agent.Session + + if err := json.Unmarshal([]byte("null"), &session); err != nil { + t.Fatal(err) + } + + if session != nil { + t.Fatalf("unmarshaled null = %v, want nil", session) + } +} diff --git a/agent/structuredoutput_test.go b/agent/structuredoutput_test.go index 8ea83b0f..bffc1dda 100644 --- a/agent/structuredoutput_test.go +++ b/agent/structuredoutput_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "iter" + "strconv" "testing" "github.com/microsoft/agent-framework-go/agent" @@ -131,6 +132,51 @@ func TestAgent_StructuredOutput_FormatError(t *testing.T) { } } +func TestAgent_StructuredOutput_CustomFormat(t *testing.T) { + type schema struct{ Unit string } + format := agent.ResponseFormat{Kind: "number", Name: "plain", Schema: schema{Unit: "integer"}} + var formatCalls, unmarshalCalls int + a := newStructuredOutputTestAgent( + func(v any) (agent.ResponseFormat, error) { + formatCalls++ + return format, nil + }, + func(got agent.ResponseFormat, data []byte, v any) error { + unmarshalCalls++ + if got != format { + return errors.New("structured output format was changed") + } + number, err := strconv.Atoi(string(data)) + if err != nil { + return err + } + output, ok := v.(*int) + if !ok { + return errors.New("structured output target was changed") + } + *output = number + return nil + }, + func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + got, ok := agent.GetOption(options, agent.WithResponseFormat) + if !ok || got != format { + return structuredOutputTestError(errors.New("provider did not receive its format")) + } + return singleStructuredOutputTestUpdate("42") + }, + ) + + var output int + response, err := a.Run(t.Context(), nil, agent.WithStructuredOutput(&output)).Collect() + if err != nil { + t.Fatal(err) + } + if output != 42 || response.String() != "42" || formatCalls != 1 || unmarshalCalls != 1 { + t.Fatalf("output=%d, response=%q, format calls=%d, unmarshal calls=%d; want 42, 42, 1, 1", + output, response.String(), formatCalls, unmarshalCalls) + } +} + func TestAgent_StructuredOutput_SuccessfulUnmarshal(t *testing.T) { a := newStructuredOutputTestAgent( func(v any) (agent.ResponseFormat, error) { diff --git a/agent/value.go b/agent/value.go index c027c859..ea154b1f 100644 --- a/agent/value.go +++ b/agent/value.go @@ -7,11 +7,14 @@ import ( "fmt" "reflect" "slices" + "sync" ) // stateValue wraps a session state value in either serialized or deserialized form, // and supports lazy deserialization with type-aware caching. type stateValue struct { + mu sync.Mutex + raw json.RawMessage cached any cachedTyp reflect.Type @@ -23,14 +26,25 @@ func newStateValue(value any) *stateValue { return &stateValue{cached: value, cachedTyp: reflect.TypeOf(value), hasCached: true} } -func (v *stateValue) readInto(out any) (bool, error) { +func stateValueDestination(out any) (reflect.Value, error) { if out == nil { - return false, fmt.Errorf("out must be a non-nil pointer") + return reflect.Value{}, fmt.Errorf("out must be a non-nil pointer") } outValue := reflect.ValueOf(out) if outValue.Kind() != reflect.Pointer || outValue.IsNil() { - return false, fmt.Errorf("out must be a non-nil pointer") + return reflect.Value{}, fmt.Errorf("out must be a non-nil pointer") } + return outValue, nil +} + +func (v *stateValue) readInto(out any) (bool, error) { + outValue, err := stateValueDestination(out) + if err != nil { + return false, err + } + v.mu.Lock() + defer v.mu.Unlock() + requestedType := outValue.Elem().Type() if v.hasCached { @@ -58,22 +72,25 @@ func (v *stateValue) readInto(out any) (bool, error) { } func (v *stateValue) MarshalJSON() ([]byte, error) { - // Prefer the original raw JSON when present: it is the authoritative - // serialized form for a deserialized value and preserves fields that were - // never read. Reading a value caches a typed copy (readInto), but that copy - // may be a narrower/partial view, so re-encoding it would silently drop the - // unread fields on the next save. Values created via Set have raw == nil and - // marshal losslessly from cached. - if v.raw != nil { - return slices.Clone(v.raw), nil + v.mu.Lock() + raw, cached, hasCached := v.raw, v.cached, v.hasCached + v.mu.Unlock() + + // Preserve the original raw JSON after a read: a narrower destination + // type may not represent every field in the stored value. Set replaces + // the raw value when a caller wants edits to be persisted. + if raw != nil { + return slices.Clone(raw), nil } - if v.hasCached { - return json.Marshal(v.cached) + if hasCached { + return json.Marshal(cached) } return []byte("null"), nil } func (v *stateValue) UnmarshalJSON(data []byte) error { + v.mu.Lock() + defer v.mu.Unlock() v.raw = slices.Clone(json.RawMessage(data)) v.cached = nil v.cachedTyp = nil diff --git a/examples/02-agents/agents/step18_compaction_pipeline/main.go b/examples/02-agents/agents/step18_compaction_pipeline/main.go index 74333418..5e45f1cd 100644 --- a/examples/02-agents/agents/step18_compaction_pipeline/main.go +++ b/examples/02-agents/agents/step18_compaction_pipeline/main.go @@ -123,6 +123,16 @@ When responding, be extra descriptive and use as many words as possible without for _, prompt := range prompts { resp, err := a.RunText(ctx, prompt, agent.WithSession(session)).Collect() demo.Response(resp, err) + if err != nil { + continue + } + history, err := session.InMemoryHistory("") + if err != nil { + demo.Panic(err) + } + if history != nil { + fmt.Printf("\n[Messages: #%d]\n", len(history)) + } } } diff --git a/message/message_test.go b/message/message_test.go index ef164f45..4f09e006 100644 --- a/message/message_test.go +++ b/message/message_test.go @@ -62,6 +62,60 @@ func TestMessage_WithSource_ClonesWhenSourceChanges(t *testing.T) { } } +func TestMessage_WithSource_ClonesWhenFirstAssigned(t *testing.T) { + original := message.NewText("Hello") + source := message.Source{Type: message.SourceTypeExternal, ID: "TestSourceId"} + + got := original.WithSource(source) + if got == original || got.Source != source { + t.Fatalf("WithSource() = %+v, want a clone with source %+v", got, source) + } + if original.Source != (message.Source{}) { + t.Fatalf("original source = %+v, want unchanged", original.Source) + } +} + +func TestMessage_WithSource_ClonesWhenSourceTypeChanges(t *testing.T) { + original := message.NewText("Hello") + original.Source = message.Source{Type: message.SourceTypeExternal, ID: "SourceId"} + source := message.Source{Type: "context-provider", ID: "SourceId"} + + got := original.WithSource(source) + if got == original || got.Source != source { + t.Fatalf("WithSource() = %+v, want a clone with source %+v", got, source) + } + if original.Source.Type != message.SourceTypeExternal { + t.Fatalf("original source = %+v, want external SourceId", original.Source) + } +} + +func TestMessage_WithSource_ClonesWhenSourceIDChanges(t *testing.T) { + original := message.NewText("Hello") + original.Source = message.Source{Type: message.SourceTypeExternal, ID: "OriginalId"} + source := message.Source{Type: message.SourceTypeExternal, ID: "NewId"} + + got := original.WithSource(source) + if got == original || got.Source != source { + t.Fatalf("WithSource() = %+v, want a clone with source %+v", got, source) + } + if original.Source.ID != "OriginalId" { + t.Fatalf("original source = %+v, want OriginalId", original.Source) + } +} + +func TestMessage_WithSource_ClonesWithSourceTypeAndNoID(t *testing.T) { + original := message.NewText("Hello") + source := message.Source{Type: "history-provider"} + + got := original.WithSource(source) + if got == original || got.Source != source || got.Source.ID != "" { + t.Fatalf("WithSource() = %+v, want a clone with history source and no ID", got) + } + if original.Source != (message.Source{}) { + t.Fatalf("original source = %+v, want unchanged", original.Source) + } +} + func TestMessage_WithSource_ReturnsOriginalWhenUnchanged(t *testing.T) { original := message.NewText("hello") original.Source = message.Source{Type: message.SourceType("context-provider"), ID: "ctx"} diff --git a/provider/aguiprovider/agui.go b/provider/aguiprovider/agui.go index 750670b3..d29b7129 100644 --- a/provider/aguiprovider/agui.go +++ b/provider/aguiprovider/agui.go @@ -11,6 +11,7 @@ import ( "iter" "slices" "strings" + "sync" "time" aguiSSEClient "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/client/sse" @@ -45,6 +46,8 @@ type provider struct { const threadIDStateKey = "aguiprovider.threadID" +var threadIDInitMu sync.Mutex + type threadIDOpt string func (o threadIDOpt) MAFValue() any { return string(o) } @@ -198,6 +201,12 @@ func decodeFrame(decoder *aguiEvents.EventDecoder, data []byte) (aguiEvents.Even } func getOrCreateThreadID(session *agent.Session) (string, error) { + if session == nil { + return aguiEvents.GenerateThreadID(), nil + } + threadIDInitMu.Lock() + defer threadIDInitMu.Unlock() + var threadID string if _, err := session.Get(threadIDStateKey, &threadID); err != nil { return "", err diff --git a/provider/aguiprovider/agui_test.go b/provider/aguiprovider/agui_test.go index 8e1f2cf6..4c91410b 100644 --- a/provider/aguiprovider/agui_test.go +++ b/provider/aguiprovider/agui_test.go @@ -398,6 +398,68 @@ func TestAGUIAgentRun_WithSession_PreservesHistoryAcrossMultipleTurns(t *testing } } +func TestAGUIAgentRun_ConcurrentFirstRunsShareThreadID(t *testing.T) { + const runs = 32 + threadIDs := make(chan string, runs) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var input aguiTypes.RunAgentInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + threadIDs <- input.ThreadID + w.Header().Set("Content-Type", "text/event-stream") + writeSSE(t, w, aguiEvents.NewRunStartedEvent(input.ThreadID, input.RunID)) + writeSSE(t, w, aguiEvents.NewRunFinishedEvent(input.ThreadID, input.RunID)) + })) + defer server.Close() + + a := aguiprovider.NewAgent(newTestClient(server.URL), aguiprovider.AgentConfig{}) + session, err := a.CreateSession(t.Context()) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, runs) + var wg sync.WaitGroup + wg.Add(runs) + for range runs { + go func() { + defer wg.Done() + <-start + _, err := a.RunText(t.Context(), "hello", agent.WithSession(session)).Collect() + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Errorf("concurrent run: %v", err) + } + } + if got := len(threadIDs); got != runs { + t.Fatalf("requests = %d, want %d", got, runs) + } + close(threadIDs) + var threadID string + for id := range threadIDs { + if id == "" { + t.Fatal("empty thread ID") + } + if threadID != "" && id != threadID { + t.Errorf("thread ID = %q, want %q", id, threadID) + } + threadID = id + } + var stored string + if ok, err := session.Get("aguiprovider.threadID", &stored); err != nil || !ok || stored != threadID { + t.Fatalf("stored thread ID = (%q, %v, %v), want %q", stored, ok, err, threadID) + } +} + func TestAGUIAgentRun_MapsReasoningEvents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var input aguiTypes.RunAgentInput diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 934db880..e5793f28 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -81,7 +81,11 @@ func NewAgent(aclient anthropic.Client, config AgentConfig) *agent.Agent { } func (a *client) formatOf(v any) (agent.ResponseFormat, error) { - return jsonformat.ForType(reflect.TypeOf(v)) + format, err := jsonformat.ForType(reflect.TypeOf(v)) + if err != nil { + return agent.ResponseFormat{}, err + } + return jsonformat.WrapNonObjectSchema(format) } func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { @@ -89,7 +93,7 @@ func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { if err != nil { return err } - return format.Unmarshal(data, v) + return format.UnmarshalStructuredOutput(data, v) } func (a *client) run(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 4a81a6c0..43487451 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -915,6 +915,86 @@ func TestStructuredOutput_NonStreaming(t *testing.T) { } } +func TestStructuredOutput_NonObject(t *testing.T) { + type species string + for _, tc := range []struct { + name, payload, wantJSON, schemaType string + newTarget func() any + }{ + {name: "integer", payload: `{"data":42}`, wantJSON: `42`, schemaType: "integer", newTarget: func() any { return new(int) }}, + {name: "array", payload: `{"data":["a","b"]}`, wantJSON: `["a","b"]`, schemaType: "array", newTarget: func() any { return new([]string) }}, + {name: "enum", payload: `{"data":"Tiger"}`, wantJSON: `"Tiger"`, schemaType: "string", newTarget: func() any { return new(species) }}, + } { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + bodyCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + bodyCh <- body + if streaming { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, minimalStreamingResponse(tc.payload)) + } else { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, minimalMessageResponse(tc.payload)) + } + })) + defer server.Close() + + out := tc.newTarget() + response, err := newTestClient(t, server).RunText(t.Context(), "get result", agent.WithStructuredOutput(out), agent.Stream(streaming)).Collect() + if err != nil { + t.Fatal(err) + } + if response.String() != tc.payload { + t.Errorf("response text = %q, want %q", response.String(), tc.payload) + } + data, err := json.Marshal(out) + if err != nil || string(data) != tc.wantJSON { + t.Errorf("decoded output = %s, err = %v; want %s", data, err, tc.wantJSON) + } + var request struct { + OutputConfig struct { + Format struct { + Schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + } `json:"schema"` + } `json:"format"` + } `json:"output_config"` + } + body := <-bodyCh + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + schema := request.OutputConfig.Format.Schema + if schema.Type != "object" || schema.AdditionalProperties == nil || *schema.AdditionalProperties || + !slices.Equal(schema.Required, []string{"data"}) { + t.Fatalf("wire schema = %+v, want closed object with required data; request = %s", schema, body) + } + var property struct{ Type any } + if err := json.Unmarshal(schema.Properties["data"], &property); err != nil { + t.Fatal(err) + } + if types, ok := property.Type.([]any); ok { + if !slices.Contains(types, any(tc.schemaType)) { + t.Errorf("data type = %v, want %s", types, tc.schemaType) + } + } else if property.Type != tc.schemaType { + t.Errorf("data type = %v, want %s", property.Type, tc.schemaType) + } + }) + } + } +} + // TestStructuredOutput_Streaming verifies the same guarantees as // TestStructuredOutput_NonStreaming but with agent.Stream(true). func TestStructuredOutput_Streaming(t *testing.T) { diff --git a/provider/geminiprovider/agent.go b/provider/geminiprovider/agent.go index e422d928..8f73a647 100644 --- a/provider/geminiprovider/agent.go +++ b/provider/geminiprovider/agent.go @@ -83,7 +83,11 @@ func NewAgent(gclient *genai.Client, config AgentConfig) *agent.Agent { } func (a *client) formatOf(v any) (agent.ResponseFormat, error) { - return jsonformat.ForType(reflect.TypeOf(v)) + format, err := jsonformat.ForType(reflect.TypeOf(v)) + if err != nil { + return agent.ResponseFormat{}, err + } + return jsonformat.WrapNonObjectSchema(format) } func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { @@ -91,7 +95,7 @@ func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { if err != nil { return err } - return format.Unmarshal(data, v) + return format.UnmarshalStructuredOutput(data, v) } func (a *client) run(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { diff --git a/provider/geminiprovider/agent_test.go b/provider/geminiprovider/agent_test.go index ca39848e..d952c233 100644 --- a/provider/geminiprovider/agent_test.go +++ b/provider/geminiprovider/agent_test.go @@ -5,6 +5,7 @@ package geminiprovider_test import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -450,6 +451,74 @@ func TestStructuredOutput_NonStreaming(t *testing.T) { } } +func TestStructuredOutput_NonObject(t *testing.T) { + type species string + for _, tc := range []struct { + name, payload, wantJSON, schemaType string + newTarget func() any + }{ + {name: "integer", payload: `{"data":42}`, wantJSON: `42`, schemaType: "integer", newTarget: func() any { return new(int) }}, + {name: "array", payload: `{"data":["a","b"]}`, wantJSON: `["a","b"]`, schemaType: "array", newTarget: func() any { return new([]string) }}, + {name: "enum", payload: `{"data":"Tiger"}`, wantJSON: `"Tiger"`, schemaType: "string", newTarget: func() any { return new(species) }}, + } { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + bodyCh := make(chan []byte, 1) + contentType := "application/json" + responseBody := minimalTextResponse(tc.payload) + if streaming { + contentType = "text/event-stream" + responseBody = minimalStreamingResponse(tc.payload) + } + server := httptest.NewServer(captureAndRespond(t, bodyCh, contentType, responseBody)) + defer server.Close() + + out := tc.newTarget() + response, err := newTestClient(t, server).RunText(t.Context(), "get result", agent.WithStructuredOutput(out), agent.Stream(streaming)).Collect() + if err != nil { + t.Fatal(err) + } + if response.String() != tc.payload { + t.Errorf("response text = %q, want %q", response.String(), tc.payload) + } + data, err := json.Marshal(out) + if err != nil || string(data) != tc.wantJSON { + t.Errorf("decoded output = %s, err = %v; want %s", data, err, tc.wantJSON) + } + var request struct { + GenerationConfig struct { + ResponseJsonSchema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + } `json:"responseJsonSchema"` + } `json:"generationConfig"` + } + if err := json.Unmarshal(<-bodyCh, &request); err != nil { + t.Fatal(err) + } + schema := request.GenerationConfig.ResponseJsonSchema + if schema.Type != "object" || schema.AdditionalProperties == nil || *schema.AdditionalProperties || + !slices.Equal(schema.Required, []string{"data"}) { + t.Fatalf("wire schema = %+v, want closed object with required data", schema) + } + var property struct{ Type any } + if err := json.Unmarshal(schema.Properties["data"], &property); err != nil { + t.Fatal(err) + } + if types, ok := property.Type.([]any); ok { + if !slices.Contains(types, any(tc.schemaType)) { + t.Errorf("data type = %v, want %s", types, tc.schemaType) + } + } else if property.Type != tc.schemaType { + t.Errorf("data type = %v, want %s", property.Type, tc.schemaType) + } + }) + } + } +} + // TestStructuredOutput_Streaming verifies the same guarantees as // TestStructuredOutput_NonStreaming but with agent.Stream(true). func TestStructuredOutput_Streaming(t *testing.T) { diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 51f67a90..7903f17e 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -104,7 +104,11 @@ func NewChatCompletionsAgent(oclient openai.Client, config AgentConfig) *agent.A } func (a *chatClient) formatOf(v any) (agent.ResponseFormat, error) { - return jsonformat.ForType(reflect.TypeOf(v)) + format, err := jsonformat.ForType(reflect.TypeOf(v)) + if err != nil { + return agent.ResponseFormat{}, err + } + return jsonformat.WrapNonObjectSchema(format) } func (a *chatClient) unmarshal(format agent.ResponseFormat, data []byte, v any) error { @@ -112,7 +116,7 @@ func (a *chatClient) unmarshal(format agent.ResponseFormat, data []byte, v any) if err != nil { return err } - return jsonFormat.Unmarshal(data, v) + return jsonFormat.UnmarshalStructuredOutput(data, v) } func (a *chatClient) run(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index db872f96..30f2e972 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -5,10 +5,12 @@ package openaiprovider_test import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "reflect" + "slices" "strings" "sync/atomic" "testing" @@ -316,6 +318,294 @@ func TestChatResponseFormatSchemaConvertsJSONSchema(t *testing.T) { } } +type structuredOutputAnimal struct { + ID int `json:"id"` + FullName string `json:"fullName"` + Species structuredOutputSpecies `json:"species"` +} + +type structuredOutputSpecies string + +type structuredOutputCase struct { + property, propertyType, response string + newTarget func() any + want any +} + +func TestStructuredOutputObjectEndToEnd(t *testing.T) { + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "id", propertyType: "integer", response: `{"id":99,"fullName":"Leo","species":"Bear"}`, + newTarget: func() any { return new(structuredOutputAnimal) }, + want: &structuredOutputAnimal{ID: 99, FullName: "Leo", Species: "Bear"}, + }) +} + +func TestStructuredOutputPrimitiveEndToEnd(t *testing.T) { + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "data", propertyType: "integer", response: `{"data":123}`, + newTarget: func() any { return new(int) }, want: new(123), + }) +} + +func TestStructuredOutputArrayEndToEnd(t *testing.T) { + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "data", propertyType: "array", response: `{"data":["one","two","three"]}`, + newTarget: func() any { return new([]string) }, want: &[]string{"one", "two", "three"}, + }) +} + +func TestStructuredOutputEnumEndToEnd(t *testing.T) { + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "data", propertyType: "string", response: `{"data":"Bear"}`, + newTarget: func() any { return new(structuredOutputSpecies) }, want: new(structuredOutputSpecies("Bear")), + }) +} + +func TestStructuredOutputNestedArrayEndToEnd(t *testing.T) { + type item struct { + Label string `json:"label"` + } + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "data", propertyType: "array", response: `{"data":[{"label":"value"}]}`, + newTarget: func() any { return new([]item) }, want: &[]item{{Label: "value"}}, + }) +} + +func TestStructuredOutputIgnoresExtraEnvelopeProperties(t *testing.T) { + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "data", propertyType: "integer", response: `{"data":42,"extra":true}`, + newTarget: func() any { return new(int) }, want: new(42), + }) +} + +func TestStructuredOutputUsesFirstUnwrappedJSONValue(t *testing.T) { + type payload struct { + Value int `json:"value"` + } + testStructuredOutputEndToEnd(t, structuredOutputCase{ + property: "value", propertyType: "integer", response: `{"value":42} {"value":99}`, + newTarget: func() any { return new(payload) }, want: &payload{Value: 42}, + }) +} + +func testStructuredOutputEndToEnd(t *testing.T, tc structuredOutputCase) { + for _, provider := range []struct { + name string + newAgent func(*httptest.Server) *agent.Agent + }{ + {name: "chat", newAgent: newTestClient}, + {name: "responses", newAgent: func(server *httptest.Server) *agent.Agent { + return newTestResponsesClient(server, "gpt-4o-mini") + }}, + } { + t.Run(provider.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ResponseFormat struct { + JSONSchema struct { + Schema json.RawMessage `json:"schema"` + Strict bool `json:"strict"` + } `json:"json_schema"` + } `json:"response_format"` + Text struct { + Format struct { + Schema json.RawMessage `json:"schema"` + Strict bool `json:"strict"` + } `json:"format"` + } `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + rawSchema := request.ResponseFormat.JSONSchema.Schema + strict := request.ResponseFormat.JSONSchema.Strict + if provider.name == "responses" { + rawSchema = request.Text.Format.Schema + strict = request.Text.Format.Strict + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + } + if err := json.Unmarshal(rawSchema, &schema); err != nil { + t.Errorf("decode response schema: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if !strict || schema.Type != "object" || schema.AdditionalProperties == nil || *schema.AdditionalProperties { + t.Errorf("wire format strict=%v, schema=%s, want strict closed object", strict, rawSchema) + } + property, ok := schema.Properties[tc.property] + if !ok { + t.Errorf("schema property %q missing: %s", tc.property, rawSchema) + } else { + var nested struct { + Type any `json:"type"` + } + if err := json.Unmarshal(property, &nested); err != nil { + t.Errorf("decode property %q: %v", tc.property, err) + } else if types, ok := nested.Type.([]any); ok { + if !slices.Contains(types, any(tc.propertyType)) { + t.Errorf("property %q type = %v, want %s", tc.property, nested.Type, tc.propertyType) + } + } else if nested.Type != tc.propertyType { + t.Errorf("property %q type = %v, want %s", tc.property, nested.Type, tc.propertyType) + } + } + if tc.property == "data" { + if !reflect.DeepEqual(schema.Required, []string{"data"}) { + t.Errorf("required = %v, want [data]", schema.Required) + } + } else if _, ok := schema.Properties["data"]; ok { + t.Error("object schema unexpectedly wrapped in data") + } + text, err := json.Marshal(tc.response) + if err != nil { + t.Errorf("encode response text: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if provider.name == "chat" { + _, err = fmt.Fprintf(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1727888631,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":%s},"finish_reason":"stop"}]}`, text) + } else { + _, err = fmt.Fprintf(w, `{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":%s,"annotations":[]}]}]}`, text) + } + if err != nil { + t.Errorf("write response: %v", err) + } + })) + defer server.Close() + + out := tc.newTarget() + response, err := provider.newAgent(server).RunText(t.Context(), "hello", agent.WithStructuredOutput(out)).Collect() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(out, tc.want) { + t.Errorf("structured output = %#v, want %#v", out, tc.want) + } + if got := response.String(); got != tc.response { + t.Errorf("response text = %q, want %q", got, tc.response) + } + }) + } +} + +func TestStructuredOutputNonObjectStreaming(t *testing.T) { + for _, provider := range []struct { + name string + newAgent func(*httptest.Server) *agent.Agent + }{ + {name: "chat", newAgent: newTestClient}, + {name: "responses", newAgent: func(server *httptest.Server) *agent.Agent { + return newTestResponsesClient(server, "gpt-4o-mini") + }}, + } { + t.Run(provider.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Stream bool `json:"stream"` + ResponseFormat struct { + JSONSchema struct { + Schema json.RawMessage `json:"schema"` + Strict bool `json:"strict"` + } `json:"json_schema"` + } `json:"response_format"` + Text struct { + Format struct { + Schema json.RawMessage `json:"schema"` + Strict bool `json:"strict"` + } `json:"format"` + } `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if !request.Stream { + t.Error("streaming request did not set stream=true") + } + rawSchema, strict := request.ResponseFormat.JSONSchema.Schema, request.ResponseFormat.JSONSchema.Strict + if provider.name == "responses" { + rawSchema, strict = request.Text.Format.Schema, request.Text.Format.Strict + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + } + if err := json.Unmarshal(rawSchema, &schema); err != nil { + t.Errorf("decode streaming response schema: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + var dataSchema struct { + Type string `json:"type"` + } + if err := json.Unmarshal(schema.Properties["data"], &dataSchema); err != nil { + t.Errorf("decode data schema: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if !strict || schema.Type != "object" || !slices.Equal(schema.Required, []string{"data"}) || + schema.AdditionalProperties == nil || *schema.AdditionalProperties || dataSchema.Type != "integer" { + t.Errorf("streaming response format strict=%v, schema=%s, want closed object with required integer data", strict, rawSchema) + } + w.Header().Set("Content-Type", "text/event-stream") + if provider.name == "chat" { + for _, part := range []string{`{"data":`, `42}`} { + if _, err := fmt.Fprintf(w, "data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":1727889370,\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":%q},\"finish_reason\":null}]}\n\n", part); err != nil { + t.Errorf("write chat stream: %v", err) + return + } + } + _, _ = io.WriteString(w, "data: [DONE]\n\n") + return + } + events := []string{ + `event: response.created +data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}}`, + `event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_001","status":"in_progress","role":"assistant","content":[]}}`, + } + for _, event := range events { + if _, err := fmt.Fprintf(w, "%s\n\n", event); err != nil { + t.Errorf("write responses event: %v", err) + return + } + } + for _, part := range []string{`{"data":`, `42}`} { + if _, err := fmt.Fprintf(w, "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_001\",\"output_index\":0,\"content_index\":0,\"delta\":%q}\n\n", part); err != nil { + t.Errorf("write responses delta: %v", err) + return + } + } + _, _ = io.WriteString(w, `event: response.completed +data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[]}} + +`) + })) + defer server.Close() + + var output int + response, err := provider.newAgent(server).RunText(t.Context(), "hello", agent.WithStructuredOutput(&output), agent.Stream(true)).Collect() + if err != nil { + t.Fatal(err) + } + if output != 42 || response.String() != `{"data":42}` { + t.Fatalf("streamed output = %d, response text = %q; want 42 and wrapped JSON", output, response.String()) + } + }) + } +} + func TestChatConfigInstructions_NonStreaming(t *testing.T) { const input = ` { diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 574c0259..7fd293ce 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -96,7 +96,11 @@ func ResponsesIncludeReasoningEncryptedContent(enabled bool) agent.Option { } func (a *responsesClient) formatOf(v any) (agent.ResponseFormat, error) { - return jsonformat.ForType(reflect.TypeOf(v)) + format, err := jsonformat.ForType(reflect.TypeOf(v)) + if err != nil { + return agent.ResponseFormat{}, err + } + return jsonformat.WrapNonObjectSchema(format) } func (a *responsesClient) unmarshal(format agent.ResponseFormat, data []byte, v any) error { @@ -104,7 +108,7 @@ func (a *responsesClient) unmarshal(format agent.ResponseFormat, data []byte, v if err != nil { return err } - return jsonFormat.Unmarshal(data, v) + return jsonFormat.UnmarshalStructuredOutput(data, v) } func (a *responsesClient) run(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { diff --git a/provider/openaiprovider/strict_schema_test.go b/provider/openaiprovider/strict_schema_test.go index fe24fca5..e335de59 100644 --- a/provider/openaiprovider/strict_schema_test.go +++ b/provider/openaiprovider/strict_schema_test.go @@ -405,6 +405,20 @@ func TestStrictSchemaToMapRejectsNonObjectRoot(t *testing.T) { } } +func TestExplicitResponseFormatStillRejectsNonObjectRoot(t *testing.T) { + format, err := jsonformat.For[int]() + if err != nil { + t.Fatal(err) + } + options := []agent.Option{agent.WithResponseFormat(format)} + if _, err := buildCompletionParams("test-model", nil, options); err == nil || !strings.Contains(err.Error(), "root schema must have type object") { + t.Fatalf("chat format error = %v, want object-root error", err) + } + if _, err := responsesBuildCompletionParams(AgentConfig{Model: "test-model"}, nil, options); err == nil || !strings.Contains(err.Error(), "root schema must have type object") { + t.Fatalf("responses format error = %v, want object-root error", err) + } +} + func TestStrictSchemaToMapRejectsRootAnyOf(t *testing.T) { _, err := strictSchemaToMap(map[string]any{ "type": "object", From 32ec428892a4a7d8f7d79ed8b4ae75d8731ab403 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Fri, 9 Oct 2026 15:56:14 +0200 Subject: [PATCH 2/2] Fix Session concurrency and PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/agent.go | 8 +- agent/agent_test.go | 119 ++++++++++++++++++++ agent/compaction/compaction_test.go | 20 ++++ agent/compaction/provider.go | 5 + agent/format/jsonformat/encoding_test.go | 54 ++++++---- agent/response_test.go | 41 ++++--- agent/session.go | 132 +++++++++++------------ agent/session_test.go | 131 +++++++++++++++++++++- provider/aguiprovider/agui.go | 8 +- 9 files changed, 412 insertions(+), 106 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index b2060633..e3775636 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -48,8 +48,9 @@ type ProviderConfig struct { // Unmarshal decodes provider structured output into v using format. Unmarshal func(format ResponseFormat, data []byte, v any) error - // CreateSession configures a provider-specific session. Implementations must - // treat options as read-only and clone the slice before making changes. + // CreateSession configures a provider-specific session. The session is initialized + // and supports concurrent state access. Implementations must treat options as + // read-only and clone the slice before making changes. CreateSession func(ctx context.Context, session *Session, options ...Option) error } @@ -628,6 +629,7 @@ func (a *Agent) prepareRun(ctx context.Context, messages []*message.Message, opt // A per-run ID belongs to the request until returned by the provider. // Only an explicit CreateSession call seeds a service ID on a session. session := &Session{} + session.initState() if a.provider.CreateSession != nil { if err := a.provider.CreateSession(ctx, session, options...); err != nil { return nil, nil, nil, err @@ -642,6 +644,8 @@ func (a *Agent) prepareRun(ctx context.Context, messages []*message.Message, opt if a.hasDefaultHistoryProvider && !a.perServiceCallHistoryPersistence { options = append(options, agentopts.SessionlessHistory{}) } + } else { + session.initState() } continuationToken, _ := GetOption(options, WithContinuationToken) diff --git a/agent/agent_test.go b/agent/agent_test.go index d29c7f1b..fbf2e08d 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1554,6 +1554,125 @@ func TestAgent_Run_UsesProvidedSession(t *testing.T) { } } +func TestAgent_InitializesSessionBeforeCallbacks(t *testing.T) { + for _, tc := range []struct { + name string + create bool + providerCallback bool + provided bool + configured bool + }{ + {name: "CreateSession provider callback", create: true, providerCallback: true}, + {name: "automatic provider callback", providerCallback: true}, + {name: "automatic middleware"}, + {name: "provided zero value", provided: true}, + {name: "configured zero value", configured: true}, + } { + t.Run(tc.name, func(t *testing.T) { + wantID := "" + if tc.create { + wantID = "requested" + } + var captured *agent.Session + checkSession := func(session *agent.Session) { + captured = session + if got := session.ServiceID(); got != wantID { + t.Fatalf("ServiceID before callback writes = %q, want %q", got, wantID) + } + const keys = 32 + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range keys { + wg.Go(func() { + <-start + key := fmt.Sprintf("key%d", i) + session.Set(key, i) + target := any(session) + if i%2 == 0 { + target = *session + } + data, err := json.Marshal(target) + if err != nil { + t.Error(err) + return + } + var payload struct { + State map[string]int + ServiceID string + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Error(err) + return + } + if value, ok := payload.State[key]; !ok || value != i || payload.ServiceID != wantID { + t.Errorf("snapshot after Set(%q, %d) = %s, want stored value and service ID %q", key, i, data, wantID) + } + }) + } + close(start) + wg.Wait() + for i := range keys { + var value int + key := fmt.Sprintf("key%d", i) + if ok, err := session.Get(key, &value); err != nil || !ok || value != i { + t.Fatalf("Get(%q) = (%d, %v, %v), want (%d, true, nil)", key, value, ok, err, i) + } + } + } + + runner := &agenttest.Runner{Responses: agenttest.NewResponseBuilder().AddText("done").Build()} + provider := agent.ProviderConfig{Run: runner.Run} + var cfg agent.Config + if tc.providerCallback { + provider.CreateSession = func(_ context.Context, session *agent.Session, _ ...agent.Option) error { + checkSession(session) + return nil + } + } else { + cfg.Middlewares = []agent.Middleware{ + agent.MiddlewareFunc(func(next agent.RunFunc, ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + session, ok := agent.GetOption(options, agent.WithSession) + if !ok || session == nil { + t.Fatal("middleware did not receive a session") + } + checkSession(session) + return next(ctx, messages, options...) + }), + } + } + var provided agent.Session + options := []agent.Option{agent.WithServiceID("requested")} + if tc.provided { + options = append(options, agent.WithSession(&provided)) + } + if tc.configured { + cfg.RunOptions = []agent.Option{agent.WithSession(&provided)} + } + a := agent.New(provider, cfg) + if tc.create { + session, err := a.CreateSession(t.Context(), options...) + if err != nil { + t.Fatal(err) + } + if session != captured { + t.Fatal("CreateSession returned a different session from its callback") + } + } else if _, err := a.RunText(t.Context(), "test", options...).Collect(); err != nil { + t.Fatal(err) + } + if captured == nil { + t.Fatal("session callback was not invoked") + } + if (tc.provided || tc.configured) && captured != &provided { + t.Fatal("run replaced the supplied session") + } + if got := captured.ServiceID(); got != wantID { + t.Fatalf("ServiceID after callback = %q, want %q", got, wantID) + } + }) + } +} + func TestAgent_Run_PrependsAgentOptions(t *testing.T) { var capturedOptions []agent.Option runner := &agenttest.Runner{ diff --git a/agent/compaction/compaction_test.go b/agent/compaction/compaction_test.go index 3119e40f..a350abc8 100644 --- a/agent/compaction/compaction_test.go +++ b/agent/compaction/compaction_test.go @@ -692,6 +692,26 @@ func TestSummarizationStrategy_PropagatesCancellation(t *testing.T) { } } +func TestNewContextProvider_PanicsWithBlankStateKey(t *testing.T) { + for _, tc := range []struct { + name string + config compaction.ContextProviderConfig + }{ + {name: "explicit state key", config: compaction.ContextProviderConfig{StateKey: " "}}, + {name: "default state key from source ID", config: compaction.ContextProviderConfig{SourceID: "\t"}}, + } { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected blank compaction state key to panic at construction") + } + }() + tc.config.Strategy = new(compaction.TruncationStrategy) + compaction.NewContextProvider(tc.config) + }) + } +} + func TestNewProvider_CompactsAndPersistsIndex(t *testing.T) { session := agenttest.CreateSession() provider := compaction.NewContextProvider(compaction.ContextProviderConfig{ diff --git a/agent/compaction/provider.go b/agent/compaction/provider.go index 62294358..0071a0ad 100644 --- a/agent/compaction/provider.go +++ b/agent/compaction/provider.go @@ -6,6 +6,7 @@ import ( "cmp" "context" "log/slog" + "strings" "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/internal/telemetry" @@ -54,12 +55,16 @@ type contextProvider struct { // incrementally update the index. Without a session, it still performs stateless compaction over the // current message list. Service-managed sessions are skipped because the service owns history. // Generated summaries are marked as history so the default history store filter skips them. +// It panics if Strategy is nil or the resolved StateKey is blank. func NewContextProvider(cfg ContextProviderConfig) agent.ContextProvider { if cfg.Strategy == nil { panic("Strategy is required") } cfg.SourceID = cmp.Or(cfg.SourceID, defaultProviderSourceID) cfg.StateKey = cmp.Or(cfg.StateKey, cfg.SourceID) + if strings.TrimSpace(cfg.StateKey) == "" { + panic("StateKey must not be blank") + } return &contextProvider{ strategy: cfg.Strategy, sourceID: cfg.SourceID, diff --git a/agent/format/jsonformat/encoding_test.go b/agent/format/jsonformat/encoding_test.go index 00ffd3f5..e921d271 100644 --- a/agent/format/jsonformat/encoding_test.go +++ b/agent/format/jsonformat/encoding_test.go @@ -164,24 +164,42 @@ func TestUnmarshalStructuredOutput(t *testing.T) { newTarget func() any want any }{ - {name: "object", response: `{"name":"Tiger"}`, - newTarget: func() any { return new(object) }, want: object{Name: "Tiger"}}, - {name: "object with multiple JSON values", response: `{"name":"First"} {"name":"Second"}`, - newTarget: func() any { return new(object) }, want: object{Name: "First"}}, - {name: "integer", response: `{"data":42}`, - newTarget: func() any { return new(int) }, want: 42}, - {name: "wrapped result with extra property", response: `{"data":42,"extra":true}`, - newTarget: func() any { return new(int) }, want: 42}, - {name: "array", response: `{"data":["a","b"]}`, - newTarget: func() any { return new([]string) }, want: []string{"a", "b"}}, - {name: "enum-like string", response: `{"data":"Tiger"}`, - newTarget: func() any { return new(species) }, want: species("Tiger")}, - {name: "nested array", response: `{"data":[{"label":"value"}]}`, - newTarget: func() any { return new([]nested) }, want: []nested{{Label: "value"}}}, - {name: "pointer destination", response: `{"data":42}`, - newTarget: func() any { return new(*int) }, want: pointerValue}, - {name: "bare integer fallback", response: `42`, - newTarget: func() any { return new(int) }, want: 42}, + { + name: "object", response: `{"name":"Tiger"}`, + newTarget: func() any { return new(object) }, want: object{Name: "Tiger"}, + }, + { + name: "object with multiple JSON values", response: `{"name":"First"} {"name":"Second"}`, + newTarget: func() any { return new(object) }, want: object{Name: "First"}, + }, + { + name: "integer", response: `{"data":42}`, + newTarget: func() any { return new(int) }, want: 42, + }, + { + name: "wrapped result with extra property", response: `{"data":42,"extra":true}`, + newTarget: func() any { return new(int) }, want: 42, + }, + { + name: "array", response: `{"data":["a","b"]}`, + newTarget: func() any { return new([]string) }, want: []string{"a", "b"}, + }, + { + name: "enum-like string", response: `{"data":"Tiger"}`, + newTarget: func() any { return new(species) }, want: species("Tiger"), + }, + { + name: "nested array", response: `{"data":[{"label":"value"}]}`, + newTarget: func() any { return new([]nested) }, want: []nested{{Label: "value"}}, + }, + { + name: "pointer destination", response: `{"data":42}`, + newTarget: func() any { return new(*int) }, want: pointerValue, + }, + { + name: "bare integer fallback", response: `42`, + newTarget: func() any { return new(int) }, want: 42, + }, } { t.Run(tc.name, func(t *testing.T) { output := tc.newTarget() diff --git a/agent/response_test.go b/agent/response_test.go index 0a4ad7d4..3ca46b83 100644 --- a/agent/response_test.go +++ b/agent/response_test.go @@ -1297,12 +1297,18 @@ func TestResponse_Collect_CombinesMessagesMetadataAndUsage(t *testing.T) { t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { createdAt := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC) updates := []*agent.ResponseUpdate{ - {AgentID: "agentId", ResponseID: "someResponse", MessageID: "12345", Role: message.RoleAssistant, CreatedAt: createdAt, - Contents: message.Contents{&message.TextContent{Text: "Hello"}}}, - {AuthorName: "Someone", Role: message.Role("human"), AdditionalProperties: map[string]any{"a": "b"}, - Contents: message.Contents{&message.TextContent{Text: ", "}}}, - {CreatedAt: createdAt.AddDate(1, 0, 0), AdditionalProperties: map[string]any{"c": "d"}, - Contents: message.Contents{&message.TextContent{Text: "world!"}}}, + { + AgentID: "agentId", ResponseID: "someResponse", MessageID: "12345", Role: message.RoleAssistant, CreatedAt: createdAt, + Contents: message.Contents{&message.TextContent{Text: "Hello"}}, + }, + { + AuthorName: "Someone", Role: message.Role("human"), AdditionalProperties: map[string]any{"a": "b"}, + Contents: message.Contents{&message.TextContent{Text: ", "}}, + }, + { + CreatedAt: createdAt.AddDate(1, 0, 0), AdditionalProperties: map[string]any{"c": "d"}, + Contents: message.Contents{&message.TextContent{Text: "world!"}}, + }, {Contents: message.Contents{&message.UsageContent{Details: message.UsageDetails{InputTokenCount: 1, OutputTokenCount: 2}}}}, {Contents: message.Contents{&message.UsageContent{Details: message.UsageDetails{InputTokenCount: 4, OutputTokenCount: 5}}}}, } @@ -1395,13 +1401,22 @@ func TestResponse_Collect_CoalescesTextAndReasoningSeparately(t *testing.T) { text string reasoning bool }{ - {"A", false}, {"B", false}, {"C", false}, - {"D", true}, {"E", true}, {"F", true}, - {"G", false}, {"H", false}, - {"I", true}, {"J", true}, - {"K", false}, {"L", true}, - {"M", false}, {"N", false}, - {"O", true}, {"P", true}, + {"A", false}, + {"B", false}, + {"C", false}, + {"D", true}, + {"E", true}, + {"F", true}, + {"G", false}, + {"H", false}, + {"I", true}, + {"J", true}, + {"K", false}, + {"L", true}, + {"M", false}, + {"N", false}, + {"O", true}, + {"P", true}, } { var content message.Content = &message.TextContent{Text: part.text} if part.reasoning { diff --git a/agent/session.go b/agent/session.go index 0eeae6e1..da920bd1 100644 --- a/agent/session.go +++ b/agent/session.go @@ -7,9 +7,7 @@ import ( "maps" "slices" "strings" - "sync/atomic" - - "github.com/microsoft/agent-framework-go/internal/concurrent" + "sync" ) // Session contains the state of a specific conversation with an agent which may include: @@ -19,40 +17,38 @@ import ( // - Any other state that the agent needs to persist across runs for a conversation. // // Agent behaviors such as history and context providers live on the agent and store their state in the Session. -// The zero value is ready to use. [Agent.CreateSession] can be used when a provider needs to configure +// The zero value is ready for sequential use. [Agent.CreateSession] can be used when a provider needs to configure // provider-specific session state before the first run. // // Because provider-specific state can be associated with the agent that created it, a Session may not be reusable across // different agents. // -// To support conversations that may need to survive application restarts or separate service requests, -// a Session can be serialized and deserialized directly with encoding/json, so that it can be saved in a -// persistent store. -// Session methods support concurrent access to the state map and service ID. A -// JSON snapshot can reflect writes from different points during marshaling; -// UnmarshalJSON replaces the state and service ID together after decoding. -// Values stored in the session are not cloned; callers must coordinate mutations -// to their own values. Call Set after editing a value read from a deserialized -// session to persist the edit. Do not copy a Session after first use. Marshal -// a *Session, not a Session value, to preserve its JSON format. +// Sessions support encoding/json, including marshaling by value. Use the same +// *Session throughout a conversation; copies are only for marshaling. +// +// Methods support concurrent use after initialization by [Agent.CreateSession], +// [Agent.Run], [Session.Set], [Session.SetServiceID], or successful JSON decoding. +// Initialize before sharing; read-modify-write sequences need separate synchronization. +// +// Marshaling takes a shallow snapshot; successful decoding replaces the state +// and service ID together. Stored values are not cloned. Synchronize mutations +// and use [Session.Set] to persist edits to restored values. type Session struct { - current atomic.Pointer[sessionState] + // state is initialized before concurrent use and never replaced. + state *sessionState } type sessionState struct { + mu sync.RWMutex serviceID string - state *concurrent.Map[string, *stateValue] + values map[string]*stateValue } -func (s *Session) stateForWrite() *sessionState { - if current := s.current.Load(); current != nil { - return current +func (s *Session) initState() *sessionState { + if s.state == nil { + s.state = new(sessionState) } - initial := &sessionState{state: new(concurrent.Map[string, *stateValue])} - if s.current.CompareAndSwap(nil, initial) { - return initial - } - return s.current.Load() + return s.state } func validateSessionStateKey(key string) { @@ -71,14 +67,13 @@ func validateSessionStateKey(key string) { // It panics if key is empty or whitespace. func (s *Session) Get(key string, value any) (bool, error) { validateSessionStateKey(key) - if s == nil { + if s == nil || s.state == nil { return false, nil } - current := s.current.Load() - if current == nil { - return false, nil - } - wrapped, ok := current.state.Load(key) + state := s.state + state.mu.RLock() + wrapped, ok := state.values[key] + state.mu.RUnlock() if !ok { return false, nil } @@ -98,21 +93,27 @@ func (s *Session) Set(key string, value any) { wrapped = newStateValue(value) } - s.stateForWrite().state.Store(key, wrapped) + state := s.initState() + state.mu.Lock() + if state.values == nil { + state.values = make(map[string]*stateValue) + } + state.values[key] = wrapped + state.mu.Unlock() } // Delete removes the value with the given key and reports whether it existed. // It panics if key is empty or whitespace. func (s *Session) Delete(key string) bool { validateSessionStateKey(key) - if s == nil { + if s == nil || s.state == nil { return false } - current := s.current.Load() - if current == nil { - return false - } - _, ok := current.state.LoadAndDelete(key) + state := s.state + state.mu.Lock() + _, ok := state.values[key] + delete(state.values, key) + state.mu.Unlock() return ok } @@ -120,13 +121,14 @@ func (s *Session) Delete(key string) bool { // When [Config.RequirePerServiceCallHistoryPersistence] is enabled, it may instead // identify history managed locally by the agent. func (s *Session) ServiceID() string { - if s == nil { + if s == nil || s.state == nil { return "" } - if current := s.current.Load(); current != nil { - return current.serviceID - } - return "" + state := s.state + state.mu.RLock() + id := state.serviceID + state.mu.RUnlock() + return id } // SetServiceID sets the provider-specific identifier associated with the session. @@ -134,32 +136,22 @@ func (s *Session) SetServiceID(id string) { if s == nil { return } - for { - current := s.current.Load() - var state *concurrent.Map[string, *stateValue] - if current == nil { - state = new(concurrent.Map[string, *stateValue]) - } else { - state = current.state - } - next := &sessionState{serviceID: id, state: state} - if s.current.CompareAndSwap(current, next) { - return - } - } + state := s.initState() + state.mu.Lock() + state.serviceID = id + state.mu.Unlock() } -func (s *Session) MarshalJSON() ([]byte, error) { - if s == nil { - return []byte("null"), nil - } - - tmp := sessionData{ - State: make(map[string]*stateValue), +func (s Session) MarshalJSON() ([]byte, error) { + var tmp sessionData + if state := s.state; state != nil { + state.mu.RLock() + tmp.ServiceID = state.serviceID + tmp.State = maps.Clone(state.values) + state.mu.RUnlock() } - if current := s.current.Load(); current != nil { - tmp.ServiceID = current.serviceID - maps.Insert(tmp.State, current.state.All()) + if tmp.State == nil { + tmp.State = make(map[string]*stateValue) } return json.Marshal(tmp) } @@ -173,11 +165,15 @@ func (s *Session) UnmarshalJSON(data []byte) error { return err } - state := new(concurrent.Map[string, *stateValue]) + values := make(map[string]*stateValue, len(tmp.State)) for key, raw := range tmp.State { - state.Store(key, &stateValue{raw: slices.Clone(raw)}) + values[key] = &stateValue{raw: slices.Clone(raw)} } - s.current.Store(&sessionState{serviceID: tmp.ServiceID, state: state}) + state := s.initState() + state.mu.Lock() + state.serviceID = tmp.ServiceID + state.values = values + state.mu.Unlock() return nil } diff --git a/agent/session_test.go b/agent/session_test.go index da6d6617..48ad364f 100644 --- a/agent/session_test.go +++ b/agent/session_test.go @@ -87,7 +87,11 @@ func TestSessionState_ConcurrentWritesAndSerialize(t *testing.T) { defer wg.Done() <-start session.Set("shared", fmt.Sprintf("value%d", index)) - data, err := json.Marshal(session) + target := any(session) + if index%2 == 0 { + target = *session + } + data, err := json.Marshal(target) if err != nil { errs <- err return @@ -112,6 +116,52 @@ func TestSessionState_ConcurrentWritesAndSerialize(t *testing.T) { } } +func TestSession_MarshalJSON_SnapshotsStateEntriesTogether(t *testing.T) { + session := agenttest.CreateSession() + session.Set("first", 0) + session.Set("second", 0) + const operations = 1000 + start := make(chan struct{}) + errs := make(chan error, operations) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + for i := 1; i <= operations; i++ { + session.Set("first", i) + session.Set("second", i) + } + }() + go func() { + defer wg.Done() + <-start + for range operations { + data, err := json.Marshal(session) + if err != nil { + errs <- err + continue + } + var payload struct{ State map[string]int } + if err := json.Unmarshal(data, &payload); err != nil { + errs <- err + continue + } + first, hasFirst := payload.State["first"] + second, hasSecond := payload.State["second"] + if !hasFirst || !hasSecond || (first != second && first != second+1) { + errs <- fmt.Errorf("snapshot mixed state entries from different points in time: %s", data) + } + } + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } +} + func TestSession_ConcurrentStateAndJSONReplacement(t *testing.T) { session := agenttest.CreateSession() const operations = 100 @@ -193,8 +243,12 @@ func TestSession_ConcurrentJSONReplacementKeepsServiceIDAndStateTogether(t *test go func() { defer wg.Done() <-start - for range operations { - data, err := json.Marshal(&session) + for i := range operations { + target := any(&session) + if i%2 == 0 { + target = session + } + data, err := json.Marshal(target) if err != nil { errs <- err continue @@ -221,7 +275,7 @@ func TestSession_ConcurrentJSONReplacementKeepsServiceIDAndStateTogether(t *test } func TestSession_ConcurrentFirstWritesPreserveState(t *testing.T) { - var session agent.Session + session := agenttest.CreateSession() const keys = 100 start := make(chan struct{}) var wg sync.WaitGroup @@ -526,6 +580,31 @@ func TestSession_UnmarshalJSON_IntoCreatedSession(t *testing.T) { } } +func TestSession_UnmarshalJSON_ErrorLeavesStateUnchanged(t *testing.T) { + for _, data := range []string{ + `{"ServiceID":"replacement","State":[]}`, + `{"State":{"replacement":"new"},"ServiceID":123}`, + } { + t.Run(data, func(t *testing.T) { + session := agenttest.CreateSession(agent.WithServiceID("original")) + session.Set("retained", "value") + if err := json.Unmarshal([]byte(data), session); err == nil { + t.Fatal("expected a deserialization error") + } + if got := session.ServiceID(); got != "original" { + t.Fatalf("ServiceID after failed restoration = %q, want original", got) + } + var value string + if ok, err := session.Get("retained", &value); err != nil || !ok || value != "value" { + t.Fatalf("retained state after failed restoration = (%q, %v, %v)", value, ok, err) + } + if ok, err := session.Get("replacement", &value); err != nil || ok { + t.Fatalf("partially restored state after failure: (%v, %v)", ok, err) + } + }) + } +} + func TestSession_UnmarshalJSON_NullStateValueRoundtripsAndCanBeOverwritten(t *testing.T) { source := agenttest.CreateSession() var original *nullablePerson @@ -581,6 +660,50 @@ func TestSession_MarshalJSON_ZeroValueSession(t *testing.T) { } } +func TestSession_MarshalJSON_ValueFormPreservesState(t *testing.T) { + var zero agent.Session + data, err := json.Marshal(zero) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `{"State":{},"ServiceID":""}`; got != want { + t.Fatalf("zero-value Session JSON = %s, want %s", got, want) + } + var nilSession *agent.Session + if data, err := json.Marshal(nilSession); err != nil || string(data) != "null" { + t.Fatalf("nil Session JSON = %s, err = %v; want null", data, err) + } + + session := agenttest.CreateSession() + session.Set("key", "value") + session.Set("other", "second") + session.SetServiceID("service") + for _, tc := range []struct { + name string + value any + }{ + {name: "value", value: *session}, + {name: "pointer", value: session}, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := json.Marshal(tc.value) + if err != nil { + t.Fatal(err) + } + var payload struct { + State map[string]string + ServiceID string + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatal(err) + } + if payload.ServiceID != "service" || payload.State["key"] != "value" || payload.State["other"] != "second" { + t.Fatalf("Session JSON = %s, want both stored values and service ID", data) + } + }) + } +} + func TestSession_UnmarshalJSON_WithStateValues(t *testing.T) { session := agenttest.CreateSession() mustUnmarshalJSON(t, []byte(`{"State":{"key1":"value1","key2":42}}`), session) diff --git a/provider/aguiprovider/agui.go b/provider/aguiprovider/agui.go index d29b7129..dada9791 100644 --- a/provider/aguiprovider/agui.go +++ b/provider/aguiprovider/agui.go @@ -204,10 +204,16 @@ func getOrCreateThreadID(session *agent.Session) (string, error) { if session == nil { return aguiEvents.GenerateThreadID(), nil } + var threadID string + if _, err := session.Get(threadIDStateKey, &threadID); err != nil { + return "", err + } + if threadID != "" { + return threadID, nil + } threadIDInitMu.Lock() defer threadIDInitMu.Unlock() - var threadID string if _, err := session.Get(threadIDStateKey, &threadID); err != nil { return "", err }