{
const { userId, resourceId, projectId, chatTitle, rootNamespaceId, agenticAvailable } =
el.dataset;
+ const metadata = convertObjectPropsToCamelCase(JSON.parse(el.dataset.metadata));
const toggleEls = document.querySelectorAll('.js-tanuki-bot-chat-toggle');
if (toggleEls.length) {
@@ -51,6 +52,7 @@ export const initTanukiBotChatDrawer = () => {
chatTitle,
rootNamespaceId,
agenticAvailable: JSON.parse(agenticAvailable),
+ metadata,
},
});
},
diff --git a/ee/app/views/layouts/_tanuki_bot_chat.html.haml b/ee/app/views/layouts/_tanuki_bot_chat.html.haml
index 734c862e674c7c1985f389649749a38f40b3f4f6..4a5b065fb7d592defb076083fb417e658a8b65bc 100644
--- a/ee/app/views/layouts/_tanuki_bot_chat.html.haml
+++ b/ee/app/views/layouts/_tanuki_bot_chat.html.haml
@@ -18,4 +18,5 @@
project_id: project_id,
root_namespace_id: root_namespace_id,
chat_title: chat_title,
- agentic_available: is_agentic_available.to_s } }
+ agentic_available: is_agentic_available.to_s,
+ metadata: Gitlab::DuoWorkflow::Client.metadata(current_user).to_json } }
diff --git a/ee/spec/frontend/ai/components/duo_chat_logging_alert_spec.js b/ee/spec/frontend/ai/components/duo_chat_logging_alert_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..dbc670cf04a6ea68ef531688b0fd1bf239baa8e3
--- /dev/null
+++ b/ee/spec/frontend/ai/components/duo_chat_logging_alert_spec.js
@@ -0,0 +1,235 @@
+import { nextTick } from 'vue';
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
+import { DUO_AGENTIC_CHAT_LOGGING_ALERT } from 'ee/ai/constants';
+import DuoChatLoggingAlert from 'ee/ai/components/duo_chat_logging_alert.vue';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ getCookie: jest.fn(),
+ setCookie: jest.fn(),
+}));
+
+describe('DuoChatLoggingAlert', () => {
+ let wrapper;
+
+ const defaultProps = {
+ metadata: { isTeamMember: true, extendedLogging: true },
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(DuoChatLoggingAlert, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getCookie.mockReturnValue(null);
+ });
+
+ describe('Alert visibility', () => {
+ it('renders alert when user is team member, extended_logging is true, and alert is not dismissed', async () => {
+ createComponent();
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('does not render alert when user is not a team member', () => {
+ createComponent({
+ metadata: { is_team_member: false, extended_logging: true },
+ });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when extended_logging is false', () => {
+ createComponent({
+ metadata: { is_team_member: true, extended_logging: false },
+ });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when extended_logging is undefined', () => {
+ createComponent({
+ metadata: { is_team_member: true },
+ });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when user is team member but alert was dismissed', () => {
+ getCookie.mockReturnValue('true');
+ createComponent();
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when metadata is undefined', () => {
+ createComponent({ metadata: undefined });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when metadata is null', () => {
+ createComponent({ metadata: null });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render alert when metadata.is_team_member is undefined', () => {
+ createComponent({
+ metadata: { extended_logging: true },
+ });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('Alert properties', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders alert with correct props', () => {
+ const alert = findAlert();
+
+ expect(alert.props()).toMatchObject({
+ dismissible: true,
+ variant: 'warning',
+ title: 'GitLab Team Member Notice: Chat Logging Active',
+ primaryButtonLink:
+ 'https://internal.gitlab.com/handbook/product/ai-strategy/duo-logging/#logging-duo-chat-usage-by-gitlab-team-members-without-logging-their-names-or-user-id',
+ primaryButtonText: 'Learn more',
+ });
+ });
+
+ it('renders alert with correct test attributes', () => {
+ const alert = findAlert();
+
+ expect(alert.attributes('role')).toBe('alert');
+ expect(alert.attributes('data-testid')).toBe('duo-alert-logging');
+ });
+
+ it('renders alert content with all list items', () => {
+ const alertText = findAlert().html();
+
+ expect(alertText).toContain("What's logged:");
+ expect(alertText).toContain('Which interfaces are affected:');
+ expect(alertText).toContain('Privacy safeguards:');
+ expect(alertText).toContain('Purpose:');
+ expect(alertText).toContain('Customers are not affected:');
+ });
+
+ it('renders all GlSprintf components', () => {
+ const sprintfComponents = wrapper.findAllComponents(GlSprintf);
+ expect(sprintfComponents).toHaveLength(5);
+ });
+ });
+
+ describe('Cookie initialization on mount', () => {
+ it('reads cookie and sets isDismissed to true when cookie is "true"', () => {
+ getCookie.mockReturnValue('true');
+ createComponent();
+
+ expect(getCookie).toHaveBeenCalledWith(DUO_AGENTIC_CHAT_LOGGING_ALERT);
+ expect(wrapper.vm.isDismissed).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('reads cookie and sets isDismissed to false when cookie is null', async () => {
+ createComponent();
+ await nextTick();
+
+ expect(getCookie).toHaveBeenCalledWith(DUO_AGENTIC_CHAT_LOGGING_ALERT);
+ expect(wrapper.vm.isDismissed).toBe(false);
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('reads cookie and sets isDismissed to false when cookie is not "true"', async () => {
+ getCookie.mockReturnValue('false');
+ createComponent();
+ await nextTick();
+
+ expect(getCookie).toHaveBeenCalledWith(DUO_AGENTIC_CHAT_LOGGING_ALERT);
+ expect(wrapper.vm.isDismissed).toBe(false);
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('Alert dismissal', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('sets cookie and updates isDismissed when alert is dismissed', async () => {
+ const alert = findAlert();
+ expect(alert.exists()).toBe(true);
+
+ alert.vm.$emit('dismiss');
+ await nextTick();
+
+ expect(setCookie).toHaveBeenCalledWith(DUO_AGENTIC_CHAT_LOGGING_ALERT, true);
+ expect(wrapper.vm.isDismissed).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('calls onDismiss method when dismiss event is emitted', async () => {
+ const onDismissSpy = jest.spyOn(DuoChatLoggingAlert.methods, 'onDismiss');
+ createComponent();
+ await nextTick();
+ findAlert().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(onDismissSpy).toHaveBeenCalled();
+
+ onDismissSpy.mockRestore();
+ });
+ });
+
+ describe('hasAlert computed property', () => {
+ it.each`
+ isTeamMember | extendedLogging | isDismissed | expected | scenario
+ ${true} | ${true} | ${false} | ${true} | ${'shows when all conditions are met'}
+ ${false} | ${true} | ${false} | ${false} | ${'hides when not team member'}
+ ${true} | ${false} | ${false} | ${false} | ${'hides when extended_logging is false'}
+ ${true} | ${undefined} | ${false} | ${false} | ${'hides when extended_logging is undefined'}
+ ${true} | ${true} | ${true} | ${false} | ${'hides when dismissed'}
+ ${undefined} | ${true} | ${false} | ${false} | ${'hides when is_team_member is undefined'}
+ `('$scenario', async ({ isTeamMember, extendedLogging, isDismissed, expected }) => {
+ getCookie.mockReturnValue(isDismissed ? 'true' : null);
+ createComponent({
+ metadata: {
+ isTeamMember,
+ extendedLogging,
+ },
+ });
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(expected);
+ });
+ });
+
+ describe('Props validation', () => {
+ it('handles null metadata prop', () => {
+ createComponent({ metadata: null });
+
+ expect(() => wrapper.vm).not.toThrow();
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('handles undefined metadata prop', () => {
+ createComponent({ metadata: undefined });
+
+ expect(() => wrapper.vm).not.toThrow();
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+});
diff --git a/ee/spec/frontend/ai/duo_agentic_chat/components/app_spec.js b/ee/spec/frontend/ai/duo_agentic_chat/components/app_spec.js
index 7a3ed039f798cee148f4f6ed9572eff44b79252e..441a5f9e2cce9c04fb362237caf8a8eb3163b9fe 100644
--- a/ee/spec/frontend/ai/duo_agentic_chat/components/app_spec.js
+++ b/ee/spec/frontend/ai/duo_agentic_chat/components/app_spec.js
@@ -12,6 +12,7 @@ import { duoChatGlobalState } from '~/super_sidebar/constants';
import getUserWorkflows from 'ee/ai/graphql/get_user_workflow.query.graphql';
import getAiChatContextPresets from 'ee/ai/graphql/get_ai_chat_context_presets.query.graphql';
import DuoAgenticChatApp from 'ee/ai/duo_agentic_chat/components/app.vue';
+import DuoChatLoggingAlert from 'ee/ai/components/duo_chat_logging_alert.vue';
import { ApolloUtils } from 'ee/ai/duo_agentic_chat/utils/apollo_utils';
import { WorkflowUtils } from 'ee/ai/duo_agentic_chat/utils/workflow_utils';
import {
@@ -68,7 +69,11 @@ const MOCK_USER_MESSAGE = {
role: 'user',
requestId: `${MOCK_WORKFLOW_ID}-0`,
};
-const MOCK_AI_RESOURCE_DATA = JSON.stringify({ type: 'issue', id: 789, title: 'Test Issue' });
+const MOCK_AI_RESOURCE_DATA = JSON.stringify({
+ type: 'issue',
+ id: 789,
+ title: 'Test Issue',
+});
const MOCK_CONTEXT_PRESETS_RESPONSE = {
data: {
aiChatContextPresets: {
@@ -146,6 +151,7 @@ Vue.use(VueApollo);
jest.mock('~/lib/utils/common_utils', () => ({
getCookie: jest.fn(),
+ setCookie: jest.fn(),
}));
jest.mock('ee/ai/utils', () => {
@@ -173,6 +179,8 @@ describe('Duo Agentic Chat', () => {
const contextPresetsQueryHandlerMock = jest.fn().mockResolvedValue(MOCK_CONTEXT_PRESETS_RESPONSE);
const findDuoChat = () => wrapper.findComponent(AgenticDuoChat);
+ const findLoggingAlert = () => wrapper.findComponent(DuoChatLoggingAlert);
+ const findGlToggle = () => wrapper.findComponent(GlToggle);
const getLastSocketCall = () => {
const { calls } = createWebSocket.mock;
if (calls.length === 0) {
@@ -182,11 +190,13 @@ describe('Duo Agentic Chat', () => {
return socketCallbacks;
};
- const createComponent = ({
- initialState = {},
- propsData = { projectId: MOCK_PROJECT_ID, resourceId: MOCK_RESOURCE_ID },
- data = {},
- } = {}) => {
+ const defaultPropsData = {
+ projectId: MOCK_PROJECT_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: { is_team_member: true, extended_logging: true },
+ };
+
+ const createComponent = ({ initialState = {}, propsData = {} } = {}) => {
const store = new Vuex.Store({
actions: actionSpies,
state: {
@@ -208,9 +218,9 @@ describe('Duo Agentic Chat', () => {
wrapper = shallowMountExtended(DuoAgenticChatApp, {
store,
apolloProvider,
- propsData,
- data() {
- return data;
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
},
});
@@ -300,6 +310,35 @@ describe('Duo Agentic Chat', () => {
});
});
+ describe('DuoChatLoggingAlert Integration', () => {
+ beforeEach(() => {
+ duoChatGlobalState.isAgenticChatShown = true;
+ });
+
+ it('renders DuoChatLoggingAlert with correct props', () => {
+ const metadata = { is_team_member: true, extended_logging: true };
+ createComponent({ propsData: { metadata } });
+
+ expect(findLoggingAlert().exists()).toBe(true);
+ expect(findLoggingAlert().props('metadata')).toEqual(metadata);
+ });
+
+ it('renders DuoChatLoggingAlert in the subheader slot', () => {
+ createComponent();
+
+ expect(findDuoChat().vm.$slots.subheader).toBeDefined();
+ expect(findLoggingAlert().exists()).toBe(true);
+ });
+
+ it('does not render DuoChatLoggingAlert when Duo Chat is not shown', () => {
+ duoChatGlobalState.isAgenticChatShown = false;
+ createComponent();
+
+ expect(findDuoChat().exists()).toBe(false);
+ expect(findLoggingAlert().exists()).toBe(false);
+ });
+ });
+
describe('events handling', () => {
beforeEach(() => {
createComponent();
@@ -362,7 +401,10 @@ describe('Duo Agentic Chat', () => {
it('creates a new workflow when sending a prompt for the first time with namespaceId', async () => {
createComponent({
- propsData: { namespaceId: MOCK_NAMESPACE_ID, resourceId: MOCK_RESOURCE_ID },
+ propsData: {
+ projectId: null,
+ namespaceId: MOCK_NAMESPACE_ID,
+ },
});
duoChatGlobalState.isAgenticChatShown = true;
@@ -415,7 +457,12 @@ describe('Duo Agentic Chat', () => {
it('creates a new workflow when sending a prompt for the first time without projectId or namespaceId', async () => {
createComponent({
- propsData: { resourceId: MOCK_RESOURCE_ID },
+ propsData: {
+ projectId: undefined,
+ namespaceId: undefined,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: undefined,
+ },
});
duoChatGlobalState.isAgenticChatShown = true;
@@ -451,6 +498,16 @@ describe('Duo Agentic Chat', () => {
});
it('sends the correct start request to WebSocket when connected', async () => {
+ createComponent({
+ propsData: {
+ projectId: undefined,
+ namespaceId: undefined,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: undefined,
+ },
+ });
+
+ await waitForPromises(); // Wait for Apollo queries to complete
findDuoChat().vm.$emit('send-chat-prompt', MOCK_USER_MESSAGE.content);
await waitForPromises();
@@ -649,7 +706,7 @@ describe('Duo Agentic Chat', () => {
workflowDefinition: 'chat',
goal: '',
approval: { approval: {} },
- workflowMetadata: null,
+ workflowMetadata: { is_team_member: true, extended_logging: true },
additionalContext: expectedAdditionalContext,
},
};
@@ -743,7 +800,7 @@ describe('Duo Agentic Chat', () => {
approval: undefined,
rejection: { message: denyMessage },
},
- workflowMetadata: null,
+ workflowMetadata: { is_team_member: true, extended_logging: true },
additionalContext: expectedAdditionalContext,
},
};
@@ -831,7 +888,7 @@ describe('Duo Agentic Chat', () => {
approval: undefined,
rejection: { message: 'I do not approve this action' },
},
- workflowMetadata: null,
+ workflowMetadata: { is_team_member: true, extended_logging: true },
additionalContext: expectedAdditionalContext,
},
};
@@ -1051,8 +1108,6 @@ describe('Duo Agentic Chat', () => {
});
describe('duoAgenticModePreference toggle', () => {
- const findGlToggle = () => wrapper.findComponent(GlToggle);
-
beforeEach(() => {
duoChatGlobalState.isAgenticChatShown = true;
jest.clearAllMocks();
@@ -1102,7 +1157,9 @@ describe('Duo Agentic Chat', () => {
describe('@thread-selected', () => {
it('switches to selected thread and fetches workflow events', async () => {
const mockThread = { id: MOCK_WORKFLOW_ID };
- const mockParsedData = { checkpoint: { channel_values: { ui_chat_log: [] } } };
+ const mockParsedData = {
+ checkpoint: { channel_values: { ui_chat_log: [] } },
+ };
WorkflowUtils.parseWorkflowData.mockReturnValue(mockParsedData);
WorkflowUtils.transformChatMessages.mockReturnValue(MOCK_TRANSFORMED_MESSAGES);
@@ -1175,15 +1232,13 @@ describe('Duo Agentic Chat', () => {
});
describe('Agentic Mode Toggle', () => {
- const findGlToggle = () => wrapper.findComponent(GlToggle);
-
beforeEach(() => {
duoChatGlobalState.isAgenticChatShown = true;
getCookie.mockReturnValue('false');
createComponent();
});
- it('renders the GlToggle component in subheader', () => {
+ it('renders the GlToggle component in footer-controls', () => {
expect(findGlToggle().exists()).toBe(true);
});
diff --git a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js
index 3bbcb01df7fe4b6893e0756213a7b26b32f4aa44..df722676248877647c8d8a756e2cda3a7bb4da92 100644
--- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js
+++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js
@@ -8,6 +8,7 @@ import { GlToggle } from '@gitlab/ui';
import { sendDuoChatCommand, setAgenticMode } from 'ee/ai/utils';
import TanukiBotChatApp from 'ee/ai/tanuki_bot/components/app.vue';
import DuoChatCallout from 'ee/ai/components/global_callout/duo_chat_callout.vue';
+import DuoChatLoggingAlert from 'ee/ai/components/duo_chat_logging_alert.vue';
import TanukiBotSubscriptions from 'ee/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue';
import {
GENIE_CHAT_RESET_MESSAGE,
@@ -116,6 +117,7 @@ describeSkipVue3(skipReason, () => {
const findCallout = () => wrapper.findComponent(DuoChatCallout);
const findSubscriptions = () => wrapper.findComponent(TanukiBotSubscriptions);
+ const findLoggingAlert = () => wrapper.findComponent(DuoChatLoggingAlert);
const createComponent = ({
initialState = {},
@@ -294,6 +296,41 @@ describeSkipVue3(skipReason, () => {
});
});
+ describe('DuoChatLoggingAlert Integration', () => {
+ beforeEach(() => {
+ duoChatGlobalState.isShown = true;
+ });
+
+ it('renders DuoChatLoggingAlert with correct props', () => {
+ const metadata = { is_team_member: true, extended_logging: true };
+ createComponent({
+ propsData: {
+ userId: MOCK_USER_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata,
+ },
+ });
+
+ expect(findLoggingAlert().exists()).toBe(true);
+ expect(findLoggingAlert().props('metadata')).toEqual(metadata);
+ });
+
+ it('renders DuoChatLoggingAlert in the subheader slot', () => {
+ createComponent();
+
+ expect(findDuoChat().vm.$slots.subheader).toBeDefined();
+ expect(findLoggingAlert().exists()).toBe(true);
+ });
+
+ it('does not render DuoChatLoggingAlert when Duo Chat is not shown', () => {
+ duoChatGlobalState.isShown = false;
+ createComponent();
+
+ expect(findDuoChat().exists()).toBe(false);
+ expect(findLoggingAlert().exists()).toBe(false);
+ });
+ });
+
describe('contextPresets', () => {
beforeEach(() => {
duoChatGlobalState.isShown = true;
@@ -1166,7 +1203,7 @@ describeSkipVue3(skipReason, () => {
it('passes chatTitle prop to DuoChat component', async () => {
const chatTitle = 'Custom Chat Title';
- createComponent({ propsData: { chatTitle } });
+ createComponent({ propsData: { userId: MOCK_USER_ID, chatTitle } });
await nextTick();
expect(findDuoChat().props('title')).toBe(chatTitle);
});
@@ -1179,7 +1216,7 @@ describeSkipVue3(skipReason, () => {
it('updates DuoChat title when chatTitle prop changes', async () => {
const localWrapper = shallowMountExtended(TanukiBotChatApp, {
- propsData: { chatTitle: 'Initial Title' },
+ propsData: { userId: MOCK_USER_ID, chatTitle: 'Initial Title' },
store: new Vuex.Store({ actions: actionSpies }),
apolloProvider: createMockApollo([]),
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 69f630e377bba2bbd94282e3147053876ba3d82e..4f74709efa045108395cad5fe3e9393ad2fa58b5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -636,6 +636,21 @@ msgstr ""
msgid "%{author} has created a merge request that you can approve."
msgstr ""
+msgid "%{bStart}Customers are not affected:%{bEnd} we never log customer usage of Duo Chat (unless specifically requested by the customer)"
+msgstr ""
+
+msgid "%{bStart}Privacy safeguards:%{bEnd} Your name and user ID are not logged as structured fields"
+msgstr ""
+
+msgid "%{bStart}Purpose:%{bEnd} This data helps us improve Duo Chat and will never be used for performance evaluation. Note: The in-app feedback form states 'GitLab team members cannot see the AI content.' This does not apply for team members' interactions with the chat."
+msgstr ""
+
+msgid "%{bStart}What's logged:%{bEnd} Your questions, contexts (files, issues, MRs, etc.), and Duo's responses"
+msgstr ""
+
+msgid "%{bStart}Which interfaces are affected:%{bEnd} usage of Duo Chat in Web and IDEs as well as Duo Agentic Chat in Web and IDEs"
+msgstr ""
+
msgid "%{board_target} not found"
msgstr ""
@@ -29543,6 +29558,9 @@ msgstr ""
msgid "GitLab Team"
msgstr ""
+msgid "GitLab Team Member Notice: Chat Logging Active"
+msgstr ""
+
msgid "GitLab University"
msgstr ""