From 934058284bae55c44ef6d5a33d4fb2015d50e88e Mon Sep 17 00:00:00 2001 From: Yehonal Date: Fri, 29 May 2026 15:20:41 +0000 Subject: [PATCH 1/2] Allow session creation in reusable threads --- discord-bot/src/handlers/commands/session.ts | 14 ++++-- discord-bot/src/handlers/dashboard-buttons.ts | 14 ++++-- discord-bot/src/handlers/session-buttons.ts | 2 + discord-bot/src/handlers/session-context.ts | 36 ++++++++++++++- discord-bot/src/handlers/session-wizard.ts | 46 ++++++++++++++----- discord-bot/src/state.ts | 1 + 6 files changed, 94 insertions(+), 19 deletions(-) diff --git a/discord-bot/src/handlers/commands/session.ts b/discord-bot/src/handlers/commands/session.ts index 5bfef2e..04bafb4 100644 --- a/discord-bot/src/handlers/commands/session.ts +++ b/discord-bot/src/handlers/commands/session.ts @@ -12,6 +12,7 @@ import { createErrorEmbed, createInfoEmbed, createSuccessEmbed } from '../../uti import { getCategoryManager } from '../../services/category-manager.js'; import { getSessionSyncService } from '../../services/session-sync.js'; import { permissionStateStore } from '../../permissions/state-store.js'; +import { getReusableSessionThreadIdFromContext } from '../session-wizard.js'; import type { RunnerInfo, Session } from '../../../../shared/types.ts'; /** @@ -71,6 +72,7 @@ async function resolveProjectContext(interaction: any): Promise<{ runnerId?: string; projectPath?: string; projectChannelId?: string; + targetThreadId?: string; }> { const categoryManager = getCategoryManager(); if (!categoryManager) return {}; @@ -99,7 +101,8 @@ async function resolveProjectContext(interaction: any): Promise<{ return { runnerId: projectInfo.runnerId, projectPath: projectInfo.projectPath, - projectChannelId: channel.id + projectChannelId: channel.id, + targetThreadId: await getReusableSessionThreadIdFromContext(interaction) }; } @@ -112,7 +115,8 @@ async function resolveProjectContext(interaction: any): Promise<{ return { runnerId, ...(projectPath ? { projectPath } : {}), - projectChannelId: channel.id + projectChannelId: channel.id, + targetThreadId: await getReusableSessionThreadIdFromContext(interaction) }; } @@ -161,7 +165,8 @@ export async function handleCreateSession(interaction: any, userId: string): Pro step: 'select_cli', runnerId: runner.runnerId, ...(projectContext.projectPath ? { folderPath: projectContext.projectPath } : {}), - ...(projectContext.projectChannelId ? { projectChannelId: projectContext.projectChannelId } : {}) + ...(projectContext.projectChannelId ? { projectChannelId: projectContext.projectChannelId } : {}), + ...(projectContext.targetThreadId ? { targetThreadId: projectContext.targetThreadId } : {}) }); // Check if we can also auto-select the CLI type @@ -308,7 +313,8 @@ export async function handleCreateSession(interaction: any, userId: string): Pro botState.sessionCreationState.set(userId, { step: 'select_runner', ...(projectContext.projectPath ? { folderPath: projectContext.projectPath } : {}), - ...(projectContext.projectChannelId ? { projectChannelId: projectContext.projectChannelId } : {}) + ...(projectContext.projectChannelId ? { projectChannelId: projectContext.projectChannelId } : {}), + ...(projectContext.targetThreadId ? { targetThreadId: projectContext.targetThreadId } : {}) }); // Row 1: Runner buttons diff --git a/discord-bot/src/handlers/dashboard-buttons.ts b/discord-bot/src/handlers/dashboard-buttons.ts index f1b51d3..7caf19e 100644 --- a/discord-bot/src/handlers/dashboard-buttons.ts +++ b/discord-bot/src/handlers/dashboard-buttons.ts @@ -12,7 +12,12 @@ import { getCategoryManager } from '../services/category-manager.js'; import { getSessionSyncService } from '../services/session-sync.js'; import { listSessions } from '@raylin01/claude-client/sessions'; import { cliToSdkPlugin } from './button-utils.js'; -import { getRunnerIdFromContext, getProjectPathFromContext, getProjectChannelIdFromContext } from './session-buttons.js'; +import { + getRunnerIdFromContext, + getProjectPathFromContext, + getProjectChannelIdFromContext, + getReusableSessionThreadIdFromContext +} from './session-buttons.js'; import { safeDeferReply, safeEditReply } from './interaction-safety.js'; // --------------------------------------------------------------------------- @@ -395,11 +400,13 @@ export async function handleNewSessionButton(interaction: any, userId: string, p // Initialize session creation state with pre-filled values const projectChannelId = await getProjectChannelIdFromContext(interaction); + const targetThreadId = await getReusableSessionThreadIdFromContext(interaction); botState.sessionCreationState.set(userId, { step: 'select_cli', runnerId: runnerId, folderPath: resolvedProjectPath, - projectChannelId + projectChannelId, + targetThreadId }); // SDK-ONLY: If single CLI type, auto-map to SDK plugin and go to review @@ -413,7 +420,8 @@ export async function handleNewSessionButton(interaction: any, userId: string, p cliType: cliType as 'claude' | 'gemini' | 'codex' | 'terminal', plugin, folderPath: resolvedProjectPath, - projectChannelId + projectChannelId, + targetThreadId }); // Go directly to review since we have CLI, plugin, and folder diff --git a/discord-bot/src/handlers/session-buttons.ts b/discord-bot/src/handlers/session-buttons.ts index e0ecbd7..a13c8f2 100644 --- a/discord-bot/src/handlers/session-buttons.ts +++ b/discord-bot/src/handlers/session-buttons.ts @@ -35,6 +35,7 @@ import { getRunnerIdFromContext, getProjectPathFromContext, getProjectChannelIdFromContext, + getReusableSessionThreadIdFromContext, resolveSessionCreationState } from './session-wizard.js'; @@ -43,6 +44,7 @@ export { getRunnerIdFromContext, getProjectPathFromContext, getProjectChannelIdFromContext, + getReusableSessionThreadIdFromContext, resolveSessionCreationState }; diff --git a/discord-bot/src/handlers/session-context.ts b/discord-bot/src/handlers/session-context.ts index c5880dc..bad492a 100644 --- a/discord-bot/src/handlers/session-context.ts +++ b/discord-bot/src/handlers/session-context.ts @@ -9,6 +9,7 @@ import * as botState from '../state.js'; import { storage } from '../storage.js'; import { getCategoryManager } from '../services/category-manager.js'; +import { getSessionSyncService } from '../services/session-sync.js'; // --------------------------------------------------------------------------- // Utility Helpers @@ -171,17 +172,45 @@ export async function getProjectPathFromContext(interaction: any): Promise { + let channel = interaction.channel; + if (!channel && interaction.channelId) { + try { + channel = await interaction.client.channels.fetch(interaction.channelId); + } catch (e) { + return undefined; + } + } + + if (!channel?.isThread?.()) return undefined; + + const parent = channel.parent || (channel.parentId + ? await interaction.client.channels.fetch(channel.parentId).catch(() => null) + : null); + if (!parent?.id) return undefined; + + const categoryManager = getCategoryManager(); + if (!categoryManager?.getProjectByChannelId(parent.id)) return undefined; + + if (storage.getSessionsByThreadId(channel.id).length > 0) return undefined; + if (getSessionSyncService()?.getSessionByThreadId(channel.id)) return undefined; + + return channel.id; +} + export async function recoverSessionCreationState(interaction: any, userId: string) { const runnerId = await getRunnerIdFromContext(interaction); if (!runnerId) return null; const projectPath = await getProjectPathFromContext(interaction); const projectChannelId = await getProjectChannelIdFromContext(interaction); + const targetThreadId = await getReusableSessionThreadIdFromContext(interaction); const state = { step: 'select_cli' as const, runnerId, folderPath: projectPath, - projectChannelId + projectChannelId, + targetThreadId }; botState.sessionCreationState.set(userId, state); return state; @@ -220,6 +249,11 @@ export async function resolveSessionCreationState(interaction: any, userId: stri if (projectChannelId) state.projectChannelId = projectChannelId; } + if (!state.targetThreadId) { + const targetThreadId = await getReusableSessionThreadIdFromContext(interaction); + if (targetThreadId) state.targetThreadId = targetThreadId; + } + botState.sessionCreationState.set(userId, state); return state; } diff --git a/discord-bot/src/handlers/session-wizard.ts b/discord-bot/src/handlers/session-wizard.ts index dab17d9..55105ff 100644 --- a/discord-bot/src/handlers/session-wizard.ts +++ b/discord-bot/src/handlers/session-wizard.ts @@ -40,6 +40,7 @@ import { getRunnerIdFromContext, getProjectPathFromContext, getProjectChannelIdFromContext, + getReusableSessionThreadIdFromContext, resolveSessionCreationState, recoverSessionCreationState } from './session-context.js'; @@ -49,6 +50,7 @@ export { getRunnerIdFromContext, getProjectPathFromContext, getProjectChannelIdFromContext, + getReusableSessionThreadIdFromContext, resolveSessionCreationState }; @@ -76,7 +78,8 @@ export async function handleRunnerSelection(interaction: any, userId: string, cu step: 'select_cli', runnerId: runnerId, ...(existingState?.folderPath ? { folderPath: existingState.folderPath } : {}), - ...(existingState?.projectChannelId ? { projectChannelId: existingState.projectChannelId } : {}) + ...(existingState?.projectChannelId ? { projectChannelId: existingState.projectChannelId } : {}), + ...(existingState?.targetThreadId ? { targetThreadId: existingState.targetThreadId } : {}) }); // CLI type buttons (+ Terminal option) @@ -960,17 +963,38 @@ export async function handleStartSession(interaction: any, userId: string): Prom return; } - const textChannel = channel as any; - const threadType = channelId === runner.privateChannelId - ? ChannelType.PrivateThread - : ChannelType.PublicThread; + let thread: any | null = null; - const thread = await textChannel.threads.create({ - name: `${state.cliType.toUpperCase()}-${Date.now()}`, - type: threadType, - invitable: threadType === ChannelType.PrivateThread ? false : undefined, - reason: `CLI session for ${state.cliType}` - }); + if (state.targetThreadId) { + const candidateThread = await botState.client.channels.fetch(state.targetThreadId).catch(() => null) as any; + const isReusableThread = + candidateThread?.isThread?.() && + candidateThread.parentId === channel.id && + storage.getSessionsByThreadId(candidateThread.id).length === 0 && + !getSessionSyncService()?.getSessionByThreadId(candidateThread.id); + + if (!isReusableThread) { + await safeReplyOrEdit(interaction, { + embeds: [createErrorEmbed('Cannot Reuse Thread', 'This thread is already bound to a session or is no longer under the selected project channel.')], + flags: 64 + }); + return; + } + + thread = candidateThread; + } else { + const textChannel = channel as any; + const threadType = channelId === runner.privateChannelId + ? ChannelType.PrivateThread + : ChannelType.PublicThread; + + thread = await textChannel.threads.create({ + name: `${state.cliType.toUpperCase()}-${Date.now()}`, + type: threadType, + invitable: threadType === ChannelType.PrivateThread ? false : undefined, + reason: `CLI session for ${state.cliType}` + }); + } const storageCLIType = state.cliType === 'terminal' ? 'generic' : state.cliType; diff --git a/discord-bot/src/state.ts b/discord-bot/src/state.ts index 0ce5cf6..d7b0174 100644 --- a/discord-bot/src/state.ts +++ b/discord-bot/src/state.ts @@ -67,6 +67,7 @@ export interface SessionCreationState { }; messageId?: string; projectChannelId?: string; + targetThreadId?: string; } export const sessionCreationState = new Map(); From 48bbba9ba86bf576d6c364c58591884642ca6f9b Mon Sep 17 00:00:00 2001 From: Yehonal Date: Mon, 15 Jun 2026 09:09:05 +0000 Subject: [PATCH 2/2] Allow reuse of ended session threads --- discord-bot/src/handlers/session-context.ts | 6 +- discord-bot/src/handlers/session-wizard.ts | 3 +- .../tests/unit/session-context.test.ts | 66 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 discord-bot/tests/unit/session-context.test.ts diff --git a/discord-bot/src/handlers/session-context.ts b/discord-bot/src/handlers/session-context.ts index bad492a..fa131c7 100644 --- a/discord-bot/src/handlers/session-context.ts +++ b/discord-bot/src/handlers/session-context.ts @@ -172,6 +172,10 @@ export async function getProjectPathFromContext(interaction: any): Promise session.status === 'active'); +} + export async function getReusableSessionThreadIdFromContext(interaction: any): Promise { let channel = interaction.channel; if (!channel && interaction.channelId) { @@ -192,7 +196,7 @@ export async function getReusableSessionThreadIdFromContext(interaction: any): P const categoryManager = getCategoryManager(); if (!categoryManager?.getProjectByChannelId(parent.id)) return undefined; - if (storage.getSessionsByThreadId(channel.id).length > 0) return undefined; + if (hasActiveSessionInThread(channel.id)) return undefined; if (getSessionSyncService()?.getSessionByThreadId(channel.id)) return undefined; return channel.id; diff --git a/discord-bot/src/handlers/session-wizard.ts b/discord-bot/src/handlers/session-wizard.ts index 55105ff..5f10326 100644 --- a/discord-bot/src/handlers/session-wizard.ts +++ b/discord-bot/src/handlers/session-wizard.ts @@ -41,6 +41,7 @@ import { getProjectPathFromContext, getProjectChannelIdFromContext, getReusableSessionThreadIdFromContext, + hasActiveSessionInThread, resolveSessionCreationState, recoverSessionCreationState } from './session-context.js'; @@ -970,7 +971,7 @@ export async function handleStartSession(interaction: any, userId: string): Prom const isReusableThread = candidateThread?.isThread?.() && candidateThread.parentId === channel.id && - storage.getSessionsByThreadId(candidateThread.id).length === 0 && + !hasActiveSessionInThread(candidateThread.id) && !getSessionSyncService()?.getSessionByThreadId(candidateThread.id); if (!isReusableThread) { diff --git a/discord-bot/tests/unit/session-context.test.ts b/discord-bot/tests/unit/session-context.test.ts new file mode 100644 index 0000000..e8aef3f --- /dev/null +++ b/discord-bot/tests/unit/session-context.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { storage } from '../../src/storage'; +import { getReusableSessionThreadIdFromContext, hasActiveSessionInThread } from '../../src/handlers/session-context'; + +vi.mock('../../src/services/category-manager', () => ({ + getCategoryManager: () => ({ + getProjectByChannelId: (channelId: string) => ( + channelId === 'project-channel' + ? { runnerId: 'runner-1', projectPath: '/workspace/project', project: { channelId, projectPath: '/workspace/project' } } + : null + ) + }) +})); + +vi.mock('../../src/services/session-sync', () => ({ + getSessionSyncService: () => ({ + getSessionByThreadId: () => null + }) +})); + +describe('session context thread reuse', () => { + beforeEach(() => { + vi.spyOn(storage, 'getSessionsByThreadId').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function interactionForProjectThread(threadId = 'thread-1') { + const parent = { id: 'project-channel' }; + const thread = { + id: threadId, + parentId: parent.id, + parent, + isThread: () => true + }; + return { + channel: thread, + channelId: thread.id, + client: { + channels: { + fetch: vi.fn(async (id: string) => id === parent.id ? parent : thread) + } + } + }; + } + + it('treats a thread with only ended sessions as reusable', async () => { + vi.mocked(storage.getSessionsByThreadId).mockReturnValue([ + { sessionId: 'old-session', threadId: 'thread-1', status: 'ended', createdAt: new Date().toISOString() } as any + ]); + + expect(hasActiveSessionInThread('thread-1')).toBe(false); + await expect(getReusableSessionThreadIdFromContext(interactionForProjectThread())).resolves.toBe('thread-1'); + }); + + it('does not reuse a thread with an active session', async () => { + vi.mocked(storage.getSessionsByThreadId).mockReturnValue([ + { sessionId: 'active-session', threadId: 'thread-1', status: 'active', createdAt: new Date().toISOString() } as any + ]); + + expect(hasActiveSessionInThread('thread-1')).toBe(true); + await expect(getReusableSessionThreadIdFromContext(interactionForProjectThread())).resolves.toBeUndefined(); + }); +});