Problem
RockBot is a single persona that I interact with across many channels (Blazor, CLI, Discord, WhatsApp, Slack, Teams). The agent regularly produces unsolicited messages — subagent completions, scheduled tasks, A2A results, idle-batched inbox notifications — and these get fanned out on the broadcast topic user.response.<AgentName>, which every connected proxy subscribes to (intentional, see UserProxyService.cs:56-73).
When one of those bubbles lands in (say) Blazor, the user often has no idea:
- Why — what was the original ask?
- When — when did I start it?
- Where — which channel was I in when I started it?
This is a real usability problem because work that spans long durations (deep research, A2A round-trips, scheduled patrols) regularly finishes after the originating session is gone, or in a different client than the one currently being watched. The user has to mentally reconstruct context that the agent already had at request time.
Today's behavior
AgentReply (src/RockBot.UserProxy.Abstractions/AgentReply.cs) carries only Content, SessionId, AgentName, IsFinal, IsCompletion, StructuredData, ContentType. The originating envelope's Source (proxy ID), the original prompt text, and the start timestamp are all dropped before the frontend ever sees them.
BlazorUserFrontend.CategorizeReply (src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.cs:55-83) does infer some provenance from SessionId (scheduled-system, scheduled, a2a-inbound) and from AgentName (subagent-* prefix), but a regular session-tagged broadcast just falls into PrimaryFinal and renders as a context-free chat bubble.
Proposed design
Add an Origin sub-record to AgentReply that every unsolicited-publish site stamps, and let each frontend render an anchor preamble like:
↳ Re: \"deep research 8 historical topics\"
started 21:14 from CLI · 2h 14m ago
The anchor is hidden when origin channel + session match the current viewing context (so it doesn't clutter the active conversation).
Data shape
public sealed record ReplyOrigin(
string Channel, // \"cli\" | \"blazor\" | \"discord\" | \"scheduled\" | \"a2a-inbound\" | …
string PromptSummary, // truncated or LLM-summarized first user turn
DateTimeOffset StartedAt,
string? SessionId // original session, for deep-linking / dedupe
);
// AgentReply gains:
public ReplyOrigin? Origin { get; init; }
Single capture point
Add a session-keyed side-channel parallel to the existing SessionClientCapabilityStore:
// src/RockBot.Host/SessionOriginStore.cs (new)
public sealed class SessionOriginStore
{
private readonly ConcurrentDictionary<string, ReplyOrigin> _byBaseSessionId = new();
public void Set(string sessionId, ReplyOrigin origin) => _byBaseSessionId[sessionId] = origin;
public ReplyOrigin? Get(string sessionId) => _byBaseSessionId.TryGetValue(sessionId, out var o) ? o : null;
}
Populate it once in UserMessageHandler.HandleAsync (~line 88, alongside clientCapabilityStore.Set):
originStore.Set(message.SessionId, new ReplyOrigin(
Channel: ChannelOf(context.Envelope.Source),
PromptSummary: Summarize(message.Content),
StartedAt: DateTimeOffset.UtcNow,
SessionId: message.SessionId));
Broadcast publish sites to update
The exhaustive inventory of user.response.<AgentName> publishers that need to read originStore and stamp reply.Origin:
Subagent path
src/RockBot.Subagent/SubagentResultHandler.cs:81 — Phase-1 completion bubble per subagent result
src/RockBot.Subagent/SubagentResultHandler.cs:246 — Phase-2 consolidated synthesis
src/RockBot.Subagent/SubagentResultHandler.cs:290 — Degraded synthesis fallback
src/RockBot.Subagent/SubagentProgressHandler.cs:53 — Subagent progress tick
A2A caller path
src/RockBot.A2A/A2ATaskStatusHandler.cs:71 — A2A working-state progress
src/RockBot.A2A/A2ATaskStatusHandler.cs:138 — A2A non-Working status synthesis
src/RockBot.A2A/A2ATaskResultHandler.cs:358 — A2A terminal-state synthesis
src/RockBot.A2A/A2ATaskResultHandler.cs:398 — A2A InputRequired max-rounds error
src/RockBot.A2A/A2ATaskErrorHandler.cs:142 — A2A terminal error synthesis
Synthetic origins (no lookup; synthesize the ReplyOrigin in place)
src/RockBot.Agent/ScheduledTaskHandler.cs:186 — Channel = \"scheduled\", PromptSummary = message.Description, StartedAt = now
src/RockBot.Host/InboundNotificationService.cs:105 — Channel = \"a2a-inbound\", PromptSummary = caller/skill summary, StartedAt = earliest notification timestamp
Out of scope (correlated / system)
UserFeedbackHandler.cs:186, CancelSessionHandler.cs:38, ClearContextHandler.cs:45, AgentProfileLoader.cs:177 — these are correlated acks or system notifications and don't need origin anchors.
Frontend rendering
src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.cs and CLI/Discord/WhatsApp/etc. each gain a RenderOriginAnchor(reply) helper.
- Suppress the anchor when
reply.Origin.Channel == currentChannel && reply.Origin.SessionId == currentSessionId so it doesn't clutter active conversations.
- Hardest sub-problem: relative-time formatting ("2 hours ago" / "yesterday" / "3 days ago"). Share a helper across frontends.
Two design choices to settle before implementing
- Prompt summarization strategy.
- Option A:
message.Content[..Math.Min(80, len)] — free, sometimes ugly.
- Option B: LLM-summarize on capture — clean but adds entry-point latency.
- Option C: Store both — truncated immediately, replace lazily with LLM summary when first downstream handler reads.
- Channel naming.
envelope.Source currently carries the full ProxyId (e.g. cli-rocky-abc123). Either:
- Add
UserProxyOptions.ChannelName set per-frontend (cli/blazor/…) and propagate on UserMessage, or
- Parse
ProxyId by convention (cli-* → cli, etc.).
The explicit field is cleaner; the convention is faster to ship.
Why this is better than a per-bubble channel badge
A bare "from CLI" label tells you where but not what or when. Across 6 channels with overlapping work, the what is the part that re-grounds the user. Anchoring on the originating task (with its origin metadata) is also more durable than anchoring on the channel — it survives retries, re-issued requests, and follow-ups within the same logical thread.
Acceptance
- An unsolicited subagent completion that arrives in Blazor after being started in CLI shows a single-line preamble identifying the original prompt, channel, and start time.
- A scheduled task firing into any channel shows "scheduled · · ".
- An a2a-inbound idle batch shows the calling agent + skill.
- When viewing a bubble that originated in the current channel + session, the preamble is suppressed.
Problem
RockBot is a single persona that I interact with across many channels (Blazor, CLI, Discord, WhatsApp, Slack, Teams). The agent regularly produces unsolicited messages — subagent completions, scheduled tasks, A2A results, idle-batched inbox notifications — and these get fanned out on the broadcast topic
user.response.<AgentName>, which every connected proxy subscribes to (intentional, seeUserProxyService.cs:56-73).When one of those bubbles lands in (say) Blazor, the user often has no idea:
This is a real usability problem because work that spans long durations (deep research, A2A round-trips, scheduled patrols) regularly finishes after the originating session is gone, or in a different client than the one currently being watched. The user has to mentally reconstruct context that the agent already had at request time.
Today's behavior
AgentReply(src/RockBot.UserProxy.Abstractions/AgentReply.cs) carries onlyContent,SessionId,AgentName,IsFinal,IsCompletion,StructuredData,ContentType. The originating envelope'sSource(proxy ID), the original prompt text, and the start timestamp are all dropped before the frontend ever sees them.BlazorUserFrontend.CategorizeReply(src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.cs:55-83) does infer some provenance fromSessionId(scheduled-system,scheduled,a2a-inbound) and fromAgentName(subagent-*prefix), but a regular session-tagged broadcast just falls intoPrimaryFinaland renders as a context-free chat bubble.Proposed design
Add an
Originsub-record toAgentReplythat every unsolicited-publish site stamps, and let each frontend render an anchor preamble like:The anchor is hidden when origin channel + session match the current viewing context (so it doesn't clutter the active conversation).
Data shape
Single capture point
Add a session-keyed side-channel parallel to the existing
SessionClientCapabilityStore:Populate it once in
UserMessageHandler.HandleAsync(~line 88, alongsideclientCapabilityStore.Set):Broadcast publish sites to update
The exhaustive inventory of
user.response.<AgentName>publishers that need to readoriginStoreand stampreply.Origin:Subagent path
src/RockBot.Subagent/SubagentResultHandler.cs:81— Phase-1 completion bubble per subagent resultsrc/RockBot.Subagent/SubagentResultHandler.cs:246— Phase-2 consolidated synthesissrc/RockBot.Subagent/SubagentResultHandler.cs:290— Degraded synthesis fallbacksrc/RockBot.Subagent/SubagentProgressHandler.cs:53— Subagent progress tickA2A caller path
src/RockBot.A2A/A2ATaskStatusHandler.cs:71— A2A working-state progresssrc/RockBot.A2A/A2ATaskStatusHandler.cs:138— A2A non-Working status synthesissrc/RockBot.A2A/A2ATaskResultHandler.cs:358— A2A terminal-state synthesissrc/RockBot.A2A/A2ATaskResultHandler.cs:398— A2A InputRequired max-rounds errorsrc/RockBot.A2A/A2ATaskErrorHandler.cs:142— A2A terminal error synthesisSynthetic origins (no lookup; synthesize the ReplyOrigin in place)
src/RockBot.Agent/ScheduledTaskHandler.cs:186—Channel = \"scheduled\",PromptSummary = message.Description,StartedAt = nowsrc/RockBot.Host/InboundNotificationService.cs:105—Channel = \"a2a-inbound\",PromptSummary = caller/skill summary,StartedAt = earliest notification timestampOut of scope (correlated / system)
UserFeedbackHandler.cs:186,CancelSessionHandler.cs:38,ClearContextHandler.cs:45,AgentProfileLoader.cs:177— these are correlated acks or system notifications and don't need origin anchors.Frontend rendering
src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.csand CLI/Discord/WhatsApp/etc. each gain aRenderOriginAnchor(reply)helper.reply.Origin.Channel == currentChannel && reply.Origin.SessionId == currentSessionIdso it doesn't clutter active conversations.Two design choices to settle before implementing
message.Content[..Math.Min(80, len)]— free, sometimes ugly.envelope.Sourcecurrently carries the fullProxyId(e.g.cli-rocky-abc123). Either:UserProxyOptions.ChannelNameset per-frontend (cli/blazor/…) and propagate onUserMessage, orProxyIdby convention (cli-*→cli, etc.).The explicit field is cleaner; the convention is faster to ship.
Why this is better than a per-bubble channel badge
A bare "from CLI" label tells you where but not what or when. Across 6 channels with overlapping work, the what is the part that re-grounds the user. Anchoring on the originating task (with its origin metadata) is also more durable than anchoring on the channel — it survives retries, re-issued requests, and follow-ups within the same logical thread.
Acceptance