Skip to content

feat: add Slack markdown/text file support for input and output#16

Merged
electronicBlacksmith merged 4 commits intomainfrom
feat/slack-markdown-file-support
Apr 11, 2026
Merged

feat: add Slack markdown/text file support for input and output#16
electronicBlacksmith merged 4 commits intomainfrom
feat/slack-markdown-file-support

Conversation

@electronicBlacksmith
Copy link
Copy Markdown
Owner

@electronicBlacksmith electronicBlacksmith commented Apr 10, 2026

Summary

  • Inbound: Users can send .md/.txt file uploads in Slack, which are read into memory and injected as prompt text (no disk write)
  • Outbound: Responses exceeding Slack's block size limit are uploaded as a .md file with a summary message instead of being chunked across multiple messages
  • Graceful fallback to chunked messages if file upload fails (missing files:write scope, API error, etc.)

Changed files

File Change
src/channels/types.ts Added "text" to attachment type union, textContent field
src/channels/slack-files.ts isTextFile(), hasNulBytes(), uploadSlackFile(), text file download support
src/channels/slack-formatter.ts generateSummary() for file upload summaries
src/channels/slack.ts extractTextFileContent(), inbound text extraction, outbound file upload in send()/postToChannel()/updateWithFeedback()
src/index.ts Updated skipped-files message
Test files 30+ new test cases across all three test files

Test plan

  • bun run typecheck passes
  • bun run lint passes
  • 933/934 tests pass (1 pre-existing flaky failure in events.test.ts, reproduces on main)
  • Manual: send a .md file to Phantom in Slack, verify content is used as prompt
  • Manual: trigger a long response (>2900 chars), verify summary message + .md file attachment
  • Verify Slack app has files:write scope for outbound uploads

Adds inbound .md/.txt file reading as prompt text (in-memory, no disk write)
and outbound .md file upload with summary for responses that exceed Slack's
block size limit, with graceful fallback to chunking on upload failure.
The createSSEResponse tests leak listeners into the module-level Set
because the ReadableStream is never cancelled. Use a baseline count
instead of assuming zero.
…ode fence

Two bugs in the loop final-notice Slack output:

1. extractStateSummary pre-truncated the state.md body at 3500 chars before
   postToChannel's upload branch ever ran, so the uploaded response.md only
   contained the first 3500 chars + "…(truncated)". Removed truncation from
   the extractor (renamed extractStateBody); the cap now applies only in
   postToChannel's chunked fallback path via a new optional
   inlineFallbackMaxChars arg, so the upload gets the full body and the
   fallback stays bounded.

2. postFinalNotice wrapped the summary in triple-backticks, which made every
   middle chunk render as an unterminated code block when splitMessage had
   to chunk the fallback. Dropped the outer fence; the state file is already
   markdown.

Restructured postFinalNotice to post a short header and then the body as
two sequential postToChannel calls. The uploaded file stays free of the
notebook header, and each message is individually sensible if the upload
path can't be used.

Also added files:write to slack-app-manifest.yaml so fresh installs pick up
the scope the upload branch needs.
Two follow-ups from code review of the previous commit:

1. inlineFallbackMaxChars is now guarded against negative, NaN, and
   non-finite values. Previously a negative cap would make
   text.slice(0, -1) post "all but the last char" plus a truncation
   notice, defeating the cap. No current caller passes a bad value, but
   the validation removes the footgun. Added a regression test.

2. The "even number of triple-backticks" test in slack.test.ts was
   vacuous: it used plain-prose input containing zero backticks, so the
   assertion (0 % 2 === 0) was always true regardless of the code under
   test. Replaced with an explicit not.toContain("```") assertion that
   directly guards against the removed outer-fence wrapper being
   reintroduced.

Also documented in a comment that splitMessage is not code-fence aware,
so a bounded body containing a long fenced block can still produce
half-open fences in the chunked fallback. Fixing that is out of scope
for this path (the upload path sidesteps it entirely).
@electronicBlacksmith
Copy link
Copy Markdown
Owner Author

Pushed two follow-up commits fixing the loop final-notice issues in this feature's file-upload path:

8b3d546 — fix(loop): preserve full state body in final-notice upload and drop code fence

Two bugs in how the loop's final notice uses the new file-upload fallback:

  1. extractStateSummary in src/loop/notifications.ts pre-truncated the state body at 3500 chars before postToChannel's upload branch ever ran, so the uploaded response.md only ever contained the first 3500 chars plus "…(truncated)", not the full log. Moved the cap off the extractor (which now returns the full frontmatter-stripped body) and onto a new optional 4th arg inlineFallbackMaxChars on postToChannel. Upload path stays verbatim; cap only applies to the chunked fallback.
  2. postFinalNotice wrapped the summary in triple-backticks before posting, so when the upload path was unavailable and splitMessage chunked the fallback, every middle chunk rendered as an unterminated code block in Slack. Dropped the outer fence and restructured to post a short header and then the body as two sequential postToChannel calls.

Also added files:write to slack-app-manifest.yaml under oauth_config.scopes.bot so fresh installs pick up the scope the upload branch needs (the running workspace was granted it manually).

2f1946f — fix(slack): validate fallback cap and tighten regression tests

Follow-ups from a dual code review (Sonnet 4.6 + Copilot GPT-5):

  • inlineFallbackMaxChars is now guarded against negative, NaN, and non-finite values. A negative cap would have made text.slice(0, -1) post "all but the last char" plus a truncation notice, defeating the cap. No current caller passes a bad value, but the validation removes the footgun.
  • The "even number of triple-backticks per chunk" regression test in slack.test.ts was vacuous — it used plain-prose input with zero backticks, so 0 % 2 === 0 was always true regardless of the code under test. Replaced with an explicit not.toContain("\``")` assertion that directly guards against the removed outer-fence wrapper being reintroduced. Added a negative-cap test too.
  • Documented in a comment that splitMessage is not code-fence aware, so a bounded body containing a long fenced block can still produce half-open fences in the chunked fallback. Fixing that is out of scope — the upload path sidesteps it entirely, which is the intended mitigation.

Verification

  • bun run typecheck — clean
  • bun run lint — clean
  • bun test — 1040 pass / 0 fail (was 1034 before these commits)

Tests added/updated

  • src/loop/__tests__/notifications.test.ts: updated "posts state.md body" to expect two postToChannel calls (header + body), assert the body has no code fence, frontmatter is stripped, and the 4th arg is 3500. Updated the status-fallback and multi-status tests for the new call count. Replaced the old "truncates very long summaries" test with a regression: 5000-char body reaches the body call with length ≥ 5000 and no truncation marker.
  • src/channels/__tests__/slack.test.ts: new describe("SlackChannel postToChannel") block with four tests — full body reaches upload branch, cap applied on fallback with notice, chunked fallback with no cap doesn't introduce fences, negative cap is silently ignored.

@electronicBlacksmith electronicBlacksmith merged commit c643705 into main Apr 11, 2026
1 check passed
@electronicBlacksmith electronicBlacksmith deleted the feat/slack-markdown-file-support branch April 11, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant