@polymorphism-tech/morph-spec 8.33.0 → 8.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +22 -2
- package/README.md +2 -2
- package/bin/morph-spec.js +43 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +20 -22
- package/framework/agents.json +198 -44
- package/framework/ai-pin.json +22 -0
- package/framework/commands/morph-media.md +45 -0
- package/framework/docs/CLI.md +238 -10
- package/framework/evals/proof-rubrics/README.md +76 -0
- package/framework/evals/proof-rubrics/eval-de-modelo.md +64 -0
- package/framework/evals/proof-rubrics/fiacao.md +54 -0
- package/framework/evals/proof-rubrics/guard-cego.md +52 -0
- package/framework/evals/proof-rubrics/media-claims.md +109 -0
- package/framework/evals/proof-rubrics/mutation-proof.md +77 -0
- package/framework/evals/proof-rubrics/observabilidade-genai.md +119 -0
- package/framework/evals/proof-rubrics/verde-nao-e-coberto.md +48 -0
- package/framework/evals/rubrics/architecture.md +30 -0
- package/framework/evals/rubrics/code-quality.md +26 -0
- package/framework/evals/rubrics/test-coverage.md +20 -0
- package/framework/hooks/README.md +11 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
- package/framework/hooks/claude-code/core/chain.js +27 -7
- package/framework/hooks/claude-code/core/protect-spec-files.js +41 -4
- package/framework/hooks/claude-code/core/worktree-context.js +83 -14
- package/framework/hooks/claude-code/core/worktree-create.js +206 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
- package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
- package/framework/hooks/shared/command-tokens.js +23 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/git-paths.js +25 -0
- package/framework/hooks/shared/hook-response.js +17 -0
- package/framework/hooks/shared/morph-cli.js +134 -0
- package/framework/rules/ai-agents-standards.md +14 -2
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/schemas/ai-pin.schema.json +97 -0
- package/framework/schemas/config.schema.json +107 -0
- package/framework/schemas/feature.schema.json +23 -4
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +28 -7
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -2
- package/framework/skills/core/morph-media/SKILL.md +215 -0
- package/framework/skills/core/morph-plan/SKILL.md +38 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +166 -9
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/PROVENANCE.json +81 -3
- package/framework/standards/STANDARDS.json +1504 -830
- package/framework/standards/ai-agents/agent-archetypes.md +160 -0
- package/framework/standards/ai-agents/agent-session.md +256 -160
- package/framework/standards/ai-agents/agent-spec.md +196 -0
- package/framework/standards/ai-agents/batching.md +217 -193
- package/framework/standards/ai-agents/context-providers.md +261 -188
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +239 -0
- package/framework/standards/ai-agents/cost-and-budget.md +256 -0
- package/framework/standards/ai-agents/durable-workflows-hitl.md +98 -5
- package/framework/standards/ai-agents/evals-with-cache.md +289 -0
- package/framework/standards/ai-agents/guardrails.md +222 -0
- package/framework/standards/ai-agents/llm-runtime-defaults.md +226 -0
- package/framework/standards/ai-agents/mcp-server.md +351 -250
- package/framework/standards/ai-agents/mcp-tools.md +250 -197
- package/framework/standards/ai-agents/media-pipeline.md +352 -0
- package/framework/standards/ai-agents/media-video.md +267 -0
- package/framework/standards/ai-agents/middleware-patterns.md +241 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +422 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +177 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +189 -181
- package/framework/standards/ai-agents/modalities-vision.md +205 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +211 -155
- package/framework/standards/ai-agents/observability-patterns.md +227 -17
- package/framework/standards/ai-agents/production.md +260 -231
- package/framework/standards/ai-agents/prompt-sources.md +389 -0
- package/framework/standards/ai-agents/providers/model-registry.md +293 -158
- package/framework/standards/ai-agents/rag-custom-pgvector.md +69 -4
- package/framework/standards/ai-agents/service-tier-flex.md +143 -135
- package/framework/standards/ai-agents/setup.md +166 -50
- package/framework/standards/ai-agents/structured-output.md +280 -249
- package/framework/standards/ai-agents/sweet-spot.md +129 -109
- package/framework/standards/ai-agents/testing-ai.md +236 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +50 -20
- package/framework/standards/ai-agents/workflows.md +27 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +12 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +13 -0
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +90 -0
- package/framework/templates/REGISTRY.json +14 -1
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/dotnet/ai-kit/Directory.Build.props +25 -0
- package/framework/templates/dotnet/ai-kit/Directory.Packages.props +26 -0
- package/framework/templates/dotnet/ai-kit/Morph.AiKit.sln +56 -0
- package/framework/templates/dotnet/ai-kit/NuGet.config +12 -0
- package/framework/templates/dotnet/ai-kit/README.md +128 -0
- package/framework/templates/dotnet/ai-kit/global.json +9 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +65 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +289 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +181 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +14 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +174 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +54 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +75 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +71 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +246 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +57 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +78 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +225 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +463 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +32 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +49 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +214 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +168 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +142 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +349 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +42 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +77 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +28 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +107 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +253 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +103 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +259 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +21 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +47 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +102 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +44 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +192 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +94 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +99 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +97 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +133 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +93 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +112 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +288 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +158 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +29 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +208 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +95 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Morph.AiKit.csproj +38 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +119 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +82 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +33 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +267 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +474 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json +87 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +169 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +49 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +495 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +260 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +405 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +379 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +197 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +139 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +157 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +178 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +153 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +194 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +86 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +213 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +95 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +225 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +215 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +55 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +60 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +131 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +133 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +127 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +216 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +264 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +148 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +184 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +98 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +451 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +76 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +261 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +374 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/.morph-eval-cache.json +12 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/orcamento/acme/a-resposta-estruturada.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/a-saudacao.json +23 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/b-escalonamento.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/globex/a-saudacao.json +23 -0
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/package.json +3 -3
- package/src/commands/e2e/e2e.js +91 -40
- package/src/commands/project/doctor.js +143 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/update.js +469 -153
- package/src/commands/project/worktree.js +274 -53
- package/src/commands/state/advance.js +24 -2
- package/src/commands/state/approve.js +33 -1
- package/src/commands/state/create.js +72 -11
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +360 -53
- package/src/commands/state/gate-check.js +190 -6
- package/src/commands/state/score.js +36 -1
- package/src/commands/validation/verify.js +134 -25
- package/src/core/state/state-manager.js +9 -1
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/lib/ai/ai-checks.js +182 -0
- package/src/lib/ai/ai-project-scan.js +347 -0
- package/src/lib/ai/code-audit.js +181 -0
- package/src/lib/ai/comment-stripper.js +184 -0
- package/src/lib/ai/pin-drift.js +138 -0
- package/src/lib/ai/registry-audit.js +285 -0
- package/src/lib/e2e/compose-env.js +55 -14
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/evaluator-report.js +269 -15
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/git/default-branch.js +92 -4
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/installers/effects.js +193 -0
- package/src/lib/installers/templates-reconcile.js +3 -3
- package/src/lib/risk-detector.js +284 -0
- package/src/lib/standards/verified-against.js +276 -0
- package/src/lib/tasks/eval-node.js +485 -0
- package/src/lib/tasks/linux-runner.js +297 -0
- package/src/lib/tasks/test-nodes.js +486 -0
- package/src/lib/tasks/test-runner.js +431 -37
- package/src/lib/validators/verify-runner.js +590 -28
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/docker-audit.js +287 -0
- package/src/lib/worktree/env-allocator.js +69 -11
- package/src/lib/worktree/worktree-audit.js +70 -3
- package/src/scripts/setup-infra.js +24 -11
- package/src/utils/agents-installer.js +18 -8
- package/src/utils/claude-md-injector.js +10 -5
- package/src/utils/file-copier.js +28 -16
- package/src/utils/hooks-installer.js +75 -29
- package/src/utils/skills-installer.js +194 -187
- package/src/utils/version-checker.js +68 -3
- package/framework/standards/ai-agents/prompts-in-db.md +0 -214
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
using System.Collections;
|
|
2
|
+
using System.Reflection;
|
|
3
|
+
using System.Text.Json;
|
|
4
|
+
using Microsoft.Extensions.AI;
|
|
5
|
+
using Morph.AiKit.Agents;
|
|
6
|
+
using Xunit;
|
|
7
|
+
|
|
8
|
+
namespace Morph.AiKit.Tests;
|
|
9
|
+
|
|
10
|
+
/// <summary>
|
|
11
|
+
/// Canarios do agente-como-dado (T7). O que estes testes protegem nao e a existencia dos
|
|
12
|
+
/// tipos — o compilador ja faz isso — e sim a UNICA regra estrutural que
|
|
13
|
+
/// <see cref="AgentSpecOptions"/> tem: <b>nulo nao envia nada ao provider</b>.
|
|
14
|
+
/// </summary>
|
|
15
|
+
/// <remarks>
|
|
16
|
+
/// A comparacao e feita por reflexao contra um <see cref="ChatOptions"/> virgem, e nao
|
|
17
|
+
/// contra uma lista de propriedades escrita a mao, de proposito: uma propriedade nova no
|
|
18
|
+
/// <c>Microsoft.Extensions.AI</c> entra na comparacao sozinha. Uma lista escrita a mao
|
|
19
|
+
/// envelheceria em silencio no proximo bump do pin — que e exatamente o defeito que a
|
|
20
|
+
/// feature inteira existe para matar.
|
|
21
|
+
/// </remarks>
|
|
22
|
+
public sealed class AgentSpecTests
|
|
23
|
+
{
|
|
24
|
+
// ---------------------------------------------------------------------------------
|
|
25
|
+
// A regra estrutural: nulo = nao envia
|
|
26
|
+
// ---------------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
[Fact]
|
|
29
|
+
public void FreshOptionsProduceChatOptionsIndistinguishableFromPristine()
|
|
30
|
+
{
|
|
31
|
+
var produced = new AgentSpecOptions().ToChatOptions();
|
|
32
|
+
|
|
33
|
+
var differing = DiffAgainstPristine(produced);
|
|
34
|
+
|
|
35
|
+
Assert.True(
|
|
36
|
+
differing.Count == 0,
|
|
37
|
+
$"AgentSpecOptions recem-construida NAO pode enviar opcao nenhuma. Enviou: {string.Join(", ", differing)}");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
[Fact]
|
|
41
|
+
public void EveryAgentSpecOptionsPropertyIsNullOnAFreshInstance()
|
|
42
|
+
{
|
|
43
|
+
var fresh = new AgentSpecOptions();
|
|
44
|
+
|
|
45
|
+
var nonNull = typeof(AgentSpecOptions)
|
|
46
|
+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
|
47
|
+
.Where(p => p.GetIndexParameters().Length == 0)
|
|
48
|
+
.Where(p => p.GetValue(fresh) is not null)
|
|
49
|
+
.Select(p => p.Name)
|
|
50
|
+
.ToList();
|
|
51
|
+
|
|
52
|
+
Assert.True(
|
|
53
|
+
nonNull.Count == 0,
|
|
54
|
+
$"Toda propriedade de AgentSpecOptions nasce nula. Nasceram preenchidas: {string.Join(", ", nonNull)}");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[Fact]
|
|
58
|
+
public void SettingOnlyTemperatureSendsOnlyTemperature()
|
|
59
|
+
{
|
|
60
|
+
var produced = new AgentSpecOptions { Temperature = 0.2f }.ToChatOptions();
|
|
61
|
+
|
|
62
|
+
var differing = DiffAgainstPristine(produced);
|
|
63
|
+
|
|
64
|
+
Assert.Equal(new[] { nameof(ChatOptions.Temperature) }, differing);
|
|
65
|
+
Assert.Equal(0.2f, produced.Temperature);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
[Fact]
|
|
69
|
+
public void AgentLevelKnobsNeverLeakIntoChatOptions()
|
|
70
|
+
{
|
|
71
|
+
// As quatro opcoes de nivel de agente. Nenhuma tem casa em ChatOptions: elas sao
|
|
72
|
+
// lidas pela AgentFactory (T10) e pela telemetria (T12). Se alguem "resolver" isso
|
|
73
|
+
// despejando-as em AdditionalProperties, este teste morre — que e o ponto.
|
|
74
|
+
var produced = new AgentSpecOptions
|
|
75
|
+
{
|
|
76
|
+
MaxToolIterations = 7,
|
|
77
|
+
AllowConcurrentToolInvocation = true,
|
|
78
|
+
TimeoutSeconds = 30,
|
|
79
|
+
EnableSensitiveTelemetry = true,
|
|
80
|
+
}.ToChatOptions();
|
|
81
|
+
|
|
82
|
+
var differing = DiffAgainstPristine(produced);
|
|
83
|
+
|
|
84
|
+
Assert.True(
|
|
85
|
+
differing.Count == 0,
|
|
86
|
+
$"Opcao de nivel de agente vazou para ChatOptions: {string.Join(", ", differing)}");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------------
|
|
90
|
+
// Mapeamento de cada opcao de nivel de requisicao para a sua casa
|
|
91
|
+
// ---------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
[Fact]
|
|
94
|
+
public void EachRequestLevelOptionLandsInItsOwnChatOptionsHome()
|
|
95
|
+
{
|
|
96
|
+
var options = new AgentSpecOptions
|
|
97
|
+
{
|
|
98
|
+
Temperature = 0.35f,
|
|
99
|
+
TopP = 0.9f,
|
|
100
|
+
MaxOutputTokens = 512,
|
|
101
|
+
Seed = 1234,
|
|
102
|
+
StopSequences = ["<END>"],
|
|
103
|
+
ToolMode = ChatToolMode.RequireAny,
|
|
104
|
+
ReasoningEffort = ReasoningEffort.Medium,
|
|
105
|
+
ServiceTier = "flex",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
var produced = options.ToChatOptions();
|
|
109
|
+
|
|
110
|
+
Assert.Equal(0.35f, produced.Temperature);
|
|
111
|
+
Assert.Equal(0.9f, produced.TopP);
|
|
112
|
+
Assert.Equal(512, produced.MaxOutputTokens);
|
|
113
|
+
Assert.Equal(1234L, produced.Seed);
|
|
114
|
+
Assert.Equal(["<END>"], produced.StopSequences);
|
|
115
|
+
Assert.Same(ChatToolMode.RequireAny, produced.ToolMode);
|
|
116
|
+
Assert.Equal(ReasoningEffort.Medium, produced.Reasoning?.Effort);
|
|
117
|
+
|
|
118
|
+
// service tier NAO existe em ChatOptions no Microsoft.Extensions.AI 10.9.0 (apurado
|
|
119
|
+
// por reflexao sobre o assembly do pin) — viaja em AdditionalProperties.
|
|
120
|
+
Assert.NotNull(produced.AdditionalProperties);
|
|
121
|
+
Assert.True(produced.AdditionalProperties.TryGetValue(
|
|
122
|
+
AgentSpecOptions.ServiceTierPropertyName,
|
|
123
|
+
out var serviceTier));
|
|
124
|
+
Assert.Equal("flex", serviceTier);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
[Fact]
|
|
128
|
+
public void AnExplicitlyEmptyStopSequenceListIsSentAsWritten()
|
|
129
|
+
{
|
|
130
|
+
// "Nulo nao envia; nao-nulo envia o que voce escreveu." Tratar lista vazia como
|
|
131
|
+
// nula seria uma excecao escondida na unica regra do tipo.
|
|
132
|
+
var produced = new AgentSpecOptions { StopSequences = [] }.ToChatOptions();
|
|
133
|
+
|
|
134
|
+
Assert.Equal([nameof(ChatOptions.StopSequences)], DiffAgainstPristine(produced));
|
|
135
|
+
Assert.NotNull(produced.StopSequences);
|
|
136
|
+
Assert.Empty(produced.StopSequences);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
[Fact]
|
|
140
|
+
public void ApplyToNeverClobbersWhatTheCallerAlreadySet()
|
|
141
|
+
{
|
|
142
|
+
// A AgentFactory (T10) preenche Tools e ResponseFormat; ApplyTo nao pode apaga-los.
|
|
143
|
+
var destination = new ChatOptions
|
|
144
|
+
{
|
|
145
|
+
ResponseFormat = ChatResponseFormat.Json,
|
|
146
|
+
ModelId = "already-chosen",
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
var returned = new AgentSpecOptions { Temperature = 0.1f }.ApplyTo(destination);
|
|
150
|
+
|
|
151
|
+
Assert.Same(destination, returned);
|
|
152
|
+
Assert.Same(ChatResponseFormat.Json, destination.ResponseFormat);
|
|
153
|
+
Assert.Equal("already-chosen", destination.ModelId);
|
|
154
|
+
Assert.Equal(0.1f, destination.Temperature);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
[Fact]
|
|
158
|
+
public void ApplyToRejectsANullDestination()
|
|
159
|
+
=> Assert.Throws<ArgumentNullException>(() => new AgentSpecOptions().ApplyTo(null!));
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------------
|
|
162
|
+
// AgentSpec
|
|
163
|
+
// ---------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
[Fact]
|
|
166
|
+
public void AFreshSpecCarriesNoToolsNoSchemaAndSendsNoOptions()
|
|
167
|
+
{
|
|
168
|
+
var spec = new AgentSpec { Alias = "text-default", Instructions = "Seja util." };
|
|
169
|
+
|
|
170
|
+
Assert.Empty(spec.Tools);
|
|
171
|
+
Assert.Null(spec.OutputSchema);
|
|
172
|
+
Assert.Null(spec.Api);
|
|
173
|
+
Assert.NotNull(spec.Options);
|
|
174
|
+
Assert.Empty(DiffAgainstPristine(spec.Options.ToChatOptions()));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
[Theory]
|
|
178
|
+
[InlineData("")]
|
|
179
|
+
[InlineData(" ")]
|
|
180
|
+
public void ABlankAliasIsRejectedAtConstruction(string blank)
|
|
181
|
+
=> Assert.Throws<ArgumentException>(
|
|
182
|
+
() => new AgentSpec { Alias = blank, Instructions = "Seja util." });
|
|
183
|
+
|
|
184
|
+
[Theory]
|
|
185
|
+
[InlineData("")]
|
|
186
|
+
[InlineData(" ")]
|
|
187
|
+
public void BlankInstructionsAreRejectedAtConstruction(string blank)
|
|
188
|
+
=> Assert.Throws<ArgumentException>(
|
|
189
|
+
() => new AgentSpec { Alias = "text-default", Instructions = blank });
|
|
190
|
+
|
|
191
|
+
[Fact]
|
|
192
|
+
public void TheSpecApiWinsWhenPresentAndTheAliasWinsWhenItIsNull()
|
|
193
|
+
{
|
|
194
|
+
var withoutOverride = new AgentSpec { Alias = "a", Instructions = "i" };
|
|
195
|
+
var withOverride = withoutOverride with { Api = AgentApi.Chat };
|
|
196
|
+
|
|
197
|
+
// alias manda quando a spec nao opinou
|
|
198
|
+
Assert.Equal(AgentApi.Responses, withoutOverride.ResolveApi(AgentApi.Responses));
|
|
199
|
+
Assert.Equal(AgentApi.Chat, withoutOverride.ResolveApi(AgentApi.Chat));
|
|
200
|
+
|
|
201
|
+
// spec manda quando opinou — inclusive contra o alias
|
|
202
|
+
Assert.Equal(AgentApi.Chat, withOverride.ResolveApi(AgentApi.Responses));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
[Fact]
|
|
206
|
+
public void ResponsesIsTheDefaultOfTheEnumBecauseItIsTheHouseDefault()
|
|
207
|
+
=> Assert.Equal(AgentApi.Responses, default(AgentApi));
|
|
208
|
+
|
|
209
|
+
[Fact]
|
|
210
|
+
public void AnOutputSchemaSurvivesAsLongAsItsDocumentDoes()
|
|
211
|
+
{
|
|
212
|
+
using var document = JsonDocument.Parse("""{"type":"object"}""");
|
|
213
|
+
|
|
214
|
+
var spec = new AgentSpec
|
|
215
|
+
{
|
|
216
|
+
Alias = "text-default",
|
|
217
|
+
Instructions = "Responda em JSON.",
|
|
218
|
+
OutputSchema = document.RootElement.Clone(),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
Assert.Equal(JsonValueKind.Object, spec.OutputSchema!.Value.ValueKind);
|
|
222
|
+
Assert.Equal("object", spec.OutputSchema.Value.GetProperty("type").GetString());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------------
|
|
226
|
+
// Helpers
|
|
227
|
+
// ---------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/// <summary>
|
|
230
|
+
/// Nomes das propriedades de <see cref="ChatOptions"/> em que <paramref name="actual"/>
|
|
231
|
+
/// difere de um <see cref="ChatOptions"/> virgem — ou seja, o conjunto exato do que
|
|
232
|
+
/// seria enviado ao provider.
|
|
233
|
+
/// </summary>
|
|
234
|
+
private static IReadOnlyList<string> DiffAgainstPristine(ChatOptions actual)
|
|
235
|
+
{
|
|
236
|
+
var pristine = new ChatOptions();
|
|
237
|
+
|
|
238
|
+
return [.. typeof(ChatOptions)
|
|
239
|
+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
|
240
|
+
.Where(p => p.GetIndexParameters().Length == 0 && p.CanRead)
|
|
241
|
+
.Where(p => !ValuesEqual(p.GetValue(actual), p.GetValue(pristine)))
|
|
242
|
+
.Select(p => p.Name)
|
|
243
|
+
.OrderBy(name => name, StringComparer.Ordinal)];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private static bool ValuesEqual(object? left, object? right)
|
|
247
|
+
{
|
|
248
|
+
if (left is null || right is null)
|
|
249
|
+
{
|
|
250
|
+
return left is null && right is null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (left is IEnumerable leftItems and not string && right is IEnumerable rightItems and not string)
|
|
254
|
+
{
|
|
255
|
+
return leftItems.Cast<object?>().SequenceEqual(rightItems.Cast<object?>());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return Equals(left, right);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
using System.Collections.Concurrent;
|
|
2
|
+
using Microsoft.Agents.AI;
|
|
3
|
+
using Microsoft.Extensions.AI;
|
|
4
|
+
using Morph.AiKit.Conversation;
|
|
5
|
+
using Morph.AiKit.Tests.Fakes;
|
|
6
|
+
using Xunit;
|
|
7
|
+
|
|
8
|
+
namespace Morph.AiKit.Tests;
|
|
9
|
+
|
|
10
|
+
/// <summary>
|
|
11
|
+
/// Canarios do <see cref="ConversationStore"/> (T13). A afirmacao central: <b>uma unica
|
|
12
|
+
/// instancia de provider atende todas as sessoes, e o historico de uma conversa nunca
|
|
13
|
+
/// aparece em outra</b> — porque o estado por sessao vive na
|
|
14
|
+
/// <see cref="AgentSession"/> (via <see cref="ProviderSessionState{TState}"/>), jamais num
|
|
15
|
+
/// campo do provider.
|
|
16
|
+
/// </summary>
|
|
17
|
+
/// <remarks>
|
|
18
|
+
/// Os testes rodam pelo caminho <b>real</b> do MAF: um <see cref="ChatClientAgent"/> de
|
|
19
|
+
/// verdade, com <c>ChatClientAgentOptions.ChatHistoryProvider</c> apontando para o
|
|
20
|
+
/// <see cref="ConversationStore"/>, sessoes criadas por <c>CreateSessionAsync</c> e voltas
|
|
21
|
+
/// disparadas por <c>RunAsync</c>. O unico fake e o <see cref="FakeChatClient"/> no lugar do
|
|
22
|
+
/// provider de modelo — e por isso "o provider e chamado" e um fato observado, e nao uma
|
|
23
|
+
/// chamada manual ao metodo protegido fingindo ser o framework.
|
|
24
|
+
/// </remarks>
|
|
25
|
+
public sealed class ConversationStoreTests
|
|
26
|
+
{
|
|
27
|
+
private static ChatClientAgent Agent(FakeChatClient client, ConversationStore history)
|
|
28
|
+
=> new(client, new ChatClientAgentOptions
|
|
29
|
+
{
|
|
30
|
+
Name = "atendente",
|
|
31
|
+
ChatOptions = new ChatOptions { Instructions = "Voce e um atendente." },
|
|
32
|
+
ChatHistoryProvider = history,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
36
|
+
|
|
37
|
+
private static string[] TextsOf(IEnumerable<ChatMessage> messages)
|
|
38
|
+
=> [.. messages.Select(m => m.Text)];
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------------
|
|
41
|
+
// doneCriteria 2: AppendAsync recebe a volta; LoadAsync alimenta a volta seguinte
|
|
42
|
+
// ---------------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/// <summary>
|
|
45
|
+
/// Uma volta e gravada com pedido e resposta, nessa ordem — e a volta seguinte chega ao
|
|
46
|
+
/// modelo ja com essa historia na frente. As duas metades do contrato, medidas nos dois
|
|
47
|
+
/// lados: no acervo e no que o cliente de modelo recebeu.
|
|
48
|
+
/// </summary>
|
|
49
|
+
[Fact]
|
|
50
|
+
public async Task TheRoundIsAppendedAndFeedsTheHistoryOfTheNextRound()
|
|
51
|
+
{
|
|
52
|
+
var store = new InMemoryConversationStore();
|
|
53
|
+
var history = new ConversationStore(store);
|
|
54
|
+
using var client = new FakeChatClient()
|
|
55
|
+
.EnqueueText("Vou verificar o pedido 1234.")
|
|
56
|
+
.EnqueueText("Foi despachado ontem.");
|
|
57
|
+
|
|
58
|
+
var agent = Agent(client, history);
|
|
59
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
60
|
+
history.BindConversation(session, "conversa-A");
|
|
61
|
+
|
|
62
|
+
await agent.RunAsync("Meu pedido 1234 esta atrasado.", session, cancellationToken: Ct);
|
|
63
|
+
|
|
64
|
+
// Depois da 1a volta o acervo tem exatamente o pedido e a resposta daquela volta.
|
|
65
|
+
var afterFirst = await store.LoadAsync("conversa-A", Ct);
|
|
66
|
+
Assert.Equal(
|
|
67
|
+
["Meu pedido 1234 esta atrasado.", "Vou verificar o pedido 1234."],
|
|
68
|
+
TextsOf(afterFirst));
|
|
69
|
+
|
|
70
|
+
await agent.RunAsync("E quando foi despachado?", session, cancellationToken: Ct);
|
|
71
|
+
|
|
72
|
+
// A 2a ida ao modelo levou o historico carregado do acervo, e so uma vez cada
|
|
73
|
+
// mensagem — o turno corrente nao vem duplicado.
|
|
74
|
+
var secondCall = TextsOf(client.ReceivedMessages[1]);
|
|
75
|
+
Assert.Equal(
|
|
76
|
+
[
|
|
77
|
+
"Meu pedido 1234 esta atrasado.",
|
|
78
|
+
"Vou verificar o pedido 1234.",
|
|
79
|
+
"E quando foi despachado?",
|
|
80
|
+
],
|
|
81
|
+
[.. secondCall.Where(t => t.Length > 0 && t != "Voce e um atendente.")]);
|
|
82
|
+
|
|
83
|
+
var afterSecond = await store.LoadAsync("conversa-A", Ct);
|
|
84
|
+
Assert.Equal(4, afterSecond.Count);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// <summary>
|
|
88
|
+
/// A primeira volta nao carrega historico nenhum: conversa inexistente e lista vazia, nao
|
|
89
|
+
/// erro. E o caso mais comum de todos.
|
|
90
|
+
/// </summary>
|
|
91
|
+
[Fact]
|
|
92
|
+
public async Task AConversationThatDoesNotExistYetLoadsEmptyInsteadOfFailing()
|
|
93
|
+
{
|
|
94
|
+
var store = new InMemoryConversationStore();
|
|
95
|
+
var history = new ConversationStore(store);
|
|
96
|
+
using var client = new FakeChatClient().EnqueueText("ola");
|
|
97
|
+
|
|
98
|
+
var agent = Agent(client, history);
|
|
99
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
100
|
+
history.BindConversation(session, "conversa-nova");
|
|
101
|
+
|
|
102
|
+
Assert.Empty(await store.LoadAsync("conversa-nova", Ct));
|
|
103
|
+
|
|
104
|
+
await agent.RunAsync("oi", session, cancellationToken: Ct);
|
|
105
|
+
|
|
106
|
+
Assert.Equal(["oi", "ola"], TextsOf(await store.LoadAsync("conversa-nova", Ct)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------------
|
|
110
|
+
// doneCriteria 1: DUAS sessoes concorrentes na MESMA instancia, sem vazamento
|
|
111
|
+
// ---------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/// <summary>
|
|
114
|
+
/// <b>O canario da invariante.</b> Uma instancia de <see cref="ConversationStore"/>, duas
|
|
115
|
+
/// sessoes, muitas voltas intercaladas em paralelo: nenhuma mensagem de uma conversa
|
|
116
|
+
/// aparece na outra, e cada uma tem exatamente as suas.
|
|
117
|
+
/// </summary>
|
|
118
|
+
/// <remarks>
|
|
119
|
+
/// Este e o teste que morre se alguem guardar "a conversa atual" num campo do provider —
|
|
120
|
+
/// o defeito que em producao aparece como historico de um usuario na tela de outro.
|
|
121
|
+
/// </remarks>
|
|
122
|
+
[Fact]
|
|
123
|
+
public async Task TwoConcurrentSessionsOnTheSameProviderNeverSeeEachOthersMessages()
|
|
124
|
+
{
|
|
125
|
+
const int Rounds = 25;
|
|
126
|
+
|
|
127
|
+
var store = new InMemoryConversationStore();
|
|
128
|
+
var history = new ConversationStore(store);
|
|
129
|
+
|
|
130
|
+
using var clientA = new FakeChatClient();
|
|
131
|
+
using var clientB = new FakeChatClient();
|
|
132
|
+
|
|
133
|
+
for (var i = 0; i < Rounds; i++)
|
|
134
|
+
{
|
|
135
|
+
clientA.EnqueueText($"resposta-A-{i}");
|
|
136
|
+
clientB.EnqueueText($"resposta-B-{i}");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Duas conversas distintas, o MESMO provider de historico nos dois agentes.
|
|
140
|
+
var agentA = Agent(clientA, history);
|
|
141
|
+
var agentB = Agent(clientB, history);
|
|
142
|
+
|
|
143
|
+
var sessionA = await agentA.CreateSessionAsync(Ct);
|
|
144
|
+
var sessionB = await agentB.CreateSessionAsync(Ct);
|
|
145
|
+
history.BindConversation(sessionA, "conversa-A");
|
|
146
|
+
history.BindConversation(sessionB, "conversa-B");
|
|
147
|
+
|
|
148
|
+
// Sequencial DENTRO de cada sessao (uma conversa e uma fila), concorrente ENTRE elas.
|
|
149
|
+
var driveA = Task.Run(async () =>
|
|
150
|
+
{
|
|
151
|
+
for (var i = 0; i < Rounds; i++)
|
|
152
|
+
{
|
|
153
|
+
await agentA.RunAsync($"pergunta-A-{i}", sessionA, cancellationToken: Ct);
|
|
154
|
+
}
|
|
155
|
+
}, Ct);
|
|
156
|
+
|
|
157
|
+
var driveB = Task.Run(async () =>
|
|
158
|
+
{
|
|
159
|
+
for (var i = 0; i < Rounds; i++)
|
|
160
|
+
{
|
|
161
|
+
await agentB.RunAsync($"pergunta-B-{i}", sessionB, cancellationToken: Ct);
|
|
162
|
+
}
|
|
163
|
+
}, Ct);
|
|
164
|
+
|
|
165
|
+
await Task.WhenAll(driveA, driveB);
|
|
166
|
+
|
|
167
|
+
var conversationA = TextsOf(await store.LoadAsync("conversa-A", Ct));
|
|
168
|
+
var conversationB = TextsOf(await store.LoadAsync("conversa-B", Ct));
|
|
169
|
+
|
|
170
|
+
Assert.Equal(Rounds * 2, conversationA.Length);
|
|
171
|
+
Assert.Equal(Rounds * 2, conversationB.Length);
|
|
172
|
+
|
|
173
|
+
// Nenhum texto do outro lado atravessou.
|
|
174
|
+
Assert.DoesNotContain(conversationA, t => t.Contains("-B-", StringComparison.Ordinal));
|
|
175
|
+
Assert.DoesNotContain(conversationB, t => t.Contains("-A-", StringComparison.Ordinal));
|
|
176
|
+
|
|
177
|
+
// E cada conversa tem as suas, na ordem.
|
|
178
|
+
Assert.Equal(
|
|
179
|
+
[.. Enumerable.Range(0, Rounds).SelectMany(i => new[] { $"pergunta-A-{i}", $"resposta-A-{i}" })],
|
|
180
|
+
conversationA);
|
|
181
|
+
|
|
182
|
+
// O que o modelo VIU tambem nao se misturou: a ultima ida de A nao carrega nada de B.
|
|
183
|
+
Assert.DoesNotContain(
|
|
184
|
+
TextsOf(clientA.ReceivedMessages[^1]),
|
|
185
|
+
t => t.Contains("-B-", StringComparison.Ordinal));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// <summary>
|
|
189
|
+
/// A prova direta de que o estado por sessao <b>mora na sessao</b>: o mesmo provider
|
|
190
|
+
/// devolve ids diferentes para sessoes diferentes, e o id de cada sessao esta gravado
|
|
191
|
+
/// no <c>StateBag</c> dela — que e onde o MAF o serializa e o retoma.
|
|
192
|
+
/// </summary>
|
|
193
|
+
[Fact]
|
|
194
|
+
public async Task TheConversationIdLivesInTheSessionStateNotInAProviderField()
|
|
195
|
+
{
|
|
196
|
+
var history = new ConversationStore(new InMemoryConversationStore());
|
|
197
|
+
using var client = new FakeChatClient();
|
|
198
|
+
var agent = Agent(client, history);
|
|
199
|
+
|
|
200
|
+
var first = await agent.CreateSessionAsync(Ct);
|
|
201
|
+
var second = await agent.CreateSessionAsync(Ct);
|
|
202
|
+
|
|
203
|
+
var firstId = history.GetConversationId(first);
|
|
204
|
+
var secondId = history.GetConversationId(second);
|
|
205
|
+
|
|
206
|
+
Assert.NotEqual(firstId, secondId);
|
|
207
|
+
Assert.Equal(firstId, history.GetConversationId(first)); // estavel na mesma sessao
|
|
208
|
+
|
|
209
|
+
// Esta gravado na sessao, sob a chave que o provider declara em StateKeys.
|
|
210
|
+
Assert.True(first.StateBag.TryGetValue<string>(ConversationStore.ConversationIdStateKey, out var stored));
|
|
211
|
+
Assert.Equal(firstId, stored);
|
|
212
|
+
Assert.Contains(ConversationStore.ConversationIdStateKey, history.StateKeys);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// <summary>
|
|
216
|
+
/// Sem amarracao explicita o id e um <see cref="Guid.CreateVersion7"/> — ordenavel no
|
|
217
|
+
/// tempo, ao contrario de <c>Guid.NewGuid</c>. A versao 7 e legivel no proprio valor
|
|
218
|
+
/// (o nibble de versao), entao a regra da casa e verificavel e nao so declarada.
|
|
219
|
+
/// </summary>
|
|
220
|
+
[Fact]
|
|
221
|
+
public async Task AnUnboundSessionGetsATimeOrderedIdentifier()
|
|
222
|
+
{
|
|
223
|
+
var history = new ConversationStore(new InMemoryConversationStore());
|
|
224
|
+
using var client = new FakeChatClient();
|
|
225
|
+
var agent = Agent(client, history);
|
|
226
|
+
|
|
227
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
228
|
+
var id = history.GetConversationId(session);
|
|
229
|
+
|
|
230
|
+
Assert.True(Guid.TryParseExact(id, "N", out var parsed));
|
|
231
|
+
Assert.Equal(7, parsed.ToByteArray(bigEndian: true)[6] >> 4);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// <summary>
|
|
235
|
+
/// O caminho do CRM: a conversa ja existe como registro, e a sessao entra nela — o
|
|
236
|
+
/// historico anterior aparece na primeira volta desta sessao. E o que torna a decisao
|
|
237
|
+
/// "conversa no acervo do projeto" util em vez de teorica.
|
|
238
|
+
/// </summary>
|
|
239
|
+
[Fact]
|
|
240
|
+
public async Task ASessionCanJoinAConversationThatAlreadyExistsInTheProjectStore()
|
|
241
|
+
{
|
|
242
|
+
var store = new InMemoryConversationStore();
|
|
243
|
+
await store.AppendAsync(
|
|
244
|
+
"crm-conversa-42",
|
|
245
|
+
[
|
|
246
|
+
new ChatMessage(ChatRole.User, "Ontem eu pedi um orcamento."),
|
|
247
|
+
new ChatMessage(ChatRole.Assistant, "Enviei por email."),
|
|
248
|
+
],
|
|
249
|
+
Ct);
|
|
250
|
+
|
|
251
|
+
var history = new ConversationStore(store);
|
|
252
|
+
using var client = new FakeChatClient().EnqueueText("Reenviei agora.");
|
|
253
|
+
var agent = Agent(client, history);
|
|
254
|
+
|
|
255
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
256
|
+
history.BindConversation(session, "crm-conversa-42");
|
|
257
|
+
|
|
258
|
+
await agent.RunAsync("Nao chegou.", session, cancellationToken: Ct);
|
|
259
|
+
|
|
260
|
+
Assert.Contains("Ontem eu pedi um orcamento.", TextsOf(client.ReceivedMessages[0]));
|
|
261
|
+
// 2 do acervo anterior + o pedido e a resposta desta volta. A volta grava SO a si
|
|
262
|
+
// mesma: regravar o historico inteiro faria a conversa crescer em progressao
|
|
263
|
+
// geometrica a cada turno.
|
|
264
|
+
Assert.Equal(4, (await store.LoadAsync("crm-conversa-42", Ct)).Count);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------------
|
|
268
|
+
// O acervo e plugavel: o kit nao conhece banco
|
|
269
|
+
// ---------------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/// <summary>
|
|
272
|
+
/// A costura e substituivel: um acervo de teste que apenas anota as chamadas prova que o
|
|
273
|
+
/// provider fala com <see cref="IConversationStore"/> e com mais nada — e um projeto que
|
|
274
|
+
/// troque memoria por <c>jsonb</c> so implementa esta interface.
|
|
275
|
+
/// </summary>
|
|
276
|
+
[Fact]
|
|
277
|
+
public async Task TheProviderTalksOnlyToTheSeamSoPersistenceIsReplaceable()
|
|
278
|
+
{
|
|
279
|
+
var spy = new RecordingConversationStore();
|
|
280
|
+
var history = new ConversationStore(spy);
|
|
281
|
+
using var client = new FakeChatClient().EnqueueText("pronto");
|
|
282
|
+
|
|
283
|
+
var agent = Agent(client, history);
|
|
284
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
285
|
+
history.BindConversation(session, "c-1");
|
|
286
|
+
|
|
287
|
+
await agent.RunAsync("faz", session, cancellationToken: Ct);
|
|
288
|
+
|
|
289
|
+
Assert.Equal(["load:c-1", "append:c-1"], spy.Calls);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// <summary>
|
|
293
|
+
/// Volta que falhou nao vira historico. Persistir o pedido sem resposta deixaria a
|
|
294
|
+
/// conversa terminando numa pergunta nunca respondida — e a volta seguinte a repetiria,
|
|
295
|
+
/// com o usuario se vendo duplicado.
|
|
296
|
+
/// </summary>
|
|
297
|
+
[Fact]
|
|
298
|
+
public async Task AFailedRoundIsNotPersisted()
|
|
299
|
+
{
|
|
300
|
+
var store = new InMemoryConversationStore();
|
|
301
|
+
var history = new ConversationStore(store);
|
|
302
|
+
using var client = new FakeChatClient(); // fila vazia: a volta estoura
|
|
303
|
+
var agent = Agent(client, history);
|
|
304
|
+
|
|
305
|
+
var session = await agent.CreateSessionAsync(Ct);
|
|
306
|
+
history.BindConversation(session, "conversa-falha");
|
|
307
|
+
|
|
308
|
+
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
309
|
+
() => agent.RunAsync("oi", session, cancellationToken: Ct));
|
|
310
|
+
|
|
311
|
+
Assert.Empty(await store.LoadAsync("conversa-falha", Ct));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// <summary>
|
|
315
|
+
/// <b>Uma volta sem sessao.</b> O MAF declara <c>InvokingContext.Session</c> como
|
|
316
|
+
/// anulavel, e o provider recusa esse caso: inventar um id gravaria conversa orfa e usar
|
|
317
|
+
/// uma chave fixa juntaria tenants no mesmo balde — as duas saidas silenciosas sao
|
|
318
|
+
/// piores que a recusa. Este teste mede o que o framework realmente faz por esse
|
|
319
|
+
/// caminho, em vez de supor: e o comportamento observado que fica pinado aqui.
|
|
320
|
+
/// </summary>
|
|
321
|
+
/// <remarks>
|
|
322
|
+
/// O caminho de construir <c>InvokingContext</c> a mao — que seria a forma direta de
|
|
323
|
+
/// exercitar a guarda — <b>nao esta disponivel</b>: no MAF 1.20.0 os construtores de
|
|
324
|
+
/// <c>InvokingContext</c> e <c>InvokedContext</c> sao os unicos membros da area de
|
|
325
|
+
/// <c>ChatHistoryProvider</c> marcados <c>[Experimental("MAAI001")]</c>, e com
|
|
326
|
+
/// <c>TreatWarningsAsErrors</c> isso e erro de compilacao. Consequencia registrada: um
|
|
327
|
+
/// provider de historico so pode ser testado <b>atraves de um agente real</b>, que e
|
|
328
|
+
/// como esta classe inteira o testa.
|
|
329
|
+
/// </remarks>
|
|
330
|
+
[Fact]
|
|
331
|
+
public async Task ARoundWithoutAnExplicitSessionStillHasAConversationOfItsOwn()
|
|
332
|
+
{
|
|
333
|
+
var store = new InMemoryConversationStore();
|
|
334
|
+
var history = new ConversationStore(store);
|
|
335
|
+
using var client = new FakeChatClient().EnqueueText("respondido");
|
|
336
|
+
var agent = Agent(client, history);
|
|
337
|
+
|
|
338
|
+
await agent.RunAsync("sem sessao", cancellationToken: Ct);
|
|
339
|
+
|
|
340
|
+
// O agente cria uma sessao efemera para a volta: a guarda de sessao ausente nao e
|
|
341
|
+
// alcancada por este caminho, e a conversa nasce com id proprio em vez de cair num
|
|
342
|
+
// balde compartilhado.
|
|
343
|
+
var conversation = Assert.Single(store.ConversationIds);
|
|
344
|
+
Assert.Equal(["sem sessao", "respondido"], TextsOf(await store.LoadAsync(conversation, Ct)));
|
|
345
|
+
|
|
346
|
+
// E a volta seguinte, tambem sem sessao, NAO herda a conversa anterior — sem sessao
|
|
347
|
+
// nao ha continuidade, que e a semantica correta de uma chamada avulsa.
|
|
348
|
+
client.EnqueueText("de novo");
|
|
349
|
+
await agent.RunAsync("outra avulsa", cancellationToken: Ct);
|
|
350
|
+
|
|
351
|
+
Assert.Equal(2, store.ConversationIds.Count);
|
|
352
|
+
Assert.DoesNotContain("sem sessao", TextsOf(client.ReceivedMessages[^1]));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/// <summary>Provider sem acervo e defeito de composicao, e falha na construcao.</summary>
|
|
356
|
+
[Fact]
|
|
357
|
+
public void TheProviderRefusesToBeBuiltWithoutAStore()
|
|
358
|
+
=> Assert.Throws<ArgumentNullException>(() => new ConversationStore(null!));
|
|
359
|
+
|
|
360
|
+
/// <summary>
|
|
361
|
+
/// O acervo em memoria e ele proprio seguro sob concorrencia — senao o canario das duas
|
|
362
|
+
/// sessoes mediria a falta de trava aqui, e nao o isolamento do provider.
|
|
363
|
+
/// </summary>
|
|
364
|
+
[Fact]
|
|
365
|
+
public async Task TheInMemoryStoreDoesNotLoseWritesUnderConcurrency()
|
|
366
|
+
{
|
|
367
|
+
var store = new InMemoryConversationStore();
|
|
368
|
+
|
|
369
|
+
await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Task.Run(
|
|
370
|
+
async () => await store.AppendAsync(
|
|
371
|
+
i % 2 == 0 ? "par" : "impar",
|
|
372
|
+
[new ChatMessage(ChatRole.User, $"m{i}")],
|
|
373
|
+
Ct),
|
|
374
|
+
Ct)));
|
|
375
|
+
|
|
376
|
+
Assert.Equal(50, (await store.LoadAsync("par", Ct)).Count);
|
|
377
|
+
Assert.Equal(50, (await store.LoadAsync("impar", Ct)).Count);
|
|
378
|
+
Assert.Empty(await store.LoadAsync("inexistente", Ct));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// <summary>Acervo de teste que so anota o que lhe pediram, na ordem.</summary>
|
|
382
|
+
private sealed class RecordingConversationStore : IConversationStore
|
|
383
|
+
{
|
|
384
|
+
private readonly ConcurrentQueue<string> _calls = new();
|
|
385
|
+
|
|
386
|
+
public IReadOnlyList<string> Calls => [.. _calls];
|
|
387
|
+
|
|
388
|
+
public ValueTask<IReadOnlyList<ChatMessage>> LoadAsync(
|
|
389
|
+
string conversationId,
|
|
390
|
+
CancellationToken cancellationToken)
|
|
391
|
+
{
|
|
392
|
+
_calls.Enqueue($"load:{conversationId}");
|
|
393
|
+
return ValueTask.FromResult<IReadOnlyList<ChatMessage>>([]);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
public ValueTask AppendAsync(
|
|
397
|
+
string conversationId,
|
|
398
|
+
IReadOnlyList<ChatMessage> messages,
|
|
399
|
+
CancellationToken cancellationToken)
|
|
400
|
+
{
|
|
401
|
+
_calls.Enqueue($"append:{conversationId}");
|
|
402
|
+
return ValueTask.CompletedTask;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|