From d9512d19825a9dfed9c36214c6ceb9bfe3b3ab00 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:11:56 -0700 Subject: [PATCH 1/9] Define shared generation input limits --- frontend/lib/package/generationLimits.mjs | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 frontend/lib/package/generationLimits.mjs diff --git a/frontend/lib/package/generationLimits.mjs b/frontend/lib/package/generationLimits.mjs new file mode 100644 index 00000000..45c40f8b --- /dev/null +++ b/frontend/lib/package/generationLimits.mjs @@ -0,0 +1,25 @@ +export const GENERATION_LIMITS = Object.freeze({ + requestBytes: 512 * 1024, + projectNameChars: 240, + notesChars: 40_000, + audienceChars: 4_000, + linksChars: 16_000, + linksCount: 8, + documentItems: 12, + documentChars: 120_000, + totalTextContextChars: 180_000, + channels: 12, + outputTypes: 8, + sourceRecordsPerKind: 24, + mediaItems: 24, +}); + +export function generationLimitIssue({ code, field, message, actual, max }) { + return Object.freeze({ + code: String(code), + field: String(field), + message: String(message), + actual: Number(actual), + max: Number(max), + }); +} From 98fabd364433ffc82bb29a1e43f6a6c4dc75173b Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:11:59 -0700 Subject: [PATCH 2/9] Bound generation request body before JSON parsing --- frontend/lib/server/generationRequestBody.mjs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 frontend/lib/server/generationRequestBody.mjs diff --git a/frontend/lib/server/generationRequestBody.mjs b/frontend/lib/server/generationRequestBody.mjs new file mode 100644 index 00000000..d4e424f9 --- /dev/null +++ b/frontend/lib/server/generationRequestBody.mjs @@ -0,0 +1,60 @@ +import { + GENERATION_LIMITS, + generationLimitIssue, +} from "../package/generationLimits.mjs"; + +function byteLength(value) { + return new TextEncoder().encode(String(value || "")).byteLength; +} + +function oversizedIssue(actual) { + return generationLimitIssue({ + code: "generation_limit.request_bytes", + field: "request", + actual, + max: GENERATION_LIMITS.requestBytes, + message: `Generation request is too large (${actual} bytes). Keep the request at or below ${GENERATION_LIMITS.requestBytes} bytes.`, + }); +} + +export async function readGenerationRequestBody(request) { + const declared = Number(request?.headers?.get?.("content-length")); + if (Number.isFinite(declared) && declared > GENERATION_LIMITS.requestBytes) { + return Object.freeze({ + ok: false, + status: 413, + code: "generation_limit_exceeded", + error: "Generation request exceeds the server input budget.", + issues: [oversizedIssue(declared)], + }); + } + + const raw = await request.text(); + const actualBytes = byteLength(raw); + if (actualBytes > GENERATION_LIMITS.requestBytes) { + return Object.freeze({ + ok: false, + status: 413, + code: "generation_limit_exceeded", + error: "Generation request exceeds the server input budget.", + issues: [oversizedIssue(actualBytes)], + }); + } + + try { + return Object.freeze({ + ok: true, + status: 200, + body: raw ? JSON.parse(raw) : {}, + actualBytes, + }); + } catch { + return Object.freeze({ + ok: false, + status: 400, + code: "invalid_json", + error: "Generation request body must be valid JSON.", + issues: [], + }); + } +} From 5505eec25e3168b062de6d0f6b9bc01261ff4697 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:12:02 -0700 Subject: [PATCH 3/9] Enforce shared generation input limits --- frontend/lib/package/validatePackage.js | 82 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/frontend/lib/package/validatePackage.js b/frontend/lib/package/validatePackage.js index 514ec598..d01c2f8d 100644 --- a/frontend/lib/package/validatePackage.js +++ b/frontend/lib/package/validatePackage.js @@ -3,6 +3,10 @@ import { normalizeDocumentText, normalizeTextInput, } from "./inputNormalization.mjs"; +import { + GENERATION_LIMITS, + generationLimitIssue, +} from "./generationLimits.mjs"; /** * Validates the generation inputs from the client. @@ -10,10 +14,83 @@ import { */ export function validateGenerationInputs(body = {}) { const errors = []; + const limitIssues = []; + + const addLimit = (issue) => { + limitIssues.push(issue); + errors.push(issue.message); + }; + const projectName = normalizeTextInput(body?.project_name ?? body?.projectName); const notes = normalizeTextInput(body?.notes); + const audience = normalizeTextInput(body?.audience); const repo = normalizeTextInput(body?.repo); - const documentText = normalizeDocumentText(body?.document_text).join("\n\n"); + const documentItems = normalizeDocumentText(body?.document_text); + const documentText = documentItems.join("\n\n"); + const researchUrl = normalizeTextInput(body?.research_url ?? body?.docs_url); + const urls = researchUrl ? researchUrl.split(/\s+/).filter(Boolean) : []; + const channels = Array.isArray(body?.channels) ? body.channels.filter(Boolean) : []; + const outputTypes = Array.isArray(body?.output_types) ? body.output_types.filter(Boolean) : []; + const assets = Array.isArray(body?.assets) ? body.assets : []; + const sourceArtifacts = Array.isArray(body?.source_artifacts ?? body?.sourceArtifacts) + ? (body.source_artifacts ?? body.sourceArtifacts) + : []; + const processingRecords = Array.isArray(body?.processing_records ?? body?.processingRecords) + ? (body.processing_records ?? body.processingRecords) + : []; + const mediaItems = Array.isArray(body?.media_items) ? body.media_items : []; + + const textChecks = [ + ["generation_limit.project_name_chars", "project_name", projectName.length, GENERATION_LIMITS.projectNameChars, "Project name"], + ["generation_limit.notes_chars", "notes", notes.length, GENERATION_LIMITS.notesChars, "Notes"], + ["generation_limit.audience_chars", "audience", audience.length, GENERATION_LIMITS.audienceChars, "Audience"], + ["generation_limit.links_chars", "docs_url", researchUrl.length, GENERATION_LIMITS.linksChars, "Documentation links"], + ["generation_limit.document_chars", "document_text", documentText.length, GENERATION_LIMITS.documentChars, "Document text"], + ]; + for (const [code, field, actual, max, label] of textChecks) { + if (actual > max) { + addLimit(generationLimitIssue({ + code, + field, + actual, + max, + message: `${label} exceed the generation limit (${actual.toLocaleString()} / ${max.toLocaleString()} characters). Reduce this input before generating.`, + })); + } + } + + const totalTextContextChars = notes.length + audience.length + researchUrl.length + documentText.length; + if (totalTextContextChars > GENERATION_LIMITS.totalTextContextChars) { + addLimit(generationLimitIssue({ + code: "generation_limit.total_text_context_chars", + field: "context", + actual: totalTextContextChars, + max: GENERATION_LIMITS.totalTextContextChars, + message: `Combined text context exceeds the generation limit (${totalTextContextChars.toLocaleString()} / ${GENERATION_LIMITS.totalTextContextChars.toLocaleString()} characters). Shorten the brief, links, or document text.`, + })); + } + + const countChecks = [ + ["generation_limit.links_count", "docs_url", urls.length, GENERATION_LIMITS.linksCount, "documentation links"], + ["generation_limit.document_items", "document_text", documentItems.length, GENERATION_LIMITS.documentItems, "document items"], + ["generation_limit.channels", "channels", channels.length, GENERATION_LIMITS.channels, "destination channels"], + ["generation_limit.output_types", "output_types", outputTypes.length, GENERATION_LIMITS.outputTypes, "output types"], + ["generation_limit.assets", "assets", assets.length, GENERATION_LIMITS.sourceRecordsPerKind, "assets"], + ["generation_limit.source_artifacts", "source_artifacts", sourceArtifacts.length, GENERATION_LIMITS.sourceRecordsPerKind, "source artifacts"], + ["generation_limit.processing_records", "processing_records", processingRecords.length, GENERATION_LIMITS.sourceRecordsPerKind, "processing records"], + ["generation_limit.media_items", "media_items", mediaItems.length, GENERATION_LIMITS.mediaItems, "media items"], + ]; + for (const [code, field, actual, max, label] of countChecks) { + if (actual > max) { + addLimit(generationLimitIssue({ + code, + field, + actual, + max, + message: `Use at most ${max} ${label} in one generation request; received ${actual}.`, + })); + } + } if (!notes && !repo && !documentText) { errors.push("You must provide at least one input context: a Description notes brief, a GitHub repo URL, or pasted document text."); @@ -23,9 +100,7 @@ export function validateGenerationInputs(body = {}) { errors.push("GitHub Repo must identify a public repository such as https://github.com/owner/repo."); } - const researchUrl = normalizeTextInput(body?.research_url ?? body?.docs_url); if (researchUrl) { - const urls = researchUrl.split(/\s+/).filter(Boolean); urls.forEach((entry) => { const candidate = /^https?:\/\//i.test(entry) ? entry : `https://${entry}`; try { @@ -42,5 +117,6 @@ export function validateGenerationInputs(body = {}) { return { valid: errors.length === 0, errors, + limitIssues, }; } From 4f4c9b0fa0160f580f9faa8db4a77278ae1ff740 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:12:06 -0700 Subject: [PATCH 4/9] Reject oversized generation requests before spend --- frontend/app/api/launch_kit/route.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/app/api/launch_kit/route.js b/frontend/app/api/launch_kit/route.js index eb23ce97..a6316604 100644 --- a/frontend/app/api/launch_kit/route.js +++ b/frontend/app/api/launch_kit/route.js @@ -14,6 +14,7 @@ import { fetchUrlContent } from "../../../lib/context/linkFetcher"; import { generateStudioPackage } from "../../../lib/ai/generateStudioPackage"; import { assertModelGenerationProvider } from "../../../lib/ai/generationPolicy.mjs"; import { ProviderError, providerErrorPayload } from "../../../lib/ai/providerErrors.mjs"; +import { readGenerationRequestBody } from "../../../lib/server/generationRequestBody.mjs"; const OWNER_ONLY_ENDPOINT_PROVIDERS = new Set(["custom", "ollama", "lmstudio"]); @@ -24,7 +25,19 @@ export async function POST(request) { const isOwner = accessError === null; try { - const parsedBody = await request.json(); + const parsedRequest = await readGenerationRequestBody(request); + if (!parsedRequest.ok) { + return new Response(JSON.stringify({ + ok: false, + code: parsedRequest.code, + error: parsedRequest.error, + limitIssues: parsedRequest.issues, + }), { + status: parsedRequest.status, + headers: { "Content-Type": "application/json" }, + }); + } + const parsedBody = parsedRequest.body; const body = parsedBody && typeof parsedBody === "object" && !Array.isArray(parsedBody) ? parsedBody : {}; @@ -66,8 +79,10 @@ export async function POST(request) { if (!validation.valid) { return new Response(JSON.stringify({ ok: false, - error: "Validation failed", + code: validation.limitIssues.length ? "generation_limit_exceeded" : "validation_failed", + error: validation.limitIssues[0]?.message || "Validation failed", warnings: validation.errors, + limitIssues: validation.limitIssues, }), { status: 400, headers: { "Content-Type": "application/json" }, From 4b5491c6dacc5415056995da663e15defd2df630 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:12:10 -0700 Subject: [PATCH 5/9] Surface generation limit recovery guidance --- frontend/app/page.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/page.js b/frontend/app/page.js index 846d7c6e..4d20e8cb 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -934,7 +934,7 @@ ${extractedText}`); return { strategyBlocked: true, data }; } if (!response.ok || data.ok === false) { - const generationError = new Error(data.providerError?.message || data.error || "SignalFlow could not generate this campaign."); + const generationError = new Error(data.limitIssues?.[0]?.message || data.providerError?.message || data.error || "SignalFlow could not generate this campaign."); generationError.providerError = data.providerError || null; throw generationError; } From 879e1b0a50b8110a90126d6223982cf3d85a2665 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:13:36 -0700 Subject: [PATCH 6/9] Mirror generation limits in MCP tool schemas --- mcp/lib/tools.mjs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/mcp/lib/tools.mjs b/mcp/lib/tools.mjs index 070e136f..635aab17 100644 --- a/mcp/lib/tools.mjs +++ b/mcp/lib/tools.mjs @@ -5,6 +5,7 @@ import { } from "../../frontend/lib/domain/sourceArtifacts.mjs"; import { signalFlowRequest } from "./httpClient.mjs"; import { campaignExecutionRegistry } from "./executionRegistry.mjs"; +import { GENERATION_LIMITS } from "../../frontend/lib/package/generationLimits.mjs"; const CHANNELS = [ "linkedin", @@ -63,10 +64,10 @@ export const TOOL_DEFINITIONS = [ type: "object", required: ["projectName", "notes", "provider", "channels"], properties: { - projectName: { type: "string", minLength: 1 }, - notes: { type: "string", minLength: 1 }, - audience: { type: "string" }, - links: { type: "string" }, + projectName: { type: "string", minLength: 1, maxLength: GENERATION_LIMITS.projectNameChars }, + notes: { type: "string", minLength: 1, maxLength: GENERATION_LIMITS.notesChars }, + audience: { type: "string", maxLength: GENERATION_LIMITS.audienceChars }, + links: { type: "string", maxLength: GENERATION_LIMITS.linksChars }, repository: { type: "string" }, provider: { type: "string", enum: PROVIDERS }, modelName: { type: "string" }, @@ -74,10 +75,15 @@ export const TOOL_DEFINITIONS = [ channels: { type: "array", minItems: 1, + maxItems: GENERATION_LIMITS.channels, uniqueItems: true, items: { type: "string", enum: CHANNELS }, }, - documentText: { type: "array", items: { type: "string" } }, + documentText: { + type: "array", + maxItems: GENERATION_LIMITS.documentItems, + items: { type: "string", maxLength: GENERATION_LIMITS.documentChars }, + }, assets: { type: "array", items: { type: "object", additionalProperties: true } }, sourceArtifacts: { type: "array", items: { type: "object", additionalProperties: true } }, processingRecords: { type: "array", items: { type: "object", additionalProperties: true } }, @@ -112,12 +118,13 @@ export const TOOL_DEFINITIONS = [ type: "object", required: ["projectName", "notes", "provider", "channels"], properties: { - projectName: { type: "string", minLength: 1 }, - notes: { type: "string", minLength: 1 }, - audience: { type: "string" }, + projectName: { type: "string", minLength: 1, maxLength: GENERATION_LIMITS.projectNameChars }, + notes: { type: "string", minLength: 1, maxLength: GENERATION_LIMITS.notesChars }, + audience: { type: "string", maxLength: GENERATION_LIMITS.audienceChars }, links: { description: "Public documentation, landing pages, or research URLs separated by spaces or new lines.", type: "string", + maxLength: GENERATION_LIMITS.linksChars, }, repository: { type: "string" }, provider: { type: "string", enum: PROVIDERS }, @@ -126,26 +133,31 @@ export const TOOL_DEFINITIONS = [ channels: { type: "array", minItems: 1, + maxItems: GENERATION_LIMITS.channels, uniqueItems: true, items: { type: "string", enum: CHANNELS }, }, documentText: { type: "array", - items: { type: "string" }, + maxItems: GENERATION_LIMITS.documentItems, + items: { type: "string", maxLength: GENERATION_LIMITS.documentChars }, }, assets: { description: "Canonical SignalFlow Asset records. Runtime file objects, credentials, temporary URLs, and local paths are rejected or excluded by the shared contract.", type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, items: { type: "object", additionalProperties: true }, }, sourceArtifacts: { description: "Canonical SignalFlow SourceArtifact records linked to the supplied assets.", type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, items: { type: "object", additionalProperties: true }, }, processingRecords: { description: "Canonical AssetProcessing records for derived outputs and extraction/transformation lineage.", type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, items: { type: "object", additionalProperties: true }, }, }, From 1524adc40ee8e1714a3e9e4ede3cff25ef3a7f4e Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:14:12 -0700 Subject: [PATCH 7/9] Tighten combined generation context budget --- frontend/lib/package/generationLimits.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/lib/package/generationLimits.mjs b/frontend/lib/package/generationLimits.mjs index 45c40f8b..fdee62b3 100644 --- a/frontend/lib/package/generationLimits.mjs +++ b/frontend/lib/package/generationLimits.mjs @@ -7,7 +7,7 @@ export const GENERATION_LIMITS = Object.freeze({ linksCount: 8, documentItems: 12, documentChars: 120_000, - totalTextContextChars: 180_000, + totalTextContextChars: 160_000, channels: 12, outputTypes: 8, sourceRecordsPerKind: 24, From 7fe1342141db75a1f0fd7f911b3253c6c4851804 Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:14:15 -0700 Subject: [PATCH 8/9] Complete MCP source-record generation limits --- mcp/lib/tools.mjs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/mcp/lib/tools.mjs b/mcp/lib/tools.mjs index 635aab17..2c4dc0e2 100644 --- a/mcp/lib/tools.mjs +++ b/mcp/lib/tools.mjs @@ -84,9 +84,21 @@ export const TOOL_DEFINITIONS = [ maxItems: GENERATION_LIMITS.documentItems, items: { type: "string", maxLength: GENERATION_LIMITS.documentChars }, }, - assets: { type: "array", items: { type: "object", additionalProperties: true } }, - sourceArtifacts: { type: "array", items: { type: "object", additionalProperties: true } }, - processingRecords: { type: "array", items: { type: "object", additionalProperties: true } }, + assets: { + type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, + items: { type: "object", additionalProperties: true }, + }, + sourceArtifacts: { + type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, + items: { type: "object", additionalProperties: true }, + }, + processingRecords: { + type: "array", + maxItems: GENERATION_LIMITS.sourceRecordsPerKind, + items: { type: "object", additionalProperties: true }, + }, }, additionalProperties: false, }, From 79d2f6de9a76e1ec4c64104a2397f8581d8f2c7f Mon Sep 17 00:00:00 2001 From: Ankit Bhardwaj <97785108+Ankit6149@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:14:38 -0700 Subject: [PATCH 9/9] Test server-enforced generation budgets --- frontend/tests/generationLimits.test.mjs | 141 +++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 frontend/tests/generationLimits.test.mjs diff --git a/frontend/tests/generationLimits.test.mjs b/frontend/tests/generationLimits.test.mjs new file mode 100644 index 00000000..4030294d --- /dev/null +++ b/frontend/tests/generationLimits.test.mjs @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { GENERATION_LIMITS } from "../lib/package/generationLimits.mjs"; +import { validateGenerationInputs } from "../lib/package/validatePackage.js"; +import { readGenerationRequestBody } from "../lib/server/generationRequestBody.mjs"; + +function issueCodes(result) { + return new Set((result.limitIssues || []).map((issue) => issue.code)); +} + +test("generation body reader rejects declared oversized payload before JSON parsing", async () => { + const request = new Request("https://signalflow.test/api/launch_kit", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": String(GENERATION_LIMITS.requestBytes + 1), + }, + body: "{}", + }); + const result = await readGenerationRequestBody(request); + assert.equal(result.ok, false); + assert.equal(result.status, 413); + assert.equal(result.code, "generation_limit_exceeded"); + assert.equal(result.issues[0].code, "generation_limit.request_bytes"); + assert.equal(result.issues[0].actual, GENERATION_LIMITS.requestBytes + 1); +}); + +test("generation body reader rejects actual oversized payload and malformed JSON", async () => { + const oversized = new Request("https://signalflow.test/api/launch_kit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ notes: "x".repeat(GENERATION_LIMITS.requestBytes + 1) }), + }); + const oversizedResult = await readGenerationRequestBody(oversized); + assert.equal(oversizedResult.status, 413); + assert.equal(oversizedResult.issues[0].code, "generation_limit.request_bytes"); + + const malformed = new Request("https://signalflow.test/api/launch_kit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{not-json", + }); + const malformedResult = await readGenerationRequestBody(malformed); + assert.equal(malformedResult.ok, false); + assert.equal(malformedResult.status, 400); + assert.equal(malformedResult.code, "invalid_json"); +}); + +test("generation validation returns stable field-specific limit codes", () => { + const cases = [ + [ + { notes: "x".repeat(GENERATION_LIMITS.notesChars + 1) }, + "generation_limit.notes_chars", + ], + [ + { notes: "ok", docs_url: Array.from({ length: GENERATION_LIMITS.linksCount + 1 }, (_, index) => `https://example.com/${index}`).join(" ") }, + "generation_limit.links_count", + ], + [ + { notes: "ok", document_text: Array.from({ length: GENERATION_LIMITS.documentItems + 1 }, () => "doc") }, + "generation_limit.document_items", + ], + [ + { notes: "ok", document_text: ["x".repeat(GENERATION_LIMITS.documentChars + 1)] }, + "generation_limit.document_chars", + ], + [ + { notes: "ok", channels: Array.from({ length: GENERATION_LIMITS.channels + 1 }, (_, index) => `channel-${index}`) }, + "generation_limit.channels", + ], + [ + { notes: "ok", assets: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) }, + "generation_limit.assets", + ], + [ + { notes: "ok", source_artifacts: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) }, + "generation_limit.source_artifacts", + ], + [ + { notes: "ok", processing_records: Array.from({ length: GENERATION_LIMITS.sourceRecordsPerKind + 1 }, () => ({})) }, + "generation_limit.processing_records", + ], + [ + { notes: "ok", media_items: Array.from({ length: GENERATION_LIMITS.mediaItems + 1 }, () => ({})) }, + "generation_limit.media_items", + ], + ]; + + for (const [body, expectedCode] of cases) { + const result = validateGenerationInputs(body); + assert.equal(result.valid, false, expectedCode); + assert.ok(issueCodes(result).has(expectedCode), `missing ${expectedCode}`); + } +}); + +test("combined text context has its own budget", () => { + const body = { + notes: "n".repeat(35_000), + audience: "a".repeat(3_000), + docs_url: "https://example.com/" + "l".repeat(4_000), + document_text: ["d".repeat(119_000)], + }; + const result = validateGenerationInputs(body); + assert.ok(issueCodes(result).has("generation_limit.total_text_context_chars")); + assert.equal(issueCodes(result).has("generation_limit.notes_chars"), false); + assert.equal(issueCodes(result).has("generation_limit.document_chars"), false); +}); + +test("exact individual generation limits remain accepted", () => { + const result = validateGenerationInputs({ + notes: "n".repeat(GENERATION_LIMITS.notesChars), + channels: Array.from({ length: GENERATION_LIMITS.channels }, (_, index) => `channel-${index}`), + document_text: Array.from({ length: GENERATION_LIMITS.documentItems }, () => "doc"), + }); + assert.equal(result.limitIssues.length, 0); +}); + +test("launch kit applies body and field limits before provider generation", async () => { + const route = await readFile(new URL("../app/api/launch_kit/route.js", import.meta.url), "utf8"); + const readIndex = route.indexOf("readGenerationRequestBody(request)"); + const validateIndex = route.indexOf("validateGenerationInputs(body)"); + const generateIndex = route.indexOf("generateStudioPackage({"); + assert.ok(readIndex >= 0); + assert.ok(validateIndex > readIndex); + assert.ok(generateIndex > validateIndex); + assert.match(route, /status: parsedRequest\.status/); + assert.match(route, /limitIssues: validation\.limitIssues/); +}); + +test("MCP generation schemas advertise the shared server ceilings", async () => { + const tools = await readFile(new URL("../../mcp/lib/tools.mjs", import.meta.url), "utf8"); + assert.match(tools, /maxLength: GENERATION_LIMITS\.projectNameChars/); + assert.match(tools, /maxLength: GENERATION_LIMITS\.notesChars/); + assert.match(tools, /maxLength: GENERATION_LIMITS\.audienceChars/); + assert.match(tools, /maxLength: GENERATION_LIMITS\.linksChars/); + assert.match(tools, /maxItems: GENERATION_LIMITS\.channels/); + assert.match(tools, /maxItems: GENERATION_LIMITS\.documentItems/); + assert.match(tools, /maxItems: GENERATION_LIMITS\.sourceRecordsPerKind/); +});