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
26 changes: 17 additions & 9 deletions src/loop/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const CLAUDE_EXIT_OPTION = "No, exit";
const CLAUDE_DEV_CHANNELS_PROMPT = "WARNING: Loading development channels";
const CLAUDE_DEV_CHANNELS_CONFIRM = "I am using this for local development";
const CLAUDE_CHANNEL_SCOPE = "local";
const CLAUDE_PROMPT_INITIAL_POLLS = 4;
const CLAUDE_PROMPT_MAX_POLLS = 8;
const CLAUDE_PROMPT_POLL_DELAY_MS = 250;
const CLAUDE_PROMPT_SETTLE_POLLS = 2;
const MCP_ALREADY_EXISTS_RE = /already exists/i;
Expand Down Expand Up @@ -758,17 +758,17 @@ const unblockClaudePane = async (
): Promise<void> => {
const pane = `${session}:0.0`;
let handledPrompt = false;
let lastSnapshot = "";
let quietPolls = 0;
let sawOutput = false;

for (
let attempt = 0;
attempt < CLAUDE_PROMPT_INITIAL_POLLS * 2;
attempt += 1
) {
const prompt = detectClaudePrompt(deps.capturePane(pane));
for (let attempt = 0; attempt < CLAUDE_PROMPT_MAX_POLLS; attempt += 1) {
const snapshot = normalizePaneText(deps.capturePane(pane));
const prompt = detectClaudePrompt(snapshot);
if (prompt === "confirm") {
deps.sendKeys(pane, ["Enter"]);
handledPrompt = true;
lastSnapshot = "";
quietPolls = 0;
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
continue;
Expand All @@ -778,16 +778,24 @@ const unblockClaudePane = async (
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
deps.sendKeys(pane, ["Enter"]);
handledPrompt = true;
lastSnapshot = "";
quietPolls = 0;
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
continue;
}

quietPolls += 1;
if (snapshot) {
sawOutput = true;
}
quietPolls = snapshot === lastSnapshot ? quietPolls + 1 : 0;
lastSnapshot = snapshot;
if (handledPrompt && quietPolls >= CLAUDE_PROMPT_SETTLE_POLLS) {
return;
}
if (!handledPrompt && quietPolls >= CLAUDE_PROMPT_INITIAL_POLLS) {
if (sawOutput && quietPolls >= CLAUDE_PROMPT_SETTLE_POLLS) {
return;
}
if (attempt + 1 >= CLAUDE_PROMPT_MAX_POLLS) {
return;
}
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
Expand Down
84 changes: 82 additions & 2 deletions tests/loop/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ test("runInTmux starts paired interactive tmux panes without a task", async () =
expect(manifest.tmuxSession).toBe("repo-loop-1");
});

test("runInTmux keeps the no-prompt Claude startup wait short", async () => {
test("runInTmux keeps the no-prompt Claude startup wait bounded", async () => {
const sleeps: number[] = [];
let sessionStarted = false;
const manifest = createRunManifest({
Expand Down Expand Up @@ -743,7 +743,7 @@ test("runInTmux keeps the no-prompt Claude startup wait short", async () => {
{ opts: makePairedOptions({ proof: "" }) }
);

expect(sleeps).toEqual([250, 250, 250]);
expect(sleeps).toEqual([250, 250, 250, 250, 250, 250, 250]);
});

test("tmux prompts keep the paired review workflow explicit", () => {
Expand Down Expand Up @@ -1020,6 +1020,86 @@ test("runInTmux confirms wrapped Claude dev-channel prompts", async () => {
});
});

test("runInTmux catches a delayed Claude dev-channel prompt", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
let pollCount = 0;
const devChannelsPrompt = [
"WARNING: Loading development channels",
"",
"--dangerously-load-development-channels is for local channel development only.",
"",
"1. I am using this for local development",
].join("\n");
const manifest = createRunManifest({
cwd: "/repo",
mode: "paired",
pid: 1234,
repoId: "repo-123",
runId: "1",
status: "running",
});
const storage = {
manifestPath: "/repo/.loop/runs/1/manifest.json",
repoId: "repo-123",
runDir: "/repo/.loop/runs/1",
runId: "1",
storageRoot: "/repo/.loop/runs",
transcriptPath: "/repo/.loop/runs/1/transcript.jsonl",
};

await runInTmux(
["--tmux", "--proof", "verify with tests"],
{
capturePane: () => {
pollCount += 1;
return pollCount === 5 ? devChannelsPrompt : "";
},
cwd: "/repo",
env: {},
findBinary: () => true,
getCodexAppServerUrl: () => "ws://127.0.0.1:4500",
getLastCodexThreadId: () => "codex-thread-1",
isInteractive: () => false,
launchArgv: ["bun", "/repo/src/cli.ts"],
log: (): void => undefined,
makeClaudeSessionId: () => "claude-session-1",
preparePairedRun: (nextOpts) => {
nextOpts.codexMcpConfigArgs = [
"-c",
'mcp_servers.loop-bridge.command="loop"',
];
return { manifest, storage };
},
sendKeys: (pane: string, keys: string[]) => {
keyCalls.push({ keys, pane });
},
sendText: (): void => undefined,
sleep: () => Promise.resolve(),
startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"),
startPersistentAgentSession: () => Promise.resolve(undefined),
spawn: (args: string[]) => {
if (args[0] === "tmux" && args[1] === "has-session") {
return sessionStarted
? { exitCode: 0, stderr: "" }
: { exitCode: 1, stderr: "" };
}
if (args[0] === "tmux" && args[1] === "new-session") {
sessionStarted = true;
}
return { exitCode: 0, stderr: "" };
},
updateRunManifest: (_path, update) => update(manifest),
},
{ opts: makePairedOptions(), task: "Ship feature" }
);

expect(keyCalls).toContainEqual({
keys: ["Enter"],
pane: "repo-loop-1:0.0",
});
});
Comment on lines +1023 to +1101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's a lot of repeated setup code across the runInTmux tests. To improve maintainability and reduce boilerplate, consider creating a test helper function. This function could encapsulate the common setup for manifest, storage, and the default mocked dependencies for runInTmux.

Each test could then call this helper and override only the specific mocks it needs, like capturePane or sendKeys.

For example, you could have a helper like this:

const setupPairedRunTest = (
  overrides: Partial<Parameters<typeof runInTmux>[1]> = {}
) => {
  const keyCalls: Array<{ keys: string[]; pane: string }> = [];
  let sessionStarted = false;
  const manifest = createRunManifest({ ... });
  const storage = { ... };

  const defaultDeps = {
    capturePane: () => "",
    cwd: "/repo",
    env: {},
    findBinary: () => true,
    sendKeys: (pane: string, keys: string[]) => {
      keyCalls.push({ keys, pane });
    },
    spawn: (args: string[]) => {
    },
    ...overrides,
  };

  const run = (
    args: string[] = ["--tmux", "--proof", "verify with tests"],
    launch?: PairedTmuxLaunch
  ) => runInTmux(args, defaultDeps, launch ?? { opts: makePairedOptions(), task: "Ship feature" });

  return { run, keyCalls, manifest, storage };
};


test("runInTmux confirms the current Claude bypass prompt wording", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
Expand Down
Loading