diff --git a/docs/plans/2026-03-06-session-continuity-design.md b/docs/plans/2026-03-06-session-continuity-design.md new file mode 100644 index 0000000..e606d0f --- /dev/null +++ b/docs/plans/2026-03-06-session-continuity-design.md @@ -0,0 +1,40 @@ +# Session Continuity via Thread-Based Session Tracking + +## Problem + +현재 매 요청마다 새 Claude 세션을 생성하여, 같은 Slack 쓰레드 내에서도 대화가 이어지지 않음. +쓰레드 메시지를 컨텍스트로 주입하여 부분적으로 커버하지만, Claude 세션 자체의 연속성은 없음. + +## Design + +### Session ID 결정 우선순위 + +1. `session:new` → 새 세션 강제 생성 +2. `session:` → 명시적 세션 ID 지정 (다른 쓰레드 세션 이어가기) +3. 쓰레드 메시지에서 마지막 세션 ID 역추출 → 자동 이어가기 +4. 위 모두 없으면 → 새 세션 생성 + +### Parser 확장 + +`ParsedMessage`에 `session: string | null` 필드 추가. `"new"` 또는 UUID 문자열. +기존 `repo:`, `model:`과 동일한 `session:\S+` 패턴으로 파싱. + +### fetchThreadContext 변경 + +반환 타입을 `{ context: string; lastSessionId: string | null }`로 확장. +봇 메시지에서 `:link: \`\`` 패턴을 정규식으로 매치하여 마지막 세션 ID 추출. +세션을 이어가는 경우 thread context 문자열은 비워서 중복 주입 방지. + +### resolveSessionId + +`maybeSessionId` 대체. 파서에서 받은 session 값, 쓰레드에서 추출한 세션 ID, config를 받아 최종 세션 ID 결정. + +## 변경 범위 + +| 파일 | 변경 | +|------|------| +| `parser.ts` | `session` 필드 추가 | +| `handler.ts` | `resolveSessionId`, `fetchThreadContext` 반환 타입 확장, 세션 이어갈 때 context 생략 | +| `responder.ts` | 변경 없음 | +| `runner.ts` | 변경 없음 | +| `config.ts` | 변경 없음 | diff --git a/src/claude/runner.ts b/src/claude/runner.ts index 52bfaee..59aae84 100644 --- a/src/claude/runner.ts +++ b/src/claude/runner.ts @@ -7,6 +7,7 @@ export interface ClaudeOptions { maxOutputTokens?: number; model?: string; sessionId?: string; + isResuming?: boolean; } export type StreamEvent = @@ -50,11 +51,17 @@ function buildClaudeEnv(maxOutputTokens?: number): Record { } function buildClaudeCmd(options: ClaudeOptions, extraFlags: string[] = []): string[] { - const { claudePath, prompt, model, allowedTools, sessionId } = options; + const { claudePath, prompt, model, allowedTools, sessionId, isResuming } = options; const cmd = [claudePath, "-p", prompt, "--output-format", "stream-json", "--verbose", ...extraFlags]; if (model) cmd.push("--model", model); if (allowedTools?.length) cmd.push("--allowedTools", ...allowedTools); - if (sessionId) cmd.push("--session-id", sessionId); + if (sessionId) { + if (isResuming) { + cmd.push("--resume", sessionId); + } else { + cmd.push("--session-id", sessionId); + } + } return cmd; } diff --git a/src/slack/handler.ts b/src/slack/handler.ts index 84d3b15..1f6eb49 100644 --- a/src/slack/handler.ts +++ b/src/slack/handler.ts @@ -22,18 +22,33 @@ export function resolveRepoPath(repo: string | null, config: CCSlackConfig): str return resolved; } -function maybeSessionId(config: CCSlackConfig): string | undefined { - return config.enableSessionContinuity !== false ? crypto.randomUUID() : undefined; +export interface SessionResolution { + sessionId: string | undefined; + isResuming: boolean; } +export function resolveSessionId( + parsedSession: string | null, + threadSessionId: string | null, + config: CCSlackConfig, +): SessionResolution { + if (config.enableSessionContinuity === false) return { sessionId: undefined, isResuming: false }; + if (parsedSession === "new") return { sessionId: crypto.randomUUID(), isResuming: false }; + if (parsedSession) return { sessionId: parsedSession, isResuming: false }; + if (threadSessionId) return { sessionId: threadSessionId, isResuming: true }; + return { sessionId: crypto.randomUUID(), isResuming: false }; +} + +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})/; + async function fetchThreadContext( client: any, channel: string, threadTs: string | undefined, currentTs: string, -): Promise { +): Promise<{ context: string; lastSessionId: string | null }> { // Not in a thread — no prior context - if (!threadTs) return ""; + if (!threadTs) return { context: "", lastSessionId: null }; try { const result = await client.conversations.replies({ @@ -42,27 +57,37 @@ async function fetchThreadContext( limit: 50, }); + let lastSessionId: string | null = null; + const messages = (result.messages || []) .filter((m: any) => m.ts !== currentTs) // exclude the current message .map((m: any) => { const isBot = !!m.bot_id; const role = isBot ? "assistant" : "user"; - // Strip bot mentions from user messages const text = (m.text || "").replace(/<@[A-Z0-9]+>/g, "").trim(); + + // Extract session ID from bot messages + if (isBot) { + const sessionMatch = text.match(SESSION_ID_RE); + if (sessionMatch) { + lastSessionId = sessionMatch[1]; + } + } + return `[${role}]: ${text}`; }); - if (messages.length === 0) return ""; + if (messages.length === 0) return { context: "", lastSessionId }; console.log(`[thread] Loaded ${messages.length} prior message(s) from thread`); - return ( + const context = "Below is the prior conversation in this Slack thread for context:\n\n" + messages.join("\n") + - "\n\n---\nNow respond to the latest request:\n" - ); + "\n\n---\nNow respond to the latest request:\n"; + return { context, lastSessionId }; } catch (err: any) { console.log(`[thread] Failed to fetch thread: ${err.message}`); - return ""; + return { context: "", lastSessionId: null }; } } @@ -74,7 +99,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { return; } - const { repo, model, prompt } = parseMessage(event.text || ""); + const { repo, model, session: parsedSession, prompt } = parseMessage(event.text || ""); const resolvedModel = model ?? config.defaultModel; console.log( `[mention] New request from ${event.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, @@ -93,16 +118,17 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { return; } - const sessionId = maybeSessionId(config); - // Hourglass reaction + thread context in parallel - const [, threadContext] = await Promise.all([ + const [, { context: threadContext, lastSessionId: threadSessionId }] = await Promise.all([ client.reactions .add({ channel: event.channel, timestamp: event.ts, name: "hourglass_flowing_sand" }) .catch(() => {}), fetchThreadContext(client, event.channel, event.thread_ts, event.ts), ]); - const fullPrompt = threadContext + prompt; + + const { sessionId, isResuming } = resolveSessionId(parsedSession, threadSessionId, config); + if (isResuming) console.log(`[session] Resuming session ${sessionId}`); + const fullPrompt = (isResuming ? "" : threadContext) + prompt; const startTime = Date.now(); console.log( @@ -120,6 +146,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) { maxOutputTokens: config.maxOutputTokens, model: resolvedModel, sessionId, + isResuming, }), ); @@ -189,7 +216,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist return; } - const { repo, model, prompt } = parseMessage(ev.text || ""); + const { repo, model, session: parsedSession, prompt } = parseMessage(ev.text || ""); const resolvedModel = model ?? config.defaultModel; console.log( `[task] New request from ${ev.user} | repo: ${repo ?? "(default)"} | model: ${resolvedModel ?? "(default)"} | prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? "..." : ""}"`, @@ -206,15 +233,16 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist console.log(`[task] Resolved repo path: ${repoPath}`); - const sessionId = maybeSessionId(config); - // Title, status, and thread context are independent — run in parallel - const [, , threadContext] = await Promise.all([ + const [, , { context: threadContext, lastSessionId: threadSessionId }] = await Promise.all([ setTitle(prompt.slice(0, 50)), setStatus("Thinking..."), fetchThreadContext(client, ev.channel, ev.thread_ts, ev.ts), ]); - const fullPrompt = threadContext + prompt; + + const { sessionId, isResuming } = resolveSessionId(parsedSession, threadSessionId, config); + if (isResuming) console.log(`[session] Resuming session ${sessionId}`); + const fullPrompt = (isResuming ? "" : threadContext) + prompt; // Queue + Stream const startTime = Date.now(); @@ -241,6 +269,7 @@ export function createAssistant(config: CCSlackConfig, queue: TaskQueue): Assist maxOutputTokens: config.maxOutputTokens, model: resolvedModel, sessionId, + isResuming, })) { if (evt.type === "text_delta") { if (hasThinking) { diff --git a/src/slack/parser.ts b/src/slack/parser.ts index 1ad221a..2bc5e1b 100644 --- a/src/slack/parser.ts +++ b/src/slack/parser.ts @@ -1,31 +1,26 @@ export interface ParsedMessage { repo: string | null; model: string | null; + session: string | null; prompt: string; } +function extractPrefix(text: string, prefix: string): { value: string | null; remaining: string } { + const re = new RegExp(`${prefix}:(\\S+)`); + const match = text.match(re); + if (!match) return { value: null, remaining: text }; + return { value: match[1] ?? null, remaining: text.replace(re, "").trim() }; +} + export function parseMessage(text: string): ParsedMessage { // Strip bot mentions like <@U12345> - let cleaned = text.replace(/<@[A-Z0-9]+>/g, "").trim(); - - // Extract repo: prefix - const repoMatch = cleaned.match(/repo:(\S+)/); - let repo: string | null = null; - if (repoMatch) { - repo = repoMatch[1] ?? null; - cleaned = cleaned.replace(/repo:\S+/, "").trim(); - } + const cleaned = text.replace(/<@[A-Z0-9]+>/g, "").trim(); - // Extract model: prefix - const modelMatch = cleaned.match(/model:(\S+)/); - let model: string | null = null; - if (modelMatch) { - model = modelMatch[1] ?? null; - cleaned = cleaned.replace(/model:\S+/, "").trim(); - } + const { value: repo, remaining: r1 } = extractPrefix(cleaned, "repo"); + const { value: model, remaining: r2 } = extractPrefix(r1, "model"); + const { value: session, remaining: r3 } = extractPrefix(r2, "session"); - // Collapse whitespace - const prompt = cleaned.replace(/\s+/g, " ").trim(); + const prompt = r3.replace(/\s+/g, " ").trim(); - return { repo, model, prompt }; + return { repo, model, session, prompt }; } diff --git a/tests/claude/runner.test.ts b/tests/claude/runner.test.ts index 492357c..0883b95 100644 --- a/tests/claude/runner.test.ts +++ b/tests/claude/runner.test.ts @@ -17,7 +17,7 @@ describe("runClaude", () => { expect(result.output).toContain("hello"); }); - it("includes --session-id flag when sessionId is provided", async () => { + it("includes --session-id flag for new sessions", async () => { const result = await runClaude({ prompt: "hello", cwd: "/tmp", @@ -28,9 +28,25 @@ describe("runClaude", () => { expect(result.success).toBe(true); expect(result.output).toContain("--session-id"); expect(result.output).toContain("test-uuid-1234"); + expect(result.output).not.toContain("--resume"); }); - it("omits --session-id when sessionId is undefined", async () => { + it("includes --resume flag when isResuming is true", async () => { + const result = await runClaude({ + prompt: "hello", + cwd: "/tmp", + claudePath: "echo", + timeout: 5000, + sessionId: "test-uuid-1234", + isResuming: true, + }); + expect(result.success).toBe(true); + expect(result.output).toContain("--resume"); + expect(result.output).toContain("test-uuid-1234"); + expect(result.output).not.toContain("--session-id"); + }); + + it("omits --session-id and --resume when sessionId is undefined", async () => { const result = await runClaude({ prompt: "hello", cwd: "/tmp", @@ -38,6 +54,7 @@ describe("runClaude", () => { timeout: 5000, }); expect(result.output).not.toContain("--session-id"); + expect(result.output).not.toContain("--resume"); }); it("returns failure for nonexistent command", async () => { diff --git a/tests/slack/handler.test.ts b/tests/slack/handler.test.ts index 811d71e..5ded81c 100644 --- a/tests/slack/handler.test.ts +++ b/tests/slack/handler.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { CCSlackConfig } from "../../src/config"; -import { resolveRepoPath } from "../../src/slack/handler"; +import { resolveRepoPath, resolveSessionId, SESSION_ID_RE } from "../../src/slack/handler"; +import { formatSessionInfo } from "../../src/slack/responder"; const mockConfig: CCSlackConfig = { allowedUsers: ["U123"], @@ -42,3 +43,78 @@ describe("resolveRepoPath", () => { expect(() => resolveRepoPath("nonexistent", mockConfig)).toThrow(); }); }); + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe("resolveSessionId", () => { + it("returns undefined sessionId when enableSessionContinuity is false", () => { + const config = { ...mockConfig, enableSessionContinuity: false }; + const { sessionId, isResuming } = resolveSessionId(null, null, config); + expect(sessionId).toBeUndefined(); + expect(isResuming).toBe(false); + }); + + it("returns a new UUID when parsedSession is 'new'", () => { + const { sessionId, isResuming } = resolveSessionId("new", null, mockConfig); + expect(sessionId).toMatch(UUID_RE); + expect(isResuming).toBe(false); + }); + + it("returns parsedSession as-is when it is a specific UUID", () => { + const specificId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + const { sessionId, isResuming } = resolveSessionId(specificId, null, mockConfig); + expect(sessionId).toBe(specificId); + expect(isResuming).toBe(false); + }); + + it("returns threadSessionId with isResuming true when parsedSession is null", () => { + const threadId = "11111111-2222-3333-4444-555555555555"; + const { sessionId, isResuming } = resolveSessionId(null, threadId, mockConfig); + expect(sessionId).toBe(threadId); + expect(isResuming).toBe(true); + }); + + it("returns a new UUID when both parsedSession and threadSessionId are null", () => { + const { sessionId, isResuming } = resolveSessionId(null, null, mockConfig); + expect(sessionId).toMatch(UUID_RE); + expect(isResuming).toBe(false); + }); +}); + +describe("SESSION_ID_RE", () => { + const testUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + it("matches formatSessionInfo text field (Session: )", () => { + const msg = formatSessionInfo(testUuid, "/tmp/repo"); + const match = msg.text.match(SESSION_ID_RE); + expect(match).not.toBeNull(); + expect(match![1]).toBe(testUuid); + }); + + it("matches formatSessionInfo block field (:link: ``)", () => { + const msg = formatSessionInfo(testUuid, "/tmp/repo"); + const blockText = msg.blocks[0]!.text; + const match = blockText.match(SESSION_ID_RE); + expect(match).not.toBeNull(); + expect(match![1]).toBe(testUuid); + }); + + it("extracts last session ID when multiple bot messages exist", () => { + const messages = [ + `Session: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee`, + `Some other bot message`, + `Session: ${testUuid}`, + ]; + let lastId: string | null = null; + for (const text of messages) { + const match = text.match(SESSION_ID_RE); + if (match) lastId = match[1] ?? null; + } + expect(lastId).toBe(testUuid); + }); + + it("does not match non-UUID strings", () => { + expect("Session: not-a-uuid".match(SESSION_ID_RE)).toBeNull(); + expect("random text".match(SESSION_ID_RE)).toBeNull(); + }); +}); diff --git a/tests/slack/parser.test.ts b/tests/slack/parser.test.ts index 25d3f24..9031b8c 100644 --- a/tests/slack/parser.test.ts +++ b/tests/slack/parser.test.ts @@ -6,12 +6,14 @@ describe("parseMessage", () => { const result = parseMessage("repo:my-project fix this bug"); expect(result.repo).toBe("my-project"); expect(result.model).toBeNull(); + expect(result.session).toBeNull(); expect(result.prompt).toBe("fix this bug"); }); it("handles repo with path", () => { const result = parseMessage("repo:~/projects/app add tests"); expect(result.repo).toBe("~/projects/app"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("add tests"); }); @@ -19,18 +21,21 @@ describe("parseMessage", () => { const result = parseMessage("fix the typo in README"); expect(result.repo).toBeNull(); expect(result.model).toBeNull(); + expect(result.session).toBeNull(); expect(result.prompt).toBe("fix the typo in README"); }); it("strips bot mention from message", () => { const result = parseMessage("<@U12345> repo:my-project fix bug"); expect(result.repo).toBe("my-project"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("fix bug"); }); it("handles extra whitespace", () => { const result = parseMessage(" repo:test do something "); expect(result.repo).toBe("test"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("do something"); }); @@ -38,6 +43,7 @@ describe("parseMessage", () => { const result = parseMessage("model:sonnet fix this bug"); expect(result.repo).toBeNull(); expect(result.model).toBe("sonnet"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("fix this bug"); }); @@ -45,12 +51,34 @@ describe("parseMessage", () => { const result = parseMessage("repo:my-project model:opus add tests"); expect(result.repo).toBe("my-project"); expect(result.model).toBe("opus"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("add tests"); }); it("handles model with full name", () => { const result = parseMessage("model:claude-sonnet-4-6 explain this code"); expect(result.model).toBe("claude-sonnet-4-6"); + expect(result.session).toBeNull(); expect(result.prompt).toBe("explain this code"); }); + + it("extracts session:new", () => { + const result = parseMessage("session:new fix this bug"); + expect(result.session).toBe("new"); + expect(result.prompt).toBe("fix this bug"); + }); + + it("extracts session with uuid value", () => { + const result = parseMessage("session:abc-123-def continue the work"); + expect(result.session).toBe("abc-123-def"); + expect(result.prompt).toBe("continue the work"); + }); + + it("handles repo, model, and session together", () => { + const result = parseMessage("repo:my-project model:opus session:abc-123-def add tests"); + expect(result.repo).toBe("my-project"); + expect(result.model).toBe("opus"); + expect(result.session).toBe("abc-123-def"); + expect(result.prompt).toBe("add tests"); + }); });