refactor(coach): count the assistant messages before the window inside the conversation read

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:22:09 +02:00
1 parent 4e47670f25
commit d6ac4acad2
5 files changed
+97 -71

No files matched your search

@@ -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();
});
+28 -15
View File
@@ -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<number> {
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<CoachConversationDetailDTO | null> {
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 }
: {}),
};
}
@@ -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<typeof window>) {
m.fetchConversationWithMessages.mockResolvedValue({
async function priorOf(messages: ReturnType<typeof rows>) {
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<typeof window>) {
}
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();
});
});
+6 -21
View File
@@ -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<k>` 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<number> {
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<k>` 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),
},
+5
View File
@@ -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;
}
/**