From 469daaf73c4fd63fe05b4a43266391d89fabf4db Mon Sep 17 00:00:00 2001 From: icethief Date: Mon, 9 Mar 2026 15:37:47 +0900 Subject: [PATCH 1/3] fix(session): always post session info as persistent message for continuity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session info was sent as ephemeral messages in 3 paths (mention cancelled, mention noreply, assistant cancelled), making them invisible to conversations.replies and preventing session resumption. - Mention handler cancelled: postEphemeral → postMessage for session info - Mention handler noreply: postReplyOrEphemeral → postMessage for session info - Assistant handler cancelled: remove cancelled/success branch, always use say() - Extract session ID parsing into extractSessionIdFromMessage with block fallback - Add rich_text block traversal for Slack API format conversion robustness Co-Authored-By: Claude Opus 4.6 --- src/slack/handler.ts | 77 ++++++++++++++++---------- tests/slack/handler.test.ts | 105 +++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 29 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index f0397bd..499f1c3 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -67,6 +67,37 @@ export function resolveSessionId( export const SESSION_ID_RE = /(?::link:\s*`|Session:\s*)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; +export function extractSessionIdFromMessage(m: any): string | null { + // 1. Try text field first + const text = (m.text || "").replace(/<@[A-Z0-9]+>/g, "").trim(); + const textMatch = text.match(SESSION_ID_RE); + if (textMatch) return textMatch[1] ?? null; + + // 2. Try blocks (markdown blocks from Assistant API may not populate text) + if (Array.isArray(m.blocks)) { + for (const block of m.blocks) { + // Direct text/content fields (markdown blocks) + const blockText = block.text ?? block.content ?? ""; + const blockMatch = blockText.match(SESSION_ID_RE); + if (blockMatch) return blockMatch[1] ?? null; + + // rich_text blocks: Slack may convert markdown blocks to rich_text in conversations.replies + if (block.type === "rich_text" && Array.isArray(block.elements)) { + for (const section of block.elements) { + if (!Array.isArray(section.elements)) continue; + const sectionText = section.elements + .map((elem: any) => (elem.type === "emoji" ? `:${elem.name}:` : elem.text ?? "")) + .join(""); + const sectionMatch = sectionText.match(SESSION_ID_RE); + if (sectionMatch) return sectionMatch[1] ?? null; + } + } + } + } + + return null; +} + async function fetchThreadContext( client: any, channel: string, @@ -92,11 +123,11 @@ async function fetchThreadContext( const role = isBot ? "assistant" : "user"; const text = (m.text || "").replace(/<@[A-Z0-9]+>/g, "").trim(); - // Extract session ID from bot messages + // Extract session ID from bot messages (check text + blocks) if (isBot) { - const sessionMatch = text.match(SESSION_ID_RE); - if (sessionMatch) { - lastSessionId = sessionMatch[1]; + const sessionId = extractSessionIdFromMessage(m); + if (sessionId) { + lastSessionId = sessionId; } } @@ -105,7 +136,7 @@ async function fetchThreadContext( if (messages.length === 0) return { context: "", lastSessionId }; - console.log(`[thread] Loaded ${messages.length} prior message(s) from thread`); + console.log(`[thread] Loaded ${messages.length} prior message(s) from thread, lastSessionId: ${lastSessionId}`); const context = `Below is the prior conversation in this Slack thread for context:\n\n${messages.join("\n")}\n\n---\n`; return { context, lastSessionId }; } catch (err: any) { @@ -194,9 +225,9 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca .catch(() => {}), ]); - // Cancelled: send error + session info as ephemeral only + // Cancelled: send cancellation notice as ephemeral, session info as persistent if (isCancelled) { - const ephemeralParts: Promise[] = [ + const parts: Promise[] = [ client.chat.postEphemeral({ channel: event.channel, user: event.user, @@ -206,16 +237,15 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca ]; if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - ephemeralParts.push( - client.chat.postEphemeral({ + parts.push( + client.chat.postMessage({ channel: event.channel, - user: event.user, thread_ts: event.ts, ...sessionMsg, }), ); } - await Promise.all(ephemeralParts); + await Promise.all(parts); } else { // noreply: skip thread reply, send session info only to requester via ephemeral if (!noreply) { @@ -225,14 +255,14 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue, ca } } - // Post session info + // Post session info (always persistent for session continuity) if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - await postReplyOrEphemeral( - client, - { channel: event.channel, threadTs: noreply ? event.thread_ts : event.ts, user: event.user, noreply }, - sessionMsg, - ); + await client.chat.postMessage({ + channel: event.channel, + thread_ts: event.thread_ts ?? event.ts, + ...sessionMsg, + }); } } @@ -404,19 +434,10 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM } }); - // Post session info: ephemeral if cancelled, public otherwise + // Post session info (always persistent for session continuity) if (sessionId) { const sessionMsg = formatSessionInfo(sessionId, resolved.repoPath, config.claudePath); - if (cancelled) { - await client.chat.postEphemeral({ - channel: ev.channel, - user: ev.user, - thread_ts: ev.thread_ts || ev.ts, - ...sessionMsg, - }); - } else { - await say(sessionMsg); - } + await say(sessionMsg); } } catch (err: any) { console.log(`[claude] Unhandled error: ${err.message}`); diff --git a/tests/slack/handler.test.ts b/tests/slack/handler.test.ts index 46b8daf..b8a540c 100644 --- a/tests/slack/handler.test.ts +++ b/tests/slack/handler.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, mock } from "bun:test"; import type { CCSlackConfig } from "../../src/config"; -import { createReactionCancelHandler, resolveRepoPath, resolveSessionId, SESSION_ID_RE } from "../../src/slack/handler"; +import { + createReactionCancelHandler, + extractSessionIdFromMessage, + resolveRepoPath, + resolveSessionId, + SESSION_ID_RE, +} from "../../src/slack/handler"; import { formatSessionInfo } from "../../src/slack/responder"; const mockConfig: CCSlackConfig = { @@ -133,6 +139,103 @@ describe("SESSION_ID_RE", () => { }); }); +describe("extractSessionIdFromMessage", () => { + const testUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + it("extracts session ID from text field (Session: format)", () => { + const msg = { text: `Session: ${testUuid}`, bot_id: "B123" }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); + + it("extracts session ID from text field (:link: format)", () => { + const msg = { text: `:link: \`${testUuid}\``, bot_id: "B123" }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); + + it("extracts session ID from blocks when text is empty", () => { + const msg = { + text: "", + bot_id: "B123", + blocks: [{ type: "markdown", text: `---\n:link: \`${testUuid}\`\n\`\`\`\ncd /repo && claude --resume ${testUuid}\n\`\`\`` }], + }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); + + it("extracts session ID from blocks when text has no session info", () => { + const msg = { + text: "Some other text without session info", + bot_id: "B123", + blocks: [ + { type: "markdown", text: "Here is my response..." }, + { type: "markdown", text: `---\n:link: \`${testUuid}\`` }, + ], + }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); + + it("returns null when no session ID found anywhere", () => { + const msg = { text: "Just a regular message", bot_id: "B123", blocks: [] }; + expect(extractSessionIdFromMessage(msg)).toBeNull(); + }); + + it("returns null when message has no text and no blocks", () => { + const msg = { bot_id: "B123" }; + expect(extractSessionIdFromMessage(msg)).toBeNull(); + }); + + it("prefers text field over blocks", () => { + const textUuid = "11111111-2222-3333-4444-555555555555"; + const blockUuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const msg = { + text: `Session: ${textUuid}`, + bot_id: "B123", + blocks: [{ type: "markdown", text: `:link: \`${blockUuid}\`` }], + }; + expect(extractSessionIdFromMessage(msg)).toBe(textUuid); + }); + + it("extracts session ID from rich_text blocks (Slack conversion of markdown blocks)", () => { + const msg = { + text: "", + bot_id: "B123", + blocks: [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "emoji", name: "link" }, + { type: "text", text: ` \`${testUuid}\`` }, + ], + }, + ], + }, + ], + }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); + + it("extracts session ID from rich_text with Session: prefix", () => { + const msg = { + text: "", + bot_id: "B123", + blocks: [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [{ type: "text", text: `Session: ${testUuid}` }], + }, + ], + }, + ], + }; + expect(extractSessionIdFromMessage(msg)).toBe(testUuid); + }); +}); + describe("createReactionCancelHandler", () => { const config = mockConfig; From 9b5bf8c7fad57dee5bba977a4a82e924de6716e8 Mon Sep 17 00:00:00 2001 From: icethief Date: Mon, 9 Mar 2026 15:42:02 +0900 Subject: [PATCH 2/3] refactor: remove unused cancelled variable in assistant handler After making session info always persistent, the cancelled flag is no longer needed for conditional posting. Simplify the error handling to just skip appending cancelled errors to the stream. Co-Authored-By: Claude Opus 4.6 --- src/slack/handler.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 499f1c3..a1ce31a 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -373,7 +373,6 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM const startTime = Date.now(); console.log(`[claude] Starting stream: claude -p "${prompt.slice(0, 50)}..." in ${resolved.repoPath}`); - let cancelled = false; try { await queue.enqueue(async () => { const streamer = client.chatStream({ @@ -417,9 +416,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue, cancelM console.log(`[thinking] ${preview}`); } else if (evt.type === "error") { console.log(`[claude] Error: ${evt.error}`); - if (evt.error === "cancelled") { - cancelled = true; - } else { + if (evt.error !== "cancelled") { await streamer.append({ markdown_text: `\n\nError: ${evt.error}` }); } } else if (evt.type === "result") { From 04b019f935d3c39e41655a45db8f9379513df312 Mon Sep 17 00:00:00 2001 From: icethief Date: Tue, 10 Mar 2026 11:17:24 +0900 Subject: [PATCH 3/3] style: fix biome format errors in handler and test Co-Authored-By: Claude Opus 4.6 --- src/slack/handler.ts | 2 +- tests/slack/handler.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/slack/handler.ts b/src/slack/handler.ts index a1ce31a..6a04f37 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -86,7 +86,7 @@ export function extractSessionIdFromMessage(m: any): string | null { for (const section of block.elements) { if (!Array.isArray(section.elements)) continue; const sectionText = section.elements - .map((elem: any) => (elem.type === "emoji" ? `:${elem.name}:` : elem.text ?? "")) + .map((elem: any) => (elem.type === "emoji" ? `:${elem.name}:` : (elem.text ?? ""))) .join(""); const sectionMatch = sectionText.match(SESSION_ID_RE); if (sectionMatch) return sectionMatch[1] ?? null; diff --git a/tests/slack/handler.test.ts b/tests/slack/handler.test.ts index b8a540c..7cbe5dd 100644 --- a/tests/slack/handler.test.ts +++ b/tests/slack/handler.test.ts @@ -156,7 +156,12 @@ describe("extractSessionIdFromMessage", () => { const msg = { text: "", bot_id: "B123", - blocks: [{ type: "markdown", text: `---\n:link: \`${testUuid}\`\n\`\`\`\ncd /repo && claude --resume ${testUuid}\n\`\`\`` }], + blocks: [ + { + type: "markdown", + text: `---\n:link: \`${testUuid}\`\n\`\`\`\ncd /repo && claude --resume ${testUuid}\n\`\`\``, + }, + ], }; expect(extractSessionIdFromMessage(msg)).toBe(testUuid); });