mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 12:06:35 +08:00
feat(server): allow chat session dangling & pin session support (#12849)
fix AI-181 fix AI-179 fix AI-178 fix PD-2682 fix PD-2683
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
# Snapshot report for `src/__tests__/models/copilot-session.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `copilot-session.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should list and filter session type
|
||||
|
||||
> workspace sessions should include workspace and pinned sessions
|
||||
|
||||
[
|
||||
{
|
||||
docId: null,
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
docId: null,
|
||||
pinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
> doc sessions should only include sessions with matching docId
|
||||
|
||||
[
|
||||
{
|
||||
docId: 'doc-id-1',
|
||||
pinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
> 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: 'doc-id-1',
|
||||
pinned: false,
|
||||
},
|
||||
type: 'doc',
|
||||
},
|
||||
]
|
||||
|
||||
## should pin and unpin sessions
|
||||
|
||||
> session states after creating second pinned session
|
||||
|
||||
[
|
||||
{
|
||||
docId: null,
|
||||
id: 'first-session-id',
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
docId: null,
|
||||
id: 'second-session-id',
|
||||
pinned: true,
|
||||
},
|
||||
]
|
||||
|
||||
> should return false when no sessions to unpin
|
||||
|
||||
false
|
||||
|
||||
> all sessions should be unpinned after unpin operation
|
||||
|
||||
[
|
||||
{
|
||||
id: 'first-session-id',
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
id: 'second-session-id',
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
id: 'third-session-id',
|
||||
pinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
## session updates and type conversions
|
||||
|
||||
> session states after pinning - should unpin existing
|
||||
|
||||
[
|
||||
{
|
||||
docId: null,
|
||||
id: 'session-update-id',
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
docId: null,
|
||||
id: 'existing-pinned-session-id',
|
||||
pinned: false,
|
||||
},
|
||||
]
|
||||
|
||||
> session state after unpinning
|
||||
|
||||
{
|
||||
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,
|
||||
pinned: false,
|
||||
},
|
||||
step: 'pinned_to_workspace',
|
||||
type: 'workspace',
|
||||
},
|
||||
{
|
||||
session: {
|
||||
docId: null,
|
||||
pinned: true,
|
||||
},
|
||||
step: 'workspace_to_pinned',
|
||||
type: 'pinned',
|
||||
},
|
||||
]
|
||||
BIN
Binary file not shown.
@@ -5,12 +5,14 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import { ContextEmbedStatus } from '../../models/common/copilot';
|
||||
import { CopilotContextModel } from '../../models/copilot-context';
|
||||
import { CopilotSessionModel } from '../../models/copilot-session';
|
||||
import { CopilotWorkspaceConfigModel } from '../../models/copilot-workspace';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { WorkspaceModel } from '../../models/workspace';
|
||||
import {
|
||||
ContextEmbedStatus,
|
||||
CopilotContextModel,
|
||||
CopilotSessionModel,
|
||||
CopilotWorkspaceConfigModel,
|
||||
UserModel,
|
||||
WorkspaceModel,
|
||||
} from '../../models';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
import { cleanObject } from '../utils/copilot';
|
||||
|
||||
@@ -46,7 +48,7 @@ let docId = 'doc1';
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4o');
|
||||
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4.1');
|
||||
user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaClient, User, Workspace } from '@prisma/client';
|
||||
import ava, { ExecutionContext, TestFn } from 'ava';
|
||||
|
||||
import { CopilotPromptInvalid } from '../../base';
|
||||
import {
|
||||
CopilotSessionModel,
|
||||
UpdateChatSessionData,
|
||||
UserModel,
|
||||
WorkspaceModel,
|
||||
} from '../../models';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
db: PrismaClient;
|
||||
user: UserModel;
|
||||
workspace: WorkspaceModel;
|
||||
copilotSession: CopilotSessionModel;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule();
|
||||
t.context.user = module.get(UserModel);
|
||||
t.context.workspace = module.get(WorkspaceModel);
|
||||
t.context.copilotSession = module.get(CopilotSessionModel);
|
||||
t.context.db = module.get(PrismaClient);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
let user: User;
|
||||
let workspace: Workspace;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
workspace = await t.context.workspace.create(user.id);
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
const createTestPrompts = async (
|
||||
copilotSession: CopilotSessionModel,
|
||||
db: PrismaClient
|
||||
) => {
|
||||
await copilotSession.createPrompt('test-prompt', 'gpt-4.1');
|
||||
await db.aiPrompt.create({
|
||||
data: { name: 'action-prompt', model: 'gpt-4.1', action: 'edit' },
|
||||
});
|
||||
};
|
||||
|
||||
const createTestSession = async (
|
||||
t: ExecutionContext<Context>,
|
||||
overrides: Partial<{
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
docId: string | null;
|
||||
pinned: boolean;
|
||||
promptName: string;
|
||||
}> = {}
|
||||
) => {
|
||||
const sessionData = {
|
||||
sessionId: randomUUID(),
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
docId: null,
|
||||
pinned: false,
|
||||
promptName: 'test-prompt',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
await t.context.copilotSession.create(sessionData);
|
||||
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;
|
||||
};
|
||||
|
||||
test('should list and filter session type', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const docId = 'doc-id-1';
|
||||
await createTestSession(t, { sessionId: randomUUID() });
|
||||
await createTestSession(t, { sessionId: randomUUID(), pinned: true });
|
||||
await createTestSession(t, { sessionId: randomUUID(), docId });
|
||||
|
||||
// should list sessions
|
||||
{
|
||||
const workspaceSessions = await copilotSession.list(user.id, workspace.id);
|
||||
|
||||
t.snapshot(
|
||||
workspaceSessions.map(s => ({ docId: s.docId, pinned: s.pinned })),
|
||||
'workspace sessions should include workspace and pinned sessions'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const docSessions = await copilotSession.list(user.id, workspace.id, docId);
|
||||
|
||||
t.snapshot(
|
||||
docSessions.map(s => ({ docId: s.docId, pinned: s.pinned })),
|
||||
'doc sessions should only include sessions with matching docId'
|
||||
);
|
||||
}
|
||||
|
||||
// should identify session types
|
||||
{
|
||||
// check get session type
|
||||
const testCases = [
|
||||
{ docId: null, pinned: false },
|
||||
{ docId: undefined, pinned: false },
|
||||
{ docId: null, pinned: true },
|
||||
{ docId, pinned: false },
|
||||
];
|
||||
|
||||
const sessionTypeResults = testCases.map(session => ({
|
||||
session,
|
||||
type: copilotSession.getSessionType(session),
|
||||
}));
|
||||
|
||||
t.snapshot(sessionTypeResults, 'session type identification results');
|
||||
}
|
||||
});
|
||||
|
||||
test('should check session validation for prompts', 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 } },
|
||||
];
|
||||
|
||||
// non-action prompts should work for all session types
|
||||
sessionTypes.forEach(({ name, session }) => {
|
||||
t.notThrows(
|
||||
() =>
|
||||
copilotSession.checkSessionPrompt(session, 'test-prompt', undefined),
|
||||
`${name} session should allow non-action prompts`
|
||||
);
|
||||
});
|
||||
|
||||
// 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, 'action-prompt', 'edit'),
|
||||
{ instanceOf: CopilotPromptInvalid },
|
||||
`${name} session should reject action prompts`
|
||||
);
|
||||
} else {
|
||||
t.notThrows(
|
||||
() =>
|
||||
copilotSession.checkSessionPrompt(session, 'action-prompt', 'edit'),
|
||||
`${name} session should allow action prompts`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should pin and unpin sessions', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const firstSessionId = 'first-session-id';
|
||||
const secondSessionId = 'second-session-id';
|
||||
const thirdSessionId = 'third-session-id';
|
||||
|
||||
// should unpin existing pinned session when creating a new one
|
||||
{
|
||||
await copilotSession.create({
|
||||
sessionId: firstSessionId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
docId: null,
|
||||
promptName: 'test-prompt',
|
||||
pinned: true,
|
||||
});
|
||||
|
||||
const firstSession = await copilotSession.get(firstSessionId);
|
||||
t.truthy(firstSession, 'first session should be created successfully');
|
||||
t.is(firstSession?.pinned, true, 'first session should be pinned');
|
||||
|
||||
// should unpin the first one when creating second pinned session
|
||||
await copilotSession.create({
|
||||
sessionId: secondSessionId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
docId: null,
|
||||
promptName: 'test-prompt',
|
||||
pinned: true,
|
||||
});
|
||||
|
||||
const sessionStatesAfterSecondPin = await Promise.all([
|
||||
getSessionState(db, firstSessionId),
|
||||
getSessionState(db, secondSessionId),
|
||||
]);
|
||||
|
||||
t.snapshot(
|
||||
sessionStatesAfterSecondPin,
|
||||
'session states after creating second pinned session'
|
||||
);
|
||||
}
|
||||
|
||||
// should can unpin a pinned session
|
||||
{
|
||||
await createTestSession(t, { sessionId: thirdSessionId, pinned: true });
|
||||
const unpinResult = await copilotSession.unpin(workspace.id, user.id);
|
||||
t.is(
|
||||
unpinResult,
|
||||
true,
|
||||
'unpin operation should return true when sessions are unpinned'
|
||||
);
|
||||
|
||||
const unpinResultAgain = await copilotSession.unpin(workspace.id, user.id);
|
||||
t.snapshot(
|
||||
unpinResultAgain,
|
||||
'should return false when no sessions to unpin'
|
||||
);
|
||||
}
|
||||
|
||||
// should unpin all sessions
|
||||
{
|
||||
const allSessionsAfterUnpin = await db.aiSession.findMany({
|
||||
where: { id: { in: [firstSessionId, secondSessionId, thirdSessionId] } },
|
||||
select: { pinned: true, id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
t.snapshot(
|
||||
allSessionsAfterUnpin,
|
||||
'all sessions should be unpinned after unpin operation'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('session updates and type conversions', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const sessionId = 'session-update-id';
|
||||
const docId = 'doc-update-id';
|
||||
|
||||
await createTestSession(t, { sessionId });
|
||||
|
||||
// should unpin existing pinned session
|
||||
{
|
||||
const existingPinnedId = 'existing-pinned-session-id';
|
||||
await createTestSession(t, { sessionId: existingPinnedId, pinned: true });
|
||||
|
||||
await copilotSession.update(user.id, sessionId, { pinned: true });
|
||||
|
||||
const sessionStatesAfterPin = await Promise.all([
|
||||
getSessionState(db, sessionId),
|
||||
getSessionState(db, existingPinnedId),
|
||||
]);
|
||||
|
||||
t.snapshot(
|
||||
sessionStatesAfterPin,
|
||||
'session states after pinning - should unpin existing'
|
||||
);
|
||||
}
|
||||
|
||||
// should unpin the session
|
||||
{
|
||||
await copilotSession.update(user.id, sessionId, { pinned: false });
|
||||
const sessionStateAfterUnpin = await getSessionState(db, sessionId);
|
||||
t.snapshot(sessionStateAfterUnpin, 'session state after unpinning');
|
||||
}
|
||||
|
||||
// should convert session types
|
||||
{
|
||||
const conversionSteps: any[] = [];
|
||||
|
||||
let session = await db.aiSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { docId: true, pinned: true },
|
||||
});
|
||||
|
||||
const convertSession = async (
|
||||
step: string,
|
||||
data: UpdateChatSessionData
|
||||
) => {
|
||||
await copilotSession.update(user.id, sessionId, data);
|
||||
session = await db.aiSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { docId: true, pinned: true },
|
||||
});
|
||||
conversionSteps.push({
|
||||
step,
|
||||
session,
|
||||
type: copilotSession.getSessionType(session!),
|
||||
});
|
||||
};
|
||||
|
||||
{
|
||||
await convertSession('workspace_to_doc', { docId }); // Workspace → Doc session
|
||||
await convertSession('doc_to_pinned', { pinned: true }); // Doc → Pinned session
|
||||
await convertSession('pinned_to_workspace', {
|
||||
pinned: false,
|
||||
docId: null,
|
||||
}); // Pinned → Workspace session
|
||||
await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session
|
||||
}
|
||||
|
||||
t.snapshot(conversionSteps, 'session type conversion steps');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user