Skip to content
Merged
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
40 changes: 40 additions & 0 deletions docs/plans/2026-03-06-session-continuity-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Session Continuity via Thread-Based Session Tracking

## Problem

현재 매 요청마다 새 Claude 세션을 생성하여, 같은 Slack 쓰레드 내에서도 대화가 이어지지 않음.
쓰레드 메시지를 컨텍스트로 주입하여 부분적으로 커버하지만, Claude 세션 자체의 연속성은 없음.

## Design

### Session ID 결정 우선순위

1. `session:new` → 새 세션 강제 생성
2. `session:<uuid>` → 명시적 세션 ID 지정 (다른 쓰레드 세션 이어가기)
3. 쓰레드 메시지에서 마지막 세션 ID 역추출 → 자동 이어가기
4. 위 모두 없으면 → 새 세션 생성

### Parser 확장

`ParsedMessage`에 `session: string | null` 필드 추가. `"new"` 또는 UUID 문자열.
기존 `repo:`, `model:`과 동일한 `session:\S+` 패턴으로 파싱.

### fetchThreadContext 변경

반환 타입을 `{ context: string; lastSessionId: string | null }`로 확장.
봇 메시지에서 `:link: \`<uuid>\`` 패턴을 정규식으로 매치하여 마지막 세션 ID 추출.
세션을 이어가는 경우 thread context 문자열은 비워서 중복 주입 방지.

### resolveSessionId

`maybeSessionId` 대체. 파서에서 받은 session 값, 쓰레드에서 추출한 세션 ID, config를 받아 최종 세션 ID 결정.

## 변경 범위

| 파일 | 변경 |
|------|------|
| `parser.ts` | `session` 필드 추가 |
| `handler.ts` | `resolveSessionId`, `fetchThreadContext` 반환 타입 확장, 세션 이어갈 때 context 생략 |
| `responder.ts` | 변경 없음 |
| `runner.ts` | 변경 없음 |
| `config.ts` | 변경 없음 |
11 changes: 9 additions & 2 deletions src/claude/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ClaudeOptions {
maxOutputTokens?: number;
model?: string;
sessionId?: string;
isResuming?: boolean;
}

export type StreamEvent =
Expand Down Expand Up @@ -50,11 +51,17 @@ function buildClaudeEnv(maxOutputTokens?: number): Record<string, string> {
}

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;
}

Expand Down
69 changes: 49 additions & 20 deletions src/slack/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
): 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({
Expand All @@ -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 };
}
}

Expand All @@ -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 ? "..." : ""}"`,
Expand All @@ -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(
Expand All @@ -120,6 +146,7 @@ export function createMentionHandler(config: CCSlackConfig, queue: TaskQueue) {
maxOutputTokens: config.maxOutputTokens,
model: resolvedModel,
sessionId,
isResuming,
}),
);

Expand Down Expand Up @@ -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 ? "..." : ""}"`,
Expand All @@ -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();
Expand All @@ -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) {
Expand Down
33 changes: 14 additions & 19 deletions src/slack/parser.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
21 changes: 19 additions & 2 deletions tests/claude/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -28,16 +28,33 @@ 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",
claudePath: "echo",
timeout: 5000,
});
expect(result.output).not.toContain("--session-id");
expect(result.output).not.toContain("--resume");
});

it("returns failure for nonexistent command", async () => {
Expand Down
Loading