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
25 changes: 25 additions & 0 deletions admin-ui/__specs__/llm-calls-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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(<LlmCallsView timeZone="UTC" />);
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(<LlmCallsView timeZone="UTC" />);
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.
Expand Down
18 changes: 11 additions & 7 deletions admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlySet<number>>(new Set());
Expand Down Expand Up @@ -150,6 +150,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<thead>
<tr className="border-b-2 border-line text-left">
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Time</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Persona</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Status</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">
{/* gh-#210: the header carries the house `?` flyover (gh-#145 pattern) β€” the
Expand Down Expand Up @@ -187,6 +188,9 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<td className="py-2 pr-3 whitespace-nowrap tabular-nums text-mute">
{formatUpSince(entry.startedAt, { timeZone })}
</td>
<td className="max-w-[10rem] truncate py-2 pr-3 text-ink">
{entry.personaName ?? "β€”"}
</td>
<td className="py-2 pr-3">
<StatusChip status={entry.status} />
</td>
Expand All @@ -210,7 +214,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
</tr>
{isExpanded && (
<tr className="border-b border-line last:border-b-0">
<td colSpan={6} className="py-3">
<td colSpan={7} className="py-3">
<div className="space-y-3 rounded-[6px] border border-line bg-surface-2 p-3">
<DetailField label="System prompt" value={entry.promptSystem} />
<DetailField label="User prompt" value={entry.promptUser} />
Expand Down
2 changes: 2 additions & 0 deletions admin-ui/lib/llm-calls-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/GenWave.Host/Api/LlmCallDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ namespace GenWave.Host.Api;
/// <see cref="PromptUser"/>/<see cref="Response"/> carry the FULL text β€” this is admin-only debug
/// detail, never a public surface, and never persisted (see <see cref="GenWave.Tts.LlmCallRing"/>'s
/// own remarks). <see cref="PromptChars"/>/<see cref="ResponseChars"/> 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. <see cref="PersonaName"/>
/// (gh-#429) is who authored the call β€” <see langword="null"/> for a persona-less render, never an
/// empty string.
/// </summary>
public sealed record LlmCallDto(
long Seq,
string? PersonaName,
DateTimeOffset StartedAt,
long ElapsedMs,
string Status,
Expand Down
1 change: 1 addition & 0 deletions src/GenWave.Host/Api/LlmCallsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions src/GenWave.Tts/LlmCallRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ namespace GenWave.Tts;
/// Ring-assigned, monotonically increasing β€” the newest record has the highest <see cref="Seq"/>.
/// Doubles as a stable row key for the admin UI across polls.
/// </param>
/// <param name="PersonaName">
/// gh-#429 β€” the on-air name of whichever persona was active for this call, via the same
/// card-first-then-legacy-row precedence <see cref="LlmPromptBuilder.ResolveName"/> already applies
/// to the prompt's own self-name-mention line, or <see langword="null"/> 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.
/// </param>
/// <param name="PromptSystem">
/// The system prompt built for this call (persona/soul/quirks/station clock composed in), or
/// <see langword="null"/> if the call faulted before prompt assembly was reached (e.g. a malformed
Expand All @@ -30,6 +38,7 @@ namespace GenWave.Tts;
/// <param name="Mode">The degradation mode active at call time (SPEC F73.1, F69.1) β€” Normal/Soft/Hard.</param>
public sealed record LlmCallRecord(
long Seq,
string? PersonaName,
string? PromptSystem,
string? PromptUser,
string? Response,
Expand Down
8 changes: 6 additions & 2 deletions src/GenWave.Tts/LlmCallRing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,19 @@ public sealed class LlmCallRing(IOptionsMonitor<LlmOptions> options)
/// fresh from <see cref="IOptionsMonitor{LlmOptions}.CurrentValue"/> on every call (mirrors
/// <c>PlayHistoryService.Push</c>'s own live-capacity seam), so a live edit to
/// <c>Llm:CallRingCapacity</c> trims (or grows) the ring on the very next record.
/// <paramref name="personaName"/> (gh-#429) is the caller's already-resolved
/// <see cref="LlmPromptBuilder.ResolveName"/> result β€” this ring never re-derives a name itself,
/// it only stores what it was handed.
/// </summary>
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;
Expand Down
13 changes: 9 additions & 4 deletions src/GenWave.Tts/LlmCopyWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ namespace GenWave.Tts;
/// <para>
/// The SAME single recording point (SPEC F73.1, STORY-196, T41) is also where every call β€” on-air,
/// Soft-cadence, or preview β€” lands in <see cref="LlmCallRing"/>, the admin call inspector's
/// in-memory ring: prompt, raw response, timing, outcome, and the degradation mode active at call
/// time (<see cref="IDegradationModeReader.CurrentMode"/>, 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
/// (<see cref="IDegradationModeReader.CurrentMode"/>, read fresh right here rather than passed
/// in β€” a preview never passes through <see cref="DegradationGatedCopyWriter"/>, 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 <see cref="LlmCallRing"/>'s
Expand Down Expand Up @@ -410,6 +411,10 @@ async Task<string> 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
Expand Down Expand Up @@ -519,7 +524,7 @@ async Task<string> 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;
}
Expand All @@ -534,7 +539,7 @@ async Task<string> 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;
}
Expand Down
6 changes: 4 additions & 2 deletions src/GenWave.Tts/LlmPromptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,11 @@ public static string BuildSystemPrompt(string? personaSection)
/// The persona's on-air name for <see cref="BuildSelfNameMentionLine"/>, card-first with the
/// legacy row as fallback β€” the same read-path precedence <see cref="BuildSoul"/> 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) β€” <see cref="LlmCopyWriter"/>
/// reuses this exact precedence to stamp <see cref="LlmCallRecord.PersonaName"/>, rather than
/// duplicating the card-first-then-legacy-row rule at a second call site.
/// </summary>
static string? ResolveName(Persona? persona, PersonaCard? card)
internal static string? ResolveName(Persona? persona, PersonaCard? card)
{
if (card is { Name.Length: > 0 })
return card.Name;
Expand Down
8 changes: 6 additions & 2 deletions tests/GenWave.Host.Tests/Specs/Story196_LlmCallInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
/// <summary>Wire shape of one row from <c>GET /api/llm-calls</c> β€” mirrors
/// <see cref="GenWave.Host.Api.LlmCallDto"/> without depending on it directly.</summary>
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
{
Expand Down Expand Up @@ -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);
}
}

Expand Down
73 changes: 73 additions & 0 deletions tests/GenWave.Tts.Tests/Specs/Story119_LlmCopyWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LlmOptions>(new LlmOptions()));
var writer = new LlmCopyWriter(
new TemplateCopyWriter(new PatterTemplateRenderer()),
new FakeHttpClientFactory(),
new TestOptionsMonitor<LlmOptions>(new LlmOptions
{
Endpoint = endpoint,
Model = "test-model",
TimeoutSeconds = 5,
MaxCopyChars = 450,
}),
new LlmCopyStatusHolder(),
new FakeActivePersonaAccessor { Persona = persona },
new CapturingLogger<LlmCopyWriter>(),
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
// ---------------------------------------------------------------------
Expand Down
Loading