feat(coach): serve a message's stored tables and publish the stream contract

GET /api/insights/chat/{id}/messages/{messageId}/results returns the
tables one assistant message read, narrowed to the conversation's owner
like the conversation read, with a table withheld as module_disabled when
its domain's module is off and as unavailable when it cannot be read. A
data read: no AI capability.

The published document now describes every stream frame as the
CoachStreamEvent union, including the suggestedAction frame it never
named, the new step, result, followUps and clarification frames, their
components, the provenance fields, the followUp and clarification request
fields, and the reuse provider type.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:45:18 +02:00
1 parent 8fcd204172
commit ad72ae3e4c
7 files changed
+1523 -167

No files matched your search

@@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
"One stored conversation: read, rename, delete.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
"A rating on a message that already exists.",
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts":
"The stored tables of one message: the person's record, withheld per module, never per AI capability.",
"src/app/api/insights/breathing-screening/route.ts":
"Device breathing data, owned by the sleep module.",
"src/app/api/insights/labs-changes/route.ts":
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
readMessageResults: vi.fn(),
resolveModuleMap: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
return {
...actual,
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
handler,
requireAuth,
};
});
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap }));
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults }));
import { GET } from "../route";
const USER_ID = "user-1";
function call(id = "c1", messageId = "m1") {
return GET(
new NextRequest(
`http://localhost/api/insights/chat/${id}/messages/${messageId}/results`,
),
{ params: Promise.resolve({ id, messageId }) },
) as Promise<Response>;
}
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
resolveModuleMap.mockResolvedValue({ sleep: false, mood: true });
});
describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => {
it("reads under the caller's own id and the path ids only", async () => {
readMessageResults.mockResolvedValue([]);
const response = await call("c1", "m1");
expect(response.status).toBe(200);
expect(readMessageResults).toHaveBeenCalledWith(
USER_ID,
"c1",
"m1",
expect.any(Function),
);
expect(await response.json()).toEqual({
data: { results: [] },
error: null,
});
});
it("answers 404 for a message the caller does not own", async () => {
readMessageResults.mockResolvedValue(null);
const response = await call();
expect(response.status).toBe(404);
});
it("withholds a domain whose module is switched off, and only that one", async () => {
readMessageResults.mockResolvedValue([]);
await call();
const withhold = readMessageResults.mock.calls[0][3] as (
domain: string,
) => boolean;
expect(withhold("sleep")).toBe(true);
expect(withhold("mood")).toBe(false);
// No module owns blood pressure or labs: never withheld.
expect(withhold("bp")).toBe(false);
expect(withhold("labs")).toBe(false);
});
it("refuses an unauthenticated caller before any read", async () => {
requireAuth.mockRejectedValue(new Error("Not authenticated"));
await expect(call()).rejects.toThrow("Not authenticated");
expect(readMessageResults).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
/**
* GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of
* values one assistant message read, decrypted for their owner.
*
* A data read, never AI-gated: the tables are the person's stored record,
* readable while the Coach is unavailable for any reason, like the
* conversation they belong to. Narrowed exactly like
* `GET /api/insights/chat/[id]`: the message must sit in a conversation the
* caller owns, and a foreign or unknown id is 404, never 403.
*
* Each table is served whole or withheld with a reason: `module_disabled`
* when its domain's module is switched off for the record now (the table is
* not even decrypted), `unavailable` when the stored tables cannot be read.
* The list follows the order of `metricSource.results` on the message.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
interface RouteCtx {
params: Promise<{ id: string; messageId: string }>;
}
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
const userId = auth.user.id;
const { id, messageId } = await ctx.params;
if (!id || !messageId) return apiError("coach.message.notFound", 404);
const modules = await resolveModuleMap(userId);
const results = await readMessageResults(userId, id, messageId, (domain) =>
isCoachDomainWithheld(domain, modules),
);
if (!results) return apiError("coach.message.notFound", 404);
annotate({
action: { name: "insights.coach.results.read" },
meta: {
conversationId: id,
tables: results.length,
withheld: results.filter((entry) => "withheld" in entry).length,
},
});
return apiSuccess({ results });
});
export const dynamic = "force-dynamic";