Files
upstream-affine/packages/backend/server/src/__tests__/copilot/conversation-host.spec.ts
upstream-mirror 702cc698a7
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
2026-09-16 04:01:20 +05:00

425 lines
12 KiB
TypeScript

import '../../plugins/copilot/runtime/capability-runtime';
import ava from 'ava';
import {
CopilotMessageNotFound,
CopilotSessionNotFound,
type Mutex,
} from '../../base';
import type { CompatSubmissionStore } from '../../plugins/copilot/compat/submission-store';
import type { ConversationPolicy } from '../../plugins/copilot/conversation/policy';
import type { Turn } from '../../plugins/copilot/core';
import type { AdmittedAttachmentSource } from '../../plugins/copilot/runtime/hosts/attachment-admission';
import { ConversationHost } from '../../plugins/copilot/runtime/hosts/conversation-host';
import {
ChatSession,
type ChatSessionService,
} from '../../plugins/copilot/session';
const test = ava;
function fixture(
options: {
failFirstAcceptedWrite?: boolean;
failFirstAppend?: boolean;
mode?: 'canonical' | 'personal';
admittedAttachments?: AdmittedAttachmentSource[];
revokeDuringAdmission?: boolean;
promptAction?: string;
} = {}
) {
const sessionId = 'session-1';
const token = 'submission-1';
const durable = new Map<string, Turn>();
const accepted = new Map<
string,
{ userId: string; sessionId: string; turnId: string }
>();
const submissions = new Map([
[
token,
{
id: token,
userId: 'user-1',
workspaceId: 'workspace-1',
sessionId,
content: 'hello',
attachments: options.admittedAttachments?.length
? ['data:text/plain;base64,aGVsbG8=']
: [],
params: {
tone: 'brief',
scopeSelectors: [{ kind: 'document', id: 'doc-2' }],
},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
]);
let appendCount = 0;
let quota = true;
let accessAllowed = true;
let acceptedWriteCount = 0;
let artifactWrites = 0;
const titleCalls: unknown[] = [];
const chatSession = new ChatSession(
{
sessionId,
userId: 'user-1',
workspaceId: 'workspace-1',
docId: 'doc-1',
focus: { selectors: [] },
prompt: {
name: 'Chat With AFFiNE AI',
action: options.promptAction,
config: {},
paramKeys: [],
params: {},
},
turns: [],
},
() => []
);
const sessions = {
getOwnedScope: async (id: string, userId: string) =>
id === sessionId && userId === 'user-1'
? { workspaceId: 'workspace-1', docId: 'doc-1' }
: undefined,
getInScope: async (input: { sessionId: string; userId: string }) =>
input.sessionId === sessionId && input.userId === 'user-1'
? chatSession
: undefined,
findTurnByCompatSubmissionId: async (
_sessionId: string,
_userId: string,
_workspaceId: string,
id: string
) => durable.get(id),
appendTurn: async (input: { compatSubmissionId: string; turn: Turn }) => {
appendCount += 1;
if (options.failFirstAppend && appendCount === 1) {
throw new Error('durable append failed');
}
const stored = { ...input.turn, id: `turn-${appendCount}` };
durable.set(input.compatSubmissionId, stored);
return stored;
},
getMessage: async (
_sessionId: string,
_userId: string,
_workspaceId: string,
turnId: string
) => [...durable.values()].find(turn => turn.id === turnId),
revertLatestMessage: async () => {},
generateSessionTitle: async (input: unknown) => {
titleCalls.push(input);
},
} as unknown as ChatSessionService;
const submissionStore = {
get: async (id: string, userId: string) => {
const value = submissions.get(id);
return value?.userId === userId ? value : undefined;
},
getAccepted: async (id: string, userId: string) => {
const value = accepted.get(id);
return value?.userId === userId
? { ...value, acceptedAt: new Date('2026-01-01T00:00:00.000Z') }
: undefined;
},
markAccepted: async (
id: string,
userId: string,
value: { sessionId: string; turnId: string }
) => {
acceptedWriteCount += 1;
if (options.failFirstAcceptedWrite && acceptedWriteCount === 1) {
throw new Error('accepted cache write failed');
}
accepted.set(id, { ...value, userId });
submissions.delete(id);
},
} as unknown as CompatSubmissionStore;
const mutex = {
acquire: async () => ({ async [Symbol.asyncDispose]() {} }),
} as unknown as Mutex;
const policy = {
hasQuota: async () => quota,
shouldScheduleTitle: (prompt: { action?: string }) => !prompt.action,
} as unknown as ConversationPolicy;
const runtime = {
putWorkspaceArtifact: async () => {
artifactWrites++;
return {
id: 'artifact-1',
canonicalMediaType: 'text/plain',
};
},
compileTurnScope: async (input: {
selectors: unknown[];
preferredSourceIds?: string[];
}) => ({
version: 1,
resolvedAt: '2026-01-01T00:00:00.000Z',
selectors: input.selectors,
requiredDocIds: [],
requiredArtifactIds: [],
preferredSourceIds: input.preferredSourceIds ?? [],
retrieval: {
mode: input.selectors.length ? 'required' : 'workspace',
requiredDocIds: [],
requiredArtifactIds: [],
preferredSourceIds: input.preferredSourceIds ?? [],
},
}),
};
const attachmentAdmission = {
admitPromptAttachments: async () => {
if (options.revokeDuringAdmission) accessAllowed = false;
return options.admittedAttachments ?? [];
},
};
return {
host: new ConversationHost(
sessions,
submissionStore,
mutex,
policy,
runtime as never,
attachmentAdmission as never,
{
sessionResource: async () => {
if (!accessAllowed) throw new Error('permission denied');
return options.mode ?? 'canonical';
},
} as never
),
sessionId,
token,
durable,
accepted,
submissions,
session: chatSession,
appendCount: () => appendCount,
artifactWrites: () => artifactWrites,
titleCalls,
setQuota: (value: boolean) => {
quota = value;
},
revokeAccess: () => {
accessAllowed = false;
},
};
}
test('compat submission becomes one durable user turn and replays idempotently', async t => {
const state = fixture();
const first = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(first.latestTurn?.content, 'hello');
t.deepEqual(first.latestTurn?.metadata, { tone: 'brief' });
t.deepEqual(first.latestTurn?.scopeSnapshot?.selectors, [
{ kind: 'document', id: 'doc-2', source: 'draft' },
]);
t.is(state.appendCount(), 1);
t.false(state.submissions.has(state.token));
t.truthy(state.accepted.get(state.token));
state.setQuota(false);
const replay = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(replay.latestTurn?.id, first.latestTurn?.id);
t.true(replay.quotaBackedRoutesAllowed);
t.is(state.appendCount(), 1);
});
test('durable compat turn recovers after accepted-cache write failure', async t => {
const state = fixture({ failFirstAcceptedWrite: true });
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'accepted cache write failed' }
);
t.is(state.appendCount(), 1);
t.truthy(state.durable.get(state.token));
const recovered = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(recovered.latestTurn?.id, state.durable.get(state.token)?.id);
t.is(state.appendCount(), 1);
t.truthy(state.accepted.get(state.token));
});
test('compat submission remains retryable when durable append fails', async t => {
const state = fixture({ failFirstAppend: true });
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'durable append failed' }
);
t.true(state.submissions.has(state.token));
t.false(state.accepted.has(state.token));
const recovered = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(recovered.latestTurn?.content, 'hello');
t.is(state.durable.size, 1);
});
test('compat submission cannot be consumed by another session', async t => {
const state = fixture();
const other = fixture();
other.submissions.set(state.token, {
id: state.token,
userId: 'user-1',
workspaceId: 'workspace-1',
sessionId: 'session-other',
content: 'secret',
attachments: [],
params: { tone: 'brief', scopeSelectors: [] },
createdAt: new Date(),
});
await t.throwsAsync(
other.host.prepareTurn('user-1', other.sessionId, {
messageId: state.token,
}),
{ instanceOf: CopilotMessageNotFound }
);
await t.throwsAsync(
state.host.prepareTurn('user-2', state.sessionId, {
messageId: state.token,
}),
{ instanceOf: CopilotSessionNotFound }
);
t.is(other.appendCount(), 0);
});
test('direct conversation rejects revoked canonical access before durable append', async t => {
const state = fixture();
state.revokeAccess();
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'permission denied' }
);
t.is(state.appendCount(), 0);
t.true(state.submissions.has(state.token));
});
test('workspace context fails closed for personal scope and revoked canonical access', async t => {
const personal = fixture({ mode: 'personal' });
const personalError = await t.throwsAsync(
personal.host.prepareTurn('user-1', personal.sessionId, {
messageId: personal.token,
})
);
const canonical = fixture({
admittedAttachments: [
{
id: 'attachment-1',
kind: 'bytes',
mimeType: 'text/plain',
size: 5,
fileName: 'note.txt',
hash: 'hash-1',
data: 'aGVsbG8=',
encoding: 'base64',
},
],
revokeDuringAdmission: true,
});
const canonicalError = await t.throwsAsync(
canonical.host.prepareTurn('user-1', canonical.sessionId, {
messageId: canonical.token,
})
);
t.snapshot({
personal: {
error: personalError?.message,
artifactWrites: personal.artifactWrites(),
durableAppends: personal.appendCount(),
},
canonical: {
error: canonicalError?.message,
artifactWrites: canonical.artifactWrites(),
durableAppends: canonical.appendCount(),
},
});
});
test('assistant persistence rechecks access and schedules eligible titles', async t => {
const state = fixture();
state.revokeAccess();
await t.throwsAsync(
state.host.persistAssistantTurn(
state.session,
{
conversationId: state.session.config.sessionId,
role: 'assistant',
content: 'must not persist',
attachments: [],
metadata: {},
renderTrace: [],
toolEvents: [],
createdAt: new Date(),
},
false
),
{ message: 'permission denied' }
);
t.is(state.appendCount(), 0);
const eligible = fixture();
const assistantTurn = {
conversationId: eligible.sessionId,
role: 'assistant' as const,
content: 'answer',
attachments: [],
metadata: {},
renderTrace: [],
toolEvents: [],
createdAt: new Date(),
};
await eligible.host.persistAssistantTurn(
eligible.session,
assistantTurn,
false
);
await eligible.host.persistAssistantTurn(
eligible.session,
{ ...assistantTurn, content: 'aborted' },
true
);
const action = fixture({ promptAction: 'edit' });
await action.host.persistAssistantTurn(
action.session,
{ ...assistantTurn, conversationId: action.sessionId },
false
);
t.deepEqual(eligible.titleCalls, [
{
sessionId: 'session-1',
userId: 'user-1',
workspaceId: 'workspace-1',
},
]);
t.deepEqual(action.titleCalls, []);
});