feat(server): allow multiple session attach to doc (#12933)

fix AI-236
This commit is contained in:
DarkSky 2025-06-26 10:15:31 +08:00 committed by GitHub
parent e32c9a814a
commit 06f27e8d6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1304 additions and 306 deletions

View File

@ -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";

View File

@ -443,6 +443,7 @@ model AiSession {
messageCost Int @default(0) messageCost Int @default(0)
tokenCost Int @default(0) tokenCost Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) 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) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -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 ## should pin and unpin sessions
> session states after creating second pinned session > 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 handle session updates and validations
> should unpin existing when pinning new session > should unpin existing when pinning new session
@ -130,7 +512,7 @@ Generated by [AVA](https://avajs.dev).
docId: 'doc-update-id', docId: 'doc-update-id',
pinned: false, pinned: false,
}, },
step: 'pinned_to_doc', step: 'workspace_to_doc',
type: '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, docId: 'multi-session-doc',
id: 'session-update-id', hasMessages: true,
pinned: true, isFirstSession: false,
isSecondSession: false,
isThirdSession: true,
messageCount: 1,
}, },
{ {
docId: null, docId: 'multi-session-doc',
id: 'existing-pinned-session-id', hasMessages: true,
pinned: false, 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
{ > should include different session types in recent topK query
docId: null,
id: 'session-update-id',
pinned: false,
}
> session type conversion steps
[ [
{ {
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, docId: null,
pinned: false, pinned: false,
},
step: 'pinned_to_workspace',
type: 'workspace', type: 'workspace',
}, },
{ {
session: {
docId: null, docId: null,
pinned: true, pinned: true,
},
step: 'workspace_to_pinned',
type: 'pinned', type: 'pinned',
}, },
{
docId: null,
pinned: false,
type: 'workspace',
},
] ]

View File

@ -47,13 +47,20 @@ test.after(async t => {
await t.context.module.close(); await t.context.module.close();
}); });
// Test data constants
const TEST_PROMPTS = {
NORMAL: 'test-prompt',
ACTION: 'action-prompt',
} as const;
// Helper functions
const createTestPrompts = async ( const createTestPrompts = async (
copilotSession: CopilotSessionModel, copilotSession: CopilotSessionModel,
db: PrismaClient db: PrismaClient
) => { ) => {
await copilotSession.createPrompt('test-prompt', 'gpt-4.1'); await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-4.1');
await db.aiPrompt.create({ 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, workspaceId: workspace.id,
docId: null, docId: null,
pinned: false, pinned: false,
promptName: 'test-prompt', promptName: TEST_PROMPTS.NORMAL,
promptAction: null, promptAction: null,
...overrides, ...overrides,
}; };
@ -84,14 +91,62 @@ const createTestSession = async (
return sessionData; return sessionData;
}; };
const getSessionState = async (db: PrismaClient, sessionId: string) => { const getSessionStates = async (db: PrismaClient, sessionIds: string[]) => {
const session = await db.aiSession.findUnique({ const sessions = await Promise.all(
where: { id: sessionId }, sessionIds.map(id =>
db.aiSession.findUnique({
where: { id },
select: { id: true, pinned: true, docId: true }, select: { id: true, pinned: true, docId: true },
}); })
return session; )
);
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<Context>,
overrides: Parameters<typeof createTestSession>[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<UpdateChatSessionOptions, 'userId' | 'sessionId'>;
test('should list and filter session type', async t => { test('should list and filter session type', async t => {
const { copilotSession, db } = t.context; const { copilotSession, db } = t.context;
@ -128,12 +183,23 @@ test('should list and filter session type', async t => {
docId, 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( t.snapshot(
cleanObject( cleanObject(
docSessions.toSorted(s => docSessions.toSorted((a, b) =>
s.docId!.localeCompare(s.docId!, undefined, { numeric: true }) 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' '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; const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db); await createTestPrompts(copilotSession, db);
const docId = randomUUID();
const sessionTypes = [ const sessionTypes = [
{ name: 'workspace', session: { docId: null, pinned: false } }, { name: 'workspace', session: { docId: null, pinned: false } },
{ name: 'pinned', session: { docId: null, pinned: true } }, { name: 'pinned', session: { docId: null, pinned: true } },
{ name: 'doc', session: { docId, pinned: false } }, { name: 'doc', session: { docId: randomUUID(), pinned: false } },
]; ];
const result = sessionTypes.flatMap(({ name, session }) => [
// non-action prompts should work for all session types // non-action prompts should work for all session types
sessionTypes.forEach(({ name, session }) => { {
t.notThrows( sessionType: name,
() => promptType: 'non-action',
shouldThrow: false,
result: (() => {
try {
copilotSession.checkSessionPrompt(session, { copilotSession.checkSessionPrompt(session, {
name: 'test-prompt', name: TEST_PROMPTS.NORMAL,
action: undefined, action: undefined,
}),
`${name} session should allow non-action prompts`
);
}); });
return 'success';
} catch (error) {
return error instanceof CopilotPromptInvalid
? 'CopilotPromptInvalid'
: 'unknown';
}
})(),
},
// action prompts should only work for doc session type // action prompts should only work for doc session type
{ {
const actionPromptTests = [ sessionType: name,
{ promptType: 'action',
name: 'workspace', shouldThrow: name !== 'doc',
session: sessionTypes[0].session, result: (() => {
shouldThrow: true, try {
},
{ 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, { copilotSession.checkSessionPrompt(session, {
name: 'action-prompt', name: TEST_PROMPTS.ACTION,
action: 'edit', 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`
);
}
}); });
return 'success';
} catch (error) {
return error instanceof CopilotPromptInvalid
? 'CopilotPromptInvalid'
: 'unknown';
} }
})(),
},
]);
t.snapshot(result, 'session prompt validation results');
}); });
test('should pin and unpin sessions', async t => { test('should pin and unpin sessions', async t => {
@ -255,9 +314,9 @@ test('should pin and unpin sessions', async t => {
pinned: true, pinned: true,
}); });
const sessionStatesAfterSecondPin = await Promise.all([ const sessionStatesAfterSecondPin = await getSessionStates(db, [
getSessionState(db, firstSessionId), firstSessionId,
getSessionState(db, secondSessionId), secondSessionId,
]); ]);
t.snapshot( 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; const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db); await createTestPrompts(copilotSession, db);
const sessionId = 'session-update-id'; const sessionId = randomUUID();
const actionSessionId = 'action-session-id'; const actionSessionId = randomUUID();
const parentSessionId = 'parent-session-id'; const forkedSessionId = randomUUID();
const forkedSessionId = 'forked-session-id'; const parentSessionId = randomUUID();
const docId = 'doc-update-id'; const docId = randomUUID();
{
await createTestSession(t, { sessionId }); await createTestSession(t, { sessionId });
await createTestSession(t, { await createTestSession(t, {
sessionId: actionSessionId, sessionId: actionSessionId,
promptName: 'action-prompt', promptName: TEST_PROMPTS.ACTION,
promptAction: 'edit', promptAction: 'edit',
docId: 'some-doc', docId,
});
await createTestSession(t, {
sessionId: parentSessionId,
docId: 'parent-doc',
}); });
await createTestSession(t, { sessionId: parentSessionId, docId });
await db.aiSession.create({ await db.aiSession.create({
data: { data: {
id: forkedSessionId, id: forkedSessionId,
workspaceId: workspace.id, workspaceId: workspace.id,
userId: user.id, userId: user.id,
docId: 'forked-doc', docId,
pinned: false, pinned: false,
promptName: 'test-prompt', promptName: TEST_PROMPTS.NORMAL,
promptAction: null, promptAction: null,
parentSessionId: parentSessionId, parentSessionId,
}, },
}); });
}
type UpdateData = Omit<UpdateChatSessionOptions, 'userId' | 'sessionId'>; const updateTestCases = [
const assertUpdateThrows = async ( // action sessions should reject all updates
t: ExecutionContext<Context>,
sessionId: string,
updateData: UpdateData,
message: string
) => {
await t.throwsAsync(
t.context.copilotSession.update({
...updateData,
userId: user.id,
sessionId,
}),
{ instanceOf: CopilotSessionInvalidInput },
message
);
};
const assertUpdate = async (
t: ExecutionContext<Context>,
sessionId: string,
updateData: UpdateData,
message: string
) => {
await t.notThrowsAsync(
t.context.copilotSession.update({
...updateData,
userId: user.id,
sessionId,
}),
message
);
};
// case 1: action sessions should reject all updates
{ {
const actionUpdates = [ sessionId: actionSessionId,
{ docId: 'new-doc' }, updates: [
{ pinned: true }, { docId: 'new-doc', expected: 'reject' },
{ promptName: 'test-prompt' }, { 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' },
],
},
]; ];
for (const data of actionUpdates) {
await assertUpdateThrows( const updateResults = [];
t, for (const { sessionId: testSessionId, updates } of updateTestCases) {
actionSessionId, for (const update of updates) {
data, const { expected: _, ...updateData } = update;
`action session should reject update: ${JSON.stringify(data)}` 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',
});
}
} }
} }
// case 2: forked sessions should reject docId updates but allow others t.snapshot(updateResults, 'session update validation results');
{
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'
);
}
// cest 4: session type conversions and pinning behavior // session type conversions
{ const existingPinnedId = randomUUID();
const existingPinnedId = 'existing-pinned-session-id';
await createTestSession(t, { sessionId: existingPinnedId, pinned: true }); await createTestSession(t, { sessionId: existingPinnedId, pinned: true });
// should unpin existing when pinning new session
await copilotSession.update({ userId: user.id, sessionId, pinned: true }); await copilotSession.update({ userId: user.id, sessionId, pinned: true });
t.snapshot( // pinning behavior
[ const states = await getSessionStates(db, [sessionId, existingPinnedId]);
await getSessionState(db, sessionId), const pinnedCount = states.filter(s => s?.pinned).length;
await getSessionState(db, existingPinnedId), const unpinnedCount = states.filter(s => s && !s.pinned).length;
],
'should unpin existing when pinning new session'
);
}
// test type conversions t.snapshot(
{ {
const conversionSteps: any[] = []; totalSessions: states.length,
const convertSession = async (step: string, data: UpdateData) => { pinnedSessions: pinnedCount,
await copilotSession.update({ ...data, userId: user.id, sessionId }); 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({ const session = await db.aiSession.findUnique({
where: { id: sessionId }, where: { id: sessionId },
select: { docId: true, pinned: true }, select: { docId: true, pinned: true },
}); });
conversionSteps.push({ conversionSteps.push({
step, step,
session, sessionState: {
hasDocId: !!session?.docId,
pinned: !!session?.pinned,
},
type: copilotSession.getSessionType(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);
} }
t.snapshot(conversionSteps, 'session type conversion steps'); 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,
pinned: false,
promptName: TEST_PROMPTS.NORMAL,
promptAction: null,
parentSessionId,
},
});
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 queryResults: Record<string, any> = {};
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,
})),
};
}
t.snapshot(queryResults, 'comprehensive session query results');
// should list sessions appear in correct order
{
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'
);
}
}
// should get latest valid session
{
const latestValidSessions = await copilotSession.list({
userId: user.id,
workspaceId: workspace.id,
docId,
limit: 1,
sessionOrder: 'desc',
action: false,
fork: false,
});
if (latestValidSessions.length > 0) {
const latestSession = latestValidSessions[0];
// 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'
);
// 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',
});
if (allValidSessions.length > 0) {
t.is(
allValidSessions[0].id,
latestSession.id,
'latest valid session should be the first in the ordered list'
);
}
}
}
// 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'
);
}); });

View File

@ -206,6 +206,7 @@ export class CopilotSessionModel extends BaseModel {
// save message // save message
await this.models.copilotSession.updateMessages({ await this.models.copilotSession.updateMessages({
...forkedState, ...forkedState,
sessionId,
messages, messages,
}); });
return sessionId; return sessionId;
@ -286,18 +287,37 @@ export class CopilotSessionModel extends BaseModel {
async list(options: ListSessionOptions) { async list(options: ListSessionOptions) {
const { userId, sessionId, workspaceId, docId } = options; 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) { if (!options?.action && options?.fork) {
// query forked sessions from other users
// only query forked session if fork == true and action == false // only query forked session if fork == true and action == false
extraCondition.push({ conditions.push({
userId: { not: userId }, userId: { not: userId },
workspaceId: workspaceId, workspaceId: workspaceId,
docId: docId ?? null, docId: docId ?? null,
id: sessionId ? { equals: sessionId } : undefined, id: sessionId ? { equals: sessionId } : undefined,
prompt: { prompt: { action: null },
action: options.action ? { not: null } : null,
},
// should only find forked session // should only find forked session
parentSessionId: { not: null }, parentSessionId: { not: null },
deletedAt: null, deletedAt: null,
@ -305,18 +325,7 @@ export class CopilotSessionModel extends BaseModel {
} }
return await this.db.aiSession.findMany({ return await this.db.aiSession.findMany({
where: { where: { OR: conditions },
OR: [
{
userId,
workspaceId,
docId: docId ?? null,
id: sessionId ? { equals: sessionId } : undefined,
deletedAt: null,
},
...extraCondition,
],
},
select: { select: {
id: true, id: true,
userId: true, userId: true,
@ -327,6 +336,7 @@ export class CopilotSessionModel extends BaseModel {
promptName: true, promptName: true,
tokenCost: true, tokenCost: true,
createdAt: true, createdAt: true,
updatedAt: true,
messages: options.withMessages messages: options.withMessages
? { ? {
select: { select: {
@ -348,8 +358,7 @@ export class CopilotSessionModel extends BaseModel {
take: options?.limit, take: options?.limit,
skip: options?.skip, skip: options?.skip,
orderBy: { orderBy: {
// session order is desc by default updatedAt: options?.sessionOrder === 'asc' ? 'asc' : 'desc',
createdAt: options?.sessionOrder === 'asc' ? 'asc' : 'desc',
}, },
}); });
} }

View File

@ -235,6 +235,12 @@ class CopilotHistoriesType implements Partial<ChatHistory> {
@Field(() => String) @Field(() => String)
sessionId!: string; sessionId!: string;
@Field(() => String)
workspaceId!: string;
@Field(() => String, { nullable: true })
docId!: string | null;
@Field(() => Boolean) @Field(() => Boolean)
pinned!: boolean; pinned!: boolean;
@ -254,6 +260,9 @@ class CopilotHistoriesType implements Partial<ChatHistory> {
@Field(() => Date) @Field(() => Date)
createdAt!: Date; createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
} }
@ObjectType('CopilotQuota') @ObjectType('CopilotQuota')

View File

@ -298,13 +298,16 @@ export class ChatSessionService {
const histories = await Promise.all( const histories = await Promise.all(
sessions.map( sessions.map(
async ({ async ({
id,
userId: uid, userId: uid,
id,
workspaceId,
docId,
pinned, pinned,
promptName, promptName,
tokenCost, tokenCost,
messages, messages,
createdAt, createdAt,
updatedAt,
}) => { }) => {
try { try {
const prompt = await this.prompt.get(promptName); const prompt = await this.prompt.get(promptName);
@ -341,10 +344,13 @@ export class ChatSessionService {
return { return {
sessionId: id, sessionId: id,
workspaceId,
docId,
pinned, pinned,
action: prompt.action || null, action: prompt.action || null,
tokens: tokenCost, tokens: tokenCost,
createdAt, createdAt,
updatedAt,
messages: preload.concat(ret.data).map(m => ({ messages: preload.concat(ret.data).map(m => ({
...m, ...m,
attachments: m.attachments attachments: m.attachments

View File

@ -47,11 +47,14 @@ export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export const ChatHistorySchema = z export const ChatHistorySchema = z
.object({ .object({
sessionId: z.string(), sessionId: z.string(),
workspaceId: z.string(),
docId: z.string().nullable(),
pinned: z.boolean(), pinned: z.boolean(),
action: z.string().nullable(), action: z.string().nullable(),
tokens: z.number(), tokens: z.number(),
messages: z.array(ChatMessageSchema), messages: z.array(ChatMessageSchema),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(),
}) })
.strict(); .strict();

View File

@ -237,12 +237,15 @@ type CopilotHistories {
"""An mark identifying which view to use to display the session""" """An mark identifying which view to use to display the session"""
action: String action: String
createdAt: DateTime! createdAt: DateTime!
docId: String
messages: [ChatMessage!]! messages: [ChatMessage!]!
pinned: Boolean! pinned: Boolean!
sessionId: String! sessionId: String!
"""The number of tokens used in the session""" """The number of tokens used in the session"""
tokens: Int! tokens: Int!
updatedAt: DateTime!
workspaceId: String!
} }
type CopilotInvalidContextDataType { type CopilotInvalidContextDataType {
@ -253,22 +256,6 @@ type CopilotMessageNotFoundDataType {
messageId: String! messageId: String!
} }
enum CopilotModels {
DallE3
Gpt4Omni
Gpt4Omni0806
Gpt4OmniMini
Gpt4OmniMini0718
Gpt41
Gpt41Mini
Gpt41Nano
Gpt410414
GptImage
TextEmbedding3Large
TextEmbedding3Small
TextEmbeddingAda002
}
input CopilotPromptConfigInput { input CopilotPromptConfigInput {
frequencyPenalty: Float frequencyPenalty: Float
presencePenalty: Float presencePenalty: Float
@ -408,7 +395,7 @@ input CreateCopilotPromptInput {
action: String action: String
config: CopilotPromptConfigInput config: CopilotPromptConfigInput
messages: [CopilotPromptMessageInput!]! messages: [CopilotPromptMessageInput!]!
model: CopilotModels! model: String!
name: String! name: String!
} }

View File

@ -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
}
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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 = { export const getCopilotSessionQuery = {
id: 'getCopilotSessionQuery' as const, id: 'getCopilotSessionQuery' as const,
op: 'getCopilotSession', 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 = { export const updateCopilotSessionMutation = {
id: 'updateCopilotSessionMutation' as const, id: 'updateCopilotSessionMutation' as const,
op: 'updateCopilotSession', op: 'updateCopilotSession',

View File

@ -323,11 +323,14 @@ export interface CopilotHistories {
/** An mark identifying which view to use to display the session */ /** An mark identifying which view to use to display the session */
action: Maybe<Scalars['String']['output']>; action: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output']; createdAt: Scalars['DateTime']['output'];
docId: Maybe<Scalars['String']['output']>;
messages: Array<ChatMessage>; messages: Array<ChatMessage>;
pinned: Scalars['Boolean']['output']; pinned: Scalars['Boolean']['output'];
sessionId: Scalars['String']['output']; sessionId: Scalars['String']['output'];
/** The number of tokens used in the session */ /** The number of tokens used in the session */
tokens: Scalars['Int']['output']; tokens: Scalars['Int']['output'];
updatedAt: Scalars['DateTime']['output'];
workspaceId: Scalars['String']['output'];
} }
export interface CopilotInvalidContextDataType { export interface CopilotInvalidContextDataType {
@ -340,22 +343,6 @@ export interface CopilotMessageNotFoundDataType {
messageId: Scalars['String']['output']; 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 { export interface CopilotPromptConfigInput {
frequencyPenalty?: InputMaybe<Scalars['Float']['input']>; frequencyPenalty?: InputMaybe<Scalars['Float']['input']>;
presencePenalty?: InputMaybe<Scalars['Float']['input']>; presencePenalty?: InputMaybe<Scalars['Float']['input']>;
@ -515,7 +502,7 @@ export interface CreateCopilotPromptInput {
action?: InputMaybe<Scalars['String']['input']>; action?: InputMaybe<Scalars['String']['input']>;
config?: InputMaybe<CopilotPromptConfigInput>; config?: InputMaybe<CopilotPromptConfigInput>;
messages: Array<CopilotPromptMessageInput>; messages: Array<CopilotPromptMessageInput>;
model: CopilotModels; model: Scalars['String']['input'];
name: Scalars['String']['input']; name: Scalars['String']['input'];
} }
@ -3575,6 +3562,41 @@ export type ForkCopilotSessionMutation = {
forkCopilotSession: string; 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<string> | null;
params: Record<string, string> | null;
createdAt: string;
}>;
}>;
};
} | null;
};
export type GetCopilotSessionQueryVariables = Exact<{ export type GetCopilotSessionQueryVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
sessionId: Scalars['String']['input']; sessionId: Scalars['String']['input'];
@ -3600,6 +3622,32 @@ export type GetCopilotSessionQuery = {
} | null; } | null;
}; };
export type GetCopilotRecentSessionsQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
}>;
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<{ export type UpdateCopilotSessionMutationVariables = Exact<{
options: UpdateChatSessionInput; options: UpdateChatSessionInput;
}>; }>;
@ -5209,11 +5257,21 @@ export type Queries =
variables: CopilotQuotaQueryVariables; variables: CopilotQuotaQueryVariables;
response: CopilotQuotaQuery; response: CopilotQuotaQuery;
} }
| {
name: 'getCopilotLatestDocSessionQuery';
variables: GetCopilotLatestDocSessionQueryVariables;
response: GetCopilotLatestDocSessionQuery;
}
| { | {
name: 'getCopilotSessionQuery'; name: 'getCopilotSessionQuery';
variables: GetCopilotSessionQueryVariables; variables: GetCopilotSessionQueryVariables;
response: GetCopilotSessionQuery; response: GetCopilotSessionQuery;
} }
| {
name: 'getCopilotRecentSessionsQuery';
variables: GetCopilotRecentSessionsQueryVariables;
response: GetCopilotRecentSessionsQuery;
}
| { | {
name: 'getCopilotSessionsQuery'; name: 'getCopilotSessionsQuery';
variables: GetCopilotSessionsQueryVariables; variables: GetCopilotSessionsQueryVariables;