Skip to content
Open
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
6 changes: 6 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,15 @@ import type * as garmin_credentials from "../garmin/credentials.js";
import type * as garmin_oauth1 from "../garmin/oauth1.js";
import type * as garmin_oauthFlow from "../garmin/oauthFlow.js";
import type * as garmin_registration from "../garmin/registration.js";
import type * as garmin_trainingApi from "../garmin/trainingApi.js";
import type * as garmin_webhookDispatch from "../garmin/webhookDispatch.js";
import type * as garmin_webhookEvents from "../garmin/webhookEvents.js";
import type * as garmin_webhookPayloads from "../garmin/webhookPayloads.js";
import type * as garmin_webhookSignature from "../garmin/webhookSignature.js";
import type * as garmin_wellnessDaily from "../garmin/wellnessDaily.js";
import type * as garmin_wellnessNormalizers from "../garmin/wellnessNormalizers.js";
import type * as garmin_workoutDelivery from "../garmin/workoutDelivery.js";
import type * as garmin_workoutPayload from "../garmin/workoutPayload.js";
import type * as goals from "../goals.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
Expand Down Expand Up @@ -269,12 +272,15 @@ declare const fullApi: ApiFromModules<{
"garmin/oauth1": typeof garmin_oauth1;
"garmin/oauthFlow": typeof garmin_oauthFlow;
"garmin/registration": typeof garmin_registration;
"garmin/trainingApi": typeof garmin_trainingApi;
"garmin/webhookDispatch": typeof garmin_webhookDispatch;
"garmin/webhookEvents": typeof garmin_webhookEvents;
"garmin/webhookPayloads": typeof garmin_webhookPayloads;
"garmin/webhookSignature": typeof garmin_webhookSignature;
"garmin/wellnessDaily": typeof garmin_wellnessDaily;
"garmin/wellnessNormalizers": typeof garmin_wellnessNormalizers;
"garmin/workoutDelivery": typeof garmin_workoutDelivery;
"garmin/workoutPayload": typeof garmin_workoutPayload;
goals: typeof goals;
healthCheck: typeof healthCheck;
http: typeof http;
Expand Down
58 changes: 57 additions & 1 deletion convex/accountDeletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async function drainAuthData(t: ReturnType<typeof convexTest>, userId: Id<"users
async function drainUserTableBatch(
t: ReturnType<typeof convexTest>,
userId: Id<"users">,
table: "currentStrengthScores" | "garminWebhookEvents",
table: "currentStrengthScores" | "garminWebhookEvents" | "garminWorkoutDeliveries",
) {
let iterations = 0;
while (await t.mutation(internal.accountDeletion.deleteUserTableBatch, { userId, table })) {
Expand Down Expand Up @@ -296,6 +296,62 @@ describe("deleteUserTableBatch", () => {
expect(remaining.deletedBlobExists).toBe(false);
expect(remaining.otherBlobExists).toBe(true);
});

test("deletes Garmin workout deliveries via the shared registry", async () => {
const t = convexTest(schema, modules);
const userId = await createUser(t);
const otherUserId = await createUser(t);

await t.run(async (ctx) => {
const now = 1_778_000_400_000;
const workoutPlanId = await ctx.db.insert("workoutPlans", {
userId,
title: "Push Day",
blocks: [{ exercises: [{ movementId: "bench", sets: 3, reps: 8 }] }],
status: "pushed",
createdAt: now,
});
const otherWorkoutPlanId = await ctx.db.insert("workoutPlans", {
userId: otherUserId,
title: "Pull Day",
blocks: [{ exercises: [{ movementId: "row", sets: 3, reps: 8 }] }],
status: "pushed",
createdAt: now,
});
await ctx.db.insert("garminWorkoutDeliveries", {
userId,
workoutPlanId,
scheduledDate: "2026-05-05",
status: "sent",
garminWorkoutId: "123",
createdAt: now,
updatedAt: now,
sentAt: now,
});
await ctx.db.insert("garminWorkoutDeliveries", {
userId: otherUserId,
workoutPlanId: otherWorkoutPlanId,
scheduledDate: "2026-05-05",
status: "sent",
garminWorkoutId: "456",
createdAt: now,
updatedAt: now,
sentAt: now,
});
});

await drainUserTableBatch(t, userId, "garminWorkoutDeliveries");

const remaining = await t.run(async (ctx) => {
const rows = await ctx.db.query("garminWorkoutDeliveries").collect();
return {
targetRows: rows.filter((row) => row.userId === userId),
otherRows: rows.filter((row) => row.userId === otherUserId),
};
});
expect(remaining.targetRows).toHaveLength(0);
expect(remaining.otherRows).toHaveLength(1);
});
});

describe("deletionInProgress", () => {
Expand Down
1 change: 1 addition & 0 deletions convex/accountDeletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ async function takeBatchForDeletion(
case "userProfileActivity":
case "garminConnections":
case "garminOauthStates":
case "garminWorkoutDeliveries":
case "garminWellnessDaily":
return (
await ctx.db
Expand Down
23 changes: 22 additions & 1 deletion convex/ai/contextWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { ModelMessage, UserContent } from "ai";
import { enforceToolCallAdjacency } from "./toolCallAdjacency";

// ---------------------------------------------------------------------------
// Merge consecutive same-role messages
Expand Down Expand Up @@ -109,7 +110,7 @@ export function stripOrphanedToolCalls(messages: ModelMessage[]): ModelMessage[]
}
}

return messages
const firstPass = messages
.map((msg) => {
if (msg.role === "assistant") {
if (typeof msg.content === "string" || !Array.isArray(msg.content)) return msg;
Expand Down Expand Up @@ -148,6 +149,26 @@ export function stripOrphanedToolCalls(messages: ModelMessage[]): ModelMessage[]
return msg;
})
.filter((msg): msg is ModelMessage => msg !== null);

// Belt-and-suspenders adjacency repair (PostHog issue 019d510a).
// Gemini's hard rule: a function-call turn MUST be immediately followed by a
// function-response turn. The set-based logic above proves a tool-result/
// approval-response exists *somewhere* in history, but not that it sits in
// the slot Gemini requires. This final pass walks the surviving messages and
// drops any tool-call (or stray tool message) that fails the adjacency check.
const repaired = enforceToolCallAdjacency(
firstPass,
approvalIdToToolCallId,
liveApprovalToolCallIds,
);
if (repaired.dropCount > 0) {
// Surface adjacency repairs in Convex logs so a regression in upstream
// persistence (the bug class this pass defends against) is observable.
console.warn(
`[stripOrphanedToolCalls] adjacency repair dropped ${repaired.dropCount} message(s)/part(s) (PostHog 019d510a)`,
);
}
return repaired.messages;
}

// ---------------------------------------------------------------------------
Expand Down
209 changes: 209 additions & 0 deletions convex/ai/toolCallAdjacency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import type { ModelMessage } from "ai";
import { stripOrphanedToolCalls } from "./contextWindow";

// Adjacency-repair pass (PostHog issue 019d510a).
//
// Gemini rejects history where an assistant function-call turn isn't
// immediately followed by a function-response turn. The set-based logic in
// stripOrphanedToolCalls proves a tool-result/approval-response exists
// *somewhere*; the adjacency pass enforces that it sits in the slot Gemini
// requires. These tests exercise the second pass specifically.
describe("stripOrphanedToolCalls — adjacency repair", () => {
it("drops a tool-call when the next message is a text-only assistant", () => {
// The set-based pass would keep tc1 (its result is in history far away),
// but the adjacency pass drops it because the immediate next turn is not
// a tool message. We keep the assistant text-only message that follows.
const msgs: ModelMessage[] = [
{ role: "user", content: "check scores" },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }],
},
{ role: "assistant", content: [{ type: "text", text: "Stale assistant turn" }] },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc1",
toolName: "get_scores",
output: { type: "text", value: "ok" },
},
],
},
{ role: "user", content: "follow up" },
];

const result = stripOrphanedToolCalls(msgs);

expect(result.find((m) => m.role === "tool")).toBeUndefined();
const assistantMsgs = result.filter((m) => m.role === "assistant");
expect(assistantMsgs).toHaveLength(1);
const surviving = assistantMsgs[0].content as Array<{ type: string }>;
expect(surviving).toEqual([{ type: "text", text: "Stale assistant turn" }]);
});

it("drops a tool-call but keeps text when next message is a fresh user", () => {
const msgs: ModelMessage[] = [
{ role: "user", content: "hi" },
{
role: "assistant",
content: [
{ type: "text", text: "Let me search." },
{ type: "tool-call", toolCallId: "tc1", toolName: "search", input: {} },
],
},
{ role: "user", content: "actually nevermind" },
];

const result = stripOrphanedToolCalls(msgs);

expect(result).toHaveLength(3);
const assistantContent = result[1].content as Array<{ type: string; text?: string }>;
expect(assistantContent).toHaveLength(1);
expect(assistantContent[0]).toEqual({ type: "text", text: "Let me search." });
});

it("preserves an assistant tool-call when next message has matching tool-result", () => {
const msgs: ModelMessage[] = [
{ role: "user", content: "scores" },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc1",
toolName: "get_scores",
output: { type: "text", value: "done" },
},
],
},
{ role: "assistant", content: "Here are your scores." },
];

expect(stripOrphanedToolCalls(msgs)).toEqual(msgs);
});

it("drops a tool-call AND the mismatched tool message when toolCallIds don't pair", () => {
const msgs: ModelMessage[] = [
{ role: "user", content: "scores" },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "tc-X", toolName: "get_scores", input: {} }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc-Y",
toolName: "search",
output: { type: "text", value: "wrong" },
},
],
},
{ role: "user", content: "next" },
];

const result = stripOrphanedToolCalls(msgs);

expect(result).toEqual([
{ role: "user", content: "scores" },
{ role: "user", content: "next" },
]);
});

it("preserves a tool-call paired by tool-approval-response via approvalId", () => {
const msgs: ModelMessage[] = [
{ role: "user", content: "deploy please" },
{
role: "assistant",
content: [
{ type: "text", text: "Approve?" },
{ type: "tool-call", toolCallId: "tc1", toolName: "approve_week_plan", input: {} },
{ type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
],
},
{
role: "tool",
content: [{ type: "tool-approval-response", approvalId: "ap1", approved: true }],
},
{ role: "user", content: "ok" },
];

expect(stripOrphanedToolCalls(msgs)).toEqual(msgs);
});

it("drops a tool message at the start with no preceding assistant", () => {
const msgs: ModelMessage[] = [
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc-orphan",
toolName: "search",
output: { type: "text", value: "leftover" },
},
],
},
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hello" },
];

const result = stripOrphanedToolCalls(msgs);

expect(result.find((m) => m.role === "tool")).toBeUndefined();
expect(result).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "Hello" },
]);
});

it("drops a tool message when the previous message is also a tool message", () => {
// Two consecutive tool messages cannot both pair with the same preceding
// assistant turn; the second has no assistant tool-call directly before it.
const msgs: ModelMessage[] = [
{ role: "user", content: "scores" },
{
role: "assistant",
content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc1",
toolName: "get_scores",
output: { type: "text", value: "ok" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc1",
toolName: "get_scores",
output: { type: "text", value: "duplicate" },
},
],
},
{ role: "assistant", content: "Done" },
];

const result = stripOrphanedToolCalls(msgs);

const toolMsgs = result.filter((m) => m.role === "tool");
expect(toolMsgs).toHaveLength(1);
const parts = toolMsgs[0].content as Array<{ type: string; output: { value: string } }>;
expect(parts[0].output.value).toBe("ok");
});
});
Loading
Loading