fix(workspace): attachments travel with a 1:1 escalated into a room (#2794) - #2799
Conversation
…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
… 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
#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
|
merge-train: two mechanical commits pushed to this branch.
Validation notes: no critical findings. Every changed value has a live consumer — the retained 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 |
… 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>
1ce1008 to
ac61d8b
Compare
…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
/review ReportBranch: Scope checkIntent (#2794): attachments must survive a 1:1 → room escalation, reaching every participant. Out of the issue's literal scope:
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 ( Critical findingsNone. 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) 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 [I2] Conditional side effects: the escalation fan-out discards the failure reason (Confidence: 7/10) 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. [I3] Performance: every room wake now costs a DB read + a Docker API call + a [I4] Test gap: the one-composer guard proves "one definition", not "every surface calls it" (Confidence: 9/10) assert [p.name for p in hits] == ["service.py"], hitsThis 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. [I5] Honesty: a file failing on two agents for two reasons reports only the last (Confidence: 6/10) Fixed during this review[F1] The rail receipt named the file that was PICKED, not the file that landed — auto-fixed in - const res = await feeds.upload(agent, file); sent.push(res?.filename || file.name)
+ await feeds.upload(agent, file); sent.push(file.name)
Clean categories
VerificationThe 12 new backend tests were checked to fail without the fix — reverting Live A/B on a local instance, same script and image both times:
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
One caveat on this review's own reach: the full |
…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
/review — re-review of the fix (
|
| 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
Fileobject 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.nameare 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 —
landedis file-scoped;lastReasonis unchanged and still cannot leak across files (missed.lengthimplies 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
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.
PortalConversationuploads a dropped file straight into the current agent's inbox as it is attached, and theescalate-to-roomevent 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
usePortalFileDropkeeps theFilehandle on each entry, so the same bytes can reach a second destination without asking the person to pick the file again, and exposessettled()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, andsettled()could otherwise resolve while an earlier batch was still going.send()awaitssettled()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.onEscalateToRoomfans 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.Decidable rules live in the new pure
components/portal/portalAttachments.js—vitest.config.jspinsenvironment: '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 buildsagentsas[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 fromagents.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-elsebinds 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 onattachments.length. Two live defects in one expression:The composer now carries
v-if="!isClosed". Av-elseis a promise about whatever happens to sit above it, and this neighbourhood has broken that promise three times.roomComposerChain.spec.jshad 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
Verification
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-demoandsidekick, 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 nothingelse. 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 productthat 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_chatandshared_sessions/service.py::_wake_agentboth call it; the room prepends theprefix to its turn prompt and passes
images=toexecute_task.Three decisions the diff does not show:
before it has been told an image exists is the agent that answers "I don't see
any image attached";
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 execper human per wake, and the shape roomsactually have is one person and N agents;
"@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.imagesisNonerather 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 toselect defaulted toparticipants[0]whilePortalRoom's own drop zone fanned out to all of them — two surfaces in onechat, 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-demowhile the question went tosidekick. A room now defaults toeveryone in it, with the individual agents still selectable underneath.
The rules live in
portalFiles.js, not the SFC (environment: 'node', no mountharness). Two of them encode a direction rather than a value:
resolveRecipientsfails toward the fan-out — a target that has left theroom 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;
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 alreadybeen driven out to a file manager to get it in at all.
usePortalFileDropnow exposesonPaste, bound on both composers, feeding thesame
addFilesbatch as a drop — a second path in, never a secondimplementation. 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
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:
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
After — the full path, from the rail
After — paste
A real
ClipboardEventcarrying aFile, dispatched at the room composer: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
images as vision input, documents named with the path to read them from.
so a third surface inventing its own fails CI rather than shipping mute.
paste that also carries text.
be read, when there is no client email, or when the collector raises.
Verification
The 12 new backend tests were checked to FAIL without the fix: reverting
shared_sessions/service.pyalone reds 6 of them. The re-anchored ordering guardin
test_ent473_chat_titles.pywas mutation-checked in the same way (restoringthe 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'sadapter. No schema change, no migration, no new endpoint, no config.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf