diff --git a/package.json b/package.json index ee46cae5..9702a828 100644 --- a/package.json +++ b/package.json @@ -1017,7 +1017,7 @@ { "name": "debug_java_application", "displayName": "Debug Java Application", - "modelDescription": "Launch or attach to a Java application in debug mode with automatic compilation and classpath resolution. The tool handles building the project, resolving dependencies, starting the JVM with JDWP enabled, and auto-attaching the VS Code debugger. Use this as the first step to establish a debug session. The debug process runs in the background until stopped. Example usage: Debug a main class ('com.example.Main'), a JAR file ('target/app.jar'), or with program arguments (['--port=8080']).", + "modelDescription": "Build and debug Java with automatic classpath resolution. On failure/timeout, diagnose; no automatic retry or terminal relaunch. Timeout is unconfirmed startup: check status once, do not stop the launch. Retry only after fixing the cause or explicit user request; check existing sessions first.", "toolReferenceName": "debugJavaApplication", "tags": [ "java", diff --git a/resources/instruments/javaDebugContext.instructions.md b/resources/instruments/javaDebugContext.instructions.md index 055b01b0..840591e9 100644 --- a/resources/instruments/javaDebugContext.instructions.md +++ b/resources/instruments/javaDebugContext.instructions.md @@ -13,4 +13,8 @@ For Java run/launch/debug/inspection requests, prefer the Java debug language mo If both apply (e.g. "launch and break on entry of `Main.foo`"), load `java-launch-troubleshooting` first, then `java-debug-inspection` after the session is active. -Fall back to `run_in_terminal` only when `debug_java_application` returns "Java Language Server not ready" or "project not detected". +After the first launch failure or timeout, do not automatically retry `debug_java_application` or relaunch through terminal commands, including when Java Language Server is not ready or the project is not detected. Report the result and diagnose the cause. + +A timeout means startup is unconfirmed, not necessarily failed. You may check `get_debug_session_info` once and inspect existing terminal output; do not enter a polling loop or terminate the original launch just because the wait expired. + +Only start a new launch attempt after fixing an identified cause or when the user explicitly requests a retry. Check for an existing session first and do not replace it without explicit restart intent. diff --git a/resources/skills/java-debug-inspection/SKILL.md b/resources/skills/java-debug-inspection/SKILL.md index 354132dd..1c608ada 100644 --- a/resources/skills/java-debug-inspection/SKILL.md +++ b/resources/skills/java-debug-inspection/SKILL.md @@ -53,6 +53,8 @@ These language model tools are contributed by the `Debugger for Java` extension - The program is a non-Java language → do not load this skill - The user is editing source code without an active debug session → do nothing -## Fallback +## Failure handling -If a tool returns "Java Language Server not ready" or repeats the same error twice, report the raw error to the user and stop calling debug tools for the current turn. Do not retry more than twice. +After the first failed inspection or control operation, report the error and diagnose its cause rather than automatically repeating the failed operation. Read-only diagnosis is allowed, but do not enter a polling loop. + +Do not relaunch the application or fall back to terminal launch commands to recover from an inspection error. For launch failures or timeouts, follow `java-launch-troubleshooting`: a new launch attempt requires an identified cause to be fixed or an explicit user retry request, with an existing-session check first. diff --git a/resources/skills/java-launch-troubleshooting/SKILL.md b/resources/skills/java-launch-troubleshooting/SKILL.md index c14dc527..b6cacf09 100644 --- a/resources/skills/java-launch-troubleshooting/SKILL.md +++ b/resources/skills/java-launch-troubleshooting/SKILL.md @@ -27,7 +27,7 @@ These language model tools are contributed by the `Debugger for Java` extension 1. **Confirm intent.** Is the user trying to *run / start / launch / stop* a Java program (use this skill) or just edit code (do not load this skill)? 2. **Check existing session.** Call `get_debug_session_info` first. If a session is already running for the target, do not launch a second one. 3. **Launch.** Call `debug_java_application` with `target` = the fully qualified main class or JAR, and `workspacePath` = the project root containing `pom.xml`, `build.gradle`, or `.classpath`. Let `skipBuild` default to `false` so the tool handles compilation. -4. **Read the error.** If `debug_java_application` fails, the error message is structured (mainClass missing, classpath unresolved, build failure with line number). Use it to suggest a fix — do not retry with `run_in_terminal`. +4. **Stop on the first failure or timeout.** Report the returned result and diagnose the cause. Do not automatically retry `debug_java_application` or relaunch through `run_in_terminal`. A timeout means startup is unconfirmed, not necessarily failed; follow the failure-handling rules below. 5. **Stop when done.** When the user says "stop", "kill it", or has the answer they need, call `stop_debug_session`. ## Common Failure Modes @@ -36,7 +36,7 @@ These language model tools are contributed by the `Debugger for Java` extension |---|---|---| | `mainClass is not configured` / `mainClass missing` | Project has no `launch.json`, and the file has no `public static void main` | Ask user which class to launch, or generate `launch.json` | | `Could not resolve classpath` | Maven/Gradle import has not completed, or `pom.xml` has unresolved dependencies | Wait for Java Language Server import, then ask user to run `Java: Clean Java Language Server Workspace` | -| `Compilation failed` with file:line | Source code has a compile error | Fix the reported error in the source file, do not retry the launch | +| `Compilation failed` with file:line | Source code has a compile error | Fix the reported compilation error before attempting another launch; follow the failure-handling rules below | | `Project not detected` | `workspacePath` does not contain a build file | Re-check `workspacePath`; for multi-module projects, use the module root, not the repo root | ## When NOT to Use This Skill @@ -45,6 +45,10 @@ These language model tools are contributed by the `Debugger for Java` extension - The user is already inside a live debug session and wants to inspect variables, evaluate expressions, walk the stack, step, or set / remove breakpoints → use `java-debug-inspection` instead, do not re-launch - The program is a non-Java language → do not load this skill -## Fallback +## Failure handling -If `debug_java_application` returns `Java Language Server not ready` or repeats the same error twice, fall back to `run_in_terminal` with the appropriate `mvn` or `gradle` command and report the raw output to the user. Do not retry the debug tool more than twice. +After the first launch failure or timeout, stop automatic launch attempts, including when Java Language Server is not ready or the project is not detected. Do not use `run_in_terminal`, `mvn`, `gradle`, or raw `java` commands to bypass this rule. + +You may inspect existing errors and terminal output. After a timeout, you may call `get_debug_session_info` once to check whether the original launch has become active. Do not enter a polling loop or terminate the original launch merely because the wait expired. + +A new launch attempt is allowed only after fixing an identified cause or when the user explicitly requests a retry. Before that attempt, check for an existing session; do not replace an active session without explicit restart intent. diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index cdfa6dfb..2ef6b7b9 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -58,6 +58,13 @@ const CONSTANTS = { MAX_FILE_SEARCH_DEPTH: 10 }; +const LAUNCH_FAILURE_GUIDANCE = '\n\nDo not automatically retry debug_java_application or start the program again ' + + 'through a terminal command. Report the result and diagnose the cause first. ' + + 'After a timeout, you may check get_debug_session_info once and inspect existing terminal output; ' + + 'do not enter a polling loop or stop the original launch just because the wait expired. ' + + 'Only start a new attempt after fixing an identified cause or when the user explicitly requests a retry. ' + + 'Before a new attempt, check whether the original launch has become active to avoid replacing it.'; + // ---------------------------------------------------------------------------- // Process-wide context probed lazily on first use. The value is constant for // the VS Code session lifetime, so we cache it. @@ -131,7 +138,8 @@ export function registerLanguageModelTool( new vscode.LanguageModelTextPart( `Java No-Config Debug is disabled by ${ENABLE_NO_CONFIG_DEBUG}. ` + "To use this tool, enable that setting, reload VS Code, and recreate existing terminals. " - + "Standard Java launch/attach debugging remains available.", + + "Standard Java launch/attach debugging remains available." + + LAUNCH_FAILURE_GUIDANCE, ), ]); } @@ -152,18 +160,18 @@ export function registerLanguageModelTool( try { const result = await debugJavaApplication(options.input, token, guard); - if (!result.success) { - outcome = result.status === 'timeout' ? 'timeout' : 'failure'; - errorCategory = result.success ? undefined : classifyError(result.message); - } else if (result.status === 'timeout') { + if (result.status === 'timeout') { outcome = 'timeout'; errorCategory = 'timeout'; + } else if (!result.success) { + outcome = 'failure'; + errorCategory = classifyError(result.message); } // Format the message for AI - use simple text, not JSON const message = result.success ? `✓ ${result.message}` - : `✗ ${result.message}`; + : `✗ ${result.message}${LAUNCH_FAILURE_GUIDANCE}`; // Return result in the expected format - simple text part return new (vscode as any).LanguageModelToolResult([ @@ -177,7 +185,7 @@ export function registerLanguageModelTool( const errorMessage = error instanceof Error ? error.message : String(error); return new (vscode as any).LanguageModelToolResult([ - new (vscode as any).LanguageModelTextPart(`✗ Debug failed: ${errorMessage}`) + new (vscode as any).LanguageModelTextPart(`✗ Debug failed: ${errorMessage}${LAUNCH_FAILURE_GUIDANCE}`) ]); } finally { recordToolInvocation({ @@ -417,18 +425,8 @@ async function debugJavaApplication( status: 'timeout', message: `⏳ Debug session not yet detected for ${targetInfo} after ` + `${CONSTANTS.SESSION_WAIT_TIMEOUT / 1000} seconds.\n\n` - + `This is often transient — the JVM may still be starting up (large ` - + `projects, cold class-loading, or remote workspaces can need additional ` - + `time). Telemetry shows that retrying a timed-out launch succeeds for ` - + `the majority of cases.\n\n` - + `Recommended next actions (in order):\n` - + `1. Call debug_java_application again — most timeout cases recover on retry.\n` - + `2. Call get_debug_session_info() to check whether the session has since ` - + `become active.\n` - + `3. If retrying still times out, inspect terminal '${terminal.name}' for ` - + `compilation errors, ClassNotFoundException, NoClassDefFoundError, or ` - + `other startup failures.\n` - + `4. Verify the target class name and classpath are correct, then retry.` + + `Startup is unconfirmed, not necessarily failed. The original command may ` + + `still be running in terminal '${terminal.name}'.` + `${warningNote}`, terminalName: terminal.name }); @@ -480,20 +478,12 @@ async function debugJavaApplication( guard?.markOutcomeRecorded(); return { - success: true, + success: false, status: 'timeout', message: `⏳ Debug command sent for ${targetInfo}; session not yet detected within ` + `${CONSTANTS.SMART_POLLING_MAX_WAIT / 1000} seconds.\n\n` - + `This is often transient — the application may still be starting in terminal ` - + `'${terminal.name}'. Telemetry shows that retrying or polling for status is more ` - + `likely to succeed than treating this as a permanent failure.\n\n` - + `Recommended next actions (in order):\n` - + `1. Call get_debug_session_info() to check whether the session has since become active.\n` - + `2. Call debug_java_application again — most timeout cases recover on retry. ` - + `In the input arguments, set "waitForSession": true (JSON object syntax) to ` - + `extend the wait window for slow-starting apps.\n` - + `3. If retrying still times out, inspect terminal '${terminal.name}' for compilation ` - + `errors or startup failures, then retry.${warningNote}`, + + `Startup is unconfirmed, not necessarily failed. The original command may ` + + `still be running in terminal '${terminal.name}'.${warningNote}`, terminalName: terminal.name }; } diff --git a/test/languageModelToolLaunchPolicy.test.ts b/test/languageModelToolLaunchPolicy.test.ts new file mode 100644 index 00000000..4af9baa0 --- /dev/null +++ b/test/languageModelToolLaunchPolicy.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import * as telemetry from "vscode-extension-telemetry-wrapper"; + +import { registerLanguageModelTool } from "../src/languageModelTool"; + +suite("Language Model Tool launch retry policy", () => { + const repoRoot = path.resolve(__dirname, "../.."); + let cleanups: (() => void)[]; + let registeredTool: vscode.LanguageModelTool | undefined; + let commandsSent: number; + let terminalsClosed: number; + let sessionsStopped: number; + let listenerDisposals: number; + let detectSession: boolean; + let sessionListener: ((session: { id: string; type: string }) => void) | undefined; + let records: { [key: string]: string }[]; + + function overrideProperty(target: object, key: string, descriptor: PropertyDescriptor): void { + const original = Object.getOwnPropertyDescriptor(target, key); + assert.ok(original, key); + Object.defineProperty(target, key, { configurable: true, ...descriptor }); + cleanups.push(() => Object.defineProperty(target, key, original)); + } + + setup(() => { + cleanups = []; + registeredTool = undefined; + commandsSent = 0; + terminalsClosed = 0; + sessionsStopped = 0; + listenerDisposals = 0; + detectSession = false; + sessionListener = undefined; + records = []; + + const registerTool: typeof vscode.lm.registerTool = (_name, tool) => { + registeredTool = tool; + return new vscode.Disposable(() => { }); + }; + overrideProperty(vscode.lm, "registerTool", { value: registerTool }); + overrideProperty(telemetry, "sendInfo", { + value: (_operationId: string, properties: { [key: string]: string }) => records.push(properties), + }); + overrideProperty(vscode.debug, "activeDebugSession", { + get: () => detectSession && commandsSent > 0 ? { id: "test-session", type: "java" } : undefined, + }); + overrideProperty(vscode.debug, "stopDebugging", { + value: async () => { sessionsStopped++; }, + }); + overrideProperty(vscode.window, "terminals", { get: () => [] }); + overrideProperty(vscode.workspace, "getWorkspaceFolder", { value: () => undefined }); + overrideProperty(vscode.debug, "onDidStartDebugSession", { + value: (listener: typeof sessionListener) => { + sessionListener = listener; + return new vscode.Disposable(() => { + listenerDisposals++; + sessionListener = undefined; + }); + }, + }); + overrideProperty(vscode.window, "createTerminal", { + value: () => ({ + name: "Java Debug", + show: () => { }, + dispose: () => { terminalsClosed++; }, + sendText: () => { + commandsSent++; + if (detectSession) { + queueMicrotask(() => sessionListener?.({ id: "test-session", type: "java" })); + } + }, + }), + }); + + // Advance only launch waits; keep Mocha and VS Code timers on the real clock. + let now = Date.now(); + const realSetTimeout = global.setTimeout; + overrideProperty(Date, "now", { value: () => now }); + overrideProperty(global, "setTimeout", { + value: (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => { + if (delay === 300 || delay === 120000) { + return realSetTimeout(() => { + now += delay === 300 ? 90000 : delay; + callback(...args); + }, 0); + } + return realSetTimeout(callback, delay, ...args); + }, + }); + }); + + teardown(() => { + for (const cleanup of cleanups.reverse()) { + cleanup(); + } + }); + + async function invoke(input: object, enabled = true): Promise { + const disposable = registerLanguageModelTool({ subscriptions: [] }, enabled); + assert.ok(disposable); + cleanups.push(() => disposable.dispose()); + assert.ok(registeredTool); + const cancellation = new vscode.CancellationTokenSource(); + cleanups.push(() => cancellation.dispose()); + const result = await registeredTool.invoke({ input, toolInvocationToken: undefined }, cancellation.token); + assert.ok(result instanceof vscode.LanguageModelToolResult); + const text = result.content[0]; + assert.ok(text instanceof vscode.LanguageModelTextPart); + return text.value; + } + + function assertNoAutomaticRetry(text: string): void { + assert.ok(text.includes("Do not automatically retry debug_java_application")); + assert.ok(text.includes("or start the program again through a terminal command")); + assert.ok(text.includes("after fixing an identified cause or when the user explicitly requests a retry")); + assert.ok(text.includes("check whether the original launch has become active")); + assert.strictEqual(text.includes("most timeout cases recover on retry"), false); + assert.strictEqual(text.includes("Call debug_java_application again"), false); + } + + for (const waitForSession of [false, true]) { + test(`reports unconfirmed startup without relaunching (waitForSession=${waitForSession})`, async () => { + const text = await invoke({ + target: "com.example.Main", workspacePath: repoRoot, + skipBuild: true, classpath: repoRoot, waitForSession, + }); + + assertNoAutomaticRetry(text); + assert.ok(text.includes("Startup is unconfirmed, not necessarily failed")); + assert.ok(text.includes("check get_debug_session_info once")); + assert.ok(text.includes("do not enter a polling loop or stop the original launch")); + assert.strictEqual(text.includes("\u2713"), false); + assert.strictEqual(commandsSent, 1); + assert.strictEqual(terminalsClosed, 0); + assert.strictEqual(sessionsStopped, 0); + assert.strictEqual(listenerDisposals, waitForSession ? 1 : 0); + const outcomes = records.filter((record) => record.operationName === "languageModelTool.debug_java_application.invoke"); + assert.strictEqual(outcomes.length, 1); + assert.strictEqual(outcomes[0].outcome, "timeout"); + assert.strictEqual(outcomes[0].errorCategory, "timeout"); + }); + + test(`preserves confirmed startup (waitForSession=${waitForSession})`, async () => { + detectSession = true; + const text = await invoke({ + target: "com.example.Main", workspacePath: repoRoot, + skipBuild: true, classpath: repoRoot, waitForSession, + }); + + assert.ok(text.includes("Debug session started")); + assert.strictEqual(text.includes("Do not automatically retry"), false); + assert.strictEqual(commandsSent, 1); + assert.strictEqual(terminalsClosed, 0); + assert.strictEqual(sessionsStopped, 0); + assert.strictEqual(listenerDisposals, waitForSession ? 1 : 0); + assert.ok(records.some((record) => + record.operationName === "languageModelTool.debug_java_application.invoke" && record.outcome === "success")); + }); + } + + test("includes the policy on a returned launch failure", async () => { + const text = await invoke({ + target: "com.example.Main", workspacePath: path.join(repoRoot, "package.json", "not-a-directory"), + skipBuild: true, + }); + assert.ok(text.includes("Workspace path does not exist")); + assertNoAutomaticRetry(text); + assert.strictEqual(commandsSent, 0); + }); + + test("includes the policy on an exception", async () => { + overrideProperty(vscode.window, "createTerminal", { + value: () => { throw new Error("Terminal creation failed"); }, + }); + const text = await invoke({ + target: "com.example.Main", workspacePath: repoRoot, skipBuild: true, classpath: repoRoot, + }); + assert.ok(text.includes("Terminal creation failed")); + assertNoAutomaticRetry(text); + assert.strictEqual(commandsSent, 0); + }); + + test("includes the policy when no-config debugging is disabled", async () => { + const text = await invoke({}, false); + assert.ok(text.includes("Java No-Config Debug is disabled")); + assertNoAutomaticRetry(text); + assert.strictEqual(commandsSent, 0); + }); + + test("keeps model-facing guidance consistent without requiring a skill to be loaded", async () => { + const manifest = JSON.parse(await fs.promises.readFile(path.join(repoRoot, "package.json"), "utf8")); + const launchTool = manifest.contributes.languageModelTools.find((tool: { name: string }) => + tool.name === "debug_java_application"); + const description: string = launchTool.modelDescription; + assert.ok(description.length <= 350, "Keep the tool description concise; details belong in skills and results"); + assert.ok(description.includes("On failure/timeout, diagnose; no automatic retry or terminal relaunch")); + assert.ok(description.includes("Timeout is unconfirmed startup")); + assert.ok(description.includes("check status once, do not stop the launch")); + assert.ok(description.includes("Retry only after fixing the cause or explicit user request")); + assert.ok(description.includes("check existing sessions first")); + + for (const file of [ + path.join("resources", "instruments", "javaDebugContext.instructions.md"), + path.join("resources", "skills", "java-launch-troubleshooting", "SKILL.md"), + path.join("resources", "skills", "java-debug-inspection", "SKILL.md"), + ]) { + const text = await fs.promises.readFile(path.join(repoRoot, file), "utf8"); + assert.match(text, /[Aa]fter the first/, file); + assert.match(text, /polling loop/, file); + assert.match(text, /explicit(?:ly requests a retry| user retry request)/, file); + assert.doesNotMatch(text, /repeats the same error twice|Do not retry (?:the debug tool )?more than twice/, file); + } + }); +});