diff --git a/package-lock.json b/package-lock.json index 36740455..5cbe98ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/react": "^19.2.17", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.11", "bun-types": "^1.1.0", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -53,7 +53,7 @@ "playwright": "^1.47.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" @@ -2922,14 +2922,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2943,8 +2943,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2953,16 +2953,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2971,13 +2971,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2998,9 +2998,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3011,13 +3011,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3025,14 +3025,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3041,9 +3041,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3051,13 +3051,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7679,19 +7679,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7719,12 +7719,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 54953ef0..303e8c83 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/react": "^19.2.17", - "@vitest/coverage-v8": "^4.0.18", + "@vitest/coverage-v8": "^4.1.11", "bun-types": "^1.1.0", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", @@ -87,7 +87,7 @@ "playwright": "^1.47.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.1.11" }, "engines": { "node": ">=22" diff --git a/src/agents/pipelineFormat.ts b/src/agents/pipelineFormat.ts index 8b6018b6..20e5b596 100644 --- a/src/agents/pipelineFormat.ts +++ b/src/agents/pipelineFormat.ts @@ -6,6 +6,16 @@ import { EmbedBuilder } from 'discord.js'; import type { PipelineResult } from './pairPipeline.js'; import { formatCost } from '../support/costTracker.js'; +import { + boundedFieldValue, + boundedDescription, + boundedMessageContent, + PIPELINE_EMBED_FIELD_VALUE_LIMIT, + PIPELINE_FAILED_TESTS_PREVIEW, + DISCORD_EMBED_FIELDS_PER_EMBED, + DISCORD_EMBED_AGGREGATE_VALUE_LIMIT, + truncate, +} from '../support/outputBudget.js'; /** Format epoch ms to HH:MM:SS local time string */ function formatTimestamp(epochMs: number): string { @@ -46,7 +56,7 @@ export function formatPipelineResult(result: PipelineResult): string { lines.push(parts.join(' | ')); } if (ctx.taskTitle) { - lines.push(`๐Ÿ“‹ ${ctx.taskTitle}`); + lines.push(`๐Ÿ“‹ ${truncate(ctx.taskTitle, 200)}`); } lines.push(''); } @@ -70,11 +80,12 @@ export function formatPipelineResult(result: PipelineResult): string { lines.push(` ${emoji} ${stage.stage} (${duration}s) @ ${time}`); } - return lines.join('\n'); + return boundedMessageContent(lines.join('\n')); } /** - * Format pipeline result as a Discord Embed + * Format pipeline result as a Discord Embed. + * Enforces per-field and aggregate embed budgets to prevent payload rejection. */ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder { const statusConfig = { @@ -95,30 +106,43 @@ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder .setColor(statusConfig.color) .setTimestamp(); - // Task context + // Task context (bounded description) if (result.taskContext) { const ctx = result.taskContext; const displayName = ctx.projectName || (ctx.projectPath ? ctx.projectPath.split('/').pop() || '' : ''); if (displayName && ctx.issueIdentifier) { - embed.setDescription(`๐Ÿ“ **${displayName}** | ๐Ÿ”– ${ctx.issueIdentifier}\n${ctx.taskTitle || ''}`); + embed.setDescription( + boundedDescription(`๐Ÿ“ **${displayName}** | ๐Ÿ”– ${ctx.issueIdentifier}\n${ctx.taskTitle || ''}`), + ); } else if (ctx.taskTitle) { - embed.setDescription(ctx.taskTitle); + embed.setDescription(boundedDescription(ctx.taskTitle)); } } + // Track aggregate field value length to stay within embed budget + let aggregateValueLength = 0; + + const tryAddField = (name: string, value: string, inline = false): boolean => { + const bounded = boundedFieldValue(value, PIPELINE_EMBED_FIELD_VALUE_LIMIT); + const newTotal = aggregateValueLength + bounded.length; + if (newTotal > DISCORD_EMBED_AGGREGATE_VALUE_LIMIT) return false; + if (embed.data.fields && embed.data.fields.length >= DISCORD_EMBED_FIELDS_PER_EMBED) return false; + embed.addFields({ name, value: bounded, inline }); + aggregateValueLength = newTotal; + return true; + }; + // Summary stats const durationStr = (result.totalDuration / 1000).toFixed(1) + 's'; const costStr = result.totalCost ? `$${result.totalCost.costUsd.toFixed(4)} (${formatCost(result.totalCost)})` : 'N/A'; - embed.addFields( - { name: '๐Ÿ”„ Iterations', value: result.iterations.toString(), inline: true }, - { name: 'โฑ๏ธ Duration', value: durationStr, inline: true }, - { name: '๐Ÿ’ฐ Cost', value: costStr, inline: true }, - ); + tryAddField('๐Ÿ”„ Iterations', result.iterations.toString(), true); + tryAddField('โฑ๏ธ Duration', durationStr, true); + tryAddField('๐Ÿ’ฐ Cost', costStr, true); // Stages const stagesStr = result.stages @@ -130,7 +154,7 @@ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder }) .join('\n') || 'No stages'; - embed.addFields({ name: '๐Ÿ“Š Stages', value: stagesStr, inline: false }); + tryAddField('๐Ÿ“Š Stages', stagesStr, false); // Worker result if (result.workerResult) { @@ -150,7 +174,7 @@ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder } if (workerValue) { - embed.addFields({ name: '๐Ÿ”จ Worker', value: workerValue, inline: false }); + tryAddField('๐Ÿ”จ Worker', workerValue, false); } } @@ -168,7 +192,7 @@ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder reviewValue += `\n\n**Issues found:** ${review.issues.length}`; } - embed.addFields({ name: 'โœ… Reviewer', value: reviewValue, inline: false }); + tryAddField('โœ… Reviewer', reviewValue, false); } // Tester result @@ -184,19 +208,19 @@ export function formatPipelineResultEmbed(result: PipelineResult): EmbedBuilder } if (test.testsFailed > 0 && test.failedTests && test.failedTests.length > 0) { - const failedStr = test.failedTests.slice(0, 2).map(t => `โŒ ${t}`).join('\n'); + const failedStr = test.failedTests.slice(0, PIPELINE_FAILED_TESTS_PREVIEW).map(t => `โŒ ${t}`).join('\n'); testValue += `\n\n${failedStr}`; - if (test.failedTests.length > 2) { - testValue += `\n... +${test.failedTests.length - 2} more`; + if (test.failedTests.length > PIPELINE_FAILED_TESTS_PREVIEW) { + testValue += `\n... +${test.failedTests.length - PIPELINE_FAILED_TESTS_PREVIEW} more`; } } - embed.addFields({ name: '๐Ÿงช Tests', value: testValue, inline: false }); + tryAddField('๐Ÿงช Tests', testValue, false); } // PR URL if (result.prUrl) { - embed.addFields({ name: '๐Ÿ”— Pull Request', value: `[View PR](${result.prUrl})`, inline: false }); + tryAddField('๐Ÿ”— Pull Request', `[View PR](${result.prUrl})`, false); } // Footer diff --git a/src/agents/reviewer.ts b/src/agents/reviewer.ts index 58d49817..117ef7b4 100644 --- a/src/agents/reviewer.ts +++ b/src/agents/reviewer.ts @@ -15,6 +15,7 @@ import type { VerifyEvidence } from '../verify/runner.js'; import { renderVerifyEvidence } from './verificationEvidence.js'; import type { InstructionCapsule } from './instructionCapsule.js'; import { COORDINATION_GUIDANCE_PROMPT, type CoordinationToolContext } from '../coordination/coordinationTools.js'; +import { boundedMessageContent } from '../support/outputBudget.js'; // Types @@ -378,7 +379,8 @@ export async function runReviewer(options: ReviewerOptions): Promise 0) { lines.push(''); lines.push('### Failed Tests:'); - for (let i = 0; i < result.failedTests.length; i++) { - lines.push(`${i + 1}. \`${result.failedTests[i]}\``); + const shown = result.failedTests.slice(0, PROMPT_FAILED_TESTS_LIMIT); + for (let i = 0; i < shown.length; i++) { + lines.push(`${i + 1}. \`${truncate(shown[i], 200)}\``); + } + if (result.failedTests.length > PROMPT_FAILED_TESTS_LIMIT) { + lines.push(`โ€ฆ +${result.failedTests.length - PROMPT_FAILED_TESTS_LIMIT} more`); } } if (result.suggestions && result.suggestions.length > 0) { lines.push(''); lines.push('### Fix Suggestions:'); - for (let i = 0; i < result.suggestions.length; i++) { - lines.push(`${i + 1}. ${result.suggestions[i]}`); + const shown = result.suggestions.slice(0, PROMPT_SUGGESTIONS_LIMIT); + for (let i = 0; i < shown.length; i++) { + lines.push(`${i + 1}. ${truncate(shown[i], 300)}`); + } + if (result.suggestions.length > PROMPT_SUGGESTIONS_LIMIT) { + lines.push(`โ€ฆ +${result.suggestions.length - PROMPT_SUGGESTIONS_LIMIT} more`); } } lines.push(''); lines.push('Fix the above test failures.'); - return lines.join('\n'); + const full = lines.join('\n'); + return full.length > PROMPT_FEEDBACK_LIMIT + ? `${full.slice(0, PROMPT_FEEDBACK_LIMIT - 1)}โ€ฆ` + : full; } diff --git a/src/automation/workerAuditLog.ts b/src/automation/workerAuditLog.ts index 8096e014..162a90e1 100644 --- a/src/automation/workerAuditLog.ts +++ b/src/automation/workerAuditLog.ts @@ -9,21 +9,29 @@ import type { WorkerResult } from '../agents/agentPair.js'; import { formatAutomationComment, type CommentSection } from '../linear/format.js'; +import { + AUDIT_FILES_MAX, + AUDIT_COMMANDS_MAX, + AUDIT_SUMMARY_CAP, + AUDIT_GOAL_CAP, + AUDIT_ENTRY_CAP, + truncate, +} from '../support/outputBudget.js'; /** Caps so a chatty agent can't post a multi-MB comment. */ -const MAX_FILES = 20; -const MAX_COMMANDS = 12; -const SUMMARY_CAP = 600; -const GOAL_CAP = 400; +const MAX_FILES = AUDIT_FILES_MAX; +const MAX_COMMANDS = AUDIT_COMMANDS_MAX; +const SUMMARY_CAP = AUDIT_SUMMARY_CAP; +const GOAL_CAP = AUDIT_GOAL_CAP; function cap(s: string | undefined, n: number): string { if (!s) return ''; - const trimmed = s.trim(); - return trimmed.length > n ? `${trimmed.slice(0, n - 1)}โ€ฆ` : trimmed; + return truncate(s.trim(), n); } function inlineCode(s: string): string { - return `\`${s.replaceAll('`', '\\`')}\``; + // Cap individual entry length before wrapping so a single path/command can't blow the comment. + return `\`${truncate(s, AUDIT_ENTRY_CAP).replaceAll('`', '\\`')}\``; } /** Render a list as inline code, capped, with an "+N more" suffix when truncated. */ diff --git a/src/discord/discordHandlers.ts b/src/discord/discordHandlers.ts index fa2a8d97..eddd7627 100644 --- a/src/discord/discordHandlers.ts +++ b/src/discord/discordHandlers.ts @@ -26,13 +26,22 @@ import { formatTimeAgo, } from './discordCore.js'; import { t, getDateLocale } from '../locale/index.js'; +import { + boundedFieldValue, + boundedDescription, + genericUserError, + paginateEmbedFields, + truncate, + DISCORD_EMBED_FIELD_VALUE_LIMIT, + DISCORD_EMBED_TITLE_LIMIT, +} from '../support/outputBudget.js'; /** * Helper: Reply with Embed for consistent Discord UI */ async function replyWithEmbed(msg: Message, content: string, color: number = 0x00ff41): Promise { const embed = new EmbedBuilder() - .setDescription(content) + .setDescription(boundedDescription(content)) .setColor(color) .setTimestamp(); await msg.reply({ embeds: [embed] }); @@ -169,58 +178,53 @@ export async function handleIssues(msg: Message, sessionName?: string): Promise< 'Backlog': 0x95a5a6, }; - // Pagination (max 10 per embed) - const ITEMS_PER_PAGE = 10; - const totalPages = Math.ceil(issues.length / ITEMS_PER_PAGE); + // Build all fields, then paginate by Discord embed field + aggregate budgets + const allFields = issues.map((issue) => { + const priority = priorityEmoji[issue.priority as keyof typeof priorityEmoji] ?? 'โšช'; + const stateEmoji = { + 'Todo': '๐Ÿ“', + 'In Progress': 'โš™๏ธ', + 'In Review': '๐Ÿ‘€', + 'Done': 'โœ…', + 'Backlog': '๐Ÿ“ฆ', + }[issue.state] ?? '๐Ÿ“‹'; + + let value = `${priority} **${issue.identifier}**: ${issue.title}\n`; + value += `${stateEmoji} ${issue.state}`; + + if (issue.project) { + value += ` ยท ${issue.project.name}`; + } - const embeds: EmbedBuilder[] = []; + if (issue.labels && issue.labels.length > 0) { + value += `\n๐Ÿท๏ธ ${issue.labels.join(', ')}`; + } - for (let page = 0; page < totalPages; page++) { - const startIdx = page * ITEMS_PER_PAGE; - const endIdx = Math.min(startIdx + ITEMS_PER_PAGE, issues.length); - const pageIssues = issues.slice(startIdx, endIdx); + return { + name: `\u200b`, + value: boundedFieldValue(value, DISCORD_EMBED_FIELD_VALUE_LIMIT), + inline: false, + }; + }); + + const fieldPages = paginateEmbedFields(allFields); + const embeds: EmbedBuilder[] = []; + for (let page = 0; page < fieldPages.length; page++) { + const pageFields = fieldPages[page]; const embed = new EmbedBuilder() .setTitle(sessionName ? t('discord.issues.sessionIssues', { session: sessionName }) : t('discord.issues.myIssues') ) - .setColor(stateColor[pageIssues[0]?.state as keyof typeof stateColor] ?? 0x3498db) + .setColor(stateColor[issues[0]?.state as keyof typeof stateColor] ?? 0x3498db) .setTimestamp(); - if (totalPages > 1) { - embed.setFooter({ text: t('discord.issues.page', { current: page + 1, total: totalPages }) }); + if (fieldPages.length > 1) { + embed.setFooter({ text: t('discord.issues.page', { current: page + 1, total: fieldPages.length }) }); } - const fields = pageIssues.map((issue) => { - const priority = priorityEmoji[issue.priority as keyof typeof priorityEmoji] ?? 'โšช'; - const stateEmoji = { - 'Todo': '๐Ÿ“', - 'In Progress': 'โš™๏ธ', - 'In Review': '๐Ÿ‘€', - 'Done': 'โœ…', - 'Backlog': '๐Ÿ“ฆ', - }[issue.state] ?? '๐Ÿ“‹'; - - let value = `${priority} **${issue.identifier}**: ${issue.title}\n`; - value += `${stateEmoji} ${issue.state}`; - - if (issue.project) { - value += ` ยท ${issue.project.name}`; - } - - if (issue.labels && issue.labels.length > 0) { - value += `\n๐Ÿท๏ธ ${issue.labels.join(', ')}`; - } - - return { - name: `\u200b`, - value, - inline: false, - }; - }); - - embed.addFields(...fields); + embed.addFields(...pageFields); embeds.push(embed); } @@ -240,8 +244,7 @@ export async function handleIssues(msg: Message, sessionName?: string): Promise< } } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - await replyWithEmbed(msg, t('discord.issues.fetchError', { error: errorMsg }), 0xff0000); + await replyWithEmbed(msg, t('discord.issues.fetchError', { error: genericUserError(error) }), 0xff0000); } } @@ -281,18 +284,15 @@ export async function handleIssue(msg: Message, issueId: string): Promise }; const embed = new EmbedBuilder() - .setTitle(`${issue.identifier}: ${issue.title}`) + .setTitle(truncate(`${issue.identifier}: ${issue.title}`, DISCORD_EMBED_TITLE_LIMIT)) .setColor(stateColor[issue.state as keyof typeof stateColor] ?? 0x3498db) .setTimestamp(); // Description if (issue.description) { - const desc = issue.description.length > 1024 - ? issue.description.slice(0, 1021) + '...' - : issue.description; embed.addFields({ name: '๐Ÿ“ Description', - value: desc, + value: boundedFieldValue(issue.description), inline: false, }); } @@ -319,7 +319,7 @@ export async function handleIssue(msg: Message, issueId: string): Promise embed.addFields({ name: '๐Ÿ“Š Details', - value: infoValue, + value: boundedFieldValue(infoValue), inline: false, }); @@ -339,7 +339,7 @@ export async function handleIssue(msg: Message, issueId: string): Promise embed.addFields({ name: `๐Ÿ’ฌ ${t('discord.issues.commentsCount', { count: issue.comments.length })}`, - value: commentValue, + value: boundedFieldValue(commentValue), inline: false, }); } else { @@ -352,8 +352,7 @@ export async function handleIssue(msg: Message, issueId: string): Promise await msg.reply({ embeds: [embed] }); } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - await replyWithEmbed(msg, t('discord.issue.fetchError', { error: errorMsg }), 0xff0000); + await replyWithEmbed(msg, t('discord.issue.fetchError', { error: genericUserError(error) }), 0xff0000); } } @@ -720,7 +719,7 @@ export async function handleSchedule(msg: Message, args: string[]): Promise { await msg.reply(`โœ… ${t('discord.codex.saveSuccess', { path: summaryPath })}`); } catch (err) { - await msg.reply(`โŒ ${t('discord.codex.saveFailed', { error: err instanceof Error ? err.message : String(err) })}`); + await msg.reply(`โŒ ${t('discord.codex.saveFailed', { error: genericUserError(err) })}`); } return; } @@ -924,7 +923,7 @@ export async function handleAuto(msg: Message, args: string[]): Promise { : `โœ… ${t('discord.auto.startedSolo')}`; await msg.reply(startMsg); } catch (err) { - await msg.reply(`โŒ ${t('discord.errors.startFailed', { error: err instanceof Error ? err.message : String(err) })}`); + await msg.reply(`โŒ ${t('discord.errors.startFailed', { error: genericUserError(err) })}`); } return; } diff --git a/src/runners/cliRunner.ts b/src/runners/cliRunner.ts index 0f153366..9c5bbfe3 100644 --- a/src/runners/cliRunner.ts +++ b/src/runners/cliRunner.ts @@ -16,6 +16,14 @@ import { startProgressHeartbeat, type ReviewProgress } from '../cli/reviewProgre import { status } from '../support/colors.js'; import { sanitizeTerminalText } from '../tui/sanitize.js'; import { safeConsole as console } from '../support/safeLog.js'; +import { + sanitizeException, + truncate, + flattenToSingleLine, + CLI_FEEDBACK_LINES, + CLI_STDERR_LINE_LIMIT, + PROMPT_FEEDBACK_LIMIT, +} from '../support/outputBudget.js'; // Types @@ -223,7 +231,7 @@ export async function runCli(options: CliRunOptions): Promise { result = await pipeline.run(task, projectPath); } catch (error) { stopHeartbeat(); - console.error('\n Pipeline execution failed:', error instanceof Error ? error.message : error); + console.error('\n Pipeline execution failed:', sanitizeException(error)); process.exitCode = 1; return; } @@ -269,18 +277,22 @@ function printResult(result: PipelineResult): void { console.log(' ======================================'); - // Summary + // Summary โ€” sanitize + bound untrusted pipeline content if (result.workerResult?.summary) { - console.log(` Summary: ${sanitizeTerminalText(result.workerResult.summary)}`); + const summary = truncate( + flattenToSingleLine(sanitizeTerminalText(result.workerResult.summary)), + CLI_STDERR_LINE_LIMIT, + ); + console.log(` Summary: ${summary}`); } // Files changed if (result.workerResult?.filesChanged && result.workerResult.filesChanged.length > 0) { const files = result.workerResult.filesChanged; if (files.length <= 5) { - console.log(` Files: ${files.map(sanitizeTerminalText).join(', ')}`); + console.log(` Files: ${files.map((f) => sanitizeTerminalText(f)).join(', ')}`); } else { - console.log(` Files: ${files.slice(0, 5).join(', ')} +${files.length - 5} more`); + console.log(` Files: ${files.slice(0, 5).map((f) => sanitizeTerminalText(f)).join(', ')} +${files.length - 5} more`); } } @@ -292,13 +304,14 @@ function printResult(result: PipelineResult): void { parts.push(`Duration: ${formatDuration(result.totalDuration)}`); console.log(` ${parts.join(' | ')}`); - // Reviewer feedback on failure + // Reviewer feedback on failure โ€” per-field line + aggregate budget if (!result.success && result.reviewResult?.feedback) { console.log(''); console.log(' Feedback:'); - const lines = result.reviewResult.feedback.split('\n').slice(0, 5); + const bounded = truncate(sanitizeTerminalText(result.reviewResult.feedback), PROMPT_FEEDBACK_LIMIT); + const lines = bounded.split('\n').slice(0, CLI_FEEDBACK_LINES); for (const line of lines) { - console.log(` ${line}`); + console.log(` ${truncate(flattenToSingleLine(line), CLI_STDERR_LINE_LIMIT)}`); } } diff --git a/src/support/outputBudget.test.ts b/src/support/outputBudget.test.ts new file mode 100644 index 00000000..d13338d5 --- /dev/null +++ b/src/support/outputBudget.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { + truncate, + truncateWithSuffix, + capArray, + boundedFieldValue, + boundedMessageContent, + flattenToSingleLine, + sanitizeException, + genericUserError, + paginateEmbedFields, + DISCORD_EMBED_FIELD_VALUE_LIMIT, + DISCORD_MESSAGE_CONTENT_LIMIT, + DISCORD_EMBED_AGGREGATE_VALUE_LIMIT, + LINEAR_DESCRIPTION_LIMIT, + boundedLinearText, +} from './outputBudget.js'; + +describe('outputBudget', () => { + it('truncates with ellipsis without exceeding the limit', () => { + expect(truncate('abcdef', 4)).toBe('abcโ€ฆ'); + expect(truncate('abc', 10)).toBe('abc'); + }); + + it('truncateWithSuffix keeps the final length at the limit', () => { + const out = truncateWithSuffix('x'.repeat(100), 40); + expect(out.length).toBe(40); + expect(out.endsWith('(truncated)')).toBe(true); + }); + + it('truncateWithSuffix falls back when suffix is longer than the limit', () => { + const out = truncateWithSuffix('abcdefghij', 4, 'โ€ฆ (truncated)'); + expect(out.length).toBe(4); + expect(out).toBe('abcโ€ฆ'); + }); + + it('caps arrays and reports omitted count', () => { + expect(capArray([1, 2, 3, 4], 2)).toEqual({ shown: [1, 2], omitted: 2 }); + expect(capArray([1], 5)).toEqual({ shown: [1], omitted: 0 }); + }); + + it('bounds Discord field values to the per-field limit', () => { + const value = boundedFieldValue('y'.repeat(DISCORD_EMBED_FIELD_VALUE_LIMIT + 500)); + expect(value.length).toBeLessThanOrEqual(DISCORD_EMBED_FIELD_VALUE_LIMIT); + }); + + it('bounds Discord message content', () => { + const value = boundedMessageContent('z'.repeat(DISCORD_MESSAGE_CONTENT_LIMIT + 200)); + expect(value.length).toBeLessThanOrEqual(DISCORD_MESSAGE_CONTENT_LIMIT); + }); + + it('flattens multiline text to a single line', () => { + expect(flattenToSingleLine('a\nb\r\nc')).toBe('a b c'); + }); + + it('sanitizes exceptions to a single bounded line', () => { + const err = new Error('boom\n at foo.ts:1\n at bar.ts:2'); + expect(sanitizeException(err)).toBe('boom'); + expect(sanitizeException(err).length).toBeLessThanOrEqual(200); + }); + + it('generic user errors never include raw exception text', () => { + const msg = genericUserError(new Error('secret stack /tmp/evil.key')); + expect(msg).not.toContain('secret'); + expect(msg).not.toContain('evil'); + expect(msg.length).toBeLessThan(80); + }); + + it('paginates embed fields by aggregate budget', () => { + const fields = Array.from({ length: 20 }, (_, i) => ({ + name: `\u200b`, + value: 'v'.repeat(800), + inline: false, + })); + const pages = paginateEmbedFields(fields); + expect(pages.length).toBeGreaterThan(1); + for (const page of pages) { + const aggregate = page.reduce((sum, f) => sum + f.value.length, 0); + expect(aggregate).toBeLessThanOrEqual(DISCORD_EMBED_AGGREGATE_VALUE_LIMIT); + expect(page.length).toBeLessThanOrEqual(25); + for (const f of page) { + expect(f.value.length).toBeLessThanOrEqual(DISCORD_EMBED_FIELD_VALUE_LIMIT); + } + } + }); + + it('bounds Linear description payloads', () => { + const out = boundedLinearText('L'.repeat(LINEAR_DESCRIPTION_LIMIT + 100)); + expect(out.length).toBeLessThanOrEqual(LINEAR_DESCRIPTION_LIMIT); + }); +}); diff --git a/src/support/outputBudget.ts b/src/support/outputBudget.ts new file mode 100644 index 00000000..d824bb44 --- /dev/null +++ b/src/support/outputBudget.ts @@ -0,0 +1,161 @@ +// ============================================ +// OpenSwarm โ€” Output Budget Helpers +// +// Shared destination-specific field, message, and aggregate limits. +// Every consumer enforces these before sending or rendering untrusted content. +// ============================================ + +// โ”€โ”€ Discord embed limits (discord.js EmbedBuilder enforces these at build time) โ”€โ”€ +export const DISCORD_EMBED_TITLE_LIMIT = 256; +export const DISCORD_EMBED_DESCRIPTION_LIMIT = 4096; +export const DISCORD_EMBED_FIELD_NAME_LIMIT = 256; +export const DISCORD_EMBED_FIELD_VALUE_LIMIT = 1024; +export const DISCORD_EMBED_FOOTER_LIMIT = 2048; +export const DISCORD_EMBED_FIELDS_PER_EMBED = 25; +/** Total characters across all field values in one embed (discord.js enforces ~6000). */ +export const DISCORD_EMBED_AGGREGATE_VALUE_LIMIT = 6000; +/** Safe message content limit (below Discord's 2000 hard cap, leaving room for framing). */ +export const DISCORD_MESSAGE_CONTENT_LIMIT = 1900; + +// โ”€โ”€ Linear API limits โ”€โ”€ +export const LINEAR_TITLE_LIMIT = 512; +export const LINEAR_DESCRIPTION_LIMIT = 3000; +export const LINEAR_COMMENT_LIMIT = 3000; + +// โ”€โ”€ Prompt / feedback budgets โ”€โ”€ +export const PROMPT_FEEDBACK_LIMIT = 4000; +export const PROMPT_FAILED_TESTS_LIMIT = 10; +export const PROMPT_SUGGESTIONS_LIMIT = 5; + +// โ”€โ”€ Worker audit log budgets โ”€โ”€ +export const AUDIT_FILES_MAX = 20; +export const AUDIT_COMMANDS_MAX = 12; +export const AUDIT_SUMMARY_CAP = 600; +export const AUDIT_GOAL_CAP = 400; +/** Cap length of a single file path or command entry before rendering. */ +export const AUDIT_ENTRY_CAP = 200; + +// โ”€โ”€ TUI display budgets โ”€โ”€ +export const TUI_LOG_LINE_LIMIT = 200; +export const TUI_CELL_DEFAULT_WIDTH = 28; + +// โ”€โ”€ CLI runner budgets โ”€โ”€ +export const CLI_FEEDBACK_LINES = 5; +export const CLI_STDERR_LINE_LIMIT = 100; + +// โ”€โ”€ Pipeline embed budgets โ”€โ”€ +export const PIPELINE_EMBED_FIELD_VALUE_LIMIT = 900; // below 1024 to leave room for markdown framing +export const PIPELINE_FAILED_TESTS_PREVIEW = 2; + +// โ”€โ”€ Helpers โ”€โ”€ + +/** Truncate a string to `limit` chars, appending "โ€ฆ" when clipped. */ +export function truncate(value: string, limit: number): string { + if (value.length <= limit) return value; + return `${value.slice(0, limit - 1)}โ€ฆ`; +} + +/** Truncate a string to `limit` chars, appending a suffix when clipped. */ +export function truncateWithSuffix(value: string, limit: number, suffix = '\nโ€ฆ (truncated)'): string { + if (limit <= 0) return ''; + if (value.length <= limit) return value; + if (suffix.length >= limit) return truncate(value, limit); + return `${value.slice(0, limit - suffix.length)}${suffix}`; +} + +/** Cap an array to `max` items, returning the slice and a count of omitted items. */ +export function capArray(items: T[], max: number): { shown: T[]; omitted: number } { + if (items.length <= max) return { shown: items, omitted: 0 }; + return { shown: items.slice(0, max), omitted: items.length - max }; +} + +/** Render a list as inline code, capped, with an "+N more" suffix when truncated. */ +export function codeList(items: string[] | undefined, max: number): string { + if (!items || items.length === 0) return '_(none)_'; + const { shown, omitted } = capArray(items, max); + const rendered = shown.map((s) => `\`${s.replaceAll('`', '\\`')}\``).join(', '); + return omitted > 0 ? `${rendered} _+${omitted} more_` : rendered; +} + +/** Ensure a Discord embed field value stays within the per-field limit. */ +export function boundedFieldValue(value: string, limit = DISCORD_EMBED_FIELD_VALUE_LIMIT): string { + return truncateWithSuffix(value, limit); +} + +/** Ensure a Discord embed description stays within the limit. */ +export function boundedDescription(value: string): string { + return truncateWithSuffix(value, DISCORD_EMBED_DESCRIPTION_LIMIT); +} + +/** Ensure a Discord message content stays within the safe limit. */ +export function boundedMessageContent(value: string): string { + return truncateWithSuffix(value, DISCORD_MESSAGE_CONTENT_LIMIT); +} + +/** Flatten multiline text to a single line (replace newlines with spaces). */ +export function flattenToSingleLine(value: string): string { + return value.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); +} + +/** Bound a string for Linear description/comment fields. */ +export function boundedLinearText(value: string, limit = LINEAR_DESCRIPTION_LIMIT): string { + return truncateWithSuffix(value, limit); +} + +/** Bound a string for Linear title. */ +export function boundedLinearTitle(value: string): string { + return truncateWithSuffix(value, LINEAR_TITLE_LIMIT); +} + +/** Sanitize and bound exception text for operator-facing stderr/logs (not end-user Discord). */ +export function sanitizeException(error: unknown): string { + if (error == null) return 'An unknown error occurred.'; + const msg = error instanceof Error ? error.message : String(error); + // Strip stack traces / multiline dumps; keep a single bounded line. + const cleaned = msg.split('\n')[0].trim(); + return truncate(cleaned || 'An error occurred.', 200); +} + +/** Generic, bounded user-visible failure โ€” never includes raw exception content. */ +export function genericUserError(_error?: unknown): string { + return 'Something went wrong. Please try again.'; +} + +/** + * Pack Discord embed fields while respecting per-field and aggregate value budgets. + * Returns pages of fields suitable for one embed each. + */ +export function paginateEmbedFields( + fields: Array<{ name: string; value: string; inline?: boolean }>, + options?: { + fieldValueLimit?: number; + aggregateLimit?: number; + maxFields?: number; + }, +): Array> { + const fieldValueLimit = options?.fieldValueLimit ?? DISCORD_EMBED_FIELD_VALUE_LIMIT; + const aggregateLimit = options?.aggregateLimit ?? DISCORD_EMBED_AGGREGATE_VALUE_LIMIT; + const maxFields = options?.maxFields ?? DISCORD_EMBED_FIELDS_PER_EMBED; + + const pages: Array> = []; + let current: Array<{ name: string; value: string; inline?: boolean }> = []; + let aggregate = 0; + + for (const field of fields) { + const value = boundedFieldValue(field.value, fieldValueLimit); + const next = { name: truncate(field.name, DISCORD_EMBED_FIELD_NAME_LIMIT), value, inline: field.inline }; + const fits = + current.length < maxFields && + aggregate + value.length <= aggregateLimit; + if (!fits && current.length > 0) { + pages.push(current); + current = []; + aggregate = 0; + } + // A single field larger than the remaining budget still goes on its own page (already bounded). + current.push(next); + aggregate += value.length; + } + if (current.length > 0) pages.push(current); + return pages; +} \ No newline at end of file diff --git a/src/support/workflowLinear.ts b/src/support/workflowLinear.ts index c070dbc9..e003bff9 100644 --- a/src/support/workflowLinear.ts +++ b/src/support/workflowLinear.ts @@ -10,10 +10,16 @@ import { StepResult, topologicalSort, } from '../orchestration/workflow.js'; +import { + boundedLinearText, + LINEAR_DESCRIPTION_LIMIT, + LINEAR_COMMENT_LIMIT, +} from './outputBudget.js'; -const LINEAR_BLOCK_LIMIT = 3000; +const LINEAR_BLOCK_LIMIT = LINEAR_COMMENT_LIMIT; const LINEAR_INLINE_LIMIT = 500; +/** Linear comment blocks: keep the historical ASCII truncation marker for callers/tests. */ function truncateForLinear(value: string, limit: number): string { return value.length > limit ? `${value.slice(0, limit)}\n... (truncated)` : value; } @@ -129,7 +135,7 @@ function buildWorkflowDescription(workflow: WorkflowConfig): string { parts.push('---'); parts.push('_Managed by OpenSwarm Workflow Engine_'); - return parts.join('\n'); + return boundedLinearText(parts.join('\n'), LINEAR_DESCRIPTION_LIMIT); } /** @@ -166,7 +172,7 @@ function buildStepDescription(step: WorkflowStep, workflow: WorkflowConfig): str parts.push('---'); parts.push(`_Part of workflow: ${workflow.name}_`); - return parts.join('\n'); + return boundedLinearText(parts.join('\n'), LINEAR_DESCRIPTION_LIMIT); } /** @@ -214,7 +220,7 @@ export function stepResultToComment(result: StepResult): string { parts.push(result.changedFiles.map(f => `- \`${f}\``).join('\n')); } - return parts.join('\n'); + return boundedLinearText(parts.join('\n'), LINEAR_COMMENT_LIMIT); } /** @@ -291,7 +297,7 @@ export function createExecutionSummary(execution: WorkflowExecution): { } } - return { body: parts.join('\n'), health }; + return { body: boundedLinearText(parts.join('\n'), LINEAR_COMMENT_LIMIT), health }; } // Linear MCP Command Templates diff --git a/src/tui/components/AuditBoard.test.tsx b/src/tui/components/AuditBoard.test.tsx index bde075bd..39d2a42f 100644 --- a/src/tui/components/AuditBoard.test.tsx +++ b/src/tui/components/AuditBoard.test.tsx @@ -75,6 +75,24 @@ describe('AuditBoard (INT-2006)', () => { expect(f).toContain('codex timeout after 300000ms'); }); + it('flattens multiline progress logs before truncating', async () => { + const events = new EventEmitter(); + const r = render(); + await act(tick); + await act(async () => { + events.emit('progress', { type: 'start', label: 'src/a', done: 0, total: 2 }); + events.emit('progress', { + type: 'log', + label: 'src/a', + line: 'first line\nsecond line\nthird line that is quite long', + }); + await tick(); + }); + const f = r.lastFrame()!; + expect(f).toContain('first line second line'); + expect(f).not.toMatch(/first line\nsecond line/); + }); + it('renders fix-pass progress with edited file tally', async () => { const events = new EventEmitter(); const r = render(); diff --git a/src/tui/components/AuditBoard.tsx b/src/tui/components/AuditBoard.tsx index 3a37c2e8..4f05db94 100644 --- a/src/tui/components/AuditBoard.tsx +++ b/src/tui/components/AuditBoard.tsx @@ -13,6 +13,7 @@ import type { AuditArea, AuditProgress } from '../../cli/reviewAudit.js'; import type { FixProgress } from '../../cli/reviewFixPass.js'; import type { ReviewResult } from '../../agents/agentPair.js'; import { sanitizeTerminalText } from '../sanitize.js'; +import { flattenToSingleLine, TUI_LOG_LINE_LIMIT } from '../../support/outputBudget.js'; type AreaStatus = { status: 'pending' | 'running' | 'done' | 'error'; @@ -31,6 +32,11 @@ export interface AuditBoardProps { const truncate = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}โ€ฆ`); +/** Flatten + sanitize + truncate so multiline tool logs can't blow past the row. */ +function displayLog(value: string, max: number): string { + return truncate(flattenToSingleLine(sanitizeTerminalText(value)), Math.min(max, TUI_LOG_LINE_LIMIT)); +} + export function AuditBoard({ areas, concurrency, events, mode = 'audit' }: AuditBoardProps) { const [statuses, setStatuses] = useState>(() => Object.fromEntries(areas.map((a) => [a.label, { status: 'pending' as const }])), @@ -88,7 +94,7 @@ export function AuditBoard({ areas, concurrency, events, mode = 'audit' }: Audit {' '} {` ${sanitizeTerminalText(label)}`} - {s.lastLog ? ` ${truncate(sanitizeTerminalText(s.lastLog), 48)}` : ''} + {s.lastLog ? ` ${displayLog(s.lastLog, 48)}` : ''} ))} {/* Failures carry the reason they failed. Showing only the counter left the @@ -96,7 +102,7 @@ export function AuditBoard({ areas, concurrency, events, mode = 'audit' }: Audit {errored.map(([label, s]) => ( {` ${ICON.warn} ${sanitizeTerminalText(label)}`} - {s.lastLog ? ` ${truncate(sanitizeTerminalText(s.lastLog), 64)}` : ''} + {s.lastLog ? ` ${displayLog(s.lastLog, 64)}` : ''} ))} diff --git a/src/tui/components/DataTable.tsx b/src/tui/components/DataTable.tsx index fbe2a8b3..c268d6cd 100644 --- a/src/tui/components/DataTable.tsx +++ b/src/tui/components/DataTable.tsx @@ -4,6 +4,7 @@ import { Box, Text } from 'ink'; import { displayWidth, truncateLine } from '../../cli/reviewProgress.js'; import type { Table } from '../monitorRows.js'; import { sanitizeTerminalText } from '../sanitize.js'; +import { flattenToSingleLine } from '../../support/outputBudget.js'; export interface DataTableProps extends Table { empty?: string; @@ -11,6 +12,11 @@ export interface DataTableProps extends Table { terminalWidth?: number; } +/** Normalize untrusted cell text to a single display line before width clipping. */ +function toDisplayCell(value: string): string { + return flattenToSingleLine(sanitizeTerminalText(value)); +} + export function DataTable({ columns, rows, empty, maxCellWidth = 28, terminalWidth }: DataTableProps) { if (rows.length === 0) { return {empty ?? '(no data)'}; @@ -18,7 +24,7 @@ export function DataTable({ columns, rows, empty, maxCellWidth = 28, terminalWid const visibleColumnCount = terminalWidth ? Math.max(1, Math.min(columns.length, terminalWidth)) : columns.length; - const rawColumns = columns.slice(0, visibleColumnCount).map(sanitizeTerminalText); + const rawColumns = columns.slice(0, visibleColumnCount).map(toDisplayCell); const separatorWidth = terminalWidth && visibleColumnCount > 1 ? Math.max(0, Math.min(2, Math.floor((terminalWidth - visibleColumnCount) / (visibleColumnCount - 1)))) : 2; @@ -31,7 +37,7 @@ export function DataTable({ columns, rows, empty, maxCellWidth = 28, terminalWid : maxCellWidth; const clip = (value: string) => truncateLine(value, cellWidth); const clippedColumns = rawColumns.map(clip); - const clippedRows = rows.map((row) => rawColumns.map((_, i) => clip(sanitizeTerminalText(row[i] ?? '')))); + const clippedRows = rows.map((row) => rawColumns.map((_, i) => clip(toDisplayCell(row[i] ?? '')))); const widths = clippedColumns.map((c, i) => Math.max(displayWidth(c), ...clippedRows.map((r) => displayWidth(r[i] ?? ''))), ); diff --git a/src/tui/dataTable.test.tsx b/src/tui/dataTable.test.tsx index 7f3e2b55..821df45d 100644 --- a/src/tui/dataTable.test.tsx +++ b/src/tui/dataTable.test.tsx @@ -34,6 +34,14 @@ describe('DataTable (EPIC INT-1813 S6)', () => { expect(f).not.toContain('์ž‘์—…์ƒํƒœํ™•์ธ'); }); + it('flattens multiline cell text before clipping so newlines cannot bypass layout', () => { + const f = render( + , + ).lastFrame()!; + expect(f).toContain('line1 line2 line3'); + expect(f).not.toContain('\nline2'); + }); + it('keeps rendered rows within the terminal width', () => { const f = render(