diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index fe02ba885..6c0f19532 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -169,7 +169,7 @@ describe("POST /api/insights/chat — fence guard", () => { expect(fetchConversationWithMessages).toHaveBeenCalledWith( "u1", "fenced-conv", - { documentScoped: false }, + { documentScoped: false, countEarlierAssistant: true }, ); expect(auditLog).not.toHaveBeenCalled(); }); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index be65d1539..db1c05d4c 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -609,20 +609,7 @@ export async function recordProactiveNudge( // newest messages up to this cap cover the rendered window without the // unbounded per-open AES-decrypt cost. The response shape is unchanged — // the messages array still arrives oldest->newest (see the reverse below). -export const CONVERSATION_MESSAGE_DETAIL_CAP = 200; - -/** - * The assistant messages of a conversation written before `before`. The - * caller has already read the conversation owner-narrowed; this only counts. - */ -export function countAssistantMessagesBefore( - conversationId: string, - before: Date, -): Promise { - return prisma.coachMessage.count({ - where: { conversationId, role: "assistant", createdAt: { lt: before } }, - }); -} +const CONVERSATION_MESSAGE_DETAIL_CAP = 200; /** * Fetch one conversation + its messages, decrypting each body on @@ -649,7 +636,17 @@ export async function fetchConversationWithMessages( * the path id so it can only ever load a conversation that actually holds * that document. Never combined with `documentScoped`. */ - opts?: { documentScoped?: boolean; attachedDocumentId?: string }, + opts?: { + documentScoped?: boolean; + attachedDocumentId?: string; + /** + * v1.39.4 — also count the assistant messages older than the loaded + * window (`earlierAssistantMessages`), so a turn names earlier tables by + * their place in the whole conversation. One count, read only when the + * window is full. + */ + countEarlierAssistant?: boolean; + }, ): Promise { const row = await prisma.coachConversation.findFirst({ where: { @@ -714,6 +711,19 @@ export async function fetchConversationWithMessages( } const attachments = mapAttachments(row.attachments); + const oldestLoaded = orderedMessages[0]; + const earlierAssistantMessages = + opts?.countEarlierAssistant && + oldestLoaded && + row.messages.length >= CONVERSATION_MESSAGE_DETAIL_CAP + ? await prisma.coachMessage.count({ + where: { + conversationId: row.id, + role: "assistant", + createdAt: { lt: oldestLoaded.createdAt }, + }, + }) + : undefined; return { id: row.id, title: readConversationTitle(row), @@ -726,6 +736,9 @@ export async function fetchConversationWithMessages( attachments, attachmentCount: attachments.length, documentTitle: attachments[0]?.title ?? null, + ...(earlierAssistantMessages !== undefined + ? { earlierAssistantMessages } + : {}), }; } diff --git a/src/lib/ai/coach/turn/__tests__/conversation-names.test.ts b/src/lib/ai/coach/turn/__tests__/conversation-names.test.ts index 27e5270cf..83ceb2355 100644 --- a/src/lib/ai/coach/turn/__tests__/conversation-names.test.ts +++ b/src/lib/ai/coach/turn/__tests__/conversation-names.test.ts @@ -6,19 +6,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const m = vi.hoisted(() => ({ - fetchConversationWithMessages: vi.fn(), - countAssistantMessagesBefore: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), })); -vi.mock("@/lib/db", () => ({ prisma: {} })); -vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/ai/coach/persistence", () => ({ - CONVERSATION_MESSAGE_DETAIL_CAP: 200, - appendMessage: vi.fn(), - createConversation: vi.fn(), - fetchConversationWithMessages: m.fetchConversationWithMessages, - countAssistantMessagesBefore: m.countAssistantMessagesBefore, +vi.mock("@/lib/db", () => ({ + prisma: { + coachConversation: { findFirst: m.findFirst }, + coachMessage: { count: m.count }, + }, })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(), + decryptFromBytes: vi.fn(() => "x"), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); import { resolveTurnConversation } from "../conversation"; @@ -38,24 +40,42 @@ const TABLE = { displayed: true, }; -/** `n` messages alternating user / assistant, the last one holding a table. */ -function window(n: number) { - return Array.from({ length: n }, (_, i) => ({ - id: `m${i}`, - role: i % 2 === 0 ? "user" : "assistant", - content: "x", - createdAt: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(), - metricSource: i === n - 1 ? { results: [TABLE] } : null, - providerType: i % 2 === 0 ? null : "openai", - })); +/** + * The newest `n` messages as the detail read returns them (newest first), + * alternating user / assistant, the newest assistant one holding a table. + */ +function rows(n: number) { + return Array.from({ length: n }, (_, i) => { + const age = i; // 0 = newest + const assistant = age % 2 === 0; + return { + id: `m${n - age}`, + role: assistant ? "assistant" : "user", + encryptedContent: new Uint8Array(), + createdAt: new Date(Date.UTC(2026, 0, 1, 0, n - age)), + metricSourceJson: + age === 0 + ? JSON.stringify({ windows: [], metrics: [], results: [TABLE] }) + : null, + providerType: assistant ? "openai" : null, + promptVersion: null, + tokensUsed: null, + model: null, + }; + }); } -async function priorOf(messages: ReturnType) { - m.fetchConversationWithMessages.mockResolvedValue({ +async function priorOf(messages: ReturnType) { + m.findFirst.mockResolvedValue({ id: "c1", + titleEncrypted: null, + title: "t", + createdAt: new Date(), + updatedAt: new Date(), + documentScoped: false, + summaryEncrypted: null, messages, - attachmentCount: 0, - summary: null, + attachments: [], }); const out = await resolveTurnConversation({ userId: "u1", @@ -68,25 +88,28 @@ async function priorOf(messages: ReturnType) { } beforeEach(() => { - m.fetchConversationWithMessages.mockReset(); - m.countAssistantMessagesBefore.mockReset(); + m.findFirst.mockReset(); + m.count.mockReset(); }); describe("prior table names across the loaded window", () => { it("counts the assistant messages before a full window", async () => { - m.countAssistantMessagesBefore.mockResolvedValue(40); - const prior = await priorOf(window(200)); + m.count.mockResolvedValue(40); + const prior = await priorOf(rows(200)); // 100 assistant messages in the window, 40 before it. expect(prior.map((p) => p.turnIndex)).toEqual([140]); - expect(m.countAssistantMessagesBefore).toHaveBeenCalledWith( - "c1", - new Date(Date.UTC(2026, 0, 1, 0, 0)), - ); + expect(m.count).toHaveBeenCalledWith({ + where: { + conversationId: "c1", + role: "assistant", + createdAt: { lt: new Date(Date.UTC(2026, 0, 1, 0, 1)) }, + }, + }); }); it("reads no count while the whole conversation is loaded", async () => { - const prior = await priorOf(window(10)); + const prior = await priorOf(rows(10)); expect(prior.map((p) => p.turnIndex)).toEqual([5]); - expect(m.countAssistantMessagesBefore).not.toHaveBeenCalled(); + expect(m.count).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/ai/coach/turn/conversation.ts b/src/lib/ai/coach/turn/conversation.ts index 223b1e7fb..54cb9534f 100644 --- a/src/lib/ai/coach/turn/conversation.ts +++ b/src/lib/ai/coach/turn/conversation.ts @@ -9,9 +9,7 @@ import { auditLog } from "@/lib/auth/audit"; import type { Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { - CONVERSATION_MESSAGE_DETAIL_CAP, appendMessage, - countAssistantMessagesBefore, createConversation, fetchConversationWithMessages, } from "@/lib/ai/coach/persistence"; @@ -23,24 +21,6 @@ import { latestMessagesOnce } from "@/lib/ai/coach/latest-messages"; import { streamRefusal } from "./sse"; import type { TurnConversation } from "./types"; -/** - * How many assistant messages precede the loaded window. The detail read - * loads only the newest messages; counting from the first loaded one gave - * an older table a new `m` name every turn once a conversation outgrew - * the window, so a name the model had read earlier pointed somewhere else. - * Read only when the window is full. - */ -async function assistantMessagesBefore(existing: { - id: string; - messages: ReadonlyArray<{ createdAt: string }>; -}): Promise { - const first = existing.messages[0]; - if (!first || existing.messages.length < CONVERSATION_MESSAGE_DETAIL_CAP) { - return 0; - } - return countAssistantMessagesBefore(existing.id, new Date(first.createdAt)); -} - export async function resolveTurnConversation(args: { userId: string; conversationId: string | undefined; @@ -78,6 +58,7 @@ export async function resolveTurnConversation(args: { // instruction against the coach's write tools. Do not relax. const existing = await fetchConversationWithMessages(userId, conversationId, { documentScoped: false, + countEarlierAssistant: true, }); if (!existing) { // 404, not 403 — never reveal cross-user / cross-mode existence @@ -162,9 +143,13 @@ export async function resolveTurnConversation(args: { priorToolFigures, priorSummary: existing.summary ?? null, // v1.39.4 — the tables earlier replies hold, named for the context. + // Named by their place in the whole conversation: counting from the + // first loaded message gave an older table a new `m` name every turn + // once the conversation outgrew the window, so a name the model had + // read earlier pointed somewhere else. priorResults: collectPriorResults( existing.messages, - await assistantMessagesBefore(existing), + existing.earlierAssistantMessages ?? 0, ), latestMessages: latestMessagesOnce(userId, existing.id), }, diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 3bb7b8195..24dd84ebf 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -758,6 +758,11 @@ export interface CoachConversationDetailDTO extends CoachConversationDTO { * `insights.coach.fence_drift` audit). Equal to `attachments.length`. */ attachmentCount: number; + /** + * v1.39.4 — server-internal: the assistant messages older than the loaded + * window, when the turn asked for the count. Never sent to a client. + */ + earlierAssistantMessages?: number; } /**