Skip to content
Draft
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
36 changes: 32 additions & 4 deletions src/main/java/io/github/easy4j/codex/CodexClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ public CodexCliResult exec(CodexCli.ExecOptions opts) {
* empty or unparseable.
*/
public List<CodexEvent> execAndParse(String prompt) {
CodexCliResult result = exec(prompt);
CodexCli.ExecOptions opts = defaultOptions(prompt).json(true);
CodexCliResult result = cli.exec(opts);
return parseJsonlOutput(result.getStdout());
}

Expand Down Expand Up @@ -296,7 +297,7 @@ public CodexCliResult execWithDisable(String prompt, String... features) {
* @return the raw CLI invocation result; never {@code null}.
*/
public CodexCliResult startSession() {
return cli.startInteractive();
return cli.startInteractive(defaultGlobalOptions(), null);
}

/**
Expand All @@ -306,7 +307,7 @@ public CodexCliResult startSession() {
* @return the raw CLI invocation result; never {@code null}.
*/
public CodexCliResult startSession(String prompt) {
return cli.startInteractive(prompt);
return cli.startInteractive(defaultGlobalOptions(), prompt);
}

/**
Expand Down Expand Up @@ -1122,7 +1123,7 @@ public CodexCliResult execute(String... args) {
* @return a fresh options instance; never {@code null}.
*/
private CodexCli.ExecOptions defaultOptions(String prompt) {
CodexCli.ExecOptions opts = new CodexCli.ExecOptions(prompt).json(true);
CodexCli.ExecOptions opts = new CodexCli.ExecOptions(prompt).json(config.isJsonOutput());
if (config.getDefaultModel() != null) opts.model(config.getDefaultModel());
if (config.getDefaultSandbox() != null) opts.sandbox(config.getDefaultSandbox());
if (config.getDefaultApprovalPolicy() != null) opts.approvalPolicy(config.getDefaultApprovalPolicy());
Expand All @@ -1146,6 +1147,33 @@ private CodexCli.ExecOptions defaultOptions(String prompt) {
return opts;
}

/**
* Builds global options for the default interactive-session entry points.
*
* @return a fresh global-options instance populated from client defaults.
*/
private CodexCli.GlobalOptions defaultGlobalOptions() {
CodexCli.GlobalOptions opts = new CodexCli.GlobalOptions();
if (config.getDefaultModel() != null) opts.model(config.getDefaultModel());
if (config.getDefaultSandbox() != null) opts.sandbox(config.getDefaultSandbox());
if (config.getDefaultApprovalPolicy() != null) opts.approvalPolicy(config.getDefaultApprovalPolicy());
if (config.getDefaultProfile() != null) opts.profile(config.getDefaultProfile());
if (config.getWorkingDir() != null) opts.workingDir(config.getWorkingDir());
if (config.getAddDir() != null) opts.addDir(config.getAddDir());
if (config.isOssProvider()) opts.oss(true);
if (config.getLocalProvider() != null) opts.localProvider(config.getLocalProvider());
if (config.isSearch()) opts.search(true);
if (config.getImage() != null) opts.image(config.getImage());
if (config.getConfigOverrides() != null) opts.configOverrides(config.getConfigOverrides());
if (config.isDangerouslyBypassApprovalsAndSandbox()) opts.dangerouslyBypassApprovalsAndSandbox(true);
if (config.isDangerouslyBypassHookTrust()) opts.dangerouslyBypassHookTrust(true);
if (config.isStrictConfig()) opts.strictConfig(true);
if (config.getEnable() != null) opts.enable(config.getEnable());
if (config.getDisable() != null) opts.disable(config.getDisable());
if (config.isNoAltScreen()) opts.noAltScreen(true);
return opts;
}

/**
* Decodes a JSON-Lines blob into a list of {@link CodexEvent}s.
*
Expand Down
13 changes: 8 additions & 5 deletions src/main/java/io/github/easy4j/codex/cli/CodexCliExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public CodexCliExecutor(CodexClientConfig config) {
* @return a {@link CodexCliResult} describing the outcome; never {@code null}.
*/
public CodexCliResult execute(String... args) {
return runProcess(null, args);
return runProcess(null, config.getLocalTimeoutSeconds() * 1000L, args);
}

/**
Expand All @@ -111,10 +111,14 @@ public CodexCliResult execute(String... args) {
* @return a {@link CodexCliResult} describing the outcome; never {@code null}.
*/
public CodexCliResult executeWithStdin(String stdin, String... args) {
return runProcess(stdin, args);
return runProcess(stdin, config.getLocalTimeoutSeconds() * 1000L, args);
}

private CodexCliResult runProcess(String stdin, String... args) {
private CodexCliResult executeWithTimeoutSeconds(int timeoutSeconds, String... args) {
return runProcess(null, timeoutSeconds * 1000L, args);
}

private CodexCliResult runProcess(String stdin, long timeoutMs, String... args) {
CommandLine cmd = CommandLine.parse(config.getLocalExecutable());
for (String arg : args) {
if (arg != null) {
Expand All @@ -136,7 +140,6 @@ private CodexCliResult runProcess(String stdin, String... args) {
executor.setStreamHandler(new org.apache.commons.exec.PumpStreamHandler(stdout, stderr,
new ByteArrayInputStream(stdinBytes)));

long timeoutMs = config.getLocalTimeoutSeconds() * 1000L;
ExecuteWatchdog watchdog = new ExecuteWatchdog(timeoutMs);
executor.setWatchdog(watchdog);

Expand Down Expand Up @@ -201,7 +204,7 @@ private static String utf8(ByteArrayOutputStream buffer) {
*/
public boolean probe() {
try {
CodexCliResult result = execute("--version");
CodexCliResult result = executeWithTimeoutSeconds(config.getLocalProbeTimeoutSeconds(), "--version");
return result.isSuccess();
} catch (Exception e) {
return false;
Expand Down
66 changes: 66 additions & 0 deletions src/test/java/io/github/easy4j/codex/CodexClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ private static CodexClient echoClient() {
return new CodexClient(echoConfig());
}

private static final class CapturingExecutor extends CodexCliExecutor {
private final String stdout;
private String[] lastArgs;

private CapturingExecutor(CodexClientConfig config, String stdout) {
super(config);
this.stdout = stdout;
}

@Override
public CodexCliResult execute(String... args) {
this.lastArgs = args;
return new CodexCliResult(0, stdout, "");
}

private boolean hasArg(String expected) {
if (lastArgs == null) {
return false;
}
for (String arg : lastArgs) {
if (expected.equals(arg)) {
return true;
}
}
return false;
}
}

// ----------------------------------------------------------------
// Constructor
// ----------------------------------------------------------------
Expand Down Expand Up @@ -148,6 +176,17 @@ void shouldDelegateExecWithPrompt() {
assertTrue(out.contains("hello"));
}

@Test
void shouldHonorJsonOutputFalseForNormalExec() {
CodexClientConfig config = echoConfig();
config.setJsonOutput(false);

CodexCliResult result = new CodexClient(config).exec("hello");

assertFalse(result.getStdout().contains("--json"),
"normal exec must honor CodexClientConfig.jsonOutput=false");
}

@Test
void shouldDelegateExecWithPromptAndModel() {
CodexCliResult result = echoClient().exec("hello", "gpt-5");
Expand Down Expand Up @@ -251,6 +290,22 @@ void shouldReturnEmptyListForBlankOutput() {
// (failed parse lines are skipped)
}

@Test
void shouldForceJsonForExecAndParseEvenWhenDefaultJsonOutputIsFalse() {
CodexClientConfig config = echoConfig();
config.setJsonOutput(false);
CapturingExecutor executor = new CapturingExecutor(
config, "{\"type\":\"message\",\"message\":\"hi\"}");
CodexClient client = new CodexClient(config, new CodexCli(executor));

List<CodexEvent> events = client.execAndParse("hello");

assertTrue(executor.hasArg("--json"),
"execAndParse must force --json independently of the normal exec default");
assertEquals(1, events.size());
assertEquals("message", events.get(0).getType());
}

@Test
void shouldParseValidJsonlOutput() throws Exception {
// Create a custom client that returns valid JSON-Lines
Expand Down Expand Up @@ -278,6 +333,17 @@ void shouldDelegateStartSessionWithPrompt() {
assertTrue(result.getStdout().contains("hello"));
}

@Test
void shouldPropagateNoAltScreenToDefaultInteractiveSession() {
CodexClientConfig config = echoConfig();
config.setNoAltScreen(true);

CodexCliResult result = new CodexClient(config).startSession("hello");

assertTrue(result.getStdout().contains("--no-alt-screen"),
"default interactive session must honor CodexClientConfig.noAltScreen=true");
}

@Test
void shouldDelegateStartSessionWithOpts() {
CodexCli.GlobalOptions opts = new CodexCli.GlobalOptions().model("gpt-5");
Expand Down
37 changes: 37 additions & 0 deletions src/test/java/io/github/easy4j/codex/cli/CodexCliExecutorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
import io.github.easy4j.codex.CodexClientConfig;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
Expand Down Expand Up @@ -158,6 +164,37 @@ void shouldReportFailureFromProbeWhenExecutableMissing() {
assertFalse(executor.probe());
}

@Test
void shouldUseProbeTimeoutWithoutChangingNormalCommandTimeout() throws Exception {
Path script = Files.createTempFile("slow-codex-", ".sh");
Files.write(script, Arrays.asList(
"#!/bin/sh",
"sleep 2",
"echo codex-test"
), StandardCharsets.UTF_8);
assertTrue(script.toFile().setExecutable(true));

CodexClientConfig config = configFor(script.toAbsolutePath().toString());
config.setLocalProbeTimeoutSeconds(1);
config.setLocalTimeoutSeconds(5);
CodexCliExecutor executor = new CodexCliExecutor(config);

long probeStarted = System.nanoTime();
assertFalse(executor.probe());
long probeElapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - probeStarted);
assertTrue(probeElapsedMs < 3_500,
"probe must use localProbeTimeoutSeconds instead of localTimeoutSeconds");

long executeStarted = System.nanoTime();
CodexCliResult normal = executor.execute("--version");
long executeElapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - executeStarted);
assertTrue(normal.isSuccess(), "normal command must still use localTimeoutSeconds");
assertTrue(executeElapsedMs >= 1_500,
"normal command should be allowed to outlive the probe timeout");

Files.deleteIfExists(script);
}

@Test
void shouldTimeoutOnHangingProcess() {
// Use a short timeout and a command that sleeps for a long time.
Expand Down
Loading