{
return false;
}
- const { userId, resourceId, projectId, chatTitle, rootNamespaceId, agenticAvailable } =
+ const { userId, resourceId, projectId, chatTitle, rootNamespaceId, agenticAvailable, metadata } =
el.dataset;
const toggleEls = document.querySelectorAll('.js-tanuki-bot-chat-toggle');
@@ -51,6 +51,7 @@ export const initTanukiBotChatDrawer = () => {
chatTitle,
rootNamespaceId,
agenticAvailable: JSON.parse(agenticAvailable),
+ metadata,
},
});
},
diff --git a/ee/app/assets/javascripts/ai/utils.js b/ee/app/assets/javascripts/ai/utils.js
index 7a9c9685830b5fbffecaffe6ddec0c5825f6a628..90b9e392ea30785e498ef1f563e88f3f901c8ddc 100644
--- a/ee/app/assets/javascripts/ai/utils.js
+++ b/ee/app/assets/javascripts/ai/utils.js
@@ -1,5 +1,6 @@
import { duoChatGlobalState } from '~/super_sidebar/constants';
import { setCookie } from '~/lib/utils/common_utils';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
import {
DUO_AGENTIC_MODE_COOKIE,
DUO_AGENTIC_MODE_COOKIE_EXPIRATION,
@@ -91,6 +92,21 @@ export const generateEventLabelFromText = (text) => {
.substring(0, 50);
};
+export const shouldShowLoggingAlert = (metadata = '') => {
+ let convertedMetadata;
+
+ try {
+ convertedMetadata = JSON.parse(metadata);
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+
+ const { extended_logging: extendedLogging, is_team_member: isTeamMember } =
+ convertedMetadata ?? {};
+
+ return Boolean(isTeamMember && extendedLogging);
+};
+
export const utils = {
concatStreamedChunks,
generateEventLabelFromText,
diff --git a/ee/app/views/layouts/_tanuki_bot_chat.html.haml b/ee/app/views/layouts/_tanuki_bot_chat.html.haml
index 32ef0c59d85186c2ffbb94c2c404ad96ae00e56e..ac071327ba5d4c095d46bc9b739d3385ff50e715 100644
--- a/ee/app/views/layouts/_tanuki_bot_chat.html.haml
+++ b/ee/app/views/layouts/_tanuki_bot_chat.html.haml
@@ -5,6 +5,7 @@
- chat_title = ::Ai::AmazonQ.enabled? ? s_('GitLab Duo Chat with Amazon Q') : s_('GitLab Duo Chat')
- is_agentic_available = Gitlab::Llm::TanukiBot.agentic_mode_available?(user: current_user, project: @project, group: @group)
- user_model_selection_enabled = Gitlab::Llm::TanukiBot.user_model_selection_enabled?(user: current_user)
+- metadata = Gitlab::DuoWorkflow::Client.metadata(current_user).to_json
- if is_agentic_available
#js-duo-agentic-chat-app{ data: {
@@ -13,6 +14,7 @@
resource_id: resource_id,
metadata: Gitlab::DuoWorkflow::Client.metadata(current_user).to_json,
user_model_selection_enabled: user_model_selection_enabled.to_s } }
+ metadata: metadata } }
#js-tanuki-bot-chat-app{ data: {
user_id: current_user.to_global_id,
@@ -20,4 +22,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: metadata } }
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..4bbbf569e9efa808b5d7cbe240c3c8c445333051
--- /dev/null
+++ b/ee/spec/frontend/ai/components/duo_chat_logging_alert_spec.js
@@ -0,0 +1,139 @@
+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_DISMISSED_COOKIE } 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 createComponent = () => {
+ wrapper = shallowMountExtended(DuoChatLoggingAlert);
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getCookie.mockReturnValue(null);
+ });
+
+ describe('Alert visibility', () => {
+ it('alert is not dismissed', async () => {
+ createComponent();
+ await nextTick();
+
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('does not render alert when dismissed', () => {
+ getCookie.mockReturnValue('true');
+ createComponent();
+
+ 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_DISMISSED_COOKIE);
+ 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_DISMISSED_COOKIE);
+ 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_DISMISSED_COOKIE);
+ 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_DISMISSED_COOKIE, 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();
+ });
+ });
+});
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 97df7cb561073638843f992a4d25aa422ef6813f..4817ae80939c699bd78a6a0cf84c933658120bb3 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
@@ -6,7 +6,7 @@ import VueApollo from 'vue-apollo';
import { GlToggle } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { setAgenticMode } from 'ee/ai/utils';
+import { setAgenticMode, shouldShowLoggingAlert } from 'ee/ai/utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { duoChatGlobalState } from '~/super_sidebar/constants';
@@ -75,7 +75,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: {
@@ -153,6 +157,7 @@ Vue.use(VueApollo);
jest.mock('~/lib/utils/common_utils', () => ({
getCookie: jest.fn(),
+ setCookie: jest.fn(),
}));
jest.mock('~/lib/utils/local_storage', () => ({
@@ -167,6 +172,7 @@ jest.mock('ee/ai/utils', () => {
__esModule: true,
...actualUtils,
setAgenticMode: jest.fn(),
+ shouldShowLoggingAlert: jest.fn(),
};
});
@@ -185,6 +191,9 @@ describe('Duo Agentic Chat', () => {
const contextPresetsQueryHandlerMock = jest.fn().mockResolvedValue(MOCK_CONTEXT_PRESETS_RESPONSE);
const findDuoChat = () => wrapper.findComponent(AgenticDuoChat);
+ const findGlToggle = () => wrapper.findComponent(GlToggle);
+ const findLoggingAlert = () => wrapper.findComponent({ name: 'DuoChatLoggingAlert' });
+
const getLastSocketCall = () => {
const { calls } = createWebSocket.mock;
if (calls.length === 0) {
@@ -194,11 +203,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: JSON.stringify({ is_team_member: true, extended_logging: true }),
+ };
+
+ const createComponent = ({ initialState = {}, propsData = {} } = {}) => {
const store = new Vuex.Store({
actions: actionSpies,
state: {
@@ -220,9 +231,9 @@ describe('Duo Agentic Chat', () => {
wrapper = shallowMountExtended(DuoAgenticChatApp, {
store,
apolloProvider,
- propsData,
- data() {
- return data;
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
},
});
@@ -234,6 +245,9 @@ describe('Duo Agentic Chat', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRefetch.mockClear();
+ mockSocketManager.connect.mockClear();
+ contextPresetsQueryHandlerMock.mockClear();
+ userWorkflowsQueryHandlerMock.mockClear();
MOCK_UTILS_SETUP();
});
@@ -244,6 +258,8 @@ describe('Duo Agentic Chat', () => {
afterEach(() => {
duoChatGlobalState.isAgenticChatShown = false;
+ contextPresetsQueryHandlerMock.mockResolvedValue(MOCK_CONTEXT_PRESETS_RESPONSE);
+ userWorkflowsQueryHandlerMock.mockResolvedValue(MOCK_USER_WORKFLOWS_RESPONSE);
});
describe('rendering', () => {
@@ -323,6 +339,24 @@ describe('Duo Agentic Chat', () => {
});
});
+ describe('DuoChatLoggingAlert Integration', () => {
+ beforeEach(() => {
+ duoChatGlobalState.isAgenticChatShown = true;
+ });
+
+ it('does not render DuoChatLoggingAlert when shouldShowLoggingAlert returns false', () => {
+ const originalUtils = jest.requireActual('ee/ai/utils');
+ shouldShowLoggingAlert.mockImplementation(originalUtils.shouldShowLoggingAlert);
+
+ const metadata = JSON.stringify({ is_team_member: false, extended_logging: false });
+
+ createComponent({ propsData: { metadata } });
+
+ expect(shouldShowLoggingAlert).toHaveBeenCalledWith(metadata);
+ expect(findLoggingAlert().exists()).toBe(false);
+ });
+ });
+
describe('events handling', () => {
beforeEach(() => {
createComponent();
@@ -385,7 +419,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;
@@ -438,7 +475,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;
@@ -460,20 +502,17 @@ describe('Duo Agentic Chat', () => {
expect(actionSpies.setLoading).toHaveBeenCalledWith(expect.anything(), true);
});
- it('does not create a new workflow if one already exists', async () => {
- wrapper.vm.workflowId = '456';
-
- findDuoChat().vm.$emit('send-chat-prompt', MOCK_USER_MESSAGE.content);
- await waitForPromises();
-
- expect(ApolloUtils.createWorkflow).not.toHaveBeenCalled();
- expect(createWebSocket).toHaveBeenCalledWith(
- '/api/v4/ai/duo_workflows/ws',
- expect.any(Object),
- );
- });
-
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();
@@ -547,6 +586,222 @@ describe('Duo Agentic Chat', () => {
expect(findDuoChat().props('activeThreadId')).toBe('Test goal for activeThread');
});
+ describe('startWorkflow request structure validation', () => {
+ describe('required fields validation', () => {
+ beforeEach(() => {
+ mockSocketManager.connect.mockClear();
+ createComponent({
+ propsData: {
+ projectId: MOCK_PROJECT_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: JSON.stringify({ is_team_member: true, extended_logging: true }),
+ },
+ });
+ });
+
+ afterEach(() => {
+ mockSocketManager.connect.mockClear();
+ });
+
+ it('validates field types match protobuf schema', async () => {
+ contextPresetsQueryHandlerMock.mockResolvedValueOnce({
+ data: { aiChatContextPresets: { questions: [], aiResourceData: null } },
+ });
+
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls[0][0];
+ const { startRequest } = callArg;
+
+ const allowedFields = [
+ 'clientVersion',
+ 'workflowID',
+ 'workflowDefinition',
+ 'goal',
+ 'workflowMetadata',
+ 'clientCapabilities',
+ 'mcpTools',
+ 'additionalContext',
+ 'approval',
+ 'flowConfig',
+ 'flowConfigSchemaVersion',
+ ];
+ const actualFields = Object.keys(callArg.startRequest);
+
+ actualFields.forEach((field) => {
+ expect(allowedFields).toContain(field);
+ });
+
+ expect(typeof startRequest.clientVersion).toBe('string');
+ expect(typeof startRequest.workflowID).toBe('string');
+ expect(typeof startRequest.workflowDefinition).toBe('string');
+ expect(typeof startRequest.goal).toBe('string');
+ expect(typeof startRequest.workflowMetadata).toBe('string');
+ expect(typeof startRequest.approval).toBe('object');
+ expect(Array.isArray(startRequest.additionalContext)).toBe(true);
+ });
+ });
+
+ describe('metadata field handling', () => {
+ beforeEach(() => {
+ mockSocketManager.connect.mockClear();
+ });
+
+ it('stringifies metadata object correctly', async () => {
+ const metadata = {
+ is_team_member: true,
+ extended_logging: false,
+ };
+
+ createComponent({
+ propsData: {
+ projectId: MOCK_PROJECT_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: JSON.stringify(metadata),
+ },
+ });
+
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls[0][0];
+ expect(callArg.startRequest.workflowMetadata).toBe(JSON.stringify(metadata));
+ });
+
+ it.each([
+ ['null', null, null],
+ ['undefined', undefined, null],
+ ['empty object', '{}', '{}'],
+ ])('handles %s metadata', async (description, metadataInput, expectedOutput) => {
+ createComponent({
+ propsData: {
+ projectId: MOCK_PROJECT_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: metadataInput,
+ },
+ });
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls[0][0];
+ expect(callArg.startRequest.workflowMetadata).toBe(expectedOutput);
+ });
+ });
+
+ describe('optional fields handling', () => {
+ afterEach(() => {
+ mockSocketManager.connect.mockClear();
+ });
+
+ it('includes additionalContext when context presets are available', async () => {
+ createComponent();
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls[0][0];
+ expect(callArg.startRequest.additionalContext).toEqual([
+ {
+ content: MOCK_AI_RESOURCE_DATA,
+ category: DUO_WORKFLOW_ADDITIONAL_CONTEXT_REPOSITORY,
+ metadata: '{}',
+ },
+ ]);
+ });
+
+ it('does not include additionalContext field when no context presets', async () => {
+ contextPresetsQueryHandlerMock.mockResolvedValueOnce({
+ data: { aiChatContextPresets: { questions: [], aiResourceData: null } },
+ });
+
+ createComponent({
+ propsData: {
+ projectId: MOCK_PROJECT_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata: JSON.stringify({ is_team_member: true, extended_logging: true }),
+ },
+ });
+
+ await nextTick();
+
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls.pop()[0];
+ expect(callArg.startRequest.additionalContext).toBeUndefined();
+ });
+
+ it('sends approval object structure correctly for tool approval', async () => {
+ createComponent();
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ wrapper.vm.workflowId = '456';
+ findDuoChat().vm.$emit('approve-tool');
+ await nextTick();
+
+ const callArg = mockSocketManager.connect.mock.calls.pop()[0];
+
+ expect(callArg.startRequest.approval).toEqual({ approval: {} });
+ expect(callArg.startRequest.goal).toBe('');
+ });
+
+ it('sends rejection structure correctly for tool denial', async () => {
+ createComponent();
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+
+ wrapper.vm.workflowId = '456';
+ const denyMessage = 'Not approved';
+ findDuoChat().vm.$emit('deny-tool', denyMessage);
+ await nextTick();
+
+ const callArg = mockSocketManager.connect.mock.calls.pop()[0];
+ expect(callArg.startRequest.approval).toEqual({
+ approval: undefined,
+ rejection: { message: denyMessage },
+ });
+ expect(callArg.startRequest.goal).toBe('');
+ });
+ });
+
+ describe('constant values validation', () => {
+ beforeEach(async () => {
+ createComponent();
+ duoChatGlobalState.isAgenticChatShown = true;
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mockSocketManager.connect.mockClear();
+ });
+
+ it('uses correct constant values for clientVersion and workflowDefinition', async () => {
+ findDuoChat().vm.$emit('send-chat-prompt', 'test question');
+ await waitForPromises();
+
+ const callArg = mockSocketManager.connect.mock.calls[0][0];
+ expect(callArg.startRequest.clientVersion).toBe('1.0');
+ expect(callArg.startRequest.workflowDefinition).toBe('chat');
+ });
+ });
+ });
+
it('handles tool approval flow', async () => {
const mockCheckpointData = {
requestID: 'request-id-1',
@@ -672,7 +927,7 @@ describe('Duo Agentic Chat', () => {
workflowDefinition: 'chat',
goal: '',
approval: { approval: {} },
- workflowMetadata: null,
+ workflowMetadata: JSON.stringify({ is_team_member: true, extended_logging: true }),
additionalContext: expectedAdditionalContext,
},
};
@@ -766,7 +1021,7 @@ describe('Duo Agentic Chat', () => {
approval: undefined,
rejection: { message: denyMessage },
},
- workflowMetadata: null,
+ workflowMetadata: JSON.stringify({ is_team_member: true, extended_logging: true }),
additionalContext: expectedAdditionalContext,
},
};
@@ -854,7 +1109,7 @@ describe('Duo Agentic Chat', () => {
approval: undefined,
rejection: { message: 'I do not approve this action' },
},
- workflowMetadata: null,
+ workflowMetadata: JSON.stringify({ is_team_member: true, extended_logging: true }),
additionalContext: expectedAdditionalContext,
},
};
@@ -1149,8 +1404,6 @@ describe('Duo Agentic Chat', () => {
});
describe('duoAgenticModePreference toggle', () => {
- const findGlToggle = () => wrapper.findComponent(GlToggle);
-
beforeEach(() => {
duoChatGlobalState.isAgenticChatShown = true;
jest.clearAllMocks();
@@ -1200,7 +1453,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);
@@ -1273,15 +1528,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', () => {
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..17127eb5cc5f9fb09e8d862a1127bce2d9678f46 100644
--- a/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js
+++ b/ee/spec/frontend/ai/tanuki_bot/components/app_spec.js
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import { GlToggle } from '@gitlab/ui';
-import { sendDuoChatCommand, setAgenticMode } from 'ee/ai/utils';
+import { sendDuoChatCommand, setAgenticMode, shouldShowLoggingAlert } 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 TanukiBotSubscriptions from 'ee/ai/tanuki_bot/components/tanuki_bot_subscriptions.vue';
@@ -66,6 +66,7 @@ jest.mock('ee/ai/utils', () => {
__esModule: true,
...actualUtils,
setAgenticMode: jest.fn(),
+ shouldShowLoggingAlert: jest.fn(),
};
});
@@ -116,6 +117,7 @@ describeSkipVue3(skipReason, () => {
const findCallout = () => wrapper.findComponent(DuoChatCallout);
const findSubscriptions = () => wrapper.findComponent(TanukiBotSubscriptions);
+ const findLoggingAlert = () => wrapper.findComponent({ name: 'DuoChatLoggingAlert' });
const createComponent = ({
initialState = {},
@@ -294,6 +296,65 @@ describeSkipVue3(skipReason, () => {
});
});
+ describe('DuoChatLoggingAlert Integration', () => {
+ beforeEach(() => {
+ duoChatGlobalState.isShown = true;
+ shouldShowLoggingAlert.mockReturnValue(true);
+ });
+
+ it('renders DuoChatLoggingAlert when shouldShowLoggingAlert returns true', async () => {
+ const metadata = JSON.stringify({ is_team_member: true, extended_logging: true });
+ shouldShowLoggingAlert.mockReturnValue(true);
+ getCookie.mockReturnValue('false');
+
+ createComponent({
+ propsData: {
+ userId: MOCK_USER_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata,
+ },
+ });
+
+ // two nextTicks are nescessary
+ await nextTick();
+ await nextTick();
+
+ expect(shouldShowLoggingAlert).toHaveBeenCalledWith(metadata);
+ expect(findLoggingAlert().exists()).toBe(true);
+ });
+
+ it('does not render DuoChatLoggingAlert when shouldShowLoggingAlert returns false', () => {
+ const metadata = JSON.stringify({ is_team_member: false, extended_logging: false });
+ shouldShowLoggingAlert.mockReturnValue(false);
+
+ createComponent({
+ propsData: {
+ userId: MOCK_USER_ID,
+ resourceId: MOCK_RESOURCE_ID,
+ metadata,
+ },
+ });
+
+ expect(shouldShowLoggingAlert).toHaveBeenCalledWith(metadata);
+ expect(findLoggingAlert().exists()).toBe(false);
+ });
+
+ it('renders DuoChatLoggingAlert in the subheader slot when shown', () => {
+ shouldShowLoggingAlert.mockReturnValue(true);
+ createComponent();
+
+ 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 +1227,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 +1240,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/ee/spec/frontend/ai/utils_spec.js b/ee/spec/frontend/ai/utils_spec.js
index ac32af2a7d7231a32e6dea8d6c75ade12943a2ef..bc219b8ae57b804cf1e8e879d9a376005c0fd89b 100644
--- a/ee/spec/frontend/ai/utils_spec.js
+++ b/ee/spec/frontend/ai/utils_spec.js
@@ -5,6 +5,7 @@ import {
utils,
setAgenticMode,
saveDuoAgenticModePreference,
+ shouldShowLoggingAlert,
} from 'ee/ai/utils';
import { duoChatGlobalState } from '~/super_sidebar/constants';
import { setCookie } from '~/lib/utils/common_utils';
@@ -12,11 +13,16 @@ import {
DUO_AGENTIC_MODE_COOKIE,
DUO_AGENTIC_MODE_COOKIE_EXPIRATION,
} from 'ee/ai/tanuki_bot/constants';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
jest.mock('~/lib/utils/common_utils', () => ({
setCookie: jest.fn(),
}));
+jest.mock('~/sentry/sentry_browser_wrapper', () => ({
+ captureException: jest.fn(),
+}));
+
describe('AI Utils', () => {
describe('concatStreamedChunks', () => {
it.each`
@@ -354,4 +360,38 @@ describe('AI Utils', () => {
);
});
});
+
+ describe('shouldShowLoggingAlert', () => {
+ it.each`
+ is_team_member | extended_logging | expectedValue
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${false} | ${false} | ${false}
+ ${true} | ${undefined} | ${false}
+ ${undefined} | ${true} | ${false}
+ ${undefined} | ${undefined} | ${false}
+ `(
+ 'returns $expectedValue when is_team_member=$is_team_member, extended_logging=$extended_logging',
+ ({ is_team_member, extended_logging, expectedValue }) => {
+ const metadata = JSON.stringify({ is_team_member, extended_logging });
+ expect(shouldShowLoggingAlert(metadata)).toBe(expectedValue);
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ },
+ );
+
+ it('returns false when metadata is null', () => {
+ // `null` stringifies to `"null"`, which then parses back to `null`, so no error is thrown.
+ expect(shouldShowLoggingAlert(null)).toBe(false);
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it.each([undefined, '', 'invalid json'])(
+ 'returns false when metadata is invalid JSON and captures exception',
+ (metadata) => {
+ expect(shouldShowLoggingAlert(metadata)).toBe(false);
+ expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(SyntaxError));
+ },
+ );
+ });
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4d248ad7ae5bf1a9ac354d75297c8c02425072b3..361a58dead3be58220dea278010c0c1f6db553c8 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -646,6 +646,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 ""
@@ -29948,6 +29963,9 @@ msgstr ""
msgid "GitLab Team"
msgstr ""
+msgid "GitLab Team Member Notice: Chat Logging Active"
+msgstr ""
+
msgid "GitLab University"
msgstr ""