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)
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)

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
> 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',
},
]

View File

@ -47,13 +47,20 @@ test.after(async t => {
await t.context.module.close();
});
// Test data constants
const TEST_PROMPTS = {
NORMAL: 'test-prompt',
ACTION: 'action-prompt',
} as const;
// Helper functions
const createTestPrompts = async (
copilotSession: CopilotSessionModel,
db: PrismaClient
) => {
await copilotSession.createPrompt('test-prompt', 'gpt-4.1');
await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-4.1');
await db.aiPrompt.create({
data: { name: 'action-prompt', model: 'gpt-4.1', action: 'edit' },
data: { name: TEST_PROMPTS.ACTION, model: 'gpt-4.1', action: 'edit' },
});
};
@ -75,7 +82,7 @@ const createTestSession = async (
workspaceId: workspace.id,
docId: null,
pinned: false,
promptName: 'test-prompt',
promptName: TEST_PROMPTS.NORMAL,
promptAction: null,
...overrides,
};
@ -84,14 +91,62 @@ const createTestSession = async (
return sessionData;
};
const getSessionState = async (db: PrismaClient, sessionId: string) => {
const session = await db.aiSession.findUnique({
where: { id: sessionId },
select: { id: true, pinned: true, docId: true },
});
return session;
const getSessionStates = async (db: PrismaClient, sessionIds: string[]) => {
const sessions = await Promise.all(
sessionIds.map(id =>
db.aiSession.findUnique({
where: { id },
select: { id: true, pinned: true, docId: true },
})
)
);
return sessions;
};
const addMessagesToSession = async (
copilotSession: CopilotSessionModel,
sessionId: string,
content: string,
delayMs: number = 0
) => {
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
await copilotSession.updateMessages({
sessionId,
userId: user.id,
prompt: { model: 'gpt-4.1' },
messages: [
{
role: 'user',
content,
createdAt: new Date(),
},
],
});
};
const createSessionWithMessages = async (
t: ExecutionContext<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 => {
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<UpdateChatSessionOptions, 'userId' | 'sessionId'>;
const assertUpdateThrows = async (
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 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<Context>,
sessionId: string,
updateData: UpdateData,
message: string
) => {
await t.notThrowsAsync(
t.context.copilotSession.update({
...updateData,
userId: user.id,
sessionId,
}),
message
);
};
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,
})),
};
}
// 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'
);
});

View File

@ -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',
},
});
}

View File

@ -235,6 +235,12 @@ class CopilotHistoriesType implements Partial<ChatHistory> {
@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<ChatHistory> {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
}
@ObjectType('CopilotQuota')

View File

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

View File

@ -47,11 +47,14 @@ export type ChatMessage = z.infer<typeof ChatMessageSchema>;
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();

View File

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

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 = {
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',

View File

@ -323,11 +323,14 @@ export interface CopilotHistories {
/** An mark identifying which view to use to display the session */
action: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
docId: Maybe<Scalars['String']['output']>;
messages: Array<ChatMessage>;
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<Scalars['Float']['input']>;
presencePenalty?: InputMaybe<Scalars['Float']['input']>;
@ -515,7 +502,7 @@ export interface CreateCopilotPromptInput {
action?: InputMaybe<Scalars['String']['input']>;
config?: InputMaybe<CopilotPromptConfigInput>;
messages: Array<CopilotPromptMessageInput>;
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<string> | null;
params: Record<string, string> | 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<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<{
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;