Skip to content

feat(chat): attach files to question answers - #463

Merged
rynfar merged 8 commits into
pylonfrom
upstream/2026-09-10-question-attachments
Sep 11, 2026
Merged

feat(chat): attach files to question answers#463
rynfar merged 8 commits into
pylonfrom
upstream/2026-09-10-question-attachments

Conversation

@rynfar

@rynfar rynfar commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

When an agent asks a question that accepts a custom answer, there was no way to answer with a screenshot or a file. Pasting an image or attaching a file was refused until the question was answered.

This ports T3 Code's question attachments, pingdotgg/t3code#9871, at the frozen head 6c583620ff7ad3235b135af7107c0543467eecfa (tracking #414). It is the one source in the web-composer lane that crosses contracts, server, web, desktop and mobile, so it is split from #457. Both PRs touch ChatView.tsx, ChatComposer.tsx, MessagesTimeline.tsx and the ledger, so whichever merges second needs a rebase.

Source

Upstream commit Upstream PR Outcome
7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49 pingdotgg/t3code#9871 Adopted. Attachment cleanup mechanics are reworked in Pylon (see below).

What it does

  • Contracts. UserInputAttachments and UserInputAttachmentAnswerPayload, an optional attachmentsByQuestionId on thread.user-input.respond, thread.user-input-response-requested and ProviderRespondToUserInputInput, and a questionAttachments environment capability. Every field is optional, so older clients and servers keep working. Clients hide the option until the server advertises it.
  • Server.
    • The normalizer claims uploaded answer files the same way it claims turn attachments, capped at eight across one set of answers. A failed dispatch releases them.
    • The decider refuses attachments on a question that is resolved, missing, or does not accept custom answers. It records a user-input.answer-submitted activity, and for Codex's message-mode questions it carries the files on the answer turn.
    • ProviderService adds one line per saved file path to the answer strings, so provider answer protocols do not change.
    • Reverts keep files referenced by answer activities as well as messages. Cleanup progress is tracked by its own projection.attachment-cleanup row in projection_state. No migration.
  • Web and desktop. Each pending question has its own attachment draft, keyed by environment, thread, request and question, separate from the prompt draft. The eight-file cap covers the whole set of answers. Submit waits for compression and uploads, and an answer with only attachments is valid. Answer history shows links and thumbnails.
  • Mobile. The same draft model, an attachment button and paste in the answer field, readiness gating, and answer history in the work log.
  • Docs. New docs/user/question-attachments.md (linked from docs/README.md), in Pylon's voice. docs/internals/overview.md describes the cleanup cursor.

Attachment cleanup: Pylon changes upstream's mechanics

The feature's cleanup behavior is upstream's: after every projector has caught up, reverts remove files no message or answer still references, and deletes remove the thread's files unless the id was re-created. How that cleanup runs is different. Upstream's design at the frozen head has three defects, and nothing after the frozen head changes them:

  • Slow startup. Bootstrap decoded every event past the cleanup cursor and listed the whole attachments directory once per deleted or reverted thread. The live path never advanced the cursor, and a database without the row started at sequence 0. So the first start after upgrade replayed the whole log, and every restart rescanned the previous session. Desktop readiness could run past its one-minute timeout.
  • Fragile startup. Decoding old events meant one historical payload that no longer matches today's schema stopped the orchestration engine from starting.
  • Pinned cursor. A persistent file error held the cursor forever and stopped the rest of that thread's cleanup.

What Pylon does instead, in ProjectionPipeline.ts:

  • Live. The cleanup cursor is written in the same upsertMany statement as the projector cursors, at the last event whose cleanup finished. Cleanup runs after commit, so the cursor trails the head by the current command. This adds no statements. A failed cleanup stops the cursor until the next start. A transaction that rolls back never ran its cleanup, so it leaves no gap.
  • Bootstrap. After projectors catch up, one query selects only thread.reverted and thread.deleted rows past the cursor. It reads sequence, event_type and stream_id, never the payload, and logs and skips any row it cannot read. The attachments directory is listed once and grouped by thread segment. Threads without files cost no reads. The cursor then moves to the projector head.
  • Failures. A file or thread error is logged, and cleanup continues with the rest. A live failure gets one retry at the next start. If the retry also fails, the file is logged and left behind, so it cannot pin the cursor. Only a failure to list the directory or read the log keeps the cursor for the next start.
  • Upgrade. A database without the cleanup row starts at its lowest projector cursor. Before this cursor existed, projector replay cleaned files as it went.
  • Revert retention reads only user-input.answer-submitted activities (activityKinds), not every activity payload.

Why cleanup still runs inside bootstrap. Running the backlog after the engine is ready would race live commands. A message arriving mid-sweep can reference a file the sweep is about to prune, so the sweep would need its own serialization with the command queue. With the live cursor and the type-filtered scan, blocking work is proportional to real pending cleanup: none on a normal restart, and only threads that still have files after a rebuild. Keeping it in bootstrap is the smallest correct design.

Measured with a throwaway probe (in-memory SQLite, every projector current, same machine). Same probe run against the previous head of this PR and against this head:

Scenario Previous PR head This PR
1,000 deleted threads, 3,000 files, no cleanup row (first start after upgrade) 28,816 ms 2.0 ms
1,000 deleted threads, 3,000 files, cleanup cursor at 0 (rebuild backlog) 26,447 ms 12.4 ms
200 deleted threads, 1,000 files, cleanup cursor at 0 1,788 ms 3.5 ms
20,000 streaming events, no cleanup row 276 ms 0.7 ms
20,000 streaming events, cleanup cursor at 0 260 ms 2.7 ms

For reference, the reviewer measured origin/pylon at 2 ms and 1 ms for the first and fourth scenarios.

ProjectionStateRepository.minLastAppliedSequence is removed. It had no callers here or upstream, and its minimum over every row would now include the cleanup row. computeSnapshotSequence already reads only the required projectors. docs/internals/overview.md records that projection_state can hold non-projector rows.

Provider decisions

Provider Question answers with files
Codex Supported: path lines on native answers; files on the turn for message-mode questions. Upstream tested this live with Codex.
Claude, Cursor, Grok, OpenCode Supported through the same path lines on the answer strings. Checked by code path only: neither upstream nor this PR ran these providers live.
Antigravity Not offered: its questions set allowCustomAnswer: false, so the client hides attach and the decider refuses
Prime Agent (daemon and ACP) Not applicable: Prime emits no user-input.requested questions (its session dialogs use respondToInteraction)

Pylon adaptations

  • normalizeDispatchCommand and cleanupFailedUploadedAttachments still cover Pylon-only thread.input-queue.follow-up alongside thread.turn.start and the new respond command.
  • ChatComposer's send gate uses Pylon's baseSendDisabledReason. Pylon's pending-question panel already passed only the active request as responding, so that hunk was already covered. Pylon's displayText and viewed-image expansion in work rows are kept, with answer history added beside them.
  • Mobile readiness uses upstream's composerAttachmentsStillUploading and composerAttachmentUploadBlockReason, which landed on pylon with feat(mobile): keep new-task drafts and queued sends visible #460. The earlier direct check was equivalent and is gone.
  • Mobile discards question attachment drafts only when the thread data is live, as web already does. A thread snapshot is persisted only while the thread is not running. So after a cold start, offline, or while reconnecting, a cached snapshot can predate the question, and cleaning up from it deleted the draft and its local file copies.
  • Mobile answers an image pasted on a server without question attachment support with "Update this server to send files with question answers.", the reason web shows, instead of ignoring it.
  • @types/react-dom is added to mobile devDependencies for the render tests. The lockfile entry was regenerated with vp i, not hand-merged.

Nothing Pylon-original was removed.

Verification

Rebased onto origin/pylon at ac8d87b63f (after #458, #460 and #467).

  • Server, vp test run on 16 files, 338 tests pass: ProjectionPipeline, ProjectionPipeline.threadHandoff, OrchestrationEngine, ProjectionSnapshotQuery, ProviderCommandReactor, RollbackReconciliation, RollbackAdmissionAtomic, Normalizer, Normalizer.attachments, decider.questionAttachments, decider.userInputDismiss, decider.inputQueue, ProviderService, userInputAttachments, ServerEnvironment, attachmentStore.
    • New: bounds bootstrap cleanup by threads that still have files (one directory listing and a SQL statement count that does not grow with 200 deleted threads without files; a restart lists nothing), starts when an old event no longer decodes, and cleans past a file that cannot be removed without pinning the cursor.
    • Updated: cleans attachments only after the command receipt commits now asserts the live cursor, one retry on the next start, and a persistent failure that does not pin the cursor.
    • All four fail on the previous head of this PR.
  • Web, 7 files, 397 tests pass: ChatView.logic, MessagesTimeline, composerDraftStore, attachmentUploadQueue, pendingUserInput, questionAttachments, session-logic.
  • Mobile, vp test run --dir apps/mobile on 5 files, 133 tests pass: QuestionAnswerHistory, threadActivity, composerAttachmentUploadQueue, use-selected-thread-requests (adds the cached and synchronizing draft cases; they fail without the live gate), and the new QuestionAttachments paste test.
  • Typechecks: t3, @t3tools/web, @t3tools/mobile, @t3tools/contracts and @t3tools/client-runtime report no errors. PRIME_AGENT_DRIVER_KIND usages match origin/pylon.
  • vp lint on the PR's 40 changed TypeScript files reports nothing, and vp fmt --check is clean on all 45 changed files. A fresh vp i leaves pnpm-lock.yaml unchanged.
  • No local client pass was run. The orchestrator runs one integrated pass. Upstream before/after screenshots and the Android readiness captures are in the feat(chat): attach files to question answers pingdotgg/t3code#9871 description.

Ported by Claude Opus 5 in Claude Code.

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pylon-marketing Ready Ready Preview Sep 11, 2026 2:01am UTC

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 14.1 KiB 13.9 KiB −162 B (−1.1%) 15.1 KiB
Codex Thread snapshot wire 7.2 KiB 7.2 KiB −9 B (−0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.9 KiB 6.7 KiB −153 B (−2.2%) 7.8 KiB
Codex Live turn WebSocket decoded 58.8 KiB 58.0 KiB −910 B (−1.5%) 66.4 KiB
Codex Live turn messages 10 8 −2 (−20.0%) 21
Claude Total thread wire 14.0 KiB 14.1 KiB +45 B (+0.3%) 15.1 KiB
Claude Thread snapshot wire 7.2 KiB 7.2 KiB +5 B (+0.1%) 7.3 KiB
Claude Live turn WebSocket wire 6.8 KiB 6.9 KiB +40 B (+0.6%) 7.8 KiB
Claude Live turn WebSocket decoded 59.7 KiB 59.7 KiB +44 B (+0.1%) 66.4 KiB
Claude Live turn messages 9 10 +1 (+11.1%) 21

Baseline: ac8d87b · PR result: 4ae0123 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 115.6 KiB
  • Claude decoded thread snapshot: 116.3 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

shivamhwp and others added 8 commits September 10, 2026 19:57
Question answers accept pasted images and uploaded files on web, desktop
and mobile. The server claims them like turn attachments, records an
answer-submitted history activity, and appends the saved file paths to
the provider answer strings. Attachment cleanup keeps its own projection
cursor so reverts and deletes retain files that answers still reference.

Pylon adaptations: the normalizer and failed-dispatch cleanup also keep
covering thread.input-queue.follow-up; mobile readiness checks upload
states directly because this branch predates the #10404 upload helper.

Adopted from 7220dfe2c949476eaa7d21eccbcd3a0ce0eddb49 (#9871)
Bootstrap decoded every event past the cleanup cursor and listed the
attachments directory once per deleted or reverted thread, and the live
path never advanced the cursor. First start after upgrade replayed the
whole log (1,000 deleted threads with 3,000 files took about 28 s), one
old payload that no longer decodes stopped the engine from starting, and
a persistent file error pinned the cursor.

The live path now writes the cleanup cursor with the projector cursors at
the last finished cleanup. Bootstrap selects only revert and delete rows
past it without decoding payloads, lists the directory once, skips
threads without files, and moves the cursor to the projector head. A
database without the row starts at its lowest projector cursor. File
errors are logged and retried once on the next start instead of pinning
the cursor, and revert retention reads only answer activities.

Remove the unused minLastAppliedSequence, which would have included the
non-projector cleanup row.
…ed pastes

A cached thread snapshot can predate a question, so discarding question
attachment drafts from it deleted drafts and their local file copies
after a cold start or while reconnecting. Only live thread data may
discard them now, matching web.

Pasting an image into an answer on a server without question attachment
support was silently ignored. It now shows the same update reason web
shows.
@rynfar
rynfar force-pushed the upstream/2026-09-10-question-attachments branch from 8d67f6d to 4ae0123 Compare September 11, 2026 02:01
@rynfar
rynfar merged commit 8092275 into pylon Sep 11, 2026
20 checks passed
@rynfar
rynfar deleted the upstream/2026-09-10-question-attachments branch September 11, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants