Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
- name: Verify OpenRouter client
run: pnpm test:ai-client

- name: Verify carry-over logic
run: pnpm test:ai-carryover

- name: Build (includes typecheck)
run: pnpm build
env:
Expand Down
245 changes: 245 additions & 0 deletions e2e/regression/carryover-briefing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { randomUUID } from "node:crypto";
import { test, expect, type APIRequestContext } from "@playwright/test";
import { waitForApp } from "./seed-data";

const SUPABASE_URL = process.env.SUPABASE_URL ?? "http://127.0.0.1:54321";
const HAS_SERVICE_ROLE = !!process.env.SUPABASE_SERVICE_ROLE_KEY;
const HAS_OPENROUTER_KEY = !!process.env.OPENROUTER_API_KEY;
const TEST_USER_ID = "00000000-0000-0000-0000-000000000001";

function serviceHeaders(prefer = "return=representation") {
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY is required for this test");
return {
apikey: key,
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
Prefer: prefer,
};
}

async function rest(
request: APIRequestContext,
path: string,
options: Parameters<APIRequestContext["fetch"]>[1] = {}
) {
const response = await request.fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
...options,
headers: {
...serviceHeaders(),
...(options.headers ?? {}),
},
});
expect(response.ok()).toBeTruthy();

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails

3) [chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:208:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails

3) [chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:208:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails

3) [chromium] › e2e/regression/carryover-briefing.spec.ts:205:7 › Carry-over briefing › shows an error state when the briefing fails Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:208:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view

2) [chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:174:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view

2) [chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:174:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view

2) [chromium] › e2e/regression/carryover-briefing.spec.ts:171:7 › Carry-over briefing › renders the carry-over panel in the upcoming meeting view Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:174:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured

1) [chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:121:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured

1) [chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:121:21

Check failure on line 33 in e2e/regression/carryover-briefing.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Tests 6/16

[chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured

1) [chromium] › e2e/regression/carryover-briefing.spec.ts:114:7 › Carry-over briefing › returns 503 from the real endpoint when OpenRouter is not configured Error: expect(received).toBeTruthy() Received: false 31 | }, 32 | }); > 33 | expect(response.ok()).toBeTruthy(); | ^ 34 | return response.status() === 204 ? null : response.json(); 35 | } 36 | at rest (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:33:25) at createCarryoverFixture (/home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:76:3) at /home/runner/work/minutia/minutia/e2e/regression/carryover-briefing.spec.ts:121:21
return response.status() === 204 ? null : response.json();
}

async function deleteSeries(request: APIRequestContext, id: string) {
await rest(request, `meeting_series?id=eq.${id}`, {
method: "DELETE",
headers: serviceHeaders("return=minimal"),
});
}

// Series with an upcoming meeting and two open issues carried in:
// one overdue with no owner, one owned and on track.
async function createCarryoverFixture(request: APIRequestContext) {
const stamp = Date.now();
const seriesId = randomUUID();
const meetingId = randomUUID();

await rest(request, "meeting_series", {
method: "POST",
data: {
id: seriesId,
name: `Carry-over coverage ${stamp}`,
description: "Created by carry-over briefing coverage.",
cadence: "weekly",
default_attendees: ["Alice", "Bob"],
owner_id: TEST_USER_ID,
},
});

await rest(request, "meetings", {
method: "POST",
data: {
id: meetingId,
series_id: seriesId,
sequence_number: 2,
title: `Carry-over session ${stamp}`,
date: "2026-12-01",
attendees: ["Alice", "Bob"],
status: "upcoming",
},
});

await rest(request, "issues", {
method: "POST",
data: {
id: randomUUID(),
series_id: seriesId,
raised_in_meeting_id: meetingId,
title: `Overdue rollout ${stamp}`,
description: "Overdue carry-over item with no owner.",
category: "action",
status: "open",
priority: "high",
owner_name: null,
due_date: "2026-01-01",
source: "manual",
},
});

await rest(request, "issues", {
method: "POST",
data: {
id: randomUUID(),
series_id: seriesId,
raised_in_meeting_id: meetingId,
title: `Owned follow-up ${stamp}`,
description: "Owned carry-over item on track.",
category: "risk",
status: "in_progress",
priority: "medium",
owner_name: "Alice",
due_date: "2026-12-15",
source: "manual",
},
});

return { seriesId, meetingId };
}

test.describe("Carry-over briefing", () => {
test("returns 503 from the real endpoint when OpenRouter is not configured", async ({
page,
request,
}) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data");
test.skip(HAS_OPENROUTER_KEY, "Requires OpenRouter to be unconfigured");

const fixture = await createCarryoverFixture(request);

try {
await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);

const response = await page.request.post(
`/api/meetings/${fixture.meetingId}/carryover-briefing`,
{ data: {}, timeout: 20_000 }
);
expect(response.status()).toBe(503);
await expect(response.json()).resolves.toMatchObject({
error: "Carry-over briefing is not configured.",
});
} finally {
await deleteSeries(request, fixture.seriesId);
}
});

test("generates a briefing with deterministic counts through the backend route", async ({
page,
request,
}) => {
test.setTimeout(90_000);
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data");
test.skip(!HAS_OPENROUTER_KEY, "Requires OpenRouter for backend carry-over coverage");

const fixture = await createCarryoverFixture(request);

try {
const response = await page.request.post(
`/api/meetings/${fixture.meetingId}/carryover-briefing`,
{ data: {}, timeout: 60_000 }
);
expect(response.status()).toBe(200);

const payload = await response.json();
expect(payload).toMatchObject({
prompt_version: "carryover-briefing-v1",
issues_count: 2,
overdue_count: 1,
no_owner_count: 1,
});
expect(typeof payload.briefing_markdown).toBe("string");
expect(payload.briefing_markdown.length).toBeGreaterThan(0);
} finally {
await deleteSeries(request, fixture.seriesId);
}
});

test("renders the carry-over panel in the upcoming meeting view", async ({ page, request }) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data");

const fixture = await createCarryoverFixture(request);

try {
await page.route("**/api/meetings/*/carryover-briefing", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
briefing_markdown: "2 open items, 1 overdue. Overdue rollout has no owner.",
overdue_count: 1,
no_owner_count: 1,
issues_count: 2,
model: "test-model",
prompt_version: "carryover-briefing-v1",
}),
});
});

await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);

await expect(page.getByRole("heading", { name: "Carry-over briefing" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "Generate briefing" }).click();
await expect(page.getByText("2 open items, 1 overdue.")).toBeVisible();
} finally {
await deleteSeries(request, fixture.seriesId);
}
});

test("shows an error state when the briefing fails", async ({ page, request }) => {
test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data");

const fixture = await createCarryoverFixture(request);

try {
await page.route("**/api/meetings/*/carryover-briefing", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "Carry-over briefing is not configured." }),
});
});

await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`);
await waitForApp(page);

await expect(page.getByRole("heading", { name: "Carry-over briefing" })).toBeVisible({
timeout: 20_000,
});
await page.getByRole("button", { name: "Generate briefing" }).click();
await expect(page.getByText("Carry-over briefing is not configured.")).toBeVisible();
} finally {
await deleteSeries(request, fixture.seriesId);
}
});
});

test.describe("Carry-over briefing auth", () => {
test.use({ storageState: { cookies: [], origins: [] } });

test("rejects unauthenticated briefing requests before checking provider config", async ({
request,
}) => {
const response = await request.post(
`/api/meetings/${randomUUID()}/carryover-briefing`,
{ data: {} }
);
expect(response.status()).toBe(401);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"test:ci-workflows": "node scripts/verify-ci-playwright-shards.mjs",
"test:ai-contracts": "node scripts/verify-ai-notes-contract.mjs",
"test:ai-client": "node --test scripts/verify-openrouter-client.test.mjs",
"test:ai-carryover": "node --test scripts/verify-carryover.test.mjs",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
Expand Down
30 changes: 30 additions & 0 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -293,4 +293,34 @@ for (const copy of [
assert(seriesDetail.includes(copy), `Series detail missing Ask UI copy: ${copy}`);
}

// Carry-over briefing (OIL-native pre-meeting intelligence).
assert(
exists("src/app/api/meetings/[meetingId]/carryover-briefing/route.ts"),
"Missing carry-over briefing API route"
);
const carryoverRoute = read("src/app/api/meetings/[meetingId]/carryover-briefing/route.ts");
assertSharedClient(carryoverRoute, "Carryover briefing");
assert(carryoverRoute.includes("carryover-briefing-v1"), "Carryover briefing route must declare a prompt version");
assert(
carryoverRoute.includes("Do not invent owners, dates, or resolutions"),
"Carryover briefing prompt must forbid invented content"
);
assert(
carryoverRoute.includes("Do not wrap it in markdown fences"),
"Carryover briefing prompt must forbid fenced output"
);
assert(
carryoverRoute.includes("summarizeCarryover"),
"Carryover briefing must derive counts from the deterministic summary, not the model"
);

assert(
meetingDetail.includes("CarryoverBriefingPanel"),
"Upcoming meeting view must mount the carry-over briefing panel"
);
const carryoverPanel = read("src/components/minutia/carryover-briefing-panel.tsx");
for (const copy of ["Carry-over briefing", "Generate briefing"]) {
assert(carryoverPanel.includes(copy), `Carry-over panel missing UI copy: ${copy}`);
}

console.log("AI notes contract verified");
84 changes: 84 additions & 0 deletions scripts/verify-carryover.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";

// Bundle the pure carry-over logic so node:test can exercise it (repo pattern).
const root = process.cwd();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-carryover-"));
const bundled = path.join(tempDir, "carryover.mjs");
await esbuild.build({
entryPoints: ["src/lib/ai/carryover.ts"],
outfile: bundled,
bundle: true,
platform: "node",
format: "esm",
logLevel: "silent",
absWorkingDir: root,
});
const { summarizeCarryover, parseCarryoverBriefing } = await import(pathToFileURL(bundled).href);

const TODAY = new Date("2026-06-06T00:00:00Z");

const ISSUES = [
// Overdue, owned, very old -> stale.
{ issue_number: 1, title: "Ship CI", category: "action", status: "open", owner_name: "Sam", due_date: "2026-06-01", created_at: "2026-01-01T00:00:00Z" },
// Future due, no owner, fresh.
{ issue_number: 2, title: "Draft RFC", category: "info", status: "open", owner_name: null, due_date: "2026-12-01", created_at: "2026-06-05T00:00:00Z" },
// No due date, owned, old -> stale.
{ issue_number: 3, title: "Coverage gap", category: "risk", status: "in_progress", owner_name: "Lee", due_date: null, created_at: "2026-05-01T00:00:00Z" },
];

test("summarizeCarryover flags overdue items and orders them first", () => {
const summary = summarizeCarryover(ISSUES, TODAY);
assert.equal(summary.total, 3);
assert.equal(summary.issues[0].issue_number, 1);
assert.equal(summary.issues[0].overdue, true);
assert.equal(summary.overdue_count, 1);
});

test("summarizeCarryover counts items with no owner", () => {
const summary = summarizeCarryover(ISSUES, TODAY);
assert.equal(summary.no_owner_count, 1);
});

test("summarizeCarryover computes days_open and flags stale items", () => {
const summary = summarizeCarryover(ISSUES, TODAY);
const first = summary.issues.find((i) => i.issue_number === 1);
assert.ok(first.days_open > 150, `expected >150 days open, got ${first.days_open}`);
// Issue 1 (~156d) and issue 3 (~36d) are stale; issue 2 (~1d) is not.
assert.equal(summary.stale_count, 2);
});

test("summarizeCarryover orders non-overdue by due date with nulls last", () => {
const summary = summarizeCarryover(ISSUES, TODAY);
assert.deepEqual(summary.issues.map((i) => i.issue_number), [1, 2, 3]);
});

test("summarizeCarryover returns zeros for an empty list", () => {
const summary = summarizeCarryover([], TODAY);
assert.deepEqual(summary, { total: 0, overdue_count: 0, no_owner_count: 0, stale_count: 0, issues: [] });
});

test("parseCarryoverBriefing tolerates markdown-fenced provider JSON", () => {
const providerData = {
choices: [
{
message: {
content: "```json\n" + JSON.stringify({
briefing_markdown: "3 open items, 1 overdue.",
overdue_count: 1,
no_owner_count: 1,
}) + "\n```",
},
},
],
};
const parsed = parseCarryoverBriefing(providerData);
assert.equal(parsed.briefing_markdown, "3 open items, 1 overdue.");
assert.equal(parsed.overdue_count, 1);
assert.equal(parsed.no_owner_count, 1);
});
Loading
Loading