feat(agents): a message to a coding agent may be files alone

Merged
DA
7d ago
+275-87

Dean asked: "Why can't I send an image by itself? Why do I need to add words?" Now you don't. A message to a coding agent, and a draft agent's first message (its Start task), may be words, files, or both, never neither, as in ChatGPT, Claude, and Cursor.

What changed

Wire (packages/hosted/src/coding-agent-wire.ts): one rule in one place.

  • A message's text and a Start's task may now be empty (still trimmed and bounded at 16,000 chars).
  • A single check on the whole struct refuses "no words and no files": hasWordsOrFiles(words, attachments). On a Start, task absent means the plan brief and carries no files, which takes over the route's hand-written "files go only with a task" check (now deleted from coding-agents-app.ts).
  • The desktop's Send button, Enter, and the draft tab's Start all call the same exported hasWordsOrFiles, so the desktop and the wire share one check instead of keeping two.

Server (apps/web/server/hosted/attached-files.ts, store/writer.ts)

  • eveContent leaves out the text part when there are no words. A files-only message reaches the model as just its image, PDF, and inlined text-file parts, never an empty text block (Anthropic refuses those).
  • The journal row still keeps the words as its first part (empty here), so the row's shape doesn't change.
  • Lines are now matched to eve's receipt by their files as well as their words (each image or PDF by name and type). Before this, a files-only steer that eve coalesced with another steer would never be taken by its turn, and the agent would read as running forever.
  • A receipt's text part spelled exactly as an inlined file's heading and fence now reads back as that file. The old rule ("the first text part is always words") stopped holding once words are optional. Without this, a files-only task with a text file would show the raw fenced file as the developer's words.
  • Title, context estimate, and transcript copy: an empty task gives handed: "", so the title is generated from the plan's name. Nothing crashes and no new branch was needed.

Desktop

  • agent-composer.tsx: Send is enabled with files and no words (still disabled while files are being read), and Enter sends them. The PLACEHOLDER.FILES placeholder ("Add a message to send these files") is gone, so the normal placeholder shows.
  • agent-tab.tsx: a developer line with no words draws only its chips: no empty bubble and no Copy button.
  • The New Plan composer takes no attachments, so it is unchanged.

Screenshot

Rendered in headless Chrome from the real AgentTabView and the built stylesheet. The last developer line is an image alone, drawn as just its thumbnail. The composer holds an image with no words, shows the normal placeholder, and Send is enabled.

Files-only message

Tests

Each new or changed test below fails without its change:

  • coding-agent-client.test.ts: words-only, files-only, and both travel for a message and a Start; neither (blank words, or empty words with attachments: []) travels nowhere.
  • coding-agents-app.test.ts: a files-only Start opens eve with only the file part and reads as a task brief. A files-only message hands eve only the file part, and its line stands with empty words and the file. The existing "files with no task are refused" and "nothing but whitespace is refused" cases still pass under the schema check.
  • coder-host.test.ts: a files-only line coalesced after a plain line and a words-and-files line is taken by the same turn. A files-only receipt that no line awaits is written with empty words, its text files read back as files. This replaces the old assertion that "words spelled like a file's heading stay words", which this change deliberately reverses.
  • agent-composer.test.ts: with files and no words, the placeholder stays the same, Send is enabled, Enter sends { text: "", attachments }, and the line draws only its chips. The old assertion about the "Add a message…" placeholder and the disabled Send button is removed along with that behavior.
  • Goldens (LUKE_UPDATE_FIXTURES=1): the four start and message request/params JSON Schemas each lose "minLength": 1 on text/task. The struct-level check emits nothing.

./scripts/check.sh exits 0, and pnpm --filter @luke/web test:store passes (638 tests).

Mac: CI does not build or verify the Mac UI, and ./scripts/verify.sh was not run (this is a Linux cloud VM). LUKE-159 is the manual Mac pass.

Further simplifications I noticed (left out to keep this PR small)

  • coding-agent-view.ts builds the window's Start and message params by spreading codingAgentStartRequestSchema.fields, which drops struct-level checks. The client re-checks with the request schema before anything travels, so nothing is lost, but the params could reuse the request schema (or its check) directly.
  • The route's remaining Start check (model and effort both or neither, a context window only with a model) is another whole-struct rule. It could move into the same schema check so the route keeps no hand-written shape checks.
  • messageRefusal in coding-agents-app.ts re-parses the body by hand to tell "too long" apart from "invalid". A tagged schema issue could answer that.
  • noReceiptOfLine (message-reads.ts) matches a line's receipt in SQL by its words alone. A files-only line's empty words match any later receipt that also has empty words. That only ever stops counting a line as awaiting (it never holds an agent running), but it is a second matching rule beside coalescedRun.

šŸ¤– Generated with Claude Code