Prompt history: every run records its transcript (task, each model step with tokens and cost, tool calls and results, engine follow-ups) - bounded in size, scrubbed of recognisable secrets, system prompts stored once per text. Retention in days per instance (default 14) and per agent (0 = never, -1 = forever); an hourly janitor deletes what is past it, "never" also purges. Usage analysis over run receipts: filter by period, agent, model, event, status, cost and free text; sort by every column; totals over the whole filter; hourly or daily series grouped by agent, model or event; markers for budget denials and model changes; run detail with receipt, transcript, tool calls and approvals. WebUI: Analysis > Token usage > Runs / Timeline, run detail, Prompt History with retention settings. New code, comments and texts in English; texts come from language packs (i18n/en.json, de.json) via modules/i18n.js. CLAUDE.md records English as the main language. Version 0.1.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,25 @@ public sealed class AgentAdministrationTests
|
||||
host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt");
|
||||
}
|
||||
|
||||
// ─── #660: prompt history retention ───
|
||||
|
||||
[Fact]
|
||||
public void Prompt_history_retention_is_stored_per_agent_and_for_the_instance()
|
||||
{
|
||||
using var host = new TestHost();
|
||||
host.Host.AgentAdmin.Add(new NewAgent("tresor", "Tresor", "modell"));
|
||||
|
||||
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", 0).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.SetDefaultPromptHistoryRetention(30).Ok.ShouldBeTrue();
|
||||
|
||||
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
|
||||
reloaded.PromptHistory.RetentionDays.ShouldBe(30);
|
||||
reloaded.Agents.Single(a => a.AgentId == "tresor").PromptHistory.RetentionDays.ShouldBe(0);
|
||||
|
||||
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", null).Ok.ShouldBeTrue();
|
||||
host.Host.AgentAdmin.Find("tresor")!.PromptHistory.RetentionDays.ShouldBeNull("back to the instance default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rechte_nur_fuer_zugewiesene_Werkzeuge()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using ClawdDotNet.Core.Analytics;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Analytics;
|
||||
|
||||
/// <summary>
|
||||
/// Usage analysis (#660): filter, sort, page and bucket run receipts. Totals cover the whole
|
||||
/// filter, not only the page; runs refused before any model call are counted separately.
|
||||
/// </summary>
|
||||
public sealed class UsageAnalyticsTests
|
||||
{
|
||||
private static readonly DateTime Day = new(2026, 9, 19, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static RunReceipt Receipt(string agent, int hour, decimal cost, string model = "gemini", string status = "Completed",
|
||||
string source = "task", string? task = null, int prompt = 1000, string result = "ERGEBNIS: ok")
|
||||
=> new()
|
||||
{
|
||||
RunId = Guid.NewGuid().ToString("N"),
|
||||
AgentId = agent,
|
||||
Model = model,
|
||||
Source = source,
|
||||
TaskId = task,
|
||||
Status = status,
|
||||
StepCount = 3,
|
||||
PromptTokens = prompt,
|
||||
CompletionTokens = 100,
|
||||
CostUsd = cost,
|
||||
CostIsKnown = cost > 0,
|
||||
ResultRef = result,
|
||||
OccurredAt = Day.AddHours(hour)
|
||||
};
|
||||
|
||||
private static UsageAnalytics Analytics(params RunReceipt[] receipts)
|
||||
{
|
||||
var audit = new InMemoryAuditRepository();
|
||||
foreach (var r in receipts) audit.Receipts.Enqueue(r);
|
||||
return new UsageAnalytics(audit, (id, _) => Task.FromResult<string?>(id == "t-1" ? "Wetter abrufen" : null));
|
||||
}
|
||||
|
||||
private static RunQuery All => new() { FromUtc = Day, ToUtc = Day.AddDays(1) };
|
||||
|
||||
[Fact]
|
||||
public async Task Totals_cover_the_whole_filter_not_only_the_page()
|
||||
{
|
||||
var analytics = Analytics(
|
||||
Receipt("wetter", 1, 0.01m), Receipt("wetter", 2, 0.02m), Receipt("kurse", 3, 0.04m),
|
||||
Receipt("kurse", 4, 0m, status: "BudgetExceeded", prompt: 0));
|
||||
|
||||
var page = await analytics.QueryRunsAsync(All with { Limit = 1 }, default);
|
||||
|
||||
page.Items.Count.ShouldBe(1);
|
||||
page.Total.ShouldBe(4);
|
||||
page.Totals.CostUsd.ShouldBe(0.07m);
|
||||
page.Totals.NotRun.ShouldBe(1);
|
||||
page.Totals.RunsWithUnknownCost.ShouldBe(0, "a refused run has no cost to know");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filters_combine()
|
||||
{
|
||||
var analytics = Analytics(
|
||||
Receipt("wetter", 1, 0.01m, model: "haiku"), Receipt("wetter", 2, 0.02m, model: "gemini"),
|
||||
Receipt("kurse", 3, 0.04m, model: "gemini"));
|
||||
|
||||
var page = await analytics.QueryRunsAsync(All with { Agents = ["wetter"], Models = ["gemini"] }, default);
|
||||
|
||||
page.Total.ShouldBe(1);
|
||||
page.Items.Single().Receipt.CostUsd.ShouldBe(0.02m);
|
||||
(await analytics.QueryRunsAsync(All with { MinCostUsd = 0.015m }, default)).Total.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_search_covers_task_title_and_result()
|
||||
{
|
||||
var analytics = Analytics(
|
||||
Receipt("wetter", 1, 0.01m, task: "t-1"),
|
||||
Receipt("kurse", 2, 0.01m, result: "ERGEBNIS: NVDA bei 120"));
|
||||
|
||||
(await analytics.QueryRunsAsync(All with { Search = "wetter abrufen" }, default)).Items.Single().TaskTitle.ShouldBe("Wetter abrufen");
|
||||
(await analytics.QueryRunsAsync(All with { Search = "nvda" }, default)).Items.Single().Receipt.AgentId.ShouldBe("kurse");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cost", true, 0.04)]
|
||||
[InlineData("cost", false, 0.01)]
|
||||
[InlineData("time", true, 0.02)]
|
||||
public async Task Every_column_sorts(string sort, bool descending, double firstCost)
|
||||
{
|
||||
var analytics = Analytics(Receipt("a", 1, 0.01m), Receipt("b", 5, 0.02m), Receipt("c", 3, 0.04m));
|
||||
|
||||
var page = await analytics.QueryRunsAsync(All with { Sort = sort, Descending = descending }, default);
|
||||
|
||||
page.Items[0].Receipt.CostUsd.ShouldBe((decimal)firstCost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Series_stack_per_bucket_and_group()
|
||||
{
|
||||
var analytics = Analytics(Receipt("wetter", 1, 0.01m), Receipt("wetter", 1, 0.02m), Receipt("kurse", 1, 0.04m), Receipt("wetter", 5, 0.1m));
|
||||
|
||||
var points = await analytics.SeriesAsync(All, "hour", "agent", TimeSpan.Zero, default);
|
||||
|
||||
points.Count.ShouldBe(3);
|
||||
points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).CostUsd.ShouldBe(0.03m);
|
||||
points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).Runs.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_day_is_the_viewers_day()
|
||||
{
|
||||
// 23:30 UTC is already the next day in Berlin (UTC+2).
|
||||
var bucket = UsageAnalytics.BucketStart(Day.AddHours(23.5), "day", TimeSpan.FromHours(2));
|
||||
bucket.ShouldBe(Day.AddDays(1).AddHours(-2), "local midnight of 20.09 expressed in UTC");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_denials_and_model_changes_become_events()
|
||||
{
|
||||
var analytics = Analytics(
|
||||
Receipt("wetter", 1, 0.01m, model: "haiku"),
|
||||
Receipt("wetter", 2, 0.01m, model: "gemini"),
|
||||
Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini"),
|
||||
Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini"));
|
||||
|
||||
var events = await analytics.EventsAsync(Day, Day.AddDays(1), default);
|
||||
|
||||
events.Single(e => e.Kind == "model_changed").Text.ShouldBe("haiku → gemini");
|
||||
events.Single(e => e.Kind == "budget_exceeded").Text.ShouldContain("2 run(s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Facets_list_what_occurs()
|
||||
{
|
||||
var analytics = Analytics(Receipt("wetter", 1, 0.01m, source: "task"), Receipt("kurse", 1, 0.01m, source: "webui", model: "haiku"));
|
||||
|
||||
var facets = await analytics.FacetsAsync(Day, Day.AddDays(1), default);
|
||||
|
||||
facets.Agents.ShouldBe(["kurse", "wetter"]);
|
||||
facets.Sources.ShouldBe(["task", "webui"]);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ internal sealed class EngineFixture
|
||||
|
||||
public EngineFixture(
|
||||
IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null,
|
||||
IUsageRepository? usage = null)
|
||||
IUsageRepository? usage = null, Core.Transcripts.IRunTranscriptStore? transcripts = null)
|
||||
{
|
||||
Engine = new AgentEngine(
|
||||
Client,
|
||||
@@ -37,7 +37,10 @@ internal sealed class EngineFixture
|
||||
usageRepository: usage,
|
||||
auditRepository: audit,
|
||||
stagingGate: staging,
|
||||
clock: clock);
|
||||
clock: clock)
|
||||
{
|
||||
TranscriptStore = transcripts
|
||||
};
|
||||
|
||||
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
|
||||
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
|
||||
|
||||
@@ -36,6 +36,11 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
|
||||
public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
|
||||
|
||||
public Task<IReadOnlyList<RunReceipt>> ListReceiptsAsync(DateTime fromUtc, DateTime toUtc, int limit, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts
|
||||
.Where(r => r.OccurredAt >= fromUtc && r.OccurredAt < toUtc)
|
||||
.OrderBy(r => r.OccurredAt).Take(limit).ToList());
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
|
||||
|
||||
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
|
||||
|
||||
@@ -20,6 +20,9 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct)
|
||||
=> Task.FromResult(_calls.GetValueOrDefault(id));
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StagedCall>>(_calls.Values.Where(c => c.RunId == runId).OrderBy(c => c.Id).ToList());
|
||||
|
||||
public Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
|
||||
=> Task.FromResult(_calls.Values.Count(c =>
|
||||
c.Status == StagingStatus.Executed && c.AgentId == agentId && c.Tool == tool
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Transcripts;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Transcripts;
|
||||
|
||||
/// <summary>
|
||||
/// Prompt history (#660): prompt and response of every run are recorded, bounded in size,
|
||||
/// scrubbed of recognisable secrets, searchable, and deleted once the retention of the
|
||||
/// agent - or the instance default - says so.
|
||||
/// </summary>
|
||||
public sealed class PromptHistoryTests : IDisposable
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly SqliteStorage _storage;
|
||||
private readonly SqliteRunTranscriptStore _store;
|
||||
|
||||
public PromptHistoryTests()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
|
||||
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_store = new SqliteRunTranscriptStore(_storage);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleanup is best effort */ }
|
||||
}
|
||||
|
||||
private static ToolCall Call(string id, string name, string args)
|
||||
=> new() { Id = id, Function = new ToolCallFunction { Name = name, Arguments = args } };
|
||||
|
||||
// ─── Retention settings ───
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 14, 14)]
|
||||
[InlineData(0, 14, 0)]
|
||||
[InlineData(-1, 14, -1)]
|
||||
[InlineData(365, 14, 365)]
|
||||
[InlineData(null, 0, 0)]
|
||||
[InlineData(-7, 14, -1)]
|
||||
public void The_agent_setting_overrides_the_instance_default(int? agent, int instance, int expected)
|
||||
=> PromptHistorySettings.Resolve(agent, instance).ShouldBe(expected);
|
||||
|
||||
// ─── Recorder ───
|
||||
|
||||
[Fact]
|
||||
public void The_recorder_keeps_task_steps_tool_results_and_usage()
|
||||
{
|
||||
var recorder = new RunTranscriptRecorder();
|
||||
recorder.Begin("You are a test.", "Fetch the weather.");
|
||||
recorder.ModelStep(new ChatMessage { Role = "assistant", Content = "Let me look.", ToolCalls = [Call("c1", "WebFetch", "{\"url\":\"x\"}")] },
|
||||
new Usage { PromptTokens = 120, CompletionTokens = 30, Cost = 0.0004m }, "tool_calls");
|
||||
recorder.ToolResults([Call("c1", "WebFetch", "{}")], ["18 °C"]);
|
||||
recorder.ModelStep(new ChatMessage { Role = "assistant", Content = "ERGEBNIS: ok - 18 °C" }, null, "stop");
|
||||
|
||||
var t = recorder.Build("run-1", "wetter", "m", "task", "t-1", "Completed");
|
||||
|
||||
t.Entries.Select(e => e.Kind).ShouldBe(["user", "assistant", "tool_result", "assistant"]);
|
||||
t.Entries[1].ToolCalls!.Single().Name.ShouldBe("WebFetch");
|
||||
t.Entries[1].PromptTokens.ShouldBe(120);
|
||||
t.Entries[1].CostUsd.ShouldBe(0.0004m);
|
||||
t.Entries[2].ToolName.ShouldBe("WebFetch");
|
||||
t.SystemPromptHash.ShouldNotBeNull();
|
||||
recorder.SystemPrompt.ShouldBe("You are a test.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_tool_results_keep_start_and_end()
|
||||
{
|
||||
var recorder = new RunTranscriptRecorder();
|
||||
var text = "START" + new string('x', 20_000) + "END";
|
||||
recorder.ToolResults([Call("c", "FileRW", "{}")], [text]);
|
||||
|
||||
var entry = recorder.Build("r", "a", "m", "task", null, "Completed").Entries.Single();
|
||||
entry.Clipped.ShouldBeTrue();
|
||||
entry.Text!.Length.ShouldBeLessThan(RunTranscriptRecorder.MaxToolResultChars + 100);
|
||||
entry.Text.ShouldStartWith("START");
|
||||
entry.Text.ShouldEndWith("END");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_runaway_run_stops_storing_text_but_keeps_the_heads()
|
||||
{
|
||||
var recorder = new RunTranscriptRecorder();
|
||||
for (var i = 0; i < 100; i++)
|
||||
recorder.ToolResults([Call($"c{i}", "WebFetch", "{}")], [new string('y', 4_000)]);
|
||||
|
||||
var t = recorder.Build("r", "a", "m", "task", null, "LoopLimitExceeded");
|
||||
t.Truncated.ShouldBeTrue();
|
||||
t.SizeChars.ShouldBeLessThanOrEqualTo(RunTranscriptRecorder.MaxTotalChars);
|
||||
t.Entries.Count.ShouldBe(100, "every call is still listed");
|
||||
t.Entries.Last().Text.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("key sk-or-v1-0123456789abcdef0123456789abcdef here")]
|
||||
[InlineData("Server=db;Password=geheim123;Database=x")]
|
||||
[InlineData("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijk")]
|
||||
[InlineData("Lizenz ABCDE-12345-FGHIJ-67890-KLMNO")]
|
||||
public void Recognisable_secrets_are_scrubbed(string text)
|
||||
{
|
||||
var recorder = new RunTranscriptRecorder();
|
||||
recorder.Begin(null, text);
|
||||
|
||||
var stored = recorder.Build("r", "a", "m", "task", null, "Completed").Entries.Single().Text!;
|
||||
stored.ShouldContain(SecretScrubber.Mask);
|
||||
stored.ShouldNotContain("geheim123");
|
||||
stored.ShouldNotContain("0123456789abcdef0123456789abcdef");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_disabled_recorder_keeps_nothing()
|
||||
{
|
||||
var recorder = RunTranscriptRecorder.Disabled;
|
||||
recorder.Begin("s", "u");
|
||||
recorder.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ─── Store ───
|
||||
|
||||
private static RunTranscript Transcript(string runId, string agent, DateTime started, string text = "Hallo Grüße")
|
||||
=> new()
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agent,
|
||||
Model = "m",
|
||||
Source = "task",
|
||||
StartedAt = started,
|
||||
CompletedAt = started.AddSeconds(5),
|
||||
Status = "Completed",
|
||||
Entries =
|
||||
[
|
||||
new TranscriptEntry { Kind = TranscriptEntryKind.User, At = started, Text = text },
|
||||
new TranscriptEntry { Kind = TranscriptEntryKind.Assistant, At = started, Text = "ERGEBNIS: ok" }
|
||||
],
|
||||
SizeChars = text.Length + 12
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task A_transcript_is_stored_and_read_back_with_its_system_prompt()
|
||||
{
|
||||
var t = Transcript("run-1", "wetter", DateTime.UtcNow) with { SystemPromptHash = RunTranscriptRecorder.Hash("sys") };
|
||||
await _store.SaveAsync(t, "sys", default);
|
||||
|
||||
var back = (await _store.GetAsync("run-1", default)).ShouldNotBeNull();
|
||||
back.Entries.Count.ShouldBe(2);
|
||||
back.Entries[0].Text.ShouldBe("Hallo Grüße");
|
||||
(await _store.GetSystemPromptAsync(t.SystemPromptHash!, default)).ShouldBe("sys");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_search_finds_text_with_umlauts_and_filters_by_agent()
|
||||
{
|
||||
await _store.SaveAsync(Transcript("a", "wetter", DateTime.UtcNow, "Unwetterwarnung für Berlin"), null, default);
|
||||
await _store.SaveAsync(Transcript("b", "kurse", DateTime.UtcNow, "Kurs von NVDA"), null, default);
|
||||
|
||||
var (items, total) = await _store.ListAsync(new TranscriptQuery { Search = "für Berlin" }, default);
|
||||
total.ShouldBe(1);
|
||||
items.Single().RunId.ShouldBe("a");
|
||||
items.Single().Prompt.ShouldBe("Unwetterwarnung für Berlin");
|
||||
items.Single().Response.ShouldBe("ERGEBNIS: ok");
|
||||
|
||||
(await _store.ListAsync(new TranscriptQuery { AgentId = "kurse" }, default)).Total.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_follows_the_retention_of_each_agent()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await _store.SaveAsync(Transcript("old-default", "a", now.AddDays(-20)), null, default);
|
||||
await _store.SaveAsync(Transcript("new-default", "a", now.AddDays(-2)), null, default);
|
||||
await _store.SaveAsync(Transcript("old-forever", "debug", now.AddDays(-400)), null, default);
|
||||
await _store.SaveAsync(Transcript("fresh-never", "secret", now), null, default);
|
||||
|
||||
var deleted = await _store.PurgeAsync(agent => agent switch
|
||||
{
|
||||
"debug" => PromptHistorySettings.Forever,
|
||||
"secret" => PromptHistorySettings.Never,
|
||||
_ => 14
|
||||
}, now, default);
|
||||
|
||||
deleted.ShouldBe(2);
|
||||
(await _store.GetAsync("old-default", default)).ShouldBeNull();
|
||||
(await _store.GetAsync("new-default", default)).ShouldNotBeNull();
|
||||
(await _store.GetAsync("old-forever", default)).ShouldNotBeNull();
|
||||
(await _store.GetAsync("fresh-never", default)).ShouldBeNull("never also removes what was recorded before");
|
||||
}
|
||||
|
||||
// ─── Engine ───
|
||||
|
||||
[Fact]
|
||||
public async Task Every_run_leaves_its_transcript()
|
||||
{
|
||||
var fixture = new EngineFixture(transcripts: _store).WithTool(FakeTool.Returning("18 °C", "Wetter"));
|
||||
var agent = fixture.AddAgent("wetterdienst", "Wetter");
|
||||
fixture.Client.RespondsWithToolCall("Wetter", "{\"ort\":\"Berlin\"}").RespondsWithText("Es sind 18 °C.");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Wie ist das Wetter?", "test-instance", default);
|
||||
|
||||
var (items, _) = await _store.ListAsync(new TranscriptQuery(), default);
|
||||
var t = (await _store.GetAsync(items.Single().RunId, default)).ShouldNotBeNull();
|
||||
t.AgentId.ShouldBe("wetterdienst");
|
||||
t.Entries.Select(e => e.Kind).ShouldBe(["user", "assistant", "tool_result", "assistant"]);
|
||||
t.Entries[0].Text.ShouldContain("Wie ist das Wetter?");
|
||||
t.Entries[2].Text.ShouldNotBeNull().ShouldContain("18 °C", Case.Sensitive, "stored as the model saw it, frame included");
|
||||
t.Entries[3].Text.ShouldBe("Es sind 18 °C.");
|
||||
(await _store.GetSystemPromptAsync(t.SystemPromptHash!, default)).ShouldNotBeNull().ShouldContain("wetterdienst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_set_to_never_is_not_recorded()
|
||||
{
|
||||
var fixture = new EngineFixture(transcripts: _store);
|
||||
var agent = fixture.AddAgent("tresor");
|
||||
agent.PromptHistory.RetentionDays = PromptHistorySettings.Never;
|
||||
fixture.Client.RespondsWithText("geheim");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Passwort?", "test-instance", default);
|
||||
|
||||
(await _store.ListAsync(new TranscriptQuery(), default)).Total.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chats_are_recorded_per_run_not_as_the_whole_history()
|
||||
{
|
||||
var fixture = new EngineFixture(transcripts: _store);
|
||||
var agent = fixture.AddAgent("assistent");
|
||||
fixture.Client.RespondsWithText("Erste Antwort").RespondsWithText("Zweite Antwort");
|
||||
|
||||
await fixture.Engine.ChatAsync(agent, "Erste Frage", "test-instance", default);
|
||||
await fixture.Engine.ChatAsync(agent, "Zweite Frage", "test-instance", default);
|
||||
|
||||
var (items, _) = await _store.ListAsync(new TranscriptQuery(), default);
|
||||
items.Count.ShouldBe(2);
|
||||
var second = (await _store.GetAsync(items.First().RunId, default)).ShouldNotBeNull();
|
||||
second.Entries.Count(e => e.Kind == TranscriptEntryKind.User).ShouldBe(1);
|
||||
second.Entries[0].Text.ShouldContain("Zweite Frage");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user