| Conversation |
Turns |
+ Continuation |
Last Model |
Provider |
Status |
@@ -816,6 +901,9 @@ function ConversationsPageContent() {
{row.turnCount}
|
+
+
+ |
{row.lastModel ?? "—"} |
diff --git a/src/app/api/conversations/[id]/route.ts b/src/app/api/conversations/[id]/route.ts
new file mode 100644
index 0000000000..999e2147df
--- /dev/null
+++ b/src/app/api/conversations/[id]/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server";
+import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import { getMultiTurnConversationById } from "@/lib/db/agenticConversations";
+import { annotateConversationRow, buildActiveCallLogIdByConversation } from "../route";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Single-conversation summary — used by the dashboard's conversation modal
+ * to keep lastModel/lastStatus/isActive/activeCallLogId fresh on the auto-
+ * refresh interval while it's open, instead of the list route re-fetching
+ * and re-annotating up to 200 rows just to pluck one back out. The turns
+ * themselves live-update through the separate .../tree poll; this only
+ * covers the summary fields the modal header and "Goto latest request"
+ * read off the row.
+ */
+export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const authError = await requireManagementAuth(req);
+ if (authError) return authError;
+
+ try {
+ const { id } = await params;
+ if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
+
+ const row = getMultiTurnConversationById(id);
+ if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const activeCallLogIdByConversation = buildActiveCallLogIdByConversation();
+ const conversation = annotateConversationRow(row, activeCallLogIdByConversation);
+
+ return NextResponse.json({ conversation });
+ } catch (err) {
+ console.error("[API ERROR] /api/conversations/[id] failed:", err);
+ return NextResponse.json({ error: "Failed to fetch conversation" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts
index 9b5ebc3746..e603460dfb 100644
--- a/src/app/api/conversations/route.ts
+++ b/src/app/api/conversations/route.ts
@@ -1,10 +1,53 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
+import type { MultiTurnConversationRow } from "@/lib/db/agenticConversations";
import { listMultiTurnConversations } from "@/lib/db/agenticConversations";
import { getPendingById } from "@/lib/usage/usageHistory";
+import { isGenuineContinuationTurn } from "@/lib/db/responsesContinuationStore";
export const dynamic = "force-dynamic";
+/**
+ * Shared row -> API-shape annotation for both the list route and the
+ * single-conversation route below: isActive/activeCallLogId (pending-request
+ * cross reference) and isGenuineContinuation (artifact-backed, cached — see
+ * isGenuineContinuationTurn) need the exact same computation regardless of
+ * whether the caller asked for one row or many. Strips the internal-only
+ * lastArtifactRelPath/lastApiKeyId fields before they reach the client.
+ */
+export function annotateConversationRow(
+ row: MultiTurnConversationRow,
+ activeCallLogIdByConversation: ReadonlyMap
+) {
+ const { lastArtifactRelPath, lastApiKeyId, ...rest } = row;
+ return {
+ ...rest,
+ isActive: activeCallLogIdByConversation.has(row.id),
+ activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null,
+ isGenuineContinuation: isGenuineContinuationTurn(lastArtifactRelPath, lastApiKeyId),
+ };
+}
+
+/**
+ * A pending (still-streaming) request's sessionTag is the conversation's own
+ * id (agentic_conversations.id === call_logs.session_tag) — cross reference
+ * so a conversation row can show "in progress" without a separate poll.
+ * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's
+ * INSERT needs duration/status/tokens, none of which exist yet), so
+ * lastCallLogId always lags one request behind while a reply is still
+ * streaming — it can't be used to fetch the in-flight response. Surfacing
+ * the pending request's own id separately lets the conversation panel poll
+ * /api/logs/[id] for it directly (same live-partial-text path
+ * RequestLoggerDetail already uses).
+ */
+export function buildActiveCallLogIdByConversation(): Map {
+ const map = new Map();
+ for (const pending of getPendingById().values()) {
+ if (pending.sessionTag) map.set(pending.sessionTag, pending.id);
+ }
+ return map;
+}
+
export async function GET(req: Request) {
const authError = await requireManagementAuth(req);
if (authError) return authError;
@@ -19,25 +62,10 @@ export async function GET(req: Request) {
offset: Number.isFinite(offset) ? offset : undefined,
});
- // A pending (still-streaming) request's sessionTag is the conversation's
- // own id (agentic_conversations.id === call_logs.session_tag) — cross
- // reference so the list can show "in progress" without a separate poll.
- // `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's
- // INSERT needs duration/status/tokens, none of which exist yet), so
- // `lastCallLogId` from listMultiTurnConversations always lags one request
- // behind while a reply is still streaming — it can't be used to fetch the
- // in-flight response. Surface the pending request's own id separately so
- // the conversation panel can poll /api/logs/[id] for it directly (same
- // live-partial-text path RequestLoggerDetail already uses).
- const activeCallLogIdByConversation = new Map();
- for (const pending of getPendingById().values()) {
- if (pending.sessionTag) activeCallLogIdByConversation.set(pending.sessionTag, pending.id);
- }
- const conversations = rows.map((row) => ({
- ...row,
- isActive: activeCallLogIdByConversation.has(row.id),
- activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null,
- }));
+ const activeCallLogIdByConversation = buildActiveCallLogIdByConversation();
+ const conversations = rows.map((row) =>
+ annotateConversationRow(row, activeCallLogIdByConversation)
+ );
return NextResponse.json({ conversations, total });
} catch (err) {
diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts
index 7c20cfb819..afdb7d2432 100644
--- a/src/app/api/logs/[id]/route.ts
+++ b/src/app/api/logs/[id]/route.ts
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogById } from "@/lib/usageDb";
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
+import {
+ extractPreviousResponseId,
+ resolveCallLogIdByResponseId,
+} from "@/lib/db/responsesContinuationStore";
// Each logged chunk-array element is one raw network read, timestamp-prefixed
// for the debug display — NOT one complete SSE `data:` line. A single JSON
@@ -159,7 +163,20 @@ export async function GET(
if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 });
- return NextResponse.json(persistedRequest);
+ // "Continues from" link for the dashboard's conversation panel: resolve
+ // this entry's own previous_response_id back to the call-log row that
+ // produced it. Persisted-only (apiKeyId isn't plumbed onto the
+ // pending/in-memory branches above) -- required for the same tenant
+ // scoping resolveCallLogIdByResponseId enforces, so an active/in-memory
+ // entry simply renders no parent link rather than resolving unscoped.
+ const previousResponseId = extractPreviousResponseId(
+ persistedRequest.pipelinePayloads as Record | null | undefined
+ );
+ const parentLogId = previousResponseId
+ ? resolveCallLogIdByResponseId(previousResponseId, persistedRequest.apiKeyId ?? null)
+ : null;
+
+ return NextResponse.json({ ...persistedRequest, previousResponseId, parentLogId });
} catch (err) {
console.error("[API ERROR] /api/logs/[id] failed:", err);
return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 });
diff --git a/src/lib/db/agenticConversations.ts b/src/lib/db/agenticConversations.ts
index 646fce57a7..0bdb9ff4e2 100644
--- a/src/lib/db/agenticConversations.ts
+++ b/src/lib/db/agenticConversations.ts
@@ -357,6 +357,12 @@ export interface MultiTurnConversationRow extends AgenticConversationRow {
lastModel: string | null;
lastProvider: string | null;
lastStatus: number | null;
+ // Exposed so the API layer can check whether the latest turn genuinely
+ // used HTTP continuation (see isGenuineContinuationTurn in
+ // responsesContinuationStore.ts) without a second query — this row's own
+ // artifact/tenant already identify it, no separate lookup needed.
+ lastArtifactRelPath: string | null;
+ lastApiKeyId: string | null;
}
/**
@@ -400,16 +406,7 @@ export function listMultiTurnConversations(
const rows = db
.prepare(
- `SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model,
- latest.provider as last_provider, latest.status as last_status
- FROM agentic_conversations ac
- LEFT JOIN (
- SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status
- FROM call_logs cl1
- WHERE cl1.timestamp = (
- SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag
- )
- ) latest ON latest.session_tag = ac.id
+ `${MULTI_TURN_CONVERSATION_SELECT}
WHERE (SELECT COUNT(*) FROM conversation_turn_nodes n WHERE n.conversation_id = ac.id) >= 2
ORDER BY ac.last_seen_at DESC
LIMIT ? OFFSET ?`
@@ -418,15 +415,52 @@ export function listMultiTurnConversations(
return {
total: Number(total ?? 0),
- rows: rows.map((r) => {
- const rec = asRecord(r);
- return {
- ...toRow(rec),
- lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null,
- lastModel: typeof rec.last_model === "string" ? rec.last_model : null,
- lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null,
- lastStatus: typeof rec.last_status === "number" ? rec.last_status : null,
- };
- }),
+ rows: rows.map(toMultiTurnConversationRow),
};
}
+
+function toMultiTurnConversationRow(value: unknown): MultiTurnConversationRow {
+ const rec = asRecord(value);
+ return {
+ ...toRow(rec),
+ lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null,
+ lastModel: typeof rec.last_model === "string" ? rec.last_model : null,
+ lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null,
+ lastStatus: typeof rec.last_status === "number" ? rec.last_status : null,
+ lastArtifactRelPath:
+ typeof rec.last_artifact_relpath === "string" ? rec.last_artifact_relpath : null,
+ lastApiKeyId: typeof rec.last_api_key_id === "string" ? rec.last_api_key_id : null,
+ };
+}
+
+const MULTI_TURN_CONVERSATION_SELECT = `
+ SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model,
+ latest.provider as last_provider, latest.status as last_status,
+ latest.artifact_relpath as last_artifact_relpath,
+ latest.api_key_id as last_api_key_id
+ FROM agentic_conversations ac
+ LEFT JOIN (
+ SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status,
+ cl1.artifact_relpath, cl1.api_key_id
+ FROM call_logs cl1
+ WHERE cl1.timestamp = (
+ SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag
+ )
+ ) latest ON latest.session_tag = ac.id
+`;
+
+/**
+ * Single-conversation equivalent of listMultiTurnConversations, for the
+ * dashboard's conversation modal: while it's open, polling this one row on
+ * the refresh interval (instead of the whole up-to-200-row list just to
+ * pluck one row back out of it) is what actually needs to stay live —
+ * lastCallLogId/lastStatus for "Goto latest request" and isActive detection.
+ * Unlike the list, this intentionally has no turn-count floor: a
+ * specifically-requested conversation should resolve even if it hasn't (yet)
+ * reached 2 turn nodes.
+ */
+export function getMultiTurnConversationById(id: string): MultiTurnConversationRow | null {
+ const db = getDbInstance();
+ const row = db.prepare(`${MULTI_TURN_CONVERSATION_SELECT} WHERE ac.id = ?`).get(id);
+ return row ? toMultiTurnConversationRow(row) : null;
+}
diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts
index 3e0f79b7ca..9c55d8edff 100644
--- a/src/lib/db/responsesContinuationStore.ts
+++ b/src/lib/db/responsesContinuationStore.ts
@@ -129,3 +129,107 @@ export function resolvePreviousResponseState(
return { input, output };
}
+
+/**
+ * Resolve the call-log id that produced `responseId`, for the dashboard's
+ * "continues from" link. Reuses the same `call_logs.response_id` index and
+ * `api_key_id` tenant scoping as `resolvePreviousResponseState` above -- a
+ * parent link must never point across API keys, even just to surface its id.
+ * Returns null on any lookup miss so the caller renders no link rather than
+ * a broken one.
+ */
+export function resolveCallLogIdByResponseId(
+ responseId: string,
+ apiKeyId: string | null | undefined
+): string | null {
+ if (!responseId || !apiKeyId) return null;
+
+ const db = getDbInstance();
+ const row = db
+ .prepare(
+ `SELECT id FROM call_logs
+ WHERE response_id = ? AND api_key_id = ?
+ ORDER BY timestamp DESC LIMIT 1`
+ )
+ .get(responseId, apiKeyId) as { id: string } | undefined;
+
+ return row?.id ?? null;
+}
+
+/**
+ * Extract `previous_response_id` from a call-log's own pipeline payload.
+ * Persisted artifacts key the client's own request `clientRawRequest`; the
+ * pending/in-flight in-memory shape keys the same thing `clientRequest`
+ * instead (RequestLoggerDetail.tsx's payloadSections list carries both keys
+ * for the same reason) -- check both so callers get the same answer
+ * regardless of which shape the payload came back as.
+ */
+export function extractPreviousResponseId(
+ pipelinePayloads: Record | null | undefined
+): string | null {
+ if (!pipelinePayloads) return null;
+ for (const key of ["clientRawRequest", "clientRequest"]) {
+ const envelope = pipelinePayloads[key];
+ const body = isPlainRecord(envelope) && "body" in envelope ? envelope.body : envelope;
+ if (isPlainRecord(body) && typeof body.previous_response_id === "string") {
+ return body.previous_response_id;
+ }
+ }
+ return null;
+}
+
+// isGenuineContinuationTurn is a pure function of one call-log's own
+// artifact, which is immutable once written (see callLogs.ts -- detailState
+// only flips to "ready" after the artifact is fully persisted) -- the same
+// artifactRelPath always answers the same way, forever. Without this cache,
+// the dashboard's own default auto-refresh polls the whole conversation list
+// on an interval the operator controls (down to 1s), so every tick re-reads
+// and re-parses one artifact per visible row for an answer that can never
+// change once computed. Keyed on artifactRelPath alone (1:1 with the owning
+// call-log row, so apiKeyId never varies for a given key) with simple FIFO
+// eviction -- correctness never depends on which entries survive, only on
+// staying bounded.
+const GENUINE_CONTINUATION_CACHE_MAX = 5000;
+const genuineContinuationCache = new Map();
+
+function cacheGenuineContinuation(key: string, value: boolean): boolean {
+ genuineContinuationCache.set(key, value);
+ if (genuineContinuationCache.size > GENUINE_CONTINUATION_CACHE_MAX) {
+ const oldest = genuineContinuationCache.keys().next().value;
+ if (oldest !== undefined) genuineContinuationCache.delete(oldest);
+ }
+ return value;
+}
+
+/**
+ * Whether a call-log's own request genuinely continued a prior response
+ * server-side: it carried `previous_response_id` AND that id resolved to a
+ * real, same-tenant prior call-log row. Backs the /dashboard/conversations
+ * "genuine continuation" badge -- a conversation the client-side turn
+ * tracker counts as multi-turn (conversationTracker.ts's content-hash chain,
+ * independent of transport) is not necessarily one actually running on the
+ * `previous_response_id` wire optimization; this checks the transport fact,
+ * not the content-hash one.
+ */
+export function isGenuineContinuationTurn(
+ artifactRelPath: string | null | undefined,
+ apiKeyId: string | null | undefined
+): boolean {
+ if (!artifactRelPath) return false;
+ const cached = genuineContinuationCache.get(artifactRelPath);
+ if (cached !== undefined) return cached;
+
+ const { artifact, state } = readCallArtifact(artifactRelPath);
+ if (state !== "ready" || !artifact?.pipeline) {
+ return cacheGenuineContinuation(artifactRelPath, false);
+ }
+ const previousResponseId = extractPreviousResponseId(
+ artifact.pipeline as Record
+ );
+ if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false);
+
+ return cacheGenuineContinuation(
+ artifactRelPath,
+ resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null
+ );
+}
diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx
index e7a145638d..78527ab495 100644
--- a/src/shared/components/RequestLoggerDetail.sections.tsx
+++ b/src/shared/components/RequestLoggerDetail.sections.tsx
@@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) {
{open ? "expand_less" : "expand_more"}
+ {liveDetail?.parentLogId && (
+ // Full navigation, not client-side routing: the logs page only reads
+ // ?id from a fresh mount (useState(() => searchParams.get("id")) in
+ // dashboard/logs/page.tsx), so an in-page route change wouldn't load
+ // the parent entry if the user is already on this page.
+
+ reply
+ continues from parent
+
+ )}
{open && (
|