From ca8431cb173e1b0f08b9848e409297314246eefb Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 23:05:25 -0700 Subject: [PATCH 01/10] fix(flows): cap generated PR and comment bodies under GitHub's 65,536-character limit (#160) Garden run 80faae32 lost its pull request after 92 minutes to "Body is too long (maximum is 65536 characters)": nothing bounded summary.md plus the check report. Every body the generated flow posts now stays within FLOW_BODY_LIMIT (60,000 bytes, which bounds the characters too): - the check report cuts the script, each log (keeping its tail) and the repair notes to fixed section limits; summary.md gets what is left. The verdict is never cut, and an open code fence is closed; - the metadata step caps an over-long body and puts back a closing reference (Fixes #N) the cut removed, so it always survives; - the unresolved-review and time-stop comments are capped, and the withheld-workflow section's room is measured against the same limit. Cut text goes to the step's stderr, so the run journal keeps it, and the body says where the full text is. Fixes #160 Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 85 ++++++++++++++++++++--- web/lib/test/flow-push-guard.test.ts | 6 +- web/lib/test/flow-workflows.test.ts | 100 ++++++++++++++++++++++++++- 3 files changed, 177 insertions(+), 14 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index f8c0ea55..e489b76a 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -226,6 +226,44 @@ export const FLOW_BASE_CHECK_COMMAND = [ + '; else echo "relayflow: could not check out the base commit to compare against." >&2; if [ -n "$tmp" ]; then rm -rf "$tmp"; fi; echo unknown; fi', ].join('; '); +/** + * The most, in bytes, of any body the flow posts: the pull request's, and every + * comment. GitHub refuses a body over 65,536 characters (`gh pr create` and the + * GitLab merge request API alike), and Garden run 80faae32 lost its pull + * request that way after 92 minutes of work (agentrelay.com#160). A byte is at + * most one character, so this bounds the characters too, with room to spare. + */ +export const FLOW_BODY_LIMIT = 60000; + +/** + * The most, in bytes, each long section of the check report may hold. Their + * sum, with the report's own text, leaves summary.md over half the body. + */ +const REPORT_SECTION_LIMITS = { script: 4000, log: 10000, repair: 4000 } as const; + +/** Bytes kept free for the closing reference line (`Fixes #N`, or a ticket URL). */ +const REFERENCE_RESERVE = 1024; + +/** + * `relayflow_cap ` cuts `file` to at most + * `bytes`, keeping its start (`head`) or its end (`tail`), at a line boundary + * so no multibyte character is split. The cut is marked in the file, naming + * `where` the full text is, and the full text goes to stderr, so the step's + * output in the run journal keeps it. A code fence the kept start leaves open + * is closed, so whatever follows is not swallowed by it. A file within + * `bytes` is left as it is. Always returns 0. + */ +const CAP_FUNCTION = 'relayflow_cap() { ' + + 'cap_size=$(LC_ALL=C wc -c < "$1" | tr -d " "); if [ "$cap_size" -le "$2" ]; then return 0; fi; ' + + 'cap_keep=$(($2 - 400)); if [ "$cap_keep" -lt 0 ]; then cap_keep=0; fi; ' + + `cap_note="_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4, and in this step's output._"; ` + + 'echo "relayflow: $1 is $cap_size bytes, more than the $2 a body may hold, so it was cut. The full text follows." >&2; cat "$1" >&2; ' + + 'if [ "$3" = tail ]; then { printf \'%s\\n\\n\' "$cap_note"; LC_ALL=C tail -c "$cap_keep" "$1" | LC_ALL=C sed 1d; } > "$1.cap"; ' + + 'else LC_ALL=C head -c "$cap_keep" "$1" | LC_ALL=C sed \'$d\' > "$1.cap"; ' + + 'if [ $(( $(LC_ALL=C grep -c \'^[[:space:]]*```\' "$1.cap" || :) % 2 )) -eq 1 ]; then printf \'```\\n\' >> "$1.cap"; fi; ' + + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi; ' + + 'mv -f "$1.cap" "$1"; return 0; }'; + /** * Writes what the checks found to `.relayflow/check-report.md`, and the pull * request body — summary.md followed by that report — to `.relayflow/pr-body.md`. @@ -239,10 +277,23 @@ export const FLOW_BASE_CHECK_COMMAND = [ * outright). A reviewer reads the verdict, the * script that ran and the tail of each log on the pull request itself, without * opening the run journal. + * + * The body stays within FLOW_BODY_LIMIT (agentrelay.com#160): the script, each + * log and the repair notes are cut to REPORT_SECTION_LIMITS (a log keeps its + * end, where the failure is), and summary.md is cut to what the report leaves. + * The verdict is never cut. */ export const FLOW_CHECK_REPORT_COMMAND = [ 'mkdir -p .relayflow', + CAP_FUNCTION, 'report=.relayflow/check-report.md', + // Each long section is a capped copy, so the report stays within + // REPORT_SECTION_LIMITS and summary.md gets the rest of FLOW_BODY_LIMIT. + 'parts=.relayflow/report-parts; rm -rf "$parts"; mkdir -p "$parts"', + `if [ -s ${FLOW_CHECK_SCRIPT} ]; then cp ${FLOW_CHECK_SCRIPT} "$parts/script"; relayflow_cap "$parts/script" ${REPORT_SECTION_LIMITS.script} head "${FLOW_CHECK_SCRIPT} in the run workspace"; fi`, + `if [ -s .relayflow/check.log ]; then tail -n 80 .relayflow/check.log > "$parts/check"; relayflow_cap "$parts/check" ${REPORT_SECTION_LIMITS.log} tail ".relayflow/check.log in the run workspace"; fi`, + `if [ -s .relayflow/base-check.log ]; then tail -n 80 .relayflow/base-check.log > "$parts/base"; relayflow_cap "$parts/base" ${REPORT_SECTION_LIMITS.log} tail ".relayflow/base-check.log in the run workspace"; fi`, + `if [ -s .relayflow/repair-notes.md ]; then cp .relayflow/repair-notes.md "$parts/repair"; relayflow_cap "$parts/repair" ${REPORT_SECTION_LIMITS.repair} head ".relayflow/repair-notes.md in the run workspace"; fi`, '{ printf \'## Checks\\n\\n\'' + `; if [ "\${implementer_timeout:-}" = yes ]; then printf '%s\\n\\n' "**The implementer was stopped at its time limit**, so this change may be incomplete. Its committed work is what was checked and pushed; the pull request is a draft."; fi` + '; case "$check" in' @@ -257,12 +308,16 @@ export const FLOW_CHECK_REPORT_COMMAND = [ + ` *) printf '%s\\n' "**The checks failed**, and the base commit could not be checked for comparison, so it is not known whether this change caused them. This pull request is a draft until someone looks." ;;` + ' esac ;;' + ' esac' - + `; if [ -s ${FLOW_CHECK_SCRIPT} ]; then printf '\\n
What ran (${FLOW_CHECK_SCRIPT})\\n\\n\`\`\`sh\\n'; cat ${FLOW_CHECK_SCRIPT}; printf '\`\`\`\\n
\\n'; fi` - + `; if [ "$check" != pass ] && [ "$check" != none ] && [ "$check" != skipped ] && [ -s .relayflow/check.log ]; then printf '\\n
Output on this branch (last 80 lines)\\n\\n\`\`\`\\n'; tail -n 80 .relayflow/check.log; printf '\`\`\`\\n
\\n'; fi` - + `; if [ "$baseline" = fail ] || [ "$baseline" = timeout ] || [ "$baseline" = unknown ]; then if [ -s .relayflow/base-check.log ]; then printf '\\n
Output on the base commit (last 80 lines)\\n\\n\`\`\`\\n'; tail -n 80 .relayflow/base-check.log; printf '\`\`\`\\n
\\n'; fi; fi` - + `; if [ -s .relayflow/repair-notes.md ]; then printf '\\n### What the repair agent found\\n\\n'; cat .relayflow/repair-notes.md; fi` + + `; if [ -s "$parts/script" ]; then printf '\\n
What ran (${FLOW_CHECK_SCRIPT})\\n\\n\`\`\`sh\\n'; cat "$parts/script"; printf '\`\`\`\\n
\\n'; fi` + + `; if [ "$check" != pass ] && [ "$check" != none ] && [ "$check" != skipped ] && [ -s "$parts/check" ]; then printf '\\n
Output on this branch (last 80 lines)\\n\\n\`\`\`\\n'; cat "$parts/check"; printf '\`\`\`\\n
\\n'; fi` + + `; if [ "$baseline" = fail ] || [ "$baseline" = timeout ] || [ "$baseline" = unknown ]; then if [ -s "$parts/base" ]; then printf '\\n
Output on the base commit (last 80 lines)\\n\\n\`\`\`\\n'; cat "$parts/base"; printf '\`\`\`\\n
\\n'; fi; fi` + + `; if [ -s "$parts/repair" ]; then printf '\\n### What the repair agent found\\n\\n'; cat "$parts/repair"; fi` + '; } > "$report"', - '{ if [ -s summary.md ]; then cat summary.md; printf \'\\n\\n\'; fi; cat "$report"; } > .relayflow/pr-body.md', + // The summary gets what the report leaves, less room for the closing + // reference FLOW_PREPARE_CHANGE_METADATA_COMMAND appends. + `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - REFERENCE_RESERVE} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - 2)) head "summary.md in the run workspace"; fi`, + '{ if [ -s "$parts/summary" ]; then cat "$parts/summary"; printf \'\\n\\n\'; fi; cat "$report"; } > .relayflow/pr-body.md', + 'rm -rf "$parts"', 'echo "relayflow: wrote the check report to $report." >&2', 'echo written', ].join('; '); @@ -382,6 +437,8 @@ export const FLOW_TIME = (() => { export const FLOW_TIME_STOP_COMMAND = [ 'mkdir -p .relayflow', `printf '%s\\n' "**This run ran out of time before its review was done** (the flow's time budget). The work is pushed and this pull request is a draft so it is not lost; review it by hand or run the flow again." > .relayflow/time-stop.md`, + CAP_FUNCTION, + `relayflow_cap .relayflow/time-stop.md ${FLOW_BODY_LIMIT} head "the run journal"`, `if ${FLOW_DRAFT_CHANGE_COMMAND} >/dev/null 2>&1; then echo "relayflow: converted the pull request to a draft."; else echo "relayflow: could not convert the pull request to a draft." >&2; fi`, `if ${flowCommentChangeCommand('.relayflow/time-stop.md')} >/dev/null 2>&1; then echo "relayflow: posted the time stop to the pull request."; else echo "relayflow: could not comment on the pull request." >&2; fi`, ].join('; '); @@ -555,16 +612,15 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ 'n=$(git diff --name-only "$mb" "$orig" -- "$wf" | wc -l | tr -d " ")', // Builds the reviewer-facing section. The heading, explanation and a file // list capped at 50 entries are written and measured first; only the room - // left under GitHub's 65,536-character body/comment limit (kept at 65,000, - // minus what the body already holds and ~400 bytes of fences and notes) - // goes to the patch. + // left under FLOW_BODY_LIMIT (minus what the body already holds and ~400 + // bytes of fences and notes) goes to the patch. 'relayflow_section() { ' + 'used=0; if [ "${comment:-}" != yes ] && [ -f .relayflow/pr-body.md ]; then used=$(wc -c < .relayflow/pr-body.md | tr -d " "); fi; ' + `{ printf '\\n## Workflow changes not applied\\n\\n%s\\n\\n' "The GitHub App token this run pushes with lacks the \\\`workflows\\\` permission, so GitHub refused the commits that change \\\`.github/workflows/\\\`. The rest of the work is pushed; these edits were taken out of its commits. Apply them manually:"; ` + `git diff --name-status "$mb" "$orig" -- "$wf" | awk -F '\\t' '{ printf "- %s \\140%s\\140\\n", substr($1, 1, 1), $NF }' | head -n 50; ` + `if [ "$n" -gt 50 ]; then printf -- '- …and %s more\\n' "$((n - 50))"; fi; } > "$tmp/head"; ` + 'overhead=$(( $(wc -c < "$tmp/head" | tr -d " ") + 400 )); ' - + 'limit=${RELAYFLOW_WITHHELD_PATCH_LIMIT:-61440}; room=$((65000 - used - overhead)); if [ "$room" -lt "$limit" ]; then limit=$room; fi; size=$(wc -c < "$patch" | tr -d " "); ' + + 'limit=${RELAYFLOW_WITHHELD_PATCH_LIMIT:-61440}; room=$((' + FLOW_BODY_LIMIT + ' - used - overhead)); if [ "$room" -lt "$limit" ]; then limit=$room; fi; size=$(wc -c < "$patch" | tr -d " "); ' + `{ cat "$tmp/head"; if [ "$limit" -le 0 ]; then printf '\\n%s\\n' "_The patch ($size bytes) does not fit in the pull request body. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._"; ` + `elif [ "$size" -le "$limit" ]; then printf '\\n\`\`\`\`diff\\n'; cat "$patch"; printf '\`\`\`\`\\n'; ` + `else printf '\\n\`\`\`\`diff\\n'; head -c "$limit" "$patch" | sed '$d'; printf '\`\`\`\`\\n\\n%s\\n' "_Truncated to $limit of $size bytes. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._"; fi; } > "$section"; }`, @@ -601,10 +657,17 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ * The reference is supplied through a shell-quoted variable by the generated * flow; grep matches a complete, fixed line so an agent-written matching line * is retained rather than duplicated. + * + * A body over FLOW_BODY_LIMIT is cut to fit (agentrelay.com#160), and a + * reference the cut removed is put back at the end: a closing reference is + * never lost to truncation. */ export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [ 'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi', - 'if [ -n "$reference" ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi', + CAP_FUNCTION, + 'relayflow_reference() { if [ -n "$reference" ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi; }', + 'relayflow_reference', + `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT} ]; then relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, 'echo prepared', ].join('; '); @@ -699,6 +762,8 @@ const REVIEW_TIMEOUT_NOTE = 'The review was stopped at its %s before it finished export const FLOW_REVIEW_BLOCKED_COMMAND = [ 'set -e', `{ printf '%s\\n\\n' "${REVIEW_BLOCKED_HEADING}"; if [ "\${review_timeout:-}" = yes ]; then if [ -n "\${review_limit:-}" ]; then stopped_at="\${review_limit}-minute limit"; else stopped_at="time limit"; fi; printf '${REVIEW_TIMEOUT_NOTE}\\n\\n' "$stopped_at"; fi; if [ -s review.md ]; then cat review.md; else printf '%s\\n' "_The reviewer left no review.md; see the review step in the run journal._"; fi; } > review-blocked.md || true`, + CAP_FUNCTION, + `relayflow_cap review-blocked.md ${FLOW_BODY_LIMIT} head "review.md in the run workspace" || true`, 'echo "relayflow: the adversarial review did not pass; wrote review-blocked.md."', `if ${FLOW_DRAFT_CHANGE_COMMAND} >/dev/null 2>&1; then echo "relayflow: converted the pull request to a draft."; else echo "relayflow: could not convert the pull request to a draft; review-blocked.md still holds the findings." >&2; fi`, `if ${flowCommentChangeCommand('review-blocked.md')} >/dev/null 2>&1; then echo "relayflow: posted the unresolved review to the pull request."; else echo "relayflow: could not comment on the pull request; review-blocked.md still holds the findings." >&2; fi`, diff --git a/web/lib/test/flow-push-guard.test.ts b/web/lib/test/flow-push-guard.test.ts index 581d1909..0fb10b08 100644 --- a/web/lib/test/flow-push-guard.test.ts +++ b/web/lib/test/flow-push-guard.test.ts @@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr import { execFileSync, spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { FLOW_PUSH_COMMAND, WORKFLOW_FILES_HINT } from '../flow-workflows'; +import { FLOW_BODY_LIMIT, FLOW_PUSH_COMMAND, WORKFLOW_FILES_HINT } from '../flow-workflows'; import { factorySource, DEFAULT_FACTORY, type FactoryDraft } from '../flow-onboarding'; /** @@ -329,7 +329,7 @@ describe('FLOW_PUSH_COMMAND', { timeout: 30_000 }, () => { }); // Review on #114: the whole section, not just the patch, must fit GitHub's - // 65,536-character body limit. + // 65,536-character body limit, within the flow's own cap (agentrelay.com#160). it('keeps the pull-request body under GitHub\'s limit even with a long file list', () => { const files: Record = { 'README.md': '#\n' }; const edits: Record = { 'src/x.ts': 'x\n' }; @@ -344,7 +344,7 @@ describe('FLOW_PUSH_COMMAND', { timeout: 30_000 }, () => { const result = push(root, base); expect(result.code).toBe(0); const body = read(root, '.relayflow/pr-body.md'); - expect(body.length).toBeLessThanOrEqual(65536); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); expect(body).toContain('…and 70 more'); expect(body.split('````').length % 2).toBe(1); }, 60_000); diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index d5a37bd9..3ab866da 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -8,7 +8,7 @@ import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_REPORT_COMMAND, FLOW_CHECK_RESOLVE_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_CHECK_SCRIPT, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PREPARE_CHANGE_METADATA_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REPORT_REVIEW_FINDINGS_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND, FLOW_REVIEW_FINDINGS_LIMIT, FLOW_VALIDATE_CHANGE_METADATA_COMMAND, FLOW_FREE_DISK_COMMAND, FLOW_TIME_STOP_COMMAND, FLOW_PUSH_COMMAND, FLOW_DRAFT_CHANGE_COMMAND, - flowCommentChangeCommand, + flowCommentChangeCommand, FLOW_BODY_LIMIT, WORKFLOWS, FLOW_TIME, } from '../flow-workflows'; import { factorySource, type FactoryDraft } from '../flow-onboarding'; @@ -886,6 +886,104 @@ describe('change metadata contract', () => { }); }); +/** + * Garden run 80faae32 (AgentWorkforce/flows#494) worked for 92 minutes and then + * lost its pull request to `Body is too long (maximum is 65536 characters)`: + * nothing bounded summary.md plus the check report (agentrelay.com#160). Every + * body the flow posts is capped at FLOW_BODY_LIMIT, measured in bytes, which + * also bounds the characters, and the closing reference always survives. + */ +describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay.com#160)', () => { + const chars = (text: string) => Array.from(text).length; + const fences = (text: string) => text.split('\n').filter(line => /^\s*```/.test(line)).length; + const longLines = (prefix: string, count: number, width: number) => + Array.from({ length: count }, (_, i) => `${prefix} ${i} ${'修'.repeat(width)}`).join('\n') + '\n'; + + function publishBody(files: Record, check = 'fail', baseline = 'fail') { + const root = fixture(files); + const report = sh(`check=${check}; baseline=${baseline}; ${FLOW_CHECK_REPORT_COMMAND}`, root); + const prepare = sh(`reference='Fixes #160'; ${FLOW_PREPARE_CHANGE_METADATA_COMMAND}`, root); + const verdict = sh(`title='Fix login'; title_length=9; source='github'; identifier='#160'; ${FLOW_VALIDATE_CHANGE_METADATA_COMMAND}`, root).token; + return { root, report, prepare, verdict, body: read(root, '.relayflow/pr-body.md') }; + } + + it('stays under the cap a verbose summary and long check output would break, keeping the summary, the verdict and Fixes', () => { + const files = { + 'summary.md': '## What changed\n\nFixed the login bug.\n\n```ts\n' + longLines('summary', 400, 60), + [FLOW_CHECK_SCRIPT]: 'set -e\n' + longLines('# step', 200, 40), + '.relayflow/check.log': longLines('FAIL src/login.test.ts', 200, 400), + '.relayflow/base-check.log': longLines('FAIL src/other.test.ts', 200, 400), + '.relayflow/repair-notes.md': longLines('note', 100, 200), + }; + expect(chars(Object.values(files).join(''))).toBeGreaterThan(65536); + const { report, prepare, verdict, body } = publishBody(files); + expect(report.code).toBe(0); + expect(prepare.token).toBe('prepared'); + expect(verdict).toBe('valid'); + expect(chars(body)).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(FLOW_BODY_LIMIT).toBeLessThan(65536); + // Valid UTF-8: no multibyte character was split. + expect(body).not.toContain('\uFFFD'); + expect(body.startsWith('## What changed\n\nFixed the login bug.')).toBe(true); + expect(body).toContain('**The checks fail on the base commit too**'); + expect(body.split('\n').filter(line => line === 'Fixes #160')).toHaveLength(1); + // The newest output is what a reviewer needs, so the logs keep their tail. + expect(body).toContain('FAIL src/login.test.ts 199 '); + expect(body).toContain('FAIL src/other.test.ts 199 '); + expect(body).toMatch(/truncated to fit GitHub's limit/); + expect(body).toContain('summary.md in the run workspace'); + expect(body).toContain('.relayflow/check.log in the run workspace'); + // The summary's open code fence is closed, so the report is not swallowed by it. + expect(fences(body) % 2).toBe(0); + // What was cut is in the step's output: the first of the last 80 lines. + expect(body).not.toContain('FAIL src/login.test.ts 120 '); + expect(report.stderr).toContain('FAIL src/login.test.ts 120 '); + }); + + it('puts back a closing reference that truncation cut from the summary', () => { + const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); + expect(verdict).toBe('valid'); + expect(chars(body)).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).toContain('Relayflow ran this repository\'s checks'); + expect(body.split('\n').filter(line => line === 'Fixes #160')).toHaveLength(1); + }); + + it('caps a body that is already too long when the reference is added, ending with the reference', () => { + const root = fixture({ '.relayflow/pr-body.md': longLines('line', 400, 100) }); + expect(sh(`reference='Fixes #160'; ${FLOW_PREPARE_CHANGE_METADATA_COMMAND}`, root).token).toBe('prepared'); + const body = read(root, '.relayflow/pr-body.md'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).not.toContain('\uFFFD'); + expect(body.trimEnd().endsWith('Fixes #160')).toBe(true); + expect(sh(`title='Fix login'; title_length=9; source='github'; identifier='#160'; ${FLOW_VALIDATE_CHANGE_METADATA_COMMAND}`, root).token).toBe('valid'); + }); + + it('leaves a body under the cap exactly as it was', () => { + const { body } = publishBody({ 'summary.md': '## What changed\n\nFixed the login bug.\n', '.relayflow/check.log': 'FAIL src/login.test.ts\n' }); + expect(body).not.toContain('truncated'); + expect(body).toContain('FAIL src/login.test.ts\n'); + }); + + it('caps the unresolved review it posts', () => { + const { code, blocked, ghCalls, stderr } = runReviewBlocked({ 'review.md': '## Findings\n\n' + longLines('P1', 600, 60) }, 'works'); + expect(code).toBe(0); + expect(ghCalls).toContain('pr comment --body-file review-blocked.md'); + expect(Buffer.byteLength(blocked, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(blocked).not.toContain('\uFFFD'); + expect(blocked).toContain('## Findings'); + expect(blocked).toContain('review.md in the run workspace'); + expect(stderr).toContain('P1 599 '); + }); + + it('keeps the time-stop comment under the cap', () => { + const root = fixture({}); + expect(sh(FLOW_TIME_STOP_COMMAND, root, { PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` }).code).toBe(0); + expect(Buffer.byteLength(read(root, '.relayflow/time-stop.md'), 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(FLOW_TIME_STOP_COMMAND).toContain(`relayflow_cap .relayflow/time-stop.md ${FLOW_BODY_LIMIT}`); + }); +}); + /** * A step without a `timeout` gets the kernel's 30s default, and a timed-out * `f.run` throws: Garden run ccbc27c8 worked for 1h46m and then lost all of it From 1313ce63e60fb9cb5dc9ee378082375f188f8bcd Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 23:14:03 -0700 Subject: [PATCH 02/10] fix(flows): bound the whole withheld section, reserve the real reference, keep one-line text, print no cut text Review on #161: - the withheld-workflow section is capped as a whole against the body's remaining room, so 50 long paths cannot overflow a near-full body; - the check report is told the closing reference and leaves room for its real length, so a long ticket URL never cuts the report; - relayflow_cap keeps what fits of a one-line file, cut at a UTF-8 character boundary, and closes an open fence with a matching fence; - cut text is no longer printed to stderr: a log or review can hold credentials the run journal must not show. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 75 +++++++++++++++++++--------- web/lib/test/flow-push-guard.test.ts | 21 ++++++++ web/lib/test/flow-workflows.test.ts | 41 ++++++++++++--- 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index e489b76a..3bcb1fae 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -241,28 +241,51 @@ export const FLOW_BODY_LIMIT = 60000; */ const REPORT_SECTION_LIMITS = { script: 4000, log: 10000, repair: 4000 } as const; -/** Bytes kept free for the closing reference line (`Fixes #N`, or a ticket URL). */ -const REFERENCE_RESERVE = 1024; +/** + * Bytes kept free, besides the closing reference line itself, for the line + * breaks around it. + */ +const REFERENCE_MARGIN = 64; + +/** + * Prints the fence that closes a Markdown code block still open at the end of + * its input, or nothing: a fence of three or more backticks opens a block, + * and a bare fence at least as long closes it. + */ +const OPEN_FENCE_AWK = String.raw`{ if (match($0, /^[ \t]*` + '```' + String.raw`+/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else { rest = $0; sub(/^[ \t]*` + '`' + String.raw`+[ \t]*$/, "", rest); if (rest == "" && length(f) >= length(open)) open = ""; } } } END { if (open != "") print open }`; /** * `relayflow_cap ` cuts `file` to at most - * `bytes`, keeping its start (`head`) or its end (`tail`), at a line boundary - * so no multibyte character is split. The cut is marked in the file, naming - * `where` the full text is, and the full text goes to stderr, so the step's - * output in the run journal keeps it. A code fence the kept start leaves open - * is closed, so whatever follows is not swallowed by it. A file within - * `bytes` is left as it is. Always returns 0. + * `bytes`, keeping its start (`head`) or its end (`tail`). It cuts at a line + * boundary, and inside a line only when the kept part has no line break at + * all, then at a character boundary, so no multibyte character is split and a + * one-line file keeps what fits. The cut is marked in the file, naming + * `where` the full text is. A code fence the kept start leaves open is + * closed, so whatever follows is not swallowed by it. The text cut is never + * printed: a log or review can hold credentials the run journal must not + * show. With less than CAP_RESERVE bytes to keep, the file is emptied. A file + * within `bytes` is left as it is. Always returns 0. */ -const CAP_FUNCTION = 'relayflow_cap() { ' - + 'cap_size=$(LC_ALL=C wc -c < "$1" | tr -d " "); if [ "$cap_size" -le "$2" ]; then return 0; fi; ' - + 'cap_keep=$(($2 - 400)); if [ "$cap_keep" -lt 0 ]; then cap_keep=0; fi; ' - + `cap_note="_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4, and in this step's output._"; ` - + 'echo "relayflow: $1 is $cap_size bytes, more than the $2 a body may hold, so it was cut. The full text follows." >&2; cat "$1" >&2; ' - + 'if [ "$3" = tail ]; then { printf \'%s\\n\\n\' "$cap_note"; LC_ALL=C tail -c "$cap_keep" "$1" | LC_ALL=C sed 1d; } > "$1.cap"; ' - + 'else LC_ALL=C head -c "$cap_keep" "$1" | LC_ALL=C sed \'$d\' > "$1.cap"; ' - + 'if [ $(( $(LC_ALL=C grep -c \'^[[:space:]]*```\' "$1.cap" || :) % 2 )) -eq 1 ]; then printf \'```\\n\' >> "$1.cap"; fi; ' - + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi; ' - + 'mv -f "$1.cap" "$1"; return 0; }'; +const CAP_RESERVE = 512; +const CAP_FUNCTION = 'relayflow_cap() { ' + [ + 'cap_size=$(LC_ALL=C wc -c < "$1" | tr -d " ")', + 'if [ "$cap_size" -le "$2" ]; then return 0; fi', + 'echo "relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4." >&2', + `cap_keep=$(($2 - ${CAP_RESERVE}))`, + 'if [ "$cap_keep" -le 0 ]; then : > "$1"; return 0; fi', + `cap_note="_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._"`, + // The bytes that start a multibyte character, and those that continue one. + "cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]')", + 'if [ "$3" = tail ]; then LC_ALL=C tail -c "$cap_keep" "$1" > "$1.cut"; else LC_ALL=C head -c "$cap_keep" "$1" > "$1.cut"; fi', + 'cap_lines=$(LC_ALL=C tr -cd "\\n" < "$1.cut" | wc -c | tr -d " ")', + 'if [ "$3" = tail ]; then ' + + '{ printf \'%s\\n\\n\' "$cap_note"; if [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed 1d "$1.cut"; else LC_ALL=C sed "s/^$cap_cont*//" "$1.cut"; fi; } > "$1.cap"; ' + + 'else ' + + '{ if [ "$(LC_ALL=C tail -c 1 "$1.cut" | wc -l | tr -d " ")" -eq 1 ]; then cat "$1.cut"; elif [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed \'$d\' "$1.cut"; else LC_ALL=C sed "s/$cap_lead$cap_cont*\\$//" "$1.cut"; echo; fi; } > "$1.cap"; ' + + `LC_ALL=C awk '${OPEN_FENCE_AWK}' "$1.cap" > "$1.cut"; cat "$1.cut" >> "$1.cap"; ` + + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi', + 'rm -f "$1.cut"; mv -f "$1.cap" "$1"; return 0', +].join('; ') + '; }'; /** * Writes what the checks found to `.relayflow/check-report.md`, and the pull @@ -272,7 +295,7 @@ const CAP_FUNCTION = 'relayflow_cap() { ' * time to run the checks), `baseline` (the base commit's token, empty when it * was not needed, or `skipped` when there was no time to check it), and * `implementer_timeout=yes` when the implementer was stopped at its time - * limit. The base commit's output is shown whenever it ran, including when + * limit, and `reference` to the closing reference line the next step adds. The base commit's output is shown whenever it ran, including when * its verdict is `unknown` (it ran out of time against a branch that failed * outright). A reviewer reads the verdict, the * script that ran and the tail of each log on the pull request itself, without @@ -314,8 +337,9 @@ export const FLOW_CHECK_REPORT_COMMAND = [ + `; if [ -s "$parts/repair" ]; then printf '\\n### What the repair agent found\\n\\n'; cat "$parts/repair"; fi` + '; } > "$report"', // The summary gets what the report leaves, less room for the closing - // reference FLOW_PREPARE_CHANGE_METADATA_COMMAND appends. - `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - REFERENCE_RESERVE} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - 2)) head "summary.md in the run workspace"; fi`, + // reference (\`reference\`, set by the caller) that + // FLOW_PREPARE_CHANGE_METADATA_COMMAND appends. + `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - REFERENCE_MARGIN} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - $(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " "))) head "summary.md in the run workspace"; fi`, '{ if [ -s "$parts/summary" ]; then cat "$parts/summary"; printf \'\\n\\n\'; fi; cat "$report"; } > .relayflow/pr-body.md', 'rm -rf "$parts"', 'echo "relayflow: wrote the check report to $report." >&2', @@ -613,7 +637,9 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ // Builds the reviewer-facing section. The heading, explanation and a file // list capped at 50 entries are written and measured first; only the room // left under FLOW_BODY_LIMIT (minus what the body already holds and ~400 - // bytes of fences and notes) goes to the patch. + // bytes of fences and notes) goes to the patch. The whole section is then + // capped to that room, so a long file list cannot overflow the body either. + CAP_FUNCTION, 'relayflow_section() { ' + 'used=0; if [ "${comment:-}" != yes ] && [ -f .relayflow/pr-body.md ]; then used=$(wc -c < .relayflow/pr-body.md | tr -d " "); fi; ' + `{ printf '\\n## Workflow changes not applied\\n\\n%s\\n\\n' "The GitHub App token this run pushes with lacks the \\\`workflows\\\` permission, so GitHub refused the commits that change \\\`.github/workflows/\\\`. The rest of the work is pushed; these edits were taken out of its commits. Apply them manually:"; ` @@ -623,7 +649,8 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ + 'limit=${RELAYFLOW_WITHHELD_PATCH_LIMIT:-61440}; room=$((' + FLOW_BODY_LIMIT + ' - used - overhead)); if [ "$room" -lt "$limit" ]; then limit=$room; fi; size=$(wc -c < "$patch" | tr -d " "); ' + `{ cat "$tmp/head"; if [ "$limit" -le 0 ]; then printf '\\n%s\\n' "_The patch ($size bytes) does not fit in the pull request body. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._"; ` + `elif [ "$size" -le "$limit" ]; then printf '\\n\`\`\`\`diff\\n'; cat "$patch"; printf '\`\`\`\`\\n'; ` - + `else printf '\\n\`\`\`\`diff\\n'; head -c "$limit" "$patch" | sed '$d'; printf '\`\`\`\`\\n\\n%s\\n' "_Truncated to $limit of $size bytes. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._"; fi; } > "$section"; }`, + + `else printf '\\n\`\`\`\`diff\\n'; head -c "$limit" "$patch" | sed '$d'; printf '\`\`\`\`\\n\\n%s\\n' "_Truncated to $limit of $size bytes. The full patch is .relayflow/workflow-changes.patch in the run workspace, and in this push step's output._"; fi; } > "$section"; ` + + 'relayflow_cap "$section" $((' + FLOW_BODY_LIMIT + ' - used)) head "$patch in the run workspace"; }', // Workflow paths with uncommitted (staged, unstaged or untracked) edits // relative to the original tip are never reset below: only committed edits // are withheld, and an agent's in-progress work stays exactly as it was. @@ -1075,7 +1102,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType { expect(body.split('````').length % 2).toBe(1); }, 60_000); + // Review on #161: a near-full body and 50 long workflow paths must not + // overflow it either; the whole section is capped, not just the patch. + it('keeps a near-full pull-request body under the cap with 50 long workflow paths', () => { + const files: Record = { 'README.md': '#\n' }; + const edits: Record = { 'src/x.ts': 'x\n' }; + for (let i = 0; i < 50; i += 1) { + const name = `.github/workflows/${'w'.repeat(200)}-${i}.yml`; + files[name] = 'name: w\n'; + edits[name] = 'name: w\n# edited\n'; + } + const { root, base } = setup(files); + commit(root, 'long workflow paths', edits); + write(root, { '.relayflow/pr-body.md': BODY + 'x\n'.repeat(29000) }); + const result = push(root, base); + expect(result.code).toBe(0); + const body = read(root, '.relayflow/pr-body.md'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).toContain('## Workflow changes not applied'); + expect(body).toMatch(/truncated to fit GitHub's limit/); + }, 60_000); + it('bounds the patch in the pull-request body and says where the rest is', () => { const { root, base } = setup({ 'README.md': '#\n', '.github/workflows/ci.yml': CI }); commit(root, 'ci', { '.github/workflows/ci.yml': CI + '# padding\n'.repeat(4000), 'docs/a.md': 'a\n' }); diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index 3ab866da..e726cec6 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -899,11 +899,11 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. const longLines = (prefix: string, count: number, width: number) => Array.from({ length: count }, (_, i) => `${prefix} ${i} ${'修'.repeat(width)}`).join('\n') + '\n'; - function publishBody(files: Record, check = 'fail', baseline = 'fail') { + function publishBody(files: Record, check = 'fail', baseline = 'fail', reference = 'Fixes #160', source = 'github') { const root = fixture(files); - const report = sh(`check=${check}; baseline=${baseline}; ${FLOW_CHECK_REPORT_COMMAND}`, root); - const prepare = sh(`reference='Fixes #160'; ${FLOW_PREPARE_CHANGE_METADATA_COMMAND}`, root); - const verdict = sh(`title='Fix login'; title_length=9; source='github'; identifier='#160'; ${FLOW_VALIDATE_CHANGE_METADATA_COMMAND}`, root).token; + const report = sh(`check=${check}; baseline=${baseline}; reference='${reference}'; ${FLOW_CHECK_REPORT_COMMAND}`, root); + const prepare = sh(`reference='${reference}'; ${FLOW_PREPARE_CHANGE_METADATA_COMMAND}`, root); + const verdict = sh(`title='Fix login'; title_length=9; source='${source}'; identifier='#160'; ${FLOW_VALIDATE_CHANGE_METADATA_COMMAND}`, root).token; return { root, report, prepare, verdict, body: read(root, '.relayflow/pr-body.md') }; } @@ -936,9 +936,35 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(body).toContain('.relayflow/check.log in the run workspace'); // The summary's open code fence is closed, so the report is not swallowed by it. expect(fences(body) % 2).toBe(0); - // What was cut is in the step's output: the first of the last 80 lines. + // The step says what it cut, but never prints the text: a log can hold credentials. expect(body).not.toContain('FAIL src/login.test.ts 120 '); - expect(report.stderr).toContain('FAIL src/login.test.ts 120 '); + expect(report.stderr).toContain('so it was cut; the full text is .relayflow/check.log in the run workspace'); + expect(report.stderr).not.toContain('FAIL src/login.test.ts 120 '); + }); + + it('keeps what fits of a one-line summary, cut at a character boundary', () => { + const { verdict, body } = publishBody({ 'summary.md': '修'.repeat(30000) }, 'pass', ''); + expect(verdict).toBe('valid'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).not.toContain('\uFFFD'); + expect(body.split('\n')[0]).toMatch(/^修{15000,}$/); + expect(body).toMatch(/truncated to fit GitHub's limit/); + }); + + it('keeps the verdict and the whole reference when the reference is long', () => { + const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(3000); + const { verdict, body } = publishBody({ 'summary.md': longLines('summary', 400, 60), '.relayflow/check.log': longLines('FAIL', 80, 400) }, 'fail', 'pass', reference, 'linear'); + expect(verdict).toBe('valid'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).toContain('**This change breaks checks that pass on the base commit.**'); + // The whole report survives, to the end of the log: the summary made the room. + expect(body).toContain('FAIL 79 '); + expect(body.split('\n').filter(line => line === reference)).toHaveLength(1); + }); + + it('tells the check report the closing reference it must leave room for', () => { + const source = factorySource({ version: 4, sources: ['github'], sourceSettings: { github: { repository: 'acme/app', labels: 'ready' } }, agents: ['claude', 'codex'], otherAgent: '', task: 'Add a test', workflow: 'simple', step: 3 }, 'cloud'); + expect(source).toContain('"; reference=" + shellQuote(changeReference) + "; " + checkReport'); }); it('puts back a closing reference that truncation cut from the summary', () => { @@ -973,7 +999,8 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(blocked).not.toContain('\uFFFD'); expect(blocked).toContain('## Findings'); expect(blocked).toContain('review.md in the run workspace'); - expect(stderr).toContain('P1 599 '); + expect(stderr).toContain('so it was cut'); + expect(stderr).not.toContain('P1 599 '); }); it('keeps the time-stop comment under the cap', () => { From d07e85b250435cd566e58ba2610a3596b5d00cbd Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 23:39:32 -0700 Subject: [PATCH 03/10] fix(flows): leave room for the withheld-workflow notice; bound the closing fence Review on #161: - the prepared pull-request body stays FLOW_WITHHELD_NOTICE_RESERVE (2 KB) under FLOW_BODY_LIMIT, so the push step can always say it withheld workflow edits, however full the body is; - relayflow_cap measures its result and, when a closing fence as long as an agent's opening one ran over, keeps less and tries again (three tries, then empty), so the result never exceeds its bound. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 44 +++++++++++++++++++--------- web/lib/test/flow-push-guard.test.ts | 16 +++++++++- web/lib/test/flow-workflows.test.ts | 14 +++++++-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index 3bcb1fae..d5c4377e 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -247,6 +247,13 @@ const REPORT_SECTION_LIMITS = { script: 4000, log: 10000, repair: 4000 } as cons */ const REFERENCE_MARGIN = 64; +/** + * Bytes the prepared pull-request body leaves free under FLOW_BODY_LIMIT, so + * the push step always has room to say it withheld workflow edits + * (FLOW_PUSH_COMMAND), however full the body is. + */ +export const FLOW_WITHHELD_NOTICE_RESERVE = 2048; + /** * Prints the fence that closes a Markdown code block still open at the end of * its input, or nothing: a fence of three or more backticks opens a block, @@ -263,27 +270,35 @@ const OPEN_FENCE_AWK = String.raw`{ if (match($0, /^[ \t]*` + '```' + String.raw * `where` the full text is. A code fence the kept start leaves open is * closed, so whatever follows is not swallowed by it. The text cut is never * printed: a log or review can hold credentials the run journal must not - * show. With less than CAP_RESERVE bytes to keep, the file is emptied. A file - * within `bytes` is left as it is. Always returns 0. + * show. The result never exceeds `bytes`: with less than CAP_RESERVE bytes + * to keep, or no fit after three tries, the file is emptied. A file within + * `bytes` is left as it is. Always returns 0. */ const CAP_RESERVE = 512; const CAP_FUNCTION = 'relayflow_cap() { ' + [ 'cap_size=$(LC_ALL=C wc -c < "$1" | tr -d " ")', 'if [ "$cap_size" -le "$2" ]; then return 0; fi', 'echo "relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4." >&2', - `cap_keep=$(($2 - ${CAP_RESERVE}))`, - 'if [ "$cap_keep" -le 0 ]; then : > "$1"; return 0; fi', `cap_note="_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._"`, // The bytes that start a multibyte character, and those that continue one. "cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]')", - 'if [ "$3" = tail ]; then LC_ALL=C tail -c "$cap_keep" "$1" > "$1.cut"; else LC_ALL=C head -c "$cap_keep" "$1" > "$1.cut"; fi', - 'cap_lines=$(LC_ALL=C tr -cd "\\n" < "$1.cut" | wc -c | tr -d " ")', - 'if [ "$3" = tail ]; then ' + // A closing fence as long as an agent's opening one can outgrow the + // reserve; each further try keeps less by what the last one ran over. + `cap_keep=$(($2 - ${CAP_RESERVE})); cap_tries=0`, + 'while :; do ' + + 'if [ "$cap_keep" -le 0 ] || [ "$cap_tries" -ge 3 ]; then : > "$1.cap"; break; fi; ' + + 'cap_tries=$((cap_tries + 1)); ' + + 'if [ "$3" = tail ]; then LC_ALL=C tail -c "$cap_keep" "$1" > "$1.cut"; else LC_ALL=C head -c "$cap_keep" "$1" > "$1.cut"; fi; ' + + 'cap_lines=$(LC_ALL=C tr -cd "\\n" < "$1.cut" | wc -c | tr -d " "); ' + + 'if [ "$3" = tail ]; then ' + '{ printf \'%s\\n\\n\' "$cap_note"; if [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed 1d "$1.cut"; else LC_ALL=C sed "s/^$cap_cont*//" "$1.cut"; fi; } > "$1.cap"; ' + 'else ' + '{ if [ "$(LC_ALL=C tail -c 1 "$1.cut" | wc -l | tr -d " ")" -eq 1 ]; then cat "$1.cut"; elif [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed \'$d\' "$1.cut"; else LC_ALL=C sed "s/$cap_lead$cap_cont*\\$//" "$1.cut"; echo; fi; } > "$1.cap"; ' + `LC_ALL=C awk '${OPEN_FENCE_AWK}' "$1.cap" > "$1.cut"; cat "$1.cut" >> "$1.cap"; ` - + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi', + + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi; ' + + 'cap_out=$(LC_ALL=C wc -c < "$1.cap" | tr -d " "); ' + + 'if [ "$cap_out" -le "$2" ]; then break; fi; ' + + 'cap_keep=$((cap_keep - cap_out + $2)); done', 'rm -f "$1.cut"; mv -f "$1.cap" "$1"; return 0', ].join('; ') + '; }'; @@ -301,7 +316,8 @@ const CAP_FUNCTION = 'relayflow_cap() { ' + [ * script that ran and the tail of each log on the pull request itself, without * opening the run journal. * - * The body stays within FLOW_BODY_LIMIT (agentrelay.com#160): the script, each + * The body stays within FLOW_BODY_LIMIT, less FLOW_WITHHELD_NOTICE_RESERVE + * for the push step (agentrelay.com#160): the script, each * log and the repair notes are cut to REPORT_SECTION_LIMITS (a log keeps its * end, where the failure is), and summary.md is cut to what the report leaves. * The verdict is never cut. @@ -339,7 +355,7 @@ export const FLOW_CHECK_REPORT_COMMAND = [ // The summary gets what the report leaves, less room for the closing // reference (\`reference\`, set by the caller) that // FLOW_PREPARE_CHANGE_METADATA_COMMAND appends. - `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - REFERENCE_MARGIN} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - $(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " "))) head "summary.md in the run workspace"; fi`, + `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE - REFERENCE_MARGIN} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - $(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " "))) head "summary.md in the run workspace"; fi`, '{ if [ -s "$parts/summary" ]; then cat "$parts/summary"; printf \'\\n\\n\'; fi; cat "$report"; } > .relayflow/pr-body.md', 'rm -rf "$parts"', 'echo "relayflow: wrote the check report to $report." >&2', @@ -685,16 +701,16 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ * flow; grep matches a complete, fixed line so an agent-written matching line * is retained rather than duplicated. * - * A body over FLOW_BODY_LIMIT is cut to fit (agentrelay.com#160), and a - * reference the cut removed is put back at the end: a closing reference is - * never lost to truncation. + * A body over FLOW_BODY_LIMIT, less FLOW_WITHHELD_NOTICE_RESERVE, is cut to + * fit (agentrelay.com#160), and a reference the cut removed is put back at the + * end: a closing reference is never lost to truncation. */ export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [ 'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi', CAP_FUNCTION, 'relayflow_reference() { if [ -n "$reference" ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi; }', 'relayflow_reference', - `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT} ]; then relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, + `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} ]; then relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, 'echo prepared', ].join('; '); diff --git a/web/lib/test/flow-push-guard.test.ts b/web/lib/test/flow-push-guard.test.ts index 036f76ab..71caba57 100644 --- a/web/lib/test/flow-push-guard.test.ts +++ b/web/lib/test/flow-push-guard.test.ts @@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, wr import { execFileSync, spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { FLOW_BODY_LIMIT, FLOW_PUSH_COMMAND, WORKFLOW_FILES_HINT } from '../flow-workflows'; +import { FLOW_BODY_LIMIT, FLOW_PUSH_COMMAND, FLOW_WITHHELD_NOTICE_RESERVE, WORKFLOW_FILES_HINT } from '../flow-workflows'; import { factorySource, DEFAULT_FACTORY, type FactoryDraft } from '../flow-onboarding'; /** @@ -370,6 +370,20 @@ describe('FLOW_PUSH_COMMAND', { timeout: 30_000 }, () => { expect(body).toMatch(/truncated to fit GitHub's limit/); }, 60_000); + // Review on #161: a body prepared as full as it may be still leaves room to + // say the workflow edits were withheld. + it('still says the workflow edits were withheld when the prepared body is as full as it may be', () => { + const { root, base } = setup({ 'README.md': '#\n', '.github/workflows/ci.yml': CI }); + commit(root, 'one', { 'src/x.ts': 'x\n', '.github/workflows/ci.yml': CI_EDITED }); + const filler = FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE - Buffer.byteLength(BODY); + write(root, { '.relayflow/pr-body.md': BODY + 'x'.repeat(filler - 1) + '\n' }); + expect(push(root, base).code).toBe(0); + const body = read(root, '.relayflow/pr-body.md'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + expect(body).toContain('## Workflow changes not applied'); + expect(body).toContain('.github/workflows/ci.yml'); + }, 60_000); + it('bounds the patch in the pull-request body and says where the rest is', () => { const { root, base } = setup({ 'README.md': '#\n', '.github/workflows/ci.yml': CI }); commit(root, 'ci', { '.github/workflows/ci.yml': CI + '# padding\n'.repeat(4000), 'docs/a.md': 'a\n' }); diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index e726cec6..98e1c62c 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -8,7 +8,7 @@ import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_REPORT_COMMAND, FLOW_CHECK_RESOLVE_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_CHECK_SCRIPT, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PREPARE_CHANGE_METADATA_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REPORT_REVIEW_FINDINGS_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND, FLOW_REVIEW_FINDINGS_LIMIT, FLOW_VALIDATE_CHANGE_METADATA_COMMAND, FLOW_FREE_DISK_COMMAND, FLOW_TIME_STOP_COMMAND, FLOW_PUSH_COMMAND, FLOW_DRAFT_CHANGE_COMMAND, - flowCommentChangeCommand, FLOW_BODY_LIMIT, + flowCommentChangeCommand, FLOW_BODY_LIMIT, FLOW_WITHHELD_NOTICE_RESERVE, WORKFLOWS, FLOW_TIME, } from '../flow-workflows'; import { factorySource, type FactoryDraft } from '../flow-onboarding'; @@ -921,7 +921,8 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(prepare.token).toBe('prepared'); expect(verdict).toBe('valid'); expect(chars(body)).toBeLessThanOrEqual(FLOW_BODY_LIMIT); - expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); + // Room is left for the push step's withheld-workflow notice. + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE); expect(FLOW_BODY_LIMIT).toBeLessThan(65536); // Valid UTF-8: no multibyte character was split. expect(body).not.toContain('\uFFFD'); @@ -967,6 +968,15 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(source).toContain('"; reference=" + shellQuote(changeReference) + "; " + checkReport'); }); + it('stays under the cap when the summary opens a fence longer than the reserve', () => { + const fence = '`'.repeat(5000); + const { verdict, body } = publishBody({ 'summary.md': fence + '\n' + longLines('code', 400, 60) }, 'pass', ''); + expect(verdict).toBe('valid'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE); + expect(body.split('\n').filter(line => line === fence)).toHaveLength(2); + expect(body).toContain('Relayflow ran this repository\'s checks'); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From 246fc8d0b60b3e21eee72c0e12129b5215be2ede Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:25:27 -0700 Subject: [PATCH 04/10] fix(flows): close a tilde fence the cut leaves open Review on #161: relayflow_cap recognised only backtick fences, so a summary cut inside a ~~~ block left the report rendering as code. Tilde fences are now tracked too, and a fence closes only with the same character, at least as long. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 7 ++++--- web/lib/test/flow-workflows.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index d5c4377e..0d61de1a 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -256,10 +256,11 @@ export const FLOW_WITHHELD_NOTICE_RESERVE = 2048; /** * Prints the fence that closes a Markdown code block still open at the end of - * its input, or nothing: a fence of three or more backticks opens a block, - * and a bare fence at least as long closes it. + * its input, or nothing: a fence of three or more backticks or tildes opens a + * block, and a bare fence of the same character, at least as long, closes it. */ -const OPEN_FENCE_AWK = String.raw`{ if (match($0, /^[ \t]*` + '```' + String.raw`+/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else { rest = $0; sub(/^[ \t]*` + '`' + String.raw`+[ \t]*$/, "", rest); if (rest == "" && length(f) >= length(open)) open = ""; } } } END { if (open != "") print open }`; +const BACKTICK = '`'; +const OPEN_FENCE_AWK = String.raw`{ if (match($0, /^[ \t]*(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^[ \t]*~+[ \t]*$" : "^[ \t]*` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } } } END { if (open != "") print open }`; /** * `relayflow_cap ` cuts `file` to at most diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index 98e1c62c..ac6640ce 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -977,6 +977,23 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(body).toContain('Relayflow ran this repository\'s checks'); }); + it('closes a tilde fence the summary leaves open, so the report renders outside it', () => { + const { verdict, body } = publishBody({ 'summary.md': '## What changed\n\n~~~~text\n' + longLines('code', 400, 60) + '~~~\n' }, 'pass', ''); + expect(verdict).toBe('valid'); + const lines = body.split('\n'); + const closer = lines.indexOf('~~~~'); + expect(closer).toBeGreaterThan(0); + expect(lines.indexOf('## Checks')).toBeGreaterThan(closer); + expect(lines.filter(line => /^~~~/.test(line))).toHaveLength(2); + }); + + it('does not close a fence with one of the other character', () => { + const root = fixture({ 'summary.md': '```\n~~~\n' + longLines('code', 400, 60) }); + sh(`check=pass; baseline=; ${FLOW_CHECK_REPORT_COMMAND}`, root); + const lines = read(root, '.relayflow/pr-body.md').split('\n'); + expect(lines.filter(line => line === '```')).toHaveLength(2); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From 8d53840f72fc0abc60566999b811b089882d20bc Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:12:30 -0700 Subject: [PATCH 05/10] fix(flows): close a cut HTML comment; refuse a reference over 1 KB Review on #161: - the closer awk also tracks HTML comments outside code blocks, so a summary cut inside cannot hide the report after it; - a closing reference over FLOW_REFERENCE_LIMIT (1,024 bytes) is never appended, and the validation step, now given the reference, refuses it (reference-too-long) before anything is pushed. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 25 ++++++++++++++++++------- web/lib/test/flow-workflows.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index 0d61de1a..74772734 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -247,6 +247,13 @@ const REPORT_SECTION_LIMITS = { script: 4000, log: 10000, repair: 4000 } as cons */ const REFERENCE_MARGIN = 64; +/** + * The longest closing reference line, in bytes, a body may carry. A longer + * one (a runaway ticket URL or identifier) is refused before anything is + * pushed, rather than crowding out the body or overflowing it. + */ +export const FLOW_REFERENCE_LIMIT = 1024; + /** * Bytes the prepared pull-request body leaves free under FLOW_BODY_LIMIT, so * the push step always has room to say it withheld workflow edits @@ -255,12 +262,14 @@ const REFERENCE_MARGIN = 64; export const FLOW_WITHHELD_NOTICE_RESERVE = 2048; /** - * Prints the fence that closes a Markdown code block still open at the end of - * its input, or nothing: a fence of three or more backticks or tildes opens a - * block, and a bare fence of the same character, at least as long, closes it. + * Prints what closes a Markdown code block or an HTML comment still open at + * the end of its input, or nothing, so a truncated agent text cannot hide + * the report appended after it. A fence of three or more backticks or tildes + * opens a block, and a bare fence of the same character, at least as long, + * closes it; outside a block, `` closes it. */ const BACKTICK = '`'; -const OPEN_FENCE_AWK = String.raw`{ if (match($0, /^[ \t]*(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^[ \t]*~+[ \t]*$" : "^[ \t]*` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } } } END { if (open != "") print open }`; +const OPEN_FENCE_AWK = String.raw`{ line = $0; if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3); } else if (match($0, /^[ \t]*(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^[ \t]*~+[ \t]*$" : "^[ \t]*` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } next; } if (open != "") next; while ((k = index(line, ""); if (k == 0) { incomment = 1; break; } line = substr(line, k + 3); } } END { if (incomment) print "-->"; if (open != "") print open }`; /** * `relayflow_cap ` cuts `file` to at most @@ -709,7 +718,7 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [ 'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi', CAP_FUNCTION, - 'relayflow_reference() { if [ -n "$reference" ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi; }', + `relayflow_reference() { if [ -n "$reference" ] && [ "$(printf %s "$reference" | LC_ALL=C wc -c | tr -d " ")" -le ${FLOW_REFERENCE_LIMIT} ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi; }`, 'relayflow_reference', `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} ]; then relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, 'echo prepared', @@ -719,10 +728,12 @@ export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [ * Final fail-closed contract immediately before a branch is pushed or a * change request is opened. It deliberately exits zero with one verdict: an * invalid verdict is handled by the flow instead of being retried as a flaky - * command. GitHub inputs must have exactly one normalized closing line. + * command. GitHub inputs must have exactly one normalized closing line, and + * no `reference` may exceed FLOW_REFERENCE_LIMIT. */ export const FLOW_VALIDATE_CHANGE_METADATA_COMMAND = [ 'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body', + `elif [ "$(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " ")" -gt ${FLOW_REFERENCE_LIMIT} ]; then echo reference-too-long`, 'elif [ -z "$title" ]; then echo empty-title', 'elif ! printf "%s\\n" "$title_length" | grep -Eq "^[0-9]+$"; then echo malformed-title-length', 'elif [ "$title_length" -gt 240 ]; then echo title-too-long', @@ -1123,7 +1134,7 @@ export function workflowCode(workflow: WorkflowId, agents: ReturnType { - const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(3000); + // As long as a reference may be: well past the old fixed 1 KB reserve's margin. + const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(FLOW_REFERENCE_LIMIT - 36); const { verdict, body } = publishBody({ 'summary.md': longLines('summary', 400, 60), '.relayflow/check.log': longLines('FAIL', 80, 400) }, 'fail', 'pass', reference, 'linear'); expect(verdict).toBe('valid'); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); @@ -994,6 +995,28 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(lines.filter(line => line === '```')).toHaveLength(2); }); + it('closes an HTML comment the cut leaves open, so the report is not hidden in it', () => { + const { verdict, body } = publishBody({ 'summary.md': '## What changed\n\n\n\n' }, 'pass', ''); + expect(verdict).toBe('valid'); + const lines = body.split('\n'); + const closer = lines.lastIndexOf('-->'); + expect(closer).toBeGreaterThan(0); + expect(lines.indexOf('## Checks')).toBeGreaterThan(closer); + // A comment opened inside a code block is code, not a comment. + const fenced = publishBody({ 'summary.md': '```\n'); + }); + + it('refuses a reference longer than FLOW_REFERENCE_LIMIT before anything is pushed', () => { + const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(FLOW_REFERENCE_LIMIT); + const { prepare, body } = publishBody({ 'summary.md': 'Fixed it.\n' }, 'pass', '', reference, 'linear'); + expect(prepare.token).toBe('prepared'); + expect(body).not.toContain(reference); + expect(sh(`reference='${reference}'; title='Fix login'; title_length=9; source='linear'; identifier='ENG-1'; ${FLOW_VALIDATE_CHANGE_METADATA_COMMAND}`, fixture({ '.relayflow/pr-body.md': 'Fixed it.\n' })).token).toBe('reference-too-long'); + const source = factorySource({ version: 4, sources: ['github'], sourceSettings: { github: { repository: 'acme/app', labels: 'ready' } }, agents: ['claude', 'codex'], otherAgent: '', task: 'Add a test', workflow: 'simple', step: 3 }, 'cloud'); + expect(source).toContain('f.run("reference=" + shellQuote(changeReference) + "; title=" + shellQuote(changeTitle)'); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From 494079b52c836a2ad0a0dc9de86ba08e7d7c1230 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 02:22:24 -0700 Subject: [PATCH 06/10] fix(flows): only fences indented up to three spaces; test the reference stop end to end Review on #161: - the closer awk treats a fence as one only when indented at most three spaces (GFM); four spaces or a tab is an indented code line; - the generated flow is run with the real validation command: a runaway closing reference stops at needs_human before any push or pull request, and an ordinary one publishes; - the long-reference tests derive their boundary from the prefix. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 5 +++-- web/lib/test/flow-onboarding.test.ts | 31 ++++++++++++++++++++++++---- web/lib/test/flow-workflows.test.ts | 18 ++++++++++++++-- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index 74772734..ff6356c8 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -265,11 +265,12 @@ export const FLOW_WITHHELD_NOTICE_RESERVE = 2048; * Prints what closes a Markdown code block or an HTML comment still open at * the end of its input, or nothing, so a truncated agent text cannot hide * the report appended after it. A fence of three or more backticks or tildes - * opens a block, and a bare fence of the same character, at least as long, + * indented at most three spaces opens a block (more, or a tab, is an indented + * code line), and a bare fence of the same character, at least as long, * closes it; outside a block, `` closes it. */ const BACKTICK = '`'; -const OPEN_FENCE_AWK = String.raw`{ line = $0; if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3); } else if (match($0, /^[ \t]*(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^[ \t]*/, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^[ \t]*~+[ \t]*$" : "^[ \t]*` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } next; } if (open != "") next; while ((k = index(line, ""); if (k == 0) { incomment = 1; break; } line = substr(line, k + 3); } } END { if (incomment) print "-->"; if (open != "") print open }`; +const OPEN_FENCE_AWK = String.raw`{ line = $0; if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3); } else if (match($0, /^( | | )?(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^ */, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^( | | )?~+[ \t]*$" : "^( | | )?` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } next; } if (open != "") next; while ((k = index(line, ""); if (k == 0) { incomment = 1; break; } line = substr(line, k + 3); } } END { if (incomment) print "-->"; if (open != "") print open }`; /** * `relayflow_cap ` cuts `file` to at most diff --git a/web/lib/test/flow-onboarding.test.ts b/web/lib/test/flow-onboarding.test.ts index 2d31c0d4..f7aba42b 100644 --- a/web/lib/test/flow-onboarding.test.ts +++ b/web/lib/test/flow-onboarding.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import ts from 'typescript'; import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_FREE_DISK_COMMAND, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PREPARE_CHANGE_METADATA_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REPORT_REVIEW_FINDINGS_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND, FLOW_VALIDATE_CHANGE_METADATA_COMMAND } from '../flow-workflows'; import { cloudBlockedReason, cloudConnectionsHref, DEFAULT_FACTORY, factorySource, isMarkdownOnly, MARKDOWN_ONLY_CLOUD_NOTE, readFactoryDraft, canContinue, primaryAgent, onboardingPath, accessibleOnboardingStep, type FactoryDraft } from '../flow-onboarding'; import { localInput } from '../flow-local'; -import { FLOW_PUSH_COMMAND } from '../flow-workflows'; +import { FLOW_PUSH_COMMAND, FLOW_REFERENCE_LIMIT } from '../flow-workflows'; // Every push goes through the workflow-file guard (run 065fd98f). const PUSH = 'base=abc123; ' + FLOW_PUSH_COMMAND + ' --set-upstream origin HEAD'; @@ -25,9 +29,9 @@ const withoutComments = (source: string) => source.split('\n').filter(line => !l * each run of the repository's checks reports, in order (pass once the list is * used up), and `baseline` what the base commit's check reports. Three * commands are built as `base=; ...`, so the mock matches each by the - * command it ends with. + * command it ends with. `validate` answers the metadata validation step. */ -async function runFactory(clean: boolean[], _approved = true, issue = matchingIssue, draft = completed, publish = 'publish', checks: string[] = [], baseline = 'pass', edit = (source: string) => source) { +async function runFactory(clean: boolean[], _approved = true, issue = matchingIssue, draft = completed, publish = 'publish', checks: string[] = [], baseline = 'pass', edit = (source: string) => source, validate = (_command: string) => 'valid') { const calls: string[] = []; const errors: string[] = []; let finish = ''; @@ -48,7 +52,7 @@ async function runFactory(clean: boolean[], _approved = true, issue = matchingIs if (command === FLOW_CHECK_RUN_COMMAND) return checks[checkIndex++] ?? 'pass'; if (command.endsWith(FLOW_BASE_CHECK_COMMAND)) return baseline; if (command.endsWith(FLOW_PUBLISH_CHECK_COMMAND)) return publish; - if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return 'valid'; + if (command.endsWith(FLOW_VALIDATE_CHANGE_METADATA_COMMAND)) return validate(command); if (command === FLOW_REPORT_REVIEW_FINDINGS_COMMAND) return 'relayflow report-review-findings: review.clean absent; remaining findings from review.md:\nOne P2 remains.\n'; return command.startsWith('test -f') ? (clean[index++] ? 'yes' : 'no') : command.startsWith('mktemp') ? '/tmp/relay-prototypes.test' : command === 'git rev-parse HEAD' ? 'abc123' : ''; }, @@ -345,6 +349,25 @@ describe('software factory onboarding', () => { expect(errors.join('\n')).toContain('no commits'); }); + it('stops before any push or pull request when the closing reference is too long (agentrelay.com#160)', async () => { + // The real validation command decides, as the generated flow calls it. + const root = mkdtempSync(path.join(tmpdir(), 'flow-reference-')); + try { + mkdirSync(path.join(root, '.relayflow')); + writeFileSync(path.join(root, '.relayflow/pr-body.md'), 'Fixed it.\n\nFixes #507\n'); + const real = (command: string) => spawnSync('/bin/sh', ['-c', command], { cwd: root, encoding: 'utf8' }).stdout.trim(); + const runaway = { ...matchingIssue, identifier: '#' + '1'.repeat(FLOW_REFERENCE_LIMIT), url: '' }; + const { calls, finish, errors } = await runFactory([true, true], true, runaway, completed, 'publish', [], 'pass', source => source, real); + expect(calls.some(call => call.includes(FLOW_PUSH_COMMAND))).toBe(false); + expect(calls.some(call => call.startsWith(FLOW_OPEN_CHANGE_COMMAND))).toBe(false); + expect(finish).toBe('needs_human'); + expect(errors.join('\n')).toContain('invalid pull-request metadata (reference-too-long)'); + // The same harness with an ordinary reference publishes. + const ordinary = await runFactory([true, true], true, matchingIssue, completed, 'publish', [], 'pass', source => source, real); + expect(ordinary.calls).toContain(PUSH); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + it('pushes the work but opens no pull request when no summary.md was written', async () => { // Commits exist, so the work is real and worth pushing; the pull request // body is what is missing, and `gh pr create --body-file summary.md` would diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index c3641793..8bce733f 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -954,7 +954,9 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. it('keeps the verdict and the whole reference when the reference is long', () => { // As long as a reference may be: well past the old fixed 1 KB reserve's margin. - const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(FLOW_REFERENCE_LIMIT - 36); + const prefix = 'Ticket: https://tickets.example.com/'; + const reference = prefix + 'a'.repeat(FLOW_REFERENCE_LIMIT - prefix.length); + expect(Buffer.byteLength(reference)).toBe(FLOW_REFERENCE_LIMIT); const { verdict, body } = publishBody({ 'summary.md': longLines('summary', 400, 60), '.relayflow/check.log': longLines('FAIL', 80, 400) }, 'fail', 'pass', reference, 'linear'); expect(verdict).toBe('valid'); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); @@ -1008,7 +1010,9 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. }); it('refuses a reference longer than FLOW_REFERENCE_LIMIT before anything is pushed', () => { - const reference = 'Ticket: https://tickets.example.com/' + 'a'.repeat(FLOW_REFERENCE_LIMIT); + const prefix = 'Ticket: https://tickets.example.com/'; + const reference = prefix + 'a'.repeat(FLOW_REFERENCE_LIMIT - prefix.length + 1); + expect(Buffer.byteLength(reference)).toBe(FLOW_REFERENCE_LIMIT + 1); const { prepare, body } = publishBody({ 'summary.md': 'Fixed it.\n' }, 'pass', '', reference, 'linear'); expect(prepare.token).toBe('prepared'); expect(body).not.toContain(reference); @@ -1017,6 +1021,16 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(source).toContain('f.run("reference=" + shellQuote(changeReference) + "; title=" + shellQuote(changeTitle)'); }); + it('treats only fences indented at most three spaces as fences', () => { + // Four spaces (or a tab) make an indented code line, not a fence: no closer. + for (const indent of [' ', '\t']) { + const { body } = publishBody({ 'summary.md': '## What changed\n\n' + indent + '```\n' + longLines('code', 400, 60) }, 'pass', ''); + expect(body.split('\n')).not.toContain('```'); + } + const { body } = publishBody({ 'summary.md': '## What changed\n\n ```\n' + longLines('code', 400, 60) }, 'pass', ''); + expect(body.split('\n')).toContain('```'); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From 9bc1717e44e3261c53f25e3d62051b99c8728ea8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:10:57 -0700 Subject: [PATCH 07/10] fix(flows): ignore comment markers in inline code and invalid backtick fences Review on #161: the closer awk now skips `` closes it. + * code line; a backtick fence whose info string holds a backtick is no + * fence), and a bare fence of the same character, at least as long, closes + * it. Outside a block and outside inline code, `` closes it. */ -const BACKTICK = '`'; -const OPEN_FENCE_AWK = String.raw`{ line = $0; if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3); } else if (match($0, /^( | | )?(` + BACKTICK.repeat(3) + String.raw`+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^ */, "", f); if (open == "") open = f; else if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open)) { rest = $0; sub(substr(f, 1, 1) == "~" ? "^( | | )?~+[ \t]*$" : "^( | | )?` + BACKTICK + String.raw`+[ \t]*$", "", rest); if (rest == "") open = ""; } next; } if (open != "") next; while ((k = index(line, ""); if (k == 0) { incomment = 1; break; } line = substr(line, k + 3); } } END { if (incomment) print "-->"; if (open != "") print open }`; +const BT = '`'; +const OPEN_FENCE_AWK = [ + '{ line = $0', + 'if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3) } ' + + `else if (match($0, /^( | | )?(${BT.repeat(3)}+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^ */, "", f); tail = substr($0, RSTART + RLENGTH); ` + + `if (open == "") { if (substr(f, 1, 1) == "~" || tail !~ /${BT}/) { open = f; next } } ` + + 'else { if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open) && tail ~ /^[ \t]*$/) open = ""; next } }', + 'if (open != "") next', + `gsub(/${BT}+[^${BT}]*${BT}+/, "", line)`, + 'while ((k = index(line, ""); if (k == 0) { incomment = 1; break } line = substr(line, k + 3) } }', + 'END { if (incomment) print "-->"; if (open != "") print open }', +].join('; '); /** * `relayflow_cap ` cuts `file` to at most diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index 8bce733f..14a23255 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -1031,6 +1031,14 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(body.split('\n')).toContain('```'); }); + it('ignores a comment marker in inline code and a backtick fence with a backtick in its info string', () => { + const inline = publishBody({ 'summary.md': 'Strips `'); + const info = publishBody({ 'summary.md': '```not `a` fence\n' + longLines('code', 400, 60) }, 'pass', '').body.split('\n'); + expect(info).not.toContain('```'); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From f0ba544be57b7e53c24ec08d8c1d54cf27e2b19a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:02:02 -0700 Subject: [PATCH 08/10] fix(flows): show a cut text as plain text in a fence no run can close Review on #161 kept finding Markdown constructs the closer awk could not track (uneven inline-code delimiters, multi-line code spans, CRLF, open
). Parsing Markdown in awk does not converge, so relayflow_cap no longer tries: the kept start of a cut is shown in a code block whose fence is longer than any backtick run in it. Nothing inside can be interpreted, so no construct the cut leaves open can hide the check verdict or the closing reference after it. The tests render each body with micromark (GFM, raw HTML allowed, as GitHub does) and assert the report heading and Fixes line are visible for every construct the review raised. micromark and micromark-extension-gfm become web devDependencies at the versions already in the lockfile. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- package-lock.json | 2 + web/lib/flow-workflows.ts | 38 ++++-------- web/lib/test/flow-workflows.test.ts | 91 +++++++++++++---------------- web/package.json | 2 + 4 files changed, 56 insertions(+), 77 deletions(-) diff --git a/package-lock.json b/package-lock.json index aeb04915..46153f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16683,6 +16683,8 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "esbuild": "0.25.4", + "micromark": "^4.0.2", + "micromark-extension-gfm": "^3.0.0", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.91.0" diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index f216520c..c98f2af6 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -261,28 +261,8 @@ export const FLOW_REFERENCE_LIMIT = 1024; */ export const FLOW_WITHHELD_NOTICE_RESERVE = 2048; -/** - * Prints what closes a Markdown code block or an HTML comment still open at - * the end of its input, or nothing, so a truncated agent text cannot hide - * the report appended after it. A fence of three or more backticks or tildes - * indented at most three spaces opens a block (more, or a tab, is an indented - * code line; a backtick fence whose info string holds a backtick is no - * fence), and a bare fence of the same character, at least as long, closes - * it. Outside a block and outside inline code, `` closes it. - */ -const BT = '`'; -const OPEN_FENCE_AWK = [ - '{ line = $0', - 'if (incomment) { k = index(line, "-->"); if (k == 0) next; incomment = 0; line = substr(line, k + 3) } ' - + `else if (match($0, /^( | | )?(${BT.repeat(3)}+|~~~+)/)) { f = substr($0, RSTART, RLENGTH); sub(/^ */, "", f); tail = substr($0, RSTART + RLENGTH); ` - + `if (open == "") { if (substr(f, 1, 1) == "~" || tail !~ /${BT}/) { open = f; next } } ` - + 'else { if (substr(f, 1, 1) == substr(open, 1, 1) && length(f) >= length(open) && tail ~ /^[ \t]*$/) open = ""; next } }', - 'if (open != "") next', - `gsub(/${BT}+[^${BT}]*${BT}+/, "", line)`, - 'while ((k = index(line, ""); if (k == 0) { incomment = 1; break } line = substr(line, k + 3) } }', - 'END { if (incomment) print "-->"; if (open != "") print open }', -].join('; '); +/** Prints the length of the longest run of backticks in its input (0 for none). */ +const LONGEST_BACKTICK_RUN_AWK = '{ s = $0; while (match(s, /`+/)) { if (RLENGTH > m) m = RLENGTH; s = substr(s, RSTART + RLENGTH) } } END { print m + 0 }'; /** * `relayflow_cap ` cuts `file` to at most @@ -290,8 +270,11 @@ const OPEN_FENCE_AWK = [ * boundary, and inside a line only when the kept part has no line break at * all, then at a character boundary, so no multibyte character is split and a * one-line file keeps what fits. The cut is marked in the file, naming - * `where` the full text is. A code fence the kept start leaves open is - * closed, so whatever follows is not swallowed by it. The text cut is never + * `where` the full text is. The kept start of a cut is shown as plain text in + * a code block whose fence is longer than any run of backticks in it: a cut + * can leave any Markdown or HTML construct open (a fence, a comment, a + * `
`), and none of them can then hide what follows, such as the + * check verdict and the closing reference. The text cut is never * printed: a log or review can hold credentials the run journal must not * show. The result never exceeds `bytes`: with less than CAP_RESERVE bytes * to keep, or no fit after three tries, the file is emptied. A file within @@ -305,7 +288,7 @@ const CAP_FUNCTION = 'relayflow_cap() { ' + [ `cap_note="_…truncated to fit GitHub's limit: $cap_size bytes were cut to under $2. The full text is $4._"`, // The bytes that start a multibyte character, and those that continue one. "cap_lead=$(printf '[\\300-\\377]'); cap_cont=$(printf '[\\200-\\277]')", - // A closing fence as long as an agent's opening one can outgrow the + // A fence as long as an agent's longest backtick run can outgrow the // reserve; each further try keeps less by what the last one ran over. `cap_keep=$(($2 - ${CAP_RESERVE})); cap_tries=0`, 'while :; do ' @@ -317,8 +300,9 @@ const CAP_FUNCTION = 'relayflow_cap() { ' + [ + '{ printf \'%s\\n\\n\' "$cap_note"; if [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed 1d "$1.cut"; else LC_ALL=C sed "s/^$cap_cont*//" "$1.cut"; fi; } > "$1.cap"; ' + 'else ' + '{ if [ "$(LC_ALL=C tail -c 1 "$1.cut" | wc -l | tr -d " ")" -eq 1 ]; then cat "$1.cut"; elif [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed \'$d\' "$1.cut"; else LC_ALL=C sed "s/$cap_lead$cap_cont*\\$//" "$1.cut"; echo; fi; } > "$1.cap"; ' - + `LC_ALL=C awk '${OPEN_FENCE_AWK}' "$1.cap" > "$1.cut"; cat "$1.cut" >> "$1.cap"; ` - + 'printf \'\\n%s\\n\' "$cap_note" >> "$1.cap"; fi; ' + + `cap_run=$(LC_ALL=C awk '${LONGEST_BACKTICK_RUN_AWK}' "$1.cap"); if [ "\${cap_run:-0}" -lt 3 ]; then cap_run=2; fi; ` + + 'cap_fence=$(printf "%$((cap_run + 1))s" "" | tr " " \'`\'); ' + + '{ printf "%stext\\n" "$cap_fence"; cat "$1.cap"; printf "%s\\n\\n%s\\n" "$cap_fence" "$cap_note"; } > "$1.cut"; mv -f "$1.cut" "$1.cap"; fi; ' + 'cap_out=$(LC_ALL=C wc -c < "$1.cap" | tr -d " "); ' + 'if [ "$cap_out" -le "$2" ]; then break; fi; ' + 'cap_keep=$((cap_keep - cap_out + $2)); done', diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index 14a23255..b5bb0273 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -4,6 +4,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, import { tmpdir } from 'node:os'; import path from 'node:path'; import ts from 'typescript'; +import { micromark } from 'micromark'; +import { gfm, gfmHtml } from 'micromark-extension-gfm'; import { FLOW_BASE_CHECK_COMMAND, FLOW_CHECK_REPORT_COMMAND, FLOW_CHECK_RESOLVE_COMMAND, FLOW_CHECK_RUN_COMMAND, FLOW_CHECK_SCRIPT, FLOW_DROP_WORKING_FILES_COMMAND, FLOW_EXCLUDE_WORKING_FILES_COMMAND, FLOW_OPEN_CHANGE_COMMAND, FLOW_PREPARE_CHANGE_METADATA_COMMAND, FLOW_PUBLISH_CHECK_COMMAND, FLOW_REPORT_REVIEW_FINDINGS_COMMAND, FLOW_REVIEW_BLOCKED_COMMAND, FLOW_REVIEW_FINDINGS_LIMIT, @@ -895,7 +897,19 @@ describe('change metadata contract', () => { */ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay.com#160)', () => { const chars = (text: string) => Array.from(text).length; - const fences = (text: string) => text.split('\n').filter(line => /^\s*```/.test(line)).length; + /** + * Renders a body as GitHub does (GFM, raw HTML allowed) and returns what a + * reader sees before the check report, or null when the report is hidden: + * inside a code block, an HTML comment, or a collapsed
. + */ + const beforeReport = (body: string) => { + const html = micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()], allowDangerousHtml: true }) + .replace(/|$)/g, ''); + const at = html.indexOf('

Checks

'); + if (at < 0) return null; + const before = html.slice(0, at); + return (before.match(/
/g) ?? []).length ? before : null; + }; const longLines = (prefix: string, count: number, width: number) => Array.from({ length: count }, (_, i) => `${prefix} ${i} ${'修'.repeat(width)}`).join('\n') + '\n'; @@ -926,7 +940,7 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(FLOW_BODY_LIMIT).toBeLessThan(65536); // Valid UTF-8: no multibyte character was split. expect(body).not.toContain('\uFFFD'); - expect(body.startsWith('## What changed\n\nFixed the login bug.')).toBe(true); + expect(body.startsWith('````text\n## What changed\n\nFixed the login bug.')).toBe(true); expect(body).toContain('**The checks fail on the base commit too**'); expect(body.split('\n').filter(line => line === 'Fixes #160')).toHaveLength(1); // The newest output is what a reviewer needs, so the logs keep their tail. @@ -935,8 +949,9 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(body).toMatch(/truncated to fit GitHub's limit/); expect(body).toContain('summary.md in the run workspace'); expect(body).toContain('.relayflow/check.log in the run workspace'); - // The summary's open code fence is closed, so the report is not swallowed by it. - expect(fences(body) % 2).toBe(0); + // The summary's open code fence cannot swallow the report or the reference. + expect(beforeReport(body)).toContain('Fixed the login bug.'); + expect(micromark(body)).toContain('

Fixes #160

'); // The step says what it cut, but never prints the text: a log can hold credentials. expect(body).not.toContain('FAIL src/login.test.ts 120 '); expect(report.stderr).toContain('so it was cut; the full text is .relayflow/check.log in the run workspace'); @@ -948,7 +963,7 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(verdict).toBe('valid'); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT); expect(body).not.toContain('\uFFFD'); - expect(body.split('\n')[0]).toMatch(/^修{15000,}$/); + expect(body.split('\n')[1]).toMatch(/^修{15000,}$/); expect(body).toMatch(/truncated to fit GitHub's limit/); }); @@ -976,37 +991,31 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. const { verdict, body } = publishBody({ 'summary.md': fence + '\n' + longLines('code', 400, 60) }, 'pass', ''); expect(verdict).toBe('valid'); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE); - expect(body.split('\n').filter(line => line === fence)).toHaveLength(2); + expect(beforeReport(body)).not.toBeNull(); expect(body).toContain('Relayflow ran this repository\'s checks'); }); - it('closes a tilde fence the summary leaves open, so the report renders outside it', () => { - const { verdict, body } = publishBody({ 'summary.md': '## What changed\n\n~~~~text\n' + longLines('code', 400, 60) + '~~~\n' }, 'pass', ''); - expect(verdict).toBe('valid'); - const lines = body.split('\n'); - const closer = lines.indexOf('~~~~'); - expect(closer).toBeGreaterThan(0); - expect(lines.indexOf('## Checks')).toBeGreaterThan(closer); - expect(lines.filter(line => /^~~~/.test(line))).toHaveLength(2); - }); - - it('does not close a fence with one of the other character', () => { - const root = fixture({ 'summary.md': '```\n~~~\n' + longLines('code', 400, 60) }); - sh(`check=pass; baseline=; ${FLOW_CHECK_REPORT_COMMAND}`, root); - const lines = read(root, '.relayflow/pr-body.md').split('\n'); - expect(lines.filter(line => line === '```')).toHaveLength(2); - }); - - it('closes an HTML comment the cut leaves open, so the report is not hidden in it', () => { - const { verdict, body } = publishBody({ 'summary.md': '## What changed\n\n\n\n' }, 'pass', ''); + // Review on #161: a cut can leave any construct open. The kept text is + // shown as plain text, so none of them can hide the report or Fixes. + it.each([ + ['a backtick fence', '```ts\n'], + ['a tilde fence', '~~~~text\n'], + ['an indented fence', ' ```\n'], + ['an HTML comment', ''); - expect(closer).toBeGreaterThan(0); - expect(lines.indexOf('## Checks')).toBeGreaterThan(closer); - // A comment opened inside a code block is code, not a comment. - const fenced = publishBody({ 'summary.md': '```\n'); + expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE); + expect(beforeReport(body)).toContain('## What changed'); + const html = micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()] }); + expect(html).toContain('

Fixes #160

'); + expect(html).toMatch(/…truncated to fit GitHub's limit/); }); it('refuses a reference longer than FLOW_REFERENCE_LIMIT before anything is pushed', () => { @@ -1021,24 +1030,6 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(source).toContain('f.run("reference=" + shellQuote(changeReference) + "; title=" + shellQuote(changeTitle)'); }); - it('treats only fences indented at most three spaces as fences', () => { - // Four spaces (or a tab) make an indented code line, not a fence: no closer. - for (const indent of [' ', '\t']) { - const { body } = publishBody({ 'summary.md': '## What changed\n\n' + indent + '```\n' + longLines('code', 400, 60) }, 'pass', ''); - expect(body.split('\n')).not.toContain('```'); - } - const { body } = publishBody({ 'summary.md': '## What changed\n\n ```\n' + longLines('code', 400, 60) }, 'pass', ''); - expect(body.split('\n')).toContain('```'); - }); - - it('ignores a comment marker in inline code and a backtick fence with a backtick in its info string', () => { - const inline = publishBody({ 'summary.md': 'Strips `'); - const info = publishBody({ 'summary.md': '```not `a` fence\n' + longLines('code', 400, 60) }, 'pass', '').body.split('\n'); - expect(info).not.toContain('```'); - }); - it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); diff --git a/web/package.json b/web/package.json index d0ddf03c..72027a3f 100644 --- a/web/package.json +++ b/web/package.json @@ -42,6 +42,8 @@ "@types/node": "^22.19.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "micromark": "^4.0.2", + "micromark-extension-gfm": "^3.0.0", "typescript": "^5.9.3", "vitest": "^4.1.0", "wrangler": "^4.91.0", From 22d74d8f5436c062fa21e0672173d842ae13fff0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:49:43 -0700 Subject: [PATCH 09/10] fix(flows): keep the closing reference out of a cut, and fence report blocks to fit Review on #161: - a cut summary (and the last-resort cap of a whole body) is shown as code, where a closing reference does nothing, so the reference is taken out first and appended after the report, exactly once; - the report's script and log blocks use relayflow_block, a fence longer than any backtick run inside, so a cut script's own fence, or a fence in check output, can no longer end the block early; - the tests render with raw HTML allowed throughout, as GitHub does. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/flow-workflows.ts | 37 ++++++++++++++++++++--------- web/lib/test/flow-workflows.test.ts | 30 ++++++++++++++++++++--- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/web/lib/flow-workflows.ts b/web/lib/flow-workflows.ts index c98f2af6..0cb0fd0d 100644 --- a/web/lib/flow-workflows.ts +++ b/web/lib/flow-workflows.ts @@ -281,7 +281,18 @@ const LONGEST_BACKTICK_RUN_AWK = '{ s = $0; while (match(s, /`+/)) { if (RLENGTH * `bytes` is left as it is. Always returns 0. */ const CAP_RESERVE = 512; -const CAP_FUNCTION = 'relayflow_cap() { ' + [ + +/** + * `relayflow_block ` prints `file` as a fenced code block whose + * fence is longer than any run of backticks in it, so nothing inside, not + * even another fence, can end the block early. + */ +const BLOCK_FUNCTION = 'relayflow_block() { ' + + `block_run=$(LC_ALL=C awk '${LONGEST_BACKTICK_RUN_AWK}' "$1"); if [ "\${block_run:-0}" -lt 3 ]; then block_run=2; fi; ` + + 'block_fence=$(printf "%$((block_run + 1))s" "" | tr " " \'`\'); ' + + 'printf "%s%s\\n" "$block_fence" "$2"; cat "$1"; if [ -s "$1" ] && [ "$(LC_ALL=C tail -c 1 "$1" | wc -l | tr -d " ")" -eq 0 ]; then echo; fi; printf "%s\\n" "$block_fence"; }'; + +const CAP_FUNCTION = BLOCK_FUNCTION + '; relayflow_cap() { ' + [ 'cap_size=$(LC_ALL=C wc -c < "$1" | tr -d " ")', 'if [ "$cap_size" -le "$2" ]; then return 0; fi', 'echo "relayflow: $1 is $cap_size bytes, more than the $2 it may hold, so it was cut; the full text is $4." >&2', @@ -300,9 +311,7 @@ const CAP_FUNCTION = 'relayflow_cap() { ' + [ + '{ printf \'%s\\n\\n\' "$cap_note"; if [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed 1d "$1.cut"; else LC_ALL=C sed "s/^$cap_cont*//" "$1.cut"; fi; } > "$1.cap"; ' + 'else ' + '{ if [ "$(LC_ALL=C tail -c 1 "$1.cut" | wc -l | tr -d " ")" -eq 1 ]; then cat "$1.cut"; elif [ "$cap_lines" -gt 0 ]; then LC_ALL=C sed \'$d\' "$1.cut"; else LC_ALL=C sed "s/$cap_lead$cap_cont*\\$//" "$1.cut"; echo; fi; } > "$1.cap"; ' - + `cap_run=$(LC_ALL=C awk '${LONGEST_BACKTICK_RUN_AWK}' "$1.cap"); if [ "\${cap_run:-0}" -lt 3 ]; then cap_run=2; fi; ` - + 'cap_fence=$(printf "%$((cap_run + 1))s" "" | tr " " \'`\'); ' - + '{ printf "%stext\\n" "$cap_fence"; cat "$1.cap"; printf "%s\\n\\n%s\\n" "$cap_fence" "$cap_note"; } > "$1.cut"; mv -f "$1.cut" "$1.cap"; fi; ' + + '{ relayflow_block "$1.cap" text; printf "\\n%s\\n" "$cap_note"; } > "$1.cut"; mv -f "$1.cut" "$1.cap"; fi; ' + 'cap_out=$(LC_ALL=C wc -c < "$1.cap" | tr -d " "); ' + 'if [ "$cap_out" -le "$2" ]; then break; fi; ' + 'cap_keep=$((cap_keep - cap_out + $2)); done', @@ -354,15 +363,20 @@ export const FLOW_CHECK_REPORT_COMMAND = [ + ` *) printf '%s\\n' "**The checks failed**, and the base commit could not be checked for comparison, so it is not known whether this change caused them. This pull request is a draft until someone looks." ;;` + ' esac ;;' + ' esac' - + `; if [ -s "$parts/script" ]; then printf '\\n
What ran (${FLOW_CHECK_SCRIPT})\\n\\n\`\`\`sh\\n'; cat "$parts/script"; printf '\`\`\`\\n
\\n'; fi` - + `; if [ "$check" != pass ] && [ "$check" != none ] && [ "$check" != skipped ] && [ -s "$parts/check" ]; then printf '\\n
Output on this branch (last 80 lines)\\n\\n\`\`\`\\n'; cat "$parts/check"; printf '\`\`\`\\n
\\n'; fi` - + `; if [ "$baseline" = fail ] || [ "$baseline" = timeout ] || [ "$baseline" = unknown ]; then if [ -s "$parts/base" ]; then printf '\\n
Output on the base commit (last 80 lines)\\n\\n\`\`\`\\n'; cat "$parts/base"; printf '\`\`\`\\n
\\n'; fi; fi` + + `; if [ -s "$parts/script" ]; then printf '\\n
What ran (${FLOW_CHECK_SCRIPT})\\n\\n'; relayflow_block "$parts/script" sh; printf '
\\n'; fi` + + `; if [ "$check" != pass ] && [ "$check" != none ] && [ "$check" != skipped ] && [ -s "$parts/check" ]; then printf '\\n
Output on this branch (last 80 lines)\\n\\n'; relayflow_block "$parts/check" ''; printf '
\\n'; fi` + + `; if [ "$baseline" = fail ] || [ "$baseline" = timeout ] || [ "$baseline" = unknown ]; then if [ -s "$parts/base" ]; then printf '\\n
Output on the base commit (last 80 lines)\\n\\n'; relayflow_block "$parts/base" ''; printf '
\\n'; fi; fi` + `; if [ -s "$parts/repair" ]; then printf '\\n### What the repair agent found\\n\\n'; cat "$parts/repair"; fi` + '; } > "$report"', // The summary gets what the report leaves, less room for the closing // reference (\`reference\`, set by the caller) that // FLOW_PREPARE_CHANGE_METADATA_COMMAND appends. - `if [ -s summary.md ]; then cp summary.md "$parts/summary"; relayflow_cap "$parts/summary" $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE - REFERENCE_MARGIN} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - $(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " "))) head "summary.md in the run workspace"; fi`, + // A summary that is cut is shown as code, where a closing reference does + // nothing, so its own copy of the reference is dropped first and the next + // step appends it after the report. + `if [ -s summary.md ]; then room=$((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE - REFERENCE_MARGIN} - $(LC_ALL=C wc -c < "$report" | tr -d " ") - $(printf %s "\${reference:-}" | LC_ALL=C wc -c | tr -d " "))); ` + + `if [ -n "\${reference:-}" ] && [ "$(LC_ALL=C wc -c < summary.md | tr -d " ")" -gt "$room" ]; then grep -vxF -- "$reference" summary.md > "$parts/summary" || :; else cp summary.md "$parts/summary"; fi; ` + + 'relayflow_cap "$parts/summary" "$room" head "summary.md in the run workspace"; fi', '{ if [ -s "$parts/summary" ]; then cat "$parts/summary"; printf \'\\n\\n\'; fi; cat "$report"; } > .relayflow/pr-body.md', 'rm -rf "$parts"', 'echo "relayflow: wrote the check report to $report." >&2', @@ -709,15 +723,16 @@ export const FLOW_PUSH_COMMAND = 'relayflow_push() { ' + [ * is retained rather than duplicated. * * A body over FLOW_BODY_LIMIT, less FLOW_WITHHELD_NOTICE_RESERVE, is cut to - * fit (agentrelay.com#160), and a reference the cut removed is put back at the - * end: a closing reference is never lost to truncation. + * fit (agentrelay.com#160), and its reference is moved to the end, after the + * cut (which is shown as code, where a reference does nothing): a closing + * reference is never lost to truncation. */ export const FLOW_PREPARE_CHANGE_METADATA_COMMAND = [ 'if [ ! -s .relayflow/pr-body.md ]; then echo missing-body; exit 0; fi', CAP_FUNCTION, `relayflow_reference() { if [ -n "$reference" ] && [ "$(printf %s "$reference" | LC_ALL=C wc -c | tr -d " ")" -le ${FLOW_REFERENCE_LIMIT} ] && ! grep -qxF "$reference" .relayflow/pr-body.md; then printf "\\n%s\\n" "$reference" >> .relayflow/pr-body.md; fi; }`, 'relayflow_reference', - `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} ]; then relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, + `if [ "$(LC_ALL=C wc -c < .relayflow/pr-body.md | tr -d " ")" -gt ${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} ]; then if [ -n "$reference" ]; then grep -vxF -- "$reference" .relayflow/pr-body.md > .relayflow/pr-body.md.tmp || :; mv -f .relayflow/pr-body.md.tmp .relayflow/pr-body.md; fi; relayflow_cap .relayflow/pr-body.md $((${FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE} - 2 - $(printf %s "$reference" | LC_ALL=C wc -c | tr -d " "))) head "the run journal"; relayflow_reference; fi`, 'echo prepared', ].join('; '); diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index b5bb0273..39cf7d4d 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -902,8 +902,9 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. * reader sees before the check report, or null when the report is hidden: * inside a code block, an HTML comment, or a collapsed
. */ + const render = (body: string) => micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()], allowDangerousHtml: true }); const beforeReport = (body: string) => { - const html = micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()], allowDangerousHtml: true }) + const html = render(body) .replace(/|$)/g, ''); const at = html.indexOf('

Checks

'); if (at < 0) return null; @@ -951,7 +952,11 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(body).toContain('.relayflow/check.log in the run workspace'); // The summary's open code fence cannot swallow the report or the reference. expect(beforeReport(body)).toContain('Fixed the login bug.'); - expect(micromark(body)).toContain('

Fixes #160

'); + expect(render(body)).toContain('

Fixes #160

'); + // A cut script is fenced inside the report's own block, which still ends + // where it should: the logs after it render as their own sections. + expect(render(body)).toMatch(/Output on this branch \(last 80 lines\)<\/summary>/); + expect(render(body)).toMatch(/Output on the base commit \(last 80 lines\)<\/summary>/); // The step says what it cut, but never prints the text: a log can hold credentials. expect(body).not.toContain('FAIL src/login.test.ts 120 '); expect(report.stderr).toContain('so it was cut; the full text is .relayflow/check.log in the run workspace'); @@ -1013,7 +1018,7 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(verdict).toBe('valid'); expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(FLOW_BODY_LIMIT - FLOW_WITHHELD_NOTICE_RESERVE); expect(beforeReport(body)).toContain('## What changed'); - const html = micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()] }); + const html = render(body); expect(html).toContain('

Fixes #160

'); expect(html).toMatch(/…truncated to fit GitHub's limit/); }); @@ -1030,6 +1035,25 @@ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay. expect(source).toContain('f.run("reference=" + shellQuote(changeReference) + "; title=" + shellQuote(changeTitle)'); }); + it('moves a closing reference out of a cut summary, where it would be code', () => { + const { verdict, body } = publishBody({ 'summary.md': '## What changed\n\nFixes #160\n\n' + longLines('code', 400, 60) }, 'pass', ''); + expect(verdict).toBe('valid'); + expect(body.split('\n').filter(line => line === 'Fixes #160')).toHaveLength(1); + expect(render(body)).toContain('

Fixes #160

'); + // So does the last-resort cap of a whole body. + const root = fixture({ '.relayflow/pr-body.md': 'Fixes #160\n\n' + longLines('line', 400, 100) }); + sh(`reference='Fixes #160'; ${FLOW_PREPARE_CHANGE_METADATA_COMMAND}`, root); + const capped = read(root, '.relayflow/pr-body.md'); + expect(capped.split('\n').filter(line => line === 'Fixes #160')).toHaveLength(1); + expect(render(capped)).toContain('

Fixes #160

'); + }); + + it('fences check output that holds a fence of its own', () => { + const { body } = publishBody({ 'summary.md': 'Fixed it.\n', '.relayflow/check.log': 'FAIL\n```\nnot the end\n' }, 'fail', 'pass'); + expect(render(body)).toContain('

Fixes #160

'); + expect(body).toContain('````\nFAIL\n```\nnot the end\n````\n'); + }); + it('puts back a closing reference that truncation cut from the summary', () => { const { verdict, body } = publishBody({ 'summary.md': 'x'.repeat(80000) + '\n\nFixes #160\n' }, 'pass', ''); expect(verdict).toBe('valid'); From 6b9979bfdb6eab566bfee257182b1a949fb866a2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 05:04:03 -0700 Subject: [PATCH 10/10] test(flows): document render and beforeReport separately Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 27d4ebe3-2e2f-461e-849f-1bd9727cfc05 --- web/lib/test/flow-workflows.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/lib/test/flow-workflows.test.ts b/web/lib/test/flow-workflows.test.ts index 39cf7d4d..d6c04ec9 100644 --- a/web/lib/test/flow-workflows.test.ts +++ b/web/lib/test/flow-workflows.test.ts @@ -897,12 +897,12 @@ describe('change metadata contract', () => { */ describe('change bodies stay under GitHub\'s 65,536-character limit (agentrelay.com#160)', () => { const chars = (text: string) => Array.from(text).length; + /** Renders a body as GitHub does: GFM, raw HTML allowed. */ + const render = (body: string) => micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()], allowDangerousHtml: true }); /** - * Renders a body as GitHub does (GFM, raw HTML allowed) and returns what a - * reader sees before the check report, or null when the report is hidden: - * inside a code block, an HTML comment, or a collapsed
. + * What a reader sees before the check report, or null when the report is + * hidden: inside a code block, an HTML comment, or a collapsed
. */ - const render = (body: string) => micromark(body, { extensions: [gfm()], htmlExtensions: [gfmHtml()], allowDangerousHtml: true }); const beforeReport = (body: string) => { const html = render(body) .replace(/|$)/g, '');