mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-security
# Conflicts: # src/lib/ai/coach/labs-snapshot.ts # src/lib/ai/coach/snapshot.ts
This commit is contained in:
commit
21aa791c3a
54 files changed
+544
-338
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9249,
|
||||
"total": 9243,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -360,7 +360,7 @@
|
||||
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
|
||||
"app/api/integrations/healthkit/route.ts": 3,
|
||||
"app/api/integrations/status/__tests__/route.test.ts": 2,
|
||||
"app/api/integrations/status/route.ts": 7,
|
||||
"app/api/integrations/status/route.ts": 6,
|
||||
"app/api/integrations/withings/resume/__tests__/route.test.ts": 1,
|
||||
"app/api/integrations/withings/resume/route.ts": 1,
|
||||
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 2,
|
||||
@@ -1381,7 +1381,7 @@
|
||||
"components/settings/integrations/connections-panel.tsx": 5,
|
||||
"components/settings/integrations/garmin-info-note.tsx": 1,
|
||||
"components/settings/integrations/notification-channels-panel.tsx": 1,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 5,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 2,
|
||||
"components/settings/integrations/shared.tsx": 3,
|
||||
"components/settings/labs-section.tsx": 1,
|
||||
"components/settings/layout-groups.ts": 2,
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"lib/ai/coach/turn/reply-guards.ts": 18,
|
||||
"lib/ai/coach/turn/reuse-turn.ts": 1,
|
||||
"lib/ai/coach/turn/sse.ts": 10,
|
||||
"lib/ai/coach/turn/steps.ts": 3,
|
||||
"lib/ai/coach/turn/steps.ts": 1,
|
||||
"lib/ai/coach/turn/types.ts": 17,
|
||||
"lib/ai/coach/types.ts": 58,
|
||||
"lib/ai/coach/visits-snapshot.ts": 1,
|
||||
|
||||
@@ -502,8 +502,8 @@ export const GET = apiHandler(async () => {
|
||||
} satisfies IntegrationViewModel & NightscoutExtras,
|
||||
{
|
||||
...publish(dawarichStatus),
|
||||
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
||||
// rides along like on every entry, and goes with the rest in v1.44.
|
||||
// Dawarich, read by the card through `health`. The ledger core rides
|
||||
// along like on every entry.
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
health: health["dawarich"],
|
||||
@@ -524,8 +524,9 @@ interface IntegrationViewModel {
|
||||
* health-shaped field on this entry (`state`, `lastError`,
|
||||
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
|
||||
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
|
||||
* it and goes once the settings cards render `health`; no other client
|
||||
* reads this envelope.
|
||||
* it. Those stay on the wire, deprecated in the published document; the
|
||||
* cards render `health`, and only their copied diagnostics still read
|
||||
* `consecutiveFailuresByKind`. No native client reads this envelope.
|
||||
*/
|
||||
health: ConnectionHealth | null;
|
||||
state: string;
|
||||
|
||||
@@ -98,7 +98,7 @@ export function BackupsSection() {
|
||||
{/* External docs link — `noopener noreferrer` because this
|
||||
leaves the admin shell. */}
|
||||
<a
|
||||
href="https://docs.healthlog.dev/admin/backups"
|
||||
href="https://docs.healthlog.dev/guides/backups/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
AiUnavailableHint,
|
||||
@@ -57,10 +62,26 @@ describe("<AiUnavailableHint>", () => {
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("nudges toward local OCR when a text-only provider needs it", () => {
|
||||
it("points at the on-device scan switch when a text-only provider needs it", () => {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("local OCR");
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
// The switch lives on the Labs module page, under its own label.
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).toContain("Settings → Modules → Labs");
|
||||
expect(html).toContain('href="/settings/modules/labs#labs-local-ocr"');
|
||||
expect(html).toContain("Open lab settings");
|
||||
expect(html).not.toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("keeps the sentence but withholds the Settings link inside somebody else's record", () => {
|
||||
record.inSharedRecord = true;
|
||||
try {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("Read scans on this device");
|
||||
expect(html).not.toContain('data-slot="assist-settings-link"');
|
||||
expect(html).not.toContain('href="/settings');
|
||||
} finally {
|
||||
record.inSharedRecord = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const record = vi.hoisted(() => ({ inSharedRecord: false }));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: record.inSharedRecord }),
|
||||
}));
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "../document-ai-section";
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AiRunPhase } from "@/hooks/use-ai-run";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -64,9 +65,13 @@ export function AiRunPhaseNote({
|
||||
}
|
||||
|
||||
/**
|
||||
* Calm pointer to the AI settings, shown when assist is unavailable. When the
|
||||
* document is already searchable (auto-indexed locally) the copy stays honest —
|
||||
* it is searchable, an AI provider only adds a richer read.
|
||||
* Calm pointer to the setting that would make assist available: the AI
|
||||
* settings when no provider is configured, the on-device scan switch on the
|
||||
* Labs module page when the provider cannot read images. Both live under
|
||||
* `/settings`, which is the caller's own and closed inside somebody else's
|
||||
* record, so the link is withheld there and the sentence stands alone. When
|
||||
* the document is already searchable (auto-indexed locally) the copy stays
|
||||
* honest — it is searchable, an AI provider only adds a richer read.
|
||||
*/
|
||||
export function AiUnavailableHint({
|
||||
reason,
|
||||
@@ -74,10 +79,11 @@ export function AiUnavailableHint({
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const body =
|
||||
reason === "enable-local-ocr"
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const localOcr = reason === "enable-local-ocr";
|
||||
const body = localOcr
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
return (
|
||||
<div
|
||||
data-slot="assist-unavailable"
|
||||
@@ -85,14 +91,25 @@ export function AiUnavailableHint({
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">
|
||||
{body}{" "}
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
{body}
|
||||
{!inSharedRecord && (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={
|
||||
localOcr
|
||||
? "/settings/modules/labs#labs-local-ocr"
|
||||
: "/settings/ai"
|
||||
}
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{localOcr
|
||||
? t("documents.assist.unavailableLocalOcrAction")
|
||||
: t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -251,8 +251,13 @@ describe("IntegrationsSection — one status display per card", () => {
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
expect(html).toContain(
|
||||
`href="https://docs.healthlog.dev/integrations/${provider}"`,
|
||||
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
|
||||
);
|
||||
}
|
||||
// Garmin has no connector; its note goes straight to the section of the
|
||||
// Google Health guide that explains the route, not through a redirect.
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe("<ManagedIntegrationStatus>", () => {
|
||||
"Services you connect here send their data to Managed record's record, not to yours.",
|
||||
);
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
|
||||
'href="https://docs.healthlog.dev/guides/managed-profiles/#connected-services"',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("<FitbitRetirementNotice>", () => {
|
||||
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
|
||||
expect(html).toContain("Everything already imported stays.");
|
||||
expect(html).toContain('href="#google-health"');
|
||||
expect(html).toContain("/google-health");
|
||||
expect(html).toContain("/guides/connect-google-health/");
|
||||
});
|
||||
|
||||
it("says the connection has stopped once the day has come", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
@@ -25,7 +25,7 @@ export function GarminInfoNote() {
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={`${INTEGRATION_DOCS_BASE}/garmin`}
|
||||
href={GARMIN_DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
|
||||
@@ -17,11 +17,16 @@ import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Base for every provider setup runbook. The provider key is appended as a
|
||||
* path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
|
||||
* a single constant so the host never drifts across cards.
|
||||
* The docs site's guides, where every provider runbook lives at
|
||||
* `connect-<provider>/`, e.g. `https://docs.healthlog.dev/guides/connect-whoop/`.
|
||||
* These are the pages themselves: the older `/integrations/<provider>` paths
|
||||
* only resolve through redirects. Kept as a single constant so the host never
|
||||
* drifts across cards.
|
||||
*/
|
||||
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
|
||||
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
|
||||
|
||||
/** Garmin has no connector; the Google Health guide explains the route. */
|
||||
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
|
||||
|
||||
export type IntegrationDocsProvider =
|
||||
| "whoop"
|
||||
@@ -30,15 +35,12 @@ export type IntegrationDocsProvider =
|
||||
| "polar"
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava"
|
||||
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
|
||||
| "dawarich";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,7 +54,7 @@ const INTEGRATION_DISPLAY_NAMES = {
|
||||
|
||||
/** Where the guardian boundary is explained, connected services included. */
|
||||
const SHARING_DOCS_HREF =
|
||||
"https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login";
|
||||
"https://docs.healthlog.dev/guides/managed-profiles/#connected-services";
|
||||
|
||||
function iconFor(key: ManagedIntegrationKey) {
|
||||
if (key === "nightscout") return Droplet;
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
summariseTitle,
|
||||
} from "../persistence";
|
||||
import { encryptToBytes } from "../bytes-codec";
|
||||
import type { CoachProvenance } from "../types";
|
||||
import { MAX_TURN_STEPS } from "../activity/contract";
|
||||
import type { CoachProvenance, CoachStep } from "../types";
|
||||
|
||||
describe("summariseTitle", () => {
|
||||
it("returns the input unchanged when below 80 chars", () => {
|
||||
@@ -212,6 +213,46 @@ describe("appendMessage — provenance round-trip", () => {
|
||||
expect(out.metricSource).toEqual(envelope);
|
||||
});
|
||||
|
||||
it("restores every step the turn may record, and none past the cap", async () => {
|
||||
const step = (n: number): CoachStep => ({
|
||||
id: `s${n}`,
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Read the series",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: n,
|
||||
});
|
||||
const recorded = Array.from({ length: MAX_TURN_STEPS }, (_, i) =>
|
||||
step(i + 1),
|
||||
);
|
||||
stubEchoCreate();
|
||||
const full = await appendMessage({
|
||||
conversationId: "conv_1",
|
||||
role: "assistant",
|
||||
content: "A long answer.",
|
||||
metricSource: { windows: [], metrics: [], steps: recorded },
|
||||
});
|
||||
expect(full.metricSource?.steps).toEqual(recorded);
|
||||
|
||||
// A stored blob longer than the cap is cut at the cap, not before it.
|
||||
stubEchoCreate(
|
||||
JSON.stringify({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
steps: [...recorded, step(MAX_TURN_STEPS + 1)],
|
||||
}),
|
||||
);
|
||||
const over = await appendMessage({
|
||||
conversationId: "conv_1",
|
||||
role: "assistant",
|
||||
content: "An over-long blob.",
|
||||
metricSource: { windows: [], metrics: [] },
|
||||
});
|
||||
expect(over.metricSource?.steps).toEqual(recorded);
|
||||
});
|
||||
|
||||
it("tolerates a legacy blob without the new fields (no throw, fields absent)", async () => {
|
||||
// A pre-feature row: only the original windows/metrics envelope on disk.
|
||||
stubEchoCreate(
|
||||
|
||||
@@ -41,6 +41,13 @@ export const ACTIVITY_TEXT_MAX_CHARS = 400;
|
||||
/** `a1`..`a99`: the 100th entry of a turn is not recorded. */
|
||||
export const ACTIVITY_MAX_ENTRIES = 99;
|
||||
|
||||
/**
|
||||
* `s1`..`s64`: the live steps of one turn (four per round of the longest
|
||||
* turn). Later tool calls still run but show no step. The turn records up to
|
||||
* this many and a reload restores up to this many, from this one constant.
|
||||
*/
|
||||
export const MAX_TURN_STEPS = 64;
|
||||
|
||||
export interface ActivityStart {
|
||||
phase: CoachActivityPhase;
|
||||
round: number;
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
* and no lab row is read, so neither the Coach prompt nor its `get_labs` tool
|
||||
* sees a value. It used to ride regardless, a decision from before Labs could
|
||||
* be switched off; the data routes (`/api/labs`) stay ungated, because a
|
||||
* module decides what a model is given, not what the owner can read.
|
||||
* module decides what a model is given, not what the owner can read. With the
|
||||
* module on, the block is `null` only when no reading falls inside the
|
||||
* lookback limit (the whole record when there is none), or, for a named read,
|
||||
* when no marker matches the analyte.
|
||||
*/
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import {
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
coachStopSchema,
|
||||
coachTrailSchema,
|
||||
} from "./stream-events";
|
||||
import { ACTIVITY_MAX_ENTRIES, TRAIL_MAX_BYTES } from "./activity/contract";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
MAX_TURN_STEPS,
|
||||
TRAIL_MAX_BYTES,
|
||||
} from "./activity/contract";
|
||||
import { commitTurnWrites } from "./memory/turn-writes";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
|
||||
import { isRedundantViewChip } from "./follow-ups/view-chip";
|
||||
@@ -56,7 +60,6 @@ import type {
|
||||
CoachResultEntry,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStepDomain,
|
||||
CoachSuggestion,
|
||||
CoachTrail,
|
||||
@@ -225,11 +228,6 @@ function restoreOne<T>(raw: unknown, schema: z.ZodType<T>): T | undefined {
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
/** The live steps a turn persisted (at most 48 per turn since v1.41). */
|
||||
function restoreSteps(raw: unknown): CoachStep[] | undefined {
|
||||
return restoreEach(raw, coachStepSchema, 48);
|
||||
}
|
||||
|
||||
/** The method line; dropped whole when it does not parse. */
|
||||
function restoreMethod(raw: unknown): CoachMethod | undefined {
|
||||
const parsed = coachMethodSchema.safeParse(raw);
|
||||
@@ -341,7 +339,8 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
|
||||
const toolCalls = restoreToolCalls(parsed.toolCalls);
|
||||
// v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
|
||||
// clarification choices, and the forced-answer marker.
|
||||
const steps = restoreSteps(parsed.steps);
|
||||
// Up to the same cap the turn records steps under.
|
||||
const steps = restoreEach(parsed.steps, coachStepSchema, MAX_TURN_STEPS);
|
||||
const method = restoreMethod(parsed.method);
|
||||
const results = restoreResultMetas(parsed.results);
|
||||
// A reply stored before view chips were withheld for a table the answer
|
||||
|
||||
@@ -667,8 +667,9 @@ async function buildCoachSnapshotImpl(
|
||||
// v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a
|
||||
// scope-gated metric: attached without a `registerBlock` so the budget
|
||||
// degrader never sheds it. The helper is module-gated internally: with the
|
||||
// labs module off it reads nothing and answers null. The block carries the most-recent resolved reading per biomarker (last 12 months,
|
||||
// capped) so the Coach can answer "what was my LDL" without re-deriving.
|
||||
// labs module off it reads nothing and answers null. The block carries the
|
||||
// latest resolved reading per biomarker (whole record or the lookback limit,
|
||||
// capped) so the Coach can answer "what was my LDL".
|
||||
const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach);
|
||||
|
||||
// v1.38 — doctor-visit context. Like illness/labs it is attempted always (a
|
||||
|
||||
@@ -34,7 +34,8 @@ import {
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types";
|
||||
|
||||
import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps";
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
import { snapshotStep, toStep } from "../steps";
|
||||
|
||||
const SOURCES = coachScopeSourceSchema.options;
|
||||
const WINDOWS = coachScopeWindowSchema.options;
|
||||
|
||||
@@ -38,13 +38,7 @@ import {
|
||||
} from "@/lib/ai/coach/tools/definitions";
|
||||
import { isTurnResultRef } from "@/lib/ai/coach/results/refs";
|
||||
import { COMPARE_SERIES_TOOL_NAME } from "@/lib/ai/coach/tools/compare-series";
|
||||
|
||||
/**
|
||||
* At most this many steps per turn; later calls run but show no step.
|
||||
* v1.41 — four per round of the longest turn: 48 for twelve rounds, 64 since
|
||||
* v1.41.2 raised the cap to sixteen.
|
||||
*/
|
||||
export const MAX_TURN_STEPS = 64;
|
||||
import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract";
|
||||
|
||||
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
|
||||
coachScopeSourceSchema.options,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Doc ↔ code parity guard for the Google Health operator runbook.
|
||||
*
|
||||
* The setup-guide link on the Settings card sends a mid-setup user to
|
||||
* `docs.healthlog.dev/integrations/google-health`, rendered from
|
||||
* `docs/integrations/google-health.md`. If the client ever requests a scope the
|
||||
* `docs.healthlog.dev/guides/connect-google-health/`; the operator runbook in
|
||||
* this repository is `docs/integrations/google-health.md`. If the client ever requests a scope the
|
||||
* runbook doesn't list, or the walkthrough regresses back to a wrong provider
|
||||
* (the file was once a Fitbit-Web-API walkthrough), a self-hoster follows a
|
||||
* broken recipe and the OAuth consent screen rejects the connection.
|
||||
@@ -20,7 +20,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GOOGLE_HEALTH_CORE_SCOPES } from "@/lib/google-health/oauth";
|
||||
import {
|
||||
INTEGRATION_DOCS_BASE,
|
||||
DOCS_GUIDES_BASE,
|
||||
integrationDocsHref,
|
||||
} from "@/components/settings/integrations/setup-guide-link";
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("google-health setup runbook parity", () => {
|
||||
// The literal argument also compile-checks that "google-health" is a member
|
||||
// of the IntegrationDocsProvider union.
|
||||
expect(integrationDocsHref("google-health")).toBe(
|
||||
`${INTEGRATION_DOCS_BASE}/google-health`,
|
||||
`${DOCS_GUIDES_BASE}/connect-google-health/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -411,6 +411,74 @@
|
||||
"grounding": {
|
||||
"type": "string"
|
||||
},
|
||||
"searchedWindow": {
|
||||
"type": "string"
|
||||
},
|
||||
"available": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "number"
|
||||
},
|
||||
"firstDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastDate": {
|
||||
"type": "string"
|
||||
},
|
||||
"series": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"series": {
|
||||
"type": "string"
|
||||
},
|
||||
"unit": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"type": "number"
|
||||
},
|
||||
"mean": {
|
||||
"type": "number"
|
||||
},
|
||||
"min": {
|
||||
"type": "number"
|
||||
},
|
||||
"max": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"series",
|
||||
"unit",
|
||||
"count",
|
||||
"mean",
|
||||
"min",
|
||||
"max"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"reachableWithWindow": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"count",
|
||||
"firstDate",
|
||||
"lastDate",
|
||||
"reachableWithWindow"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"analyte": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -540,6 +540,52 @@ describe("get_illness_recovery — condition body site", () => {
|
||||
});
|
||||
|
||||
describe("get_labs", () => {
|
||||
it.each([{}, { analyte: "LDL" }])(
|
||||
"declares empty-read availability for ordinary and filtered reads: %j",
|
||||
async (args) => {
|
||||
const { z } = await import("zod/v4");
|
||||
const empty = {
|
||||
present: false,
|
||||
reason: "out_of_window",
|
||||
searchedWindow: "lastYear",
|
||||
available: {
|
||||
count: 2,
|
||||
firstDate: "2020-01-01",
|
||||
lastDate: "2020-01-02",
|
||||
reachableWithWindow: null,
|
||||
},
|
||||
};
|
||||
vi.mocked(executeCoachTool).mockResolvedValue(empty);
|
||||
const def = tool("get_labs");
|
||||
const result = await def.run(CTX, args);
|
||||
expect(executeCoachTool).toHaveBeenCalledWith({
|
||||
userId: CTX.userId,
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify(args),
|
||||
});
|
||||
expect(z.object(def.outputShape!).strict().parse(result)).toEqual(empty);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps output validation closed and availability typed", async () => {
|
||||
const { z } = await import("zod/v4");
|
||||
const schema = z.object(tool("get_labs").outputShape!).strict();
|
||||
expect(z.toJSONSchema(schema).additionalProperties).toBe(false);
|
||||
expect(schema.safeParse({ present: false, unexpected: true }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
schema.safeParse({ present: false, searchedWindow: 7 }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
schema.safeParse({ present: false, available: { count: "2" } }).success,
|
||||
).toBe(false);
|
||||
expect(schema.parse({ present: false, reason: "no_data" })).toEqual({
|
||||
present: false,
|
||||
reason: "no_data",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the optional analyte filter and returns readings with units + bands", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
|
||||
@@ -5,16 +5,14 @@ import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
|
||||
import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
|
||||
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
import { coachReadOutput } from "./output-shapes";
|
||||
import { runCoachTool, whenModuleOn } from "./shared";
|
||||
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
|
||||
|
||||
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
|
||||
const getLabsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
// Latest-mode payload (the Coach labs section, carried opaque).
|
||||
data: z.unknown().optional(),
|
||||
grounding: z.string().optional(),
|
||||
// Latest-mode payload, including typed empty-read availability metadata.
|
||||
...coachReadOutput,
|
||||
// History-mode payload (per-analyte trajectory + pagination).
|
||||
analyte: z.string().optional(),
|
||||
readings: z
|
||||
|
||||
@@ -75,10 +75,12 @@ const healthKitEntry = z
|
||||
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
|
||||
});
|
||||
|
||||
// The health-shaped fields that predate `health` (v1.42.4). They stay on the
|
||||
// wire through v1.43 so App 1.2.0 keeps decoding, are marked deprecated in the
|
||||
// document, and go in v1.44.
|
||||
const LEGACY_HEALTH_FIELD = "Deprecated: removed in v1.44, read `health`.";
|
||||
// The health-shaped fields that predate `health`. They are marked deprecated
|
||||
// in the document and stay on the wire: native builds from before `health`
|
||||
// still decode them, the current one falls back to them only against a server
|
||||
// that sends no `health`, and the web card's copied diagnostics read the
|
||||
// failure streak. No removal release is set.
|
||||
const LEGACY_HEALTH_FIELD = "Deprecated, still served: read `health`.";
|
||||
|
||||
function legacy(text?: string): { deprecated: true; description: string } {
|
||||
return {
|
||||
@@ -1184,7 +1186,7 @@ const integrationEnvelopeEntry = z
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.describe(
|
||||
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
|
||||
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and still served for clients that predate `health`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
Reference in new issue
Block a user