Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ follow [Semantic Versioning](https://semver.org). Ongoing work is collected unde

### Added

- **Kiosk demo shows a business-scale deployment.** The kiosk's seeded history now runs at
per-agent business volumes — roughly 1,300–1,700 interactions a day across the four showcase
agents over the 14-day window, with production-sized token counts — so the dashboard's cost,
throughput and token cards read like a real installation instead of a toy. A new simulated
**live traffic feed** keeps fabricating agent calls after boot (paced along the same day/night
curve as the history), so the pulse band, live telemetry and recent-traces feed stay in motion
during a demo even without a real LLM endpoint configured.

- **Agent-proposed test cases.** The trace detail panel gains a **Generate tests** action: an agent
reads the trace's *whole* conversation and proposes the test cases actually worth building —
the turns where the agent decided something, not every turn — as GREEN promotions (lock in what it
Expand Down
8 changes: 8 additions & 0 deletions Proxytrace.Api.Tests/SeededRandomIsNotUsedForSecretsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public sealed class SeededRandomIsNotUsedForSecretsTests : BaseTest<Module>
// on every boot. Produces trace statistics, never a credential.
["Proxytrace.Application.Demo.Scenarios.StatisticsBackfillScenario"] =
"demo data seeding — deterministic by design, mints no credential",
// Samples the demo traffic's content/token/latency shape for the backfill scenario above
// and the kiosk live feed. Same surface, same rationale: statistics, never a credential.
["Proxytrace.Application.Demo.Internal.DemoCallPlanner"] =
"demo traffic sampling — deterministic by design, mints no credential",
// Paces and profiles the kiosk's simulated live traffic (delays, agent/endpoint picks).
// Kiosk-only fabricated telemetry, never a credential.
["Proxytrace.Application.Demo.Internal.KioskLiveTrafficService"] =
"kiosk live traffic pacing — fabricated demo telemetry, mints no credential",
};

[TestMethod]
Expand Down
36 changes: 36 additions & 0 deletions Proxytrace.Application.Tests/Demo/DemoSeedingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,42 @@ public async Task Seed_Endpoints_Price_Traces_With_Displayable_Cost()
}
}

[TestMethod]
public async Task Seed_Backfill_Produces_Business_Scale_Call_Volume()
{
var calls = await services.GetRequiredService<IRepository<IAgentCall>>()
.GetAllAsync(CancellationToken);

// The 14-day backfill runs at per-agent business volumes (DemoTrafficCatalog: ~1,300–1,700
// interactions/day across the four agents, tool round-trips adding a second call). The
// floor is deliberately below the expected ~30k so random volume draws never flake it,
// while still failing hard if the volumes regress to toy scale.
calls.Count.Should().BeGreaterThan(15_000,
"the kiosk dashboard must show business-scale traffic, not a handful of demo rows");

// Throughput on the default 24h dashboard window: the trailing day must carry a full
// business day of calls (minimum daily volume across agents is ~1,270 interactions).
var dayWindowStart = DateTimeOffset.UtcNow.AddHours(-24);
calls.Count(c => c.CreatedAt >= dayWindowStart).Should().BeGreaterThan(700,
"the default dashboard window must read as an active business day");
}

[TestMethod]
public async Task Seed_Backfill_Carries_A_Meaningful_Total_Spend()
{
var calls = await services.GetRequiredService<IRepository<IAgentCall>>()
.GetAllAsync(CancellationToken);

decimal total = calls
.Select(c => c.Response?.Usage is { } usage ? c.Endpoint.CalculateCost(usage) ?? 0m : 0m)
.Sum();

// ~€20–30/day at the catalog's volumes and token weights → several hundred euros across
// the window. The floor is far below the expectation but far above what toy data produces.
total.Should().BeGreaterThan(100m,
"the seeded window must carry a spend that reads like a real deployment's LLM bill");
}

[TestMethod]
public async Task Seed_Flags_Outlier_Calls_With_Every_Flag_Kind()
{
Expand Down
65 changes: 65 additions & 0 deletions Proxytrace.Application.Tests/Demo/KioskLiveTrafficServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Autofac;
using AwesomeAssertions;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Proxytrace.Application.Demo.Internal;
using Proxytrace.Application.Demo.Scenarios;
using Proxytrace.Application.Streaming;
using Proxytrace.Domain;
using Proxytrace.Domain.AgentCall;
using Proxytrace.Domain.Kiosk;
using Nordstein.Core.Testing;

namespace Proxytrace.Application.Tests.Demo;

[TestClass]
public class KioskLiveTrafficServiceTests : BaseTest<Module>
{
[TestMethod]
public async Task EmitInteraction_PersistsFreshCalls_ForASeededDemoAgent()
{
var broadcaster = Substitute.For<ITraceBroadcaster>();
IServiceProvider services = GetServices(builder =>
{
builder.RegisterInstance(new KioskOptions { Enabled = true }).AsSelf();
builder.RegisterInstance(broadcaster).As<ITraceBroadcaster>();
});

await services.GetRequiredService<CoreSeedScenario>().SeedAsync(CancellationToken);
var callRepo = services.GetRequiredService<IRepository<IAgentCall>>();
int before = (await callRepo.GetAllAsync(CancellationToken)).Count;

var sut = services.GetRequiredService<KioskLiveTrafficService>();
await sut.EmitInteractionAsync(CancellationToken);

var after = await callRepo.GetAllAsync(CancellationToken);
// One emission is a single call or a two-call tool round-trip.
(after.Count - before).Should().BeInRange(1, 2);

var fresh = after.OrderByDescending(c => c.CreatedAt).Take(after.Count - before).ToList();
fresh.Should().OnlyContain(
c => c.CreatedAt > DateTimeOffset.UtcNow.AddMinutes(-1),
"live traffic must land 'now' so the dashboard's pulse and live telemetry pick it up");
}

[TestMethod]
public async Task EmitInteraction_BroadcastsATraceCreatedEvent_PerPersistedCall()
{
var broadcaster = Substitute.For<ITraceBroadcaster>();
IServiceProvider services = GetServices(builder =>
{
builder.RegisterInstance(new KioskOptions { Enabled = true }).AsSelf();
builder.RegisterInstance(broadcaster).As<ITraceBroadcaster>();
});

await services.GetRequiredService<CoreSeedScenario>().SeedAsync(CancellationToken);
var callRepo = services.GetRequiredService<IRepository<IAgentCall>>();
int before = (await callRepo.GetAllAsync(CancellationToken)).Count;

var sut = services.GetRequiredService<KioskLiveTrafficService>();
await sut.EmitInteractionAsync(CancellationToken);

int added = (await callRepo.GetAllAsync(CancellationToken)).Count - before;
broadcaster.Received(added).Publish(Arg.Any<TraceCreatedEvent>());
}
}
201 changes: 201 additions & 0 deletions Proxytrace.Application/Demo/Internal/DemoCallPlanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System.Net;
using Nordstein.Core.AI.Completions;
using Nordstein.Core.AI.Messages;
using Nordstein.Core.AI.Tools;
using Nordstein.Core.Common.Random;
using Proxytrace.Domain.AgentCall;

namespace Proxytrace.Application.Demo.Internal;

/// <summary>
/// One planned agent call within a simulated interaction. <paramref name="RequestTail"/> holds the
/// request messages <em>after</em> the agent's system message (the executor prepends that, since
/// only it knows the agent); a <see langword="null"/> <paramref name="ResponseMessage"/> means the
/// call errored and has no completion.
/// </summary>
internal sealed record PlannedDemoCall(
IReadOnlyList<Message> RequestTail,
AssistantMessage? ResponseMessage,
TokenUsage Usage,
int LatencyMs,
HttpStatusCode HttpStatus,
string? ErrorMessage,
string? FinishReason,
OutlierFlags OutlierFlags,
int OffsetSeconds);

/// <summary>
/// A simulated interaction: a single call, or a two-call tool round-trip whose calls share one
/// conversation id (<paramref name="SharesConversation"/>).
/// </summary>
internal sealed record DemoInteractionPlan(
IReadOnlyList<PlannedDemoCall> Calls,
bool SharesConversation);

/// <summary>
/// Samples one simulated interaction (content, token usage, latency, error/outlier dice) from a
/// <see cref="DemoTrafficCatalog.AgentTraffic"/> profile. This is the single source of the demo
/// traffic's statistical shape: the historical backfill and the live traffic feed both draw from
/// it, so "yesterday" and "right now" describe the same business.
/// </summary>
internal sealed class DemoCallPlanner
{
private readonly IRandom random;

public DemoCallPlanner(IRandom random)
{
this.random = random;
}

public DemoInteractionPlan Plan(DemoTrafficCatalog.AgentTraffic traffic)
{
bool isError = random.Double() < DemoTrafficCatalog.ErrorRate;
bool isSpike = !isError && random.Double() < DemoTrafficCatalog.TokenSpikeRate;

if (!isError && !isSpike && traffic.ToolStories.Length > 0 && random.Double() < traffic.ToolRate)
{
return PlanToolConversation(traffic);
}

return new DemoInteractionPlan([PlanSingleCall(traffic, isError, isSpike)], SharesConversation: false);
}

private PlannedDemoCall PlanSingleCall(DemoTrafficCatalog.AgentTraffic traffic, bool isError, bool isSpike)
{
var flags = OutlierFlags.None;
string userText;
string assistantText;
ulong inTok;
ulong outTok;
ulong cachedIn;

if (isSpike)
{
// A conversation that ballooned: far above the profile's baseline mean, with a pasted
// wall of text in the request to match the numbers. The one-off paste also misses the
// prompt cache.
var spike = traffic.Spike;
userText = spike.User;
assistantText = spike.Assistant;
inTok = (ulong)random.Int(spike.MinIn, spike.MaxIn + 1);
outTok = (ulong)random.Int(spike.MinOut, spike.MaxOut + 1);
cachedIn = 0;
flags |= OutlierFlags.HighTokens;
}
else
{
(userText, assistantText) = random.Any(traffic.Pool);
var shape = traffic.Text;
inTok = (ulong)random.Int(shape.MinIn, shape.MaxIn + 1);
outTok = (ulong)random.Int(shape.MinOut, shape.MaxOut + 1);

// Most calls hit the prompt cache for part of the input; ~30% miss entirely, giving
// the cache-hit KPI and distribution a realistic spread.
cachedIn = CachedShare(inTok);
}

int latencyMs;
if (random.Double() < DemoTrafficCatalog.LatencySpikeRate)
{
latencyMs = random.Int(4200, 9001);
flags |= OutlierFlags.HighLatency;
}
else if (flags.HasFlag(OutlierFlags.HighTokens))
{
// A big context takes longer, but stays inside the unflagged latency tail.
latencyMs = random.Int(1600, 3401);
}
else
{
latencyMs = random.Double() < DemoTrafficCatalog.LatencyTailRate
? random.Int(1500, 3501)
: random.Int(400, 901);
}

var userMsg = new UserMessage([Content.FromText(userText)]);

if (isError)
{
var variant = random.Any(DemoTrafficCatalog.ErrorVariants);
return new PlannedDemoCall(
RequestTail: [userMsg],
ResponseMessage: null,
Usage: new TokenUsage(0, 0),
LatencyMs: latencyMs,
HttpStatus: variant.Status,
ErrorMessage: variant.Message,
FinishReason: null,
OutlierFlags: OutlierFlags.None,
OffsetSeconds: 0);
}

return new PlannedDemoCall(
RequestTail: [userMsg],
ResponseMessage: new AssistantMessage([Content.FromText(assistantText)], []),
Usage: new TokenUsage(inTok, outTok, cachedIn),
LatencyMs: latencyMs,
HttpStatus: HttpStatusCode.OK,
ErrorMessage: null,
FinishReason: "stop",
OutlierFlags: flags,
OffsetSeconds: 0);
}

private DemoInteractionPlan PlanToolConversation(DemoTrafficCatalog.AgentTraffic traffic)
{
var story = random.Any(traffic.ToolStories);
string orderId = random.Int(10000, 100000).ToString();
var user = new UserMessage([Content.FromText(story.User(orderId))]);

var toolRequest = new ToolRequest(
id: $"call_{story.ToolName}_{orderId}",
name: story.ToolName,
arguments: story.Arguments(orderId));
var assistantToolMsg = new AssistantMessage([], [toolRequest]);

var shape = traffic.ToolTurn;
ulong toolTurnIn = (ulong)random.Int(shape.MinIn, shape.MaxIn + 1);
var toolCall = new PlannedDemoCall(
RequestTail: [user],
ResponseMessage: assistantToolMsg,
Usage: new TokenUsage(
toolTurnIn,
(ulong)random.Int(shape.MinOut, shape.MaxOut + 1),
CachedShare(toolTurnIn)),
LatencyMs: random.Int(380, 721),
HttpStatus: HttpStatusCode.OK,
ErrorMessage: null,
FinishReason: "tool_calls",
OutlierFlags: OutlierFlags.None,
OffsetSeconds: 0);

var toolMsg = new ToolMessage(new ToolResponse(
toolRequest, [Content.FromText(story.ToolResult(orderId))]));
var finalAssistant = new AssistantMessage([Content.FromText(story.Final(orderId))], []);

// The answer turn re-sends the grown conversation (tool result included) and writes the
// user-facing reply, so it carries more input and roughly twice the output of the request
// turn.
ulong finalTurnIn = toolTurnIn + (ulong)random.Int(120, 421);
var answerCall = new PlannedDemoCall(
RequestTail: [user, assistantToolMsg, toolMsg],
ResponseMessage: finalAssistant,
Usage: new TokenUsage(
finalTurnIn,
(ulong)random.Int(shape.MinOut * 2, shape.MaxOut * 2 + 1),
CachedShare(finalTurnIn)),
LatencyMs: random.Int(430, 821),
HttpStatus: HttpStatusCode.OK,
ErrorMessage: null,
FinishReason: "stop",
OutlierFlags: OutlierFlags.None,
OffsetSeconds: random.Int(2, 9));

return new DemoInteractionPlan([toolCall, answerCall], SharesConversation: true);
}

private ulong CachedShare(ulong inTok)
=> random.Double() < DemoTrafficCatalog.UncachedShareRate
? 0UL
: (ulong)(inTok * random.Double(0.3, 0.8));
}
Loading
Loading