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
14 changes: 10 additions & 4 deletions discord-bot/src/handlers/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -71,6 +72,7 @@ async function resolveProjectContext(interaction: any): Promise<{
runnerId?: string;
projectPath?: string;
projectChannelId?: string;
targetThreadId?: string;
}> {
const categoryManager = getCategoryManager();
if (!categoryManager) return {};
Expand Down Expand Up @@ -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)
};
}

Expand All @@ -112,7 +115,8 @@ async function resolveProjectContext(interaction: any): Promise<{
return {
runnerId,
...(projectPath ? { projectPath } : {}),
projectChannelId: channel.id
projectChannelId: channel.id,
targetThreadId: await getReusableSessionThreadIdFromContext(interaction)
};
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions discord-bot/src/handlers/dashboard-buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions discord-bot/src/handlers/session-buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getRunnerIdFromContext,
getProjectPathFromContext,
getProjectChannelIdFromContext,
getReusableSessionThreadIdFromContext,
resolveSessionCreationState
} from './session-wizard.js';

Expand All @@ -43,6 +44,7 @@ export {
getRunnerIdFromContext,
getProjectPathFromContext,
getProjectChannelIdFromContext,
getReusableSessionThreadIdFromContext,
resolveSessionCreationState
};

Expand Down
40 changes: 39 additions & 1 deletion discord-bot/src/handlers/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,17 +172,49 @@ export async function getProjectPathFromContext(interaction: any): Promise<strin
return projectInfo?.projectPath;
}

export function hasActiveSessionInThread(threadId: string): boolean {
return storage.getSessionsByThreadId(threadId).some(session => session.status === 'active');
}

export async function getReusableSessionThreadIdFromContext(interaction: any): Promise<string | undefined> {
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 (hasActiveSessionInThread(channel.id)) 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;
Expand Down Expand Up @@ -220,6 +253,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;
}
47 changes: 36 additions & 11 deletions discord-bot/src/handlers/session-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
getRunnerIdFromContext,
getProjectPathFromContext,
getProjectChannelIdFromContext,
getReusableSessionThreadIdFromContext,
hasActiveSessionInThread,
resolveSessionCreationState,
recoverSessionCreationState
} from './session-context.js';
Expand All @@ -49,6 +51,7 @@ export {
getRunnerIdFromContext,
getProjectPathFromContext,
getProjectChannelIdFromContext,
getReusableSessionThreadIdFromContext,
resolveSessionCreationState
};

Expand Down Expand Up @@ -76,7 +79,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)
Expand Down Expand Up @@ -960,17 +964,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 &&
!hasActiveSessionInThread(candidateThread.id) &&
!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;

Expand Down
1 change: 1 addition & 0 deletions discord-bot/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface SessionCreationState {
};
messageId?: string;
projectChannelId?: string;
targetThreadId?: string;
}
export const sessionCreationState = new Map<string, SessionCreationState>();

Expand Down
66 changes: 66 additions & 0 deletions discord-bot/tests/unit/session-context.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});