Skip to content

[bug] A keystroke sent to a coding session is answered 200 with a snapshot and never delivered — resolve key() rather than leave it #448

Closed

Description

@serge-ivo

POST …/sessions/:id/message {keys} returns 200 and a snapshot for a keystroke that was never sent

Filed from the #249 assessment, recommendation item 2 — "Resolve key() one way or the other…
This is the memo's item 3, still open, still unfiled."
#249 cannot close leaving it as the ADR's
only record.

What is still true, and what has already been fixed

The #249 memo framed this as "the console offers keystrokes and the runner accepts them into a
transcript nobody treats as an error."
The Pilot half is already fixed and should not be
re-litigated: press_keys is no longer offered as a tool, and the reasoning is written at
workers/api/src/lib/coding-loop.ts:201-207 —

"the tool was still advertised, mapped to {kind:"keys"}, routed into key() and answered with a
normal snapshot, indistinguishable from success. So a menu ("1) Sign in with…") produced: brain
calls press_keys("Enter") → nothing sent → unchanged pane → … The run burned all 40 decisions of
BYOK Claude and ended max_steps having done nothing."

toDecision (:283-286) now converts a stale press_keys into an honest stuck, and the system
prompt says outright "You CANNOT send keystrokes" (:235).

The HTTP surface was not fixed with it. workers/api/src/routes/coding.ts:466-470:

const action: CodingActionKind =
    typeof body.keys === "string"
        ? { kind: "keys", keys: body.keys }
        : { kind: "message", text: String(body.text ?? "") };

→ callRunner(conn, "/coding/act", {sessionId, action}) → CodingRuntime.act
(packages/browser-runner/src/coding/runtime.ts:240) → session.key(action.keys) →
packages/browser-runner/src/coding/headless.ts:555-557:

key(keys: string): void {
    this.push(`[ignored keypress ${keys.slice(0,40)} — this session has no terminal attached]`);
}

The route then returns the fresh snapshot with an ordinary 200. Nothing in the response says the
keystroke was not delivered.
The only record is a line in the pane text, which a caller reading
{status, pane} has no reason to parse.

Who can reach it, measured

  • The console: nobody. CodingTab.tsx:565-577 is the only sender and posts {text}. grep -rn '"keys"' agents/coder/web/src store/console/src → no hits.
  • MCP: no. coding_session_message (workers/mcp/src/index.ts:907) posts {text: message}.
  • The Pilot: no longer. Per above.
  • The REST API: yes, to any owner of the instance, and to anything built against the documented
    surface. It is in store/openapi.yaml's shape by virtue of being accepted.

So today this is a lie-shaped API with no caller rather than an active bug — which is the best
possible moment to resolve it, and the exact condition under which it gets forgotten instead.

The decision to make

The memo put it as "either the console stops offering keystrokes, or the runner stops accepting them
into a transcript nobody treats as an error." Since the console never offered them, the real fork is:

(a) Refuse — the cheap, honest answer, and my recommendation. key() is not implementable
without a PTY, so the route should say so. routes/coding.ts returns 409 with the sentence the
Pilot already uses: "this session has no terminal attached — a human needs to answer the prompt."
One branch, no runner change, and it makes the impossibility discoverable at the boundary rather
than in a pane. The runner's key() should also stop being a silent success: return a failure the
runtime propagates, so a future backend that can send keys does not inherit the pretence.

(b) Implement — node-pty inside HeadlessSession. #249's proposed ADR 0002 names this as the
only admissible route to a PTY (rule E4), because node-pty preserves the spawn env, the usage
drain (parseEngineUsage off the stream-json result event), the act parser
(noteAct/settleAct), and the resolved-credential derivation — all four of which tmux gives up.
That is real work, and #249's own value analysis says keystrokes are "the one genuine PTY-shaped
gap"
with no named user asking for it.

Do (a) now. Leave (b) to the ADR. If (b) is ever built, (a) is the thing it deletes — one branch —
and until then the platform stops claiming a capability it does not have.

The third thing, which is not optional either way

interrupt is the sibling action and it does work (headless.ts:562-570 sends SIGINT). So the
answer to "the CLI is stuck on a prompt" is not "we can do nothing" — it is interrupt, or a
request_human takeover. Whatever wording the 409 carries should point at those, because a refusal
that names no alternative is how the next caller invents one.

What to do, cheapest first

  • routes/coding.ts:466-470 — refuse {keys} with 409 and the "no terminal attached" sentence,
    naming interrupt and takeover as what exists.
  • headless.ts:555-557 — make key() report failure rather than push a line and return void, so
    the runtime can propagate it. Keep the transcript line: [bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391's reasoning that a swallowed
    no-op is worse than a recorded one still holds.
  • Delete {kind:"keys"} from CodingActionKind only if (a) is chosen and nothing else
    routes it — coding-loop.ts:57 still declares it in the decision union, and removing it there
    is what stops the stale-tool path from being re-added. Check runtime.ts:65,240 together.
  • One test: POST …/message {keys:"Enter"} → 409 with that sentence; {text:"…"} unchanged.

Alternatives considered and rejected

Acceptance criteria

  • POST /v1/instances/:id/coding/sessions/:sid/message {"keys":"Enter"} → 409, with a message
    naming what to do instead.
  • {"text":"…"} on the same route is byte-identically unchanged, including the rules-prefix
    injection and the watcher stamp.
  • No path from the Pilot, the console or MCP can produce a {kind:"keys"} action.
  • If key() survives on the runner, it returns a failure the runtime surfaces — a snapshot alone
    is no longer the whole answer.

Regression risk

  • An old runner is the one real risk. CodingRuntime.act on a published @proagentstore/cli
    older than this change still accepts {kind:"keys"}; the cloud refusing first is what makes that
    moot, so the refusal must be in the API route, not only in the runner.
  • coding-loop.ts:283-286 deliberately keeps a press_keys arm for a cached/older tool list. Do not
    delete it while removing the action kind — it is the thing that turns a stale call into an honest
    stuck rather than an unknown tool.
  • The takeover path (resume) and interrupt share this route's neighbourhood; a 409 branch added
    before the session lookup would change their error ordering. Add it after getSession.

Files: workers/api/src/routes/coding.ts:462-500,
packages/browser-runner/src/coding/headless.ts:545-570,
packages/browser-runner/src/coding/runtime.ts:60-70,234-250,
workers/api/src/lib/coding-loop.ts:55-60,195-207,230-240,275-292,
agents/coder/web/src/CodingTab.tsx:565-577, workers/mcp/src/index.ts:900-915.
Related: #249 (the assessment that asks for this, item 2, and proposed ADR 0002 rule E4),
#391 (process exit as the turn boundary, and why a swallowed no-op is worse than a recorded one),
#294/#267 (what a screen-buffer backend would cost), #433 (the docs that describe the split).

Activity

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in dc8ed14 (+ CLI 0.4.45 in e65cad0) — verified live in production

    Option (a), as recommended: the platform stops claiming a capability it does not have.

    Verified against production, just now

    POST https://api.proagentstore.online/v1/instances/12ebf1f0-…/coding/sessions/csess_7e1ac098-…/message
         {"keys":"Enter"}
    → HTTP 409
      "This session has no terminal attached, so a keystroke can't be delivered — a human needs to
       answer the prompt. Phrase the answer as an instruction and send it as {\"text\": \"...\"} on
       this route, or restart the engine with POST …/coding/sessions/:sid/restart."
    

    The refusal names alternatives, per the ticket's "third thing, which is not optional either way" —
    a refusal that names no alternative is how the next caller invents one.

    Also verified live that the 404-before-409 ordering is intact: the same body against a session
    id that does not exist returns 404 {"error":"Session not found"}, not the 409. That was the named
    regression risk — a refusal added before the session lookup would have changed interrupt's and
    resume's error ordering. The branch sits after getSession and a test pins it.

    Acceptance criteria

    • {"keys":"Enter"} → 409 naming what to do instead. Measured above.
    • {"text":"…"} unchanged, including the rules-prefix injection and the watcher stamp — driven
      through the route rather than grepped.
    • No path from the Pilot, the console or MCP can produce a {kind:"keys"} action.
      {kind:"keys"} is removed from CodingActionKind in lib/coding-loop.ts. workers/mcp was
      re-checked and left untouched: it posts {text}.
    • key() returns a failure the runtime surfaces — {delivered:false, reason}, raised as a
      RunnerInputError by CodingRuntime.act. The transcript line is kept: [bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391's reasoning
      that a swallowed no-op is worse than a recorded one still holds.

    Two judgement calls worth recording

    1. The stale press_keys arm in toDecision is kept, exactly as the ticket warns. It is what
      converts a cached or older tool list into an honest stuck instead of an unknown tool.
    2. keys stays in the runner's action union, with a comment. The runner is a published
      package: a shape has to be recognised to be refused with the right sentence, rather than
      falling into "Unknown coding action". The cloud refusing first is what makes an old runner moot,
      which is why the 409 is in the API route and not only in the runner.

    Reaches installed runners via @proagentstore/cli@0.4.45, published by CI on this push
    (confirmed on npm). Full bar green including Playwright chromium+webkit; deploy workflows green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions