diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 94140276e..7eaa7c951 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -1130,7 +1130,11 @@ async function renderFallbackFinalAnswer( }) return { status: 'failed', error: 'empty fallback final answer', lastEventId } } - const fallbackText = truncateSlackText(text, SLACK_FALLBACK_TEXT_MAX_CHARS, 'Slack final answer') + const fallbackText = truncateSlackText( + slackFriendlyText(text), + SLACK_FALLBACK_TEXT_MAX_CHARS, + 'Slack final answer' + ) if (replacement) { await thread.adapter.editMessage(thread.id, replacement.replaceMessageId, fallbackText) } else { @@ -1755,11 +1759,13 @@ async function renderExecutionStream( const capture = { diverged: false } try { const visibleStream = await streamAfterFirstChunk( - conflateChatSdkStream( - slackSafeChatSdkStream( - codexAppServerToChatSdkStream( - stream, - rendererOptions(thread, options, capture) + slackFriendlyChatSdkStream( + conflateChatSdkStream( + slackSafeChatSdkStream( + codexAppServerToChatSdkStream( + stream, + rendererOptions(thread, options, capture) + ) ) ) ) @@ -1801,11 +1807,13 @@ async function renderRecoveredExecutionStream( const capture = { diverged: false } try { const visibleStream = await streamAfterFirstChunk( - conflateChatSdkStream( - slackSafeChatSdkStream( - codexAppServerToChatSdkStream( - stream, - rendererOptions(thread, options, capture) + slackFriendlyChatSdkStream( + conflateChatSdkStream( + slackSafeChatSdkStream( + codexAppServerToChatSdkStream( + stream, + rendererOptions(thread, options, capture) + ) ) ) ) @@ -1857,7 +1865,7 @@ async function renderPlainTextExecutionStream( void _chunk } const text = truncateSlackText( - fallback.text() || 'Execution completed, but no final text was captured.', + slackFriendlyText(fallback.text() || 'Execution completed, but no final text was captured.'), SLACK_FALLBACK_TEXT_MAX_CHARS, 'Slack final answer' ) @@ -1924,6 +1932,26 @@ async function* slackSafeChatSdkStream( } } +async function* slackFriendlyChatSdkStream( + stream: AsyncIterable +): AsyncIterable { + let markdownText = '' + for await (const chunk of stream) { + if (chunk.type === 'markdown_text') { + markdownText += chunk.text + continue + } + if (markdownText) { + yield { type: 'markdown_text', text: slackFriendlyText(markdownText) } + markdownText = '' + } + yield chunk + } + if (markdownText) { + yield { type: 'markdown_text', text: slackFriendlyText(markdownText) } + } +} + function slackSafeChatSdkChunk(chunk: ChatSDKStreamChunk): ChatSDKStreamChunk { if (chunk.type !== 'task_update') return chunk const { output: _output, details, ...safeChunk } = chunk @@ -1934,6 +1962,116 @@ function slackSafeChatSdkChunk(chunk: ChatSDKStreamChunk): ChatSDKStreamChunk { } } +function slackFriendlyText(text: string): string { + return rewriteMarkdownPipeTables(text) +} + +function rewriteMarkdownPipeTables(text: string): string { + const lines = text.split('\n') + const output: string[] = [] + let index = 0 + let inCodeFence = false + + while (index < lines.length) { + const line = lines[index]! + if (line.trimStart().startsWith('```')) { + inCodeFence = !inCodeFence + output.push(line) + index += 1 + continue + } + + const separatorLine = lines[index + 1] + if ( + !inCodeFence + && separatorLine + && isMarkdownTableRow(line) + && isMarkdownTableSeparator(separatorLine) + ) { + const headers = parseMarkdownTableRow(line) + const rows: string[][] = [] + let cursor = index + 2 + while (cursor < lines.length) { + const rowLine = lines[cursor]! + if (!rowLine.trim()) { + if (cursor + 1 < lines.length && isMarkdownTableRow(lines[cursor + 1]!)) { + cursor += 1 + continue + } + break + } + if (!isMarkdownTableRow(rowLine)) break + rows.push(parseMarkdownTableRow(rowLine)) + cursor += 1 + } + + if (headers.length >= 2 && rows.length > 0) { + output.push(...markdownTableToSlackList(headers, rows)) + index = cursor + continue + } + } + + output.push(line) + index += 1 + } + + return output.join('\n') +} + +function isMarkdownTableRow(line: string): boolean { + const trimmed = line.trim() + return trimmed.includes('|') && parseMarkdownTableRow(trimmed).length >= 2 +} + +function isMarkdownTableSeparator(line: string): boolean { + const cells = parseMarkdownTableRow(line) + return cells.length >= 2 && cells.every(cell => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, ''))) +} + +function parseMarkdownTableRow(line: string): string[] { + const trimmed = line.trim() + const body = trimmed.startsWith('|') && trimmed.endsWith('|') + ? trimmed.slice(1, -1) + : trimmed + const cells: string[] = [] + let cell = '' + let escaped = false + for (const char of body) { + if (escaped) { + cell += char + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === '|') { + cells.push(cell.trim()) + cell = '' + continue + } + cell += char + } + cells.push(cell.trim()) + return cells +} + +function markdownTableToSlackList(headers: string[], rows: string[][]): string[] { + return rows.flatMap((row, rowIndex) => { + const title = row[0]?.trim() || `Row ${rowIndex + 1}` + const lines = [`• *${title}*`] + for (let column = 1; column < Math.max(headers.length, row.length); column += 1) { + const value = row[column]?.trim() + if (!value) continue + const header = headers[column]?.trim() || `Column ${column + 1}` + lines.push(` *${header}:* ${value}`) + } + return rowIndex === 0 ? lines : ['', ...lines] + }) +} + function isPlainTextOnlyRequest(text: string): boolean { const normalized = text.toLowerCase() return ( diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index b83428de1..89308fe3e 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -2253,6 +2253,61 @@ describe('slackbotv2', () => { expect(await threadText(parent.ts)).toContain('STREAM_CONTINUATION_END') }) + it('rewrites markdown pipe tables before streaming to Slack', async () => { + codexApi.autoRespond = false + + const parent = await postUserMessage('Context before a markdown table answer.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> update these Attio leads`, parent.ts) + const key = threadKey(parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-markdown-table-output', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> update these Attio leads` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await waitFor(() => codexApi.executes.length === 1) + await waitFor(() => codexApi.eventRequests.length === 1) + + const answer = [ + 'Done. Verified notes/address/tasks in Attio.', + '', + '| Lead | Attio record | Task |', + '|---|---|---|', + '| SolitaireNY (Sohil Bhansali) | https://app.attio.com/fin/custom/pipeline/record/51d31937-ed2a-457e-8088-9d17cba9189f | Created: call/go to office on 2026-07-09, assigned to Lucas |', + '| Nooga Timepieces (Steven Chaffin) | https://app.attio.com/fin/custom/pipeline/record/eb40d3b7-a225-4d0d-b32f-51cccc5f0e32 | Completed current task; created follow-up on 2026-07-02 |' + ].join('\n') + codexApi.emitOutputLines(key, sampleCodexOutputLines(answer)) + codexApi.emitSessionEvent(key, 'session.execution_completed', { + execution_id: 'exe-markdown-table-output', + status: 'completed', + result_text: answer + }) + + await Promise.all(waits) + const text = await threadText(parent.ts) + expect(text).toContain('Done. Verified notes/address/tasks in Attio.') + expect(text).toContain('• *SolitaireNY (Sohil Bhansali)*') + expect(text).toContain('*Attio record:* https://app.attio.com/fin/custom/pipeline/record/51d31937-ed2a-457e-8088-9d17cba9189f') + expect(text).toContain('*Task:* Created: call/go to office on 2026-07-09, assigned to Lucas') + expect(text).toContain('• *Nooga Timepieces (Steven Chaffin)*') + expect(text).not.toContain('| Lead | Attio record | Task |') + expect(text).not.toContain('|---|---|---|') + }) + it('conflates rapid task updates instead of one Slack call per event', async () => { codexApi.autoRespond = false