diff --git a/src/mcp/tools/runner-tools.ts b/src/mcp/tools/runner-tools.ts index 85aa808..6bbcb0d 100644 --- a/src/mcp/tools/runner-tools.ts +++ b/src/mcp/tools/runner-tools.ts @@ -52,7 +52,7 @@ export class RunnerToolRegistry extends ToolRegistry { "run_local_tests", { description: - "Runs the user's code locally in an isolated subprocess, captures stdout / stderr / exit code, and updates the session's lastLocalRunPassed flag. Use this in the inner loop instead of submit_solution — it costs no LeetCode submission and turns around in seconds. The agent is responsible for including test invocations (e.g. `print(Solution().twoSum([2,7,11,15], 9))`) in the code passed in. Currently runnable: python3 and go; java lands in Phase 4c.", + "Runs the user's code locally in an isolated subprocess, captures stdout / stderr / exit code, and updates the session's lastLocalRunPassed flag. Use this in the inner loop instead of submit_solution — it costs no LeetCode submission and turns around in seconds. The agent is responsible for including test invocations (e.g. `print(Solution().twoSum([2,7,11,15], 9))`) in the code passed in. Currently runnable: python3, go, and java.", inputSchema: { titleSlug: z .string() diff --git a/src/runner/runner.ts b/src/runner/runner.ts index 80136e3..a5b8ba9 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -33,7 +33,7 @@ export const SUPPORTED_LANGUAGES: readonly RunnerLanguage[] = [ /** * The languages this build of the runner has *implemented*. Phase 4a - * shipped `python3`; Phase 4b adds `go`; Phase 4c adds `java`. + * shipped `python3`; Phase 4b added `go`; Phase 4c added `java`. * * Kept distinct from `SUPPORTED_LANGUAGES` so the wire-level * `RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE` error has a single source of @@ -42,5 +42,6 @@ export const SUPPORTED_LANGUAGES: readonly RunnerLanguage[] = [ */ export const IMPLEMENTED_LANGUAGES: readonly RunnerLanguage[] = [ "python3", - "go" + "go", + "java" ] as const; diff --git a/src/runner/subprocess-runner.ts b/src/runner/subprocess-runner.ts index c6e37c4..4393b15 100644 --- a/src/runner/subprocess-runner.ts +++ b/src/runner/subprocess-runner.ts @@ -1,7 +1,7 @@ /** * Plain-subprocess `LocalRunner` implementation. * - * Per-language registry (currently `python3` and `go`) describes how to: + * Per-language registry (currently `python3`, `go`, and `java`) describes how to: * - probe whether the runtime is available on PATH * - spawn the runtime against a source file written to the run's * temp dir @@ -51,11 +51,13 @@ const execFile = promisify(execFileCb); const MAX_OUTPUT_BYTES = 1_000_000; // 1 MB per stream const DEFAULT_TIMEOUT_MS = 5_000; const GO_DEFAULT_TIMEOUT_MS = 20_000; +const JAVA_DEFAULT_TIMEOUT_MS = 20_000; const TRUNCATION_MARKER = "\n[...output truncated at 1 MB...]"; interface LanguageSpec { /** File extension (without dot) used for the temp source file. */ extension: string; + sourceFileName?: string; /** `[binary, args]` to probe — exit code 0 means available. */ probe: { cmd: string; args: string[] }; defaultTimeoutMs?: number; @@ -128,18 +130,15 @@ const LANGUAGES: Record = { args: ["run", sourcePath] }) }, - // Phase 4c stub — present in the registry so the type system - // requires it stays in sync with `RunnerLanguage`. The runner - // refuses to use it until we actually wire the harness. java: { extension: "java", probe: { cmd: "java", args: ["-version"] }, - buildArgs: () => { - throw new LeetCodeError( - ErrorCode.RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE, - "Java runner ships in Phase 4c" - ); - } + sourceFileName: "Solution.java", + defaultTimeoutMs: JAVA_DEFAULT_TIMEOUT_MS, + buildArgs: (sourcePath) => ({ + cmd: "java", + args: [sourcePath] + }) } }; @@ -274,7 +273,10 @@ export class SubprocessRunner implements LocalRunner { writablePaths: [] }; const workDir = await mkdtemp(join(tmpdir(), "leetcode-mcp-run-")); - const sourcePath = join(workDir, `solution.${spec.extension}`); + const sourcePath = join( + workDir, + spec.sourceFileName ?? `solution.${spec.extension}` + ); try { await writeFile(sourcePath, input.code, "utf-8"); diff --git a/src/types/runner.ts b/src/types/runner.ts index 796db43..8206835 100644 --- a/src/types/runner.ts +++ b/src/types/runner.ts @@ -11,10 +11,9 @@ /** * Languages the local runner knows how to execute. * - * Phase 4a ships `python3` only; Phase 4b/4c add `go` and `java`. Other - * LeetCode languages remain valid for `submit_solution` but - * `run_local_tests` will reject them with - * `RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE`. + * Phase 4a shipped `python3`; Phase 4b/4c added `go` and `java`. Other + * LeetCode languages remain valid for `submit_solution` but are not + * accepted by `run_local_tests`. */ export type RunnerLanguage = "python3" | "go" | "java"; diff --git a/tests/e2e/runner.test.ts b/tests/e2e/runner.test.ts index 6a10e7b..acb63aa 100644 --- a/tests/e2e/runner.test.ts +++ b/tests/e2e/runner.test.ts @@ -33,6 +33,11 @@ const TWO_SUM_PROBLEM = { lang: "Go", langSlug: "go", code: "package main\n\nfunc main() {}\n" + }, + { + lang: "Java", + langSlug: "java", + code: "class Solution {\n public static void main(String[] args) {}\n}\n" } ], similarQuestions: "[]", @@ -72,6 +77,17 @@ function goAvailable(): boolean { const GO_PRESENT = goAvailable(); +function javaAvailable(): boolean { + try { + execFileSync("java", ["-version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const JAVA_PRESENT = javaAvailable(); + describe.skipIf(!PYTHON_PRESENT)("e2e: local runner (python3)", () => { let spawned: SpawnedServer | undefined; @@ -201,27 +217,6 @@ describe.skipIf(!PYTHON_PRESENT)("e2e: local runner (python3)", () => { expect(payload.result.passed).toBe(false); }); - it("rejects unimplemented languages with RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE", async () => { - spawned = await spawnServer({ fixture: FIXTURE }); - - await spawned.client.callTool({ - name: "start_problem", - arguments: { titleSlug: "two-sum", language: "java" } - }); - - const run = (await spawned.client.callTool({ - name: "run_local_tests", - arguments: { - titleSlug: "two-sum", - language: "java", - code: "public class Solution {}" - } - })) as ToolTextResult; - - const payload = JSON.parse(run.content[0].text); - expect(payload.code).toBe("RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE"); - }); - it("blocks submit_solution under strict mode until run_local_tests passes", async () => { spawned = await spawnServer({ fixture: FIXTURE, @@ -324,3 +319,53 @@ describe.skipIf(!GO_PRESENT)("e2e: local runner (go)", () => { expect(sessionPayload.session.attempts).toBe(1); }); }); + +describe.skipIf(!JAVA_PRESENT)("e2e: local runner (java)", () => { + let spawned: SpawnedServer | undefined; + + afterEach(async () => { + if (spawned) { + await spawned.cleanup(); + spawned = undefined; + } + }); + + it("executes a passing Java program and updates the session", async () => { + spawned = await spawnServer({ fixture: FIXTURE }); + + await spawned.client.callTool({ + name: "start_problem", + arguments: { titleSlug: "two-sum", language: "java" } + }); + + const run = (await spawned.client.callTool({ + name: "run_local_tests", + arguments: { + titleSlug: "two-sum", + language: "java", + code: [ + "public class Solution {", + " public static void main(String[] args) {", + ' System.out.println("java ok");', + ' if (1 + 1 != 2) { throw new RuntimeException("bad math"); }', + " }", + "}" + ].join("\n") + } + })) as ToolTextResult; + + const payload = JSON.parse(run.content[0].text); + expect(payload.titleSlug).toBe("two-sum"); + expect(payload.result.passed).toBe(true); + expect(payload.result.exitCode).toBe(0); + expect(payload.result.stdout).toContain("java ok"); + + const state = (await spawned.client.callTool({ + name: "get_session_state", + arguments: { titleSlug: "two-sum" } + })) as ToolTextResult; + const sessionPayload = JSON.parse(state.content[0].text); + expect(sessionPayload.session.lastLocalRunPassed).toBe(true); + expect(sessionPayload.session.attempts).toBe(1); + }); +}); diff --git a/tests/integration/runner-tools-integration.test.ts b/tests/integration/runner-tools-integration.test.ts index 280f51c..21a7c50 100644 --- a/tests/integration/runner-tools-integration.test.ts +++ b/tests/integration/runner-tools-integration.test.ts @@ -234,13 +234,13 @@ describe("Runner Tools Integration", () => { ); it( - "surfaces RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE for still-unimplemented languages", + "surfaces runner language-runtime errors", async () => { await sessions.startOrResume({ slug: "two-sum" }); const broken = createFakeRunner({ runError: new LeetCodeError( - ErrorCode.RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE, - "Java runner ships in Phase 4c" + ErrorCode.LANGUAGE_RUNTIME_NOT_FOUND, + "Required runtime for java not found on PATH" ) }); await testClient.cleanup(); @@ -264,9 +264,7 @@ describe("Runner Tools Integration", () => { assertions.hasToolResultStructure(result); const payload = JSON.parse(result.content[0].text); - expect(payload.code).toBe( - ErrorCode.RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE - ); + expect(payload.code).toBe(ErrorCode.LANGUAGE_RUNTIME_NOT_FOUND); // The session attempt counter should NOT bump on a // pre-run rejection. diff --git a/tests/runner/subprocess-runner.test.ts b/tests/runner/subprocess-runner.test.ts index 3855152..a8666d8 100644 --- a/tests/runner/subprocess-runner.test.ts +++ b/tests/runner/subprocess-runner.test.ts @@ -19,11 +19,7 @@ import { SubprocessRunner, __resetProbeCacheForTest } from "../../src/runner/subprocess-runner.js"; -import { - ErrorCode, - isLeetCodeError, - type RunnerLanguage -} from "../../src/types/index.js"; +import { ErrorCode, isLeetCodeError } from "../../src/types/index.js"; function runtimeAvailable(cmd: string, args: string[]): boolean { try { @@ -35,6 +31,7 @@ function runtimeAvailable(cmd: string, args: string[]): boolean { } const GO_PRESENT = runtimeAvailable("go", ["version"]); +const JAVA_PRESENT = runtimeAvailable("java", ["-version"]); describe("SubprocessRunner", () => { let runner: SubprocessRunner; @@ -144,6 +141,32 @@ describe("SubprocessRunner", () => { 30_000 ); + it.skipIf(!JAVA_PRESENT)( + "executes a happy-path Java program", + async () => { + const result = await runner.run({ + titleSlug: "two-sum", + language: "java", + timeoutMs: 20_000, + code: [ + "public class Solution {", + " public static void main(String[] args) {", + ' System.out.println("hello from java");', + ' if (1 + 1 != 2) { throw new RuntimeException("bad math"); }', + " }", + "}" + ].join("\n") + }); + + expect(result.passed).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.stdout).toContain("hello from java"); + expect(result.stderr).toBe(""); + }, + 30_000 + ); + it("reuses a stable Go build cache across runs", async () => { __setSandboxCacheForTest({ kind: "none" }); const fakeBin = await mkdtemp(join(tmpdir(), "fake-go-bin-")); @@ -211,6 +234,24 @@ describe("SubprocessRunner", () => { } ); + it.skipIf(JAVA_PRESENT)( + "reports LANGUAGE_RUNTIME_NOT_FOUND when Java is unavailable", + async () => { + await expect(async () => { + await runner.run({ + titleSlug: "two-sum", + language: "java", + code: "public class Solution {}" + }); + }).rejects.toSatisfy((error: unknown) => { + if (!isLeetCodeError(error)) { + return false; + } + return error.code === ErrorCode.LANGUAGE_RUNTIME_NOT_FOUND; + }); + } + ); + it("kills runaway processes after the timeout budget", async () => { const start = Date.now(); const result = await runner.run({ @@ -228,23 +269,6 @@ describe("SubprocessRunner", () => { expect(elapsed).toBeLessThan(2_500); }); - it("rejects still-unimplemented languages with RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE", async () => { - await expect(async () => { - await runner.run({ - titleSlug: "two-sum", - language: "java" as RunnerLanguage, - code: "public class Solution {}" - }); - }).rejects.toSatisfy((error: unknown) => { - if (!isLeetCodeError(error)) { - return false; - } - return ( - error.code === ErrorCode.RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE - ); - }); - }); - it("forwards a clean env (no leaking secrets)", async () => { // Ask the child to print one of its env vars. We never set // SECRET_ON_PARENT in the child env, so it should print