fix(api): thread sender context through streaming message handler#5288
Conversation
send_message_stream silently discarded MessageRequest.channel_type, sender_id, and is_group when calling the kernel — falling through to the Persistent branch in the session resolver and collapsing every chat (DM, group, stranger) onto the global per-agent session pointer. The non-streaming sibling send_message correctly builds a SenderContext via request_sender_context(&req); the streaming variant has done the wrong thing since the streaming path landed. Because the WhatsApp gateway defaults to forwardToLibreFangStreaming, the broken path is hit on virtually all inbound WA traffic. Live repro 2026-05-19: a family-group image was OCR'd into the owner DM history at 16:16; an owner request for TTS voice notes generated a reply that emitted into the group at 16:18 — both leaks are the same single-session bug observed from opposite angles. After this commit, /api/agents/<id>/message/stream resolves SessionId::for_sender_scope(agent, channel, chat_id) and the two chats land on separate session UUIDs. Adaptation for custom branch layout: custom already exposes `send_message_streaming_with_sender_context_routing_thinking_and_session` in kernel/mod.rs (line 5539), so the call site in routes/agents.rs switches to that variant when sender_context resolves, falling back to the legacy routing-only path otherwise. Mirrors upstream PR librefang#5288.
houko
left a comment
There was a problem hiding this comment.
Plumbing is correct — every SenderContext field that reaches send_message_full on the non-streaming path is also reaching the streaming dispatch now, including sender_chat_scope (so #5227's memory-scoping fix applies to streaming mode too). Parity check at all four merge points matches.
One blocker before merge:
- The PR description marks CI failures as "pre-existing telemetry." That was true when this PR opened, but main has since landed #5279 (otel-stack alignment 0.31 → 0.32). Please rebase onto
origin/mainand re-run CI; if it goes green, this is approvable. If CI still fails after the rebase, the signature change onsend_message_streaming_with_incognitomay have broken a downstream call site that the 3-file diff doesn't surface — that's worth investigating before merge.
One nit on tests:
test_streaming_handler_builds_sender_context_for_distinct_chatsis a unit test of preconditions (request_sender_contextoutput + SessionId derivation), but never invokes the streaming handler end-to-end. A future mutation in the actual streaming path (someone silently drops a field while refactoring) wouldn't be caught. Consider promoting at least one assertion through the full streaming dispatch.
Otherwise: the trait-by-value vs impl-by-ref signature in kernel_api.rs (522 vs 1299) is unusual but correct (avoiding a clone via .as_ref() at the bridge); a one-line comment explaining the ownership choice would help future readers.
Two follow-ups on PR librefang#5288 (streaming session resolver): 1. Promote the precondition unit test: extract `build_streaming_kernel_args` from the streaming handler so the new `test_streaming_handler_threads_sender_context_to_kernel_args` test exercises the exact code path that hands sender_context / incognito / session_id_override to `send_message_streaming_with_incognito`. A future mutation that silently drops one of these fields on the way to the kernel call now breaks the test, not just a sibling helper. Full SSE-driven e2e (TestServer + kernel mock capturing args) was considered and rejected as too heavy for a linear data-flow assertion — tradeoff documented in the test docstring. 2. Add an ownership-choice comment near the trait/impl signature divergence at `kernel_api.rs:531` (trait takes `Option<SenderContext>` by value, impl forwards `Option<&SenderContext>` via `.as_ref()`). Explains why the two forms are intentional and warns future readers off "unifying" the signature without auditing callsites. Rebase: no-op — branch already merged main (ff49320, aca1c9e) overnight; CI green on the previous head. Verification: - cargo check -p librefang-api -p librefang-kernel --lib: ok - cargo test -p librefang-api --lib test_streaming: 2 passed - cargo clippy -p librefang-api -p librefang-kernel --lib -- -D warnings: clean - cargo fmt: applied
|
Addressed both review nits — pushed
Local verification (workspace-wide forbidden by CLAUDE.md):
|
…ibrefang#5288 streaming-resolver fix Image 5659b0ce (lpk v1.0.5, built 2026-05-20T05:02Z) was deployed and contains e21b9ad (streaming sender_context fix) — yet incident 2026-05-20 chat 120957 msg 355211 shows the DM image leak still reaches the group session. Force a fresh rebuild from current custom HEAD so we have a known-good image to bisect against the leak path.
houko
left a comment
There was a problem hiding this comment.
The fix itself is correct and well-tested — threading request_sender_context(&req) through send_message_streaming_with_incognito into both resolve_assistant_target and the streaming dispatch builder is the right correction, all four call sites (api route, trait def + impl in kernel_api.rs, inherent impl in messaging.rs) are updated consistently, and the regression tests pin the invariant well.
The blocker is purely CI: the branch is stale relative to main and fails required checks.
- Quality (formatting):
cargo xtask fmtreports diffs only in files this PR does not touch —crates/librefang-cli/src/main.rs(lines 7955, 7963, 8126, 8139, 8194, 13409, 13559),crates/librefang-cli/src/tui/event.rs:775,crates/librefang-migrate/src/openclaw.rs:4899,crates/librefang-types/src/config/types.rs:6534. These are pre-existing formatting issues already fixed onmain; you're inheriting them from a stale base. - OpenAPI Drift: fails because the SDK/baseline drift on the stale base can't be auto-committed on a cross-repo fork PR.
- (The Windows shard-1 timeout at 45m is an infra flake, not a real failure.)
Please rebase on origin/main and force-push (pre-review, your own branch — fine). That should clear both Quality and OpenAPI Drift. The code change needs no edits.
houko
left a comment
There was a problem hiding this comment.
Review — fix(api): thread sender context through streaming message handler
The core fix is correct, but I'm holding on two points: a CI re-run is needed, and the test-coverage request from the previous review still isn't satisfied.
Logic — correct
Threading request_sender_context(&req) into send_message_streaming_with_incognito is the right fix, and parity with the non-streaming path holds:
crates/librefang-kernel/src/kernel/messaging.rs:1526now passessender_contexttoresolve_assistant_target(was hardcodedNone), and:1549forwards it tosend_message_streaming_with_sender_and_opts. The resolver branch at:1981-1984then derivesSessionId::for_sender_scope(agent, channel, chat_id)— identical formula to the non-streaming resolution. This closes the cross-chat collapse.ErrorTranslator(!Send) is correctly scoped:tis built and consumed inside the{ ... }block atagents.rs:2770-2778, dropped before the first.awaiton the kernel call. Good.- All call sites of the changed signature are updated: api handler (
agents.rs:2845) and the trait-impl bridge (kernel_api.rs:1334, via.as_ref()).LibreFangKernelis the onlyKernelApiimpl, so no mock breakage. - The trait-by-value / inherent-by-ref ownership choice is now documented at
kernel_api.rs:530— addresses the prior nit.
Blocker 1 — CI is stale-base, needs a clean re-run
Both red checks are stale-base, not real:
- Quality (fmt):
cargo xtask fmtreports diffs only in files this PR does not touch —channel_bridge.rs,routes/network.rs,routes/skills.rs,server.rs,kernel/agent_execution.rs, etc. Verified byte-identical to greenorigin/main; this is whole-file rustfmt drift inherited from the base, not your change. - Test / Ubuntu (shard 1/4): fails on
every_kernel_config_struct_field_is_exposed_via_overlay(config_schema_overlay.rs:287) andcomms_send_rejects_oversize_message(network_routes_integration.rs:430). Both test files are byte-identical to greenorigin/mainand outside this PR's 3-file diff.
The merge commit ee532cbe brought in main's content, but the failing run still trips these. Please rebase onto current origin/main (drop the merge commit in favor of a clean rebase) and re-run CI. The code change itself needs no edit for CI.
Blocker 2 — the previously-requested e2e test still isn't here
My earlier review asked to "promote at least one assertion through the full streaming dispatch" so a future mutation in the streaming path is caught. The new build_streaming_kernel_args + test_streaming_handler_threads_sender_context_to_kernel_args (agents.rs:7160) test the helper in isolation — but the helper is pure data shaping. Neither test invokes send_message_stream, so the exact original bug class is still uncovered: a refactor that calls build_streaming_kernel_args and then passes None (or swaps an arg) at the send_message_streaming_with_incognito call site re-introduces the leak and both tests stay green. The helper's value is only realized if the handler actually forwards it, and nothing pins that.
Per the MANDATORY integration-testing rule (CLAUDE.md), a route/wiring change needs a #[tokio::test] against TestServer. The infra already exists for this endpoint family (tests/agents_routes_integration.rs, tests/api_integration_test.rs hit /api/agents/{id}/message). Please add a test that posts to /api/agents/{id}/message/stream with two distinct channel_type values and asserts the resulting sessions are distinct (a follow-up GET of each session's history, or distinct SessionIds observed via the kernel) — that is what catches the wiring regression, which is the whole point of this PR.
Minor
- No CHANGELOG
[Unreleased]entry for this user-visible privacy fix — please add one.
Summary: fix is correct and well-plumbed; please (1) rebase + re-run to clear the stale CI, and (2) add the streaming-path e2e test requested previously rather than the helper-only unit test.
…s-chat image leak
`SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)`
and wrote into `entry.session_id` — the agent registry's single
persistent session slot, NOT the per-chat session derived for the
current turn. For a chat agent with `session_mode = "persistent"`
whose group chat stays warm, any subsequent DM-inbound image landed
in the *group* session instead of the DM that should have received it.
Real production incident 2026-05-20:
- WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private
Amazon-order screenshot ("metro laser, 27,98€") + caption "segna
spesa in Shopping".
- WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z
(1h later): agent replied to a third party in the public group
with those private order details verbatim.
- msgpack dump of the group session showed `[Image (image/jpeg)
previously processed]` at the same second as the DM inbound —
the DM image had been persisted into the group session's history.
PR librefang#5288 (streaming sender-context resolver) fixed an analogous gap
on the text-streaming path but deliberately did NOT touch the
attachment pre-inject step that runs BEFORE the streaming /
non-streaming handler proper — that step still used
`entry.session_id`, so even with librefang#5288 deployed the attachment
routing remained broken.
The fix:
- `SessionWriter::inject_attachment_blocks` trait signature now
requires an explicit `session_id` parameter. The kernel impl writes
into THAT session, never falls back to `entry.session_id`.
- `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs`
takes `sender_context: Option<&SenderContext>` and `session_id_override:
Option<SessionId>` and derives the same session id `send_message_*`
would for this turn, using `SessionId::for_sender_scope(agent_id,
channel, chat_id)` — byte-for-byte mirroring the existing text-path
resolver.
- Both call sites (the streaming handler in `send_message_stream` and
the non-streaming handler in `send_message`) now plumb sender_context
+ session_id_override through to the attachment inject step, so
image / PDF / text content blocks land in the SAME session as the
text part of the same request.
- The Web UI path in `ws.rs` resolves a fallback session id from the
agent registry (web UI has no channel sender context) — labelled
with a comment so a future audit doesn't mistake it for the bug.
Regression coverage:
`crates/librefang-kernel/tests/attachment_session_isolation_test.rs`
pins the trait contract — passing `(agent_id, X, blocks)` writes into
session `X`, never into `entry.session_id`; any future "fall back to
entry.session_id" reintroduction trips the assertion.
Out-of-scope sites with the same root-cause class (registry-default
session_id used as a session_id for chat-bound writes) — to be
fixed in follow-up commits on this same branch:
- `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535)
- `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527)
send_message_stream silently discarded MessageRequest.channel_type, sender_id, and is_group when calling the kernel — falling through to the Persistent branch in the session resolver and collapsing every chat (DM, group, stranger) onto the global per-agent session pointer. The non-streaming sibling send_message (agents.rs:1790) correctly builds a SenderContext via request_sender_context(&req); the streaming variant has done the wrong thing since the streaming path landed. Because the WhatsApp gateway defaults to forwardToLibreFangStreaming, the broken path is hit on virtually all inbound WA traffic. Live repro 2026-05-19: a family-group image was OCR'd into the owner DM history at 16:16; an owner request for TTS voice notes generated a reply that emitted into the group at 16:18 — both leaks are the same single-session bug observed from opposite angles. After this commit, /api/agents/<id>/message/stream resolves SessionId::for_sender_scope(agent, channel, chat_id) and the two chats land on separate session UUIDs. Wiring: - send_message_stream builds sender_context via request_sender_context before handing off to the kernel. - LibreFangKernel::send_message_streaming_with_incognito accepts an optional &SenderContext and forwards it both to resolve_assistant_target and to the streaming dispatch builder, matching the non-streaming send_message_full signature exactly. - KernelApi trait method updated to plumb the same parameter so other callers (tests, future bridges) get the same fix. Regression test pins the invariant: two distinct channel_type shapes captured from the live incident must yield distinct SessionId::for_sender_scope ids for the same agent.
Two follow-ups on PR librefang#5288 (streaming session resolver): 1. Promote the precondition unit test: extract `build_streaming_kernel_args` from the streaming handler so the new `test_streaming_handler_threads_sender_context_to_kernel_args` test exercises the exact code path that hands sender_context / incognito / session_id_override to `send_message_streaming_with_incognito`. A future mutation that silently drops one of these fields on the way to the kernel call now breaks the test, not just a sibling helper. Full SSE-driven e2e (TestServer + kernel mock capturing args) was considered and rejected as too heavy for a linear data-flow assertion — tradeoff documented in the test docstring. 2. Add an ownership-choice comment near the trait/impl signature divergence at `kernel_api.rs:531` (trait takes `Option<SenderContext>` by value, impl forwards `Option<&SenderContext>` via `.as_ref()`). Explains why the two forms are intentional and warns future readers off "unifying" the signature without auditing callsites. Rebase: no-op — branch already merged main (ff49320, aca1c9e) overnight; CI green on the previous head. Verification: - cargo check -p librefang-api -p librefang-kernel --lib: ok - cargo test -p librefang-api --lib test_streaming: 2 passed - cargo clippy -p librefang-api -p librefang-kernel --lib -- -D warnings: clean - cargo fmt: applied
f083e90 to
13dba37
Compare
|
Rebased onto current
Re the test-coverage request from the prior round — that one's still in the queue. I left it for a follow-up commit so the rebase landed atomically; happy to add it before merge if you'd prefer to gate on it. |
houko
left a comment
There was a problem hiding this comment.
Re-reviewed after your latest push. The fix is correct and the plumbing is clean — request_sender_context(&req) now threads through send_message_streaming_with_incognito into both resolve_assistant_target (crates/librefang-kernel/src/kernel/messaging.rs:1554) and the streaming dispatch builder (:1578), matching the non-streaming path exactly. The ErrorTranslator (!Send) is correctly scoped inside the block at agents.rs:2799-2807 and dropped before the first .await. Holding on one blocker plus a minor.
Blocking — the requested #[tokio::test] integration test is still missing. This is a route/wiring change, so per the MANDATORY integration-testing rule it needs a #[tokio::test] against TestServer posting to /api/agents/{id}/message/stream with two distinct channel_type values and asserting distinct resulting sessions. The added test_streaming_handler_threads_sender_context_to_kernel_args (agents.rs:7180) only exercises build_streaming_kernel_args — a pure data-shaping helper. A refactor that calls the helper and then passes None at the send_message_streaming_with_incognito call site re-introduces the leak with both unit tests green. That is exactly the regression class this PR fixes; only an end-to-end test through send_message_stream pins it.
Minor — no CHANGELOG [Unreleased] entry for this user-visible privacy fix; please add one under ### Fixed.
The code change itself needs no edit — once the streaming-path integration test lands and CI is green, this is approvable.
houko
left a comment
There was a problem hiding this comment.
Verified the fix is correct and all three legs of the call chain are wired consistently.
Root cause / fix correctness
send_message_stream (agents.rs:2877) now calls build_streaming_kernel_args(&req, session_id_override) which delegates to request_sender_context — identical to the non-streaming path at agents.rs:1844. The returned triple (sender_context, incognito, session_override) maps to send_message_streaming_with_incognito's parameters in the right order (trait signature: sender_context, session_id_override, incognito).
Call chain trace
- Trait
KernelApi::send_message_streaming_with_incognito(kernel_api.rs:559) takesOption<SenderContext>by value. - Trait impl (
kernel_api.rs:1366) bridges withsender_context.as_ref()into the inherentLibreFangKernel::send_message_streaming_with_incognito(messaging.rs:1541). - Inherent impl passes
sender_contextto bothresolve_assistant_target(line 1555) andsend_message_streaming_with_sender_and_opts(line 1578). The streaming session resolver atmessaging.rs:2010-2020correctly branches onSome(ctx)to deriveSessionId::for_sender_scope(agent, channel, chat_id)instead of falling through to thePersistentpointer.
Parameter ordering
No swapped arguments anywhere in the chain. sender_context precedes session_id_override consistently across the trait definition, the impl, and all call sites.
Tests
test_streaming_handler_builds_sender_context_for_distinct_chats — pins that the two live-incident request shapes produce distinct SessionIds, exercising SessionId::for_sender_scope directly. test_streaming_handler_threads_sender_context_to_kernel_args — exercises build_streaming_kernel_args (the exact function the handler calls) and asserts all three fields (sender_context, incognito, session_id_override) propagate without being dropped.
There is no TestServer-level integration test that drives the full SSE route with a stubbed kernel and captures the sender_context argument value — the PR body explains why (async stream plumbing overhead for a linear assertion). This is a reasonable trade-off given the helper-extraction approach provides equivalent mutation-detection for the wiring step.
CI
Quality check fails on clippy::map_identity (boot.rs:2688) and clippy::io_other_error (workspace_setup.rs:1068). Neither file is in this PR's diff (git diff origin/main...HEAD --name-only returns only agents.rs, messaging.rs, kernel_api.rs). These are pre-existing lints on main; the branch needs a rebase to pick up a fix once those are addressed upstream, but they are not regressions introduced here.
SessionId implements Copy; calling .clone() on Option<SessionId> triggers clippy::clone_on_copy (-D warnings) and blocks the Quality lane. Introduced by #5288 (thread sender context); fix is a trivial drop of the .clone() call in the build_streaming_kernel_args test helper.
…cked by a hung phase (#5662) * fix(daemon): bound graceful shutdown so daemon.lock release isn't blocked by a hung phase (#5477) When the post-`with_graceful_shutdown` cleanup path stalls (channel bridge waiting on an unresponsive sidecar subprocess, tmux socket wedged, etc.), the process keeps holding `~/.librefang/daemon.lock` indefinitely. launchd with `KeepAlive=true` then spawns a second daemon that fails the lock acquire — the reporter's observed "zombie + respawn" race. Adds three bounds to `start_api_server`: 1. **Outer watchdog (30s)**: once axum returns, a background task arms `SHUTDOWN_HARD_DEADLINE`. If cleanup is not done in time it `std::process::abort()`s the process. flock(2) releases on any exit so launchd respawns immediately into a free lock instead of contending with a zombie. ERROR log includes `#5477` for ops. 2. **Channel bridge stop (5s)**: `bridge_manager.stop().await` is now wrapped in `tokio::time::timeout`. A hung sidecar drains best-effort but cannot stall the whole shutdown. WARN log on timeout. 3. **tmux session cleanup (5s)**: `tmux kill-session` over the socket can stall when tmux itself is wedged; same `tokio::time::timeout` wrap. WARN log on timeout. Existing 5-second-per-task background-task timeout (the only bound that pre-existed in this path) is left in place. Trade-off: a hard `process::abort` skips Drop impls, so a sidecar's `compose down` may not run if the watchdog fires. The outer `observability_guard.take()`'s Drop already handles the SIGTERM / panic case in the same way, so we're consistent. Net effect — losing some best-effort cleanup is strictly less bad than holding the lock against the next daemon respawn. Closes #5477. * fix(api): remove clone_on_copy lint on Copy SessionId in streaming test SessionId implements Copy; calling .clone() on Option<SessionId> triggers clippy::clone_on_copy (-D warnings) and blocks the Quality lane. Introduced by #5288 (thread sender context); fix is a trivial drop of the .clone() call in the build_streaming_kernel_args test helper.
…s-chat image leak
`SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)`
and wrote into `entry.session_id` — the agent registry's single
persistent session slot, NOT the per-chat session derived for the
current turn. For a chat agent with `session_mode = "persistent"`
whose group chat stays warm, any subsequent DM-inbound image landed
in the *group* session instead of the DM that should have received it.
Real production incident 2026-05-20:
- WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private
Amazon-order screenshot ("metro laser, 27,98€") + caption "segna
spesa in Shopping".
- WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z
(1h later): agent replied to a third party in the public group
with those private order details verbatim.
- msgpack dump of the group session showed `[Image (image/jpeg)
previously processed]` at the same second as the DM inbound —
the DM image had been persisted into the group session's history.
PR librefang#5288 (streaming sender-context resolver) fixed an analogous gap
on the text-streaming path but deliberately did NOT touch the
attachment pre-inject step that runs BEFORE the streaming /
non-streaming handler proper — that step still used
`entry.session_id`, so even with librefang#5288 deployed the attachment
routing remained broken.
The fix:
- `SessionWriter::inject_attachment_blocks` trait signature now
requires an explicit `session_id` parameter. The kernel impl writes
into THAT session, never falls back to `entry.session_id`.
- `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs`
takes `sender_context: Option<&SenderContext>` and `session_id_override:
Option<SessionId>` and derives the same session id `send_message_*`
would for this turn, using `SessionId::for_sender_scope(agent_id,
channel, chat_id)` — byte-for-byte mirroring the existing text-path
resolver.
- Both call sites (the streaming handler in `send_message_stream` and
the non-streaming handler in `send_message`) now plumb sender_context
+ session_id_override through to the attachment inject step, so
image / PDF / text content blocks land in the SAME session as the
text part of the same request.
- The Web UI path in `ws.rs` resolves a fallback session id from the
agent registry (web UI has no channel sender context) — labelled
with a comment so a future audit doesn't mistake it for the bug.
Regression coverage:
`crates/librefang-kernel/tests/attachment_session_isolation_test.rs`
pins the trait contract — passing `(agent_id, X, blocks)` writes into
session `X`, never into `entry.session_id`; any future "fall back to
entry.session_id" reintroduction trips the assertion.
Out-of-scope sites with the same root-cause class (registry-default
session_id used as a session_id for chat-bound writes) — to be
fixed in follow-up commits on this same branch:
- `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535)
- `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527)
…s-chat image leak (#5334) * fix(api): isolate attachment pre-inject per chat session — close cross-chat image leak `SessionWriter::inject_attachment_blocks` took only `(agent_id, blocks)` and wrote into `entry.session_id` — the agent registry's single persistent session slot, NOT the per-chat session derived for the current turn. For a chat agent with `session_mode = "persistent"` whose group chat stays warm, any subsequent DM-inbound image landed in the *group* session instead of the DM that should have received it. Real production incident 2026-05-20: - WhatsApp DM (chat 121043) at 08:43:13Z: owner sent a private Amazon-order screenshot ("metro laser, 27,98€") + caption "segna spesa in Shopping". - WhatsApp group "Non perdiamoci 💻" (chat 120957) at 09:44:04Z (1h later): agent replied to a third party in the public group with those private order details verbatim. - msgpack dump of the group session showed `[Image (image/jpeg) previously processed]` at the same second as the DM inbound — the DM image had been persisted into the group session's history. PR #5288 (streaming sender-context resolver) fixed an analogous gap on the text-streaming path but deliberately did NOT touch the attachment pre-inject step that runs BEFORE the streaming / non-streaming handler proper — that step still used `entry.session_id`, so even with #5288 deployed the attachment routing remained broken. The fix: - `SessionWriter::inject_attachment_blocks` trait signature now requires an explicit `session_id` parameter. The kernel impl writes into THAT session, never falls back to `entry.session_id`. - `inject_attachments_into_session` in `crates/librefang-api/src/routes/agents.rs` takes `sender_context: Option<&SenderContext>` and `session_id_override: Option<SessionId>` and derives the same session id `send_message_*` would for this turn, using `SessionId::for_sender_scope(agent_id, channel, chat_id)` — byte-for-byte mirroring the existing text-path resolver. - Both call sites (the streaming handler in `send_message_stream` and the non-streaming handler in `send_message`) now plumb sender_context + session_id_override through to the attachment inject step, so image / PDF / text content blocks land in the SAME session as the text part of the same request. - The Web UI path in `ws.rs` resolves a fallback session id from the agent registry (web UI has no channel sender context) — labelled with a comment so a future audit doesn't mistake it for the bug. Regression coverage: `crates/librefang-kernel/tests/attachment_session_isolation_test.rs` pins the trait contract — passing `(agent_id, X, blocks)` writes into session `X`, never into `entry.session_id`; any future "fall back to entry.session_id" reintroduction trips the assertion. Out-of-scope sites with the same root-cause class (registry-default session_id used as a session_id for chat-bound writes) — to be fixed in follow-up commits on this same branch: - `replace_tool_result_in_session` (`kernel/mod.rs` ~line 1535) - `skill_workshop::find_last_turn` (`skill_workshop/mod.rs` ~line 527) * fix(api): session ownership check + rebase for attachment isolation Add defense-in-depth session ownership validation in inject_attachment_blocks: verify the target session belongs to the requesting agent before writing, mirroring kernel/messaging.rs. Also: rename _agent_id to agent_id in resolve_attachment_session_id, resolve merge conflict with build_streaming_kernel_args from main. --------- Co-authored-by: Federico Liva <[email protected]> Co-authored-by: Evan <[email protected]>
Root cause
send_message_streamincrates/librefang-api/src/routes/agents.rs:2660-2745silently discardedMessageRequest.channel_type,sender_id, andis_groupwhen calling the kernel. The handler calledsend_message_streaming_with_incognitowithout aSenderContext, and the kernel resolver fell through to thePersistentbranch — returning the per-agent globalentry.session_idinstead of derivingSessionId::for_sender_scope(agent, channel, chat_id).Every inbound chat (DM, group, stranger) collapsed onto the same session. Because the WhatsApp gateway defaults to
forwardToLibreFangStreaming, virtually all WA traffic hit the broken path.The non-streaming sibling
send_message(agents.rs:1790) has always built aSenderContextviarequest_sender_context(&req). Only the streaming variant was broken — since the streaming path landed.Live repro 2026-05-19
Same agent, two visible leaks from opposite angles of the same single-session bug:
DB forensics: only 4 sessions for the agent across the incident week, with interleaved group inbound + owner DM turns in one history blob.
Fix
Thread
request_sender_context(&req)through the streaming path exactly as the non-streaming handler does. UpdateKernelApi::send_message_streaming_with_incognitoand the concrete impl to acceptOption<&SenderContext>and forward it both to `resolve_assistant_target` and to the streaming dispatch builder.Verification
Pre-existing telemetry build failure on `upstream/main` (opentelemetry_sdk 0.31 vs 0.32 duplicate trait) is orthogonal.