From 44f4cbd1ae60ae849b82683cee922268ee544ebb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 21:54:44 +0000 Subject: [PATCH 1/5] feat(activity): ingest agent session lifecycle into the activity log Map macro.agent_session_lifecycle onto the shared activity feed: opened is created, user prompts are messaged, renamed is edited, and deleted purges the session. Runtime consequences are ignored. DSS consumes the topic as wiring only. Co-authored-by: Wolf Mermelstein --- Cargo.lock | 2 + crates/agent_session/Cargo.toml | 2 +- crates/agent_session/src/domain/activity.rs | 100 +++++++++++ .../agent_session/src/domain/activity/test.rs | 164 ++++++++++++++++++ crates/agent_session/src/domain/mod.rs | 2 + services/document_storage_service/Cargo.toml | 1 + .../src/service/activity.rs | 3 + 7 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 crates/agent_session/src/domain/activity.rs create mode 100644 crates/agent_session/src/domain/activity/test.rs diff --git a/Cargo.lock b/Cargo.lock index 82e3aebd1f2..ff94d3d4f18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,6 +422,7 @@ dependencies = [ name = "agent_session" version = "0.1.0" dependencies = [ + "activity", "agent", "agent-client-protocol", "agent_fold", @@ -5233,6 +5234,7 @@ name = "document_storage_service" version = "0.1.0" dependencies = [ "activity", + "agent_session", "ai_tools", "ai_usage", "analytics_client", diff --git a/crates/agent_session/Cargo.toml b/crates/agent_session/Cargo.toml index 7dd702d72cb..10d12f27d28 100644 --- a/crates/agent_session/Cargo.toml +++ b/crates/agent_session/Cargo.toml @@ -35,7 +35,7 @@ schema = ["dep:utoipa", "agent_runtime_protocol/utoipa", "macro_user_id/schema"] test-utils = ["dep:mockall", "agent_fold/test-utils"] [dependencies] - +activity = { path = "../activity", default-features = false } agent-client-protocol = { workspace = true } harness_id = { path = "../harness_id" } agent = { path = "../agent", optional = true } diff --git a/crates/agent_session/src/domain/activity.rs b/crates/agent_session/src/domain/activity.rs new file mode 100644 index 00000000000..f4102c47984 --- /dev/null +++ b/crates/agent_session/src/domain/activity.rs @@ -0,0 +1,100 @@ +//! What counts as activity in the agent-session domain. +//! +//! Opening, prompting, and renaming are user (or delegated) acts on the +//! session. The runtime answering, asking a question, and tearing down are +//! consequences of a prompt, not acts by the subject. Deleting a session +//! removes the row, so its activities are purged like other hard deletes. + +#[cfg(test)] +mod test; + +use ::activity::{ + Action, Activity, ActivitySource, Actor, CommonAction, DomainActivity, EntityType, Ingest, + event_time, +}; +use macro_uuid::Uuid; + +use super::events::AgentSessionLifecycleEvent; + +/// Agent-session-exclusive actions. Common lifecycle actions go through +/// [`Activity::common`] and need no representation here. +#[derive(Debug, Clone, PartialEq)] +pub enum AgentSessionAction { + /// The subject sent a prompt in the session. + Messaged, +} + +/// An agent-session-exclusive activity. +#[derive(Debug, Clone, PartialEq)] +pub struct AgentSessionActivity { + /// The session acted on. + pub session_id: String, + /// What happened to it. + pub action: AgentSessionAction, +} + +impl DomainActivity for AgentSessionActivity { + const ENTITY_TYPE: EntityType = EntityType::AgentSession; + + fn entity_id(&self) -> &str { + &self.session_id + } + + fn into_action(self) -> Action { + match self.action { + AgentSessionAction::Messaged => Action::Messaged, + } + } +} + +impl ActivitySource for AgentSessionLifecycleEvent { + /// Maps one `macro.agent_session_lifecycle` event to its ingest outcome. + /// + /// Exhaustive on purpose: a new event variant fails compilation here + /// until someone classifies it or explicitly drops it. + fn ingest(&self, event_id: Uuid) -> Ingest { + let now = || event_time(event_id); + let session_id = self.session_id().to_string(); + let owner = self.identity().owner_id.clone(); + let common = |action: CommonAction| { + Ingest::Insert(vec![Activity::common( + event_id, + 0, + Actor::new_from_user(owner.clone()), + None, + EntityType::AgentSession, + session_id.clone(), + action, + now(), + )]) + }; + + match self { + AgentSessionLifecycleEvent::Opened(_) => common(CommonAction::Created), + AgentSessionLifecycleEvent::TurnStarted(metadata) => match &metadata.actor { + Some(actor) => Ingest::Insert(vec![Activity::from_domain( + event_id, + 0, + Actor::new_from_user(actor.clone()), + None, + AgentSessionActivity { + session_id, + action: AgentSessionAction::Messaged, + }, + now(), + )]), + None => Ingest::Ignore, + }, + AgentSessionLifecycleEvent::Renamed(_) => common(CommonAction::Edited), + // The session row is gone once this event is published. + AgentSessionLifecycleEvent::Deleted(_) => { + Ingest::Purge(vec![(EntityType::AgentSession, session_id)]) + } + AgentSessionLifecycleEvent::TurnEnded(_) + | AgentSessionLifecycleEvent::Settled(_) + | AgentSessionLifecycleEvent::WaitingForInput(_) + | AgentSessionLifecycleEvent::InputReceived(_) + | AgentSessionLifecycleEvent::Stopped(_) => Ingest::Ignore, + } + } +} diff --git a/crates/agent_session/src/domain/activity/test.rs b/crates/agent_session/src/domain/activity/test.rs new file mode 100644 index 00000000000..ce88e1ee7aa --- /dev/null +++ b/crates/agent_session/src/domain/activity/test.rs @@ -0,0 +1,164 @@ +use ::activity::Action; +use bots::domain::models::BotId; +use macro_user_id::user_id::MacroUserIdStr; +use macro_uuid::Uuid; + +use macro_event_broker::Event; + +use super::*; +use crate::domain::events::{ + InputReceivedMetadata, SessionDeletedMetadata, SessionIdentity, SessionOpenedMetadata, + SessionRenamedMetadata, SessionSettledMetadata, SessionStoppedMetadata, TurnEndedMetadata, + TurnStartedMetadata, WaitingForInputMetadata, +}; +use crate::domain::model::{AgentSessionId, TurnId}; + +fn user(id: &str) -> MacroUserIdStr<'static> { + MacroUserIdStr::try_from(id.to_string()).expect("valid user id") +} + +fn envelope(event: AgentSessionLifecycleEvent) -> Event { + Event::with_event_id(Uuid::now_v7(), event) +} + +fn identity() -> SessionIdentity { + SessionIdentity { + session_id: AgentSessionId::TEST_A, + session_name: "Fix the flaky test".to_owned(), + bot_id: BotId::TEST_A, + bot_name: "Macro Coder".to_owned(), + owner_id: user("macro|owner@macro.com"), + origin: None, + } +} + +fn session_id() -> String { + AgentSessionId::TEST_A.to_string() +} + +fn single_activity(ingest: Ingest) -> Activity { + match ingest { + Ingest::Insert(mut activities) => { + assert_eq!(activities.len(), 1); + activities.pop().unwrap() + } + other => panic!("expected a single activity, got {other:?}"), + } +} + +#[test] +fn opened_maps_to_created_on_the_owner() { + let event = envelope(AgentSessionLifecycleEvent::Opened(SessionOpenedMetadata { + identity: identity(), + model: "claude".to_owned(), + harness: "claude_code".to_owned(), + })); + + let activity = single_activity(event.event.ingest(event.event_id)); + assert_eq!(activity.action, Action::Created); + assert_eq!(activity.entity_type, EntityType::AgentSession); + assert_eq!(activity.entity_id, session_id()); + assert_eq!(activity.subject_id, "macro|owner@macro.com"); + assert_eq!(activity.actor.as_ref(), "macro|owner@macro.com"); +} + +#[test] +fn user_prompt_maps_to_messaged() { + let event = envelope(AgentSessionLifecycleEvent::TurnStarted( + TurnStartedMetadata { + identity: identity(), + turn: TurnId(0), + action_id: agent_runtime_protocol::domain::action::AgentActionId::mint(), + actor: Some(user("macro|teo@macro.com")), + announcement_message_id: None, + }, + )); + + let activity = single_activity(event.event.ingest(event.event_id)); + assert_eq!(activity.action, Action::Messaged); + assert_eq!(activity.entity_type, EntityType::AgentSession); + assert_eq!(activity.subject_id, "macro|teo@macro.com"); +} + +#[test] +fn actorless_prompt_is_dropped() { + let event = envelope(AgentSessionLifecycleEvent::TurnStarted( + TurnStartedMetadata { + identity: identity(), + turn: TurnId(0), + action_id: agent_runtime_protocol::domain::action::AgentActionId::mint(), + actor: None, + announcement_message_id: None, + }, + )); + assert_eq!(event.event.ingest(event.event_id), Ingest::Ignore); +} + +#[test] +fn renamed_maps_to_edited() { + let event = envelope(AgentSessionLifecycleEvent::Renamed( + SessionRenamedMetadata { + identity: identity(), + }, + )); + assert_eq!( + single_activity(event.event.ingest(event.event_id)).action, + Action::Edited + ); +} + +#[test] +fn deleted_purges_the_session() { + let event = envelope(AgentSessionLifecycleEvent::Deleted( + SessionDeletedMetadata { + identity: identity(), + }, + )); + assert_eq!( + event.event.ingest(event.event_id), + Ingest::Purge(vec![(EntityType::AgentSession, session_id())]) + ); +} + +#[test] +fn runtime_consequences_are_dropped() { + let identity = identity(); + let action_id = agent_runtime_protocol::domain::action::AgentActionId::mint(); + let ignored = [ + AgentSessionLifecycleEvent::TurnEnded(TurnEndedMetadata { + identity: identity.clone(), + turn: TurnId(0), + action_id: action_id.clone(), + actor: Some(user("macro|owner@macro.com")), + announcement_message_id: None, + stop_reason: "end_turn".to_owned(), + queued_remaining: 0, + }), + AgentSessionLifecycleEvent::Settled(SessionSettledMetadata { + identity: identity.clone(), + last_turn: None, + }), + AgentSessionLifecycleEvent::WaitingForInput(WaitingForInputMetadata { + identity: identity.clone(), + turn: TurnId(1), + action_id: action_id.clone(), + announcement_message_id: None, + question: "Which approach?".to_owned(), + }), + AgentSessionLifecycleEvent::InputReceived(InputReceivedMetadata { + identity: identity.clone(), + turn: TurnId(1), + action_id, + }), + AgentSessionLifecycleEvent::Stopped(SessionStoppedMetadata { + identity, + reason: "transport closed".to_owned(), + turn_in_flight: None, + }), + ]; + + for event in ignored { + let envelope = envelope(event); + assert_eq!(envelope.event.ingest(envelope.event_id), Ingest::Ignore); + } +} diff --git a/crates/agent_session/src/domain/mod.rs b/crates/agent_session/src/domain/mod.rs index 9a00c48cd5a..f8b02297cb8 100644 --- a/crates/agent_session/src/domain/mod.rs +++ b/crates/agent_session/src/domain/mod.rs @@ -1,3 +1,5 @@ +/// Event-to-activity mappings for this domain. +pub mod activity; pub mod connection; pub mod error; pub mod events; diff --git a/services/document_storage_service/Cargo.toml b/services/document_storage_service/Cargo.toml index 0057cfb5122..0d6fe6291b8 100644 --- a/services/document_storage_service/Cargo.toml +++ b/services/document_storage_service/Cargo.toml @@ -28,6 +28,7 @@ location_check = [] [dependencies] activity = { path = "../../crates/activity" } +agent_session = { path = "../../crates/agent_session", default-features = false } ai_tools = { path = "../../crates/ai_tools" } ai_usage = { path = "../../crates/ai_usage" } analytics_client = { path = "../../crates/analytics_client" } diff --git a/services/document_storage_service/src/service/activity.rs b/services/document_storage_service/src/service/activity.rs index a9249fce990..f4290592fee 100644 --- a/services/document_storage_service/src/service/activity.rs +++ b/services/document_storage_service/src/service/activity.rs @@ -6,6 +6,7 @@ //! machinery lives in the `activity` crate. These arms are pure wiring. use activity::Ingest; +use agent_session::domain::events::AgentSessionLifecycleMacroEvent; use call::domain::events::CallMacroEvent; use channels::domain::broker_events::ChannelMacroEvent; use chat::domain::events::ChatMacroEvent; @@ -28,6 +29,7 @@ mod source { EmailMacroEvent, PropertyMacroEvent, CallMacroEvent, + AgentSessionLifecycleMacroEvent, ); } pub(crate) use source::ActivitySourceEvent; @@ -48,5 +50,6 @@ pub(crate) fn ingest(event: &ActivitySourceEvent) -> Ingest { ActivitySourceEvent::EmailMacroEvent(e) => arm(e.event()), ActivitySourceEvent::PropertyMacroEvent(e) => arm(e.event()), ActivitySourceEvent::CallMacroEvent(e) => arm(e.event()), + ActivitySourceEvent::AgentSessionLifecycleMacroEvent(e) => arm(e.event()), } } From 191a851136eb3b800c8bbe036fb1393fb8484931 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 21:54:48 +0000 Subject: [PATCH 2/5] feat(web): show agent sessions in the activity UI Decode AGENT_SESSION events, resolve names and links from session previews, query entity timelines via Soup's opt-in agent session filter, and add an Activity section to the agent side panel. Co-authored-by: Wolf Mermelstein --- .../context/activity-context.test.tsx | 7 ++++ .../activity/context/activity-context.tsx | 40 +++++++++++++++++-- apps/web/src/features/activity/core/event.ts | 9 ++++- .../primitives/entity-activity.test.ts | 9 ++++- .../activity/primitives/entity-activity.ts | 11 +++-- .../activity/primitives/entity-opener.test.ts | 13 ++++++ .../activity/primitives/entity-opener.ts | 4 +- .../features/activity/queries/decode.test.ts | 4 ++ .../src/features/activity/queries/decode.ts | 1 + .../activity/queries/entity-query.test.ts | 23 +++++++++++ .../features/activity/queries/entity-query.ts | 39 +++++++++++++++--- .../src/features/activity/queries/fixtures.ts | 8 ++++ .../views/entity-activity-section.tsx | 4 +- .../sidepanel/AgentSidePanelSections.tsx | 16 +++++--- .../AI/component/tool/ReadActivity.test.tsx | 18 ++++++++- .../AI/component/tool/ReadActivity.tsx | 1 + docs/AGENT_GUIDE/ai-chat.md | 4 ++ 17 files changed, 185 insertions(+), 26 deletions(-) diff --git a/apps/web/src/features/activity/context/activity-context.test.tsx b/apps/web/src/features/activity/context/activity-context.test.tsx index df50fb610ca..504d3ad96b8 100644 --- a/apps/web/src/features/activity/context/activity-context.test.tsx +++ b/apps/web/src/features/activity/context/activity-context.test.tsx @@ -16,6 +16,13 @@ vi.mock('@property/editor/hooks/useAllProperties', () => ({ useAllProperties: () => () => [], })); vi.mock('@property/hooks', () => ({ usePropertyEntityDisplay: () => ({}) })); +vi.mock('@queries/preview', () => ({ + useItemPreview: () => [() => undefined], + isAccessiblePreviewItem: () => false, +})); +vi.mock('@core/component/EntityIcon', () => ({ + EntityIcon: () => null, +})); vi.mock('@service-storage/graphql-soup', () => ({ getGraphqlSoupClient: () => ({}), })); diff --git a/apps/web/src/features/activity/context/activity-context.tsx b/apps/web/src/features/activity/context/activity-context.tsx index 9e71e643f56..8ab5ba09612 100644 --- a/apps/web/src/features/activity/context/activity-context.tsx +++ b/apps/web/src/features/activity/context/activity-context.tsx @@ -1,3 +1,4 @@ +import { EntityIcon as CoreEntityIcon } from '@core/component/EntityIcon'; import { useUserId } from '@core/context/user'; import { tryMacroId, useDisplayName } from '@core/user'; import { useAllProperties } from '@property/editor/hooks/useAllProperties'; @@ -8,17 +9,19 @@ import { firstPartyBotName, getBotDisplayName, } from '@queries/channel/message-sender'; -import type { EntityType } from '@service-properties/generated/schemas/entityType'; +import { isAccessiblePreviewItem, useItemPreview } from '@queries/preview'; import { getGraphqlSoupClient } from '@service-storage/graphql-soup'; import type { Client } from '@urql/core'; import { type Accessor, createContext, + createMemo, getOwner, type JSX, runWithOwner, useContext, } from 'solid-js'; +import type { ActivityDisplayEntityType } from '../core/event'; /** Resolved display for one referenced entity: name, icon, and link target. */ export type EntityDisplay = { @@ -64,7 +67,7 @@ export type ActivityContext = { /** Name, icon, and link target for a referenced entity. */ entityDisplay: ( entityId: Accessor, - entityType: Accessor + entityType: Accessor ) => EntityDisplay; /** The property definition behind a property-changed row, when known. */ propertyDefinition: ( @@ -107,8 +110,13 @@ function appActivityContext(): ActivityContext { if (!list || list.isPending) return undefined; return getBotDisplayName(`bot|${id}`, undefined, list.data ?? []); }, - entityDisplay: (entityId, entityType) => - usePropertyEntityDisplay(entityId, entityType), + entityDisplay: (entityId, entityType) => { + const type = entityType(); + if (type === 'AGENT_SESSION') { + return agentSessionEntityDisplay(entityId); + } + return usePropertyEntityDisplay(entityId, () => type); + }, propertyDefinition: (propertyId) => { const definitions = useAllProperties(); return () => { @@ -118,3 +126,27 @@ function appActivityContext(): ActivityContext { }, }; } + +function agentSessionEntityDisplay(entityId: Accessor): EntityDisplay { + const previewWrapper = () => + useItemPreview(() => ({ + id: entityId(), + type: 'agent_session' as const, + }))[0]; + const preview = createMemo(() => previewWrapper()?.()); + return { + name: () => { + const item = preview(); + if (!item || item.loading) return 'Loading...'; + if (isAccessiblePreviewItem(item)) return item.name; + return 'Agent session'; + }, + icon: () => , + isLoading: () => { + const item = preview(); + return !item || item.loading; + }, + blockOrFileType: () => 'agent', + linkParams: () => undefined, + }; +} diff --git a/apps/web/src/features/activity/core/event.ts b/apps/web/src/features/activity/core/event.ts index 8a0ec0d5104..9666d177518 100644 --- a/apps/web/src/features/activity/core/event.ts +++ b/apps/web/src/features/activity/core/event.ts @@ -20,6 +20,7 @@ export type ActivityEntityType = | 'email-thread' | 'channel' | 'user' + | 'agent-session' | { kind: 'unsupported'; raw: string }; export type ActivityEvent = { @@ -54,9 +55,12 @@ export type PropertyEntityType = | 'CHANNEL' | 'USER'; -export function toPropertyEntityType( +/** Entity kinds the activity UI can resolve a name, icon, and link for. */ +export type ActivityDisplayEntityType = PropertyEntityType | 'AGENT_SESSION'; + +export function toDisplayEntityType( entityType: ActivityEntityType -): PropertyEntityType | undefined { +): ActivityDisplayEntityType | undefined { return match(entityType) .with({ kind: 'unsupported' }, () => undefined) .with('document', () => 'DOCUMENT' as const) @@ -65,5 +69,6 @@ export function toPropertyEntityType( .with('email-thread', () => 'THREAD' as const) .with('channel', () => 'CHANNEL' as const) .with('user', () => 'USER' as const) + .with('agent-session', () => 'AGENT_SESSION' as const) .exhaustive(); } diff --git a/apps/web/src/features/activity/primitives/entity-activity.test.ts b/apps/web/src/features/activity/primitives/entity-activity.test.ts index c52e0e8b631..0b6f3820847 100644 --- a/apps/web/src/features/activity/primitives/entity-activity.test.ts +++ b/apps/web/src/features/activity/primitives/entity-activity.test.ts @@ -13,7 +13,9 @@ afterEach(() => { for (const dispose of disposals.splice(0)) dispose(); }); -function setup(entityType: 'DOCUMENT' | 'USER' = 'DOCUMENT') { +function setup( + entityType: 'DOCUMENT' | 'USER' | 'AGENT_SESSION' = 'DOCUMENT' +) { const context = createMockActivityContext(); let state!: EntityActivityState; const dispose = createRoot((rootDispose) => { @@ -77,4 +79,9 @@ describe('createEntityActivityState', () => { expect(state.isEnabled()).toBe(false); expect(graphql.pending).toHaveLength(0); }); + + it('is enabled for agent sessions', () => { + const { state } = setup('AGENT_SESSION'); + expect(state.isEnabled()).toBe(true); + }); }); diff --git a/apps/web/src/features/activity/primitives/entity-activity.ts b/apps/web/src/features/activity/primitives/entity-activity.ts index 06ee636c36e..ebe1fb35cce 100644 --- a/apps/web/src/features/activity/primitives/entity-activity.ts +++ b/apps/web/src/features/activity/primitives/entity-activity.ts @@ -1,8 +1,10 @@ -import type { EntityType } from '@service-properties/generated/schemas/entityType'; import { type Accessor, createMemo } from 'solid-js'; import type { ActivityContext } from '../context/activity-context'; import type { ActivityEvent } from '../core/event'; -import { createEntityActivityQuery } from '../queries/entity-query'; +import { + createEntityActivityQuery, + type EntityActivityEntityType, +} from '../queries/entity-query'; export type EntityActivityView = | { t: 'loading' } @@ -22,7 +24,10 @@ export type EntityActivityState = { */ export function createEntityActivityState( context: Pick, - options: { entityId: Accessor; entityType: Accessor } + options: { + entityId: Accessor; + entityType: Accessor; + } ): EntityActivityState { const query = createEntityActivityQuery(context, { entityType: options.entityType, diff --git a/apps/web/src/features/activity/primitives/entity-opener.test.ts b/apps/web/src/features/activity/primitives/entity-opener.test.ts index 6946b12b99e..64d84c82911 100644 --- a/apps/web/src/features/activity/primitives/entity-opener.test.ts +++ b/apps/web/src/features/activity/primitives/entity-opener.test.ts @@ -60,6 +60,19 @@ describe('createEntityOpener', () => { expect(opener()).toBeUndefined(); }); + it('resolves agent sessions as a linkable entity', () => { + const onOpen = vi.fn(); + const opener = setup(createMockActivityContext(), 'agent-session', onOpen); + expect(opener()?.display.name()).toBe('Entity doc-1'); + opener()?.handlers?.onClick(click(false)); + expect(onOpen).toHaveBeenCalledWith({ + block: 'md', + id: 'doc-1', + params: undefined, + newSplit: false, + }); + }); + it('does nothing when the display has no block mapping', () => { const onOpen = vi.fn(); const context = createMockActivityContext({ diff --git a/apps/web/src/features/activity/primitives/entity-opener.ts b/apps/web/src/features/activity/primitives/entity-opener.ts index 5392e913c35..8c4c1275f2f 100644 --- a/apps/web/src/features/activity/primitives/entity-opener.ts +++ b/apps/web/src/features/activity/primitives/entity-opener.ts @@ -5,7 +5,7 @@ import type { EntityDisplay, OpenEntityTarget, } from '../context/activity-context'; -import { type ActivityEntityType, toPropertyEntityType } from '../core/event'; +import { type ActivityEntityType, toDisplayEntityType } from '../core/event'; export type EntityOpener = { display: EntityDisplay; @@ -28,7 +28,7 @@ export function createEntityOpener( onOpen: ((target: OpenEntityTarget) => void) | undefined ): Accessor { return createMemo(() => { - const type = toPropertyEntityType(entityType()); + const type = toDisplayEntityType(entityType()); if (!type) return undefined; const display = context.entityDisplay(entityId, () => type); if (!onOpen) return { display }; diff --git a/apps/web/src/features/activity/queries/decode.test.ts b/apps/web/src/features/activity/queries/decode.test.ts index 5518a2e8722..309930e1028 100644 --- a/apps/web/src/features/activity/queries/decode.test.ts +++ b/apps/web/src/features/activity/queries/decode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { decodeActivityEvent } from './decode'; import { + agentSessionCreatedEvent, callStartedEvent, createdEvent, deletedEvent, @@ -55,6 +56,9 @@ describe('decodeActivityEvent', () => { expect(decodeActivityEvent(createdEvent).entityType).toBe('document'); expect(decodeActivityEvent(messagedEvent).entityType).toBe('channel'); expect(decodeActivityEvent(sentEvent).entityType).toBe('email-thread'); + expect(decodeActivityEvent(agentSessionCreatedEvent).entityType).toBe( + 'agent-session' + ); }); it('keeps the unknown-action tag so describeAction can humanize it', () => { diff --git a/apps/web/src/features/activity/queries/decode.ts b/apps/web/src/features/activity/queries/decode.ts index 61b2abb4158..e1998b7da2d 100644 --- a/apps/web/src/features/activity/queries/decode.ts +++ b/apps/web/src/features/activity/queries/decode.ts @@ -51,6 +51,7 @@ export function decodeEntityType( .with('EMAIL_THREAD', () => 'email-thread' as const) .with('CHANNEL', () => 'channel' as const) .with('USER', () => 'user' as const) + .with('AGENT_SESSION', () => 'agent-session' as const) .otherwise((raw) => ({ kind: 'unsupported' as const, raw })); } diff --git a/apps/web/src/features/activity/queries/entity-query.test.ts b/apps/web/src/features/activity/queries/entity-query.test.ts index 46e06e0639e..27bbcddfcfc 100644 --- a/apps/web/src/features/activity/queries/entity-query.test.ts +++ b/apps/web/src/features/activity/queries/entity-query.test.ts @@ -1,8 +1,31 @@ import { describe, expect, it } from 'vitest'; import { soupPage } from '../tests/wire'; +import { buildEntityActivityInput } from './entity-query'; import { createdEvent } from './fixtures'; import { selectEntityActivity } from './select-entity-activity'; +const NIL = '00000000-0000-0000-0000-000000000000'; + +describe('buildEntityActivityInput', () => { + it('opts agent sessions into Soup by id', () => { + expect(buildEntityActivityInput('AGENT_SESSION', 'session-1')).toMatchObject( + { + initial: { + limit: 1, + filters: { + documentFilter: { literal: { id: NIL } }, + agentSessionFilter: { literal: { id: 'session-1' } }, + }, + }, + } + ); + }); + + it('does not issue a lookup for users', () => { + expect(buildEntityActivityInput('USER', 'user-1')).toBeUndefined(); + }); +}); + describe('selectEntityActivity', () => { it('returns entity-missing when the soup page omits the entity', () => { expect(selectEntityActivity(soupPage([]), 'doc-1')).toEqual({ diff --git a/apps/web/src/features/activity/queries/entity-query.ts b/apps/web/src/features/activity/queries/entity-query.ts index 73c11f63bc8..4df5eb9819c 100644 --- a/apps/web/src/features/activity/queries/entity-query.ts +++ b/apps/web/src/features/activity/queries/entity-query.ts @@ -1,10 +1,11 @@ import { createUrqlQuery } from '@app/lib/urql-solid/create-urql-query'; -import { buildEntityPropertiesInput } from '@queries/properties/graphql/entity'; +import { buildGraphqlEntitySoupInput } from '@queries/soup/graphql/entity-input'; import type { EntityType } from '@service-properties/generated/schemas/entityType'; import { EntityActivityDocument, type EntityActivityQuery, type EntityActivityQueryVariables, + type SoupInput, } from '@service-storage/graphql/generated/graphql'; import { type Accessor, createMemo } from 'solid-js'; import type { ActivityContext } from '../context/activity-context'; @@ -16,18 +17,44 @@ import { /** Rows requested for a side-panel activity preview. */ export const ENTITY_ACTIVITY_PREVIEW_LIMIT = 20; +const NIL_ENTITY_ID = '00000000-0000-0000-0000-000000000000'; + +/** Soup-backed entity kinds the entity activity query can address. */ +export type EntityActivityEntityType = EntityType | 'AGENT_SESSION'; + type EntityActivityQueryOptions = { - entityType: Accessor; + entityType: Accessor; entityId: Accessor; enabled: Accessor; limit?: number; }; +/** Exact Soup input for one entity's activity edge. */ +export function buildEntityActivityInput( + entityType: EntityActivityEntityType, + entityId: string +): SoupInput | undefined { + if (entityType === 'AGENT_SESSION') { + const base = buildGraphqlEntitySoupInput('DOCUMENT', NIL_ENTITY_ID); + if (!base || !('initial' in base) || !base.initial) return undefined; + return { + initial: { + ...base.initial, + filters: { + ...base.initial.filters, + agentSessionFilter: { literal: { id: entityId } }, + }, + }, + }; + } + return buildGraphqlEntitySoupInput(entityType, entityId); +} + /** * Live urql query for one Soup-backed entity's recent activity, newest - * first. Reuses the exact-single-entity Soup input builder from the - * properties query, so the same entity types are supported (everything but - * `USER`) and the query pauses (`isEnabled` false) for the rest. + * first. Reuses the exact-single-entity Soup input builder so the same + * entity types are supported (everything but `USER`, plus agent sessions) + * and the query pauses (`isEnabled` false) for the rest. */ export function createEntityActivityQuery( context: Pick, @@ -36,7 +63,7 @@ export function createEntityActivityQuery( const input = createMemo(() => { const entityId = options.entityId(); if (!options.enabled() || entityId.length === 0) return undefined; - return buildEntityPropertiesInput(options.entityType(), entityId); + return buildEntityActivityInput(options.entityType(), entityId); }); const result = createUrqlQuery< diff --git a/apps/web/src/features/activity/queries/fixtures.ts b/apps/web/src/features/activity/queries/fixtures.ts index 90a8065453d..5882e6639a1 100644 --- a/apps/web/src/features/activity/queries/fixtures.ts +++ b/apps/web/src/features/activity/queries/fixtures.ts @@ -110,3 +110,11 @@ export const unsupportedEntityEvent: ActivityEventFieldsFragment = { entityId: 'team-1', action: { __typename: 'GraphqlActivityCreated' }, }; + +export const agentSessionCreatedEvent: ActivityEventFieldsFragment = { + ...BASE, + id: 'evt-13', + entityType: 'AGENT_SESSION', + entityId: 'session-1', + action: { __typename: 'GraphqlActivityCreated' }, +}; diff --git a/apps/web/src/features/activity/views/entity-activity-section.tsx b/apps/web/src/features/activity/views/entity-activity-section.tsx index e7d47ebf998..58de6b5d9a3 100644 --- a/apps/web/src/features/activity/views/entity-activity-section.tsx +++ b/apps/web/src/features/activity/views/entity-activity-section.tsx @@ -1,6 +1,5 @@ import { SidePanel } from '@components/app/side-panel/SidePanel'; import CaretUpDownIcon from '@phosphor/caret-up-down.svg'; -import type { EntityType } from '@service-properties/generated/schemas/entityType'; import { cn } from '@ui'; import { createMemo, @@ -24,6 +23,7 @@ import type { RailEnds } from '../core/feed-rows'; import { foldPanel } from '../core/fold-panel'; import { createActorName } from '../primitives/actor-name'; import { createEntityActivityState } from '../primitives/entity-activity'; +import type { EntityActivityEntityType } from '../queries/entity-query'; import { useEntityActivityFlag } from '../use-entity-activity-flag'; /** Newest entries shown before the section folds behind its toggle. */ @@ -31,7 +31,7 @@ const PANEL_HEAD_LIMIT = 3; export interface EntityActivitySectionProps { entityId: string; - entityType: EntityType; + entityType: EntityActivityEntityType; order?: number; } diff --git a/apps/web/src/features/block-agent/component/sidepanel/AgentSidePanelSections.tsx b/apps/web/src/features/block-agent/component/sidepanel/AgentSidePanelSections.tsx index 9a5e711a679..a79e3070702 100644 --- a/apps/web/src/features/block-agent/component/sidepanel/AgentSidePanelSections.tsx +++ b/apps/web/src/features/block-agent/component/sidepanel/AgentSidePanelSections.tsx @@ -5,11 +5,11 @@ * `` elements that self-register into the enclosing * ``. * - * Everything rendered here is derived from state the block already holds — - * the session record, the fold's metadata, and pure summaries over the - * folded transcript (`state/session-summary.ts`). + * The Details/Plan/files/session-stats sections are derived from state the + * block already holds. Activity is the shared entity-activity timeline. */ +import { EntityActivitySectionConditional } from '@app/features/activity/views/entity-activity-section'; import { SidePanel, useSidePanel } from '@components/app/side-panel'; import { useSplitPanel } from '@components/app/split-layout/layoutUtils'; import { registerHotkey } from '@core/hotkey/hotkeys'; @@ -33,7 +33,8 @@ import { import { harnessTitle } from '../AgentSplitHeader'; export function AgentSidePanelSections() { - const { session, bot, metadata, messages, status } = useAgentSession(); + const { sessionId, session, bot, metadata, messages, status } = + useAgentSession(); const plan = createMemo(() => latestPlan(messages())); const files = createMemo(() => changedFiles(messages())); @@ -180,12 +181,17 @@ export function AgentSidePanelSections() { item.count > 0)}> - +
+ ); } diff --git a/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.test.tsx b/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.test.tsx index c6a75b76913..74af6d2cfe0 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.test.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.test.tsx @@ -137,6 +137,22 @@ describe('ReadActivity renderer', () => { }); it('renders an unsupported entity type without leaking the raw id', () => { + const { container } = renderTool([ + { + actorId: 'macro|user@example.com', + entityType: 'team', + entityId: 'team-raw-id', + action: { type: 'created' }, + occurredAt: '2026-08-19T17:30:00Z', + }, + ]); + + expect(screen.getByRole('button', { name: /1 activity/i })).toBeTruthy(); + expect(container.textContent).toContain('Created'); + expect(container.textContent).not.toContain('team-raw-id'); + }); + + it('renders an agent session as a named entity', () => { const { container } = renderTool([ { actorId: 'macro|user@example.com', @@ -148,7 +164,7 @@ describe('ReadActivity renderer', () => { ]); expect(screen.getByRole('button', { name: /1 activity/i })).toBeTruthy(); - expect(container.textContent).toContain('Created'); + expect(container.textContent).toContain('Launch plan'); expect(container.textContent).not.toContain('agent-session-raw-id'); }); diff --git a/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.tsx b/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.tsx index 15abe23d068..c98b482f61e 100644 --- a/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.tsx +++ b/apps/web/src/lib/core/component/AI/component/tool/ReadActivity.tsx @@ -28,6 +28,7 @@ function decodeToolEntityType(raw: string): ActivityEntityType { .with('email_thread', () => 'email-thread' as const) .with('channel', () => 'channel' as const) .with('user', () => 'user' as const) + .with('agent_session', () => 'agent-session' as const) .otherwise((value) => ({ kind: 'unsupported' as const, raw: value })); } diff --git a/docs/AGENT_GUIDE/ai-chat.md b/docs/AGENT_GUIDE/ai-chat.md index 4ccbf1376ce..c31b9bd47f7 100644 --- a/docs/AGENT_GUIDE/ai-chat.md +++ b/docs/AGENT_GUIDE/ai-chat.md @@ -146,6 +146,10 @@ to open the title menu (caret), then **Rename** — that opens the generic entit rename dialog. Do not expect a tap on the name itself to start an inline edit. +The side panel (toggle with `]`) includes Details, Plan, Changed files, This +session (tool-call counts for the current transcript), and Activity (the same +glyph-rail history as `/app/component/activity`: created, prompted, renamed). + ### Sharing a session Saved sessions have **Share** and **Copy Share Link** in the desktop header; From 2eea4d7536f82f000c16b8acc325bbeb8d2cdab5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 21:58:58 +0000 Subject: [PATCH 3/5] style(web): format agent-session activity tests Co-authored-by: Wolf Mermelstein --- .../primitives/entity-activity.test.ts | 4 +--- .../activity/queries/entity-query.test.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/web/src/features/activity/primitives/entity-activity.test.ts b/apps/web/src/features/activity/primitives/entity-activity.test.ts index 0b6f3820847..9e72982c8e8 100644 --- a/apps/web/src/features/activity/primitives/entity-activity.test.ts +++ b/apps/web/src/features/activity/primitives/entity-activity.test.ts @@ -13,9 +13,7 @@ afterEach(() => { for (const dispose of disposals.splice(0)) dispose(); }); -function setup( - entityType: 'DOCUMENT' | 'USER' | 'AGENT_SESSION' = 'DOCUMENT' -) { +function setup(entityType: 'DOCUMENT' | 'USER' | 'AGENT_SESSION' = 'DOCUMENT') { const context = createMockActivityContext(); let state!: EntityActivityState; const dispose = createRoot((rootDispose) => { diff --git a/apps/web/src/features/activity/queries/entity-query.test.ts b/apps/web/src/features/activity/queries/entity-query.test.ts index 27bbcddfcfc..b8e357b29b6 100644 --- a/apps/web/src/features/activity/queries/entity-query.test.ts +++ b/apps/web/src/features/activity/queries/entity-query.test.ts @@ -8,17 +8,17 @@ const NIL = '00000000-0000-0000-0000-000000000000'; describe('buildEntityActivityInput', () => { it('opts agent sessions into Soup by id', () => { - expect(buildEntityActivityInput('AGENT_SESSION', 'session-1')).toMatchObject( - { - initial: { - limit: 1, - filters: { - documentFilter: { literal: { id: NIL } }, - agentSessionFilter: { literal: { id: 'session-1' } }, - }, + expect( + buildEntityActivityInput('AGENT_SESSION', 'session-1') + ).toMatchObject({ + initial: { + limit: 1, + filters: { + documentFilter: { literal: { id: NIL } }, + agentSessionFilter: { literal: { id: 'session-1' } }, }, - } - ); + }, + }); }); it('does not issue a lookup for users', () => { From 66efd07acc0f9b7d2324fa359a66b399a0758107 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 22:34:23 +0000 Subject: [PATCH 4/5] docs(agent-guide): match agent session activity copy to the feed Prompts reuse Messaged, so the rail reads "sent a message" rather than "prompted". Co-authored-by: Wolf Mermelstein --- docs/AGENT_GUIDE/ai-chat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AGENT_GUIDE/ai-chat.md b/docs/AGENT_GUIDE/ai-chat.md index c31b9bd47f7..0bcb77e613e 100644 --- a/docs/AGENT_GUIDE/ai-chat.md +++ b/docs/AGENT_GUIDE/ai-chat.md @@ -148,7 +148,7 @@ an inline edit. The side panel (toggle with `]`) includes Details, Plan, Changed files, This session (tool-call counts for the current transcript), and Activity (the same -glyph-rail history as `/app/component/activity`: created, prompted, renamed). +glyph-rail history as `/app/component/activity`: created, sent a message, edited). ### Sharing a session From 90b8dd21259e6e4582fce53c1b4534fb65b11841 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 18:23:25 +0000 Subject: [PATCH 5/5] fix(activity): classify agent-session Mentioned as ignored ingest Main added Mentioned and SessionIdentity.audience after this branch opened. CI merges with main, so the exhaustive ingest match failed to compile and the activity fixture was missing audience. Mentions notify named users; TurnStarted already records the prompt as Messaged. Also drop the duplicate agent_session Cargo.toml entry that the merge left in document_storage_service (main already depends on it). Co-authored-by: Wolf Mermelstein --- crates/agent_session/src/domain/activity.rs | 2 ++ .../agent_session/src/domain/activity/test.rs | 20 ++++++++++++++++--- services/document_storage_service/Cargo.toml | 1 - 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/agent_session/src/domain/activity.rs b/crates/agent_session/src/domain/activity.rs index f4102c47984..8623b4f4138 100644 --- a/crates/agent_session/src/domain/activity.rs +++ b/crates/agent_session/src/domain/activity.rs @@ -90,6 +90,8 @@ impl ActivitySource for AgentSessionLifecycleEvent { AgentSessionLifecycleEvent::Deleted(_) => { Ingest::Purge(vec![(EntityType::AgentSession, session_id)]) } + // Mentions notify named users; the prompt is already Messaged via TurnStarted. + AgentSessionLifecycleEvent::Mentioned(_) => Ingest::Ignore, AgentSessionLifecycleEvent::TurnEnded(_) | AgentSessionLifecycleEvent::Settled(_) | AgentSessionLifecycleEvent::WaitingForInput(_) diff --git a/crates/agent_session/src/domain/activity/test.rs b/crates/agent_session/src/domain/activity/test.rs index ce88e1ee7aa..a9abac999de 100644 --- a/crates/agent_session/src/domain/activity/test.rs +++ b/crates/agent_session/src/domain/activity/test.rs @@ -7,9 +7,9 @@ use macro_event_broker::Event; use super::*; use crate::domain::events::{ - InputReceivedMetadata, SessionDeletedMetadata, SessionIdentity, SessionOpenedMetadata, - SessionRenamedMetadata, SessionSettledMetadata, SessionStoppedMetadata, TurnEndedMetadata, - TurnStartedMetadata, WaitingForInputMetadata, + InputReceivedMetadata, SessionDeletedMetadata, SessionIdentity, SessionMentionedMetadata, + SessionOpenedMetadata, SessionRenamedMetadata, SessionSettledMetadata, SessionStoppedMetadata, + TurnEndedMetadata, TurnStartedMetadata, WaitingForInputMetadata, }; use crate::domain::model::{AgentSessionId, TurnId}; @@ -29,6 +29,7 @@ fn identity() -> SessionIdentity { bot_name: "Macro Coder".to_owned(), owner_id: user("macro|owner@macro.com"), origin: None, + audience: Vec::new(), } } @@ -120,6 +121,19 @@ fn deleted_purges_the_session() { ); } +#[test] +fn mentioned_is_dropped() { + let event = envelope(AgentSessionLifecycleEvent::Mentioned( + SessionMentionedMetadata { + identity: identity(), + action_id: agent_runtime_protocol::domain::action::AgentActionId::mint(), + mentioned_by: Some(user("macro|teo@macro.com")), + mentioned: vec![user("macro|reviewer@macro.com")], + }, + )); + assert_eq!(event.event.ingest(event.event_id), Ingest::Ignore); +} + #[test] fn runtime_consequences_are_dropped() { let identity = identity(); diff --git a/services/document_storage_service/Cargo.toml b/services/document_storage_service/Cargo.toml index cafb7e7b532..14c459c1411 100644 --- a/services/document_storage_service/Cargo.toml +++ b/services/document_storage_service/Cargo.toml @@ -29,7 +29,6 @@ location_check = [] [dependencies] agent_session = { path = "../../crates/agent_session", features = ["outbound"] } activity = { path = "../../crates/activity" } -agent_session = { path = "../../crates/agent_session", default-features = false } ai_tools = { path = "../../crates/ai_tools" } ai_usage = { path = "../../crates/ai_usage" } analytics_client = { path = "../../crates/analytics_client" }