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
5 changes: 5 additions & 0 deletions changelog.d/input-line-long-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- A one-line message of more than 200 characters sent from the terminal's input line reaches the pane's
agent whole: it goes as the agent's prompt, pasted in the agent's own paste mode, where typing it left
Claude Code with only its tail. A shorter line, and any line to a pane whose agent is waiting for an
answer or that runs no agent, is still typed.
30 changes: 30 additions & 0 deletions server/core/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ export const closeMention = (text: string): string => {
return /(?:^|\s)@\S+$/.test(body) ? `${open}${body} ${close}` : text;
};

/**
* The longest line the terminal's input line types raw; a longer one goes to the pane's agent as its prompt.
* Typed raw, a line reaches the program in pty-sized reads, and Claude Code took each full one for a paste of
* its own and dropped it before the Enter came: only the tail was sent (#1174). Lines of about 400 characters
* still arrived whole; 200 (at most 600 bytes) stays well under the smallest read seen, 1,022 bytes.
*/
const TYPED_LINE_MAX = 200;

/** herdr's refusals of an agent.prompt that typed nothing: the input line types the line instead, as the keyboard would */
const PROMPT_REFUSED = new Set(["agent_not_found", "agent_not_ready", "agent_blocked"]);

export const modelSwitchText = (agent: string | null, text: string): string =>
agent === "omp" && text.trim() === "/model" ? "/switch" : text;

Expand Down Expand Up @@ -495,6 +506,25 @@ export function createSubmitText({ options, lastTyped, attachments, terminalAtta
if (error.code !== "agent_not_found" && error.code !== "agent_not_ready" && !queuedOnly) throw error;
}
}
// one long line from the input line: herdr's agent.prompt pastes it in the agent's own paste mode and
// returns once its Enter followed (TYPED_LINE_MAX). An agent waiting for an answer, or none, is typed into.
if (fromTerminal && !sentText.includes("\n") && sentText.length > TYPED_LINE_MAX) {
// the sender is asked once more right before herdr is written to: it can be revoked, or switch to
// watching, while the connection opens. Its refusal sends nothing, and nothing is typed instead.
let refused: unknown;
const mayType = (): boolean => {
try { authorize(); return true; } catch (error) { refused = error; return false; }
};
try {
inTime();
await agentPrompt(paneId, closeMention(sentText), undefined, mayType);
noteSubmitted(paneId, sentText, recorded(command));
return;
} catch (error) {
if (refused !== undefined) throw refused;
if (!(error instanceof HerdrError) || !PROMPT_REFUSED.has(error.code)) throw error;
}
}
inTime();
// The terminal input line has no pre-read pane identity. Do not add reads just for tracking.
// a mirrored pane's browser never learned the program's paste mode, so `payload` came as bare
Expand Down
70 changes: 70 additions & 0 deletions server/core/input-line-send.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, expect, it, mock, spyOn } from "bun:test";
import * as client from "../herdr/client.ts";
import { createSubmitText } from "./composer.ts";

afterEach(() => mock.restore());

const submitter = () => createSubmitText({
options: { submitDelayMs: 0 }, lastTyped: new Map(), attachments: new Map(), terminalAttach: async () => true,
SUBMIT_DEADLINE_MS: 1000, TYPED_SETTLE_MS: 0, DRAFT_CLEAR_WAIT_MS: 0, DRAFT_CLEAR_POLL_MS: 0, SUBMIT_DELAY_MS: 0,
});

/** #1174's example: one line of 24 sentences, which typed raw reached Claude Code as only its tail */
const LONG = Array.from({ length: 24 }, (_, i) => `This is sentence number ${i + 1} of a long dictated message.`).join(" ");

const herdr = () => ({
prompt: spyOn(client, "agentPrompt").mockResolvedValue(),
typed: spyOn(client, "paneSendText").mockResolvedValue(),
keys: spyOn(client, "paneSendKeys").mockResolvedValue(),
});

it("sends one long input line to the pane's agent as its prompt, not typed raw", async () => {
const { prompt, typed, keys } = herdr();
await submitter()("w:p1", LONG, LONG, Date.now(), true);
expect(prompt).toHaveBeenCalledWith("w:p1", LONG, undefined, expect.any(Function));
expect(typed).not.toHaveBeenCalled();
expect(keys).not.toHaveBeenCalled();
});

it("still types a short input line, an answer into an open menu included", async () => {
const { prompt, typed, keys } = herdr();
await submitter()("w:p1", "2", "2", Date.now(), true);
expect(prompt).not.toHaveBeenCalled();
expect(typed).toHaveBeenCalledWith("w:p1", "2");
expect(keys).toHaveBeenCalledWith("w:p1", ["Enter"]);
});

for (const code of ["agent_not_found", "agent_not_ready", "agent_blocked"]) {
it(`types a long input line as before when herdr refuses its prompt with ${code}`, async () => {
const { prompt, typed, keys } = herdr();
prompt.mockRejectedValue(new client.HerdrError(code, "fixture refusal"));
await submitter()("w:p1", LONG, LONG, Date.now(), true);
expect(typed).toHaveBeenCalledWith("w:p1", LONG);
expect(keys).toHaveBeenCalledWith("w:p1", ["Enter"]);
});
}

it("writes and types nothing of a long input line whose sender may no longer type once herdr is reached", async () => {
const { prompt, typed, keys } = herdr();
let watching = false;
const authorize = () => { if (watching) throw new client.HerdrError("read_only", "this connection can only watch"); };
// herdrRpc asks the guard once connected, right before the prompt is written
prompt.mockImplementation(async (_target, _text, _socketPath, guard) => {
watching = true;
if (guard && !guard()) throw new client.HerdrError("cancelled", "herdr agent.prompt was not sent");
});
const failed = await submitter()("w:p1", LONG, LONG, Date.now(), true, authorize).then(() => null, (error: unknown) => error);
expect(failed).toBeInstanceOf(client.HerdrError);
expect((failed as client.HerdrError).code).toBe("read_only");
expect(typed).not.toHaveBeenCalled();
expect(keys).not.toHaveBeenCalled();
});

it("never types a long input line after its prompt failed in a way that may have delivered it", async () => {
const { prompt, typed, keys } = herdr();
prompt.mockRejectedValue(new client.HerdrError("timeout", "herdr did not answer"));
const failed = await submitter()("w:p1", LONG, LONG, Date.now(), true).then(() => null, (error: unknown) => error);
expect(failed).toBeInstanceOf(client.HerdrError);
expect(typed).not.toHaveBeenCalled();
expect(keys).not.toHaveBeenCalled();
});
65 changes: 65 additions & 0 deletions server/submit.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ class Socket {

const paste = (text: string) => `\u001b[200~${text}\u001b[201~`;

// #1174: typed raw, a long line reached Claude Code in pty-sized reads, and it kept only the tail
const longLine = Array.from({ length: 24 }, (_, i) => `This is sentence number ${i + 1} of a long dictated message.`).join(" ");

/** A promise the test settles itself: it orders a server's step against the test's own. */
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
Expand Down Expand Up @@ -1213,6 +1216,34 @@ describe("WebSocket submit", () => {
}
}, 30_000);

it("hands an agent one long line from the terminal's input line through agent.prompt, pasted whole", async () => {
const socket = await Socket.connect();
try {
const from = chunks(agent).length;
socket.send({ type: "submit", id: 1174, pane_id: agent.pane, text: longLine, payload: longLine, typed: true });
expect(await socket.result(1174)).toMatchObject({ ok: true });
await received(agent, from, 1);
expect(typed(agent, from)).toBe(`${paste(longLine)}\r`);
} finally {
socket.close();
}
}, 30_000);

it("types one long line from the terminal's input line into an agent waiting for an answer, as the keyboard would", async () => {
const socket = await Socket.connect();
await herdrRpc("pane.report_agent", { pane_id: agent.pane, source: "manual", agent: "claude", state: "blocked" });
try {
const from = chunks(agent).length;
socket.send({ type: "submit", id: 1175, pane_id: agent.pane, text: longLine, payload: longLine, typed: true });
expect(await socket.result(1175)).toMatchObject({ ok: true });
await received(agent, from, 1);
expect(typed(agent, from)).toBe(`${longLine}\r`);
} finally {
await herdrRpc("pane.report_agent", { pane_id: agent.pane, source: "manual", agent: "claude", state: "idle" });
socket.close();
}
}, 30_000);

it("still hands a message to a Codex blocked only by questions waiting collapsed in its queue", async () => {
const socket = await Socket.connect();
try {
Expand Down Expand Up @@ -1838,6 +1869,40 @@ describe("typing without terminal attach, at the edges", () => {
}
}, 30_000);

it("writes no long input-line line for a sender that switched to observe while herdr was being reached", async () => {
const bare = createServer({ port: 0, stateDir: join(root, "observe-long-line"), terminalAttach: false });
const sender = await Socket.connect(bare.port);
const keeper = await Socket.connect(bare.port);
const gate = deferred();
const entered = deferred();
const originalPrompt = herdr.agentPrompt;
// every check before the RPC has passed; the sender switches to watching before herdr is written to
const prompt = spyOn(herdr, "agentPrompt").mockImplementation(async (target, text, socketPath, guard) => {
if (target === agent.pane && text === longLine) { entered.resolve(); await gate.promise; }
return originalPrompt(target, text, socketPath, guard);
});
try {
for (const socket of [keeper, sender]) await attached(socket, agent.pane);
const from = chunks(agent).length;
sender.send({ type: "submit", id: 1176, pane_id: agent.pane, text: longLine, payload: longLine, typed: true });
await entered.promise;
sender.send({ type: "role", mode: "observe" });
await sender.waitFor((message) => message.type === "role-ack" && message.mode === "observe");
gate.resolve();
expect(await sender.result(1176)).toMatchObject({ ok: false, code: "read_only" });
keeper.send({ type: "submit", id: 1177, pane_id: agent.pane, text: "after", payload: "after", typed: true });
expect(await keeper.result(1177)).toMatchObject({ ok: true });
await received(agent, from, 1);
expect(typed(agent, from)).toBe("after\r");
} finally {
gate.resolve();
prompt.mockRestore();
sender.close();
keeper.close();
bare.stop();
}
}, 30_000);

it("enters no secret for a sender that switched to observe and back while the screen was read (#589)", async () => {
const bare = createServer({ port: 0, stateDir: join(root, "push-role-secret"), terminalAttach: false });
const sender = await Socket.connect(bare.port);
Expand Down
Loading