fix(coach): ask the capability before deferring a memory refresh, and pin test fixtures to the new quiet time

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 21:02:41 +02:00
1 parent c56a05925c
commit c7d659eb11
7 files changed
+49 -20

No files matched your search

+1 -1
View File
@@ -190,7 +190,7 @@ export function AssistantSection() {
>
<SettingsToggle
label={t(REASONING_ADMIN_KEYS.enabled)}
description={t("admin.assistant.reasoning.description")}
description={t(REASONING_ADMIN_KEYS.description)}
checked={reasoningOn}
onCheckedChange={(checked) =>
mutation.mutate({ aiReasoningEnabled: checked })
@@ -97,7 +97,22 @@ describe("runCoachMemoryRefresh", () => {
COACH_MEMORY_QUIET_MS - 10 * 60_000,
);
expect(extractAndStoreFacts).not.toHaveBeenCalled();
expect(aiCapabilityForJob).not.toHaveBeenCalled();
// The capability is asked first, so a switched-off Coach never re-queues.
expect(aiCapabilityForJob).toHaveBeenCalledTimes(1);
});
it("ends without re-queueing when the Coach is off, however fresh the conversation", async () => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
} as never);
newestMessage.mockResolvedValue({
createdAt: new Date(NOW.getTime() - 60_000),
});
await runCoachMemoryRefresh(payload, NOW);
expect(enqueueCoachMemoryRefresh).not.toHaveBeenCalled();
expect(extractAndStoreFacts).not.toHaveBeenCalled();
});
it("runs on a short conversation once it is quiet (no twenty-turn gate)", async () => {
@@ -113,9 +128,14 @@ describe("runCoachMemoryRefresh", () => {
});
it("does nothing for a conversation that is gone", async () => {
vi.mocked(aiCapabilityForJob).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
newestMessage.mockResolvedValue(null);
await runCoachMemoryRefresh(payload, NOW);
expect(aiCapabilityForJob).not.toHaveBeenCalled();
expect(extractAndStoreFacts).not.toHaveBeenCalled();
expect(enqueueCoachMemoryRefresh).not.toHaveBeenCalled();
});
});
@@ -121,8 +121,8 @@ describe("v1.41 contract keys", () => {
it("produces the whole catalog", () => {
// Every key laid down for the release but the `change_assumption` chip
// label, which the dialog catalog's own test covers, plus the two
// declined-proposal replies.
expect(keys.size).toBe(59);
// declined-proposal replies and the admin reasoning description.
expect(keys.size).toBe(60);
});
it.each(locales)("every catalog key resolves in %s", (locale) => {
+15 -13
View File
@@ -61,6 +61,21 @@ export async function runCoachMemoryRefresh(
// fallback body for every locale that has no reviewed one.
const locale = payload.locale ?? "en";
// The `coach` capability first, before the quiet-time check and any transcript
// read: a job for a Coach that is switched off ends here rather than
// re-queueing itself until the conversation goes quiet. The chat route that
// enqueued this admitted the Coach, but the operator, the person's Coach
// switch or a consent can change while the job waits. Stored memory stays
// as it is; only new model work stops. The chokepoint re-checks per step.
const capability = await aiCapabilityForJob(userId, "coach");
if (!capability.available) {
annotate({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason: capability.reason },
});
return;
}
const quiet = await quietForMs(conversationId, userId, now);
if (quiet === null) {
annotate({
@@ -81,19 +96,6 @@ export async function runCoachMemoryRefresh(
return;
}
// The `coach` capability before any transcript is read. The chat route that
// enqueued this admitted the Coach, but the operator, the person's Coach
// switch or a consent can change while the job waits. Stored memory stays
// as it is; only new model work stops. The chokepoint re-checks per step.
const capability = await aiCapabilityForJob(userId, "coach");
if (!capability.available) {
annotate({
action: { name: "coach.memory.refresh.skipped" },
meta: { reason: capability.reason },
});
return;
}
let summaryStatus = "error";
try {
const result = await refreshConversationSummary(conversationId, userId, {
+1
View File
@@ -128,4 +128,5 @@ export const REASONING_SETTING_KEYS = {
export const REASONING_ADMIN_KEYS = {
enabled: "admin.assistant.reasoning.enabled",
max: "admin.assistant.reasoning.max",
description: "admin.assistant.reasoning.description",
} as const;
@@ -101,6 +101,10 @@ async function seed(recordId: string): Promise<void> {
category: "preference",
confidence: 80,
createdAt: new Date(NOW - DAY),
// Pinned like `createdAt`: each state re-seeds the record, and the
// facts read publishes `updatedAt`, so a wall-clock stamp would differ
// between states for reasons that have nothing to do with AI.
updatedAt: new Date(NOW - DAY),
},
});
}
+4 -2
View File
@@ -136,7 +136,9 @@ async function seedState(state: State) {
data: { userId: user.id, kind: "weight", localDate, occurredAt },
});
// Long enough that the rolling summary has turns to fold.
// Long enough that the rolling summary has turns to fold, and over an hour
// old so the memory refresh finds the conversation quiet and does its work
// instead of deferring.
const conversation = await prisma.coachConversation.create({
data: { userId: user.id, title: "long conversation" },
});
@@ -145,7 +147,7 @@ async function seedState(state: State) {
conversationId: conversation.id,
role: i % 2 === 0 ? "user" : "assistant",
encryptedContent: encryptToBytes(`turn ${i}: about sleep and walks`),
createdAt: new Date(now - (40 - i) * 60_000),
createdAt: new Date(now - (100 - i) * 60_000),
})),
});