- |
+ |
diff --git a/admin-ui/lib/llm-calls-api.ts b/admin-ui/lib/llm-calls-api.ts
index 969ac684..b5eec7ff 100644
--- a/admin-ui/lib/llm-calls-api.ts
+++ b/admin-ui/lib/llm-calls-api.ts
@@ -10,6 +10,8 @@
*/
export interface LlmCallEntry {
seq: number;
+ /** gh-#429 — who authored this call's copy, or `null` for a persona-less render (never `""`). */
+ personaName: string | null;
startedAt: string;
elapsedMs: number;
status: string;
diff --git a/src/GenWave.Host/Api/LlmCallDto.cs b/src/GenWave.Host/Api/LlmCallDto.cs
index f45f42db..7612b8d3 100644
--- a/src/GenWave.Host/Api/LlmCallDto.cs
+++ b/src/GenWave.Host/Api/LlmCallDto.cs
@@ -6,10 +6,13 @@ namespace GenWave.Host.Api;
/// / carry the FULL text — this is admin-only debug
/// detail, never a public surface, and never persisted (see 's
/// own remarks). / are a cheap at-a-glance size
-/// for the table view; the full text is what the expandable row shows.
+/// for the table view; the full text is what the expandable row shows.
+/// (gh-#429) is who authored the call — for a persona-less render, never an
+/// empty string.
///
public sealed record LlmCallDto(
long Seq,
+ string? PersonaName,
DateTimeOffset StartedAt,
long ElapsedMs,
string Status,
diff --git a/src/GenWave.Host/Api/LlmCallsController.cs b/src/GenWave.Host/Api/LlmCallsController.cs
index 55c30e08..73977743 100644
--- a/src/GenWave.Host/Api/LlmCallsController.cs
+++ b/src/GenWave.Host/Api/LlmCallsController.cs
@@ -35,6 +35,7 @@ public IActionResult List()
static LlmCallDto ToDto(LlmCallRecord record) => new(
record.Seq,
+ record.PersonaName,
record.StartedAt,
record.ElapsedMs,
record.Outcome.ToString().ToLowerInvariant(),
diff --git a/src/GenWave.Tts/LlmCallRecord.cs b/src/GenWave.Tts/LlmCallRecord.cs
index d34f2260..322aedd0 100644
--- a/src/GenWave.Tts/LlmCallRecord.cs
+++ b/src/GenWave.Tts/LlmCallRecord.cs
@@ -12,6 +12,14 @@ namespace GenWave.Tts;
/// Ring-assigned, monotonically increasing — the newest record has the highest .
/// Doubles as a stable row key for the admin UI across polls.
///
+///
+/// gh-#429 — the on-air name of whichever persona was active for this call, via the same
+/// card-first-then-legacy-row precedence already applies
+/// to the prompt's own self-name-mention line, or when no persona was active
+/// (persona-less rendering, or a call that faulted before a persona was even resolved). Personas now
+/// author the copy this ring exists to triage, so a row needs to name who wrote it without an admin
+/// having to read the system prompt to find out.
+///
///
/// The system prompt built for this call (persona/soul/quirks/station clock composed in), or
/// if the call faulted before prompt assembly was reached (e.g. a malformed
@@ -30,6 +38,7 @@ namespace GenWave.Tts;
/// The degradation mode active at call time (SPEC F73.1, F69.1) — Normal/Soft/Hard.
public sealed record LlmCallRecord(
long Seq,
+ string? PersonaName,
string? PromptSystem,
string? PromptUser,
string? Response,
diff --git a/src/GenWave.Tts/LlmCallRing.cs b/src/GenWave.Tts/LlmCallRing.cs
index accf2198..47e5f457 100644
--- a/src/GenWave.Tts/LlmCallRing.cs
+++ b/src/GenWave.Tts/LlmCallRing.cs
@@ -41,15 +41,19 @@ public sealed class LlmCallRing(IOptionsMonitor options)
/// fresh from on every call (mirrors
/// PlayHistoryService.Push's own live-capacity seam), so a live edit to
/// Llm:CallRingCapacity trims (or grows) the ring on the very next record.
+ /// (gh-#429) is the caller's already-resolved
+ /// result — this ring never re-derives a name itself,
+ /// it only stores what it was handed.
///
public void Record(
- string? promptSystem, string? promptUser, string? response, DateTimeOffset startedAt,
+ string? personaName, string? promptSystem, string? promptUser, string? response, DateTimeOffset startedAt,
long elapsedMs, LlmCallOutcome outcome, string? statusDetail, DegradationMode mode)
{
lock (gate)
{
var record = new LlmCallRecord(
- ++nextSeq, promptSystem, promptUser, response, startedAt, elapsedMs, outcome, statusDetail, mode);
+ ++nextSeq, personaName, promptSystem, promptUser, response, startedAt, elapsedMs, outcome,
+ statusDetail, mode);
ring.AddFirst(record); // newest first
var capacity = options.CurrentValue.CallRingCapacity;
diff --git a/src/GenWave.Tts/LlmCopyWriter.cs b/src/GenWave.Tts/LlmCopyWriter.cs
index ef8fb698..be12b786 100644
--- a/src/GenWave.Tts/LlmCopyWriter.cs
+++ b/src/GenWave.Tts/LlmCopyWriter.cs
@@ -51,8 +51,9 @@ namespace GenWave.Tts;
///
/// The SAME single recording point (SPEC F73.1, STORY-196, T41) is also where every call — on-air,
/// Soft-cadence, or preview — lands in , the admin call inspector's
-/// in-memory ring: prompt, raw response, timing, outcome, and the degradation mode active at call
-/// time (, read fresh right here rather than passed
+/// in-memory ring: the active persona's name (gh-#429), prompt, raw response, timing, outcome, and
+/// the degradation mode active at call time
+/// (, read fresh right here rather than passed
/// in — a preview never passes through , so there is no
/// caller-supplied mode to reuse for that path; reading it uniformly for every path keeps this the
/// one recording point instead of two). Never logged, never persisted — see 's
@@ -410,6 +411,10 @@ async Task RequestCompletionAsync(
// uniformly for every path keeps this the one recording point instead of two.
var startedAt = timeProvider.GetUtcNow();
var mode = degradationMode.CurrentMode;
+ // gh-#429: the SAME card-first-then-legacy-row precedence the prompt's own self-name-mention
+ // line already uses (LlmPromptBuilder.BuildSelfNameMentionLine) — resolved once, up front, so
+ // the ring entry names whichever persona authored this call whether it succeeds or faults.
+ var personaName = LlmPromptBuilder.ResolveName(persona, card);
// Hoisted above the try (mirrors WriteAsync's own cfg/persona hoisting, T3 review finding)
// so a fault EARLIER than prompt assembly (e.g. a malformed endpoint URI) still lets the
@@ -519,7 +524,7 @@ async Task RequestCompletionAsync(
// is a hygiene decision the caller makes, not a fact about whether the call itself
// succeeded; see LlmCallOutcome.Ok's own remarks.
callRing.Record(
- systemPrompt, userPrompt, text, startedAt, ElapsedMs(startedAt),
+ personaName, systemPrompt, userPrompt, text, startedAt, ElapsedMs(startedAt),
LlmCallOutcome.Ok, statusDetail: null, mode);
return text;
}
@@ -534,7 +539,7 @@ async Task RequestCompletionAsync(
{
var (outcome, detail) = ClassifyForRing(ex);
callRing.Record(
- systemPrompt, userPrompt, response: null, startedAt, ElapsedMs(startedAt),
+ personaName, systemPrompt, userPrompt, response: null, startedAt, ElapsedMs(startedAt),
outcome, detail, mode);
throw;
}
diff --git a/src/GenWave.Tts/LlmPromptBuilder.cs b/src/GenWave.Tts/LlmPromptBuilder.cs
index 09ce2034..6bf3c2c5 100644
--- a/src/GenWave.Tts/LlmPromptBuilder.cs
+++ b/src/GenWave.Tts/LlmPromptBuilder.cs
@@ -145,9 +145,11 @@ public static string BuildSystemPrompt(string? personaSection)
/// The persona's on-air name for , card-first with the
/// legacy row as fallback — the same read-path precedence established
/// (for an admin-managed persona the two are kept in lockstep anyway; see BuildSoul's remarks).
- /// Null when neither carries one — no name, no line.
+ /// Null when neither carries one — no name, no line. Internal (gh-#429) —
+ /// reuses this exact precedence to stamp , rather than
+ /// duplicating the card-first-then-legacy-row rule at a second call site.
///
- static string? ResolveName(Persona? persona, PersonaCard? card)
+ internal static string? ResolveName(Persona? persona, PersonaCard? card)
{
if (card is { Name.Length: > 0 })
return card.Name;
diff --git a/tests/GenWave.Host.Tests/Specs/Story196_LlmCallInspector.cs b/tests/GenWave.Host.Tests/Specs/Story196_LlmCallInspector.cs
index 3cc11330..f323b270 100644
--- a/tests/GenWave.Host.Tests/Specs/Story196_LlmCallInspector.cs
+++ b/tests/GenWave.Host.Tests/Specs/Story196_LlmCallInspector.cs
@@ -155,8 +155,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
/// Wire shape of one row from GET /api/llm-calls — mirrors
/// without depending on it directly.
file sealed record LlmCallRow(
- long Seq, DateTimeOffset StartedAt, long ElapsedMs, string Status, string? StatusDetail, string Mode,
- string? PromptSystem, string? PromptUser, string? Response, int PromptChars, int ResponseChars);
+ long Seq, string? PersonaName, DateTimeOffset StartedAt, long ElapsedMs, string Status, string? StatusDetail,
+ string Mode, string? PromptSystem, string? PromptUser, string? Response, int PromptChars, int ResponseChars);
public static class FeatureLlmCallInspector
{
@@ -213,6 +213,10 @@ public async Task A_real_preview_render_is_readable_back_via_the_inspector_endpo
row.PromptSystem != null && row.PromptSystem.Contains("moody, late-night") &&
row.PromptChars == (row.PromptSystem!.Length + (row.PromptUser?.Length ?? 0)) &&
row.ResponseChars == stub.ReplyContent.Length);
+
+ // gh-#429: the DTO carries who authored the call — DraftPreviewBody's own "name" field,
+ // exactly as PersonaController built the override Persona the writer resolved it from.
+ Assert.Equal("Neon Nightowl", row.PersonaName);
}
}
diff --git a/tests/GenWave.Tts.Tests/Specs/Story119_LlmCopyWriter.cs b/tests/GenWave.Tts.Tests/Specs/Story119_LlmCopyWriter.cs
index ee445199..bfadef22 100644
--- a/tests/GenWave.Tts.Tests/Specs/Story119_LlmCopyWriter.cs
+++ b/tests/GenWave.Tts.Tests/Specs/Story119_LlmCopyWriter.cs
@@ -371,6 +371,79 @@ public async Task AFailedCompletionRecordsFailedWithTimestamp()
}
}
+ // ---------------------------------------------------------------------
+ // gh-#429 — the call ring names which persona authored each call, success or failure
+ // ---------------------------------------------------------------------
+
+ public sealed class ScenarioCallRingRecordsThePersonaName : IAsyncLifetime
+ {
+ MockCompletionsServer mock = null!;
+
+ public async Task InitializeAsync() => mock = await MockCompletionsServer.StartAsync();
+
+ public async Task DisposeAsync() => await mock.DisposeAsync();
+
+ static (LlmCopyWriter Writer, LlmCallRing Ring) BuildWriterWithRing(string endpoint, Persona? persona)
+ {
+ var ring = new LlmCallRing(new TestOptionsMonitor(new LlmOptions()));
+ var writer = new LlmCopyWriter(
+ new TemplateCopyWriter(new PatterTemplateRenderer()),
+ new FakeHttpClientFactory(),
+ new TestOptionsMonitor(new LlmOptions
+ {
+ Endpoint = endpoint,
+ Model = "test-model",
+ TimeoutSeconds = 5,
+ MaxCopyChars = 450,
+ }),
+ new LlmCopyStatusHolder(),
+ new FakeActivePersonaAccessor { Persona = persona },
+ new CapturingLogger(),
+ TimeProvider.System,
+ ring,
+ new FakeDegradationModeReader());
+ return (writer, ring);
+ }
+
+ [Fact]
+ public async Task ASuccessfulCallRecordsTheActivePersonasName()
+ {
+ var persona = new Persona(1, "Neon Nightowl", "Spins vinyl til dawn.", "moody, late-night", "af_sky",
+ DateTime.UtcNow, DateTime.UtcNow);
+ var (writer, ring) = BuildWriterWithRing(mock.BaseUri.ToString(), persona);
+
+ await writer.WriteAsync(LeadInRequest(), CancellationToken.None);
+
+ var record = Assert.Single(ring.Snapshot());
+ Assert.Equal("Neon Nightowl", record.PersonaName);
+ }
+
+ [Fact]
+ public async Task AFailedCallStillRecordsTheActivePersonasName()
+ {
+ mock.Mode = MockCompletionsMode.Fail;
+ var persona = new Persona(2, "The Archivist", "Keeper of the catalog.", "dry, precise", "af_sky",
+ DateTime.UtcNow, DateTime.UtcNow);
+ var (writer, ring) = BuildWriterWithRing(mock.BaseUri.ToString(), persona);
+
+ await writer.WriteAsync(LeadInRequest(), CancellationToken.None);
+
+ var record = Assert.Single(ring.Snapshot());
+ Assert.Equal("The Archivist", record.PersonaName);
+ }
+
+ [Fact]
+ public async Task NoActivePersonaRecordsANullPersonaName()
+ {
+ var (writer, ring) = BuildWriterWithRing(mock.BaseUri.ToString(), persona: null);
+
+ await writer.WriteAsync(LeadInRequest(), CancellationToken.None);
+
+ var record = Assert.Single(ring.Snapshot());
+ Assert.Null(record.PersonaName);
+ }
+ }
+
// ---------------------------------------------------------------------
// SAD PATH — every miss lands on the template rung
// ---------------------------------------------------------------------
|