From 9cec64b90544e05ee6f4cf255bf08407ea4ef133 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 14 Jul 2026 09:37:34 -0700 Subject: [PATCH 1/3] fix(agents): preserve queued prompt across bounded context --- AGENT_COORDINATION.md | 12 +- .../agentRuntimeTierFallback.test.ts | 116 ++++++++++++++++++ convex/domains/agents/agentRunPresentation.ts | 9 +- .../domains/agents/fastAgentPanelStreaming.ts | 29 ++--- convex/domains/agents/runtimeTierFallback.ts | 63 ++++++++++ 5 files changed, 206 insertions(+), 23 deletions(-) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index f0d68e28..115c9bf2 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -58,15 +58,11 @@ Server Error) for an unknown slug. ## Active claims (who is editing what RIGHT NOW) - **2026-07-14 · Codex release captain →** - `convex/domains/agents/fastAgentPanelStreaming.ts#runtime-tier-fallback`, - `convex/domains/agents/orchestrator/queueProtocol.ts#run-terminal-summary`, - `convex/domains/integrations/billing/rateLimiting.ts#internal-usage-accounting`, - `convex/tools/media/linkupSearch.ts#abort-signal`, - `convex/schema.ts#llm-usage-reservation-fields`, + `convex/domains/agents/fastAgentPanelStreaming.ts#provider-message-adapter`, `src/features/agents/components/FastAgentPanel/FastAgentPanel.tsx#terminal-run-error`, - `.github/workflows/ci.yml#runtime-smoke`, and focused tests · route automatic agent turns onto a tier-eligible fallback and - surface terminal worker errors instead of rendering silence · branch - `fix/agent-tier-fallback-errors`. + and focused tests · production dogfood after #529 selected the correct + tier-eligible model but exposed an empty provider-message list plus an internal + error type in the terminal alert · branch `codex/fast-agent-empty-messages`. - **2026-06-03 · Claude →** `convex/*#handoff-token`, `src/.../ScratchnodePrivateBridge`, `src/App.tsx#events-private-route`, `public/proto/home-v5.html#private-handoff` · diff --git a/convex/__tests__/agentRuntimeTierFallback.test.ts b/convex/__tests__/agentRuntimeTierFallback.test.ts index 384976fd..514c1f54 100644 --- a/convex/__tests__/agentRuntimeTierFallback.test.ts +++ b/convex/__tests__/agentRuntimeTierFallback.test.ts @@ -7,6 +7,7 @@ import { getTierSelectionFailureReason, getRuntimeAccessTokenEstimate, planMeteredProviderStep, + requireRuntimePromptText, resolveRuntimeBillingContext, selectTierEligibleRuntimeModel, type TierModelCheck, @@ -301,7 +302,98 @@ describe("cumulative metered provider token planning", () => { }); }); +describe("queued runtime prompt validation", () => { + const promptMessage = { + _id: "prompt-production-shape", + threadId: "thread-production-shape", + status: "success", + message: { + role: "user", + content: "Reply with exactly TIER_OK and nothing else.", + }, + }; + + it("carries the exact production-shaped user prompt across the bounded history boundary", () => { + expect( + requireRuntimePromptText({ + messages: [promptMessage], + promptMessageId: promptMessage._id, + threadId: promptMessage.threadId, + }), + ).toBe("Reply with exactly TIER_OK and nothing else."); + }); + + it.each([ + { label: "missing", messages: [] }, + { + label: "blank", + messages: [{ ...promptMessage, message: { role: "user", content: " " } }], + }, + { + label: "wrong thread", + messages: [{ ...promptMessage, threadId: "thread-other" }], + }, + { + label: "non-user", + messages: [ + { + ...promptMessage, + message: { ...promptMessage.message, role: "assistant" }, + }, + ], + }, + { + label: "failed", + messages: [{ ...promptMessage, status: "failed" }], + }, + { + label: "missing status", + messages: [{ ...promptMessage, status: undefined }], + }, + ])("fails closed for a $label prompt row", ({ messages }) => { + expect(() => + requireRuntimePromptText({ + messages, + promptMessageId: promptMessage._id, + threadId: promptMessage.threadId, + }), + ).toThrow("Runtime prompt message is missing or empty"); + }); +}); + describe("streamAsync tier-gate wiring", () => { + it("validates an exact prompt before routing and carries it explicitly with zero history", () => { + const actionStart = streamingSource.indexOf("export const streamAsync"); + const actionEnd = streamingSource.indexOf( + "export const generateDocumentContent", + actionStart, + ); + const actionSource = streamingSource.slice(actionStart, actionEnd); + const promptGuard = actionSource.indexOf( + "const promptText = requireRuntimePromptText({", + ); + const tierGate = actionSource.indexOf( + "const tierSelection = await selectTierEligibleRuntimeModel", + ); + const reservation = actionSource.indexOf( + "await reserveRuntimeModelAccess(model, attemptKey)", + ); + const providerCall = actionSource.indexOf("result = await agent.streamText("); + const providerCallEnd = actionSource.indexOf( + "if (attemptTimedOut)", + providerCall, + ); + const providerCallSource = actionSource.slice(providerCall, providerCallEnd); + + expect(promptGuard).toBeGreaterThanOrEqual(0); + expect(tierGate).toBeGreaterThan(promptGuard); + expect(reservation).toBeGreaterThan(tierGate); + expect(providerCall).toBeGreaterThan(reservation); + expect(providerCallSource).toContain("promptMessageId: args.promptMessageId"); + expect(providerCallSource).toContain("prompt: promptText"); + expect(providerCallSource).toContain("recentMessages: 0"); + }); + it("removes the unmetered dynamic-prompt LLM path from queued streaming", () => { const actionStart = streamingSource.indexOf("export const streamAsync"); const preflight = streamingSource.indexOf( @@ -459,6 +551,30 @@ describe("agent run presentation", () => { ); }); + it("maps custom SDK wrappers and empty-message internals to a safe retry hint", () => { + expect( + formatPublicAgentRunError( + "AgentStreamFailedError: Invalid prompt: messages must not be empty\n at stream (/convex/file.ts:1:1)", + ), + ).toBe("The agent could not start this request. Please try again."); + expect( + formatPublicAgentRunError( + "Uncaught RuntimePromptError: Runtime prompt message is missing or empty (request id: qa-1)", + ), + ).toBe("The agent could not start this request. Please try again."); + }); + + it("maps bare error-class wrappers to the generic public failure", () => { + expect(formatPublicAgentRunError("Error\n at handler")).toBe( + "The agent run failed before producing a response.", + ); + expect( + formatPublicAgentRunError( + "Uncaught AgentStreamFailedError\n at stream (/convex/file.ts:1:1)", + ), + ).toBe("The agent run failed before producing a response."); + }); + it("projects terminal error metadata without exposing database-only fields", () => { const projection = projectAgentRunPresentation({ _id: "run-qa", diff --git a/convex/domains/agents/agentRunPresentation.ts b/convex/domains/agents/agentRunPresentation.ts index df51997c..2b607c03 100644 --- a/convex/domains/agents/agentRunPresentation.ts +++ b/convex/domains/agents/agentRunPresentation.ts @@ -32,11 +32,18 @@ export function formatPublicAgentRunError( while (message && message !== previous) { previous = message; message = message - .replace(/^Error:\s*/i, "") .replace(/^Uncaught\s+/i, "") + .replace(/^(?:[A-Za-z_$][\w$.-]*Error|Error)\s*(?::\s*|$)/i, "") .trim(); } + if ( + /^Invalid prompt:\s*messages must not be empty\b/i.test(message) || + /^Runtime prompt message is missing or empty\b/i.test(message) + ) { + return "The agent could not start this request. Please try again."; + } + if (!message) return "The agent run failed before producing a response."; return message.slice(0, MAX_PUBLIC_RUN_ERROR_LENGTH); } diff --git a/convex/domains/agents/fastAgentPanelStreaming.ts b/convex/domains/agents/fastAgentPanelStreaming.ts index 6551f297..c213ac05 100644 --- a/convex/domains/agents/fastAgentPanelStreaming.ts +++ b/convex/domains/agents/fastAgentPanelStreaming.ts @@ -81,6 +81,7 @@ import { getTierSelectionFailureReason, getRuntimeAccessTokenEstimate, planMeteredProviderStep, + requireRuntimePromptText, resolveRuntimeBillingContext, selectTierEligibleRuntimeModel, } from "./runtimeTierFallback"; @@ -250,18 +251,6 @@ async function listThreadMessages(ctx: any, threadId: string, numItems = 30): Pr return (result?.page ?? result ?? []) as any[]; } -function findPromptMessageText(messages: any[], promptMessageId?: string, fallbackPrompt?: string): string { - if (promptMessageId) { - const found = messages.find( - (message: any) => String(message?.messageId ?? message?.id ?? message?._id ?? "") === promptMessageId, - ); - if (typeof found?.text === "string" && found.text.trim()) { - return found.text; - } - } - return String(fallbackPrompt ?? "").trim(); -} - async function buildRuntimeDailyBriefSummary(ctx: any): Promise { try { const brief = await ctx.runQuery(api.domains.research.dailyBriefMemoryQueries.getLatestMemory, {}); @@ -3295,8 +3284,11 @@ export const streamAsync = internalAction({ summary?: string; messageId?: string; } | undefined; - const [threadMessages, persistedScratchpad] = await Promise.all([ + const [threadMessages, promptMessages, persistedScratchpad] = await Promise.all([ listThreadMessages(ctx, args.threadId, 32), + ctx.runQuery(components.agent.messages.getMessagesByIds, { + messageIds: [args.promptMessageId], + }), ctx.runQuery( internal.domains.agents.agentScratchpads.getByAgentThreadInternal, { @@ -3304,7 +3296,11 @@ export const streamAsync = internalAction({ }, ), ]); - const promptText = findPromptMessageText(threadMessages, args.promptMessageId); + const promptText = requireRuntimePromptText({ + messages: promptMessages, + promptMessageId: args.promptMessageId, + threadId: args.threadId, + }); previousCompactContext = (persistedScratchpad?.scratchpad?.compactContext as typeof previousCompactContext) ?? undefined; let workingSetContext: @@ -4051,6 +4047,11 @@ export const streamAsync = internalAction({ { threadId: args.threadId }, { promptMessageId: args.promptMessageId, + // recentMessages stays at zero so the queued runtime cannot silently + // expand provider context. Supplying the exact validated prompt keeps + // the SDK input non-empty; promptMessageId prevents the SDK from + // saving a duplicate user message. + prompt: promptText, system: attemptSystemPrompt, abortSignal: attemptController.signal, providerOptions, diff --git a/convex/domains/agents/runtimeTierFallback.ts b/convex/domains/agents/runtimeTierFallback.ts index d3493bb9..44b4e294 100644 --- a/convex/domains/agents/runtimeTierFallback.ts +++ b/convex/domains/agents/runtimeTierFallback.ts @@ -29,6 +29,69 @@ export type RuntimeBillingContext = | "anonymous_user" | "trusted_evaluation"; +type RuntimePromptMessage = { + _id?: unknown; + id?: unknown; + messageId?: unknown; + threadId?: unknown; + role?: unknown; + status?: unknown; + text?: unknown; + content?: unknown; + message?: { + role?: unknown; + content?: unknown; + }; +}; + +/** + * Resolve the exact queued user prompt before routing or reserving provider + * budget. The component query supplying these rows is already scoped to the + * requested message ID; the additional thread/role/status checks make that + * boundary fail closed if queue state is stale or malformed. + */ +export function requireRuntimePromptText(args: { + messages: readonly (RuntimePromptMessage | null | undefined)[]; + promptMessageId: string; + threadId: string; +}): string { + const expectedMessageId = args.promptMessageId.trim(); + const expectedThreadId = args.threadId.trim(); + const promptMessage = args.messages.find((message) => { + const messageId = String( + message?._id ?? message?.messageId ?? message?.id ?? "", + ); + return messageId === expectedMessageId; + }); + const role = String( + promptMessage?.role ?? promptMessage?.message?.role ?? "", + ).trim(); + const status = String(promptMessage?.status ?? "").trim(); + const threadId = String(promptMessage?.threadId ?? "").trim(); + const content = + typeof promptMessage?.text === "string" + ? promptMessage.text + : typeof promptMessage?.content === "string" + ? promptMessage.content + : typeof promptMessage?.message?.content === "string" + ? promptMessage.message.content + : ""; + + if ( + !expectedMessageId || + !expectedThreadId || + !promptMessage || + threadId !== expectedThreadId || + role !== "user" || + status !== "success" || + !content.trim() + ) { + throw new Error("Runtime prompt message is missing or empty"); + } + + return content.trim(); +} + export type MeteredProviderStep = { usage?: { totalTokens?: number; From 37f49a732e8877a947b271ef96f78c857fb01d16 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 14 Jul 2026 09:39:23 -0700 Subject: [PATCH 2/3] chore: align release claim branch --- AGENT_COORDINATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENT_COORDINATION.md b/AGENT_COORDINATION.md index 115c9bf2..a6c0762c 100644 --- a/AGENT_COORDINATION.md +++ b/AGENT_COORDINATION.md @@ -62,7 +62,7 @@ Server Error) for an unknown slug. `src/features/agents/components/FastAgentPanel/FastAgentPanel.tsx#terminal-run-error`, and focused tests · production dogfood after #529 selected the correct tier-eligible model but exposed an empty provider-message list plus an internal - error type in the terminal alert · branch `codex/fast-agent-empty-messages`. + error type in the terminal alert · branch `fix/fast-agent-empty-messages`. - **2026-06-03 · Claude →** `convex/*#handoff-token`, `src/.../ScratchnodePrivateBridge`, `src/App.tsx#events-private-route`, `public/proto/home-v5.html#private-handoff` · From 9a6de10493feffefcf5a5b4ba8d8cd98d4995175 Mon Sep 17 00:00:00 2001 From: hshum Date: Tue, 14 Jul 2026 09:43:23 -0700 Subject: [PATCH 3/3] chore(agents): document prompt status boundary --- convex/domains/agents/runtimeTierFallback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/convex/domains/agents/runtimeTierFallback.ts b/convex/domains/agents/runtimeTierFallback.ts index 44b4e294..c1dec209 100644 --- a/convex/domains/agents/runtimeTierFallback.ts +++ b/convex/domains/agents/runtimeTierFallback.ts @@ -48,7 +48,8 @@ type RuntimePromptMessage = { * Resolve the exact queued user prompt before routing or reserving provider * budget. The component query supplying these rows is already scoped to the * requested message ID; the additional thread/role/status checks make that - * boundary fail closed if queue state is stale or malformed. + * boundary fail closed if queue state is stale or malformed. Success status is + * required explicitly so a partial component row cannot enter provider input. */ export function requireRuntimePromptText(args: { messages: readonly (RuntimePromptMessage | null | undefined)[];