diff --git a/admin-ui/__specs__/llm-calls-page.spec.tsx b/admin-ui/__specs__/llm-calls-page.spec.tsx index 42a10a0a..f881e686 100644 --- a/admin-ui/__specs__/llm-calls-page.spec.tsx +++ b/admin-ui/__specs__/llm-calls-page.spec.tsx @@ -23,6 +23,7 @@ const ISO_NOW = "2026-07-20T12:00:00.000Z"; interface EntryOverrides { seq?: number; + personaName?: string | null; startedAt?: string; elapsedMs?: number; status?: string; @@ -38,6 +39,7 @@ interface EntryOverrides { function makeEntry(overrides: EntryOverrides = {}) { return { seq: 1, + personaName: "Neon Nightowl", startedAt: "2026-07-20T11:58:00.000Z", elapsedMs: 340, status: "ok", @@ -155,6 +157,29 @@ describe("Feature: LLM call inspector", () => { }); }); + // gh-#429: personas now author the copy this table exists to triage, so each row names who + // authored it without an admin having to expand into the system prompt to find out. + describe("Scenario: rows show which persona authored the call", () => { + it("renders the entry's persona name in its own column", async () => { + installFetchMock(ok([makeEntry({ personaName: "The Archivist" })])); + + render(); + await flush(); + + expect(screen.getByText("The Archivist")).toBeInTheDocument(); + }); + + it("falls back to a dash when the call had no active persona", async () => { + installFetchMock(ok([makeEntry({ personaName: null })])); + + render(); + await flush(); + + const rows = screen.getAllByRole("row"); + expect(rows[1].textContent ?? "").toContain("—"); + }); + }); + // gh-#210: the per-chip native titles (gh-#142) are invisible on touch and undiscoverable by // anyone not already hovering — the Mode HEADER now carries the house `?` flyover (gh-#145 // pattern), naming the ladder and all three rungs in one place. diff --git a/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx b/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx index 8be9e7b2..8bdaa7a0 100644 --- a/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx +++ b/admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx @@ -97,12 +97,12 @@ function DetailField({ label, value }: { label: string; value: string | null }): } /** - * The LLM call inspector's table (PLAN T41, STORY-196, SPEC F73.1-F73.2): newest-first rows of - * time / status chip / mode chip / elapsed / a truncated response preview, each expandable to the - * full system prompt, user prompt, and raw response text — admin-only debug detail, never - * persisted (see GenWave.Tts.LlmCallRing's own remarks). Loading/empty/error idioms match the - * booth log's own BoothLogFeed (skeleton rows, EmptyState, a quiet unavailable hint on a poll - * failure that keeps whatever was already loaded). + * The LLM call inspector's table (PLAN T41, STORY-196, SPEC F73.1-F73.2; persona column gh-#429): + * newest-first rows of time / persona / status chip / mode chip / elapsed / a truncated response + * preview, each expandable to the full system prompt, user prompt, and raw response text — + * admin-only debug detail, never persisted (see GenWave.Tts.LlmCallRing's own remarks). Loading/ + * empty/error idioms match the booth log's own BoothLogFeed (skeleton rows, EmptyState, a quiet + * unavailable hint on a poll failure that keeps whatever was already loaded). */ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): ReactNode { const [expanded, setExpanded] = useState>(new Set()); @@ -150,6 +150,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R Time + Persona Status {/* gh-#210: the header carries the house `?` flyover (gh-#145 pattern) — the @@ -187,6 +188,9 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R {formatUpSince(entry.startedAt, { timeZone })} + + {entry.personaName ?? "—"} + @@ -210,7 +214,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R {isExpanded && ( - +
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 // ---------------------------------------------------------------------