diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 3ee132231..6d3994a24 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -190,7 +190,7 @@ export function AssistantSection() { > mutation.mutate({ aiReasoningEnabled: checked }) diff --git a/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts index cb53d7df3..88a5bef88 100644 --- a/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts +++ b/src/lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts @@ -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(); }); }); diff --git a/src/lib/ai/coach/__tests__/contract-keys.test.ts b/src/lib/ai/coach/__tests__/contract-keys.test.ts index d5ef9ecb5..81747a5de 100644 --- a/src/lib/ai/coach/__tests__/contract-keys.test.ts +++ b/src/lib/ai/coach/__tests__/contract-keys.test.ts @@ -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) => { diff --git a/src/lib/ai/coach/coach-memory-refresh-worker.ts b/src/lib/ai/coach/coach-memory-refresh-worker.ts index 9d8ce0f26..c93f4c7fc 100644 --- a/src/lib/ai/coach/coach-memory-refresh-worker.ts +++ b/src/lib/ai/coach/coach-memory-refresh-worker.ts @@ -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, { diff --git a/src/lib/ai/reasoning/levels.ts b/src/lib/ai/reasoning/levels.ts index 28573c634..3dca7931c 100644 --- a/src/lib/ai/reasoning/levels.ts +++ b/src/lib/ai/reasoning/levels.ts @@ -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; diff --git a/tests/integration/ai-optional-data-routes.test.ts b/tests/integration/ai-optional-data-routes.test.ts index f668f6636..465e775d0 100644 --- a/tests/integration/ai-optional-data-routes.test.ts +++ b/tests/integration/ai-optional-data-routes.test.ts @@ -101,6 +101,10 @@ async function seed(recordId: string): Promise { 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), }, }); } diff --git a/tests/integration/ai-optional-jobs.test.ts b/tests/integration/ai-optional-jobs.test.ts index e0c216f0b..f3b4c61d0 100644 --- a/tests/integration/ai-optional-jobs.test.ts +++ b/tests/integration/ai-optional-jobs.test.ts @@ -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), })), });