feat(server): add typed list session gql (#12979)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced new API endpoints and GraphQL queries to retrieve Copilot
chat sessions by workspace, document, and pinned status, with detailed
session and message information.
* Added support for filtering and querying Copilot chat histories with
new options such as pinned status and message ordering.

* **Bug Fixes**
* Improved filtering logic for listing and retrieving chat sessions,
ensuring accurate results for workspace, document, and pinned session
queries.

* **Tests**
* Expanded and refactored test coverage for session listing, filtering,
and new query options to ensure reliability and correctness of Copilot
session retrieval.
* Updated snapshot data to reflect new session types and filtering
capabilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-07-01 19:31:37 +08:00
committed by GitHub
parent 6e9487a9e1
commit 0326da0806
13 changed files with 704 additions and 152 deletions

View File

@@ -16,6 +16,7 @@ Generated by [AVA](https://avajs.dev).
role: 'assistant',
},
],
pinned: false,
tokens: 8,
},
]
@@ -30,6 +31,7 @@ Generated by [AVA](https://avajs.dev).
role: 'assistant',
},
],
pinned: false,
tokens: 8,
},
]

View File

@@ -53,7 +53,10 @@ import {
createWorkspaceCopilotSession,
forkCopilotSession,
getCopilotSession,
getDocSessions,
getHistories,
getPinnedSessions,
getWorkspaceSessions,
listContext,
listContextDocAndFiles,
matchFiles,
@@ -1140,31 +1143,94 @@ test('should list histories for different session types correctly', async t => {
]);
const testHistoryQuery = async (
queryDocId: string | undefined,
expectedSessionId: string,
queryFn: () => Promise<any[]>,
opts: {
sessionIds?: string[];
sessionId?: string;
pinned?: boolean;
isEmpty?: boolean;
},
description: string
) => {
const histories = await getHistories(app, {
workspaceId,
docId: queryDocId,
});
t.is(histories.length, 1, `should return ${description}`);
t.is(
histories[0].sessionId,
expectedSessionId,
`should return correct ${description}`
);
const s = await queryFn();
if (opts.isEmpty) {
t.is(s.length, 0, `should return ${description}`);
return;
}
if (opts.sessionIds) {
t.is(s.length, opts.sessionIds.length, `should return ${description}`);
const ids = s.map(h => h.sessionId).sort((a, b) => a.localeCompare(b));
const expectedIds = opts.sessionIds.sort((a, b) => a.localeCompare(b));
t.deepEqual(ids, expectedIds, `should return correct ${description}`);
} else if (opts.sessionId) {
t.is(s.length, 1, `should return ${description}`);
t.is(
s[0].sessionId,
opts.sessionId,
`should return correct ${description}`
);
if (opts.pinned !== undefined) {
t.is(s[0].pinned, opts.pinned, `pinned status for ${description}`);
}
}
};
// test for getHistories
await testHistoryQuery(
undefined,
workspaceSessionId,
() => getHistories(app, { workspaceId, docId: null }),
{ sessionId: workspaceSessionId },
'workspace session history'
);
await testHistoryQuery(
pinnedDocId,
pinnedSessionId,
() => getHistories(app, { workspaceId, docId: pinnedDocId }),
{ sessionId: pinnedSessionId },
'pinned session history'
);
await testHistoryQuery(docId, docSessionId, 'doc session history');
await testHistoryQuery(
() => getHistories(app, { workspaceId, docId }),
{ sessionId: docSessionId },
'doc session history'
);
// test for getWorkspaceSessions
await testHistoryQuery(
() => getWorkspaceSessions(app, { workspaceId }),
{ sessionId: workspaceSessionId, pinned: false },
'workspace-level sessions'
);
// test for getDocSessions
await testHistoryQuery(
() =>
getDocSessions(app, { workspaceId, docId, options: { pinned: false } }),
{ sessionId: docSessionId, pinned: false },
'doc sessions'
);
await testHistoryQuery(
() => getDocSessions(app, { workspaceId, docId: pinnedDocId }),
{ sessionId: pinnedSessionId, pinned: true },
'pinned doc sessions'
);
// test for getPinnedSessions
await testHistoryQuery(
() => getPinnedSessions(app, { workspaceId }),
{ sessionId: pinnedSessionId, pinned: true },
'pinned sessions'
);
await testHistoryQuery(
() => getPinnedSessions(app, { workspaceId, docId: pinnedDocId }),
{ sessionId: pinnedSessionId, pinned: true },
'pinned session for specific doc'
);
await testHistoryQuery(
() => getPinnedSessions(app, { workspaceId, docId }),
{ isEmpty: true },
'no pinned sessions for non-pinned doc'
);
});

View File

@@ -262,16 +262,53 @@ Generated by [AVA](https://avajs.dev).
{
all_workspace_sessions: {
count: 2,
count: 7,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'pinned',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'workspace',
@@ -283,30 +320,35 @@ Generated by [AVA](https://avajs.dev).
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
@@ -318,6 +360,7 @@ Generated by [AVA](https://avajs.dev).
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
@@ -325,28 +368,39 @@ Generated by [AVA](https://avajs.dev).
],
},
non_action_sessions: {
count: 4,
count: 5,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
@@ -354,28 +408,25 @@ Generated by [AVA](https://avajs.dev).
],
},
non_fork_sessions: {
count: 4,
count: 3,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
@@ -383,16 +434,44 @@ Generated by [AVA](https://avajs.dev).
],
},
recent_top3_sessions: {
count: 3,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
],
},
workspace_sessions_with_messages: {
count: 2,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'pinned',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'workspace',
@@ -486,102 +565,3 @@ Generated by [AVA](https://avajs.dev).
workspaceSessionExists: true,
},
}
## should handle session updates and validations
> should unpin existing when pinning new session
[
{
docId: null,
id: 'session-update-id',
pinned: true,
},
{
docId: null,
id: 'existing-pinned-session-id',
pinned: false,
},
]
> session type conversion steps
[
{
session: {
docId: 'doc-update-id',
pinned: false,
},
step: 'workspace_to_doc',
type: 'doc',
},
{
session: {
docId: null,
pinned: false,
},
step: 'doc_to_workspace',
type: 'workspace',
},
{
session: {
docId: null,
pinned: true,
},
step: 'workspace_to_pinned',
type: 'pinned',
},
]
## should create multiple doc sessions and query latest
> multiple doc sessions for same document with order verification
[
{
docId: 'multi-session-doc',
hasMessages: true,
isFirstSession: false,
isSecondSession: false,
isThirdSession: true,
messageCount: 1,
},
{
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,
},
]
## should query recent topK sessions of different types
> should include different session types in recent topK query
[
{
docId: null,
pinned: false,
type: 'workspace',
},
{
docId: null,
pinned: true,
type: 'pinned',
},
{
docId: null,
pinned: false,
type: 'workspace',
},
]

View File

@@ -169,6 +169,7 @@ test('should list and filter session type', async t => {
const workspaceSessions = await copilotSession.list({
userId: user.id,
workspaceId: workspace.id,
docId: null,
});
t.snapshot(
@@ -575,6 +576,10 @@ test('should handle session queries, ordering, and filtering', async t => {
const docParams = { ...baseParams, docId };
const queryTestCases = [
{ name: 'all_workspace_sessions', params: baseParams },
{
name: 'workspace_sessions_with_messages',
params: { ...baseParams, docId: null, withMessages: true },
},
{
name: 'doc_sessions_with_messages',
params: { ...docParams, withMessages: true },
@@ -609,6 +614,7 @@ test('should handle session queries, ordering, and filtering', async t => {
type: copilotSession.getSessionType(s),
hasMessages: !!s.messages?.length,
messageCount: s.messages?.length || 0,
isAction: s.promptName === TEST_PROMPTS.ACTION,
isFork: !!s.parentSessionId,
})),
};

View File

@@ -709,26 +709,30 @@ type ChatMessage = {
type History = {
sessionId: string;
pinned: boolean;
tokens: number;
action: string | null;
createdAt: string;
messages: ChatMessage[];
};
type HistoryOptions = {
action?: boolean;
fork?: boolean;
pinned?: boolean;
limit?: number;
skip?: number;
sessionOrder?: 'asc' | 'desc';
messageOrder?: 'asc' | 'desc';
sessionId?: string;
};
export async function getHistories(
app: TestingApp,
variables: {
workspaceId: string;
docId?: string;
options?: {
action?: boolean;
fork?: boolean;
limit?: number;
skip?: number;
sessionOrder?: 'asc' | 'desc';
messageOrder?: 'asc' | 'desc';
sessionId?: string;
};
docId?: string | null;
options?: HistoryOptions;
}
): Promise<History[]> {
const res = await app.gql(
@@ -742,6 +746,7 @@ export async function getHistories(
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: $options) {
sessionId
pinned
tokens
action
createdAt
@@ -763,6 +768,152 @@ export async function getHistories(
return res.currentUser?.copilot?.histories || [];
}
export async function getWorkspaceSessions(
app: TestingApp,
variables: {
workspaceId: string;
options?: HistoryOptions;
}
): Promise<History[]> {
const res = await app.gql(
`query getCopilotWorkspaceSessions(
$workspaceId: String!
$options: QueryChatHistoriesInput
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: null, options: $options) {
sessionId
pinned
tokens
action
createdAt
messages {
id
role
content
streamObjects {
type
textDelta
toolCallId
toolName
args
result
}
attachments
createdAt
}
}
}
}
}`,
variables
);
return res.currentUser?.copilot?.histories || [];
}
export async function getDocSessions(
app: TestingApp,
variables: {
workspaceId: string;
docId: string;
options?: HistoryOptions;
}
): Promise<History[]> {
const res = await app.gql(
`query getCopilotDocSessions(
$workspaceId: String!
$docId: String!
$options: QueryChatHistoriesInput
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: $options) {
sessionId
pinned
tokens
action
createdAt
messages {
id
role
content
streamObjects {
type
textDelta
toolCallId
toolName
args
result
}
attachments
createdAt
}
}
}
}
}`,
variables
);
return res.currentUser?.copilot?.histories || [];
}
export async function getPinnedSessions(
app: TestingApp,
variables: {
workspaceId: string;
docId?: string;
messageOrder?: 'asc' | 'desc';
withPrompt?: boolean;
}
): Promise<History[]> {
const res = await app.gql(
`query getCopilotPinnedSessions(
$workspaceId: String!
$docId: String
$messageOrder: ChatHistoryOrder
$withPrompt: Boolean
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: {
limit: 1,
pinned: true,
messageOrder: $messageOrder,
withPrompt: $withPrompt
}) {
sessionId
pinned
tokens
action
createdAt
messages {
id
role
content
streamObjects {
type
textDelta
toolCallId
toolName
args
result
}
attachments
createdAt
}
}
}
}
}`,
variables
);
return res.currentUser?.copilot?.histories || [];
}
type Prompt = {
name: string;
model: string;

View File

@@ -285,38 +285,44 @@ export class CopilotSessionModel extends BaseModel {
}
async list(options: ListSessionOptions) {
const { userId, sessionId, workspaceId, docId } = options;
const { userId, sessionId, workspaceId, docId, action, fork } = options;
function getNullCond<T>(
maybeBool: boolean | undefined,
wrap: (ret: { not: null } | null) => T = ret => ret as T
): T | undefined {
return maybeBool === true
? wrap({ not: null })
: maybeBool === false
? wrap(null)
: undefined;
}
function getEqCond<T>(maybeValue: T | undefined): T | undefined {
return maybeValue !== undefined ? maybeValue : undefined;
}
const conditions: Prisma.AiSessionWhereInput['OR'] = [
{
userId,
workspaceId,
docId: docId ?? null,
id: sessionId ? { equals: sessionId } : undefined,
docId: getEqCond(docId),
id: getEqCond(sessionId),
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,
pinned: getEqCond(options.pinned),
prompt: getNullCond(fork, ret => ({ action: ret })),
parentSessionId: getNullCond(fork),
},
];
if (!options?.action && options?.fork) {
if (!action && fork) {
// query forked sessions from other users
// only query forked session if fork == true and action == false
conditions.push({
userId: { not: userId },
workspaceId: workspaceId,
docId: docId ?? null,
id: sessionId ? { equals: sessionId } : undefined,
id: getEqCond(sessionId),
prompt: { action: null },
// should only find forked session
parentSessionId: { not: null },