summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/store.svelte.ts4
-rw-r--r--src/features/chat/ui.test.ts17
-rw-r--r--src/features/chat/ui/ChatView.svelte8
-rw-r--r--src/features/conversation-cache/logic.test.ts23
-rw-r--r--src/features/conversation-cache/logic.ts22
5 files changed, 54 insertions, 20 deletions
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index c83c5fd..45f1ed1 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -342,6 +342,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
if (transcript.sealedTurnId !== null) {
void syncTail();
void syncMetrics();
+ } else if (msg.event.type === "error") {
+ // A turn that FAILS never seals, but the backend still persists the
+ // metrics it accumulated — pull them so the turn gets its row.
+ void syncMetrics();
}
},
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 9568813..bea5e77 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -373,8 +373,8 @@ describe("ChatView", () => {
expect(screen.getByText("Hi")).toBeInTheDocument();
expect(screen.getByText("Hello!")).toBeInTheDocument();
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument();
expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
});
@@ -478,11 +478,11 @@ describe("ChatView", () => {
// Both step-metrics and turn-metrics render
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument();
// They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div");
expect(stepEl).not.toBe(turnEl);
});
@@ -554,9 +554,9 @@ describe("ChatView", () => {
// Step metrics rendered
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument();
// Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument();
// No "null" or "undefined" in the DOM
expect(screen.queryByText("null")).toBeNull();
expect(screen.queryByText("undefined")).toBeNull();
@@ -602,8 +602,7 @@ describe("ChatView", () => {
render(ChatView, { props: { chunks, turnMetrics } });
// Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
// Turn total should NOT render (total is null — turn still in progress)
expect(screen.queryByText(/^turn/)).toBeNull();
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 6805b20..182b3d6 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -254,8 +254,8 @@
{sv.label} · {sv.tokensLabel}
{#if sv.tps}
· {sv.tps}{/if}
- {#if sv.genTotal}
- · {sv.genTotal}{/if}
+ {#if sv.ttft}
+ · {sv.ttft} to first token{/if}
</div>
</div>
</div>
@@ -263,12 +263,12 @@
{@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
{@const lastCache = viewCacheRate(row.turn.usage)}
{@const chatCache = viewCacheRate(row.cumulativeUsage)}
- {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
+ {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
<div class="flex flex-col gap-1 text-xs">
<div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown}
{#if turnView.tps}
· {turnView.tps}{/if}
{#if turnView.duration}
diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts
index 880c32e..7a31783 100644
--- a/src/features/conversation-cache/logic.test.ts
+++ b/src/features/conversation-cache/logic.test.ts
@@ -138,3 +138,26 @@ describe("selectEvictions", () => {
expect(result).toEqual(["b"]);
});
});
+
+describe("reconcileCache — turnId provenance upgrade", () => {
+ it("upgrades a cached row lacking turnId when the incoming copy carries it", () => {
+ const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }];
+ const incoming: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" },
+ ];
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]);
+ expect(toAppend.map((c) => c.seq)).toEqual([1, 2]);
+ });
+
+ it("does not rewrite a cached row that already has a turnId", () => {
+ const cached: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ ];
+ const { toAppend } = reconcileCache(cached, [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ ]);
+ expect(toAppend).toEqual([]);
+ });
+});
diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts
index 3cb23e2..344a061 100644
--- a/src/features/conversation-cache/logic.ts
+++ b/src/features/conversation-cache/logic.ts
@@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types";
* Merge authoritative seq-keyed chunks with cached chunks.
*
* Deduplicates by `seq`, produces seq-monotonic order.
- * `toAppend` = the incoming chunks whose `seq` is not already in `cached`
- * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`.
+ * `toAppend` = the incoming chunks whose `seq` is not already in `cached`,
+ * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the
+ * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`.
*/
export function reconcileCache(
cached: readonly StoredChunk[],
incoming: readonly StoredChunk[],
): ReconcileResult {
- const seen = new Set<number>();
+ const bySeq = new Map<number, StoredChunk>();
for (const chunk of cached) {
- seen.add(chunk.seq);
+ bySeq.set(chunk.seq, chunk);
}
const toAppend: StoredChunk[] = [];
for (const chunk of incoming) {
- if (!seen.has(chunk.seq)) {
+ const have = bySeq.get(chunk.seq);
+ if (have === undefined) {
toAppend.push(chunk);
- seen.add(chunk.seq);
+ bySeq.set(chunk.seq, chunk);
+ } else if (have.turnId === undefined && chunk.turnId !== undefined) {
+ // Upgrade a cached row written before the server stamped `turnId`
+ // (seqs are immutable, so only this provenance field can differ).
+ const upgraded = { ...have, turnId: chunk.turnId };
+ toAppend.push(upgraded);
+ bySeq.set(chunk.seq, upgraded);
}
}
- const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
+ const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
return { merged, toAppend };
}