Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 150 additions & 12 deletions services/slackbotv2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
)
)
)
)
Expand Down Expand Up @@ -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)
)
)
)
)
Expand Down Expand Up @@ -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'
)
Expand Down Expand Up @@ -1924,6 +1932,26 @@ async function* slackSafeChatSdkStream(
}
}

async function* slackFriendlyChatSdkStream(
stream: AsyncIterable<ChatSDKStreamChunk>
): AsyncIterable<ChatSDKStreamChunk> {
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
Expand All @@ -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 (
Expand Down
55 changes: 55 additions & 0 deletions services/slackbotv2/test/chat-sdk-emulate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[] = []
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

Expand Down
Loading