Skip to content

fix(workspace): attachments travel with a 1:1 escalated into a room (#2794) - #2799

Merged
vybe merged 13 commits into
devfrom
fix/2794-room-escalation-attachments
Sep 15, 2026
Merged

vybe merged 13 commits into
devfrom
fix/2794-room-escalation-attachments

Conversation

@dolho

@dolho dolho commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

Attach a file in a 1:1, type a message that @mentions a second agent, send. The conversation correctly escalates into a room — and the file does not come with it.

PortalConversation uploads a dropped file straight into the current agent's inbox as it is attached, and the escalate-to-room event carried only { agents, message }. So the person had watched a chip confirm the upload and believed both agents had it; only the original one ever did, and the room showed no trace of a file at all.

The rule the issue states is the one this follows: whatever a user could do inside a room, escalating into one must produce the same result. A room-native drop is one upload per participant, so an escalation owes exactly that to the participants that have not already received the file — no more (the origin agent must not get two copies) and no less.

The carry

  • usePortalFileDrop keeps the File handle on each entry, so the same bytes can reach a second destination without asking the person to pick the file again, and exposes settled() so a caller can wait for an in-flight batch. Overlapping drops now chain rather than race: two batches firing together is the request burst the sequencing already existed to avoid, and settled() could otherwise resolve while an earlier batch was still going.
  • send() awaits settled() before escalating and emits the entries with the message. Waiting is the honest branch of AC 3 and the last moment it is possible — the composer is about to unmount. It deliberately does not clear the chips: on success the component unmounts as the room opens, and on failure the shell already hands the text back and the chips are still standing beside it. That is AC 4 with no new plumbing.
  • onEscalateToRoom fans each carried file out before posting the message. The order is the feature: the message is what wakes the mentioned agent, and a turn that starts before the file is in its inbox cannot see the thing it was asked about. Per-agent failures are collected rather than aborting the carry.
  • The room then says what happened — what arrived and for whom; a file that missed a participant named per file and per agent ("attach it again here to retry"); a file that never finished uploading in the 1:1 named too. Never silently dropped.

Decidable rules live in the new pure components/portal/portalAttachments.js — vitest.config.js pins environment: 'node' with no mount harness, so a rule decided inside an SFC is a rule no test can reach. The origin agent is excluded by name, not by position (the shell builds agents as [origin, ...mentioned]; a plan trusting that order would double-send the day it changes), and the notice reads its recipients off the plan rather than re-deriving them from agents.

Two adjacent defects, found on the way

Escalating lands attachments in a room — and attaching in a room was itself broken.

The room composer rendered on the wrong condition

It shipped as <form v-else> chained to the "this conversation has ended" line (ent#358) — render the composer unless the room is closed, the right rule. v-else binds to the immediately preceding element, and three changes since have each inserted a conditional in between (the batch notice and the attachment chips in ent#524, the budget banner in #2620), so the chain now ends on attachments.length. Two live defects in one expression:

  • attaching a file to a room replaced the composer — and the room cleared no chips, so it never came back;
  • a closed room rendered a live composer directly under the line saying it had ended.

The composer now carries v-if="!isClosed". A v-else is a promise about whatever happens to sit above it, and this neighbourhood has broken that promise three times.

roomComposerChain.spec.js had pinned the broken state as the contract, so it is rewritten to pin the outcome instead: the composer names its own condition, no composer form is chained at all, the chips render beside the composer rather than instead of it, and a closed room still says so.

The room never cleared its chips

It accumulated every chip it had ever drawn, describing files delivered several messages ago as though they were still pending. It now clears after a successful send — the 1:1's rule.

Acceptance criteria

  • Escalating carries the composer's pending attachments into the room; the room renders the same chip vocabulary naming the files and their recipients.
  • Every participant of the new room receives the file, including the @mentioned agent — the room's own per-participant fan-out, minus the agent that already has it.
  • A still-uploading file is waited for; one that failed is named, never silently dropped.
  • If room creation fails the text comes back and the chips are still there.
  • Unit tests for the escalation payload including attachments, plus source guards for the wiring (no mount harness exists) and a rewritten chain guard.

Verification

src/frontend vitest run ... 130 files, 2943 passed
  incl. roomEscalationAttachments.spec.js (33), roomComposerChain.spec.js (5, rewritten),
        rawColorRatchet + loadingGateRatchet

No backend change.

Fixes #2794


Round two — what testing this live turned up

Verifying the carry above against a running instance surfaced the rest of the
path, and it was worse than the original report. In a room holding
analyst-demo and sidekick, the client sent a screenshot and asked
"@sidekick what is displayed on the pasted image?". sidekick answered
"I don't see any image attached to your message." — truthfully.

Three independent gaps, each on its own enough to produce that reply.

1. No agent was ever told — the core of it

A room turn was _build_turn_prompt: a header plus the transcript, and nothing
else. The sentence that makes a file visible to an agent, and the vision blocks
that make "what is in this picture" answerable at all, were written inline in
portal_chat
— so the 1:1 conversation was the only surface in the product
that had them. Delivery had never been the problem; the telling did not exist.

The composition moves to client_portal/service.py::collect_inbox_context,
returning (manifest_prefix, images). portal_chat and
shared_sessions/service.py::_wake_agent both call it; the room prepends the
prefix to its turn prompt and passes images= to execute_task.

Three decisions the diff does not show:

  • the manifest is a PREFIX. An agent that meets "what is in the image?"
    before it has been told an image exists is the agent that answers "I don't see
    any image attached";
  • whose inbox — the posting principal's, because a portal inbox is keyed by
    the client's email and in a Workspace room that principal is the person who put
    the file there. Residual, stated in the docstring: a room with two humans
    surfaces only the email of whoever's message triggered this wake. Reading every
    human's inbox costs one docker exec per human per wake, and the shape rooms
    actually have is one person and N agents;
  • the image-intent test reads the whole delta, agent lines included.
    "@sidekick can you look at the screenshot the client sent?" is an ordinary
    room move, and scoping the test to human text would make exactly that relay
    arrive image-less — this bug, one hop along.

Fail-safe throughout: no client email, an unreadable inbox or a raising collector
each yield ("", []) and the turn runs unchanged. images is None rather than
[] when there is nothing, so a room without files is a byte-for-byte no-op.

2. The rail aimed at one agent

PortalRailFiles' Send to select defaulted to participants[0] while
PortalRoom's own drop zone fanned out to all of them — two surfaces in one
chat, two meanings for send a file here, and the one with the visible control
was the wrong one. That is exactly how the reported screenshot reached
analyst-demo while the question went to sidekick. A room now defaults to
everyone in it, with the individual agents still selectable underneath.

The rules live in portalFiles.js, not the SFC (environment: 'node', no mount
harness). Two of them encode a direction rather than a value:

  • resolveRecipients fails toward the fan-out — a target that has left the
    room resolves to everyone, because a file sent to one agent too many is
    recoverable from the rail's own delete and a file sent to nobody is the silent
    loss this issue is about;
  • a file counts as sent only when it reached every recipient. A partial is a
    failure line naming the agents it missed — counting it as a success would
    rebuild the reported bug inside its own fix, since "Sent shot.png to
    analyst-demo and sidekick"
    while sidekick got nothing is precisely the
    reassurance that made the gap invisible the first time.

3. Pasting did nothing

There was no paste handler on either composer, so the most common way anyone
attaches a screenshot was inert and silent. The reported session shows the
cost: the client's file was named Pasted image (3).png, i.e. they had already
been driven out to a file manager to get it in at all.

usePortalFileDrop now exposes onPaste, bound on both composers, feeding the
same addFiles batch as a drop — a second path in, never a second
implementation. It suppresses the default only when the clipboard carries no
text/plain, so pasting out of a rich editor still types the text it came with.


Live verification

Same scripted scenario, same image, same two agents, run against a local
instance on this branch's parent and then on this branch. The image is a
generated PNG containing a red circle, a blue triangle and the number 47.

Before — the reported bug, reproduced

ROOM: /workspace/r/room_36b7cc266f5d4074
SEND-TO OPTIONS: ["analyst-demo","sidekick"]
SEND-TO DEFAULT: "analyst-demo"
RECEIPT:         Sent "room-image-proof.png" to analyst-demo.
SIDEKICK REPLY:  I don't see any image in the conversation — the transcript
                 shows only your text message. Could you try sharing it again?
agent-analyst-demo: room-image-proof.png
agent-sidekick:     (empty)

Before, with the delivery gap taken out of the picture

The decisive one. Still on the parent commit, the file was placed in both
inboxes by hand, so the only thing left untested is the telling:

agent-analyst-demo: room-image-proof.png
agent-sidekick:     room-image-proof.png

SIDEKICK REPLY: I don't see any image attached to your message in this
                conversation. Could you resend the image, or let me know where
                it's located (e.g. a file path or URL)?

The file is in sidekick's own inbox and sidekick cannot see it. That is gap 1,
isolated — and it is why fixing only the fan-out would have left the report
standing.

After — same inboxes, same question, only the code differs

SIDEKICK REPLY: The image displays three elements:
                Red circle — a large filled circle on the left side
                Blue triangle — a filled upward-pointing triangle in the center
                47 — the number displayed in bold green text on the right
                This is also confirmed by the caption at the bottom of the
                image: "red circle · blue triangle · 47"

After — the full path, from the rail

ROOM: /workspace/r/room_c2aa9e3bfd1f4596
SEND-TO OPTIONS: ["Everyone in this chat (2 agents)","analyst-demo","sidekick"]
SEND-TO DEFAULT: "*"
RECEIPT:         Sent "room-image-proof.png" to analyst-demo and sidekick.
SIDEKICK REPLY:  The image displays three elements:
                 A red circle — large, solid red, on the left side
                 A blue triangle — large, solid blue, in the center
                 The number 47 — in bold dark green, on the right
agent-analyst-demo: room-image-proof.png
agent-sidekick:     room-image-proof.png

After — paste

A real ClipboardEvent carrying a File, dispatched at the room composer:

COMPOSER CHIP:  clipboard-screenshot.png · to analyst-demo and sidekick
SIDEKICK REPLY: The screenshot shows three items arranged horizontally:
                A red circle on the left
                A blue triangle in the center
                The number 47 in dark green on the right
agent-analyst-demo: clipboard-screenshot.png
agent-sidekick:     clipboard-screenshot.png

The chip also confirms the parent commit's v-if="!isClosed" fix still holds:
the composer is still there with an attachment pending.

Round-two acceptance

  • An agent @mentioned in a room about a file it holds can actually see it —
    images as vision input, documents named with the path to read them from.
  • One composer for that sentence, guarded by count across the whole backend,
    so a third surface inventing its own fails CI rather than shipping mute.
  • A file sent from a room's rail reaches every agent in the room by default.
  • A partial fan-out reports as a failure naming the agents it missed.
  • Pasting a screenshot attaches it, on both composers, without eating a
    paste that also carries text.
  • Every new path fails safe: the turn runs unchanged when the inbox cannot
    be read, when there is no client email, or when the collector raises.

Verification

src/frontend  vitest run                    131 files, 2985 passed
              incl. roomFileReach.spec.js (24, new)
                    roomEscalationAttachments.spec.js (33)
                    roomComposerChain.spec.js (5)
                    rawColorRatchet + loadingGateRatchet

tests/        pytest unit/ -m "not slow"    334 passed across every file that
                                            touches the changed paths, incl.
                                            test_2794_room_file_awareness.py (12, new)
                                            test_ent473_chat_titles.py (57)
                                            test_ent79_portal_exposure.py
                                            test_ent358_workspace_absorbs_session.py

The 12 new backend tests were checked to FAIL without the fix: reverting
shared_sessions/service.py alone reds 6 of them. The re-anchored ordering guard
in test_ent473_chat_titles.py was mutation-checked in the same way (restoring
the pre-#2794 inline shape reds it) — moving the composition out left its old
anchor string in the file, several thousand lines below the spawn, so it would
otherwise have stayed green while comparing two lines in different functions.

This round adds a backend change (the parent commits did not): one new
function in client_portal/service.py, its two callers, and the room's
adapter. No schema change, no migration, no new endpoint, no config.


🤖 Generated with Claude Code

https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf

…2794)

Attaching a file in a 1:1, then @mentioning a second agent, correctly moved the
conversation to a room and left the file behind. `PortalConversation` uploads a
dropped file straight into the CURRENT agent's inbox as it is attached, and the
`escalate-to-room` event carried only `{ agents, message }` — so the person had
watched a chip confirm the upload and believed both agents had it, while only
the original one ever did and the room showed no trace of a file at all.

The rule the issue states is the one this follows: whatever a user could do
inside a room, escalating into one must produce the same result. A room-native
drop is one upload per participant, so an escalation owes exactly that to the
participants that have not already received the file — no more (the origin
agent must not get two copies) and no less.

- `usePortalFileDrop` keeps the `File` handle on each entry, so the same bytes
  can reach a second destination without asking the person to pick the file
  again, and exposes `settled()` so a caller can wait for an in-flight batch.
  Overlapping drops now CHAIN rather than race: two batches firing together is
  the request burst the sequencing already existed to avoid, and `settled()`
  could otherwise resolve while an earlier batch was still going.
- `send()` awaits `settled()` before escalating and emits the entries with the
  message. Waiting is the honest branch of the AC and the last moment it is
  possible, since the composer is about to unmount. It deliberately does NOT
  clear the chips: on success the component unmounts as the room opens, and on
  failure the shell already hands the text back and the chips are still
  standing beside it — the recovery AC with no new plumbing.
- `onEscalateToRoom` fans each carried file out to the participants that do not
  already have it, BEFORE posting the message — the message is what wakes the
  mentioned agent, and a turn that starts before the file is in its inbox
  cannot see the thing it was asked about. Per-agent failures are collected
  rather than aborting the carry.
- The room then SAYS what arrived, for whom, and what did not: a file that
  missed a participant is named per file and per agent ("attach it again here
  to retry"), and a file that never finished uploading in the 1:1 is named too.
  Never silently dropped.

Decidable rules live in the new pure `components/portal/portalAttachments.js`
(`vitest.config.js` pins `environment: 'node'` with no mount harness); the SFCs
are dispatchers over it. The origin agent is excluded BY NAME, not by position
— the shell builds `agents` as `[origin, ...mentioned]` and a plan trusting
that order would double-send the day it changes — and the notice reads its
recipients off the plan rather than re-deriving them from `agents`.

## Two adjacent defects, found on the way

Escalating lands attachments in a room, and attaching in a room was broken.

**The room composer rendered on the wrong condition.** It shipped as
`<form v-else>` chained to the "this conversation has ended" line (ent#358) —
render the composer unless the room is closed. `v-else` binds to the
immediately preceding ELEMENT, and three changes since have each inserted a
conditional in between (the batch notice and the attachment chips in ent#524,
the budget banner in #2620), so the chain ended on `attachments.length`. Two
live defects in one expression: attaching a file to a room REPLACED the
composer, and a closed room rendered a live composer directly under the line
saying it had ended. The composer now carries `v-if="!isClosed"` — a `v-else`
is a promise about whatever happens to sit above it, and this neighbourhood has
broken that promise three times.

`roomComposerChain.spec.js` had pinned the broken state as the contract, so it
is rewritten to pin the OUTCOME: the composer names its own condition, no
composer form is chained at all, the chips render beside the composer rather
than instead of it, and a closed room still says so.

**The room never cleared its chips.** It accumulated every chip it had ever
drawn, describing files delivered several messages ago as though they were
still pending. It now clears after a successful send, the 1:1's rule.

Tests: `src/frontend/tests/unit/roomEscalationAttachments.spec.js` (33) plus
the rewritten chain spec. Full frontend suite 2943 green, raw-colour and
loading-gate ratchets included.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
dolho and others added 2 commits September 15, 2026 10:57
… flow doc (#2794)

/review on the branch surfaced two real findings and one stale doc.

**Re-entry during the settle wait.** The escalation now AWAITS the in-flight
uploads, and `input.value` is cleared BEFORE that await — so the composer is
empty and live for seconds rather than one microtask. A second Enter in that
window cleared the newly typed text and emitted a second escalation, which
`Portal.vue`'s own `escalating` flag then dropped on the floor: message gone,
no error, and no composer left to recover it from. `escalatingNow` guards it,
held separately from `sending` (which means "a turn is running" and is read by
the header, the Stop control and the reattach poller), and released in a
`finally` on BOTH paths — a flag left set would outlive a FAILED escalation and
leave the composer the shell had just restored permanently dead.

**The carry notice outlived its message.** It describes the message that
created the room, and sat under the composer for every later message too. The
room's own send retires it. It cannot fire early: the escalation's first post
is made by the shell, not by the room.

**Doc.** `workspace-agents-at-the-centre.md` owns the ent#524 upload gesture —
its destination table and its "uploads run sequentially" contract both moved.
Adds the escalation destination, the chaining/`settled()` rule, the
before-the-post ordering, and the two adjacent composer-chain defects.

Full frontend suite 2945 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…e defect (#2794)

The #2794 class, worth the ledger because it recurred inside its own sibling
fix during the same session: the room composer's `v-else` was correct when
written, three later inserts stole it, and the guard added afterwards pinned
the broken adjacency as the contract for three months.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
@dolho
dolho requested a review from vybe September 15, 2026 08:19
dolho and others added 2 commits September 15, 2026 11:53
#2794)

Operator testing found the hole: attach a file through the rail's **Files**
panel, @mention a second agent, and nothing was carried — and because the
composer held no attachments, not even a notice saying so. Verified on the live
instance: the file reached the 1:1's agent and no other.

There are two upload surfaces and only one of them is the composer.
`PortalRailFiles.vue::uploadBatch` sends straight to its own "Send to" target
and keeps no pending state at all, so `attachments` was empty at send time and
the carry had nothing to work with. The two are indistinguishable to someone
who just wants to attach a file, and the rail is the more discoverable of them.

`clientPortal.uploadDocument` is the ONE funnel all three surfaces already share
(#2582 says so and relies on it), so the record goes there: a carry log of
uploads that have not yet gone out with a message. `mergeCarrySources` unions it
with the composer's own entries, deduped on `name + size` — not on the `File`
reference, which would double-carry every composer upload, since a composer
attachment passes through the same funnel and therefore appears in both views.
The composer entry wins a tie: it holds the live per-file outcome the chip is
rendering, so a chip that FAILED stays failed and is reported as not carried
rather than being masked by a same-named log entry.

The boundary is drawn exactly where the composer clears its chips — on mount
(files from a previous visit are not pending), after a sent turn, and after an
escalation consumes them (so a second escalation in the same conversation cannot
carry them twice). That is the same rule the chips already follow, applied to
the surface that has no chips.

The log retains `File` objects, so it is bounded three ways and the tightest
wins: 15 minutes, 20 entries, 64 MiB — evicting oldest. A single file over the
byte cap is kept anyway; evicting it would silently drop the one file the person
cares about, which is the failure this whole issue is about.

Tests: 50 in `roomEscalationAttachments.spec.js` (was 35) — the merge rules, the
three prune bounds, and the boundary sites. Full frontend suite 2960 green.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…te the carry (#2794)

Operator reproduced it twice on the live instance: attach a file to Analyst
through the rail's Files panel, open Analyst's chat, @mention a second agent —
no carry, and no notice either. Found by instrumenting the live Pinia store, and
proven both ways: with the boundary the log entry survives but `uploadsCarriedAt`
is stamped the moment the chat mounts and the carry finds nothing; without it the
entry is still there at escalation and the file reaches both inboxes.

The rail is a SIBLING of the stage (ent#474) and survives every navigation, so
"attach from wherever you are, then open the chat you want to escalate from" is
the ordinary gesture — and `onMounted`'s `markUploadsCarried` consumed exactly
that upload. A thread switch, ⌘J and an agent switch all remount this component,
so one boundary broke several gestures, and it broke them SILENTLY: an empty
carry set produces no notice, which is the same silence the issue exists to fix.

The rule it was reaching for — "files from a previous visit must not ride along"
— is already covered twice: `CARRY_MAX_AGE_MS` bounds staleness, and the log is
plain Pinia state, so a page load starts it empty regardless. Mounting a
component was never evidence that anything had been SENT. The two things that
genuinely consume a pending upload are a message going out and an escalation
taking it, and both already mark it themselves.

The replacing test asserts the ABSENCE at the mount site and pins the consume
points as a whole-file count, so a third one cannot be added quietly.

Full frontend suite 2961 green; verified live on the operator's exact flow.

Related to #2794

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
@vybe

vybe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

merge-train: two mechanical commits pushed to this branch.

  1. 1ce1008 — three corrections to the carry notice:

    • The dismiss control was a hand-rolled underline link; it is now BaseButton ghost/sm, the variant every other dismiss-shaped control in the portal uses. The contract's Primitives-first rule is explicit that identical pixels from a class string is still a defect.
    • role="status" is polite, so the problem arm — the files that did not travel — was announced as a passing remark. It now reads alert when carryNotice.problem and stays status otherwise.
    • vi was imported and never used in roomEscalationAttachments.spec.js.

    Markup, testids and the source guards are otherwise untouched. Full frontend suite green: 130 files, 2961 tests.

  2. The PR body said Related to #2794, which GitHub does not parse as a closing keyword. Patched to Fixes #2794.

Validation notes: no critical findings. Every changed value has a live consumer — the retained File handle reaches Portal.vue's real per-participant uploadDocument fan-out before postRoomMessage, and the name\0size dedup key is sound because the composable entries carry size.

Two things recorded as deliberate rather than missing. AC1/AC5 are delivered differently than written: the issue asks for attachment chips in the room transcript, but no room transcript surface has ever rendered attachments and postRoomMessage takes text only, so a literal reading needs a backend change. The notice under the composer is substantively equivalent parity. Worth a follow-up issue for transcript-level attachment rendering if you want the literal shape. Also, text typed during the escalation wait is still lost, since input.value = '' precedes await attachmentsSettled() — pre-existing shape, low impact, left alone.

… on a problem (#2794)

Three mechanical corrections to the notice added by this PR:

- The dismiss control was a hand-rolled underline link. Buttons are
  `BaseButton` (design-system contract, Primitives first) — ghost/sm, the
  variant every other dismiss-shaped control in the portal already uses.
- `role="status"` is polite, so the problem arm — files that did NOT travel —
  was announced as a passing remark. It now reads `alert` when
  `carryNotice.problem` and stays `status` otherwise.
- `vi` was imported and never used in the spec.

Markup, testids and the source guards are otherwise untouched.

merge-train: mechanical, per the merge-train note on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho and others added 5 commits September 15, 2026 13:14
…iles (#2794)

A client opened a room with `analyst-demo` and `sidekick`, sent a screenshot,
and asked "@sidekick what is displayed on the pasted image?". sidekick replied
"I don't see any image attached to your message." — truthfully.

Delivery was never the problem. The bytes were in an inbox, the rail listed
them, the transcript carried the question. What did not exist was the TELLING:
a room turn was `_build_turn_prompt`, i.e. a header plus the transcript, and
nothing else. The sentence that makes a file visible to an agent — and the
vision blocks that make "what is in this picture" answerable at all — were
written inline in `portal_chat`, so the 1:1 conversation was the only surface
in the product that had them.

Proven by isolating it: on the pre-fix code, with the file placed in sidekick's
OWN inbox by hand, it still answered "I don't see any image attached".

The composition moves to `client_portal.service.collect_inbox_context`, which
both `portal_chat` and `shared_sessions.service._wake_agent` now call. The room
prepends the manifest to its turn prompt and passes `images=` to
`execute_task`.

Three decisions the diff does not show:

- the manifest is a PREFIX. An agent that meets "what is in the image?" before
  it has been told an image exists is the agent that answers "I don't see any
  image attached";
- whose inbox: the posting principal's, because a portal inbox is keyed by the
  client's email and in a Workspace room that principal put the file there.
  Residual, stated in the docstring: a room with two humans surfaces only one
  of them;
- the image-intent test reads the WHOLE delta, agent lines included. "@sidekick
  look at the screenshot the client sent" is an ordinary room move, and scoping
  it to human text would make that relay arrive image-less — this bug, one hop
  along.

Fail-safe throughout: no client email, an unreadable inbox or a raising
collector each yield ("", []) and the turn runs unchanged. `images` is None
rather than [] when there is nothing, so a room without files is a no-op.

`test_2794_room_file_awareness.py` counts the manifest sentence across the whole
backend and fails if it appears anywhere but `client_portal/service.py` — the
failure being fixed IS a surface that quietly composes nothing, so a third one
is caught, not just a second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
… pasting works (#2794)

Two more gaps on the same path as the parent commit, each individually enough
to produce the reported "I don't see any image attached".

**The rail aimed at one agent.** `PortalRailFiles`' `Send to` select defaulted
to `participants[0]` while `PortalRoom`'s own drop zone fanned out to all of
them — two surfaces in one chat, two meanings for "send a file here", and the
one with the visible control was the wrong one. So the client's screenshot
reached `analyst-demo` and the question went to `sidekick`. A room now defaults
to EVERYONE in it, with the individual agents still selectable underneath.

The rules live in `portalFiles.js`, not the SFC (`environment: 'node'`, no mount
harness — a rule in a .vue file is a rule no test can reach). Two encode a
direction rather than a value:

- `resolveRecipients` fails TOWARD the fan-out: a target that has left the room
  resolves to everyone. A file sent to one agent too many is recoverable from
  the rail's own delete; a file sent to nobody is the silent loss this issue is
  about;
- a file counts as sent only when it reached EVERY recipient. A partial is a
  failure line naming the agents it missed — counting it as a success would
  rebuild the reported bug inside its own fix, since "Sent shot.png to
  analyst-demo and sidekick" while sidekick got nothing is exactly the
  reassurance that made the gap invisible the first time.

**Pasting did nothing.** There was no paste handler on either composer, so the
most common way anyone attaches a screenshot was inert and silent. The reported
session shows the cost: the client's file was called "Pasted image (3).png" —
they had already been driven out to a file manager. `usePortalFileDrop` now
exposes `onPaste`, bound on both composers, feeding the same `addFiles` batch as
a drop. It suppresses the default ONLY when the clipboard carries no
`text/plain`, so pasting out of a rich editor still types the text it came with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
#2794)

The room could not see the client's files because the sentence that tells an
agent about them was ~25 lines inline in `portal_chat`. Three independent causes
produced one symptom, and the symptom named the agent ("I don't see any image
attached") rather than the platform — which is why it read as a model failure
and got worked around instead of filed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
…hat makes (#2794)

`test_the_spawn_sits_between_the_persist_and_the_turn` indexed on
`images, image_names, doc_files = await _collect_inbox_for_turn` as "the first
thing the turn path does after the spawn". Moving the manifest composition out
to `collect_inbox_context` left that string in the file — inside the new
function, ~1900 lines BELOW the spawn — so `persist < spawn < turn` stayed green
while comparing the positions of two lines in different functions. Green for the
wrong reason is the failure mode this ledger keeps recording, so the anchor is
now the call `portal_chat` itself makes, plus a uniqueness assertion so a later
refactor cannot let `index()` drift to a second occurrence.

Mutation-checked: restoring the pre-#2794 inline shape fails it
(`ValueError: substring not found`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
Review finding on my own round-two commit. The rail's fan-out rewrite dropped
the server's response:

  - const res = await feeds.upload(agent, file); sent.push(res?.filename || file.name)
  + await feeds.upload(agent, file);             sent.push(file.name)

`upload_client_file` sanitizes through `_safe_filename` and returns the name it
actually wrote, so the receipt could name a file the inbox does not contain —
the same honesty class this PR exists to fix, reintroduced by its own fix (the
§4.14 "a fix that breeds the next bug" shape).

The failure line deliberately keeps `file.name`: for something that never
arrived, the name the person picked is the only one they can recognise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
@dolho

dolho commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/review Report

Branch: fix/2794-room-escalation-attachments → dev
Merge-base: eb41896f5 (diffed against the merge-base, not the base tip)
Files changed: 17 (+2096 / −112) · 11 commits
Scope: DRIFT DETECTED — deliberate, sanctioned, and called out below
Plan completion: 5 AC done · 0 partial · 0 not done · 0 changed · 0 unverifiable


Scope check

Intent (#2794): attachments must survive a 1:1 → room escalation, reaching every participant.
Delivered: that, plus three further defects on the same path found by testing it live — a room turn that never told any agent a file existed, a rail Send to that aimed at one agent while the room's own drop zone fanned out, and composers with no paste handler at all.

Out of the issue's literal scope:

  • src/backend/** — the issue says "no backend change"; the PR now has one. Justified: the escalation delivered the file correctly and the receiving agent still could not see it, so the AC "every participant receives the file" was satisfiable while the user-visible symptom survived intact. Proven below.
  • The rail Send to default and paste-to-attach are new behaviour, not repairs of the escalation.

Recorded rather than defended: this is scope growth, sanctioned by the operator mid-PR ("fix it as part of this PR, make sure it works as people would expect"). A reviewer who wants it split should say so — the backend commit (ed01729d0) is separable.


Critical findings

None. Details of what was checked under Clean categories.


Informational findings

[I1] Conditional side effects: a failed room post loses the typed message silently (Confidence: 8/10)
src/frontend/src/views/Portal.vue:1308

try { await store.postRoomMessage(roomId, message) }
catch { /* the room is open in front of them; retyping recovers */ }

By this point the room is created, the files are fanned out, and markUploadsCarried has already consumed the carry log. If the post fails, the room opens empty, the text is gone, and nothing says so — the comment's "retyping recovers" requires the person to notice a message that never appeared. The outer catch restores prefill for an escalation failure; this inner one restores nothing.
Suggestion: set roomCarryNotice (or a sibling problem notice) on this branch, or re-prefill into the room composer. The files are already delivered, so the recovery is text-only.

[I2] Conditional side effects: the escalation fan-out discards the failure reason (Confidence: 7/10)
src/frontend/src/views/Portal.vue:1289

try { await store.uploadDocument(name, item.file) } catch { missed.push(name) }

The notice names which agents missed the file but never why — quota, offline agent and a 429 are indistinguishable. PortalRailFiles now surfaces uploadFailureReason(err) for the same operation, so the two surfaces disagree about how much they tell you.
Suggestion: carry the reason into failures[] and render it the way the rail does.

[I3] Performance: every room wake now costs a DB read + a Docker API call + a docker exec (Confidence: 8/10)
src/backend/shared_sessions/service.py:1160 → _read_inbox → _legacy_migration_is_safe (db.list_agent_share_emails) + get_agent_container + container_exec_run.
Previously a room wake did zero container reads. Now it is one per woken agent per hop, on the shared 4-slot Docker pool, and it fires for agent-only operator rooms too, where the inbox is always empty (current_user.email is truthy for an operator, so the gate passes).
Not fixable by gating on room_is_user_facing — that is False for kind='user', and since ent#357 an operator's platform session is their workspace session, so they upload files too; gating there would break exactly the case the PR was tested on. It is the same cost the 1:1 has always paid per turn, multiplied by fan-out and chain depth.
Suggestion: acceptable as shipped; revisit with a short-TTL per-(agent, email) listing cache if room fan-out grows.

[I4] Test gap: the one-composer guard proves "one definition", not "every surface calls it" (Confidence: 9/10)
tests/unit/test_2794_room_file_awareness.py:280

assert [p.name for p in hits] == ["service.py"], hits

This is the right guard for the bug being fixed (a surface composing its own sentence) and it will catch a third one. It cannot catch a fourth surface that composes nothing — which is the actual failure mode here. services/loop_service.py:656,701 (triggered_by="loop") is such a surface today: a Workspace loop (ent#458) on an agent holding client files tells it nothing. Pre-existing, out of scope, and stated here so it is not mistaken for covered.

[I5] Honesty: a file failing on two agents for two reasons reports only the last (Confidence: 6/10)
src/frontend/src/components/portal/PortalRailFiles.vue:342
lastReason is overwritten per catch, so shot.png → analyst-demo, sidekick: <reason of sidekick> attributes one agent's reason to both. No cross-file leak (missed.length implies this file assigned it).
Suggestion: group by reason, or keep the first.


Fixed during this review

[F1] The rail receipt named the file that was PICKED, not the file that landed — auto-fixed in b10743e0d.
The fan-out rewrite dropped the server's response:

- const res = await feeds.upload(agent, file); sent.push(res?.filename || file.name)
+ await feeds.upload(agent, file);             sent.push(file.name)

upload_client_file sanitizes through _safe_filename (service.py:4403) and returns the name it actually wrote, so the receipt could name a file the inbox does not contain — this PR's own honesty class, reintroduced by its own fix (§4.14, a fix that breeds the next bug). Restored, with the failure line deliberately keeping file.name: for something that never arrived, the name the person picked is the only one they can recognise. Pinned by two assertions in roomFileReach.spec.js.


Clean categories

  • SQL & data safety — no SQL added. The one new DB read is the existing db.list_agent_share_emails(agent_name) reached through _read_inbox.
  • Auth boundaries — the new backend read is scoped by construction. _room_inbox_context passes current_user.email, and _safe_email_dir (service.py:2650) hashes it into <slug>-<sha256[:n]>, so a path is derived, never interpolated. The directory only exists inside an agent's container if that client previously uploaded to it, which requires agent_on_roster. No new endpoint, no new principal, no widened gate.
  • Credential exposure — no credentials in the diff. The manifest carries filenames, sizes and one inbox path; no file contents except images, which go as typed vision blocks and never as text (bug: agent-server.py spins at 90% CPU on OAuth token auth failure, blocking CB recovery #728).
  • Race conditions — no new shared state. The rail's fan-out is sequential by design (the ent#287 per-email limiter); usePortalFileDrop batches still chain on inFlight.
  • Enum completeness — no new enum, status or type constant.
  • Frontend XSS — no new v-html. Every new string (uploadReceipt, uploadTargetLabel, the carry notice) is text interpolation.
  • Error handling — _room_inbox_context catches broadly, which is correct here and argued in the docstring: the alternative is a file that cannot be mentioned costing the client their turn. It logs at WARNING with agent + email + exception.
  • Product quality bar — the new default (fan-out in a room) is the safe one; individual agents stay selectable; a partial fan-out reports as a failure rather than a success; no new setting, flag or .env knob.
  • Docs — docs/memory/feature-flows/workspace-agents-at-the-centre.md extended (+134); docs/memory/learnings.md has the durable entry. architecture.md's Rooms catalog entry still reads "each woken agent runs an ordinary execute_task(triggered_by="room")", which remains true; a one-line note that the turn now also carries the client's file manifest would be accurate but is below the tiered-docs bar for a bug fix.

Verification

src/frontend  vitest run                 131 files, 2986 passed
tests/        pytest (targeted)          334 passed across every file
                                         touching the changed paths

The 12 new backend tests were checked to fail without the fix — reverting shared_sessions/service.py alone reds 6 of them. The re-anchored guard in test_ent473_chat_titles.py was mutation-checked the same way.

Live A/B on a local instance, same script and image both times:

rail default receipt sidekick's answer
before analyst-demo to analyst-demo "I don't see any image in the conversation"
before, file forced into both inboxes — — "I don't see any image attached"
after Everyone in this chat (2 agents) to analyst-demo and sidekick red circle · blue triangle · 47

The middle row is the one that matters: it isolates the backend half and shows that fixing only the fan-out would have left the report standing.


Summary

  • Critical: 0 — none found
  • Fixed during review: 1 (F1, pushed as b10743e0d)
  • Informational: 5 — I1 and I2 are worth doing; I3–I5 are stated residuals
  • Scope: grew deliberately, with operator sign-off; the backend commit is separable if a reviewer wants it split

One caveat on this review's own reach: the full pytest unit/ sweep was still running when this was written (18 min elapsed). Every file touching the changed paths passes, and the single failure seen in an earlier sweep — test_736_a2a_outbound_edges::test_C1b_mapped_addresses_either_side_of_the_cgnat_block_stay_public — reproduces on unmodified dev and is unrelated.

…us commit (#2794)

`b10743e0d` committed 384 lines of unrelated Playwright capture scripts
(`.capture-*.mjs`, `.probe.mjs`) that had no business in this PR. They were
untracked files in the worktree — restored there by an accidental `git stash
pop` during this session — and `git add -A` took them along with the one-line
fix it was meant to carry.

Checked before removing, because this is a public repo: they contain no
credentials (they read `process.env.ADMIN_PASSWORD`), no internal URLs (only
`localhost:8001`/`:8002`), and no PII. So this is scope and hygiene, not an
incident — CLAUDE.md "keep the working directory clean".

`--cached` only: the files stay on disk as untracked, which is exactly the state
they were in before, and they remain in the stash they came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
@dolho

dolho commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

/review — re-review of the fix (b10743e0d)

Re-reviewed the commit that closed [F1] from the previous review. The one-line fix is correct; the commit that carried it was not, and the fix turns out to be one of three places with the same defect.


Critical findings

None.


[R1] The fix commit committed 384 lines of unrelated debug scripts (Confidence: 10/10) — FIXED

b10743e0d — git show --stat:

 src/frontend/.capture-492.mjs      80 +++++++
 src/frontend/.capture-492b.mjs     60 ++++++
 src/frontend/.capture-after.mjs    65 ++++++
 src/frontend/.capture-drop.mjs     49 ++++++
 src/frontend/.capture-mid.mjs      81 +++++++
 src/frontend/.probe.mjs            49 ++++++
 …PortalRailFiles.vue               12 +-      ← the actual fix
 …roomFileReach.spec.js             10 +-

Untracked Playwright capture scripts, swept in by git add -A. They reached the worktree through an accidental git stash pop earlier in the session and were never mine to commit. This is CLAUDE.md rule 10 ("Delete temporary files… never leave test artifacts or debug outputs") on a public repo.

Checked before removing, because that matters more than the tidiness: no credentials (they read process.env.ADMIN_PASSWORD), no internal URLs (localhost:8001/:8002 only), no PII, no API keys. Scope and hygiene, not a security incident — stated explicitly so nobody has to re-derive it.

Removed in 757d29dac with git rm --cached, which restores the exact prior state: still on disk, still untracked, still in the stash they came from. Audited the other four commits on this branch — all clean; this was the only one using git add -A that had anything to sweep.


[R2] The fix is correct, but its test is weaker than it looks (Confidence: 9/10)

The fix itself verifies:

const missed = []
let landed = file.name              // declared INSIDE the file loop — no cross-file leak
for (const agent of to) {
  try {
    const res = await feeds.upload(agent, file)
    if (res?.filename) landed = res.filename
  } catch (err) { missed.push(agent); lastReason = uploadFailureReason(err) }
}
if (missed.length) failed.push(`${file.name} → ${missed.join(', ')}: ${lastReason}`)
else sent.push(landed)              // only reachable when every upload succeeded

_safe_filename is deterministic on the name, so "last writer wins" across the fan-out is safe; sent.push(landed) is unreachable unless every upload resolved, so landed always carries a real response (with file.name as the fallback if the server omits it). And the fix is not inert — clientPortal.js:1406 confirms uploadDocument returns the response body.

But the test I wrote to pin it is a source-text assertion:

expect(RAIL).toContain('if (res?.filename) landed = res.filename')

That pins the code's shape, not its behaviour. It cannot tell you whether uploadDocument still returns anything — which is exactly the "green for the wrong reason" class this PR's own learnings entry is about. It is the best available here (environment: 'node', no mount harness), so it stays, but it should not be read as behavioural coverage. Verified by hand instead, cited above.


[R3] §4.14 — the fix was applied to ONE of three sites (Confidence: 8/10)

The review question that matters for any fix: is it applied to every call site, or only the reported path? Here: only the reported path.

_safe_filename (service.py:4403) maps anything outside [A-Za-z0-9._ ()-] to _, so this is not theoretical — résumé.docx lands as r_sum_.docx, and non-ASCII filenames are completely ordinary.

Three surfaces name a file after an upload succeeds:

surface names correct?
rail receipt — PortalRailFiles.vue:361 the server's name ✅ fixed by b10743e0d
composer chip — usePortalFileDrop.js:238,272 file.name, never updated (await upload(file) discards the response) ❌ same defect
carry-log → escalation notice — clientPortal.js:1420 file.name ❌ same defect

Net effect: a client sends résumé.docx, the chip and the room's carry notice say résumé.docx, and the rail list beside them says r_sum_.docx. Two names for one file on the same screen.

Bounded, and worth stating precisely so it is not over-read:

  • the right file is always delivered — the carry re-uploads the same File object and the server re-sanitizes identically;
  • the agent is always told the correct name — the manifest is built from _read_inbox, i.e. the real directory listing;
  • the failure lines keeping file.name are correct and deliberate (F1's own rule: for something that never arrived, the picked name is the only one the person can recognise).

So: cosmetic divergence on a non-ASCII filename, no data loss, no wrong delivery.

Not fixed here, deliberately. The chip fix requires usePortalFileDrop's upload callback to return the server response, and the room's callback is a fan-out loop that currently returns nothing — so it is a three-surface change, on a PR already carrying a flagged scope expansion. Raising it rather than quietly widening scope a third time.


Re-checked and clean

  • No new bug bred by the fix — landed is file-scoped; lastReason is unchanged and still cannot leak across files (missed.length implies this file assigned it); the receipt/failure split is unchanged.
  • Behaviour of the fan-out itself — untouched by b10743e0d; the partial-delivery rule (a file counts as sent only when it reached every recipient) still holds.
  • No secrets, internal URLs or PII anywhere in the branch — re-scanned after the untrack.
  • Suites, after both commits: frontend 131 files / 2986 passed; backend targeted 69 passed (test_2794_room_file_awareness.py + test_ent473_chat_titles.py).

Summary

  • Critical: 0
  • Fixed during this re-review: 1 (R1, pushed as 757d29dac)
  • Informational: 2 — R2 (test strength, stated not fixed), R3 (two remaining sites, needs a scope decision)

The honest headline: the fix was right, the commit around it was sloppy, and reviewing my own fix found a wider instance of the same defect than the one I fixed. R3 is the one that needs a decision — fix all three surfaces now, or file it.

The full pytest unit/ sweep is still running (27 min elapsed, 6:57 CPU) and is unchanged by either commit — both are frontend-only plus an untrack.

…on-attachments

# Conflicts:
#	docs/memory/learnings.md

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260915-1011 (#2808) — full suite green across all five members together; dev merged in for the learnings.md append, both entries kept.

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.

2 participants