Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,33 @@ describe('ObjectQLConversationService', () => {
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
});

it('auto-titles an untitled conversation from its first user message', async () => {
const conv = await service.create(); // no title
expect(conv.title).toBeUndefined();
const updated = await service.addMessage(conv.id, { role: 'user', content: 'Build me a ticketing system' });
expect(updated.title).toBe('Build me a ticketing system');
});

it('does not overwrite an existing title, and only a USER first turn seeds it', async () => {
const titled = await service.create({ title: 'My title' });
const afterUser = await service.addMessage(titled.id, { role: 'user', content: 'hello' });
expect(afterUser.title).toBe('My title'); // kept

const fresh = await service.create();
const afterAssistant = await service.addMessage(fresh.id, { role: 'assistant', content: 'hi there' });
expect(afterAssistant.title).toBeUndefined(); // assistant turn does not seed a title
});

it('collapses whitespace and truncates a long first message into the title', async () => {
const conv = await service.create();
const long = 'design an\n employee onboarding system with departments tasks members and a dashboard and reports';
const updated = await service.addMessage(conv.id, { role: 'user', content: long });
expect(updated.title!.length).toBeLessThanOrEqual(60);
expect(updated.title).not.toContain('\n');
expect(updated.title).toMatch(/^design an employee onboarding/);
expect(updated.title!.endsWith('…')).toBe(true);
});

it('should add a tool message with toolCallId', async () => {
const conv = await service.create();
const msg: ModelMessage = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ const MESSAGE_ORDER = [
* Production environments should use this implementation to ensure
* conversation history survives service restarts.
*/
/**
* A short, single-line title derived from a user message: collapse whitespace
* and cap the length. Pure + deterministic (no model call).
*/
function firstMessageTitle(text: string, maxLength = 60): string {
const flat = text.replace(/\s+/g, ' ').trim();
if (flat.length <= maxLength) return flat;
return `${flat.slice(0, maxLength - 1).trimEnd()}…`;
}

export class ObjectQLConversationService implements IAIConversationService {
private readonly engine: IDataEngine;

Expand Down Expand Up @@ -237,10 +247,22 @@ export class ObjectQLConversationService implements IAIConversationService {
created_at: now,
});

// Update conversation timestamp
await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
where: { id: conversationId },
});
// Auto-title from the first user message. The sidebar lists conversations
// straight off the `ai_conversations` rows, so an untitled conversation
// shows a generic label — a wall of identical rows once a user has a few.
// The LLM auto-titler may be disabled or run a beat later; this gives every
// conversation a readable label the instant its first user turn lands,
// deterministically and with no extra model call. A nicer LLM title (when
// enabled) simply overwrites it.
const titleUpdate =
message.role === 'user' && !row.title && contentStr
? { title: firstMessageTitle(contentStr) }
: {};
await this.engine.update(
CONVERSATIONS_OBJECT,
{ id: conversationId, updated_at: now, ...titleUpdate },
{ where: { id: conversationId } },
);

// Return the full updated conversation
return (await this.get(conversationId))!;
Expand Down