fix: O2A title and tool call improvements (#10385)

This commit is contained in:
udev
2026-02-05 23:55:12 +05:30
committed by GitHub
parent ac57e491d6
commit c64197b2af
15 changed files with 1041 additions and 200 deletions
File diff suppressed because it is too large Load Diff
@@ -45,7 +45,9 @@ export const createAlertsContextProvider = (
alertName: formData?.value?.name || '',
isRealTime: formData?.value?.type === 'realtime',
organization_identifier: store?.state?.selectedOrganization?.identifier || '',
user_intent: isUpdated ? 'edit existing alert' : 'create new alert'
user_intent: isUpdated ? 'edit existing alert' : 'create new alert',
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
}
};
@@ -47,7 +47,9 @@ export const createDashboardsContextProvider = (
const context: PageContext = {
currentPage: 'Dashboards',
dashboardId: route?.query?.dashboard || '',
organization_identifier: store?.state?.selectedOrganization?.identifier || ''
organization_identifier: store?.state?.selectedOrganization?.identifier || '',
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
// Determine user intent based on route and context
@@ -42,16 +42,19 @@ export const createDefaultContextProvider = (
return {
currentPage: currentRoute?.meta?.title || 'unknown',
// Current route information
routeName: currentRoute?.name || 'unknown',
routePath: currentRoute?.path || '/',
routeFullPath: currentRoute?.fullPath || '/',
routeParams: currentRoute?.params || {},
routeQuery: currentRoute?.query || {},
// Organization context
organization_identifier: store?.state?.selectedOrganization?.identifier || ''
organization_identifier: store?.state?.selectedOrganization?.identifier || '',
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
}
};
@@ -50,6 +50,8 @@ export const createIncidentsContextProvider = (
alert_count: incidentData?.alert_count,
first_alert_at: incidentData?.first_alert_at,
last_alert_at: incidentData?.last_alert_at,
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
return context;
@@ -181,19 +181,21 @@ export const createLogsContextProvider = (
// Stream information
selectedStreams: streams || [],
streamType: streamType,
// Interesting fields organized by stream name (using actual structure)
interestingFields: extractInterestingFieldsByStream(
searchObj?.data?.stream?.selectedInterestingStreamFields || []
),
// Time range (conditional based on type)
timeRange: buildTimeRangeContext(searchObj.data.datetime),
// Current organization
organization_identifier: store?.state?.selectedOrganization?.identifier || '',
quickMode: searchObj?.meta?.quickMode || false,
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
} catch (error) {
console.error('Error generating logs context:', error);
@@ -44,7 +44,9 @@ export const createPipelinesContextProvider = (
pipelineId: pipeline?.id || '',
pipelineName: pipeline?.name || '',
organization_identifier: store?.state?.selectedOrganization?.identifier || '',
user_intent: pipelineObj?.isEditPipeline ? 'edit existing pipeline' : 'create new pipeline'
user_intent: pipelineObj?.isEditPipeline ? 'edit existing pipeline' : 'create new pipeline',
// Current timestamp when request is fired (microseconds) for AI agent time calculations
request_timestamp: Date.now() * 1000,
};
}
};
+20 -13
View File
@@ -368,12 +368,12 @@ describe('useAiChat', () => {
it('should make API call with correct URL and body when model is provided', async () => {
const mockResponse = { ok: true, json: vi.fn() };
mockFetch.mockResolvedValue(mockResponse);
const model = 'gpt-4';
const orgId = 'test-org';
const result = await aiChatComposable.fetchAiChat(mockMessages, model, orgId);
expect(mockFetch).toHaveBeenCalledWith(
`http://localhost:5080/api/test-org/ai/chat_stream`,
expect.objectContaining({
@@ -383,27 +383,34 @@ describe('useAiChat', () => {
'Content-Type': 'application/json',
'traceparent': expect.stringMatching(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/)
}),
body: JSON.stringify({ model, messages: mockMessages })
})
);
// Verify body includes model, messages, and context with user_timezone
const callArgs = mockFetch.mock.calls[0][1];
const requestBody = JSON.parse(callArgs.body);
expect(requestBody.model).toBe(model);
expect(requestBody.messages).toEqual(mockMessages);
expect(requestBody.context).toBeDefined();
expect(requestBody.context.user_timezone).toBeDefined();
expect(result).toBe(mockResponse);
});
it('should make API call without model when model is empty string', async () => {
const mockResponse = { ok: true, json: vi.fn() };
mockFetch.mockResolvedValue(mockResponse);
const model = '';
const orgId = 'test-org';
await aiChatComposable.fetchAiChat(mockMessages, model, orgId);
expect(mockFetch).toHaveBeenCalledWith(
`http://localhost:5080/api/test-org/ai/chat_stream`,
expect.objectContaining({
body: JSON.stringify({ messages: mockMessages })
})
);
// Verify body includes messages and context with user_timezone but no model
const callArgs = mockFetch.mock.calls[0][1];
const requestBody = JSON.parse(callArgs.body);
expect(requestBody.model).toBeUndefined();
expect(requestBody.messages).toEqual(mockMessages);
expect(requestBody.context).toBeDefined();
expect(requestBody.context.user_timezone).toBeDefined();
});
it('should not mutate original messages array', async () => {
+17 -8
View File
@@ -80,10 +80,16 @@ const useAiChat = () => {
// Extract agent_type from context if present (for SRE agent routing)
const { agent_type, ...contextWithoutAgentType } = contextToUse;
// Add user's timezone to context for time display formatting
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Build payload with agent_type at root level if present
const payload: any = {
messages: _messages,
context: contextWithoutAgentType
context: {
...contextWithoutAgentType,
user_timezone: userTimezone,
}
};
body = JSON.stringify(payload);
@@ -91,14 +97,17 @@ const useAiChat = () => {
// Fallback to legacy approach - inject context into message content
const currentMessage = _messages[_messages.length - 1];
currentMessage.content = getFormattedContext(currentMessage, legacyContext);
body = model.length > 0
? JSON.stringify({ model, messages: _messages })
: JSON.stringify({ messages: _messages });
// Add user's timezone to context
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
body = model.length > 0
? JSON.stringify({ model, messages: _messages, context: { user_timezone: userTimezone } })
: JSON.stringify({ messages: _messages, context: { user_timezone: userTimezone } });
} else {
// No context available
body = model.length > 0
? JSON.stringify({ model, messages: _messages })
: JSON.stringify({ messages: _messages });
// No context available - still include timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
body = model.length > 0
? JSON.stringify({ model, messages: _messages, context: { user_timezone: userTimezone } })
: JSON.stringify({ messages: _messages, context: { user_timezone: userTimezone } });
}
try {
@@ -134,7 +134,7 @@ export default defineComponent({
{ name: 'Sensitive Data Redaction', price: '$0.15 / GB' , is_parent: true},
{ name: 'Incident Management', price: '$0 during preview' , is_parent: true},
{ name: 'AI Assistant', price: '$0 during preview' , is_parent: true},
{ name: 'O2 Assistant', price: '$0 during preview' , is_parent: true},
{ name: 'AI SRE Agent', price: '$0 during preview' , is_parent: true},
{ name: 'Audit Trail', price: '2% of monthly spend' , is_parent: true},
{ name: 'Retention', price: '' , is_parent: true},
+2 -6
View File
@@ -92,7 +92,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="col-auto"
v-show="store.state.isAiChatEnabled && isLoading"
style="width: 25%; max-width: 100%; min-width: 75px; z-index: 10"
style="width: 25%; max-width: 100%; min-width: 75px; z-index: 10; padding-top: 44px; padding-right: 0.625rem;"
:class="
store.state.theme == 'dark'
? 'dark-mode-chat-container'
@@ -100,7 +100,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
>
<O2AIChat
:header-height="82.5"
:header-height="42.5"
:is-open="store.state.isAiChatEnabled"
@close="closeChat"
:aiChatInputContext="aiChatInputContext"
@@ -1602,12 +1602,8 @@ body.ai-chat-open {
color: white;
}
.dark-mode-chat-container {
border-left: 1.5px solid #232323ff;
box-shadow: -0rem 0.1rem 0.3rem var(--hover-shadow);
}
.light-mode-chat-container {
border-left: 1.5px solid #f7f7f7;
box-shadow: -0rem 0.1rem 0.3rem var(--hover-shadow);
}
.ai-btn-active {
+1 -1
View File
@@ -661,7 +661,7 @@
"note": "Automated SRE operations and insights"
},
"ai_assistant": {
"name": "AI Assistant",
"name": "O2 Assistant",
"note": "Intelligent query and analysis assistance"
},
"anomaly_detection": {
+1 -1
View File
@@ -647,7 +647,7 @@
"note": "Automated SRE operations and insights"
},
"ai_assistant": {
"name": "AI Assistant",
"name": "O2 Assistant",
"note": "Intelligent query and analysis assistance"
},
"anomaly_detection": {
+219 -58
View File
@@ -12,47 +12,54 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAlertPayload } from './alertPayload';
describe('alertPayload', () => {
describe('getAlertPayload', () => {
it('should transform form data to API payload', () => {
const formData = {
name: 'Test Alert',
description: 'Test Description',
is_real_time: 'false',
trigger_condition: {
threshold: 100,
period: 5,
frequency: 60,
silence: 300
},
context_attributes: [
{ key: 'env', value: 'production' },
{ key: 'service', value: 'api' }
],
query_condition: {
type: 'sql',
conditions: [],
sql: 'SELECT * FROM logs'
},
stream_name: 'default',
stream_type: 'logs',
uuid: 'temp-uuid-123'
};
const createBaseFormData = () => ({
name: 'Test Alert',
description: ' Test Description ',
is_real_time: 'false',
trigger_condition: {
threshold: '100',
period: '5',
frequency: '60',
silence: '300'
},
context_attributes: [
{ key: 'env', value: 'production' },
{ key: 'service', value: 'api' }
],
query_condition: {
type: 'sql',
conditions: [],
sql: 'SELECT * FROM logs',
aggregation: { field: 'count' },
promql_condition: { query: 'up' },
vrl_function: ''
},
stream_name: 'default',
stream_type: 'logs',
uuid: 'temp-uuid-123'
});
const context = {
store: {
state: {
selectedOrganization: { identifier: 'test-org' },
userInfo: { email: 'test@example.com' }
}
},
isAggregationEnabled: { value: false },
getSelectedTab: { value: 'sql' },
beingUpdated: false
};
const createBaseContext = (overrides = {}) => ({
store: {
state: {
selectedOrganization: { identifier: 'test-org' },
userInfo: { email: 'test@example.com' }
}
},
isAggregationEnabled: { value: false },
getSelectedTab: { value: 'sql' },
beingUpdated: false,
...overrides
});
it('should transform form data to API payload', () => {
const formData = createBaseFormData();
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
@@ -64,34 +71,188 @@ describe('alertPayload', () => {
expect(payload.context_attributes.env).toBe('production');
});
it('should handle real-time alerts', () => {
const formData = {
name: 'RT Alert',
description: '',
is_real_time: 'true',
trigger_condition: { threshold: 10, period: 1, frequency: 10, silence: 60 },
context_attributes: [],
query_condition: { type: 'sql', conditions: [], sql: 'SELECT count(*) FROM logs' },
stream_name: 'default',
stream_type: 'logs'
};
it('should convert string trigger conditions to integers', () => {
const formData = createBaseFormData();
const context = createBaseContext();
const context = {
store: {
state: {
selectedOrganization: { identifier: 'test-org' },
userInfo: { email: 'test@example.com' }
}
},
isAggregationEnabled: { value: true },
getSelectedTab: { value: 'promql' },
beingUpdated: false
};
const payload = getAlertPayload(formData, context);
expect(payload.trigger_condition.threshold).toBe(100);
expect(payload.trigger_condition.period).toBe(5);
expect(payload.trigger_condition.frequency).toBe(60);
expect(payload.trigger_condition.silence).toBe(300);
expect(typeof payload.trigger_condition.threshold).toBe('number');
});
it('should trim the description', () => {
const formData = createBaseFormData();
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
expect(payload.description).toBe('Test Description');
});
it('should handle real-time alerts and set type to custom', () => {
const formData = createBaseFormData();
formData.is_real_time = 'true';
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
expect(payload.is_real_time).toBe(true);
expect(payload.query_condition.type).toBe('custom');
});
it('should clear aggregation when not enabled', () => {
const formData = createBaseFormData();
const context = createBaseContext({ isAggregationEnabled: { value: false } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.aggregation).toBeNull();
});
it('should keep aggregation when enabled and tab is custom', () => {
const formData = createBaseFormData();
const context = createBaseContext({
isAggregationEnabled: { value: true },
getSelectedTab: { value: 'custom' }
});
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.aggregation).toEqual({ field: 'count' });
});
it('should clear aggregation when tab is not custom even if enabled', () => {
const formData = createBaseFormData();
const context = createBaseContext({
isAggregationEnabled: { value: true },
getSelectedTab: { value: 'sql' }
});
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.aggregation).toBeNull();
});
it('should clear conditions for sql tab', () => {
const formData = createBaseFormData();
formData.query_condition.conditions = [{ field: 'level', operator: '=', value: 'error' }];
const context = createBaseContext({ getSelectedTab: { value: 'sql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.conditions).toEqual([]);
});
it('should clear conditions for promql tab', () => {
const formData = createBaseFormData();
formData.query_condition.conditions = [{ field: 'level', operator: '=', value: 'error' }];
const context = createBaseContext({ getSelectedTab: { value: 'promql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.conditions).toEqual([]);
});
it('should clear promql_condition for sql tab', () => {
const formData = createBaseFormData();
const context = createBaseContext({ getSelectedTab: { value: 'sql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.promql_condition).toBeNull();
});
it('should clear promql_condition for custom tab', () => {
const formData = createBaseFormData();
const context = createBaseContext({ getSelectedTab: { value: 'custom' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.promql_condition).toBeNull();
});
it('should clear sql for promql tab', () => {
const formData = createBaseFormData();
const context = createBaseContext({ getSelectedTab: { value: 'promql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.sql).toBe('');
});
it('should base64 encode vrl_function when present', () => {
const formData = createBaseFormData();
formData.query_condition.vrl_function = ' .message = "test" ';
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
// b64EncodeUnicode uses URL-safe base64 encoding with . instead of =
// So btoa('.message = "test"') = "Lm1lc3NhZ2UgPSAidGVzdCI=" becomes "Lm1lc3NhZ2UgPSAidGVzdCI."
expect(payload.query_condition.vrl_function).toBe('Lm1lc3NhZ2UgPSAidGVzdCI.');
});
it('should set createdAt and owner for new alerts', () => {
const formData = createBaseFormData();
const context = createBaseContext({ beingUpdated: false });
const payload = getAlertPayload(formData, context);
expect(payload.createdAt).toBeDefined();
expect(payload.owner).toBe('test@example.com');
expect(payload.lastTriggeredAt).toBeDefined();
expect(payload.lastEditedBy).toBe('test@example.com');
});
it('should set updatedAt for existing alerts', () => {
const formData = createBaseFormData();
const context = createBaseContext({ beingUpdated: true });
const payload = getAlertPayload(formData, context);
expect(payload.updatedAt).toBeDefined();
expect(payload.lastEditedBy).toBe('test@example.com');
expect(payload.createdAt).toBeUndefined();
});
it('should filter out empty context attributes', () => {
const formData = createBaseFormData();
formData.context_attributes = [
{ key: 'valid', value: 'value' },
{ key: '', value: 'no-key' },
{ key: 'no-value', value: '' },
{ key: ' ', value: ' ' }
];
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
expect(Object.keys(payload.context_attributes)).toEqual(['valid']);
});
it('should handle empty context attributes array', () => {
const formData = createBaseFormData();
formData.context_attributes = [];
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
expect(payload.context_attributes).toEqual({});
});
it('should preserve original formData by using deep clone', () => {
const formData = createBaseFormData();
const originalUuid = formData.uuid;
const context = createBaseContext();
getAlertPayload(formData, context);
// Original formData should not be modified
expect(formData.uuid).toBe(originalUuid);
});
});
});
+28 -7
View File
@@ -14,8 +14,8 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<q-page class="tw:px-[0.625rem] q-pt-xs" style="overflow-y: auto; min-height: inherit; height: calc(100vh - 40px);" :class="store.state.zoConfig.ai_enabled ? 'ai-enabled-home-view q-pb-sm' : ''">
<div v-if="!no_data_ingest && !isLoadingSummary" class="tw:w-full tw:h-full tw:px-[0.625rem] tw:py-[0.625rem] card-container" style="display: flex; flex-direction: column; ">
<q-page class="tw:px-[0.625rem] q-pt-xs" style="overflow-y: auto; min-height: inherit; height: calc(100vh - 40px);" :class="store.state.isAiChatEnabled ? 'ai-enabled-home-view q-pb-sm' : ''">
<div v-if="!no_data_ingest && !isLoadingSummary" class="tw:w-full tw:px-[0.625rem] tw:py-[0.625rem] card-container" :class="store.state.isAiChatEnabled ? 'tw:h-[calc(100% - 40px)]' : 'tw:h-full'" style="display: flex; flex-direction: column; ">
<!-- 1st section -->
<div>
<TrialPeriod></TrialPeriod>
@@ -1232,11 +1232,23 @@ export default defineComponent({
margin-top: 1rem;
align-items: stretch;
// Responsive: stack on smaller screens
@media (max-width: 1280px) {
// Stack on smaller screens
@media (max-width: 1400px) {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
// Also stack on mobile
@media (max-width: 768px) {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
}
// When AI chat is enabled, force vertical stacking (overrides all breakpoints)
.ai-enabled-home-view .charts-main-container {
grid-template-columns: 1fr !important;
grid-template-rows: auto auto auto !important;
}
.functions-dashboards-column {
@@ -1245,17 +1257,26 @@ export default defineComponent({
gap: 1rem;
width: 100%;
// On smaller screens, display as a row
@media (max-width: 1280px) {
// Display as row when stacked
@media (max-width: 1400px) {
flex-direction: row;
}
// On mobile, stack vertically again
// Stack on mobile
@media (max-width: 768px) {
flex-direction: column;
}
}
// When AI chat is enabled, display functions/dashboards in a row (overrides breakpoints)
.ai-enabled-home-view .functions-dashboards-column {
flex-direction: row !important;
@media (max-width: 768px) {
flex-direction: column !important;
}
}
.tile-wrapper {
flex: 1;
display: flex;