Skip to content

Commit 735d238

Browse files
fix: normalize conversation ids (#479)
* normalize conversation ids * changeset
1 parent bd5f09a commit 735d238

File tree

8 files changed

+162
-6
lines changed

8 files changed

+162
-6
lines changed

.changeset/tall-eyes-unite.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@inkeep/agents-core": patch
3+
"@inkeep/agents-run-api": patch
4+
---
5+
6+
normalize conversation ids

agents-run-api/src/routes/chat.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getActiveAgentForConversation,
99
getAgentById,
1010
getAgentGraphWithDefaultAgent,
11+
getConversationId,
1112
getFullGraph,
1213
getRequestExecutionContext,
1314
handleContextResolution,
@@ -191,7 +192,7 @@ app.openapi(chatCompletionsRoute, async (c) => {
191192

192193
// Get conversationId from request body or generate new one
193194
const body = c.get('requestBody') || {};
194-
const conversationId = body.conversationId || nanoid();
195+
const conversationId = body.conversationId || getConversationId();
195196

196197
// Get the graph from the full graph system first, fall back to legacy system
197198
const fullGraph = await getFullGraph(dbClient)({

agents-run-api/src/routes/chatDataStream.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getActiveAgentForConversation,
99
getAgentById,
1010
getAgentGraphWithDefaultAgent,
11+
getConversationId,
1112
getRequestExecutionContext,
1213
handleContextResolution,
1314
loggerFactory,
@@ -98,7 +99,7 @@ app.openapi(chatDataStreamRoute, async (c) => {
9899

99100
// Get parsed body from middleware (shared across all handlers)
100101
const body = c.get('requestBody') || {};
101-
const conversationId = body.conversationId || nanoid();
102+
const conversationId = body.conversationId || getConversationId();
102103
// Add conversation ID to parent span
103104
const activeSpan = trace.getActiveSpan();
104105
if (activeSpan) {

agents-run-api/src/routes/mcp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getAgentById,
2020
getAgentGraphWithDefaultAgent,
2121
getConversation,
22+
getConversationId,
2223
getRequestExecutionContext,
2324
handleContextResolution,
2425
updateConversation,
@@ -462,7 +463,7 @@ const handleInitializationRequest = async (
462463
) => {
463464
const { tenantId, projectId, graphId } = executionContext;
464465
logger.info({ body }, 'Received initialization request');
465-
const sessionId = nanoid();
466+
const sessionId = getConversationId();
466467

467468
// Get the default agent for the graph
468469
const agentGraph = await getAgentGraphWithDefaultAgent(dbClient)({
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getConversationId } from '../../utils/conversations';
3+
4+
describe('getConversationId', () => {
5+
describe('ID generation', () => {
6+
it('should generate a non-empty string', () => {
7+
const id = getConversationId();
8+
expect(id).toBeTruthy();
9+
expect(typeof id).toBe('string');
10+
expect(id.length).toBeGreaterThan(0);
11+
});
12+
13+
it('should generate unique IDs', () => {
14+
const id1 = getConversationId();
15+
const id2 = getConversationId();
16+
const id3 = getConversationId();
17+
18+
expect(id1).not.toBe(id2);
19+
expect(id1).not.toBe(id3);
20+
expect(id2).not.toBe(id3);
21+
});
22+
23+
it('should generate IDs with reasonable length (nanoid default is 21)', () => {
24+
const id = getConversationId();
25+
// nanoid default length is 21, but could be slightly less if leading hyphens are removed
26+
expect(id.length).toBeGreaterThanOrEqual(20);
27+
expect(id.length).toBeLessThanOrEqual(21);
28+
});
29+
});
30+
31+
describe('lowercase requirement', () => {
32+
it('should generate only lowercase IDs', () => {
33+
// Test multiple generations to ensure consistency
34+
for (let i = 0; i < 50; i++) {
35+
const id = getConversationId();
36+
expect(id).toBe(id.toLowerCase());
37+
}
38+
});
39+
40+
it('should not contain any uppercase letters', () => {
41+
for (let i = 0; i < 50; i++) {
42+
const id = getConversationId();
43+
expect(/[A-Z]/.test(id)).toBe(false);
44+
}
45+
});
46+
});
47+
48+
describe('no leading hyphens requirement', () => {
49+
it('should not start with a hyphen', () => {
50+
// Test multiple generations to ensure consistency
51+
for (let i = 0; i < 50; i++) {
52+
const id = getConversationId();
53+
expect(id.startsWith('-')).toBe(false);
54+
}
55+
});
56+
57+
it('should not start with multiple hyphens', () => {
58+
for (let i = 0; i < 50; i++) {
59+
const id = getConversationId();
60+
expect(/^-+/.test(id)).toBe(false);
61+
}
62+
});
63+
});
64+
65+
describe('character set', () => {
66+
it('should only contain URL-safe characters', () => {
67+
// nanoid uses A-Za-z0-9_- alphabet
68+
// After lowercase conversion: a-z0-9_-
69+
const urlSafePattern = /^[a-z0-9_-]+$/;
70+
71+
for (let i = 0; i < 50; i++) {
72+
const id = getConversationId();
73+
expect(urlSafePattern.test(id)).toBe(true);
74+
}
75+
});
76+
77+
it('should allow underscores and hyphens (but not leading hyphens)', () => {
78+
// Generate many IDs to potentially get ones with these characters
79+
const ids = Array.from({ length: 100 }, () => getConversationId());
80+
81+
// Check that none start with hyphen
82+
for (const id of ids) {
83+
expect(id.startsWith('-')).toBe(false);
84+
}
85+
86+
// IDs should be valid
87+
for (const id of ids) {
88+
expect(id.length).toBeGreaterThan(0);
89+
expect(/^[a-z0-9_-]+$/.test(id)).toBe(true);
90+
}
91+
});
92+
});
93+
94+
describe('collision resistance', () => {
95+
it('should generate unique IDs even in rapid succession', () => {
96+
const ids = new Set<string>();
97+
const count = 1000;
98+
99+
for (let i = 0; i < count; i++) {
100+
ids.add(getConversationId());
101+
}
102+
103+
// Should have generated 1000 unique IDs
104+
expect(ids.size).toBe(count);
105+
});
106+
});
107+
108+
describe('consistency', () => {
109+
it('should always return a valid ID format', () => {
110+
for (let i = 0; i < 100; i++) {
111+
const id = getConversationId();
112+
113+
// Verify all requirements
114+
expect(typeof id).toBe('string');
115+
expect(id.length).toBeGreaterThan(0);
116+
expect(id).toBe(id.toLowerCase());
117+
expect(id.startsWith('-')).toBe(false);
118+
expect(/^[a-z0-9_-]+$/.test(id)).toBe(true);
119+
}
120+
});
121+
});
122+
});

packages/agents-core/src/data-access/conversations.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { and, count, desc, eq } from 'drizzle-orm';
2-
import { nanoid } from 'nanoid';
32
import type { DatabaseClient } from '../db/client';
43
import { conversations, messages } from '../db/schema';
54
import type {
@@ -11,6 +10,7 @@ import type {
1110
PaginationConfig,
1211
ProjectScopeConfig,
1312
} from '../types/index';
13+
import { getConversationId } from '../utils/conversations';
1414

1515
export const listConversations =
1616
(db: DatabaseClient) =>
@@ -159,7 +159,7 @@ export const getConversation =
159159
// Create or get existing conversation
160160
export const createOrGetConversation =
161161
(db: DatabaseClient) => async (input: ConversationInsert) => {
162-
const conversationId = input.id || nanoid();
162+
const conversationId = input.id || getConversationId();
163163

164164
// Check if conversation already exists
165165
if (input.id) {
@@ -243,7 +243,7 @@ function applyContextWindowManagement(messageHistory: any[], maxTokens: number):
243243
// Add a summary message for truncated history if there are more messages
244244
if (i > 0) {
245245
const summaryMessage = {
246-
id: `summary-${nanoid()}`,
246+
id: `summary-${getConversationId()}`,
247247
role: 'system',
248248
content: {
249249
text: `[Previous conversation history truncated - ${i + 1} earlier messages]`,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { nanoid } from 'nanoid';
2+
3+
/**
4+
* Generates a standardized conversation ID.
5+
*
6+
* The generated ID follows these rules:
7+
* 1. Always lowercase
8+
* 2. No leading hyphens
9+
*
10+
* @returns A unique conversation ID
11+
*
12+
* @example
13+
* ```typescript
14+
* const id = getConversationId(); // returns something like "v1stgxr8z5jdhi6bmyt"
15+
* ```
16+
*/
17+
export function getConversationId(): string {
18+
let id = nanoid();
19+
20+
// Convert to lowercase and remove any leading hyphens
21+
id = id.toLowerCase().replace(/^-+/, '');
22+
23+
return id;
24+
}

packages/agents-core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './apiKeys';
22
export * from './auth-detection';
3+
export * from './conversations';
34
export * from './credential-store-utils';
45
export * from './error';
56
export * from './execution';

0 commit comments

Comments
 (0)