Report when AI agent suggestions are shown to the user.

This change introduces a new reporting interaction type,
AI_AGENT_SUGGESTIONS_SHOWN, and an associated AiAgentEventDetails type.
The gemini-message component now reports this interaction when an AI
agent's response is fully rendered.

Google-Bug-Id: b/489833285
Release-Notes: skip
Change-Id: Ib9f9f5f2b16681425104d39cab43e5181c2ef2df
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index f95148b..bae1c58 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -169,4 +169,21 @@
   COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
 
   COPY_AI_PROMPT = 'copy-ai-prompt',
+
+  // AI agent suggests comments/fixes to user.
+  AI_AGENT_SUGGESTIONS_SHOWN = 'ai-agent-suggestions-shown',
 }
+
+/**
+ * EventDetails to be passed to the reportInteraction method for AI agent
+ * interactions.
+ */
+export type AiAgentEventDetails = {
+  host: string;
+  agentId: string;
+  conversationId: string;
+  // Each agent response in a conversation is a turn.
+  turnIndex: number;
+  // commentCount is 0 if agent ran but didn't suggest any comments/fixes.
+  commentCount: number;
+};
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
index 5f568ff..8f0dbee 100644
--- a/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
+++ b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
@@ -13,10 +13,11 @@
 import './references-dropdown';
 import './message-actions';
 
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {when} from 'lit/directives/when.js';
 
+import {AiAgentEventDetails, Interaction} from '../../constants/reporting';
 import {changeModelToken} from '../../models/change/change-model';
 import {
   filesModelToken,
@@ -31,6 +32,7 @@
 } from '../../models/chat/chat-model';
 import {commentsModelToken} from '../../models/comments/comments-model';
 import {resolve} from '../../models/dependency';
+import {getAppContext} from '../../services/app-context';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {
   compareComments,
@@ -64,6 +66,10 @@
 
   @state() latestPatchNum?: PatchSetNumber;
 
+  @state() private conversationId?: string;
+
+  private reportedSuggestionsShown = false;
+
   private readonly getChatModel = resolve(this, chatModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
@@ -72,6 +78,8 @@
 
   private readonly getFilesModel = resolve(this, filesModelToken);
 
+  private readonly reportingService = getAppContext().reportingService;
+
   static override styles = [
     materialStyles,
     css`
@@ -212,6 +220,11 @@
       () => this.getChangeModel().latestPatchNum$,
       x => (this.latestPatchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getChatModel().conversationId$,
+      x => (this.conversationId = x)
+    );
   }
 
   private async onAddAsComment(part: CreateCommentPart) {
@@ -242,6 +255,17 @@
     fire(this, 'open-diff-in-change-view', {path, lineNum});
   }
 
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('turns') && !this.reportedSuggestionsShown) {
+      if (
+        this.turnIndex < this.turns.length &&
+        this.message()?.responseComplete
+      ) {
+        this.reportSuggestionsShown();
+      }
+    }
+  }
+
   override render() {
     if (this.turnIndex >= this.turns.length) return;
     const message = this.message();
@@ -405,10 +429,9 @@
   }
 
   private sortedComments() {
-    return this.message()
-      .responseParts.filter(
-        part => part.type === ResponsePartType.CREATE_COMMENT
-      )
+    const parts = this.message()?.responseParts ?? [];
+    return parts
+      .filter(part => part.type === ResponsePartType.CREATE_COMMENT)
       .sort((p1, p2) => {
         const c1 = {...createNew(p1.comment.message), ...p1.comment};
         const c2 = {...createNew(p2.comment.message), ...p2.comment};
@@ -419,9 +442,27 @@
   private turnId() {
     return {
       turnIndex: this.turnIndex,
-      regenerationIndex: this.message().regenerationIndex,
+      regenerationIndex: this.message()?.regenerationIndex ?? 0,
     };
   }
+
+  private reportSuggestionsShown() {
+    if (!this.conversationId) return;
+    this.reportedSuggestionsShown = true;
+
+    const agentId = this.turns[this.turnIndex]?.userMessage?.actionId ?? '';
+    const details: AiAgentEventDetails = {
+      host: window.location.host,
+      agentId,
+      conversationId: this.conversationId,
+      turnIndex: this.turnIndex,
+      commentCount: this.sortedComments().length,
+    };
+    this.reportingService.reportInteraction(
+      Interaction.AI_AGENT_SUGGESTIONS_SHOWN,
+      details
+    );
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
index dcf2904..57401f3 100644
--- a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
@@ -12,6 +12,7 @@
   CreateCommentPart,
   GeminiMessage as GeminiMessageModel,
   ResponsePartType,
+  TextPart,
   Turn,
   UserType,
 } from '../../models/chat/chat-model';
@@ -25,6 +26,8 @@
 import {chatProvider, createChange} from '../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../types/types';
 import {CommentsModel} from '../../models/comments/comments-model';
+import {AiAgentEventDetails, Interaction} from '../../constants/reporting';
+import {getAppContext} from '../../services/app-context';
 
 suite('gemini-message tests', () => {
   let element: GeminiMessage;
@@ -71,6 +74,22 @@
     };
   }
 
+  const RESPONSE_TEXT: TextPart = {
+    id: 0,
+    type: ResponsePartType.TEXT,
+    content: 'test message',
+  };
+  const RESPONSE_CREATE_COMMENT: CreateCommentPart = {
+    id: 1,
+    type: ResponsePartType.CREATE_COMMENT,
+    content: 'test comment',
+    commentCreationId: 'test-id',
+    comment: {
+      message: 'test comment',
+      path: '/test/path',
+    },
+  };
+
   test('renders thinking', async () => {
     const turn = createTurn({responseComplete: false});
     chatModel.updateState({...chatModel.getState(), turns: [turn]});
@@ -113,9 +132,7 @@
   test('renders text response', async () => {
     const turn = createTurn({
       responseComplete: true,
-      responseParts: [
-        {id: 0, type: ResponsePartType.TEXT, content: 'test message'},
-      ],
+      responseParts: [RESPONSE_TEXT],
     });
     chatModel.updateState({...chatModel.getState(), turns: [turn]});
     await element.updateComplete;
@@ -151,19 +168,9 @@
   });
 
   test('renders suggested comment', async () => {
-    const comment: CreateCommentPart = {
-      id: 1,
-      type: ResponsePartType.CREATE_COMMENT,
-      content: 'test comment',
-      commentCreationId: 'test-id',
-      comment: {
-        message: 'test comment',
-        path: '/test/path',
-      },
-    };
     const turn = createTurn({
       responseComplete: true,
-      responseParts: [comment],
+      responseParts: [RESPONSE_CREATE_COMMENT],
     });
     chatModel.updateState({...chatModel.getState(), turns: [turn]});
     await element.updateComplete;
@@ -190,9 +197,7 @@
   test('renders citations', async () => {
     const turn = createTurn({
       responseComplete: true,
-      responseParts: [
-        {id: 0, type: ResponsePartType.TEXT, content: 'test message'},
-      ],
+      responseParts: [RESPONSE_TEXT],
       citations: ['http://example.com'],
     });
     chatModel.updateState({...chatModel.getState(), turns: [turn]});
@@ -213,9 +218,7 @@
     ];
     const turn = createTurn({
       responseComplete: true,
-      responseParts: [
-        {id: 0, type: ResponsePartType.TEXT, content: 'test message'},
-      ],
+      responseParts: [RESPONSE_TEXT],
       references,
     });
     chatModel.updateState({...chatModel.getState(), turns: [turn]});
@@ -227,4 +230,40 @@
     );
     assert.isOk(referencesDropdown);
   });
+
+  test('reports AI_AGENT_SUGGESTIONS_SHOWN interaction', async () => {
+    chatModel.updateState({
+      ...chatModel.getState(),
+      id: 'test-conversation-id',
+      selectedModelId: 'gemini-model-id',
+    });
+
+    const reportStub = sinon.stub(
+      getAppContext().reportingService,
+      'reportInteraction'
+    );
+
+    const turn = createTurn({
+      responseComplete: true,
+      responseParts: [RESPONSE_TEXT, RESPONSE_CREATE_COMMENT],
+    });
+    const updatedTurn = {
+      ...turn,
+      userMessage: {...turn.userMessage, actionId: 'custom-agent-id'},
+    };
+
+    chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]});
+    await element.updateComplete;
+
+    assert.isTrue(reportStub.calledOnce);
+    assert.equal(
+      reportStub.firstCall.args[0],
+      Interaction.AI_AGENT_SUGGESTIONS_SHOWN
+    );
+    const details = reportStub.firstCall.args[1] as AiAgentEventDetails;
+    assert.equal(details.host, window.location.host);
+    assert.equal(details.conversationId, 'test-conversation-id');
+    assert.equal(details.agentId, 'custom-agent-id');
+    assert.equal(details.commentCount, 1);
+  });
 });