Repository navigation
Link a channel turn's files to the message it wrote - #703
Merged
Merged
Conversation
`link_files_to_message` was called from one place - `persist_user_turn`
in the web chat path - so every other surface stored a `ChatFile`, fed it
to the agent and then left `message_id` NULL for ever. A spreadsheet
dropped on a Slack, Telegram or Mattermost bot ran the turn perfectly
and left an orphan row behind it.
That is worse than untidy. `chat_files` carries no `organization_id`, so
a row with no message is scoped by `user_id` alone: reachable through
`GET /files/{id}` by whoever sent it and by nothing else, while the
conversation holding the question does not know the file exists - a
transcript of a channel thread showed the question and not the
spreadsheet it was about.
Linked where the user turn is created, which is
`TranscriptService.record` - the one write every non-streaming surface
reaches, for the same reason the transcript itself moved there: a thing
each surface has to remember is a thing the next surface will not. The
attachments travel from `execute` through `_run` to get there. The link
sits inside the existing SAVEPOINT, so a failure rolls back with the
transcript and cannot poison the session the run row commits on. Web
chat is untouched: it streams, writes its own transcript and links its
own files.
Verified by a new test in tests/test_surface_transcripts.py driving a
bot's default agent with an attachment through its real entry point - it
fails on main with the file unlinked. `make lint-backend` and `make test`
are green, the platform layer still at 100%.
Not fixed here: a message carrying a file and no caption records no user
turn at all, so there is still nothing to link it to. Filed separately.
Closes #690
This was referenced Aug 13, 2026
Reviewing the previous commit: the link was written inside the SAVEPOINT the whole transcript shares, so an exception from it would roll back the user turn, the settled calls and the assistant message with its tool calls - for a run that has already spent money, over a file. That is the opposite trade from the one web chat makes for the same write, where `persist_user_turn` catches it and keeps the message. So the link gets a SAVEPOINT of its own inside the transcript's: it is the only write there touching rows the conversation does not own, and a failure now costs the link alone. Skipped outright when nothing was attached, because a SAVEPOINT and its release on every turn in the deployment is a real cost for a list that is almost always empty. Also adds the test #690 actually asked for - one that reads the column back off a real database rather than asserting at the repository boundary. `link_to_message` was never the broken part; that no surface but web chat called it was, so a test that stops at the repository proves the half that already worked. It fails against `main`'s `transcript.py` and passes here. Verified: `make lint-backend`, `make test` (4378 passed, platform layer still 100%) and `make test-integration` (423 passed) against a real pgvector database. Refs #690
#634 landed the link itself, so what is left here is the savepoint it rides: a failed file link no longer rolls back the user turn, the settled calls and the answer of a run that has already spent money. Main's `link_to_message` call becomes `_attach`, and its test for an empty attachment list now asserts no call at all, which is what skipping the savepoint on the common path means.
Merged
DEENUU1
added a commit
that referenced
this pull request
Aug 13, 2026
The file link a channel turn writes gets a SAVEPOINT of its own — #703, refs #690. #634 landed the linking itself. What was left, and what this releases, is the transaction it rides: the link was written inside the SAVEPOINT the whole transcript shares, so an exception from it rolled back the user turn, the settled calls and the assistant message with its tool calls — for a run that had already spent money, over a file. It also adds the test #690 actually asked for: one that reads the column back off a real database rather than asserting at the repository boundary. `link_to_message` was never the broken part; that no surface but web chat called it was. Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and `frontend/package.json`. The CHANGELOG entry is written here rather than promoted from `[Unreleased]`, because #703 wrote none.
This was referenced Aug 13, 2026
DEENUU1
added a commit
that referenced
this pull request
Aug 13, 2026
## What this fixes Closes #704, and completes the remainder PR #703 deliberately left: a channel turn whose attachment produced no prompt text now leaves a user message that names what arrived, and the `ChatFile` row hangs off it. Since #634/#703, `TranscriptService.record` already wrote the user turn under `if prompt or attachments:` and linked the files — so the linking half of the issue holds on `main`, and so does the `None`/`""` split the issue asks for: a resume passes `None` and no attachments and writes no user turn, while an empty caption always arrives with the file that makes it a turn. What remained was the body. `content=prompt or ""` wrote a **blank** user message for a caption-less upload, and a blank user message reads as somebody sending nothing: the thread in `/chat` jumped straight to the answer, with the file card as the only trace of the question. ## The choice, as the issue asked Between "make the empty-text routings yield a textual reference" and "distinguish the cases through the record path", this takes the **record path**: since the `said` refactor (#634) the transcript records what the person wrote, never the assembled prompt, so a reference added in `AttachmentRouter.build_prompt` reaches only the model and would not have touched the transcript at all. `record` is also the one write every non-streaming surface reaches — the same reason the transcript and the #703 link live there. `record` now composes the empty turn's body from its attachments — `Attached image: photo.jpg`, one line per file — reusing the vocabulary `build_prompt` already writes into the model's briefing, so the two compose rather than diverge. A caption is never replaced; the naming fills only the turn that said nothing. Web chat already does the equivalent in its composer (`trimmed || t("analyzeFiles")`), so channels stop being the one surface with blank turns. ## How it failed before 1. Send a Telegram bot a photo with no caption; its agent has no workspace. 2. The agent answers about the image. 3. The conversation shows a blank user bubble (the run and link were already recorded since #634/#703 — the issue's "no user turn at all" described the pre-#634 tree). ## Verified - All new tests fail without the one-line fix in `transcript.py`. - `backend/tests/test_surface_transcripts.py` — the issue's exact sequence through the real entry point: `ChannelAgentRouter.answer_default` with empty text and an inline image on a workspace-less agent (the router yields `["", BinaryContent]`), asserting the user message is named and the `ChatFile` links to *that* message. - `backend/tests/test_transcript.py` — the named body for one file, for several (image and non-image), a caption surviving verbatim, and the existing `test_a_resumed_run_writes_no_user_turn` still pinning the resume half. - `backend/tests/integration/test_transcript_savepoint.py` and `test_channel_attachment_rows.py` — both columns read back from a real Postgres: the message content and the `chat_files.message_id`. - `make lint-backend` green. `make test` green: 4799 passed, platform layer at 100%. The two tests that pinned the old blank body were updated to pin the named one. - `docs/channels.md` § Files: one sentence saying a caption-less turn's message names its files. ## Noticed, not fixed - **#746 (new, filed from this work)** — `attachments.py:208/:238` still hand the model nothing for a routing failure or an unparsed file on a workspace-less agent, so the agent denies receiving a file the transcript now names. Model-side, outside #704's acceptance criteria. Closes #704
DEENUU1
added a commit
that referenced
this pull request
Aug 13, 2026
## What A web-chat turn could attach **another user's file** to its own message by sending that file's id. `chat_file_repo.link_to_message` was a blind bulk UPDATE — no owner predicate, no unlinked check — and `get_many` filtered on id alone, with the ids arriving straight off the socket payload. Two consequences from the one missing predicate: the victim's filename/MIME/size rendered in the attacker's conversation, and a non-NULL `message_id` was overwritten, so the file silently moved off the victim's own message. `chat_files` carries no `organization_id`, so `user_id` is the only scope a row has — and now both the read and the update carry it: - `chat_file_repo.get_many` and `link_to_message` take the owner in their `WHERE`; the UPDATE also requires `message_id IS NULL`. - `ConversationService.link_files_to_message` reads the rows first and **refuses** rather than silently narrowing: a foreign or unknown id raises `NotFoundError` (deliberately indistinguishable, so an id cannot be probed for existence), an already-linked one raises `BadRequestError`. Both name only `file_ids` the caller sent — nothing of the victim's. - The refusal escapes `persist_user_turn`'s swallow (which now covers only infrastructure failures), so the socket answers with an error frame instead of a turn that quietly dropped the attachment. - The channel transcript (`TranscriptService._attach`, the second caller added by #703/#690) links rows as their own uploader; the embed's owner check moved from Python into the query, and a page whose publisher's account is gone (`owner_user_id` is `SET NULL`) reads no rows at all instead of passing `None` into the predicate. ## The re-link decision **A file already on a message never moves, not even for its owner.** No caller legitimately re-links: web chat links fresh uploads once per send (no retry resends `file_ids`), channel rows are created server-side unlinked in the same turn, and the embed already documented and dropped a spent id. The silent move only ever rewrote history, so it is refused outright (`BadRequestError`) rather than allowed for the owner. ## How verified - `backend/tests/integration/test_chat_file_ownership.py` — the issue's sequence against a real database, both rows read back: the attacker gets a refusal, the victim's message keeps its file; the unlinked-file disclosure case; the repo-level UPDATE skipping a foreign row even without the service's pre-read; the owner's own upload still linking; the re-link refusal leaving the row where it was; the scoped read returning nothing for a foreign id. - Unit tests for the service refusals, the owner riding through `persist_user_turn`, `load_attached_files`, the embed session and the channel transcript, and the ownerless-embed drop. - The pre-existing channel-turn integration test (`test_transcript_savepoint.py`) proves server-side rows still link through the new predicates. - `make lint` and `make test` (100% platform gate) pass; `docs/architecture.md` and `docs/file-processing.md` updated in the same change. Closes #706
DEENUU1
added a commit
that referenced
this pull request
Aug 15, 2026
## What this fixes Closes #690. A channel turn that ran perfectly left its `ChatFile` rows with `message_id` NULL. `link_files_to_message` was called from exactly one place - `persist_user_turn` in the web chat path - so a file sent to a Slack, Telegram or Mattermost bot was stored, fed to the agent, and then orphaned. `chat_files` carries no `organization_id`, so a row with no message is scoped by `user_id` alone: reachable through `GET /files/{id}` by whoever sent it and by nothing else, while the conversation holding the question does not know the file exists. A transcript of a channel thread showed the question and not the spreadsheet it was asked about. ## What changed - `TranscriptService.record` takes the turn's `attachments` and links them to the user message it writes. That is the right place because it is where that message first exists, and it is the one write every non-streaming surface reaches - the same reason the transcript itself lives there (#205): a thing each surface has to remember is a thing the next surface will not. - **The link gets a SAVEPOINT of its own** inside the transcript's. It is the only write there touching rows the conversation does not own, so sharing the outer one would mean a run that has already spent money losing its answer and its tool calls because a file could not be attached - the opposite of the trade web chat makes for the same write. Skipped entirely when nothing was attached: a SAVEPOINT and its release on every turn in the deployment is a real cost for a list that is almost always empty. - `AgentRunnerService.execute` passes the attachments through `_run` to get them there. A resume passes none and records no prompt, so nothing links. - Web chat is untouched. It streams, writes its own transcript and links its own files through `persist_user_turn`, so there is no second link and no double write. - `docs/channels.md`, the Files section: a file the turn ran on belongs to that turn, why an unlinked row matters given no `organization_id`, and what the link widens - the metadata, never the bytes (see Noticed below). No repository, schema or route change. `chat_file_repo.link_to_message` already existed; only its second caller is new. ## How it failed before 1. Drop a `.csv` on a channel bot with any question. 2. The agent answers using the file. 3. `SELECT message_id FROM chat_files WHERE id = …` → NULL. ## Testing - `backend/tests/integration/test_transcript_savepoint.py` — a channel turn with an attachment against a real database, **reading the column back**, which is what #690 asked for. The repository boundary is not where this was ever broken: `link_to_message` worked and no surface but web chat called it, so a test that stops there proves the half that already worked. Fails against `main`'s `transcript.py`. - `backend/tests/test_surface_transcripts.py` — the same turn through its real entry point (`ChannelAgentRouter.answer_default`), asserting the link carries the id of the **user** message and not the assistant one. - `backend/tests/test_transcript.py` — a link that raises costs the files and not the turn: the assistant message and its tool calls are still written. - `make lint-backend` green. `make test` green: 4378 passed, platform layer still at 100%. `make test-integration` green: 423 passed. - Not run locally: `make test-e2e` (backend-only change). CI's `e2e` job passed. ## Overlaps Based on `origin/main`; the code being fixed is on `main`, so no stacking was needed. Four PRs are open on this same channel-attachment path and **merge order matters** — whichever lands second needs a rebase, though none of them touch the files changed here: - **#684** (#660) — moves `_receive_files` above `_answer_mention`. **This one gates half of the claim above.** Until it lands, a message that names no handle stores each file twice — `_answer_mention` receives them, discards them on `UnaddressedMessage`, and `_route_inner` receives them again — and only the second copy is the one this links. The row the agent actually read comes out linked either way; the duplicate stays orphaned until #660 is fixed. Worth merging first. - **#689** (#661) — discards a *refused* turn's stored files. This PR is the success half of the same story: #689 deletes rows for a turn that never ran, this links rows for a turn that did. Both touch `docs/channels.md` § Files in different paragraphs — a textual conflict there is likely and trivial. - **#685** (#662) — Mattermost webhook gating, based on `feat/agent-surfaces`. - **#547** — both transports bypassing `parse_incoming`, in progress elsewhere. ## Noticed, not fixed - **#706 (new, `security`)** — `chat_file_repo.link_to_message` is a blind `UPDATE … WHERE id IN (…)` with no owner predicate, and the *web* path feeds it client-supplied ids, so a chat turn can attach another user's file to its own message. Pre-existing and not reachable from the caller added here — a channel turn links rows the adapter created server-side for that sender in that turn — but adding a second caller is what put the function under review. - **#704 (new)** — an attachment that yields no text at all (an inline image on a workspace-less agent, a file with no `parsed_content`, a routing failure) leaves `prompt` empty, so no user turn is written and there is nothing to link to. I filed this with the wrong scope first and have corrected it: a caption-less `.csv` *is* linked, because `build_prompt` prepends an `Attached file: …` reference. - **Shared channels widen metadata by design.** A channel's conversation is owned by whoever spoke first, so a colleague's file now appears in a transcript other members can read — name, type, size. The bytes still answer only the owner, so the chip is visible and the download is not. Documented in `docs/channels.md` rather than left to be discovered.
DEENUU1
added a commit
that referenced
this pull request
Aug 15, 2026
The file link a channel turn writes gets a SAVEPOINT of its own — #703, refs #690. #634 landed the linking itself. What was left, and what this releases, is the transaction it rides: the link was written inside the SAVEPOINT the whole transcript shares, so an exception from it rolled back the user turn, the settled calls and the assistant message with its tool calls — for a run that had already spent money, over a file. It also adds the test #690 actually asked for: one that reads the column back off a real database rather than asserting at the repository boundary. `link_to_message` was never the broken part; that no surface but web chat called it was. Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and `frontend/package.json`. The CHANGELOG entry is written here rather than promoted from `[Unreleased]`, because #703 wrote none.
DEENUU1
added a commit
that referenced
this pull request
Aug 15, 2026
## What this fixes Closes #704, and completes the remainder PR #703 deliberately left: a channel turn whose attachment produced no prompt text now leaves a user message that names what arrived, and the `ChatFile` row hangs off it. Since #634/#703, `TranscriptService.record` already wrote the user turn under `if prompt or attachments:` and linked the files — so the linking half of the issue holds on `main`, and so does the `None`/`""` split the issue asks for: a resume passes `None` and no attachments and writes no user turn, while an empty caption always arrives with the file that makes it a turn. What remained was the body. `content=prompt or ""` wrote a **blank** user message for a caption-less upload, and a blank user message reads as somebody sending nothing: the thread in `/chat` jumped straight to the answer, with the file card as the only trace of the question. ## The choice, as the issue asked Between "make the empty-text routings yield a textual reference" and "distinguish the cases through the record path", this takes the **record path**: since the `said` refactor (#634) the transcript records what the person wrote, never the assembled prompt, so a reference added in `AttachmentRouter.build_prompt` reaches only the model and would not have touched the transcript at all. `record` is also the one write every non-streaming surface reaches — the same reason the transcript and the #703 link live there. `record` now composes the empty turn's body from its attachments — `Attached image: photo.jpg`, one line per file — reusing the vocabulary `build_prompt` already writes into the model's briefing, so the two compose rather than diverge. A caption is never replaced; the naming fills only the turn that said nothing. Web chat already does the equivalent in its composer (`trimmed || t("analyzeFiles")`), so channels stop being the one surface with blank turns. ## How it failed before 1. Send a Telegram bot a photo with no caption; its agent has no workspace. 2. The agent answers about the image. 3. The conversation shows a blank user bubble (the run and link were already recorded since #634/#703 — the issue's "no user turn at all" described the pre-#634 tree). ## Verified - All new tests fail without the one-line fix in `transcript.py`. - `backend/tests/test_surface_transcripts.py` — the issue's exact sequence through the real entry point: `ChannelAgentRouter.answer_default` with empty text and an inline image on a workspace-less agent (the router yields `["", BinaryContent]`), asserting the user message is named and the `ChatFile` links to *that* message. - `backend/tests/test_transcript.py` — the named body for one file, for several (image and non-image), a caption surviving verbatim, and the existing `test_a_resumed_run_writes_no_user_turn` still pinning the resume half. - `backend/tests/integration/test_transcript_savepoint.py` and `test_channel_attachment_rows.py` — both columns read back from a real Postgres: the message content and the `chat_files.message_id`. - `make lint-backend` green. `make test` green: 4799 passed, platform layer at 100%. The two tests that pinned the old blank body were updated to pin the named one. - `docs/channels.md` § Files: one sentence saying a caption-less turn's message names its files. ## Noticed, not fixed - **#746 (new, filed from this work)** — `attachments.py:208/:238` still hand the model nothing for a routing failure or an unparsed file on a workspace-less agent, so the agent denies receiving a file the transcript now names. Model-side, outside #704's acceptance criteria. Closes #704
DEENUU1
added a commit
that referenced
this pull request
Aug 15, 2026
## What A web-chat turn could attach **another user's file** to its own message by sending that file's id. `chat_file_repo.link_to_message` was a blind bulk UPDATE — no owner predicate, no unlinked check — and `get_many` filtered on id alone, with the ids arriving straight off the socket payload. Two consequences from the one missing predicate: the victim's filename/MIME/size rendered in the attacker's conversation, and a non-NULL `message_id` was overwritten, so the file silently moved off the victim's own message. `chat_files` carries no `organization_id`, so `user_id` is the only scope a row has — and now both the read and the update carry it: - `chat_file_repo.get_many` and `link_to_message` take the owner in their `WHERE`; the UPDATE also requires `message_id IS NULL`. - `ConversationService.link_files_to_message` reads the rows first and **refuses** rather than silently narrowing: a foreign or unknown id raises `NotFoundError` (deliberately indistinguishable, so an id cannot be probed for existence), an already-linked one raises `BadRequestError`. Both name only `file_ids` the caller sent — nothing of the victim's. - The refusal escapes `persist_user_turn`'s swallow (which now covers only infrastructure failures), so the socket answers with an error frame instead of a turn that quietly dropped the attachment. - The channel transcript (`TranscriptService._attach`, the second caller added by #703/#690) links rows as their own uploader; the embed's owner check moved from Python into the query, and a page whose publisher's account is gone (`owner_user_id` is `SET NULL`) reads no rows at all instead of passing `None` into the predicate. ## The re-link decision **A file already on a message never moves, not even for its owner.** No caller legitimately re-links: web chat links fresh uploads once per send (no retry resends `file_ids`), channel rows are created server-side unlinked in the same turn, and the embed already documented and dropped a spent id. The silent move only ever rewrote history, so it is refused outright (`BadRequestError`) rather than allowed for the owner. ## How verified - `backend/tests/integration/test_chat_file_ownership.py` — the issue's sequence against a real database, both rows read back: the attacker gets a refusal, the victim's message keeps its file; the unlinked-file disclosure case; the repo-level UPDATE skipping a foreign row even without the service's pre-read; the owner's own upload still linking; the re-link refusal leaving the row where it was; the scoped read returning nothing for a foreign id. - Unit tests for the service refusals, the owner riding through `persist_user_turn`, `load_attached_files`, the embed session and the channel transcript, and the ownerless-embed drop. - The pre-existing channel-turn integration test (`test_transcript_savepoint.py`) proves server-side rows still link through the new predicates. - `make lint` and `make test` (100% platform gate) pass; `docs/architecture.md` and `docs/file-processing.md` updated in the same change. Closes #706
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Closes #690. A channel turn that ran perfectly left its
ChatFilerows withmessage_idNULL.link_files_to_messagewas called from exactly one place -persist_user_turnin the web chat path - so a file sent to a Slack, Telegram orMattermost bot was stored, fed to the agent, and then orphaned.
chat_filescarries noorganization_id, so a row with no message is scoped byuser_idalone: reachable throughGET /files/{id}by whoever sent it and bynothing else, while the conversation holding the question does not know the file
exists. A transcript of a channel thread showed the question and not the
spreadsheet it was asked about.
What changed
TranscriptService.recordtakes the turn'sattachmentsand links them to theuser message it writes. That is the right place because it is where that
message first exists, and it is the one write every non-streaming surface
reaches - the same reason the transcript itself lives there (Embed and channel-mention runs record no messages, so a run has a cost and no content #205): a thing
each surface has to remember is a thing the next surface will not.
write there touching rows the conversation does not own, so sharing the outer
one would mean a run that has already spent money losing its answer and its tool
calls because a file could not be attached - the opposite of the trade web chat
makes for the same write. Skipped entirely when nothing was attached: a
SAVEPOINT and its release on every turn in the deployment is a real cost for a
list that is almost always empty.
AgentRunnerService.executepasses the attachments through_runto get themthere. A resume passes none and records no prompt, so nothing links.
files through
persist_user_turn, so there is no second link and no doublewrite.
docs/channels.md, the Files section: a file the turn ran on belongs to thatturn, why an unlinked row matters given no
organization_id, and what the linkwidens - the metadata, never the bytes (see Noticed below).
No repository, schema or route change.
chat_file_repo.link_to_messagealreadyexisted; only its second caller is new.
How it failed before
.csvon a channel bot with any question.SELECT message_id FROM chat_files WHERE id = …→ NULL.Testing
backend/tests/integration/test_transcript_savepoint.py— a channel turn withan attachment against a real database, reading the column back, which is
what A successful channel turn never links its ChatFile rows to a message #690 asked for. The repository boundary is not where this was ever broken:
link_to_messageworked and no surface but web chat called it, so a test thatstops there proves the half that already worked. Fails against
main'stranscript.py.backend/tests/test_surface_transcripts.py— the same turn through its realentry point (
ChannelAgentRouter.answer_default), asserting the link carriesthe id of the user message and not the assistant one.
backend/tests/test_transcript.py— a link that raises costs the files and notthe turn: the assistant message and its tool calls are still written.
make lint-backendgreen.make testgreen: 4378 passed, platform layer stillat 100%.
make test-integrationgreen: 423 passed.make test-e2e(backend-only change). CI'se2ejob passed.Overlaps
Based on
origin/main; the code being fixed is onmain, so no stacking wasneeded. Four PRs are open on this same channel-attachment path and merge order
matters — whichever lands second needs a rebase, though none of them touch the
files changed here:
_receive_filesabove_answer_mention. This onegates half of the claim above. Until it lands, a message that names no handle
stores each file twice —
_answer_mentionreceives them, discards them onUnaddressedMessage, and_route_innerreceives them again — and only thesecond copy is the one this links. The row the agent actually read comes out
linked either way; the duplicate stays orphaned until A non-mention channel message with attachments stores every file twice #660 is fixed. Worth
merging first.
success half of the same story: Delete the files a refused channel turn stored #689 deletes rows for a turn that never ran,
this links rows for a turn that did. Both touch
docs/channels.md§ Files indifferent paragraphs — a textual conflict there is likely and trivial.
feat/agent-surfaces.parse_incoming, in progress elsewhere.Noticed, not fixed
security) —chat_file_repo.link_to_messageis a blindUPDATE … WHERE id IN (…)with no owner predicate, and the web path feeds itclient-supplied ids, so a chat turn can attach another user's file to its own
message. Pre-existing and not reachable from the caller added here — a channel
turn links rows the adapter created server-side for that sender in that turn —
but adding a second caller is what put the function under review.
workspace-less agent, a file with no
parsed_content, a routing failure) leavespromptempty, so no user turn is written and there is nothing to link to. Ifiled this with the wrong scope first and have corrected it: a caption-less
.csvis linked, becausebuild_promptprepends anAttached file: …reference.
by whoever spoke first, so a colleague's file now appears in a transcript other
members can read — name, type, size. The bytes still answer only the owner, so
the chip is visible and the download is not. Documented in
docs/channels.mdrather than left to be discovered.