diff --git a/Cargo.lock b/Cargo.lock index 8f403d73313..64b6a0063f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,7 @@ dependencies = [ name = "agent_session" version = "0.1.0" dependencies = [ + "activity", "agent", "agent-client-protocol", "agent_fold", 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..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,7 +13,7 @@ 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 +77,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..b8e357b29b6 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/crates/agent_session/Cargo.toml b/crates/agent_session/Cargo.toml index 33a4d47928f..f4ecbf8c669 100644 --- a/crates/agent_session/Cargo.toml +++ b/crates/agent_session/Cargo.toml @@ -36,11 +36,11 @@ 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 } lazy-regex = { workspace = true } ai_toolset = { path = "../ai_toolset", optional = true } async-trait = { workspace = true, optional = true } schemars = { workspace = true, optional = true } - 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..8623b4f4138 --- /dev/null +++ b/crates/agent_session/src/domain/activity.rs @@ -0,0 +1,102 @@ +//! 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)]) + } + // Mentions notify named users; the prompt is already Messaged via TurnStarted. + AgentSessionLifecycleEvent::Mentioned(_) => Ingest::Ignore, + 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..a9abac999de --- /dev/null +++ b/crates/agent_session/src/domain/activity/test.rs @@ -0,0 +1,178 @@ +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, SessionMentionedMetadata, + 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, + audience: Vec::new(), + } +} + +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 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(); + 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 08faba65661..ad404d5373d 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/docs/AGENT_GUIDE/ai-chat.md b/docs/AGENT_GUIDE/ai-chat.md index 0e5faf02391..4e4f924dbc5 100644 --- a/docs/AGENT_GUIDE/ai-chat.md +++ b/docs/AGENT_GUIDE/ai-chat.md @@ -220,6 +220,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, sent a message, edited). + ### Sharing a session Saved sessions have **Share** and **Copy Share Link** in the desktop header; 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()), } }