diff --git a/packages/backend/server/migrations/20250625070609_ai_session_updated_at/migration.sql b/packages/backend/server/migrations/20250625070609_ai_session_updated_at/migration.sql new file mode 100644 index 000000000..3fac9cd02 --- /dev/null +++ b/packages/backend/server/migrations/20250625070609_ai_session_updated_at/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "ai_sessions_metadata" ADD COLUMN "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- DropIndex +DROP INDEX IF EXISTS "ai_session_unique_doc_session_idx"; \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index de3ad5f44..aad496f16 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -443,6 +443,7 @@ model AiSession { messageCost Int @default(0) tokenCost Int @default(0) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md index 996552706..45d7f5da7 100644 --- a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md +++ b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md @@ -67,6 +67,49 @@ Generated by [AVA](https://avajs.dev). }, ] +## should validate session prompt compatibility + +> session prompt validation results + + [ + { + promptType: 'non-action', + result: 'success', + sessionType: 'workspace', + shouldThrow: false, + }, + { + promptType: 'action', + result: 'CopilotPromptInvalid', + sessionType: 'workspace', + shouldThrow: true, + }, + { + promptType: 'non-action', + result: 'success', + sessionType: 'pinned', + shouldThrow: false, + }, + { + promptType: 'action', + result: 'CopilotPromptInvalid', + sessionType: 'pinned', + shouldThrow: true, + }, + { + promptType: 'non-action', + result: 'success', + sessionType: 'doc', + shouldThrow: false, + }, + { + promptType: 'action', + result: 'success', + sessionType: 'doc', + shouldThrow: false, + }, + ] + ## should pin and unpin sessions > session states after creating second pinned session @@ -105,6 +148,345 @@ Generated by [AVA](https://avajs.dev). }, ] +## should handle session updates and type conversions + +> session update validation results + + [ + { + result: 'rejected', + sessionType: 'action', + update: { + docId: 'new-doc', + }, + }, + { + result: 'rejected', + sessionType: 'action', + update: { + pinned: true, + }, + }, + { + result: 'rejected', + sessionType: 'action', + update: { + promptName: 'test-prompt', + }, + }, + { + result: 'success', + sessionType: 'forked', + update: { + pinned: true, + }, + }, + { + result: 'success', + sessionType: 'forked', + update: { + promptName: 'test-prompt', + }, + }, + { + result: 'rejected', + sessionType: 'forked', + update: { + docId: 'new-doc', + }, + }, + { + result: 'success', + sessionType: 'regular', + update: { + promptName: 'test-prompt', + }, + }, + { + result: 'rejected', + sessionType: 'regular', + update: { + promptName: 'action-prompt', + }, + }, + { + result: 'rejected', + sessionType: 'regular', + update: { + promptName: 'non-existent-prompt', + }, + }, + ] + +> pinning behavior - should unpin existing when pinning new + + { + onlyOneSessionPinned: true, + pinnedSessions: 1, + totalSessions: 2, + unpinnedSessions: 1, + } + +> session type conversion steps + + [ + { + sessionState: { + hasDocId: true, + pinned: false, + }, + step: 'workspace_to_doc', + type: 'doc', + }, + { + sessionState: { + hasDocId: false, + pinned: false, + }, + step: 'doc_to_workspace', + type: 'workspace', + }, + { + sessionState: { + hasDocId: false, + pinned: true, + }, + step: 'workspace_to_pinned', + type: 'pinned', + }, + ] + +## should handle session queries, ordering, and filtering + +> comprehensive session query results + + { + all_workspace_sessions: { + count: 2, + sessionTypes: [ + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'pinned', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'workspace', + }, + ], + }, + doc_sessions_with_messages: { + count: 5, + sessionTypes: [ + { + hasMessages: false, + isFork: true, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: true, + isFork: false, + messageCount: 1, + type: 'doc', + }, + { + hasMessages: true, + isFork: false, + messageCount: 1, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: true, + isFork: false, + messageCount: 1, + type: 'doc', + }, + ], + }, + latest_valid_session: { + count: 1, + sessionTypes: [ + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + ], + }, + non_action_sessions: { + count: 4, + sessionTypes: [ + { + hasMessages: false, + isFork: true, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + ], + }, + non_fork_sessions: { + count: 4, + sessionTypes: [ + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'doc', + }, + ], + }, + recent_top3_sessions: { + count: 2, + sessionTypes: [ + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'pinned', + }, + { + hasMessages: false, + isFork: false, + messageCount: 0, + type: 'workspace', + }, + ], + }, + } + +> session type identification results + + [ + { + session: { + docId: null, + pinned: false, + }, + type: 'workspace', + }, + { + session: { + docId: undefined, + pinned: false, + }, + type: 'workspace', + }, + { + session: { + docId: null, + pinned: true, + }, + type: 'pinned', + }, + { + session: { + docId: 'test-doc-id', + pinned: false, + }, + type: 'doc', + }, + ] + +## should handle fork and session attachment operations + +> fork operation results + + { + existingPinnedSessionUnpinned: true, + forkResults: [ + { + actualState: { + hasDocId: false, + hasParent: true, + isDocIdCorrect: true, + pinned: false, + }, + description: 'workspace fork', + success: true, + }, + { + actualState: { + hasDocId: true, + hasParent: true, + isDocIdCorrect: true, + pinned: false, + }, + description: 'doc fork', + success: true, + }, + { + actualState: { + hasDocId: false, + hasParent: true, + isDocIdCorrect: true, + pinned: true, + }, + description: 'pinned fork', + success: true, + }, + ], + } + +> attach and detach operation results + + { + attachPhase: { + bothSessionsPresent: true, + docSessionCount: 2, + }, + detachPhase: { + originalDocSessionRemains: true, + workspaceSessionExists: true, + }, + } + ## should handle session updates and validations > should unpin existing when pinning new session @@ -130,7 +512,7 @@ Generated by [AVA](https://avajs.dev). docId: 'doc-update-id', pinned: false, }, - step: 'pinned_to_doc', + step: 'workspace_to_doc', type: 'doc', }, { @@ -151,64 +533,55 @@ Generated by [AVA](https://avajs.dev). }, ] -## session updates and type conversions +## should create multiple doc sessions and query latest -> session states after pinning - should unpin existing +> multiple doc sessions for same document with order verification [ { - docId: null, - id: 'session-update-id', - pinned: true, + docId: 'multi-session-doc', + hasMessages: true, + isFirstSession: false, + isSecondSession: false, + isThirdSession: true, + messageCount: 1, }, { - docId: null, - id: 'existing-pinned-session-id', - pinned: false, + docId: 'multi-session-doc', + hasMessages: true, + isFirstSession: false, + isSecondSession: true, + isThirdSession: false, + messageCount: 1, + }, + { + docId: 'multi-session-doc', + hasMessages: true, + isFirstSession: true, + isSecondSession: false, + isThirdSession: false, + messageCount: 1, }, ] -> session state after unpinning +## should query recent topK sessions of different types - { - docId: null, - id: 'session-update-id', - pinned: false, - } - -> session type conversion steps +> should include different session types in recent topK query [ { - session: { - docId: 'doc-update-id', - pinned: false, - }, - step: 'workspace_to_doc', - type: 'doc', - }, - { - session: { - docId: 'doc-update-id', - pinned: true, - }, - step: 'doc_to_pinned', - type: 'pinned', - }, - { - session: { - docId: null, - pinned: false, - }, - step: 'pinned_to_workspace', + docId: null, + pinned: false, type: 'workspace', }, { - session: { - docId: null, - pinned: true, - }, - step: 'workspace_to_pinned', + docId: null, + pinned: true, type: 'pinned', }, + { + docId: null, + pinned: false, + type: 'workspace', + }, ] diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap index 441c7f561..385eed316 100644 Binary files a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap and b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts index ddf01ed31..d521daa11 100644 --- a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts +++ b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts @@ -47,13 +47,20 @@ test.after(async t => { await t.context.module.close(); }); +// Test data constants +const TEST_PROMPTS = { + NORMAL: 'test-prompt', + ACTION: 'action-prompt', +} as const; + +// Helper functions const createTestPrompts = async ( copilotSession: CopilotSessionModel, db: PrismaClient ) => { - await copilotSession.createPrompt('test-prompt', 'gpt-4.1'); + await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-4.1'); await db.aiPrompt.create({ - data: { name: 'action-prompt', model: 'gpt-4.1', action: 'edit' }, + data: { name: TEST_PROMPTS.ACTION, model: 'gpt-4.1', action: 'edit' }, }); }; @@ -75,7 +82,7 @@ const createTestSession = async ( workspaceId: workspace.id, docId: null, pinned: false, - promptName: 'test-prompt', + promptName: TEST_PROMPTS.NORMAL, promptAction: null, ...overrides, }; @@ -84,14 +91,62 @@ const createTestSession = async ( return sessionData; }; -const getSessionState = async (db: PrismaClient, sessionId: string) => { - const session = await db.aiSession.findUnique({ - where: { id: sessionId }, - select: { id: true, pinned: true, docId: true }, - }); - return session; +const getSessionStates = async (db: PrismaClient, sessionIds: string[]) => { + const sessions = await Promise.all( + sessionIds.map(id => + db.aiSession.findUnique({ + where: { id }, + select: { id: true, pinned: true, docId: true }, + }) + ) + ); + return sessions; }; +const addMessagesToSession = async ( + copilotSession: CopilotSessionModel, + sessionId: string, + content: string, + delayMs: number = 0 +) => { + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + await copilotSession.updateMessages({ + sessionId, + userId: user.id, + prompt: { model: 'gpt-4.1' }, + messages: [ + { + role: 'user', + content, + createdAt: new Date(), + }, + ], + }); +}; + +const createSessionWithMessages = async ( + t: ExecutionContext, + overrides: Parameters[1] = {}, + messageContent?: string, + delayMs: number = 0 +) => { + const sessionData = await createTestSession(t, overrides); + if (messageContent) { + await addMessagesToSession( + t.context.copilotSession, + sessionData.sessionId, + messageContent, + delayMs + ); + } + return sessionData; +}; + +// Simplified update assertion helpers +type UpdateData = Omit; + test('should list and filter session type', async t => { const { copilotSession, db } = t.context; @@ -128,12 +183,23 @@ test('should list and filter session type', async t => { docId, }); + t.is( + docSessions.length, + 2, + 'should return exactly 2 doc sessions for the specified docId' + ); + + t.true( + docSessions.every(s => s.docId === docId), + 'all returned sessions should have the specified docId' + ); + t.snapshot( cleanObject( - docSessions.toSorted(s => - s.docId!.localeCompare(s.docId!, undefined, { numeric: true }) + docSessions.toSorted((a, b) => + a.promptName.localeCompare(b.promptName) ), - ['id', 'userId', 'workspaceId', 'createdAt', 'tokenCost'] + ['id', 'userId', 'workspaceId', 'createdAt', 'updatedAt', 'tokenCost'] ), 'doc sessions should only include sessions with matching docId' ); @@ -158,65 +224,58 @@ test('should list and filter session type', async t => { } }); -test('should check session validation for prompts', async t => { +test('should validate session prompt compatibility', async t => { const { copilotSession, db } = t.context; - await createTestPrompts(copilotSession, db); - const docId = randomUUID(); const sessionTypes = [ { name: 'workspace', session: { docId: null, pinned: false } }, { name: 'pinned', session: { docId: null, pinned: true } }, - { name: 'doc', session: { docId, pinned: false } }, + { name: 'doc', session: { docId: randomUUID(), pinned: false } }, ]; - // non-action prompts should work for all session types - sessionTypes.forEach(({ name, session }) => { - t.notThrows( - () => - copilotSession.checkSessionPrompt(session, { - name: 'test-prompt', - action: undefined, - }), - `${name} session should allow non-action prompts` - ); - }); + const result = sessionTypes.flatMap(({ name, session }) => [ + // non-action prompts should work for all session types + { + sessionType: name, + promptType: 'non-action', + shouldThrow: false, + result: (() => { + try { + copilotSession.checkSessionPrompt(session, { + name: TEST_PROMPTS.NORMAL, + action: undefined, + }); + return 'success'; + } catch (error) { + return error instanceof CopilotPromptInvalid + ? 'CopilotPromptInvalid' + : 'unknown'; + } + })(), + }, + // action prompts should only work for doc session type + { + sessionType: name, + promptType: 'action', + shouldThrow: name !== 'doc', + result: (() => { + try { + copilotSession.checkSessionPrompt(session, { + name: TEST_PROMPTS.ACTION, + action: 'edit', + }); + return 'success'; + } catch (error) { + return error instanceof CopilotPromptInvalid + ? 'CopilotPromptInvalid' + : 'unknown'; + } + })(), + }, + ]); - // action prompts should only work for doc session type - { - const actionPromptTests = [ - { - name: 'workspace', - session: sessionTypes[0].session, - shouldThrow: true, - }, - { name: 'pinned', session: sessionTypes[1].session, shouldThrow: true }, - { name: 'doc', session: sessionTypes[2].session, shouldThrow: false }, - ]; - - actionPromptTests.forEach(({ name, session, shouldThrow }) => { - if (shouldThrow) { - t.throws( - () => - copilotSession.checkSessionPrompt(session, { - name: 'action-prompt', - action: 'edit', - }), - { instanceOf: CopilotPromptInvalid }, - `${name} session should reject action prompts` - ); - } else { - t.notThrows( - () => - copilotSession.checkSessionPrompt(session, { - name: 'action-prompt', - action: 'edit', - }), - `${name} session should allow action prompts` - ); - } - }); - } + t.snapshot(result, 'session prompt validation results'); }); test('should pin and unpin sessions', async t => { @@ -255,9 +314,9 @@ test('should pin and unpin sessions', async t => { pinned: true, }); - const sessionStatesAfterSecondPin = await Promise.all([ - getSessionState(db, firstSessionId), - getSessionState(db, secondSessionId), + const sessionStatesAfterSecondPin = await getSessionStates(db, [ + firstSessionId, + secondSessionId, ]); t.snapshot( @@ -298,177 +357,553 @@ test('should pin and unpin sessions', async t => { } }); -test('should handle session updates and validations', async t => { +test('should handle session updates and type conversions', async t => { const { copilotSession, db } = t.context; await createTestPrompts(copilotSession, db); - const sessionId = 'session-update-id'; - const actionSessionId = 'action-session-id'; - const parentSessionId = 'parent-session-id'; - const forkedSessionId = 'forked-session-id'; - const docId = 'doc-update-id'; + const sessionId = randomUUID(); + const actionSessionId = randomUUID(); + const forkedSessionId = randomUUID(); + const parentSessionId = randomUUID(); + const docId = randomUUID(); - await createTestSession(t, { sessionId }); - await createTestSession(t, { - sessionId: actionSessionId, - promptName: 'action-prompt', - promptAction: 'edit', - docId: 'some-doc', - }); - await createTestSession(t, { - sessionId: parentSessionId, - docId: 'parent-doc', - }); + { + await createTestSession(t, { sessionId }); + await createTestSession(t, { + sessionId: actionSessionId, + promptName: TEST_PROMPTS.ACTION, + promptAction: 'edit', + docId, + }); + await createTestSession(t, { sessionId: parentSessionId, docId }); + await db.aiSession.create({ + data: { + id: forkedSessionId, + workspaceId: workspace.id, + userId: user.id, + docId, + pinned: false, + promptName: TEST_PROMPTS.NORMAL, + promptAction: null, + parentSessionId, + }, + }); + } + + const updateTestCases = [ + // action sessions should reject all updates + { + sessionId: actionSessionId, + updates: [ + { docId: 'new-doc', expected: 'reject' }, + { pinned: true, expected: 'reject' }, + { promptName: TEST_PROMPTS.NORMAL, expected: 'reject' }, + ], + }, + // forked sessions should reject docId updates but allow others + { + sessionId: forkedSessionId, + updates: [ + { pinned: true, expected: 'allow' }, + { promptName: TEST_PROMPTS.NORMAL, expected: 'allow' }, + { docId: 'new-doc', expected: 'reject' }, + ], + }, + // Regular sessions - prompt validation + { + sessionId, + updates: [ + { promptName: TEST_PROMPTS.NORMAL, expected: 'allow' }, + { promptName: TEST_PROMPTS.ACTION, expected: 'reject' }, + { promptName: 'non-existent-prompt', expected: 'reject' }, + ], + }, + ]; + + const updateResults = []; + for (const { sessionId: testSessionId, updates } of updateTestCases) { + for (const update of updates) { + const { expected: _, ...updateData } = update; + try { + await t.context.copilotSession.update({ + ...updateData, + userId: user.id, + sessionId: testSessionId, + }); + updateResults.push({ + sessionType: + testSessionId === actionSessionId + ? 'action' + : testSessionId === forkedSessionId + ? 'forked' + : 'regular', + update: updateData, + result: 'success', + }); + } catch (error) { + updateResults.push({ + sessionType: + testSessionId === actionSessionId + ? 'action' + : testSessionId === forkedSessionId + ? 'forked' + : 'regular', + update: updateData, + result: + error instanceof CopilotSessionInvalidInput ? 'rejected' : 'error', + }); + } + } + } + + t.snapshot(updateResults, 'session update validation results'); + + // session type conversions + const existingPinnedId = randomUUID(); + await createTestSession(t, { sessionId: existingPinnedId, pinned: true }); + + await copilotSession.update({ userId: user.id, sessionId, pinned: true }); + + // pinning behavior + const states = await getSessionStates(db, [sessionId, existingPinnedId]); + const pinnedCount = states.filter(s => s?.pinned).length; + const unpinnedCount = states.filter(s => s && !s.pinned).length; + + t.snapshot( + { + totalSessions: states.length, + pinnedSessions: pinnedCount, + unpinnedSessions: unpinnedCount, + onlyOneSessionPinned: pinnedCount === 1, + }, + 'pinning behavior - should unpin existing when pinning new' + ); + + // type conversions + const conversionSteps = []; + const conversions: Array<[string, UpdateData]> = [ + ['workspace_to_doc', { docId, pinned: false }], + ['doc_to_workspace', { docId: null }], + ['workspace_to_pinned', { pinned: true }], + ]; + + for (const [step, data] of conversions) { + await copilotSession.update({ userId: user.id, sessionId, ...data }); + const session = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { docId: true, pinned: true }, + }); + conversionSteps.push({ + step, + sessionState: { + hasDocId: !!session?.docId, + pinned: !!session?.pinned, + }, + type: copilotSession.getSessionType(session!), + }); + } + + t.snapshot(conversionSteps, 'session type conversion steps'); +}); + +test('should handle session queries, ordering, and filtering', async t => { + const { copilotSession, db } = t.context; + await createTestPrompts(copilotSession, db); + + const docId = randomUUID(); + const sessionIds: string[] = []; + const sessionConfigs = [ + { type: 'workspace', config: { docId: null, pinned: false } }, + { type: 'pinned', config: { docId: null, pinned: true } }, + { type: 'doc', config: { docId, pinned: false }, withMessages: true }, + { + type: 'action', + config: { docId, promptName: TEST_PROMPTS.ACTION, promptAction: 'edit' }, + }, + ]; + + // create sessions with timing delays for ordering tests + for (let i = 0; i < sessionConfigs.length; i++) { + const { config, withMessages } = sessionConfigs[i]; + const sessionId = randomUUID(); + sessionIds.push(sessionId); + + if (withMessages) { + await createSessionWithMessages( + t, + { sessionId, ...config }, + `Message for session ${i}`, + 100 * i + ); + } else { + await createTestSession(t, { sessionId, ...config }); + } + } + + // Create additional doc sessions for multiple doc test + for (let i = 0; i < 2; i++) { + const sessionId = randomUUID(); + sessionIds.push(sessionId); + await createSessionWithMessages( + t, + { sessionId, docId }, + `Additional doc message ${i}`, + 200 + 100 * i + ); + } + + // create fork session + const parentSessionId = sessionIds[2]; // use first doc session as parent + const forkedSessionId = randomUUID(); await db.aiSession.create({ data: { id: forkedSessionId, workspaceId: workspace.id, userId: user.id, - docId: 'forked-doc', + docId, pinned: false, - promptName: 'test-prompt', + promptName: TEST_PROMPTS.NORMAL, promptAction: null, - parentSessionId: parentSessionId, + parentSessionId, }, }); - type UpdateData = Omit; - const assertUpdateThrows = async ( - t: ExecutionContext, - sessionId: string, - updateData: UpdateData, - message: string - ) => { - await t.throwsAsync( - t.context.copilotSession.update({ - ...updateData, - userId: user.id, - sessionId, - }), - { instanceOf: CopilotSessionInvalidInput }, - message - ); - }; + const baseParams = { userId: user.id, workspaceId: workspace.id }; + const docParams = { ...baseParams, docId }; + const queryTestCases = [ + { name: 'all_workspace_sessions', params: baseParams }, + { + name: 'doc_sessions_with_messages', + params: { ...docParams, withMessages: true }, + }, + { + name: 'recent_top3_sessions', + params: { ...baseParams, limit: 3, sessionOrder: 'desc' as const }, + }, + { + name: 'non_action_sessions', + params: { ...docParams, action: false }, + }, + { name: 'non_fork_sessions', params: { ...docParams, fork: false } }, + { + name: 'latest_valid_session', + params: { + ...docParams, + limit: 1, + sessionOrder: 'desc' as const, + action: false, + fork: false, + }, + }, + ]; - const assertUpdate = async ( - t: ExecutionContext, - sessionId: string, - updateData: UpdateData, - message: string - ) => { - await t.notThrowsAsync( - t.context.copilotSession.update({ - ...updateData, - userId: user.id, - sessionId, - }), - message - ); - }; + const queryResults: Record = {}; + for (const { name, params } of queryTestCases) { + const sessions = await copilotSession.list(params); + queryResults[name] = { + count: sessions.length, + sessionTypes: sessions.map(s => ({ + type: copilotSession.getSessionType(s), + hasMessages: !!s.messages?.length, + messageCount: s.messages?.length || 0, + isFork: !!s.parentSessionId, + })), + }; + } - // case 1: action sessions should reject all updates + t.snapshot(queryResults, 'comprehensive session query results'); + + // should list sessions appear in correct order { - const actionUpdates = [ - { docId: 'new-doc' }, - { pinned: true }, - { promptName: 'test-prompt' }, - ]; - for (const data of actionUpdates) { - await assertUpdateThrows( - t, - actionSessionId, - data, - `action session should reject update: ${JSON.stringify(data)}` + const docSessionsWithMessages = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId, + withMessages: true, + sessionOrder: 'desc', + }); + + // check sessions are returned in desc order by updatedAt + if (docSessionsWithMessages.length > 1) { + for (let i = 1; i < docSessionsWithMessages.length; i++) { + const currentSession = docSessionsWithMessages[i - 1]; + const nextSession = docSessionsWithMessages[i]; + t.true( + currentSession.updatedAt >= nextSession.updatedAt, + `sessions should be ordered by updatedAt desc: ${currentSession.updatedAt} >= ${nextSession.updatedAt}` + ); + } + } + } + + // should update `updatedAt` when updating messages + { + const oldestDocSession = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId, + sessionOrder: 'asc', + limit: 1, + }); + + if (oldestDocSession.length > 0) { + const sessionId = oldestDocSession[0].id; + + // get initial updatedAt + const sessionBeforeUpdate = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { updatedAt: true }, + }); + + await new Promise(resolve => setTimeout(resolve, 100)); + await addMessagesToSession( + copilotSession, + sessionId, + 'Update to verify sorting' + ); + + const sessionAfterUpdate = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { updatedAt: true }, + }); + t.true( + sessionAfterUpdate!.updatedAt > sessionBeforeUpdate!.updatedAt, + 'updatedAt should be updated after adding messages' + ); + + // the updated session now should appears first in desc order + const sessionsAfterUpdate = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId, + sessionOrder: 'desc', + }); + t.is( + sessionsAfterUpdate[0].id, + sessionId, + 'session with updated messages should appear first in descending order' ); } } - // case 2: forked sessions should reject docId updates but allow others + // should get latest valid session { - await assertUpdate( - t, - forkedSessionId, - { pinned: true }, - 'forked session should allow pinned update' - ); - await assertUpdate( - t, - forkedSessionId, - { promptName: 'test-prompt' }, - 'forked session should allow promptName update' - ); - await assertUpdateThrows( - t, - forkedSessionId, - { docId: 'new-doc' }, - 'forked session should reject docId update' - ); - } - { - // case 3: prompt update validation - await assertUpdate( - t, - sessionId, - { promptName: 'test-prompt' }, - 'should allow valid non-action prompt' - ); - await assertUpdateThrows( - t, - sessionId, - { promptName: 'action-prompt' }, - 'should reject action prompt' - ); - await assertUpdateThrows( - t, - sessionId, - { promptName: 'non-existent-prompt' }, - 'should reject non-existent prompt' - ); - } + const latestValidSessions = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId, + limit: 1, + sessionOrder: 'desc', + action: false, + fork: false, + }); - // cest 4: session type conversions and pinning behavior - { - const existingPinnedId = 'existing-pinned-session-id'; - await createTestSession(t, { sessionId: existingPinnedId, pinned: true }); + if (latestValidSessions.length > 0) { + const latestSession = latestValidSessions[0]; - // should unpin existing when pinning new session - await copilotSession.update({ userId: user.id, sessionId, pinned: true }); + // verify this is indeed a non-action, non-fork session + t.falsy( + latestSession.parentSessionId, + 'latest session should not be a fork' + ); + t.not( + latestSession.promptName, + TEST_PROMPTS.ACTION, + 'latest session should not use action prompt' + ); - t.snapshot( - [ - await getSessionState(db, sessionId), - await getSessionState(db, existingPinnedId), - ], - 'should unpin existing when pinning new session' - ); - } - - // test type conversions - { - const conversionSteps: any[] = []; - const convertSession = async (step: string, data: UpdateData) => { - await copilotSession.update({ ...data, userId: user.id, sessionId }); - const session = await db.aiSession.findUnique({ - where: { id: sessionId }, - select: { docId: true, pinned: true }, + // verify it's the most recently updated among valid sessions + const allValidSessions = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId, + action: false, + fork: false, + sessionOrder: 'desc', }); - conversionSteps.push({ - step, - session, - type: copilotSession.getSessionType(session!), - }); - }; - const conversions = [ - ['pinned_to_doc', { docId, pinned: false }], - ['doc_to_workspace', { docId: null }], - ['workspace_to_pinned', { pinned: true }], - ] as const; - - for (const [step, data] of conversions) { - await convertSession(step, data); + if (allValidSessions.length > 0) { + t.is( + allValidSessions[0].id, + latestSession.id, + 'latest valid session should be the first in the ordered list' + ); + } } - - t.snapshot(conversionSteps, 'session type conversion steps'); } + + // session type identification + const sessionTypeTests = [ + { docId: null, pinned: false }, + { docId: undefined, pinned: false }, + { docId: null, pinned: true }, + { docId: 'test-doc-id', pinned: false }, + ]; + + const sessionTypeResults = sessionTypeTests.map(session => ({ + session, + type: copilotSession.getSessionType(session), + })); + + t.snapshot(sessionTypeResults, 'session type identification results'); +}); + +test('should handle fork and session attachment operations', async t => { + const { copilotSession } = t.context; + await createTestPrompts(copilotSession, t.context.db); + + const parentSessionId = randomUUID(); + const docId = randomUUID(); + + await createSessionWithMessages( + t, + { sessionId: parentSessionId, docId }, + 'Original message' + ); + + const forkTestCases = [ + { + sessionId: randomUUID(), + docId: null, + pinned: false, + description: 'workspace fork', + }, + { sessionId: randomUUID(), docId, pinned: false, description: 'doc fork' }, + { + sessionId: randomUUID(), + docId: null, + pinned: true, + description: 'pinned fork', + }, + ]; + + // test unpinning behavior + const existingPinnedId = randomUUID(); + await createTestSession(t, { sessionId: existingPinnedId, pinned: true }); + + const performForkOperation = async ( + copilotSession: CopilotSessionModel, + parentSessionId: string, + forkConfig: { + sessionId: string; + docId: string | null; + pinned: boolean; + } + ) => { + return await copilotSession.fork({ + sessionId: forkConfig.sessionId, + userId: user.id, + workspaceId: workspace.id, + docId: forkConfig.docId, + pinned: forkConfig.pinned, + parentSessionId, + prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' }, + messages: [ + { + role: 'user', + content: 'Original message', + createdAt: new Date(), + }, + ], + }); + }; + + // fork operations + const forkResults = await Promise.all( + forkTestCases.map(async test => { + const returnedId = await performForkOperation( + copilotSession, + parentSessionId, + test + ); + const forkedSession = await copilotSession.get(test.sessionId); + return { + description: test.description, + success: returnedId === test.sessionId, + actualState: forkedSession + ? { + hasDocId: !!forkedSession.docId, + isDocIdCorrect: forkedSession.docId === test.docId, + pinned: forkedSession.pinned, + hasParent: !!forkedSession.parentSessionId, + } + : null, + }; + }) + ); + + // check if pinned fork unpinned existing session + const originalPinned = await copilotSession.get(existingPinnedId); + + t.snapshot( + { + forkResults, + existingPinnedSessionUnpinned: !originalPinned?.pinned, + }, + 'fork operation results' + ); + + // attach/detach operations + const workspaceSessionId = randomUUID(); + const existingDocSessionId = randomUUID(); + const attachTestDocId = randomUUID(); + + // sessions for attach/detach test + await createTestSession(t, { sessionId: workspaceSessionId, docId: null }); + await createTestSession(t, { + sessionId: existingDocSessionId, + docId: attachTestDocId, + }); + + // attach: workspace -> doc + await copilotSession.update({ + userId: user.id, + sessionId: workspaceSessionId, + docId: attachTestDocId, + }); + + const docSessionsAfterAttach = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId: attachTestDocId, + }); + + // detach: doc -> workspace + await copilotSession.update({ + userId: user.id, + sessionId: workspaceSessionId, + docId: null, + }); + + const workspaceSessionsAfterDetach = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId: null, + }); + + const remainingDocSessions = await copilotSession.list({ + userId: user.id, + workspaceId: workspace.id, + docId: attachTestDocId, + }); + + t.snapshot( + { + attachPhase: { + docSessionCount: docSessionsAfterAttach.length, + bothSessionsPresent: + docSessionsAfterAttach.some(s => s.id === workspaceSessionId) && + docSessionsAfterAttach.some(s => s.id === existingDocSessionId), + }, + detachPhase: { + workspaceSessionExists: workspaceSessionsAfterDetach.some( + s => s.id === workspaceSessionId && !s.pinned + ), + originalDocSessionRemains: + remainingDocSessions.length === 1 && + remainingDocSessions[0].id === existingDocSessionId, + }, + }, + 'attach and detach operation results' + ); }); diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index 521f865f3..e57f8a911 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -206,6 +206,7 @@ export class CopilotSessionModel extends BaseModel { // save message await this.models.copilotSession.updateMessages({ ...forkedState, + sessionId, messages, }); return sessionId; @@ -286,18 +287,37 @@ export class CopilotSessionModel extends BaseModel { async list(options: ListSessionOptions) { const { userId, sessionId, workspaceId, docId } = options; - const extraCondition = []; + const conditions: Prisma.AiSessionWhereInput['OR'] = [ + { + userId, + workspaceId, + docId: docId ?? null, + id: sessionId ? { equals: sessionId } : undefined, + deletedAt: null, + prompt: + typeof options.action === 'boolean' + ? options.action + ? { action: { not: null } } + : { action: null } + : undefined, + parentSessionId: + typeof options.fork === 'boolean' + ? options.fork + ? { not: null } + : null + : undefined, + }, + ]; if (!options?.action && options?.fork) { + // query forked sessions from other users // only query forked session if fork == true and action == false - extraCondition.push({ + conditions.push({ userId: { not: userId }, workspaceId: workspaceId, docId: docId ?? null, id: sessionId ? { equals: sessionId } : undefined, - prompt: { - action: options.action ? { not: null } : null, - }, + prompt: { action: null }, // should only find forked session parentSessionId: { not: null }, deletedAt: null, @@ -305,18 +325,7 @@ export class CopilotSessionModel extends BaseModel { } return await this.db.aiSession.findMany({ - where: { - OR: [ - { - userId, - workspaceId, - docId: docId ?? null, - id: sessionId ? { equals: sessionId } : undefined, - deletedAt: null, - }, - ...extraCondition, - ], - }, + where: { OR: conditions }, select: { id: true, userId: true, @@ -327,6 +336,7 @@ export class CopilotSessionModel extends BaseModel { promptName: true, tokenCost: true, createdAt: true, + updatedAt: true, messages: options.withMessages ? { select: { @@ -348,8 +358,7 @@ export class CopilotSessionModel extends BaseModel { take: options?.limit, skip: options?.skip, orderBy: { - // session order is desc by default - createdAt: options?.sessionOrder === 'asc' ? 'asc' : 'desc', + updatedAt: options?.sessionOrder === 'asc' ? 'asc' : 'desc', }, }); } diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index 51832d0b4..439a3ecc3 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -235,6 +235,12 @@ class CopilotHistoriesType implements Partial { @Field(() => String) sessionId!: string; + @Field(() => String) + workspaceId!: string; + + @Field(() => String, { nullable: true }) + docId!: string | null; + @Field(() => Boolean) pinned!: boolean; @@ -254,6 +260,9 @@ class CopilotHistoriesType implements Partial { @Field(() => Date) createdAt!: Date; + + @Field(() => Date) + updatedAt!: Date; } @ObjectType('CopilotQuota') diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index d072e74ca..3a2c57847 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -298,13 +298,16 @@ export class ChatSessionService { const histories = await Promise.all( sessions.map( async ({ - id, userId: uid, + id, + workspaceId, + docId, pinned, promptName, tokenCost, messages, createdAt, + updatedAt, }) => { try { const prompt = await this.prompt.get(promptName); @@ -341,10 +344,13 @@ export class ChatSessionService { return { sessionId: id, + workspaceId, + docId, pinned, action: prompt.action || null, tokens: tokenCost, createdAt, + updatedAt, messages: preload.concat(ret.data).map(m => ({ ...m, attachments: m.attachments diff --git a/packages/backend/server/src/plugins/copilot/types.ts b/packages/backend/server/src/plugins/copilot/types.ts index c5465b860..4071dacc7 100644 --- a/packages/backend/server/src/plugins/copilot/types.ts +++ b/packages/backend/server/src/plugins/copilot/types.ts @@ -47,11 +47,14 @@ export type ChatMessage = z.infer; export const ChatHistorySchema = z .object({ sessionId: z.string(), + workspaceId: z.string(), + docId: z.string().nullable(), pinned: z.boolean(), action: z.string().nullable(), tokens: z.number(), messages: z.array(ChatMessageSchema), createdAt: z.date(), + updatedAt: z.date(), }) .strict(); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index c5615615c..72c861b92 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -237,12 +237,15 @@ type CopilotHistories { """An mark identifying which view to use to display the session""" action: String createdAt: DateTime! + docId: String messages: [ChatMessage!]! pinned: Boolean! sessionId: String! """The number of tokens used in the session""" tokens: Int! + updatedAt: DateTime! + workspaceId: String! } type CopilotInvalidContextDataType { @@ -253,22 +256,6 @@ type CopilotMessageNotFoundDataType { messageId: String! } -enum CopilotModels { - DallE3 - Gpt4Omni - Gpt4Omni0806 - Gpt4OmniMini - Gpt4OmniMini0718 - Gpt41 - Gpt41Mini - Gpt41Nano - Gpt410414 - GptImage - TextEmbedding3Large - TextEmbedding3Small - TextEmbeddingAda002 -} - input CopilotPromptConfigInput { frequencyPenalty: Float presencePenalty: Float @@ -408,7 +395,7 @@ input CreateCopilotPromptInput { action: String config: CopilotPromptConfigInput messages: [CopilotPromptMessageInput!]! - model: CopilotModels! + model: String! name: String! } diff --git a/packages/common/graphql/src/graphql/copilot-session-get-latest-doc.gql b/packages/common/graphql/src/graphql/copilot-session-get-latest-doc.gql new file mode 100644 index 000000000..43b9c0c14 --- /dev/null +++ b/packages/common/graphql/src/graphql/copilot-session-get-latest-doc.gql @@ -0,0 +1,35 @@ +query getCopilotLatestDocSession( + $workspaceId: String! + $docId: String! +) { + currentUser { + copilot(workspaceId: $workspaceId) { + histories( + docId: $docId + options: { + limit: 1 + sessionOrder: desc + action: false + fork: false + } + ) { + sessionId + workspaceId + docId + pinned + action + tokens + createdAt + updatedAt + messages { + id + role + content + attachments + params + createdAt + } + } + } + } +} diff --git a/packages/common/graphql/src/graphql/copilot-session-list-recent.gql b/packages/common/graphql/src/graphql/copilot-session-list-recent.gql new file mode 100644 index 000000000..34caa79e0 --- /dev/null +++ b/packages/common/graphql/src/graphql/copilot-session-list-recent.gql @@ -0,0 +1,24 @@ +query getCopilotRecentSessions( + $workspaceId: String! + $limit: Int = 10 +) { + currentUser { + copilot(workspaceId: $workspaceId) { + histories( + options: { + limit: $limit + sessionOrder: desc + } + ) { + sessionId + workspaceId + docId + pinned + action + tokens + createdAt + updatedAt + } + } + } +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 9e9c40b4b..eabc6c78f 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -755,6 +755,38 @@ export const forkCopilotSessionMutation = { }`, }; +export const getCopilotLatestDocSessionQuery = { + id: 'getCopilotLatestDocSessionQuery' as const, + op: 'getCopilotLatestDocSession', + query: `query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) { + currentUser { + copilot(workspaceId: $workspaceId) { + histories( + docId: $docId + options: {limit: 1, sessionOrder: desc, action: false, fork: false} + ) { + sessionId + workspaceId + docId + pinned + action + tokens + createdAt + updatedAt + messages { + id + role + content + attachments + params + createdAt + } + } + } + } +}`, +}; + export const getCopilotSessionQuery = { id: 'getCopilotSessionQuery' as const, op: 'getCopilotSession', @@ -775,6 +807,27 @@ export const getCopilotSessionQuery = { }`, }; +export const getCopilotRecentSessionsQuery = { + id: 'getCopilotRecentSessionsQuery' as const, + op: 'getCopilotRecentSessions', + query: `query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { + currentUser { + copilot(workspaceId: $workspaceId) { + histories(options: {limit: $limit, sessionOrder: desc}) { + sessionId + workspaceId + docId + pinned + action + tokens + createdAt + updatedAt + } + } + } +}`, +}; + export const updateCopilotSessionMutation = { id: 'updateCopilotSessionMutation' as const, op: 'updateCopilotSession', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 05a1baa13..bd97e7258 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -323,11 +323,14 @@ export interface CopilotHistories { /** An mark identifying which view to use to display the session */ action: Maybe; createdAt: Scalars['DateTime']['output']; + docId: Maybe; messages: Array; pinned: Scalars['Boolean']['output']; sessionId: Scalars['String']['output']; /** The number of tokens used in the session */ tokens: Scalars['Int']['output']; + updatedAt: Scalars['DateTime']['output']; + workspaceId: Scalars['String']['output']; } export interface CopilotInvalidContextDataType { @@ -340,22 +343,6 @@ export interface CopilotMessageNotFoundDataType { messageId: Scalars['String']['output']; } -export enum CopilotModels { - DallE3 = 'DallE3', - Gpt4Omni = 'Gpt4Omni', - Gpt4Omni0806 = 'Gpt4Omni0806', - Gpt4OmniMini = 'Gpt4OmniMini', - Gpt4OmniMini0718 = 'Gpt4OmniMini0718', - Gpt41 = 'Gpt41', - Gpt41Mini = 'Gpt41Mini', - Gpt41Nano = 'Gpt41Nano', - Gpt410414 = 'Gpt410414', - GptImage = 'GptImage', - TextEmbedding3Large = 'TextEmbedding3Large', - TextEmbedding3Small = 'TextEmbedding3Small', - TextEmbeddingAda002 = 'TextEmbeddingAda002', -} - export interface CopilotPromptConfigInput { frequencyPenalty?: InputMaybe; presencePenalty?: InputMaybe; @@ -515,7 +502,7 @@ export interface CreateCopilotPromptInput { action?: InputMaybe; config?: InputMaybe; messages: Array; - model: CopilotModels; + model: Scalars['String']['input']; name: Scalars['String']['input']; } @@ -3575,6 +3562,41 @@ export type ForkCopilotSessionMutation = { forkCopilotSession: string; }; +export type GetCopilotLatestDocSessionQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + docId: Scalars['String']['input']; +}>; + +export type GetCopilotLatestDocSessionQuery = { + __typename?: 'Query'; + currentUser: { + __typename?: 'UserType'; + copilot: { + __typename?: 'Copilot'; + histories: Array<{ + __typename?: 'CopilotHistories'; + sessionId: string; + workspaceId: string; + docId: string | null; + pinned: boolean; + action: string | null; + tokens: number; + createdAt: string; + updatedAt: string; + messages: Array<{ + __typename?: 'ChatMessage'; + id: string | null; + role: string; + content: string; + attachments: Array | null; + params: Record | null; + createdAt: string; + }>; + }>; + }; + } | null; +}; + export type GetCopilotSessionQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; sessionId: Scalars['String']['input']; @@ -3600,6 +3622,32 @@ export type GetCopilotSessionQuery = { } | null; }; +export type GetCopilotRecentSessionsQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; + limit?: InputMaybe; +}>; + +export type GetCopilotRecentSessionsQuery = { + __typename?: 'Query'; + currentUser: { + __typename?: 'UserType'; + copilot: { + __typename?: 'Copilot'; + histories: Array<{ + __typename?: 'CopilotHistories'; + sessionId: string; + workspaceId: string; + docId: string | null; + pinned: boolean; + action: string | null; + tokens: number; + createdAt: string; + updatedAt: string; + }>; + }; + } | null; +}; + export type UpdateCopilotSessionMutationVariables = Exact<{ options: UpdateChatSessionInput; }>; @@ -5209,11 +5257,21 @@ export type Queries = variables: CopilotQuotaQueryVariables; response: CopilotQuotaQuery; } + | { + name: 'getCopilotLatestDocSessionQuery'; + variables: GetCopilotLatestDocSessionQueryVariables; + response: GetCopilotLatestDocSessionQuery; + } | { name: 'getCopilotSessionQuery'; variables: GetCopilotSessionQueryVariables; response: GetCopilotSessionQuery; } + | { + name: 'getCopilotRecentSessionsQuery'; + variables: GetCopilotRecentSessionsQueryVariables; + response: GetCopilotRecentSessionsQuery; + } | { name: 'getCopilotSessionsQuery'; variables: GetCopilotSessionsQueryVariables;