diff --git a/packages/adapter-java/java/JdiDapServer.java b/packages/adapter-java/java/JdiDapServer.java index 49ebdf1e..c49fc053 100644 --- a/packages/adapter-java/java/JdiDapServer.java +++ b/packages/adapter-java/java/JdiDapServer.java @@ -59,6 +59,7 @@ public class JdiDapServer { private volatile boolean configurationDone = false; private volatile boolean launchSuspended = false; // true if we launched with suspend=y private volatile boolean stopOnEntry = true; // whether to stop on entry in launch mode + private volatile boolean lastStopAllThreads = true; // tracks whether last stop suspended all threads // --- Logging --- private static boolean debug = false; @@ -195,6 +196,7 @@ private void handleMessage(Map msg) { case "scopes": handleScopes(reqSeq, args); break; case "variables": handleVariables(reqSeq, args); break; case "continue": handleContinue(reqSeq, args); break; + case "pause": handlePause(reqSeq, args); break; case "next": handleStep(reqSeq, args, StepRequest.STEP_OVER); break; case "stepIn": handleStep(reqSeq, args, StepRequest.STEP_INTO); break; case "stepOut": handleStep(reqSeq, args, StepRequest.STEP_OUT); break; @@ -392,7 +394,8 @@ private void registerPendingBreakpoints() { Map bpSpec = asMap(bpObj); int line = intVal(bpSpec, "line"); String condition = str(bpSpec, "condition"); - Map bp = setBreakpointOnClass(found, line, condition, sourcePath); + String suspendPol = str(bpSpec, "suspendPolicy"); + Map bp = setBreakpointOnClass(found, line, condition, sourcePath, suspendPol); if (!Boolean.TRUE.equals(bp.get("verified"))) { hasUnresolved = true; } @@ -543,7 +546,8 @@ private void handleSetBreakpoints(int reqSeq, Map args) { Map bpSpec = asMap(bpObj); int line = intVal(bpSpec, "line"); String condition = str(bpSpec, "condition"); - Map bp = setBreakpointOnClass(refType, line, condition, sourcePath); + String suspendPol = str(bpSpec, "suspendPolicy"); + Map bp = setBreakpointOnClass(refType, line, condition, sourcePath, suspendPol); results.add(bp); if (!Boolean.TRUE.equals(bp.get("verified"))) { hasUnresolvedBreakpoints = true; @@ -600,7 +604,7 @@ private void handleSetBreakpoints(int reqSeq, Map args) { sendResponse(reqSeq, "setBreakpoints", true, mapOf("breakpoints", results)); } - private Map setBreakpointOnClass(ReferenceType refType, int line, String condition, String sourcePath) { + private Map setBreakpointOnClass(ReferenceType refType, int line, String condition, String sourcePath, String suspendPolicy) { Map bp = new HashMap<>(); bp.put("id", nextBreakpointId.getAndIncrement()); try { @@ -624,7 +628,12 @@ private Map setBreakpointOnClass(ReferenceType refType, int line if (sourcePath != null) { bpr.putProperty("jdi-bp-source", sourcePath); } - bpr.setSuspendPolicy(EventRequest.SUSPEND_ALL); + // "thread" = only suspend the event thread; default = suspend all threads + if ("thread".equals(suspendPolicy)) { + bpr.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); + } else { + bpr.setSuspendPolicy(EventRequest.SUSPEND_ALL); + } bpr.enable(); bp.put("verified", true); @@ -891,16 +900,62 @@ private void handleContinue(int reqSeq, Map args) { sendErrorResponse(reqSeq, "continue", "No active debug session"); return; } + long threadId = longVal(args, "threadId"); + boolean allContinued; try { - vm.resume(); + if (!lastStopAllThreads && threadId > 0) { + // Only a single thread was suspended — resume just that thread + for (ThreadReference t : vm.allThreads()) { + if (t.uniqueID() == threadId) { + t.resume(); + break; + } + } + allContinued = false; + } else { + vm.resume(); + allContinued = true; + } } catch (VMDisconnectedException e) { - // VM already gone + allContinued = true; } Map body = new HashMap<>(); - body.put("allThreadsContinued", true); + body.put("allThreadsContinued", allContinued); sendResponse(reqSeq, "continue", true, body); } + private void handlePause(int reqSeq, Map args) { + if (vm == null) { + sendErrorResponse(reqSeq, "pause", "No active debug session"); + return; + } + long threadId = longVal(args, "threadId"); + try { + if (threadId > 0) { + // Pause a specific thread + for (ThreadReference t : vm.allThreads()) { + if (t.uniqueID() == threadId) { + t.suspend(); + sendStoppedEvent("pause", t.uniqueID(), false); + sendResponse(reqSeq, "pause", true, new HashMap<>()); + return; + } + } + sendErrorResponse(reqSeq, "pause", "Thread not found: " + threadId); + } else { + // Pause all threads (threadId 0 or absent) + vm.suspend(); + // Pick the first thread for the stopped event + List threads = vm.allThreads(); + long stoppedThreadId = threads.isEmpty() ? 0 : threads.get(0).uniqueID(); + sendStoppedEvent("pause", stoppedThreadId); + sendResponse(reqSeq, "pause", true, new HashMap<>()); + } + } catch (VMDisconnectedException e) { + sendErrorResponse(reqSeq, "pause", "VM disconnected"); + } + } + private void handleStep(int reqSeq, Map args, int depth) { long threadId = longVal(args, "threadId"); String cmdName = stepCommandName(depth); @@ -1043,7 +1098,8 @@ private void startEventLoop() { } log("Breakpoint hit: " + bpe.location()); - sendStoppedEvent("breakpoint", bpe.thread().uniqueID()); + boolean allStopped = bpr == null || bpr.suspendPolicy() == EventRequest.SUSPEND_ALL; + sendStoppedEvent("breakpoint", bpe.thread().uniqueID(), allStopped); resume = false; } else if (event instanceof StepEvent) { @@ -1161,7 +1217,8 @@ private void handleClassPrepared(ReferenceType refType) { Map bpSpec = asMap(bpObj); int line = intVal(bpSpec, "line"); String condition = str(bpSpec, "condition"); - Map bp = setBreakpointOnClass(refType, line, condition, sourcePath); + String suspendPol = str(bpSpec, "suspendPolicy"); + Map bp = setBreakpointOnClass(refType, line, condition, sourcePath, suspendPol); // Send breakpoint verified event if (Boolean.TRUE.equals(bp.get("verified"))) { @@ -1341,10 +1398,15 @@ private void sendEvent(String event, Map body) { } private void sendStoppedEvent(String reason, long threadId) { + sendStoppedEvent(reason, threadId, true); + } + + private void sendStoppedEvent(String reason, long threadId, boolean allThreadsStopped) { + lastStopAllThreads = allThreadsStopped; Map body = new HashMap<>(); body.put("reason", reason); body.put("threadId", threadId); - body.put("allThreadsStopped", true); + body.put("allThreadsStopped", allThreadsStopped); sendEvent("stopped", body); } diff --git a/packages/shared/src/models/index.ts b/packages/shared/src/models/index.ts index 1bb3e51e..7ec4b419 100644 --- a/packages/shared/src/models/index.ts +++ b/packages/shared/src/models/index.ts @@ -210,6 +210,8 @@ export interface Breakpoint { line: number; /** Conditional expression (if any) */ condition?: string; + /** Suspend policy: 'all' suspends all threads (default), 'thread' only suspends the event thread */ + suspendPolicy?: 'all' | 'thread'; /** Whether the breakpoint is verified */ verified: boolean; /** Validation message from DAP adapter */ diff --git a/src/server.ts b/src/server.ts index e8b6d655..e28368ad 100644 --- a/src/server.ts +++ b/src/server.ts @@ -97,6 +97,8 @@ interface ToolArguments { stopOnEntry?: boolean; justMyCode?: boolean; terminateProcess?: boolean; + suspendPolicy?: 'all' | 'thread'; + threadId?: number; } /** @@ -336,14 +338,14 @@ export class DebugMcpServer { return this.sessionManager.closeSession(sessionId); } - public async setBreakpoint(sessionId: string, file: string, line: number, condition?: string): Promise { + public async setBreakpoint(sessionId: string, file: string, line: number, condition?: string, suspendPolicy?: 'all' | 'thread'): Promise { this.validateSession(sessionId); // Check if the adapter handles non-file source identifiers (e.g. Java FQCNs) const policy = this.sessionManager.getSessionPolicy(sessionId); if (policy.isNonFileSourceIdentifier?.(file)) { this.logger.info(`[DebugMcpServer.setBreakpoint] Non-file source identifier detected: ${file}`); - return this.sessionManager.setBreakpoint(sessionId, file, line, condition); + return this.sessionManager.setBreakpoint(sessionId, file, line, condition, suspendPolicy); } // Check file exists for immediate feedback @@ -355,7 +357,7 @@ export class DebugMcpServer { this.logger.info(`[DebugMcpServer.setBreakpoint] File exists: ${fileCheck.effectivePath} (original: ${file})`); // Pass the effective path (which has been resolved for container) to session manager - return this.sessionManager.setBreakpoint(sessionId, fileCheck.effectivePath, line, condition); + return this.sessionManager.setBreakpoint(sessionId, fileCheck.effectivePath, line, condition, suspendPolicy); } public async getVariables(sessionId: string, variablesReference: number): Promise { @@ -530,7 +532,7 @@ export class DebugMcpServer { { name: 'create_debug_session', description: 'Create a new debugging session. Provide host and port to attach to a running process; omit them for launch mode', inputSchema: { type: 'object', properties: { language: { type: 'string', enum: supportedLanguages, description: 'Programming language for debugging' }, name: { type: 'string', description: 'Optional session name' }, executablePath: {type: 'string', description: 'Path to language executable (optional, will auto-detect if not provided)'}, host: { type: 'string', description: 'Host to attach to for remote debugging (optional, triggers attach mode)' }, port: { type: 'number', description: 'Debug port to attach to for remote debugging (optional, triggers attach mode)' }, timeout: { type: 'number', description: 'Connection timeout in milliseconds for attach mode (default: 30000)' } }, required: ['language'] } }, { name: 'list_supported_languages', description: 'List all supported debugging languages with metadata', inputSchema: { type: 'object', properties: {} } }, { name: 'list_debug_sessions', description: 'List all active debugging sessions', inputSchema: { type: 'object', properties: {} } }, - { name: 'set_breakpoint', description: 'Set a breakpoint. Setting breakpoints on non-executable lines (structural, declarative) may lead to unexpected behavior', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: 'Path to the source file or Java FQCN. For Java, passing a fully-qualified class name (e.g. "com.example.MyClass" or "com.example.Outer$Inner") is preferred — it works reliably with all classloaders including custom classloaders. Alternatively, use absolute file paths.' }, line: { type: 'number', description: 'Line number where to set breakpoint. Executable statements (assignments, function calls, conditionals, returns) work best. Structural lines (function/class definitions), declarative lines (imports), or non-executable lines (comments, blank lines) may cause unexpected stepping behavior' }, condition: { type: 'string' } }, required: ['sessionId', 'file', 'line'] } }, + { name: 'set_breakpoint', description: 'Set a breakpoint. Setting breakpoints on non-executable lines (structural, declarative) may lead to unexpected behavior', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, file: { type: 'string', description: 'Path to the source file or Java FQCN. For Java, passing a fully-qualified class name (e.g. "com.example.MyClass" or "com.example.Outer$Inner") is preferred — it works reliably with all classloaders including custom classloaders. Alternatively, use absolute file paths.' }, line: { type: 'number', description: 'Line number where to set breakpoint. Executable statements (assignments, function calls, conditionals, returns) work best. Structural lines (function/class definitions), declarative lines (imports), or non-executable lines (comments, blank lines) may cause unexpected stepping behavior' }, condition: { type: 'string' }, suspendPolicy: { type: 'string', enum: ['all', 'thread'], description: 'Suspend policy when breakpoint is hit: "all" suspends all threads (default), "thread" only suspends the event thread. Only supported by the Java/JDI adapter.' } }, required: ['sessionId', 'file', 'line'] } }, { name: 'start_debugging', description: 'Start debugging a script', inputSchema: { type: 'object', properties: { @@ -584,7 +586,8 @@ export class DebugMcpServer { { name: 'step_into', description: 'Step into', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'step_out', description: 'Step out', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'continue_execution', description: 'Continue execution', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, - { name: 'pause_execution', description: 'Pause a running program', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, + { name: 'pause_execution', description: 'Pause a running program', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads. Only supported by the Java/JDI adapter.' } }, required: ['sessionId'] } }, + { name: 'list_threads', description: 'List all threads in the debugged process', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'get_variables', description: 'Get variables (scope is variablesReference: number)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, scope: { type: 'number', description: "The variablesReference number from a StackFrame or Variable" } }, required: ['sessionId', 'scope'] } }, { name: 'get_local_variables', description: 'Get local variables for the current stack frame. This is a convenience tool that returns just the local variables without needing to traverse stack->scopes->variables manually', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeSpecial: { type: 'boolean', description: 'Include special/internal variables like this, __proto__, __builtins__, etc. Default: false' } }, required: ['sessionId'] } }, { name: 'get_stack_trace', description: 'Get stack trace', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, includeInternals: { type: 'boolean', description: 'Include internal/framework frames (e.g., Node.js internals). Default: false for cleaner output.' } }, required: ['sessionId'] } }, @@ -702,7 +705,7 @@ export class DebugMcpServer { } try { - const breakpoint = await this.setBreakpoint(args.sessionId, args.file, args.line, args.condition); + const breakpoint = await this.setBreakpoint(args.sessionId, args.file, args.line, args.condition, args.suspendPolicy); // Log breakpoint event this.logger.info('debug:breakpoint', { @@ -1045,7 +1048,14 @@ export class DebugMcpServer { break; } case 'pause_execution': { - result = await this.handlePause(args as { sessionId: string }); + result = await this.handlePause(args as { sessionId: string; threadId?: number }); + break; + } + case 'list_threads': { + if (!args.sessionId) { + throw new McpError(McpErrorCode.InvalidParams, 'Missing required sessionId'); + } + result = await this.handleListThreads(args as { sessionId: string }); break; } case 'get_variables': { @@ -1202,10 +1212,10 @@ export class DebugMcpServer { } } - private async handlePause(args: { sessionId: string }): Promise { + private async handlePause(args: { sessionId: string; threadId?: number }): Promise { try { this.validateSession(args.sessionId); - const result = await this.sessionManager.pause(args.sessionId); + const result = await this.sessionManager.pause(args.sessionId, args.threadId); return { content: [{ type: 'text', text: JSON.stringify(result) }] }; } catch (error) { this.logger.error('Failed to pause execution', { error }); @@ -1219,6 +1229,18 @@ export class DebugMcpServer { } } + private async handleListThreads(args: { sessionId: string }): Promise { + try { + this.validateSession(args.sessionId); + const threads = await this.sessionManager.listThreads(args.sessionId); + return { content: [{ type: 'text', text: JSON.stringify({ success: true, threads }) }] }; + } catch (error) { + this.logger.error('Failed to list threads', { error }); + if (error instanceof McpError) throw error; + throw new McpError(McpErrorCode.InternalError, `Failed to list threads: ${(error as Error).message}`); + } + } + private async handleEvaluateExpression(args: { sessionId: string, expression: string, frameId?: number }): Promise { try { // Validate session diff --git a/src/session/session-manager-operations.ts b/src/session/session-manager-operations.ts index db8332ca..9b851a42 100644 --- a/src/session/session-manager-operations.ts +++ b/src/session/session-manager-operations.ts @@ -758,7 +758,8 @@ export abstract class SessionManagerOperations extends SessionManagerData { sessionId: string, file: string, line: number, - condition?: string + condition?: string, + suspendPolicy?: 'all' | 'thread' ): Promise { const session = this._getSessionById(sessionId); @@ -774,7 +775,7 @@ export abstract class SessionManagerOperations extends SessionManagerData { `[SessionManager setBreakpoint] Using validated file path "${file}" for session ${sessionId}` ); - const newBreakpoint: Breakpoint = { id: bpId, file, line, condition, verified: false }; + const newBreakpoint: Breakpoint = { id: bpId, file, line, condition, suspendPolicy, verified: false }; if (!session.breakpoints) session.breakpoints = new Map(); session.breakpoints.set(bpId, newBreakpoint); @@ -800,7 +801,11 @@ export abstract class SessionManagerOperations extends SessionManagerData { 'setBreakpoints', { source: { path: file }, - breakpoints: allBpsForFile.map(bp => ({ line: bp.line, condition: bp.condition })), + breakpoints: allBpsForFile.map(bp => ({ + line: bp.line, + condition: bp.condition, + ...(bp.suspendPolicy ? { suspendPolicy: bp.suspendPolicy } : {}), + })), } ); if ( @@ -1151,7 +1156,7 @@ export abstract class SessionManagerOperations extends SessionManagerData { } } - async pause(sessionId: string): Promise { + async pause(sessionId: string, threadId?: number): Promise { const session = this._getSessionById(sessionId); if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { @@ -1175,8 +1180,8 @@ export abstract class SessionManagerOperations extends SessionManagerData { } try { - // DAP pause request uses threadId 0 to pause all threads - await session.proxyManager.sendDapRequest('pause', { threadId: 0 }); + // DAP pause request: threadId 0 pauses all threads, specific ID pauses only that thread + await session.proxyManager.sendDapRequest('pause', { threadId: threadId ?? 0 }); this.logger.info( `[SessionManager pause] DAP 'pause' sent for session ${sessionId}. Waiting for stopped event.` @@ -1194,6 +1199,21 @@ export abstract class SessionManagerOperations extends SessionManagerData { } } + async listThreads(sessionId: string): Promise> { + const session = this._getSessionById(sessionId); + + if (session.sessionLifecycle === SessionLifecycleState.TERMINATED) { + throw new SessionTerminatedError(sessionId); + } + + if (!session.proxyManager || !session.proxyManager.isRunning()) { + throw new ProxyNotRunningError(sessionId, 'listThreads'); + } + + const response = await session.proxyManager.sendDapRequest('threads', {}); + return (response?.body?.threads ?? []).map(t => ({ id: t.id, name: t.name })); + } + /** * Helper method to truncate long strings for logging */ diff --git a/tests/core/unit/server/server-control-tools.test.ts b/tests/core/unit/server/server-control-tools.test.ts index 76d73e67..7c2a84ff 100644 --- a/tests/core/unit/server/server-control-tools.test.ts +++ b/tests/core/unit/server/server-control-tools.test.ts @@ -83,6 +83,7 @@ describe('Server Control Tools Tests', () => { 'test-session', expect.stringContaining('/path/to/test.py'), 10, + undefined, undefined ); @@ -125,14 +126,52 @@ describe('Server Control Tools Tests', () => { 'test-session', expect.stringContaining('/path/to/test.py'), 20, - 'x > 10' + 'x > 10', + undefined + ); + }); + + it('should pass suspendPolicy to SessionManager', async () => { + const mockBreakpoint = { + id: 'bp-3', + file: '/path/to/test.py', + line: 30, + suspendPolicy: 'thread' as const, + verified: true + }; + + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: 'ACTIVE' + }); + mockSessionManager.setBreakpoint.mockResolvedValue(mockBreakpoint); + + await callToolHandler({ + method: 'tools/call', + params: { + name: 'set_breakpoint', + arguments: { + sessionId: 'test-session', + file: '/path/to/test.py', + line: 30, + suspendPolicy: 'thread' + } + } + }); + + expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith( + 'test-session', + expect.stringContaining('/path/to/test.py'), + 30, + undefined, + 'thread' ); }); it('should handle SessionManager errors', async () => { // Mock getSession to return null - session not found mockSessionManager.getSession.mockReturnValue(null); - + const result = await callToolHandler({ method: 'tools/call', params: { @@ -399,7 +438,31 @@ describe('Server Control Tools Tests', () => { const content = JSON.parse(result.content[0].text); expect(content.success).toBe(true); - expect(mockSessionManager.pause).toHaveBeenCalledWith('test-session'); + expect(mockSessionManager.pause).toHaveBeenCalledWith('test-session', undefined); + }); + + it('should pause a specific thread when threadId is provided', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'running', + sessionLifecycle: 'ACTIVE' + }); + mockSessionManager.pause.mockResolvedValue({ + success: true, + state: 'paused' + }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { + name: 'pause_execution', + arguments: { sessionId: 'test-session', threadId: 42 } + } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.success).toBe(true); + expect(mockSessionManager.pause).toHaveBeenCalledWith('test-session', 42); }); it('should handle pause on non-existent session', async () => { @@ -414,4 +477,55 @@ describe('Server Control Tools Tests', () => { })).rejects.toThrow(McpError); }); }); + + describe('list_threads', () => { + it('should list threads successfully', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + state: 'paused', + sessionLifecycle: 'ACTIVE' + }); + mockSessionManager.listThreads.mockResolvedValue([ + { id: 1, name: 'main' }, + { id: 2, name: 'AWT-EventQueue-0' }, + ]); + + const result = await callToolHandler({ + method: 'tools/call', + params: { + name: 'list_threads', + arguments: { sessionId: 'test-session' } + } + }); + + const content = JSON.parse(result.content[0].text); + expect(content.success).toBe(true); + expect(content.threads).toHaveLength(2); + expect(content.threads[0]).toEqual({ id: 1, name: 'main' }); + expect(content.threads[1]).toEqual({ id: 2, name: 'AWT-EventQueue-0' }); + expect(mockSessionManager.listThreads).toHaveBeenCalledWith('test-session'); + }); + + it('should handle list_threads on non-existent session', async () => { + mockSessionManager.getSession.mockReturnValue(null); + + await expect(callToolHandler({ + method: 'tools/call', + params: { + name: 'list_threads', + arguments: { sessionId: 'test-session' } + } + })).rejects.toThrow(McpError); + }); + + it('should reject list_threads with missing sessionId', async () => { + await expect(callToolHandler({ + method: 'tools/call', + params: { + name: 'list_threads', + arguments: {} + } + })).rejects.toThrow('Missing required sessionId'); + }); + }); }); diff --git a/tests/core/unit/server/server-test-helpers.ts b/tests/core/unit/server/server-test-helpers.ts index 4f972d5c..f42bb08c 100644 --- a/tests/core/unit/server/server-test-helpers.ts +++ b/tests/core/unit/server/server-test-helpers.ts @@ -100,6 +100,7 @@ export function createMockSessionManager(mockAdapterRegistry: any) { evaluateExpression: vi.fn(), getSessionPolicy: vi.fn().mockReturnValue({}), pause: vi.fn(), + listThreads: vi.fn(), detachFromProcess: vi.fn(), attachToProcess: vi.fn(), getAdapterRegistry: vi.fn().mockReturnValue(mockAdapterRegistry), diff --git a/tests/unit/server-coverage.test.ts b/tests/unit/server-coverage.test.ts index 837cc0a3..e75d02ec 100644 --- a/tests/unit/server-coverage.test.ts +++ b/tests/unit/server-coverage.test.ts @@ -321,7 +321,7 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { expect(result.verified).toBe(true); expect(mockFileChecker.checkExists).not.toHaveBeenCalled(); - expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith('test-session', 'com.example.MyClass', 42, undefined); + expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith('test-session', 'com.example.MyClass', 42, undefined, undefined); }); it('should skip file existence check for inner class notation via policy', async () => { @@ -670,10 +670,10 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { // Both calls should have been made expect(mockSessionManager.setBreakpoint).toHaveBeenCalledTimes(2); expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith( - 'test-session', 'com.example.Foo', 10, undefined + 'test-session', 'com.example.Foo', 10, undefined, undefined ); expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith( - 'test-session', 'com.example.Foo', 20, undefined + 'test-session', 'com.example.Foo', 20, undefined, undefined ); }); @@ -722,14 +722,85 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { // Both should be set independently expect(mockSessionManager.setBreakpoint).toHaveBeenCalledTimes(2); expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith( - 'test-session', 'com.a.Foo', 10, undefined + 'test-session', 'com.a.Foo', 10, undefined, undefined ); expect(mockSessionManager.setBreakpoint).toHaveBeenCalledWith( - 'test-session', 'com.b.Foo', 15, undefined + 'test-session', 'com.b.Foo', 15, undefined, undefined ); }); }); + describe('handleListThreads', () => { + it('should return threads on success', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: SessionLifecycleState.ACTIVE + }); + mockSessionManager.listThreads = vi.fn().mockResolvedValue([ + { id: 1, name: 'main' }, + { id: 2, name: 'worker-1' }, + ]); + + const result = await (server as any).handleListThreads({ sessionId: 'test-session' }); + const payload = JSON.parse(result.content[0].text); + + expect(payload.success).toBe(true); + expect(payload.threads).toHaveLength(2); + expect(payload.threads[0]).toEqual({ id: 1, name: 'main' }); + }); + + it('should re-throw McpError subclasses (SessionTerminatedError etc.)', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: SessionLifecycleState.ACTIVE + }); + const { SessionTerminatedError } = await import('../../src/errors/debug-errors'); + mockSessionManager.listThreads = vi.fn().mockRejectedValue(new SessionTerminatedError('test-session')); + + await expect((server as any).handleListThreads({ sessionId: 'test-session' })) + .rejects.toThrow(McpError); + }); + + it('should throw McpError for unknown errors', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: SessionLifecycleState.ACTIVE + }); + mockSessionManager.listThreads = vi.fn().mockRejectedValue(new Error('unexpected')); + + await expect((server as any).handleListThreads({ sessionId: 'test-session' })) + .rejects.toThrow('Failed to list threads: unexpected'); + }); + }); + + describe('handlePause with threadId', () => { + it('should pass threadId to session manager pause', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: SessionLifecycleState.ACTIVE + }); + mockSessionManager.pause = vi.fn().mockResolvedValue({ success: true, state: 'paused' }); + + const result = await (server as any).handlePause({ sessionId: 'test-session', threadId: 7 }); + const payload = JSON.parse(result.content[0].text); + + expect(payload.success).toBe(true); + expect(mockSessionManager.pause).toHaveBeenCalledWith('test-session', 7); + }); + + it('should pass undefined threadId when not provided', async () => { + mockSessionManager.getSession.mockReturnValue({ + id: 'test-session', + sessionLifecycle: SessionLifecycleState.ACTIVE + }); + mockSessionManager.pause = vi.fn().mockResolvedValue({ success: true, state: 'paused' }); + + await (server as any).handlePause({ sessionId: 'test-session' }); + + expect(mockSessionManager.pause).toHaveBeenCalledWith('test-session', undefined); + }); + }); + describe('Evaluate Expression Edge Cases', () => { it('should handle expression evaluation in terminated session', async () => { mockSessionManager.getSession.mockReturnValue({ diff --git a/tests/unit/session-manager-operations-coverage.test.ts b/tests/unit/session-manager-operations-coverage.test.ts index b170bb3a..26c3fb8e 100644 --- a/tests/unit/session-manager-operations-coverage.test.ts +++ b/tests/unit/session-manager-operations-coverage.test.ts @@ -2322,4 +2322,118 @@ describe('Session Manager Operations Coverage - Error Paths and Edge Cases', () expect(frames[0].name).toBe('MyApp.Main'); }); }); + + describe('listThreads', () => { + it('should return mapped threads from DAP response', async () => { + mockSession.state = SessionState.RUNNING; + mockProxyManager.sendDapRequest.mockResolvedValue({ + body: { + threads: [ + { id: 1, name: 'main' }, + { id: 2, name: 'AWT-EventQueue-0' }, + ] + } + }); + + const threads = await operations.listThreads('test-session'); + + expect(threads).toEqual([ + { id: 1, name: 'main' }, + { id: 2, name: 'AWT-EventQueue-0' }, + ]); + expect(mockProxyManager.sendDapRequest).toHaveBeenCalledWith('threads', {}); + }); + + it('should return empty array when DAP response has no threads', async () => { + mockSession.state = SessionState.RUNNING; + mockProxyManager.sendDapRequest.mockResolvedValue({}); + + const threads = await operations.listThreads('test-session'); + + expect(threads).toEqual([]); + }); + + it('should throw SessionTerminatedError for terminated session', async () => { + mockSession.sessionLifecycle = SessionLifecycleState.TERMINATED; + + await expect(operations.listThreads('test-session')) + .rejects.toBeInstanceOf(SessionTerminatedError); + }); + + it('should throw ProxyNotRunningError when proxy is not running', async () => { + mockProxyManager.isRunning.mockReturnValue(false); + + await expect(operations.listThreads('test-session')) + .rejects.toBeInstanceOf(ProxyNotRunningError); + }); + + it('should throw ProxyNotRunningError when proxy manager is null', async () => { + mockSession.proxyManager = null; + + await expect(operations.listThreads('test-session')) + .rejects.toBeInstanceOf(ProxyNotRunningError); + }); + }); + + describe('pause with threadId', () => { + it('should pass specific threadId to DAP request', async () => { + mockSession.state = SessionState.RUNNING; + mockProxyManager.sendDapRequest.mockResolvedValue({}); + + const result = await operations.pause('test-session', 42); + + expect(result.success).toBe(true); + expect(mockProxyManager.sendDapRequest).toHaveBeenCalledWith('pause', { threadId: 42 }); + }); + + it('should default threadId to 0 when not provided', async () => { + mockSession.state = SessionState.RUNNING; + mockProxyManager.sendDapRequest.mockResolvedValue({}); + + const result = await operations.pause('test-session'); + + expect(result.success).toBe(true); + expect(mockProxyManager.sendDapRequest).toHaveBeenCalledWith('pause', { threadId: 0 }); + }); + }); + + describe('setBreakpoint with suspendPolicy', () => { + it('should include suspendPolicy in DAP setBreakpoints request', async () => { + mockSession.state = SessionState.PAUSED; + mockProxyManager.sendDapRequest.mockResolvedValue({ + body: { + breakpoints: [{ verified: true, line: 10, id: 1 }] + } + }); + + await operations.setBreakpoint('test-session', 'test.py', 10, undefined, 'thread'); + + expect(mockProxyManager.sendDapRequest).toHaveBeenCalledWith( + 'setBreakpoints', + expect.objectContaining({ + breakpoints: expect.arrayContaining([ + expect.objectContaining({ line: 10, suspendPolicy: 'thread' }) + ]) + }) + ); + }); + + it('should not include suspendPolicy when not provided', async () => { + mockSession.state = SessionState.PAUSED; + mockProxyManager.sendDapRequest.mockResolvedValue({ + body: { + breakpoints: [{ verified: true, line: 10, id: 1 }] + } + }); + + await operations.setBreakpoint('test-session', 'test.py', 10); + + const call = mockProxyManager.sendDapRequest.mock.calls.find( + (c: any[]) => c[0] === 'setBreakpoints' + ); + expect(call).toBeDefined(); + const bpArg = call![1].breakpoints[0]; + expect(bpArg).not.toHaveProperty('suspendPolicy'); + }); + }); });