Skip to content

Fix interactive service session message delivery - #1162

Merged
edwin-zvs merged 1 commit into
mainfrom
fix-interactive-service-delivery
Aug 2, 2026
Merged

Fix interactive service session message delivery#1162
edwin-zvs merged 1 commit into
mainfrom
fix-interactive-service-delivery

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Reported against the slack-construct service: the first Slack message in a thread never showed up in the interactive session, and a second message from the same thread got typed into the session's prompt but never submitted.

Root causes

Both come from delivering channel messages by writing into the session's PTY with send_input, which is not a valid way to talk to an agent TUI.

First message — lost to a boot race. The opening message was written immediately after create() returned, while codex was still drawing its splash screen and had not attached its input handler. The bytes sat in the tty's canonical-mode input buffer, got echoed back, and were discarded when codex switched the terminal to raw mode. The reported session's own pty.log shows it plainly — it opens with the raw echoed message text, before codex's ESC[?2004h setup, then redraws with an empty composer:

SF event at japantown tomorrow^M
^M
[Construct service delivery 7aeaf560...]^M
Send the caller-facing final response with the `construct_service_reply` tool...^M
^[[?2004h^[[>4;0m...        <- codex takes over here, message already gone

Follow-up message — typed but never submitted. run_pty terminates Input with LF. A terminal's Enter key sends CR; crossterm explicitly maps LF to Ctrl+J in raw mode (crossterm#371), and codex binds Ctrl+J to "insert a newline". So the message was typed into the composer, one line per embedded newline, and left there — exactly what the tail of that session's pty.log shows.

Fix

The daemon already solved both problems for Playbook Run forks (wait_for_fork_ready, bracketed-paste + gated Enter). This routes service deliveries through that machinery instead of maintaining a second, weaker path.

  • First delivery becomes the session's seed prompt, carried in session.start as structured data per spec 0046, so the adapter starts its first turn natively and there is no PTY to race.
  • Later deliveries go through a new deliver_user_text, which frames the message the way the target harness accepts a submitted turn: bracketed paste + gated Enter for an agent TUI, CR-terminated write for a line editor, structured input for a headless harness.
  • deliver_user_text records the delivery as a user turn only for harnesses that don't mirror their own. Codex reports it from its rollout, so recording it again showed the caller's message twice.
  • Renames the delivery classifier off its playbook_ prefix — it's now the shared answer to "how does this harness take a submitted turn".

New spec 0177-service-deliveries-reach-sessions-as-native-input.md records the rule.

Verification

End-to-end against a live interactive codex service over the HTTP channel (same ingress code path Slack uses — the channel is transport-only):

delivery result
first (session_key: thread-B) started a codex turn, replied CHARLIE via construct_service_reply
follow-up (same key) submitted at 06:57:29, replied DELTA at 06:57:36

The follow-up's user message appears exactly once in the transcript. The new session's pty.log opens with codex's own setup sequence — no echoed message text.

cargo test --workspace: 51 test binaries, 0 failures.

Binary

Touches crates/daemon → the relevant binary is construct (the daemon lives in it).

An interactive service session dropped the first channel message and left
every later one typed but unsubmitted, so a Slack thread produced a session
that never answered.

Both failures came from writing the message into the session's PTY with
`send_input`, which is not a valid way to talk to an agent TUI:

- The opening message was written immediately after spawn, while codex was
  still drawing its splash screen and had not attached its input handler.
  The terminal echoed it in canonical mode and discarded it the moment the
  harness switched to raw mode. The session's own pty.log opens with the
  echoed text, then a clean redraw with an empty composer.

- Later messages were written as `text` + LF. A terminal's Enter key sends
  CR; in raw mode crossterm reads LF as Ctrl+J, which codex binds to "insert
  a newline". The message was typed into the composer, line by line, and left
  sitting there.

The daemon already solved both problems for Playbook Run forks. Route service
deliveries through the same machinery instead of a second, weaker path:

- The first delivery becomes the session's seed prompt, carried in
  session.start as structured data (spec 0046), so the adapter starts its
  first turn natively and there is no PTY to race.
- Later deliveries go through a new `deliver_user_text`, which frames the
  message the way the target harness accepts a submitted turn — bracketed
  paste plus a gated Enter for an agent TUI, CR-terminated write for a line
  editor, structured input for a headless harness.

`deliver_user_text` records the delivery as a user turn only for harnesses
that do not mirror their own; codex reports it from its rollout, so recording
it again showed the caller's message twice.

Renames the delivery classifier off its `playbook_` prefix — it is now the
shared answer to "how does this harness take a submitted turn", not a
playbook detail.

Verified end to end against a live interactive codex service over the HTTP
channel: first delivery and follow-up each started a turn and returned
through construct_service_reply.
@edwin-zvs
edwin-zvs merged commit ebe0f26 into main Aug 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant