Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/mcp/tools/runner-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 3 additions & 2 deletions src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,5 +42,6 @@ export const SUPPORTED_LANGUAGES: readonly RunnerLanguage[] = [
*/
export const IMPLEMENTED_LANGUAGES: readonly RunnerLanguage[] = [
"python3",
"go"
"go",
"java"
] as const;
24 changes: 13 additions & 11 deletions src/runner/subprocess-runner.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -128,18 +130,15 @@ const LANGUAGES: Record<RunnerLanguage, LanguageSpec> = {
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]
})
}
};

Expand Down Expand Up @@ -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");
Expand Down
7 changes: 3 additions & 4 deletions src/types/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
87 changes: 66 additions & 21 deletions tests/e2e/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[]",
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
10 changes: 4 additions & 6 deletions tests/integration/runner-tools-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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.
Expand Down
68 changes: 46 additions & 22 deletions tests/runner/subprocess-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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-"));
Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand Down
Loading