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
166 changes: 159 additions & 7 deletions apps/desktop/src/main/services/pty/ptyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ describe("ptyService", () => {
}
});

it("does not send Codex initialInput into the update prompt", async () => {
it("preserves Codex initialInput after a readiness timeout without sending it into the update prompt", async () => {
vi.useFakeTimers();
try {
const { service, mockPty, logger, sessionService } = createHarness();
Expand All @@ -1417,6 +1417,7 @@ describe("ptyService", () => {
args: ["--no-alt-screen"],
startupCommand: "codex --no-alt-screen",
initialInput: "please keep going",
initialInputReadyTimeoutMs: 20_000,
});

mockPty._emitter.emit("data", [
Expand All @@ -1433,12 +1434,8 @@ describe("ptyService", () => {
expect.objectContaining({ provider: "codex" }),
);
expect(logger.warn).toHaveBeenCalledWith(
"pty.initial_input_skipped_not_ready",
expect.objectContaining({ provider: "codex" }),
);
expect(logger.warn).toHaveBeenCalledWith(
"pty.initial_input_launch_failed",
expect.objectContaining({ toolType: "codex" }),
"pty.initial_input_retrying_not_ready",
expect.objectContaining({ provider: "codex", timeoutMs: 20_000 }),
);
expect(mockPty.kill).not.toHaveBeenCalled();
expect(sessionService.end).not.toHaveBeenCalledWith(expect.objectContaining({
Expand All @@ -1449,6 +1446,160 @@ describe("ptyService", () => {
}
});

it("delivers preserved Codex initialInput when the composer appears after a readiness timeout", async () => {
vi.useFakeTimers();
try {
const { service, mockPty, logger } = createHarness();

await service.create({
laneId: "lane-1",
title: "Codex CLI",
cols: 80,
rows: 24,
toolType: "codex",
command: "codex",
args: ["--no-alt-screen"],
startupCommand: "codex --no-alt-screen",
initialInput: "ADE_INITIAL_PROMPT_RETRY_MARKER",
initialInputReadyTimeoutMs: 20_000,
});

mockPty._emitter.emit("data", "Starting MCP servers (unityMCP)\n");
await vi.advanceTimersByTimeAsync(20_100);
expect(mockPty.write).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
"pty.initial_input_retrying_not_ready",
expect.objectContaining({ provider: "codex" }),
);

mockPty._emitter.emit(
"data",
"\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.6-terra\nMCP startup incomplete (failed: unityMCP)\n› ",
);
await vi.advanceTimersByTimeAsync(600);
await vi.advanceTimersByTimeAsync(25);
await vi.advanceTimersByTimeAsync(25);

expect(mockPty.write).toHaveBeenCalledWith(
"\x1b[200~ADE_INITIAL_PROMPT_RETRY_MARKER\x1b[201~",
);
} finally {
vi.useRealTimers();
}
});

it("cancels preserved Codex initialInput when the user takes control before late readiness", async () => {
vi.useFakeTimers();
try {
const { service, mockPty, logger } = createHarness();

const created = await service.create({
laneId: "lane-1",
title: "Codex CLI",
cols: 80,
rows: 24,
toolType: "codex",
command: "codex",
args: ["--no-alt-screen"],
startupCommand: "codex --no-alt-screen",
initialInput: "ADE_STALE_INITIAL_PROMPT",
initialInputReadyTimeoutMs: 20_000,
});

mockPty._emitter.emit("data", "Starting MCP servers (unityMCP)\n");
await vi.advanceTimersByTimeAsync(20_100);
service.write({ ptyId: created.ptyId, data: "user draft" });
mockPty._emitter.emit(
"data",
"\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.6-terra\nMCP startup incomplete (failed: unityMCP)\n› user draft",
);
await vi.advanceTimersByTimeAsync(700);

expect(mockPty.write).toHaveBeenCalledTimes(1);
expect(mockPty.write).toHaveBeenCalledWith("user draft");
expect(mockPty.write).not.toHaveBeenCalledWith(expect.stringContaining("ADE_STALE_INITIAL_PROMPT"));
expect(logger.info).toHaveBeenCalledWith(
"pty.initial_input_cancelled_user_takeover",
expect.objectContaining({ provider: "codex" }),
);
} finally {
vi.useRealTimers();
}
});

it("cancels Codex initialInput when the user takes control during the initial delay", async () => {
vi.useFakeTimers();
try {
const { service, mockPty, logger } = createHarness();

const created = await service.create({
laneId: "lane-1",
title: "Codex CLI",
cols: 80,
rows: 24,
toolType: "codex",
command: "codex",
args: ["--no-alt-screen"],
startupCommand: "codex --no-alt-screen",
initialInput: "ADE_DELAYED_STALE_PROMPT",
initialInputDelayMs: 750,
});

service.write({ ptyId: created.ptyId, data: "user draft" });
mockPty._emitter.emit(
"data",
"\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.6-terra\nMCP startup incomplete (failed: unityMCP)\n› user draft",
);
await vi.advanceTimersByTimeAsync(1_500);

expect(mockPty.write).toHaveBeenCalledTimes(1);
expect(mockPty.write).toHaveBeenCalledWith("user draft");
expect(mockPty.write).not.toHaveBeenCalledWith(expect.stringContaining("ADE_DELAYED_STALE_PROMPT"));
expect(logger.info).toHaveBeenCalledWith(
"pty.initial_input_cancelled_user_takeover",
expect.objectContaining({ provider: "codex" }),
);
} finally {
vi.useRealTimers();
}
});

it("accepts a stable Codex composer while unrelated PTY redraws continue after failed MCP startup", async () => {
vi.useFakeTimers();
try {
const { service, mockPty } = createHarness();

await service.create({
laneId: "lane-1",
title: "Codex CLI",
cols: 80,
rows: 24,
toolType: "codex",
command: "codex",
args: ["--no-alt-screen"],
startupCommand: "codex --no-alt-screen",
initialInput: "ADE_STABLE_COMPOSER_MARKER",
});

mockPty._emitter.emit(
"data",
"\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.6-terra\nMCP startup incomplete (failed: unityMCP)\n› ",
);
for (let elapsed = 0; elapsed < 700; elapsed += 100) {
mockPty._emitter.emit("data", `\x1b]0;Codex startup ${elapsed}\x07`);
await vi.advanceTimersByTimeAsync(100);
}
await vi.advanceTimersByTimeAsync(25);
await vi.advanceTimersByTimeAsync(25);

expect(mockPty.write).toHaveBeenCalledWith(
"\x1b[200~ADE_STABLE_COMPOSER_MARKER\x1b[201~",
);
} finally {
vi.useRealTimers();
}
});

it("moves node_modules bins behind user paths for Codex CLI launches", async () => {
const previousPath = process.env.PATH;
process.env.PATH = [
Expand Down Expand Up @@ -1556,6 +1707,7 @@ describe("ptyService", () => {
startupCommand: "codex --no-alt-screen",
initialInput: "please keep going",
awaitInitialInput: true,
initialInputReadyTimeoutMs: 20_000,
}).then(
() => null,
(error: unknown) => error,
Expand Down
95 changes: 82 additions & 13 deletions apps/desktop/src/main/services/pty/ptyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const AGENT_CLI_SUBMIT_DELAY_MS = 25;
const CODEX_CLI_PASTE_SUBMIT_DELAY_MS = 180;
const CURSOR_CLI_PASTE_SUBMIT_DELAY_MS = 500;
const AGENT_CLI_READY_TIMEOUT_MS = 20_000;
const CODEX_CLI_READY_TIMEOUT_MS = 60_000;
const AGENT_CLI_READY_POLL_MS = 100;
const AGENT_CLI_READY_QUIET_MS = 600;
const PTY_PROCESS_TREE_KILL_DELAY_MS = 1500;
Expand Down Expand Up @@ -611,6 +612,8 @@ type PtyEntry = {
processOutputData: ((data: string) => void) | null;
/** Epoch ms of the last user write; shortens the data batch window. */
lastUserInputAt: number;
/** Monotonic generation used to detect user takeover of deferred input. */
userInputGeneration: number;
terminalSnapshot: TerminalSnapshotMirror | null;
recentOutputTail: string;
runtimeWindowTitleScanBuffer: string;
Expand Down Expand Up @@ -3778,21 +3781,37 @@ export function createPtyService({
timeoutMs = AGENT_CLI_READY_TIMEOUT_MS,
): Promise<boolean> => {
const deadline = Date.now() + timeoutMs;
let stableReadyText = "";
let stableReadySince = 0;
while (Date.now() < deadline) {
if (agentCliInputReadyNow(sessionId, provider)) return true;
if (!liveEntryBySessionId(sessionId)) return false;
const readiness = agentCliInputReadiness(sessionId, provider);
if (!readiness) return false;
if (readiness.readyNow) return true;
if (provider === "codex" && readiness.markerVisible) {
if (readiness.text === stableReadyText) {
if (stableReadySince > 0 && Date.now() - stableReadySince >= AGENT_CLI_READY_QUIET_MS) {
return true;
}
} else {
stableReadyText = readiness.text;
stableReadySince = Date.now();
}
} else {
stableReadyText = "";
stableReadySince = 0;
}
await delay(AGENT_CLI_READY_POLL_MS);
}
logger.warn("pty.agent_cli_ready_wait_timeout", { sessionId, provider, timeoutMs });
return false;
};

const agentCliInputReadyNow = (
const agentCliInputReadiness = (
sessionId: string,
provider: TerminalResumeProvider,
): boolean => {
): { markerVisible: boolean; readyNow: boolean; text: string } | null => {
const live = liveEntryBySessionId(sessionId);
if (!live || live[1].disposed) return false;
if (!live || live[1].disposed) return null;
const entry = live[1];
const outputTail = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n");
const visibleText = entry.terminalSnapshot
Expand All @@ -3803,8 +3822,19 @@ export function createPtyService({
const readinessText = visibleText.trim().length > 0 ? visibleText : outputTail;
const runtime = runtimeStates.get(sessionId);
const quietForMs = runtime ? Date.now() - runtime.lastActivityAt : 0;
return providerReadyMarkerVisible(provider, readinessText)
&& quietForMs >= AGENT_CLI_READY_QUIET_MS;
const markerVisible = providerReadyMarkerVisible(provider, readinessText);
return {
markerVisible,
readyNow: markerVisible && quietForMs >= AGENT_CLI_READY_QUIET_MS,
text: readinessText,
};
};

const agentCliInputReadyNow = (
sessionId: string,
provider: TerminalResumeProvider,
): boolean => {
return agentCliInputReadiness(sessionId, provider)?.readyNow ?? false;
};

const writeAgentCliInput = async (
Expand Down Expand Up @@ -4101,6 +4131,7 @@ export function createPtyService({

const markPtyUserInput = (entry: PtyEntry): void => {
entry.lastUserInputAt = Date.now();
entry.userInputGeneration += 1;
if (entry.tracked && isTrackedAgentCliToolType(entry.toolTypeHint)) {
clearTrackedCliTurnStartMarkers(entry.sessionId);
entry.attentionRequested = false;
Expand Down Expand Up @@ -4606,6 +4637,7 @@ export function createPtyService({
pendingOutputHighSurrogate: "",
processOutputData: null,
lastUserInputAt: 0,
userInputGeneration: 0,
terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null,
recentOutputTail: "",
runtimeWindowTitleScanBuffer: "",
Expand Down Expand Up @@ -4732,9 +4764,13 @@ export function createPtyService({

if (requestedInitialInput.length > 0) {
const normalizedInitialInput = requestedInitialInput.replace(/\r\n?/g, "\n");
const provider = providerFromTool(toolTypeHint);
const defaultInitialInputReadyTimeoutMs = provider === "codex"
? CODEX_CLI_READY_TIMEOUT_MS
: AGENT_CLI_READY_TIMEOUT_MS;
const requestedInitialInputReadyTimeoutMs = args.initialInputReadyTimeoutMs;
const parsedInitialInputReadyTimeoutMs = Math.floor(
Number(requestedInitialInputReadyTimeoutMs ?? AGENT_CLI_READY_TIMEOUT_MS) || 0,
Number(requestedInitialInputReadyTimeoutMs ?? defaultInitialInputReadyTimeoutMs) || 0,
);
const initialInputReadyTimeoutMs = Math.max(
AGENT_CLI_READY_TIMEOUT_MS,
Expand All @@ -4756,24 +4792,57 @@ export function createPtyService({
maxTimeoutMs: 300_000,
});
}
const initialInputUserGeneration = entry.userInputGeneration;
const writeInitialInput = async (): Promise<void> => {
entry.initialInputTimer = null;
if (entry.disposed) throw new Error("Terminal session closed before initial input could be sent.");
const provider = providerFromTool(toolTypeHint);
const userTookControl = (): boolean => entry.userInputGeneration !== initialInputUserGeneration;
try {
if (provider) {
const ready = await waitForAgentCliInputReady(sessionId, provider, initialInputReadyTimeoutMs);
if (!ready) {
logger.warn("pty.initial_input_skipped_not_ready", {
while (!await waitForAgentCliInputReady(sessionId, provider, initialInputReadyTimeoutMs)) {
if (entry.disposed || !liveEntryBySessionId(sessionId)) {
throw new Error("Terminal session closed before initial input could be sent.");
}
if (userTookControl()) {
logger.info("pty.initial_input_cancelled_user_takeover", {
ptyId,
sessionId,
cwd,
toolType: toolTypeHint,
provider,
});
return;
}
if (args.awaitInitialInput || provider !== "codex") {
logger.warn("pty.initial_input_skipped_not_ready", {
ptyId,
sessionId,
cwd,
toolType: toolTypeHint,
provider,
});
throw new Error(`${provider} CLI did not become ready; initial input was not sent.`);
}
logger.warn("pty.initial_input_retrying_not_ready", {
ptyId,
sessionId,
cwd,
toolType: toolTypeHint,
provider,
timeoutMs: initialInputReadyTimeoutMs,
});
throw new Error(`${provider} CLI did not become ready; initial input was not sent.`);
}
if (entry.disposed) throw new Error("Terminal session closed before initial input could be sent.");
if (userTookControl()) {
logger.info("pty.initial_input_cancelled_user_takeover", {
ptyId,
sessionId,
cwd,
toolType: toolTypeHint,
provider,
});
return;
}
}
if (provider) {
const submittedInitialInput = normalizedInitialInput.trim();
Expand Down
Loading