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
1 change: 1 addition & 0 deletions apps/web/src/lib/feature-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('validateFeatureHeader', () => {
'github',
'linear',
'scheduled',
'quick-chat',
])('accepts emitted feature %s', feature => {
expect(validateFeatureHeader(feature)).toBe(feature);
});
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/feature-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const FEATURE_VALUES = [
'kiloclaw',
'openclaw',
'direct-gateway',
'quick-chat',
'embeddings',
'kiloclaw-embedding',
'openclaw-embedding',
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ import {
user_moderation_blocks,
user_moderation_mutes,
user_terms_acceptances,
quick_chat_threads,
quick_chat_messages,
user_deletion_requests,
user_deletion_steps,
cloud_agent_pending_uploads,
Expand Down Expand Up @@ -302,6 +304,8 @@ describe('User', () => {
await db.delete(platform_oauth_credentials);
await db.delete(platform_access_token_credentials);
await db.delete(platform_integrations);
await db.delete(quick_chat_messages);
await db.delete(quick_chat_threads);
await db.delete(organizations);
await db.delete(kilocode_users);
});
Expand Down Expand Up @@ -1398,6 +1402,55 @@ describe('User', () => {
).toHaveLength(1);
});

it('deletes quick chat threads and messages for the user and leaves other users intact', async () => {
const user = await insertTestUser({ google_user_email: 'quick-chat-user@example.com' });
const otherUser = await insertTestUser();

const [thread] = await db
.insert(quick_chat_threads)
.values({ user_id: user.id, organization_id: null })
.returning();
const [otherThread] = await db
.insert(quick_chat_threads)
.values({ user_id: otherUser.id, organization_id: null })
.returning();
if (!thread || !otherThread) throw new Error('Failed to seed quick chat threads');

const [message] = await db
.insert(quick_chat_messages)
.values({ thread_id: thread.id, role: 'user', content: 'hello' })
.returning();
const [otherMessage] = await db
.insert(quick_chat_messages)
.values({ thread_id: otherThread.id, role: 'user', content: 'keep me' })
.returning();
if (!message || !otherMessage) throw new Error('Failed to seed quick chat messages');

await softDeleteUser(user.id);

expect(
await db
.select()
.from(quick_chat_messages)
.where(eq(quick_chat_messages.thread_id, thread.id))
).toHaveLength(0);
expect(
await db.select().from(quick_chat_threads).where(eq(quick_chat_threads.user_id, user.id))
).toHaveLength(0);
expect(
await db
.select()
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, otherUser.id))
).toHaveLength(1);
expect(
await db
.select()
.from(quick_chat_messages)
.where(eq(quick_chat_messages.thread_id, otherThread.id))
).toHaveLength(1);
});

it('deletes user data export state and dependent multipart and outbox rows', async () => {
const user = await insertTestUser();
const [exportJob] = await db
Expand Down
20 changes: 19 additions & 1 deletion apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ import {
user_moderation_blocks,
user_moderation_mutes,
user_terms_acceptances,
quick_chat_threads,
quick_chat_messages,
} from '@kilocode/db/schema';
import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm';
import { allow_fake_login, IS_DEVELOPMENT } from '@/lib/constants';
Expand Down Expand Up @@ -1045,7 +1047,7 @@ export async function assertUserCanBeSoftDeleted(userId: string): Promise<void>
* device_auth_requests, auto_top_up_configs,
* user_github_app_tokens, kiloclaw_instances/inbound_email_aliases/access_codes,
* user_period_cache, kilo_pass_scheduled_changes, coding_plan_availability_intents,
* user_notification_preferences)
* user_notification_preferences, quick_chat_threads, quick_chat_messages)
* - operation_ledgers (keyed by kilo_user_id)
* - analytics_event_outbox (keyed by distinct_id: the user's email or, when the
* writer's email lookup failed, the user id)
Expand Down Expand Up @@ -1474,6 +1476,22 @@ export async function anonymizeCloudUserData(
await tx.delete(user_moderation_mutes).where(eq(user_moderation_mutes.blocker_user_id, userId));
await tx.delete(user_terms_acceptances).where(eq(user_terms_acceptances.kilo_user_id, userId));

// Quick chat threads and messages are user-owned, so they are hard-deleted
// with the account. Messages go first so the thread delete below cannot race
// a cascade that would leave them behind.
await tx
.delete(quick_chat_messages)
.where(
inArray(
quick_chat_messages.thread_id,
tx
.select({ id: quick_chat_threads.id })
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, userId))
)
);
await tx.delete(quick_chat_threads).where(eq(quick_chat_threads.user_id, userId));

// Code indexing data
await tx.delete(source_embeddings).where(eq(source_embeddings.kilo_user_id, userId));
await tx.delete(code_indexing_search).where(eq(code_indexing_search.kilo_user_id, userId));
Expand Down
189 changes: 189 additions & 0 deletions apps/web/src/routers/quick-chat-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import { cleanupDbForTest, db } from '@/lib/drizzle';
import { createCallerFactory, createTRPCRouter } from '@/lib/trpc/init';
import { quickChatRouter } from '@/routers/quick-chat-router';
import { insertTestUser } from '@/tests/helpers/user.helper';
import { createTestOrganization } from '@/tests/helpers/organization.helper';
import { quick_chat_messages, quick_chat_threads } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';

const createCaller = createCallerFactory(createTRPCRouter({ quickChat: quickChatRouter }));

describe('quickChatRouter', () => {
beforeEach(async () => {
await cleanupDbForTest();
});

it('is idempotent for a personal null-org thread', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });

const first = await caller.quickChat.getOrCreateThread({ organizationId: null });
const second = await caller.quickChat.getOrCreateThread({ organizationId: null });

expect(second.id).toBe(first.id);
expect(first.organizationId).toBeNull();

const threads = await db
.select()
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, user.id));
expect(threads).toHaveLength(1);
});

it('keeps an organization thread separate from the personal thread', async () => {
const user = await insertTestUser();
const organization = await createTestOrganization('Quick Chat Org', user.id, 0);
const caller = createCaller({ user });

const personal = await caller.quickChat.getOrCreateThread({ organizationId: null });
const orgThread = await caller.quickChat.getOrCreateThread({
organizationId: organization.id,
});

expect(orgThread.id).not.toBe(personal.id);
expect(orgThread.organizationId).toBe(organization.id);

const threads = await db
.select()
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, user.id));
expect(threads).toHaveLength(2);
});

it('returns an empty list when the user has no thread', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });

const result = await caller.quickChat.listMessages({ organizationId: null });

expect(result).toEqual({ messages: [], nextCursor: null });
});

it('returns appended messages from list', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });

await caller.quickChat.appendMessages({
organizationId: null,
messages: [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hi', clientId: 'client-1' },
],
});

const result = await caller.quickChat.listMessages({ organizationId: null });
expect(result.messages).toHaveLength(2);
expect(result.messages.map(message => message.content)).toEqual(['hello', 'hi']);
const roles: ('user' | 'assistant')[] = result.messages.map(message => message.role);
expect(roles).toEqual(['user', 'assistant']);
expect(result.messages[1]?.clientId).toBe('client-1');
});

it('rejects an invalid stored message role', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });
const thread = await caller.quickChat.getOrCreateThread({ organizationId: null });
await db.insert(quick_chat_messages).values({
thread_id: thread.id,
role: 'tool',
content: 'Invalid role',
});

await expect(caller.quickChat.listMessages({ organizationId: null })).rejects.toThrow();
});

it('pages older messages through nextCursor', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });

const thread = await caller.quickChat.getOrCreateThread({ organizationId: null });
const base = Date.parse('2026-01-01T00:00:00.000Z');
const times = Array.from({ length: 5 }, (_, i) => new Date(base + i * 1000).toISOString());
for (let i = 0; i < times.length; i++) {
await db.insert(quick_chat_messages).values({
thread_id: thread.id,
role: 'user',
content: `msg-${i}`,
created_at: times[i],
});
}

const page1 = await caller.quickChat.listMessages({ organizationId: null, limit: 2 });
expect(page1.messages.map(message => message.content)).toEqual(['msg-3', 'msg-4']);
expect(page1.nextCursor).not.toBeNull();
expect(page1.nextCursor).not.toBe(times[3]);

const page2 = await caller.quickChat.listMessages({
organizationId: null,
limit: 2,
cursor: page1.nextCursor!,
});
expect(page2.messages.map(message => message.content)).toEqual(['msg-1', 'msg-2']);
expect(page2.nextCursor).not.toBeNull();

const page3 = await caller.quickChat.listMessages({
organizationId: null,
limit: 2,
cursor: page2.nextCursor!,
});
expect(page3.messages.map(message => message.content)).toEqual(['msg-0']);
expect(page3.nextCursor).toBeNull();
});

it('pages two messages that share a created_at without skipping one', async () => {
const user = await insertTestUser();
const caller = createCaller({ user });

const thread = await caller.quickChat.getOrCreateThread({ organizationId: null });
const sharedTime = '2026-02-02T00:00:00.000Z';
const ids = ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'];
for (let i = 0; i < ids.length; i++) {
await db.insert(quick_chat_messages).values({
id: ids[i],
thread_id: thread.id,
role: 'user',
content: `msg-${i}`,
created_at: sharedTime,
});
}

const page1 = await caller.quickChat.listMessages({ organizationId: null, limit: 1 });
expect(page1.messages).toHaveLength(1);
expect(page1.nextCursor).not.toBeNull();

const page2 = await caller.quickChat.listMessages({
organizationId: null,
limit: 1,
cursor: page1.nextCursor!,
});
expect(page2.messages).toHaveLength(1);
expect(page2.nextCursor).toBeNull();

const contents = [page1.messages[0]!.content, page2.messages[0]!.content].sort();
expect(contents).toEqual(['msg-0', 'msg-1']);
});

it('does not let a second user read the first user thread', async () => {
const user = await insertTestUser();
const otherUser = await insertTestUser();
const caller = createCaller({ user });
const otherCaller = createCaller({ user: otherUser });

await caller.quickChat.getOrCreateThread({ organizationId: null });
await caller.quickChat.appendMessages({
organizationId: null,
messages: [{ role: 'user', content: 'secret' }],
});

const result = await otherCaller.quickChat.listMessages({ organizationId: null });
expect(result.messages).toHaveLength(0);

const otherThread = await otherCaller.quickChat.getOrCreateThread({ organizationId: null });
const [firstThread] = await db
.select()
.from(quick_chat_threads)
.where(eq(quick_chat_threads.user_id, user.id));
expect(otherThread.id).not.toBe(firstThread.id);
});
});
Loading