From 06f27e8d6a4c9aacc3e9011a6e1c1eed31bfcf7c Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:15:31 +0800 Subject: [PATCH] feat(server): allow multiple session attach to doc (#12933) fix AI-236 --- .../migration.sql | 5 + packages/backend/server/schema.prisma | 1 + .../__snapshots__/copilot-session.spec.ts.md | 459 +++++++++- .../copilot-session.spec.ts.snap | Bin 1387 -> 3763 bytes .../__tests__/models/copilot-session.spec.ts | 853 +++++++++++++----- .../server/src/models/copilot-session.ts | 47 +- .../server/src/plugins/copilot/resolver.ts | 9 + .../server/src/plugins/copilot/session.ts | 8 +- .../server/src/plugins/copilot/types.ts | 3 + packages/backend/server/src/schema.gql | 21 +- .../copilot-session-get-latest-doc.gql | 35 + .../graphql/copilot-session-list-recent.gql | 24 + packages/common/graphql/src/graphql/index.ts | 53 ++ packages/common/graphql/src/schema.ts | 92 +- 14 files changed, 1304 insertions(+), 306 deletions(-) create mode 100644 packages/backend/server/migrations/20250625070609_ai_session_updated_at/migration.sql create mode 100644 packages/common/graphql/src/graphql/copilot-session-get-latest-doc.gql create mode 100644 packages/common/graphql/src/graphql/copilot-session-list-recent.gql 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 0000000000..3fac9cd021 --- /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 de3ad5f444..aad496f168 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 9965527069..45d7f5da75 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 441c7f561ee738a8a2ae35f525e70580baa195a0..385eed31663ae2a644766b9731ad639b2ce14f0f 100644 GIT binary patch literal 3763 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 literal 1387 zcmV-x1(fM2@*{0!GgNIa|@?O;xAr^?Y0e)Cfip4Tv8QWC9{_V>SUnP@)h8T?O1Ih=GuRpbHmqB{=o% zp6WKQE8P=aIGd@Oci+9;_uO~RnYU7(wOwP*KX#G}OW1sAtKV>2wyD{cFErgTwYp^s z?rA>veam$;vE1aA@90gx;R^qilcuQ)eVT4%7(wtuC1N`O>;mv8fC(yAsqmQR>B~gP zQ@LCQZ2&2tF`|9Z#xbIW;5UUiRsehq;Oitu@xbg0+z_OskCjR#4E+VbB>)O#LfP8T z@vvYS6Qpb+1}apkV7kT=X86X&Gi$}B^24H+6$24fVCS z;g)0AEt6~Qh@ksXk~BDJ_xnR>_ls$})l&dI1@HraKcp>JZzEtk0aFCb$=lBUxo?Fk zl}y*DS!V6tt9CH6xS{E}^Gz|O&-3I@t%e|5Dcod=LXVx|4A)Gf~!wYU#8%ar4Pjg_iCH(bZ;vs5{T`2GZ9U+9ASnqCill;Lq*1VkU@ z%m{ph;Qkap1;|w0zyaAb zuy6(rr_=CiTRcw3;!z^@6MzcR3bl@3SQ zx9^N9zMiw}dxRo~~&<$vgt{ z&`Kv|2RJ?m;1GagSsdu!X>27s(&3lv^dnCCZKl;vhI*~>UR#B}X { 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 521f865f3a..e57f8a9117 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 51832d0b4d..439a3ecc38 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 d072e74cac..3a2c578473 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 c5465b8602..4071dacc71 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 c5615615c0..72c861b921 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 0000000000..43b9c0c14d --- /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 0000000000..34caa79e07 --- /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 9e9c40b4b4..eabc6c78f0 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 05a1baa13c..bd97e72587 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;