feat(server): refactor copilot (#14892)

#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-04 00:36:47 +08:00
committed by GitHub
parent fa8f1a096c
commit d64f368623
239 changed files with 35859 additions and 16777 deletions
@@ -1,4 +1,4 @@
# Snapshot report for `src/__tests__/copilot.spec.ts`
# Snapshot report for `src/__tests__/copilot/copilot.spec.ts`
The actual snapshot is saved in `copilot.spec.ts.snap`.
@@ -52,12 +52,10 @@ Generated by [AVA](https://avajs.dev).
},
{
content: 'hello',
params: {},
role: 'user',
},
{
content: 'world',
params: {},
role: 'assistant',
},
]
@@ -74,12 +72,10 @@ Generated by [AVA](https://avajs.dev).
},
{
content: 'hello',
params: {},
role: 'user',
},
{
content: 'world',
params: {},
role: 'assistant',
},
]
@@ -96,22 +92,18 @@ Generated by [AVA](https://avajs.dev).
},
{
content: 'hello',
params: {},
role: 'user',
},
{
content: 'world',
params: {},
role: 'assistant',
},
{
content: 'aaa',
params: {},
role: 'user',
},
{
content: 'bbb',
params: {},
role: 'assistant',
},
]
@@ -128,22 +120,18 @@ Generated by [AVA](https://avajs.dev).
},
{
content: 'hello',
params: {},
role: 'user',
},
{
content: 'world',
params: {},
role: 'assistant',
},
{
content: 'aaa',
params: {},
role: 'user',
},
{
content: 'bbb',
params: {},
role: 'assistant',
},
]
@@ -445,6 +433,40 @@ Generated by [AVA](https://avajs.dev).
],
}
## capability policy host should gate pro model requests by subscription status
> should honor requested pro model
'gemini-2.5-pro'
> should fallback to default model
'gemini-2.5-flash'
> should fallback to default model when requesting pro model during trialing
'gemini-2.5-flash'
> should honor requested non-pro model during trialing
'gemini-2.5-flash'
> should pick default model when no requested model during trialing
'gemini-2.5-flash'
> should pick default model when no requested model during active
'gemini-2.5-flash'
> should honor requested pro model during active
'claude-sonnet-4-5@20250929'
> should fallback to default model when requesting non-optional model during active
'gemini-2.5-flash'
## should resolve model correctly based on subscription status and prompt config
> should honor requested pro model
@@ -0,0 +1,703 @@
# Snapshot report for `src/__tests__/copilot/native-provider.spec.ts`
The actual snapshot is saved in `native-provider.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## NativeProviderAdapter streamObject should map tool and text events
> Snapshot 1
[
{
args: {
doc_id: 'a1',
},
argumentParseError: undefined,
rawArgumentsText: undefined,
thought: undefined,
toolCallId: 'call_1',
toolName: 'doc_read',
type: 'tool-call',
},
{
args: {
doc_id: 'a1',
},
argumentParseError: undefined,
rawArgumentsText: undefined,
result: {
markdown: '# a1',
},
toolCallId: 'call_1',
toolName: 'doc_read',
type: 'tool-result',
},
{
textDelta: 'ok',
type: 'text-delta',
},
]
## buildCanonicalNativeRequest should only use explicit structured contract inputs
> Snapshot 1
{
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
}
## buildCanonicalNativeStructuredRequest should accept schema-only explicit structured response contracts
> Snapshot 1
{
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: true,
}
## buildCanonicalNativeStructuredRequest should honor explicit structured options contract before system responseFormat
> Snapshot 1
{
schema: {
additionalProperties: false,
properties: {
ok: {
type: 'boolean',
},
},
required: [
'ok',
],
type: 'object',
},
strict: true,
}
## buildCanonicalNativeStructuredRequest should honor explicit responseSchema for array outputs
> Snapshot 1
{
items: {
additionalProperties: false,
properties: {
speaker: {
type: 'string',
},
text: {
type: 'string',
},
},
required: [
'speaker',
'text',
],
type: 'object',
},
type: 'array',
}
## buildCanonicalNativeStructuredRequest should consume explicit structured response contract without options.schema
> Snapshot 1
{
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: false,
}
## buildCanonicalNativeStructuredRequest should accept explicit schema contracts without schemaHash
> Snapshot 1
{
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: true,
}
## buildNativeRequest should canonicalize Gemini attachments
> remote file url
[
{
text: 'summarize this attachment',
type: 'text',
},
{
source: {
media_type: 'application/pdf',
url: 'https://example.com/a.pdf',
},
type: 'file',
},
]
> remote image url
[
{
text: 'describe this image',
type: 'text',
},
{
source: {
media_type: 'image/png',
url: 'https://example.com/cat.png',
},
type: 'image',
},
]
> data url
[
{
text: 'read this note',
type: 'text',
},
{
source: {
data: 'aGVsbG8gd29ybGQ=',
media_type: 'text/plain',
},
type: 'file',
},
]
> remote audio url
[
{
text: 'transcribe this clip',
type: 'text',
},
{
source: {
media_type: 'audio/mpeg',
url: 'https://example.com/a.mp3',
},
type: 'audio',
},
]
> bytes and file handle
[
{
text: 'inspect these assets',
type: 'text',
},
{
source: {
data: 'aGVsbG8=',
file_name: 'hello.txt',
media_type: 'text/plain',
},
type: 'file',
},
{
source: {
file_handle: 'file_123',
file_name: 'report.pdf',
media_type: 'application/pdf',
},
type: 'file',
},
]
## buildNativeStructuredRequest should prefer explicit schema option
> Snapshot 1
{
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
}
## buildNativeStructuredRequest should ignore legacy params.schema fallback when explicit schema contract exists
> Snapshot 1
{
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: true,
}
## defineTool should precompute json schema at definition time
> Snapshot 1
{
additionalProperties: false,
properties: {
docId: {
type: 'string',
},
includeChildren: {
type: 'boolean',
},
},
required: [
'docId',
],
type: 'object',
}
## GeminiProvider should use native path for text-only requests
> Snapshot 1
{
include: [
'reasoning',
],
middleware: {
request: [
'normalize_messages',
'tool_schema_rewrite',
],
stream: [
'stream_event_normalize',
'citation_indexing',
],
},
reasoning: {
effort: 'medium',
},
remoteAttachmentRequests: [],
}
## GeminiProvider should use native path for structured requests
> Snapshot 1
{
request: {
messages: [
{
content: [
{
text: 'Return JSON only.',
type: 'text',
},
],
role: 'system',
},
{
content: [
{
text: 'Summarize AFFiNE in one short sentence.',
type: 'text',
},
],
role: 'user',
},
],
middleware: {
request: [
'normalize_messages',
'tool_schema_rewrite',
],
},
model: 'gemini-2.5-flash',
responseMimeType: 'application/json',
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: true,
},
result: {
summary: 'AFFiNE native',
},
}
## GeminiProvider should use native structured path for audio attachments
> Snapshot 1
{
content: [
{
text: 'transcribe the audio',
type: 'text',
},
{
source: {
data: 'YXVkaW8tYnl0ZXM=',
media_type: 'audio/mpeg',
},
type: 'audio',
},
],
remoteAttachmentRequests: [
'https://example.com/a.mp3',
],
result: [
{
a: 'Speaker 1',
e: 1,
s: 0,
t: 'Hello',
},
],
}
## GeminiProvider should use native path for embeddings
> Snapshot 1
{
request: {
dimensions: 3,
inputs: [
'first',
'second',
],
model: 'gemini-embedding-001',
taskType: 'RETRIEVAL_DOCUMENT',
},
result: [
[
0.1,
0.2,
],
[
1.1,
1.2,
],
],
}
## GeminiProvider should canonicalize native text attachments
> remote file attachment
{
content: [
{
text: 'summarize this file',
type: 'text',
},
{
source: {
data: 'cGRmLWJ5dGVz',
media_type: 'application/pdf',
},
type: 'file',
},
],
remoteAttachmentRequests: [
'https://example.com/a.pdf',
],
}
> remote image attachment
{
content: [
{
text: 'describe this image',
type: 'text',
},
{
source: {
data: 'aW1hZ2UtYnl0ZXM=',
media_type: 'image/jpeg',
},
type: 'image',
},
],
remoteAttachmentRequests: [
'https://example.com/a.jpg',
],
}
> downloaded audio webm attachment
{
content: [
{
text: 'transcribe this clip',
type: 'text',
},
{
source: {
data: 'YXVkaW8tYnl0ZXM=',
media_type: 'audio/webm',
},
type: 'audio',
},
],
remoteAttachmentRequests: [
'https://example.com/a.webm',
],
}
> google file url attachment
{
content: [
{
text: 'summarize this file',
type: 'text',
},
{
source: {
media_type: 'application/pdf',
url: 'https://generativelanguage.googleapis.com/v1beta/files/file-123',
},
type: 'file',
},
],
remoteAttachmentRequests: [],
}
## PerplexityProvider should ignore attachments during text model matching
> Snapshot 1
[
{
text: 'summarize this',
type: 'text',
},
]
## GeminiVertexProvider should prefetch bearer token for native config
> Snapshot 1
{
auth_token: 'vertex-token',
base_url: 'https://vertex.example',
}
## GeminiVertexProvider should materialize remote attachments before native text path
> remote http url
{
content: [
{
text: 'transcribe the audio',
type: 'text',
},
{
source: {
data: 'YXVkaW8tYnl0ZXM=',
media_type: 'audio/mpeg',
},
type: 'audio',
},
],
remoteAttachmentRequests: [
'https://example.com/a.mp3',
],
}
> gs url
{
content: [
{
text: 'transcribe the audio',
type: 'text',
},
{
source: {
data: 'b3B1cy1ieXRlcw==',
media_type: 'audio/opus',
},
type: 'audio',
},
],
remoteAttachmentRequests: [
'gs://bucket/audio.opus',
],
}
## OpenAIProvider should use native structured dispatch
> Snapshot 1
{
request: {
messages: [
{
content: [
{
text: 'Return JSON only.',
type: 'text',
},
],
role: 'system',
},
{
content: [
{
text: 'Summarize AFFiNE in one sentence.',
type: 'text',
},
],
role: 'user',
},
],
middleware: {
request: [
'normalize_messages',
'tool_schema_rewrite',
],
},
model: 'gpt-4.1',
responseMimeType: 'application/json',
schema: {
additionalProperties: false,
properties: {
summary: {
type: 'string',
},
},
required: [
'summary',
],
type: 'object',
},
strict: true,
},
result: {
summary: 'AFFiNE structured',
},
}
## OpenAIProvider should prefer native output_json for structured dispatch
> Snapshot 1
{
summary: 'AFFiNE structured',
}
## OpenAIProvider should use native embedding dispatch
> Snapshot 1
{
request: {
dimensions: 8,
inputs: [
'alpha',
'beta',
],
model: 'text-embedding-3-small',
taskType: 'RETRIEVAL_DOCUMENT',
},
result: [
[
0.4,
0.5,
],
[
0.4,
0.5,
],
],
}
## OpenAIProvider should use native rerank dispatch
> Snapshot 1
{
request: {
candidates: [
{
id: 'react',
text: 'React is a UI library.',
},
{
id: 'weather',
text: 'The park is sunny today.',
},
],
model: 'gpt-4.1',
query: 'programming',
},
scores: [
0.8,
0.8,
],
}
@@ -0,0 +1,505 @@
# Snapshot report for `src/__tests__/copilot/provider-native.spec.ts`
The actual snapshot is saved in `provider-native.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## CopilotProviderFactory should return no prepared routes when native prepare returns null
> Snapshot 1
{
chat: [
length: 0,
prepared: undefined,
providerId: undefined,
],
embedding: [
length: 0,
prepared: undefined,
],
rerank: [
length: 0,
prepared: undefined,
],
structured: [
length: 0,
prepared: undefined,
],
}
## getActiveProviderMiddleware should merge defaults with profile override
> Snapshot 1
{
node: {
text: [
'citation_footnote',
'callout',
'thinking_format',
],
},
rust: {
request: [
'clamp_max_tokens',
],
stream: undefined,
},
}
## checkParams should infer remote image capability from url extension without host mime inference
> Snapshot 1
{
attachmentKinds: [
'image',
],
attachmentSourceKinds: [
'url',
],
inputTypes: [
'image',
'text',
],
}
## llmResolveRequestedModelMatch should preserve provider-prefixed optional matches
> prefixed optional hit
{
matchedOptionalModel: true,
selectedModel: 'openai-default/gemini-2.5-pro',
}
> prefixed optional miss
{
matchedOptionalModel: false,
selectedModel: 'gemini-2.5-flash',
}
## ExecutionPlan should serialize routed request state and reject host-only signal
> Snapshot 1
{
fallbackOrder: [
'openai-main',
],
transport: {
kind: 'chat',
request: {
messages: [
{
content: [
{
text: 'hello',
type: 'text',
},
],
role: 'user',
},
],
model: 'gpt-5-mini',
},
},
}
## NativeExecutionEngine should dispatch prepared text routes through native fallback
> Snapshot 1
[
{
model: 'gpt-5-mini',
providerId: 'openai-primary',
requestShape: {
candidateCount: 0,
firstContent: 'hello from primary',
inputCount: 0,
keys: [
'messages',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
{
model: 'gpt-5-mini',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 0,
firstContent: 'hello from fallback',
inputCount: 0,
keys: [
'messages',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
]
## NativeExecutionEngine should prefer prepared native fallback dispatch for explicit routes
> Snapshot 1
[
{
model: 'gpt-5-mini',
providerId: 'openai-primary',
requestShape: {
candidateCount: 0,
firstContent: 'hello',
inputCount: 0,
keys: [
'messages',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
{
model: 'gpt-5-mini',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 0,
firstContent: 'hello',
inputCount: 0,
keys: [
'messages',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
]
## ExecutionPlanBuilder should keep tool-loop chat routes on prepared dispatch path
> Snapshot 1
{
preparedTools: [
'answer',
],
transport: undefined,
}
## ExecutionPlanBuilder should keep single-route tool chat plans on prepared_routes path
> Snapshot 1
{
kind: 'chat',
request: {
messages: [
{
content: [
{
text: 'hello',
type: 'text',
},
],
role: 'user',
},
],
model: 'gpt-5-mini',
tools: [
{
description: 'Answer',
name: 'answer',
parameters: {
properties: {
value: {
type: 'string',
},
},
required: [
'value',
],
type: 'object',
},
},
],
},
}
## NativeExecutionEngine should route tool-loop chat prepared routes through native dispatch
> Snapshot 1
[
{
model: 'gpt-5-mini',
providerId: 'openai-primary',
requestShape: {
candidateCount: 0,
firstContent: 'hello',
inputCount: 0,
keys: [
'messages',
'model',
'tools',
],
query: undefined,
schemaKeys: undefined,
toolNames: [
'answer',
],
},
},
{
model: 'gpt-5-mini',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 0,
firstContent: 'hello from fallback',
inputCount: 0,
keys: [
'messages',
'model',
'tools',
],
query: undefined,
schemaKeys: undefined,
toolNames: [
'answer',
],
},
},
]
## ExecutionPlanBuilder should build native prepared routes for structured, image, embedding and rerank
> Snapshot 1
{
embedding: {
routes: 2,
transport: undefined,
},
image: {
prepared: {
request: {
images: [],
model: 'gpt-image-1',
operation: 'generate',
prompt: 'draw a cat',
},
route: {
backendConfig: {
auth_token: 'image-key',
base_url: 'https://api.openai.com',
},
model: 'gpt-image-1',
protocol: 'openai_images',
providerId: 'openai-default',
},
},
routes: [
{
config: {
auth_token: 'image-key',
base_url: 'https://api.openai.com',
},
model: 'gpt-image-1',
protocol: 'openai_images',
provider_id: 'openai-default',
request: {
images: [],
model: 'gpt-image-1',
operation: 'generate',
prompt: 'draw a cat',
},
},
],
},
rerank: {
routes: 2,
transport: undefined,
},
structured: {
routes: 2,
transport: undefined,
},
}
## NativeExecutionEngine should dispatch structured prepared routes through native execution
> Snapshot 1
[
{
model: 'gpt-5-mini',
providerId: 'openai-primary',
requestShape: {
candidateCount: 0,
firstContent: 'hello',
inputCount: 0,
keys: [
'messages',
'model',
'schema',
],
query: undefined,
schemaKeys: [
'ok',
],
toolNames: [],
},
},
{
model: 'gpt-5-mini',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 0,
firstContent: 'hello from fallback',
inputCount: 0,
keys: [
'messages',
'model',
'schema',
],
query: undefined,
schemaKeys: [
'ok',
],
toolNames: [],
},
},
]
## NativeExecutionEngine should dispatch embedding prepared routes through native execution
> Snapshot 1
{
called: true,
result: [
[
0.1,
0.2,
],
],
routes: [
{
model: 'text-embedding-3-small',
providerId: 'openai-primary',
requestShape: {
candidateCount: 0,
firstContent: null,
inputCount: 1,
keys: [
'inputs',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
{
model: 'text-embedding-3-small',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 0,
firstContent: null,
inputCount: 1,
keys: [
'inputs',
'model',
],
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
],
}
## NativeExecutionEngine should dispatch rerank prepared routes through native execution
> Snapshot 1
{
called: true,
result: [
0.9,
0.1,
],
routes: [
{
model: 'gpt-4o-mini',
providerId: 'openai-primary',
requestShape: {
candidateCount: 1,
firstContent: null,
inputCount: 0,
keys: [
'candidates',
'model',
'query',
],
query: 'programming',
schemaKeys: undefined,
toolNames: [],
},
},
{
model: 'gpt-4o-mini',
providerId: 'openai-fallback',
requestShape: {
candidateCount: 1,
firstContent: null,
inputCount: 0,
keys: [
'candidates',
'model',
'query',
],
query: 'programming fallback',
schemaKeys: undefined,
toolNames: [],
},
},
],
}
## NativeExecutionEngine should dispatch image plans through prepared native routes
> Snapshot 1
[
{
model: 'gpt-image-1',
providerId: 'openai-image',
requestShape: {
candidateCount: 0,
firstContent: null,
imageCount: 0,
inputCount: 0,
keys: [
'images',
'model',
'operation',
'prompt',
],
prompt: 'draw a cat',
query: undefined,
schemaKeys: undefined,
toolNames: [],
},
},
]
@@ -1,50 +1,51 @@
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { z } from 'zod';
import { ServerFeature, ServerService } from '../../core';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { Models } from '../../models';
import { llmImageDispatchPlan } from '../../native';
import { CopilotModule } from '../../plugins/copilot';
import { prompts, PromptService } from '../../plugins/copilot/prompt';
import { PromptService } from '../../plugins/copilot/prompt';
import {
CopilotProviderFactory,
CopilotProviderType,
StreamObject,
StreamObjectSchema,
} from '../../plugins/copilot/providers';
import { TranscriptionResponseSchema } from '../../plugins/copilot/transcript/schema';
import {
CopilotChatTextExecutor,
CopilotWorkflowService,
GraphExecutorState,
} from '../../plugins/copilot/workflow';
import {
CopilotChatImageExecutor,
CopilotCheckHtmlExecutor,
CopilotCheckJsonExecutor,
} from '../../plugins/copilot/workflow/executor';
import { ActionStreamHost } from '../../plugins/copilot/runtime/hosts/action-stream-host';
import { getProviderRuntimeHost } from '../../plugins/copilot/runtime/provider-runtime-context';
import { ChatSession, ChatSessionService } from '../../plugins/copilot/session';
import { TranscriptPayloadSchema } from '../../plugins/copilot/transcript/schema';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript/service';
import { TestingPromptService } from '../mocks/prompt-service.mock';
import { createTestingModule, TestingModule } from '../utils';
import { TestAssets } from '../utils/copilot';
import {
assistantPrompt,
promptMessages,
singleUserPromptMessages,
userPrompt,
} from './prompt-test-helper';
type Tester = {
auth: AuthService;
module: TestingModule;
models: Models;
service: ServerService;
prompt: PromptService;
prompt: TestingPromptService;
factory: CopilotProviderFactory;
workflow: CopilotWorkflowService;
executors: {
image: CopilotChatImageExecutor;
text: CopilotChatTextExecutor;
html: CopilotCheckHtmlExecutor;
json: CopilotCheckJsonExecutor;
};
session: ChatSessionService;
actionStreams: ActionStreamHost;
transcript: CopilotTranscriptionService;
};
const test = ava as TestFn<Tester>;
let isCopilotConfigured = false;
@@ -65,6 +66,9 @@ const runIfCopilotConfigured = test.macro(
test.serial.before(async t => {
const module = await createTestingModule({
imports: [QuotaModule, CopilotModule],
tapModule: builder => {
builder.overrideProvider(PromptService).useClass(TestingPromptService);
},
});
const service = module.get(ServerService);
@@ -72,9 +76,11 @@ test.serial.before(async t => {
const auth = module.get(AuthService);
const models = module.get(Models);
const prompt = module.get(PromptService);
const prompt = module.get(PromptService) as TestingPromptService;
const factory = module.get(CopilotProviderFactory);
const workflow = module.get(CopilotWorkflowService);
const session = module.get(ChatSessionService);
const actionStreams = module.get(ActionStreamHost);
const transcript = module.get(CopilotTranscriptionService);
t.context.module = module;
t.context.auth = auth;
@@ -82,51 +88,15 @@ test.serial.before(async t => {
t.context.models = models;
t.context.prompt = prompt;
t.context.factory = factory;
t.context.workflow = workflow;
t.context.executors = {
image: module.get(CopilotChatImageExecutor),
text: module.get(CopilotChatTextExecutor),
html: module.get(CopilotCheckHtmlExecutor),
json: module.get(CopilotCheckJsonExecutor),
};
t.context.session = session;
t.context.actionStreams = actionStreams;
t.context.transcript = transcript;
});
test.serial.before(async t => {
const { prompt, executors, models, service } = t.context;
const { prompt } = t.context;
executors.image.register();
executors.text.register();
executors.html.register();
executors.json.register();
for (const name of await prompt.listNames()) {
await prompt.delete(name);
}
for (const p of prompts) {
await prompt.set(p.name, p.model, p.messages, p.config);
}
const user = await models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await service.updateConfig(user.id, [
{
module: 'copilot',
key: 'scenarios',
value: {
enabled: true,
scenarios: {
image: 'flux-1/schnell',
complex_text_generation: 'gpt-5-mini',
coding: 'gpt-5-mini',
quick_decision_making: 'gpt-5-mini',
quick_text_generation: 'gpt-5-mini',
polish_and_summarize: 'gemini-2.5-flash',
},
},
},
]);
prompt.reset();
});
test.after(async t => {
@@ -332,10 +302,9 @@ const actions = [
{
name: 'Should chat with histories',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: `
messages: promptMessages(
userPrompt(
`
Hi! Im going to send you a technical term related to real-time collaborative editing (e.g., CRDT, Operational Transformation, OT Composer, etc.). Whenever I send you a term:
1. Translate it into Chinese (send me the Chinese version).
2. Then translate that Chinese back into English (send me the retranslated English).
@@ -344,11 +313,10 @@ Hi! Im going to send you a technical term related to real-time collaborative
5. Finally, give the origin or “term history” (e.g., who introduced it, in which paper or year).
If you understand, please proceed by explaining the term “CRDT.”
`.trim(),
},
{
role: 'assistant' as const,
content: `
`.trim()
),
assistantPrompt(
`
1. **Chinese Translation:**
“CRDT” → **无冲突复制数据类型**
@@ -366,13 +334,12 @@ CRDTs enable **eventual consistency (最终一致性)** in real-time collaborati
4. **Origin / Term History:**
The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski in their 2011 paper titled “Conflict-free Replicated Data Types” (published in the _Stabilization, Safety, and Security of Distributed Systems (SSS)_ conference). They formalized two families of CRDTs—state-based (“Convergent Replicated Data Types” or CvRDTs) and operation-based (“Commutative Replicated Data Types” or CmRDTs)—and proved their convergence properties under asynchronous, unreliable networks.
`.trim(),
},
{
role: 'user' as const,
content: `Thanks! Now please just tell me the **Chinese translation** and the **back-translated English term** that you provided previously for “CRDT.” Do not reprint the full introduction—only those two lines.`,
},
],
`.trim()
),
userPrompt(
'Thanks! Now please just tell me the **Chinese translation** and the **back-translated English term** that you provided previously for “CRDT.” Do not reprint the full introduction—only those two lines.'
)
),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const lower = result.toLowerCase();
@@ -387,22 +354,18 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
{
name: 'Should not have citation',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: 'what is AFFiNE AI?',
params: {
files: [
{
blobId: 'todo_md',
fileName: 'todo.md',
fileType: 'text/markdown',
fileContent: TestAssets.TODO,
},
],
},
messages: singleUserPromptMessages('what is AFFiNE AI?', {
params: {
files: [
{
blobId: 'todo_md',
fileName: 'todo.md',
fileType: 'text/markdown',
fileContent: TestAssets.TODO,
},
],
},
],
}),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
assertCitation(t, result, (t, c) => {
@@ -422,22 +385,18 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
{
name: 'Should have citation',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: 'what is ssot',
params: {
docs: [
{
docId: 'SSOT',
docTitle: 'Single source of truth - Wikipedia',
fileType: 'text/markdown',
docContent: TestAssets.SSOT,
},
],
},
messages: singleUserPromptMessages('what is ssot', {
params: {
docs: [
{
docId: 'SSOT',
docTitle: 'Single source of truth - Wikipedia',
fileType: 'text/markdown',
docContent: TestAssets.SSOT,
},
],
},
],
}),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
assertCitation(t, result);
@@ -447,12 +406,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
{
name: 'stream objects',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: 'what is AFFiNE AI',
},
],
messages: singleUserPromptMessages('what is AFFiNE AI'),
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.truthy(checkStreamObjects(result), 'should be valid stream objects');
},
@@ -461,13 +415,9 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
{
name: 'Gemini native text',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content:
'In one short sentence, explain what AFFiNE AI is and mention AFFiNE by name.',
},
],
messages: singleUserPromptMessages(
'In one short sentence, explain what AFFiNE AI is and mention AFFiNE by name.'
),
config: { model: 'gemini-2.5-flash' },
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
@@ -482,13 +432,9 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
{
name: 'Gemini native stream objects',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content:
'Respond with one short sentence about AFFiNE AI and mention AFFiNE by name.',
},
],
messages: singleUserPromptMessages(
'Respond with one short sentence about AFFiNE AI and mention AFFiNE by name.'
),
config: { model: 'gemini-2.5-flash' },
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.truthy(checkStreamObjects(result), 'should be valid stream objects');
@@ -501,92 +447,18 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
prefer: CopilotProviderType.Gemini,
type: 'object' as const,
},
{
name: 'Should transcribe short audio',
promptName: ['Transcript audio'],
messages: [
{
role: 'user' as const,
content: 'transcript the audio',
attachments: [
'https://cdn.affine.pro/copilot-test/MP9qDGuYgnY+ILoEAmHpp3h9Npuw2403EAYMEA.mp3',
],
params: {
schema: TranscriptionResponseSchema,
},
},
],
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.notThrows(() => {
TranscriptionResponseSchema.parse(JSON.parse(result));
});
},
type: 'structured' as const,
prefer: CopilotProviderType.Gemini,
},
{
name: 'Should transcribe middle audio',
promptName: ['Transcript audio'],
messages: [
{
role: 'user' as const,
content: 'transcript the audio',
attachments: [
'https://cdn.affine.pro/copilot-test/2ed05eo1KvZ2tWB_BAjFo67EAPZZY-w4LylUAw.m4a',
],
params: {
schema: TranscriptionResponseSchema,
},
},
],
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.notThrows(() => {
TranscriptionResponseSchema.parse(JSON.parse(result));
});
},
type: 'structured' as const,
prefer: CopilotProviderType.Gemini,
},
{
name: 'Should transcribe long audio',
promptName: ['Transcript audio'],
messages: [
{
role: 'user' as const,
content: 'transcript the audio',
attachments: [
'https://cdn.affine.pro/copilot-test/nC9-e7P85PPI2rU29QWwf8slBNRMy92teLIIMw.opus',
],
params: {
schema: TranscriptionResponseSchema,
},
},
],
config: { model: 'gemini-2.5-pro' },
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.notThrows(() => {
TranscriptionResponseSchema.parse(JSON.parse(result));
});
},
type: 'structured' as const,
prefer: CopilotProviderType.Gemini,
},
{
promptName: ['Conversation Summary'],
messages: [
{
role: 'user' as const,
content: '',
params: {
messages: [
{ role: 'user', content: 'what is single source of truth?' },
{ role: 'assistant', content: TestAssets.SSOT },
],
focus: 'technical decisions',
length: 'comprehensive',
},
messages: singleUserPromptMessages('', {
params: {
messages: [
userPrompt('what is single source of truth?'),
assistantPrompt(TestAssets.SSOT),
],
focus: 'technical decisions',
length: 'comprehensive',
},
],
}),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const cleared = result.toLowerCase();
@@ -619,7 +491,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
'Section Edit',
'Chat With AFFiNE AI',
],
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
messages: singleUserPromptMessages(TestAssets.SSOT),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const cleared = result.toLowerCase();
@@ -634,7 +506,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Continue writing'],
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
messages: singleUserPromptMessages(TestAssets.AFFiNE),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(result.length > 0, 'should not be empty');
@@ -643,7 +515,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Brainstorm ideas about this', 'Brainstorm mindmap'],
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
messages: singleUserPromptMessages(TestAssets.AFFiNE),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(checkMDList(result), 'should be a markdown list');
@@ -652,7 +524,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: 'Expand mind map',
messages: [{ role: 'user' as const, content: '- Single source of truth' }],
messages: singleUserPromptMessages('- Single source of truth'),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(checkMDList(result), 'should be a markdown list');
@@ -661,7 +533,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: 'Find action items from it',
messages: [{ role: 'user' as const, content: TestAssets.TODO }],
messages: singleUserPromptMessages(TestAssets.TODO),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(checkMDList(result), 'should be a markdown list');
@@ -670,7 +542,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Explain this code', 'Check code error'],
messages: [{ role: 'user' as const, content: TestAssets.Code }],
messages: singleUserPromptMessages(TestAssets.Code),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(
@@ -683,13 +555,9 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: 'Translate to',
messages: [
{
role: 'user' as const,
content: TestAssets.SSOT,
params: { language: 'Simplified Chinese' },
},
],
messages: singleUserPromptMessages(TestAssets.SSOT, {
params: { language: 'Simplified Chinese' },
}),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const cleared = result.toLowerCase();
@@ -702,15 +570,11 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Generate a caption', 'Explain this image'],
messages: [
{
role: 'user' as const,
content: '',
attachments: [
'https://cdn.affine.pro/copilot-test/Qgqy9qZT3VGIEuMIotJYoCCH.jpg',
],
},
],
messages: singleUserPromptMessages('', {
attachments: [
'https://cdn.affine.pro/copilot-test/Qgqy9qZT3VGIEuMIotJYoCCH.jpg',
],
}),
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const content = result.toLowerCase();
@@ -725,15 +589,11 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Convert to sticker', 'Remove background', 'Upscale image'],
messages: [
{
role: 'user' as const,
content: '',
attachments: [
'https://cdn.affine.pro/copilot-test/Zkas098lkjdf-908231.jpg',
],
},
],
messages: singleUserPromptMessages('', {
attachments: [
'https://cdn.affine.pro/copilot-test/Zkas098lkjdf-908231.jpg',
],
}),
verifier: (t: ExecutionContext<Tester>, link: string) => {
t.truthy(checkUrl(link), 'should be a valid url');
},
@@ -741,12 +601,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
{
promptName: ['Generate image'],
messages: [
{
role: 'user' as const,
content: 'Panda',
},
],
messages: singleUserPromptMessages('Panda'),
config: { quality: 'low' },
verifier: (t: ExecutionContext<Tester>, link: string) => {
t.truthy(checkUrl(link), 'should be a valid url');
@@ -774,7 +629,9 @@ for (const {
const prompt = (await promptService.get(promptName))!;
t.truthy(prompt, 'should have prompt');
const finalConfig = Object.assign({}, prompt.config, config);
const modelId = finalConfig.model || prompt.model;
const modelId =
('model' in finalConfig ? finalConfig.model : undefined) ??
prompt.model;
const provider = (await factory.getProviderByModel(modelId, {
prefer,
}))!;
@@ -782,29 +639,11 @@ for (const {
await retry(`action: ${promptName}`, t, async t => {
switch (type) {
case 'text': {
const result = await provider.text(
const result = await getProviderRuntimeHost(provider).run.text(
{ modelId },
[
...prompt.finish(
messages.reduce(
// @ts-expect-error params not typed
(acc, m) => Object.assign(acc, m.params),
{}
)
),
...messages,
],
finalConfig
);
t.truthy(result, 'should return result');
verifier?.(t, result);
break;
}
case 'structured': {
const result = await provider.structure(
{ modelId },
[
...prompt.finish(
...promptService.finish(
prompt,
messages.reduce(
(acc, m) => Object.assign(acc, m.params),
{}
@@ -820,10 +659,13 @@ for (const {
}
case 'object': {
const streamObjects: StreamObject[] = [];
for await (const chunk of provider.streamObject(
for await (const chunk of getProviderRuntimeHost(
provider
).run.streamObject(
{ modelId },
[
...prompt.finish(
...promptService.finish(
prompt,
messages.reduce(
(acc, m) => Object.assign(acc, (m as any).params || {}),
{}
@@ -852,29 +694,39 @@ for (const {
: undefined,
});
}
const stream = provider.streamImages(
{ modelId },
[
...prompt.finish(
finalMessage.reduce(
// @ts-expect-error params not typed
(acc, m) => Object.assign(acc, m.params),
params
)
),
...finalMessage,
const imageMessages = [
...promptService.finish(
prompt,
finalMessage.reduce(
(acc, m) => Object.assign(acc, m.params),
params
)
),
...finalMessage,
];
const prepared = await getProviderRuntimeHost(
provider
).prepare.image({ modelId }, imageMessages, finalConfig);
t.truthy(prepared, 'should prepare image request');
const result = await llmImageDispatchPlan({
preparedRoutes: [
{
provider_id: prepared!.route.providerId,
protocol: prepared!.route.protocol,
model: prepared!.route.model,
config: prepared!.route.backendConfig,
request: prepared!.request,
},
],
finalConfig
);
});
const result = [];
for await (const attachment of stream) {
result.push(attachment);
}
t.truthy(result.length, 'should return result');
for (const r of result) {
verifier?.(t, r);
t.truthy(result.response.images.length, 'should return result');
for (const image of result.response.images) {
const link = image.data_base64
? `data:${image.media_type};base64,${image.data_base64}`
: image.url;
t.truthy(link);
verifier?.(t, link!);
}
break;
}
@@ -889,53 +741,278 @@ for (const {
}
}
// ==================== workflow ====================
// ==================== action recipes ====================
const workflows = [
function actionRunRecord(
input: Parameters<Models['copilotActionRun']['create']>[0]
) {
return {
id: `action-run-${randomUUID()}`,
userId: input.userId,
workspaceId: input.workspaceId,
docId: input.docId ?? null,
sessionId: input.sessionId ?? null,
userMessageId: input.userMessageId ?? null,
compatSubmissionId: input.compatSubmissionId ?? null,
assistantMessageId: null,
actionId: input.actionId,
actionVersion: input.actionVersion,
status: 'created' as const,
attempt: input.attempt ?? 1,
retryOf: input.retryOf ?? null,
inputSnapshot: (input.inputSnapshot ?? null) as Prisma.JsonValue,
result: null,
artifacts: null,
resultSummary: null,
errorCode: null,
trace: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function installActionSessionMock(
t: ExecutionContext<Tester>,
{
name: 'brainstorm',
actionId,
actionPrompt,
content,
}: {
actionId: string;
actionPrompt: Awaited<ReturnType<TestingPromptService['get']>>;
content: string;
}
) {
const { models, session } = t.context;
const sandbox = Sinon.createSandbox();
const sessionId = `copilot-provider-action-${actionId}-${randomUUID()}`;
const userId = `copilot-provider-user-${randomUUID()}`;
const workspaceId = `copilot-provider-action-${actionId}`;
const docId = `copilot-provider-action-${actionId}-doc`;
const savedTurns: Array<{ role: string }> = [];
const userTurn = {
conversationId: sessionId,
role: 'user' as const,
content,
attachments: [],
renderTrace: [],
toolEvents: [],
metadata: { language: 'English' },
createdAt: new Date(),
};
const chatSession = new ChatSession(
{
userId,
sessionId,
workspaceId,
docId,
turns: [userTurn],
prompt: actionPrompt!,
},
(prompt, turns, params, maxTokenSize, sessionId) =>
t.context.prompt.renderSession(
prompt,
turns,
params,
maxTokenSize,
sessionId
),
async state => {
savedTurns.push(...state.turns);
}
);
sandbox
.stub(session, 'get')
.callsFake(async id => (id === sessionId ? chatSession : null));
sandbox.stub(session, 'appendTurn').callsFake(async input => {
savedTurns.push(input.turn);
return { ...input.turn, id: `assistant-${randomUUID()}` };
});
sandbox.stub(session, 'revertLatestMessage').resolves();
sandbox
.stub(models.copilotActionRun, 'create')
.callsFake(async input => actionRunRecord(input));
sandbox.stub(models.copilotActionRun, 'markRunning').callsFake(
async id =>
({
id,
status: 'running',
}) as never
);
sandbox.stub(models.copilotActionRun, 'complete').callsFake(
async (id, input) =>
({
id,
...input,
updatedAt: new Date(),
}) as never
);
return { sandbox, sessionId, userId, savedTurns };
}
const actionRecipeCases = [
{
actionId: 'mindmap.generate',
content: 'apple company',
verifier: (t: ExecutionContext, result: string) => {
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(checkMDList(result), 'should be a markdown list');
},
},
{
name: 'presentation',
actionId: 'slides.outline',
content: 'apple company',
verifier: (t: ExecutionContext, result: string) => {
for (const l of result.split('\n')) {
const line = l.trim();
if (!line) continue;
t.notThrows(() => {
JSON.parse(l.trim());
}, 'should be valid json');
}
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
t.assert(
result
.split('\n')
.filter(line => line.trim())
.every(line => /^( {2})*(-|\*|\+) .+$/.test(line)),
'should be a markdown list'
);
t.false(
result
.split('\n')
.filter(line => line.trim())
.every(line => {
try {
JSON.parse(line);
return true;
} catch {
return false;
}
}),
'should not expose raw NDJSON'
);
},
},
];
for (const { name, content, verifier } of workflows) {
test(
`should be able to run workflow: ${name}`,
for (const { actionId, content, verifier } of actionRecipeCases) {
test.serial(
`should be able to run action recipe: ${actionId}`,
runIfCopilotConfigured,
async t => {
const { workflow } = t.context;
await retry(`action recipe: ${actionId}`, t, async t => {
const { actionStreams, prompt } = t.context;
const actionPrompt = await prompt.get(actionId);
if (!actionPrompt) {
return t.fail(`prompt ${actionId} should exist`);
}
const { sandbox, sessionId, userId, savedTurns } =
installActionSessionMock(t, { actionId, actionPrompt, content });
await retry(`workflow: ${name}`, t, async t => {
let result = '';
for await (const ret of workflow.runGraph({ content }, name)) {
if (ret.status === GraphExecutorState.EnterNode) {
t.log('enter node:', ret.node.name);
} else if (ret.status === GraphExecutorState.ExitNode) {
t.log('exit node:', ret.node.name);
} else if (ret.status === GraphExecutorState.EmitAttachment) {
t.log('stream attachment:', ret);
} else {
result += ret.content;
try {
const prepared = await actionStreams.stream(userId, sessionId, {
actionId,
actionVersion: 'v1',
modelId: actionPrompt.model,
});
for await (const event of prepared.stream) {
if (event.type === 'action_done' && event.status === 'succeeded') {
if (typeof event.result === 'string') {
result += event.result;
} else if (event.result && typeof event.result === 'object') {
const value = event.result as {
content?: unknown;
text?: unknown;
result?: unknown;
};
result +=
typeof value.content === 'string'
? value.content
: typeof value.text === 'string'
? value.text
: typeof value.result === 'string'
? value.result
: '';
}
}
}
} finally {
sandbox.restore();
}
t.truthy(result, 'should return result');
verifier?.(t, result);
verifier(t, result);
t.true(
savedTurns.some(turn => turn.role === 'assistant'),
'should persist assistant turn through real conversation host'
);
});
}
);
}
const TRANSCRIPT_AUDIO_CASES = [
{
name: 'short audio',
url: 'https://cdn.affine.pro/copilot-test/MP9qDGuYgnY+ILoEAmHpp3h9Npuw2403EAYMEA.mp3',
mimeType: 'audio/mpeg',
modelId: 'gemini-2.5-flash',
},
{
name: 'middle audio',
url: 'https://cdn.affine.pro/copilot-test/2ed05eo1KvZ2tWB_BAjFo67EAPZZY-w4LylUAw.m4a',
mimeType: 'audio/m4a',
modelId: 'gemini-2.5-flash',
},
{
name: 'long audio',
url: 'https://cdn.affine.pro/copilot-test/nC9-e7P85PPI2rU29QWwf8slBNRMy92teLIIMw.opus',
mimeType: 'audio/opus',
modelId: 'gemini-2.5-pro',
},
];
for (const testCase of TRANSCRIPT_AUDIO_CASES) {
test(
`should run transcript task through native action bridge: ${testCase.name}`,
runIfCopilotConfigured,
async t => {
const { models, transcript } = t.context;
const userId = `copilot-provider-transcript-user-${randomUUID()}`;
const workspaceId = `copilot-provider-transcript-workspace-${randomUUID()}`;
const blobId = `copilot-provider-transcript-blob-${randomUUID()}`;
const payload = TranscriptPayloadSchema.parse({
sourceAudio: { blobId, mimeType: testCase.mimeType },
infos: [
{
url: testCase.url,
mimeType: testCase.mimeType,
index: 0,
},
],
});
const task = await models.copilotTranscriptTask.create({
userId,
workspaceId,
blobId,
strategy: 'gemini',
recipeId: 'transcript.audio.gemini',
recipeVersion: 'v1',
inputSnapshot: payload,
publicMeta: {
sourceAudio: payload.sourceAudio,
infos: payload.infos,
},
});
await retry('transcript native action recipe', t, async t => {
await transcript.transcriptTask({
taskId: task.id,
payload,
modelId: testCase.modelId,
});
const ready = await models.copilotTranscriptTask.get(task.id);
t.is(ready?.status, 'ready');
const parsed = TranscriptPayloadSchema.parse(ready?.protectedResult);
t.is(typeof parsed.normalizedTranscript, 'string');
});
}
);
@@ -967,7 +1044,7 @@ test(
const provider = (await factory.getProviderByModel('gpt-4o-mini'))!;
t.assert(provider, 'should have provider for rerank');
const scores = await provider.rerank(
const scores = await getProviderRuntimeHost(provider).run.rerank(
{ modelId: 'gpt-4o-mini' },
{
query,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import test from 'ava';
import { summarizePreparedRoutes } from '../../plugins/copilot/runtime/execution-metrics';
test('summarizePreparedRoutes should report none when no route is prepared', t => {
t.deepEqual(
summarizePreparedRoutes([{ prepared: undefined }, { prepared: undefined }]),
{
routeCount: 2,
preparedCount: 0,
preparedMode: 'none',
}
);
});
test('summarizePreparedRoutes should report partial when only some routes are prepared', t => {
t.deepEqual(
summarizePreparedRoutes([
{ prepared: { route: {} } as never },
{ prepared: undefined },
]),
{
routeCount: 2,
preparedCount: 1,
preparedMode: 'partial',
}
);
});
test('summarizePreparedRoutes should report all when every route is prepared', t => {
t.deepEqual(
summarizePreparedRoutes([
{ prepared: { route: {} } as never },
{ prepared: { route: {} } as never },
]),
{
routeCount: 2,
preparedCount: 2,
preparedMode: 'all',
}
);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
import type { LlmRequest } from '../../native';
import type { PromptMessage } from '../../plugins/copilot/providers/types';
function createPromptMessage(
role: PromptMessage['role'],
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return {
role,
content,
...extra,
};
}
export function userPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('user', content, extra);
}
export function assistantPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('assistant', content, extra);
}
export function systemPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('system', content, extra);
}
export function promptMessages(...messages: PromptMessage[]) {
return messages;
}
export function singleUserPromptMessages(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
) {
return promptMessages(userPrompt(content, extra));
}
export function jsonOnlyPromptMessages(userContent: string) {
return promptMessages(
systemPrompt('Return JSON only.'),
userPrompt(userContent)
);
}
type NativeTextMessage = LlmRequest['messages'][number];
export function nativeUserText(text: string): NativeTextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
};
}
export function nativeAssistantText(text: string): NativeTextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text }],
};
}
export function nativeMessages(...messages: NativeTextMessage[]) {
return messages;
}
@@ -7,14 +7,7 @@ import { CopilotProviderType } from '../../plugins/copilot/providers/types';
test('resolveProviderMiddleware should include anthropic defaults', t => {
const middleware = resolveProviderMiddleware(CopilotProviderType.Anthropic);
t.deepEqual(middleware.rust?.request, [
'normalize_messages',
'tool_schema_rewrite',
]);
t.deepEqual(middleware.rust?.stream, [
'stream_event_normalize',
'citation_indexing',
]);
t.is(middleware.rust, undefined);
t.deepEqual(middleware.node?.text, ['citation_footnote', 'callout']);
});
@@ -24,10 +17,7 @@ test('resolveProviderMiddleware should merge defaults and overrides', t => {
node: { text: ['thinking_format'] },
});
t.deepEqual(middleware.rust?.request, [
'normalize_messages',
'clamp_max_tokens',
]);
t.deepEqual(middleware.rust?.request, ['clamp_max_tokens']);
t.deepEqual(middleware.node?.text, [
'citation_footnote',
'callout',
@@ -48,9 +38,6 @@ test('buildProviderRegistry should normalize profile middleware defaults', t =>
const profile = registry.profiles.get('openai-main');
t.truthy(profile);
t.deepEqual(profile?.middleware.rust?.stream, [
'stream_event_normalize',
'citation_indexing',
]);
t.is(profile?.middleware.rust, undefined);
t.deepEqual(profile?.middleware.node?.text, ['citation_footnote', 'callout']);
});
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
import test from 'ava';
import { OpenAIProvider } from '../../plugins/copilot/providers';
import { CopilotProviderLifecycleService } from '../../plugins/copilot/providers/lifecycle-service';
import {
buildProviderRegistry,
resolveModel,
@@ -142,6 +144,46 @@ test('resolveModel should follow defaults -> fallback -> order and apply filters
t.deepEqual(routed.candidateProviderIds, ['openai-main', 'fal-main']);
});
test('resolveModel should resolve bare model ids by provider priority order', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
priority: 10,
config: { apiKey: '1' },
},
{
id: 'anthropic-main',
type: CopilotProviderType.Anthropic,
priority: 5,
config: { apiKey: '2' },
},
{
id: 'fal-main',
type: CopilotProviderType.FAL,
priority: 1,
config: { apiKey: '3' },
},
],
defaults: {
[ModelOutputType.Text]: 'anthropic-main',
fallback: 'fal-main',
},
});
const routed = resolveModel({
registry,
modelId: 'shared-model',
});
t.deepEqual(routed.candidateProviderIds, [
'openai-main',
'anthropic-main',
'fal-main',
]);
});
test('stripProviderPrefix should only strip matched provider prefix', t => {
const registry = buildProviderRegistry({
profiles: [
@@ -166,3 +208,75 @@ test('stripProviderPrefix should only strip matched provider prefix', t => {
'gpt-5-mini'
);
});
test('CopilotProviderLifecycleService should register current profiles and unregister stale ones', async t => {
const calls: string[] = [];
let registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
{
id: 'openai-backup',
type: CopilotProviderType.OpenAI,
config: { apiKey: '2' },
},
],
});
const provider = {
type: CopilotProviderType.OpenAI,
configured(execution: { providerId?: string } | undefined) {
return execution?.providerId === 'openai-main';
},
};
const service = new CopilotProviderLifecycleService(
{
get(token: unknown) {
return token === OpenAIProvider ? provider : undefined;
},
} as any,
{
register(providerId: string) {
calls.push(`register:${providerId}`);
},
unregister(providerId: string) {
calls.push(`unregister:${providerId}`);
},
} as any,
{
getRegistry() {
return registry;
},
} as any
);
await service.syncProviders();
t.deepEqual(calls.slice().sort(), [
'register:openai-main',
'unregister:openai-backup',
]);
calls.length = 0;
registry = buildProviderRegistry({
profiles: [
{
id: 'openai-backup',
type: CopilotProviderType.OpenAI,
config: { apiKey: '2' },
},
],
});
provider.configured = (execution: { providerId?: string } | undefined) =>
execution?.providerId === 'openai-backup';
await service.syncProviders();
t.deepEqual(calls.slice().sort(), [
'register:openai-backup',
'unregister:openai-main',
]);
});
@@ -0,0 +1,201 @@
import serverNativeModule from '@affine/server-native';
import test from 'ava';
import { z } from 'zod';
import type {
LlmEmbeddingRequest,
LlmRerankRequest,
LlmStructuredRequest,
} from '../../native';
import { CopilotProvider } from '../../plugins/copilot/providers/provider';
import type { ProviderDriverSpec } from '../../plugins/copilot/providers/provider-runtime-contract';
import { CopilotProviderType } from '../../plugins/copilot/providers/types';
import {
buildStructuredResponseContract,
type RequiredStructuredOutputContract,
requireStructuredOutputContract,
} from '../../plugins/copilot/runtime/contracts';
import { getProviderRuntimeHost } from '../../plugins/copilot/runtime/provider-runtime-context';
import { nativeUserText, singleUserPromptMessages } from './prompt-test-helper';
function structuredOptions(schema: z.ZodTypeAny) {
const { responseSchemaJson, schemaHash } =
buildStructuredResponseContract(schema);
return { responseSchemaJson, schemaHash };
}
function structuredContract(
schema: z.ZodTypeAny
): RequiredStructuredOutputContract {
const contract = buildStructuredResponseContract(schema);
const requiredContract = requireStructuredOutputContract(contract);
if (!requiredContract) {
throw new Error('structured response contract is required');
}
return requiredContract;
}
class TemplateOnlyProvider extends CopilotProvider<{ apiKey: string }> {
readonly type = CopilotProviderType.OpenAI;
protected resolveModelBackendKind() {
return 'openai_responses' as const;
}
readonly structuredRequests: LlmStructuredRequest[] = [];
readonly embeddingRequests: LlmEmbeddingRequest[] = [];
readonly rerankRequests: Array<{
model: string;
query: string;
candidates: Array<{ id?: string; text: string }>;
topN?: number;
}> = [];
configured() {
return true;
}
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: () => ({
base_url: 'https://api.openai.com',
auth_token: 'test-key',
}),
mapError: (error: unknown) => error,
structured: {},
embedding: {
defaultDimensions: 8,
},
rerank: {},
};
}
}
test('template-only provider should reuse base structured, embedding and rerank drivers', async t => {
const provider = new TemplateOnlyProvider();
const originalStructured = (serverNativeModule as any).llmStructuredDispatch;
const originalEmbedding = (serverNativeModule as any).llmEmbeddingDispatch;
const originalRerank = (serverNativeModule as any).llmRerankDispatch;
(serverNativeModule as any).llmStructuredDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
provider.structuredRequests.push(
JSON.parse(requestJson) as LlmStructuredRequest
);
return JSON.stringify({
id: 'structured_1',
model: 'gpt-5-mini',
output_text: '{"summary":"native"}',
output_json: { summary: 'native' },
usage: {
prompt_tokens: 3,
completion_tokens: 2,
total_tokens: 5,
},
finish_reason: 'stop',
});
};
(serverNativeModule as any).llmEmbeddingDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
const request = JSON.parse(requestJson) as LlmEmbeddingRequest;
provider.embeddingRequests.push(request);
return JSON.stringify({
model: request.model,
embeddings: request.inputs.map((_, index) => [index + 0.1, index + 0.2]),
});
};
(serverNativeModule as any).llmRerankDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
const request = JSON.parse(requestJson) as LlmRerankRequest;
provider.rerankRequests.push(request);
return JSON.stringify({
model: request.model,
scores: request.candidates.map((_candidate, index) =>
index === 0 ? 0.9 : 0.1
),
});
};
t.teardown(() => {
(serverNativeModule as any).llmStructuredDispatch = originalStructured;
(serverNativeModule as any).llmEmbeddingDispatch = originalEmbedding;
(serverNativeModule as any).llmRerankDispatch = originalRerank;
});
const structured = await getProviderRuntimeHost(provider).run.structured(
{ modelId: 'gpt-5-mini' },
singleUserPromptMessages('summarize this'),
structuredOptions(z.object({ summary: z.string() })),
structuredContract(z.object({ summary: z.string() }))
);
const embeddings = await getProviderRuntimeHost(provider).run.embedding(
{ modelId: 'text-embedding-3-small' },
['alpha', 'beta'],
{
dimensions: 8,
}
);
const scores = await getProviderRuntimeHost(provider).run.rerank(
{ modelId: 'gpt-4o-mini' },
{
query: 'alpha',
candidates: [
{ id: 'alpha', text: 'alpha result' },
{ id: 'beta', text: 'beta result' },
],
topK: 1,
}
);
t.is(structured, JSON.stringify({ summary: 'native' }));
t.deepEqual(embeddings, [
[0.1, 0.2],
[1.1, 1.2],
]);
t.deepEqual(scores, [0.9, 0.1]);
t.is(provider.structuredRequests.length, 1);
t.like(provider.structuredRequests[0], {
model: 'gpt-5-mini',
messages: [
{ role: 'user', content: nativeUserText('summarize this').content },
],
schema: {
type: 'object',
properties: {
summary: { type: 'string' },
},
required: ['summary'],
additionalProperties: false,
},
strict: true,
responseMimeType: 'application/json',
});
t.is(provider.structuredRequests[0]?.middleware, undefined);
t.deepEqual(provider.embeddingRequests, [
{
model: 'text-embedding-3-small',
inputs: ['alpha', 'beta'],
dimensions: 8,
taskType: 'RETRIEVAL_DOCUMENT',
},
]);
t.deepEqual(provider.rerankRequests, [
{
model: 'gpt-4o-mini',
query: 'alpha',
candidates: [
{ id: 'alpha', text: 'alpha result' },
{ id: 'beta', text: 'beta result' },
],
topN: 1,
},
]);
});
@@ -1,15 +1,26 @@
import serverNativeModule from '@affine/server-native';
import test from 'ava';
import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { Models } from '../../models';
import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native';
import {
ToolCallAccumulator,
ToolCallLoop,
ToolSchemaExtractor,
} from '../../plugins/copilot/providers/loop';
LlmRequest,
type LlmToolCallbackRequest,
type LlmToolCallbackResponse,
type LlmToolLoopStreamEvent,
llmValidateContract,
} from '../../native';
import {
buildToolContracts,
parseToolContract,
parseToolLoopStreamEvent,
} from '../../plugins/copilot/runtime/contracts';
import {
createToolExecutionCallback,
createToolLoopBridge,
} from '../../plugins/copilot/runtime/tool/bridge';
import {
buildBlobContentGetter,
createBlobReadTool,
@@ -30,100 +41,47 @@ import {
DOCUMENT_SYNC_PENDING_MESSAGE,
LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
} from '../../plugins/copilot/tools/doc-sync';
import { defineTool } from '../../plugins/copilot/tools/tool';
import {
nativeMessages,
nativeUserText,
singleUserPromptMessages,
} from './prompt-test-helper';
test('ToolCallAccumulator should merge deltas and complete tool call', t => {
const accumulator = new ToolCallAccumulator();
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":"',
});
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
arguments_delta: 'a1"}',
test('defineTool should freeze json schema at definition time', t => {
const tool = defineTool({
description: 'Read doc',
inputSchema: z.object({
doc_id: z.string(),
limit: z.number().optional(),
}),
execute: async () => ({}),
});
const completed = accumulator.complete({
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
});
t.deepEqual(completed, {
id: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
rawArgumentsText: '{"doc_id":"a1"}',
thought: undefined,
t.deepEqual(tool.jsonSchema, {
type: 'object',
properties: {
doc_id: { type: 'string' },
limit: { type: 'number' },
},
additionalProperties: false,
required: ['doc_id'],
});
});
test('ToolCallAccumulator should preserve invalid JSON instead of swallowing it', t => {
const accumulator = new ToolCallAccumulator();
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":',
});
const pending = accumulator.drainPending();
t.is(pending.length, 1);
t.deepEqual(pending[0]?.id, 'call_1');
t.deepEqual(pending[0]?.name, 'doc_read');
t.deepEqual(pending[0]?.args, {});
t.is(pending[0]?.rawArgumentsText, '{"doc_id":');
t.truthy(pending[0]?.argumentParseError);
});
test('ToolCallAccumulator should prefer native canonical tool arguments metadata', t => {
const accumulator = new ToolCallAccumulator();
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"stale":true}',
});
const completed = accumulator.complete({
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: {},
arguments_text: '{"doc_id":"a1"}',
arguments_error: 'invalid json',
});
t.deepEqual(completed, {
id: 'call_1',
name: 'doc_read',
args: {},
rawArgumentsText: '{"doc_id":"a1"}',
argumentParseError: 'invalid json',
thought: undefined,
});
});
test('ToolSchemaExtractor should convert zod schema to json schema', t => {
test('buildToolContracts should project precomputed json schema', t => {
const toolSet = {
doc_read: {
doc_read: defineTool({
description: 'Read doc',
inputSchema: z.object({
doc_id: z.string(),
limit: z.number().optional(),
}),
execute: async () => ({}),
},
}),
};
const extracted = ToolSchemaExtractor.extract(toolSet);
const extracted = buildToolContracts(toolSet);
t.deepEqual(extracted, [
{
@@ -142,43 +100,224 @@ test('ToolSchemaExtractor should convert zod schema to json schema', t => {
]);
});
test('ToolCallLoop should execute tool call and continue to next round', async t => {
const dispatchRequests: NativeLlmRequest[] = [];
const originalMessages = [{ role: 'user', content: 'read doc' }] as const;
test('buildToolContracts should reject tool definitions without json schema', t => {
const error = t.throws(() =>
buildToolContracts({
doc_read: {
description: 'Read doc',
inputSchema: z.object({ doc_id: z.string() }),
execute: async () => ({}),
} as never,
})
);
t.regex(error.message, /missing precomputed jsonSchema/);
});
test('defineTool should prefer explicit json schema when provided', t => {
const extracted = buildToolContracts({
doc_read: defineTool({
description: 'Read doc',
jsonSchema: {
type: 'object',
properties: {
doc_id: { type: 'string' },
},
required: ['doc_id'],
},
inputSchema: z.object({
doc_id: z.string(),
ignored: z.number(),
}),
execute: async () => ({}),
}),
});
t.deepEqual(extracted, [
{
name: 'doc_read',
description: 'Read doc',
parameters: {
type: 'object',
properties: {
doc_id: { type: 'string' },
},
required: ['doc_id'],
},
},
]);
});
test('ToolContract should freeze stable tool schema and callback payloads', t => {
const tool = parseToolContract({
name: 'doc_read',
description: 'Read doc',
parameters: {
type: 'object',
properties: {
doc_id: { type: 'string' },
},
required: ['doc_id'],
},
});
const result = llmValidateContract<LlmToolCallbackResponse>(
'toolCallbackResponse',
{
callId: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
output: { markdown: '# a1' },
}
);
const request = llmValidateContract<LlmToolCallbackRequest>(
'toolCallbackRequest',
{
callId: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
}
);
t.is(tool.name, 'doc_read');
t.deepEqual(request.args, { doc_id: 'a1' });
t.deepEqual(result.args, { doc_id: 'a1' });
});
test('ToolLoopStreamEvent should reject malformed tool_result metadata at decode boundary', t => {
const event = parseToolLoopStreamEvent({
type: 'tool_result',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
output: { markdown: '# a1' },
});
t.is(event.type, 'tool_result');
const error = t.throws(() =>
parseToolLoopStreamEvent({
type: 'tool_result',
call_id: 'call_1',
output: { markdown: '# a1' },
})
);
t.truthy(error);
});
test('createNativeToolExecutionCallback should preserve tool execution ABI', async t => {
const callback = createToolExecutionCallback(
{
doc_read: {
inputSchema: z.object({ doc_id: z.string() }),
execute: async args => ({ markdown: `# ${String(args.doc_id)}` }),
},
},
{ messages: singleUserPromptMessages('read doc') }
);
const result = await callback({
callId: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
rawArgumentsText: '{"doc_id":"a1"}',
});
t.deepEqual(result, {
callId: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
rawArgumentsText: '{"doc_id":"a1"}',
argumentParseError: undefined,
output: { markdown: '# a1' },
});
});
test('createNativeToolLoopBridge should preserve native callback and stream ABI', async t => {
const capturedRequests: LlmRequest[] = [];
const originalMessages = singleUserPromptMessages('read doc');
const signal = new AbortController().signal;
let executedArgs: Record<string, unknown> | null = null;
let executedMessages: unknown;
let executedSignal: AbortSignal | undefined;
const dispatch = (request: NativeLlmRequest) => {
dispatchRequests.push(request);
const round = dispatchRequests.length;
const original = (serverNativeModule as any).llmDispatchToolLoopStream;
(serverNativeModule as any).llmDispatchToolLoopStream = (
_protocol: string,
_backendConfigJson: string,
requestJson: string,
maxSteps: number,
callback: (error: Error | null, eventJson: string) => void,
toolCallback: (error: Error | null, requestJson: string) => Promise<string>
) => {
capturedRequests.push(JSON.parse(requestJson) as LlmRequest);
t.is(maxSteps, 4);
return (async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
if (round === 1) {
yield {
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":"a1"}',
};
yield {
void (async () => {
callback(
null,
JSON.stringify({
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
};
yield { type: 'done', finish_reason: 'tool_calls' };
return;
}
})
);
yield { type: 'text_delta', text: 'done' };
yield { type: 'done', finish_reason: 'stop' };
const result = JSON.parse(
await toolCallback(
null,
JSON.stringify({
callId: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
rawArgumentsText: '{"doc_id":"a1"}',
})
)
) as {
callId: string;
name: string;
args: Record<string, unknown>;
rawArgumentsText?: string;
argumentParseError?: string;
output: unknown;
isError?: boolean;
};
callback(
null,
JSON.stringify({
type: 'tool_result',
call_id: result.callId,
name: result.name,
arguments: result.args,
arguments_text: result.rawArgumentsText,
arguments_error: result.argumentParseError,
output: result.output,
is_error: result.isError,
})
);
callback(null, JSON.stringify({ type: 'text_delta', text: 'done' }));
callback(null, JSON.stringify({ type: 'done', finish_reason: 'stop' }));
callback(null, '__AFFINE_LLM_STREAM_END__');
})();
};
let executedArgs: Record<string, unknown> | null = null;
let executedMessages: unknown;
let executedSignal: AbortSignal | undefined;
const loop = new ToolCallLoop(
dispatch,
return {
abort() {},
};
};
t.teardown(() => {
(serverNativeModule as any).llmDispatchToolLoopStream = original;
});
const bridge = createToolLoopBridge(
{
protocol: 'openai_chat',
backendConfig: {
base_url: 'https://api.openai.com',
auth_token: 'test-key',
},
},
{
doc_read: {
inputSchema: z.object({ doc_id: z.string() }),
@@ -193,14 +332,12 @@ test('ToolCallLoop should execute tool call and continue to next round', async t
4
);
const events: NativeLlmStreamEvent[] = [];
for await (const event of loop.run(
const events: LlmToolLoopStreamEvent[] = [];
for await (const event of bridge(
{
model: 'gpt-5-mini',
stream: true,
messages: [
{ role: 'user', content: [{ type: 'text', text: 'read doc' }] },
],
stream: false,
messages: nativeMessages(nativeUserText('read doc')),
},
signal,
[...originalMessages]
@@ -211,105 +348,13 @@ test('ToolCallLoop should execute tool call and continue to next round', async t
t.deepEqual(executedArgs, { doc_id: 'a1' });
t.deepEqual(executedMessages, originalMessages);
t.is(executedSignal, signal);
t.true(
dispatchRequests[1]?.messages.some(message => message.role === 'tool')
);
t.deepEqual(dispatchRequests[1]?.messages[1]?.content, [
{
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
arguments_text: '{"doc_id":"a1"}',
arguments_error: undefined,
thought: undefined,
},
]);
t.deepEqual(dispatchRequests[1]?.messages[2]?.content, [
{
type: 'tool_result',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
arguments_text: '{"doc_id":"a1"}',
arguments_error: undefined,
output: { markdown: '# doc' },
is_error: undefined,
},
]);
t.true(capturedRequests[0]?.stream);
t.deepEqual(
events.map(event => event.type),
['tool_call', 'tool_result', 'text_delta', 'done']
);
});
test('ToolCallLoop should surface invalid JSON as tool error without executing', async t => {
let executed = false;
let round = 0;
const loop = new ToolCallLoop(
request => {
round += 1;
const hasToolResult = request.messages.some(
message => message.role === 'tool'
);
return (async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
if (!hasToolResult && round === 1) {
yield {
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":',
};
yield { type: 'done', finish_reason: 'tool_calls' };
return;
}
yield { type: 'done', finish_reason: 'stop' };
})();
},
{
doc_read: {
inputSchema: z.object({ doc_id: z.string() }),
execute: async () => {
executed = true;
return { markdown: '# doc' };
},
},
},
2
);
const events: NativeLlmStreamEvent[] = [];
for await (const event of loop.run({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'read doc' }] }],
})) {
events.push(event);
}
t.false(executed);
t.true(events[0]?.type === 'tool_result');
t.deepEqual(events[0], {
type: 'tool_result',
call_id: 'call_1',
name: 'doc_read',
arguments: {},
arguments_text: '{"doc_id":',
arguments_error:
events[0]?.type === 'tool_result' ? events[0].arguments_error : undefined,
output: {
message: 'Invalid tool arguments JSON',
rawArguments: '{"doc_id":',
error:
events[0]?.type === 'tool_result'
? events[0].arguments_error
: undefined,
},
is_error: true,
});
});
test('doc_read should return specific sync errors for unavailable docs', async t => {
const cases = [
{
@@ -434,7 +479,7 @@ test('document search tools should return sync error for local workspace', async
);
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
buildDocSearchGetter(ac, contextService, undefined, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
@@ -478,7 +523,7 @@ test('doc_semantic_search should return empty array when nothing matches', async
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
buildDocSearchGetter(ac, contextService, undefined, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,16 @@
import { randomBytes } from 'node:crypto';
import serverNativeModule from '@affine/server-native';
import type { ProviderMiddlewareConfig } from '../../plugins/copilot/config';
import {
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
type CopilotProviderModel,
CopilotProviderType,
CopilotStructuredOptions,
ModelConditions,
ModelInputType,
ModelFullConditions,
ModelOutputType,
PromptMessage,
StreamObject,
@@ -15,130 +19,534 @@ import {
DEFAULT_DIMENSIONS,
OpenAIProvider,
} from '../../plugins/copilot/providers/openai';
import type { ProviderModelRuntimeContext } from '../../plugins/copilot/providers/provider-model-runtime';
import {
type CopilotProviderExecution,
createNativeExecutionDriverSpec,
type ProviderDriverSpec,
} from '../../plugins/copilot/providers/provider-runtime-contract';
import type { ProviderRuntimeContexts } from '../../plugins/copilot/runtime/provider-runtime-context';
import { sleep } from '../utils/utils';
export class MockCopilotProvider extends OpenAIProvider {
override readonly models = [
{
id: 'test',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text, ModelOutputType.Object],
defaultForOutputType: true,
},
],
},
{
id: 'test-image',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Image],
defaultForOutputType: true,
},
],
},
{
id: 'gpt-5',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
id: 'gpt-5-2025-08-07',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
},
],
},
{
id: 'gpt-5-mini',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
},
],
},
{
id: 'gpt-5-nano',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
},
],
},
{
id: 'gpt-image-1',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Image],
defaultForOutputType: true,
},
],
},
{
id: 'gemini-2.5-flash',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
},
],
},
{
id: 'gemini-2.5-pro',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
},
],
},
{
id: 'gemini-3.1-pro-preview',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
},
],
},
];
const LLM_STREAM_END_MARKER = '__AFFINE_LLM_STREAM_END__';
const MOCK_NATIVE_TEXT = 'generate text to text';
const MOCK_NATIVE_STREAM_TEXT = 'generate text to text stream';
override async text(
function mockUsage() {
return {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
};
}
function buildMockDispatchResponse(model: string, text: string) {
return {
id: 'mock-dispatch',
model,
message: {
role: 'assistant',
content: [{ type: 'text', text }],
},
usage: mockUsage(),
finish_reason: 'stop',
};
}
function buildMockStructuredValue(schema: any, key?: string): any {
if (!schema || typeof schema !== 'object') {
return key === 'title' ? 'Weekly Sync' : MOCK_NATIVE_TEXT;
}
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
return buildMockStructuredValue(schema.anyOf[0], key);
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
return buildMockStructuredValue(schema.oneOf[0], key);
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return schema.enum[0];
}
switch (schema.type) {
case 'object': {
const properties =
schema.properties && typeof schema.properties === 'object'
? schema.properties
: {};
return Object.fromEntries(
Object.entries(properties).map(([key, value]) => [
key,
buildMockStructuredValue(value, key),
])
);
}
case 'array':
return [buildMockStructuredValue(schema.items, key)];
case 'boolean':
return true;
case 'number':
case 'integer':
switch (key) {
case 'durationMinutes':
return 45;
case 's':
return 30;
case 'e':
return 53;
default:
return 1;
}
case 'null':
return null;
case 'string':
default:
switch (key) {
case 'title':
return 'Weekly Sync';
case 'description':
return 'Send recap';
case 'owner':
return 'A';
case 'deadline':
return 'Friday';
case 'speaker':
case 'a':
return 'A';
case 'attendees':
return 'A';
case 'start':
return '00:00:42';
case 'end':
return '00:01:05';
case 'text':
case 'transcription':
case 't':
return 'Hello, everyone.';
case 'keyPoints':
return 'Reviewed launch status';
case 'decisions':
return 'Ship on Monday';
case 'openQuestions':
return 'Need final QA sign-off';
case 'blockers':
return 'Waiting on analytics';
case 'summary':
return 'Reviewed launch status';
default:
return MOCK_NATIVE_TEXT;
}
}
}
function parseFirstRoute(routesJson: string) {
const routes = JSON.parse(routesJson) as Array<{
provider_id?: string;
model?: string;
request?: {
model?: string;
operation?: string;
prompt?: string;
schema?: unknown;
};
}>;
return routes[0];
}
function buildMockStructuredResponse(model: string, schema: unknown) {
const output_json = buildMockStructuredValue(schema);
return {
id: 'mock-structured-dispatch',
model,
output_text: JSON.stringify(output_json),
output_json,
usage: mockUsage(),
finish_reason: 'stop',
};
}
function emitMockTextStream(
model: string,
callback: (error: Error | null, eventJson: string) => void
) {
callback(null, JSON.stringify({ type: 'message_start', model }));
for (const text of MOCK_NATIVE_STREAM_TEXT) {
callback(null, JSON.stringify({ type: 'text_delta', text }));
}
callback(
null,
JSON.stringify({
type: 'done',
finish_reason: 'stop',
usage: mockUsage(),
})
);
callback(null, LLM_STREAM_END_MARKER);
}
export function installMockCopilotRuntime() {
const native = serverNativeModule as Record<string, any>;
const original = {
llmDispatchPrepared: native.llmDispatchPrepared,
llmDispatchPreparedStream: native.llmDispatchPreparedStream,
llmRenderBuiltInPrompt: native.llmRenderBuiltInPrompt,
llmRenderBuiltInSessionPrompt: native.llmRenderBuiltInSessionPrompt,
llmValidateJsonSchema: native.llmValidateJsonSchema,
llmStructuredDispatch: native.llmStructuredDispatch,
llmStructuredDispatchPrepared: native.llmStructuredDispatchPrepared,
llmEmbeddingDispatch: native.llmEmbeddingDispatch,
llmEmbeddingDispatchPrepared: native.llmEmbeddingDispatchPrepared,
llmRerankDispatch: native.llmRerankDispatch,
llmRerankDispatchPrepared: native.llmRerankDispatchPrepared,
llmImageDispatchPrepared: native.llmImageDispatchPrepared,
runNativeActionRecipePreparedStream:
native.runNativeActionRecipePreparedStream,
};
native.llmDispatchPrepared = (routesJson: string) => {
const route = parseFirstRoute(routesJson);
return JSON.stringify({
provider_id: route?.provider_id ?? 'mock-provider',
response: buildMockDispatchResponse(
route?.request?.model ?? route?.model ?? 'test',
MOCK_NATIVE_TEXT
),
});
};
native.llmDispatchPreparedStream = (
routesJson: string,
callback: (error: Error | null, eventJson: string) => void
) => {
const route = parseFirstRoute(routesJson);
emitMockTextStream(
route?.request?.model ?? route?.model ?? 'test',
callback
);
return { abort() {} };
};
native.llmStructuredDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
const request = JSON.parse(requestJson) as {
model?: string;
schema?: unknown;
};
return JSON.stringify(
buildMockStructuredResponse(request.model ?? 'test', request.schema)
);
};
native.llmStructuredDispatchPrepared = (routesJson: string) => {
const route = parseFirstRoute(routesJson);
return JSON.stringify({
provider_id: route?.provider_id ?? 'mock-provider',
response: buildMockStructuredResponse(
route?.request?.model ?? route?.model ?? 'test',
route?.request?.schema
),
});
};
native.llmValidateJsonSchema = (_schema: unknown, value: unknown) => value;
native.llmEmbeddingDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
const request = JSON.parse(requestJson) as {
model?: string;
dimensions?: number;
};
const length = request.dimensions ?? DEFAULT_DIMENSIONS;
return JSON.stringify({
model: request.model ?? 'test',
embeddings: [
Array.from({ length }, (_value, index) => (index % 128) + 1),
],
usage: { prompt_tokens: 1, total_tokens: 1 },
});
};
native.llmEmbeddingDispatchPrepared = (routesJson: string) => {
const route = parseFirstRoute(routesJson);
const response = JSON.parse(
native.llmEmbeddingDispatch(
'',
'',
JSON.stringify(route?.request ?? { model: route?.model ?? 'test' })
)
) as Record<string, unknown>;
return JSON.stringify({
provider_id: route?.provider_id ?? 'mock-provider',
response,
});
};
native.llmRerankDispatch = (
_protocol: string,
_backendConfigJson: string,
requestJson: string
) => {
const request = JSON.parse(requestJson) as {
model?: string;
candidates?: unknown[];
};
const candidateCount = request.candidates?.length ?? 0;
return JSON.stringify({
model: request.model ?? 'test',
scores: Array.from(
{ length: candidateCount },
(_value, index) => candidateCount - index
),
});
};
native.llmRerankDispatchPrepared = (routesJson: string) => {
const route = parseFirstRoute(routesJson);
const response = JSON.parse(
native.llmRerankDispatch(
'',
'',
JSON.stringify(route?.request ?? { model: route?.model ?? 'test' })
)
) as Record<string, unknown>;
return JSON.stringify({
provider_id: route?.provider_id ?? 'mock-provider',
response,
});
};
native.llmImageDispatchPrepared = (routesJson: string) => {
const route = parseFirstRoute(routesJson);
const model = route?.request?.model ?? route?.model ?? 'test-image';
const images = [
{
url: `https://example.com/${model}.jpg`,
media_type: 'image/jpeg',
},
];
if (route?.request?.operation === 'edit' && route.request.prompt) {
images.push({
url: `https://example.com/generated/${encodeURIComponent(route.request.prompt)}.jpg`,
media_type: 'image/jpeg',
});
}
return JSON.stringify({
provider_id: route?.provider_id ?? 'mock-provider',
response: {
images,
},
});
};
native.runNativeActionRecipePreparedStream = (
input: {
recipeId: string;
recipeVersion?: string;
input?: Record<string, any>;
},
callback: (error: Error | null, eventJson: string) => void
) => {
const version = input.recipeVersion ?? 'v1';
const result = input.recipeId.startsWith('image.filter.')
? {
url: `https://example.com/${input.recipeId}.jpg`,
}
: MOCK_NATIVE_STREAM_TEXT;
const attachmentEvent = input.recipeId.startsWith('image.filter.')
? [
{
type: 'attachment',
actionId: input.recipeId,
actionVersion: version,
status: 'running',
attachment: result,
},
]
: [];
const events = [
{
type: 'action_start',
actionId: input.recipeId,
actionVersion: version,
status: 'running',
},
{
type: 'step_start',
actionId: input.recipeId,
actionVersion: version,
stepId: 'generate',
status: 'running',
},
...attachmentEvent,
{
type: 'step_end',
actionId: input.recipeId,
actionVersion: version,
stepId: 'generate',
status: 'running',
},
{
type: 'action_done',
actionId: input.recipeId,
actionVersion: version,
status: 'succeeded',
result,
trace: {
actionId: input.recipeId,
actionVersion: version,
status: 'succeeded',
lightweight: [
{ type: 'action_start', status: 'running' },
{ type: 'action_trace', status: 'succeeded' },
],
},
},
];
for (const event of events) {
callback(null, JSON.stringify(event));
}
callback(null, LLM_STREAM_END_MARKER);
return { abort() {} };
};
return () => {
Object.assign(native, original);
};
}
export class MockCopilotProvider extends OpenAIProvider {
private runtimeHostOverride?: ProviderRuntimeContexts;
protected override resolveModelRuntimeContext(): ProviderModelRuntimeContext {
const providerType = this.type as CopilotProviderType;
return {
type: providerType,
backendKind:
providerType === CopilotProviderType.Gemini
? 'gemini_api'
: 'openai_responses',
};
}
override getDriverSpec(): ProviderDriverSpec {
const spec = super.getDriverSpec();
return {
...spec,
image: {
prepareMessages: async messages => messages,
},
};
}
private resolveMockModelId(
cond: Pick<ModelFullConditions, 'modelId' | 'outputType'>
) {
if (cond.modelId === 'test') {
return 'gpt-5-mini';
}
if (cond.modelId === 'test-image') {
return 'gpt-image-1';
}
return cond.modelId;
}
private normalizeMockConditions(
cond: ModelFullConditions
): ModelFullConditions {
const modelId = this.resolveMockModelId(cond);
return modelId === cond.modelId ? cond : { ...cond, modelId };
}
protected override createDriverSpec(spec: ProviderDriverSpec) {
return createNativeExecutionDriverSpec(spec, {
createBackendConfig: spec.createBackendConfig,
mapError: spec.mapError,
checkParams: input => this.checkParams(input),
selectModel: (cond, execution) => this.selectModel(cond, execution),
getTools: this.getTools.bind(this),
getActiveProviderMiddleware: this.getActiveProviderMiddleware.bind(this),
});
}
override async match(
cond: ModelFullConditions = {},
execution?: CopilotProviderExecution
) {
return await super.match(this.normalizeMockConditions(cond), execution);
}
override resolveModel(
modelId: string,
execution?: CopilotProviderExecution
): CopilotProviderModel | undefined {
const resolvedModelId = this.resolveMockModelId({ modelId });
return resolvedModelId
? super.resolveModel(resolvedModelId, execution)
: undefined;
}
override selectModel(
cond: ModelFullConditions,
execution?: CopilotProviderExecution
): CopilotProviderModel {
return super.selectModel(this.normalizeMockConditions(cond), execution);
}
override checkParams(input: Parameters<OpenAIProvider['checkParams']>[0]) {
return super.checkParams({
...input,
cond: this.normalizeMockConditions(input.cond),
});
}
override getActiveProviderMiddleware(): ProviderMiddlewareConfig {
return {};
}
overrideRuntimeHost(runtimeHost: ProviderRuntimeContexts) {
if (!this.runtimeHostOverride) {
const runtimeHostOverride: ProviderRuntimeContexts = {
...runtimeHost,
run: {
...runtimeHost.run,
text: this.text.bind(this),
streamText: this.streamTextRuntime.bind(this),
streamObject: this.streamObjectRuntime.bind(this),
structured: this.structure.bind(this),
embedding: this.embedding.bind(this),
},
};
this.runtimeHostOverride = runtimeHostOverride;
}
return this.runtimeHostOverride;
}
private async *streamTextRuntime(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions
): AsyncIterableIterator<string> {
yield* this.streamText(cond, messages, options);
}
private async *streamObjectRuntime(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions
): AsyncIterableIterator<StreamObject> {
yield* this.streamObject(cond, messages, options);
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
@@ -147,19 +555,27 @@ export class MockCopilotProvider extends OpenAIProvider {
...cond,
outputType: ModelOutputType.Text,
};
await this.checkParams({ messages, cond: fullCond, options });
await this.checkParams({
messages,
cond: fullCond,
options,
});
// make some time gap for history test case
await sleep(100);
return 'generate text to text';
}
override async *streamText(
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
await this.checkParams({ messages, cond: fullCond, options });
await this.checkParams({
messages,
cond: fullCond,
options,
});
// make some time gap for history test case
await sleep(100);
@@ -173,70 +589,58 @@ export class MockCopilotProvider extends OpenAIProvider {
}
}
override async structure(
async structure(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Structured };
await this.checkParams({ messages, cond: fullCond, options });
await this.checkParams({
messages,
cond: fullCond,
options,
});
// make some time gap for history test case
await sleep(100);
return 'generate text to text';
}
override async *streamImages(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotImageOptions = {}
) {
const fullCond = { ...cond, outputType: ModelOutputType.Image };
await this.checkParams({ messages, cond: fullCond, options });
// make some time gap for history test case
await sleep(100);
const { content: prompt } = [...messages].pop() || {};
if (!prompt) throw new Error('Prompt is required');
const imageUrls = [
`https://example.com/${cond.modelId || 'test'}.jpg`,
prompt,
];
for (const imageUrl of imageUrls) {
yield imageUrl;
if (options.signal?.aborted) {
break;
}
}
return;
}
// ====== text to embedding ======
override async embedding(
async embedding(
cond: ModelConditions,
messages: string | string[],
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
messages = Array.isArray(messages) ? messages : [messages];
const fullCond = { ...cond, outputType: ModelOutputType.Embedding };
await this.checkParams({ embeddings: messages, cond: fullCond, options });
await this.checkParams({
embeddings: messages,
cond: fullCond,
options,
});
// make some time gap for history test case
await sleep(100);
return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)];
return [
Array.from(randomBytes(options.dimensions ?? DEFAULT_DIMENSIONS)).map(
v => v % 128
),
];
}
override async *streamObject(
async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
await this.checkParams({ messages, cond: fullCond, options });
await this.checkParams({
messages,
cond: fullCond,
options,
});
// make some time gap for history test case
await sleep(100);
@@ -1,11 +1,12 @@
export { createFactory } from './factory';
export * from './prompt-service.mock';
export * from './team-workspace.mock';
export * from './user.mock';
export * from './workspace.mock';
export * from './workspace-user.mock';
import { MockAccessToken } from './access-token.mock';
import { MockCopilotProvider } from './copilot.mock';
import { installMockCopilotRuntime, MockCopilotProvider } from './copilot.mock';
import { MockDocMeta } from './doc-meta.mock';
import { MockDocSnapshot } from './doc-snapshot.mock';
import { MockDocUser } from './doc-user.mock';
@@ -30,4 +31,10 @@ export const Mockers = {
AccessToken: MockAccessToken,
};
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
export {
installMockCopilotRuntime,
MockCopilotProvider,
MockEventBus,
MockJobQueue,
MockMailer,
};
@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { CopilotPromptInvalid } from '../../base';
import { llmGetBuiltInPromptSpec, llmRenderBuiltInPrompt } from '../../native';
import { PromptService } from '../../plugins/copilot/prompt';
import type { Prompt } from '../../plugins/copilot/prompt/spec';
import type {
PromptConfig,
PromptMessage,
} from '../../plugins/copilot/providers/types';
@Injectable()
export class TestingPromptService extends PromptService {
private readonly customPrompts = new Map<string, Prompt>();
private readonly builtInPromptOverrides = new Map<string, Prompt>();
reset() {
this.customPrompts.clear();
this.builtInPromptOverrides.clear();
}
async set(
name: string,
model: string,
messages: PromptMessage[],
config?: PromptConfig | null,
extraConfig?: { optionalModels: string[] }
) {
this.assertCustomPromptName(name);
const existing = this.customPrompts.get(name);
this.customPrompts.set(name, {
name,
model,
action: existing?.action,
optionalModels: existing?.optionalModels?.length
? [...existing.optionalModels, ...(extraConfig?.optionalModels ?? [])]
: extraConfig?.optionalModels,
config: config ? structuredClone(config) : undefined,
messages: this.cloneMessages(messages),
});
}
async overrideBuiltIn(
name: string,
data: {
messages?: PromptMessage[];
model?: string;
config?: PromptConfig | null;
}
) {
const current = this.loadBuiltInPrompt(name);
if (!current) {
throw new CopilotPromptInvalid(
`Built-in prompt ${name} not found in native catalog`
);
}
const { config, messages, model } = data;
const next = this.clonePrompt(current);
if (model !== undefined) {
next.model = model;
}
if (config === null) {
next.config = undefined;
} else if (config !== undefined) {
next.config = structuredClone(config);
}
if (messages) {
next.messages = this.cloneMessages(messages);
}
this.builtInPromptOverrides.set(name, next);
}
protected override lookupCompatPrompt(name: string) {
return (
this.builtInPromptOverrides.get(name) ??
this.customPrompts.get(name) ??
null
);
}
private assertCustomPromptName(name: string) {
if (this.loadBuiltInPrompt(name)) {
throw new CopilotPromptInvalid(
`Built-in prompt ${name} is owned by native catalog`
);
}
}
private loadBuiltInPrompt(name: string): Prompt | null {
const spec = llmGetBuiltInPromptSpec(name);
if (!spec) return null;
const prompt = llmRenderBuiltInPrompt({ name, renderParams: {} });
return {
name: spec.name,
action: spec.action,
model: spec.model,
optionalModels: spec.optionalModels,
config: spec.config,
messages: prompt.messages.map(message => ({
role: message.role,
content: message.content,
...(message.params ? { params: message.params } : {}),
})),
};
}
}
@@ -48,7 +48,9 @@ let docId = 'doc1';
test.beforeEach(async t => {
await t.context.module.initTestingDB();
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-5-mini');
await t.context.db.aiPrompt.create({
data: { name: 'prompt-name', model: 'gpt-5-mini', action: null },
});
user = await t.context.user.create({
email: 'test@affine.pro',
});
@@ -6,6 +6,7 @@ import ava, { ExecutionContext, TestFn } from 'ava';
import { CopilotPromptInvalid, CopilotSessionInvalidInput } from '../../base';
import {
CopilotSessionModel,
Models,
UpdateChatSessionOptions,
UserModel,
WorkspaceModel,
@@ -19,6 +20,7 @@ interface Context {
user: UserModel;
workspace: WorkspaceModel;
copilotSession: CopilotSessionModel;
models: Models;
}
const test = ava as TestFn<Context>;
@@ -28,6 +30,7 @@ test.before(async t => {
t.context.user = module.get(UserModel);
t.context.workspace = module.get(WorkspaceModel);
t.context.copilotSession = module.get(CopilotSessionModel);
t.context.models = module.get(Models);
t.context.db = module.get(PrismaClient);
t.context.module = module;
});
@@ -55,10 +58,12 @@ const TEST_PROMPTS = {
// Helper functions
const createTestPrompts = async (
copilotSession: CopilotSessionModel,
_copilotSession: CopilotSessionModel,
db: PrismaClient
) => {
await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-5-mini');
await db.aiPrompt.create({
data: { name: TEST_PROMPTS.NORMAL, model: 'gpt-5-mini', action: null },
});
await db.aiPrompt.create({
data: { name: TEST_PROMPTS.ACTION, model: 'gpt-5-mini', action: 'edit' },
});
@@ -1000,6 +1005,146 @@ test('should cleanup empty sessions correctly', async t => {
);
});
test('should append durable message and account durable costs', async t => {
const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db);
const { sessionId } = await createTestSession(t);
const appended = await copilotSession.appendMessage({
sessionId,
userId: user.id,
prompt: { model: 'gpt-5-mini' },
message: {
role: 'user',
content: 'hello durable world',
params: { foo: 'bar' },
createdAt: new Date(),
},
});
const afterAppend = await db.aiSession.findUniqueOrThrow({
where: { id: sessionId },
select: { messageCost: true, tokenCost: true },
});
t.truthy(appended.id);
t.is(afterAppend.messageCost, 1);
t.true(afterAppend.tokenCost > 0);
t.deepEqual(appended.params, { foo: 'bar' });
const appendedBare = await copilotSession.appendMessage({
sessionId,
userId: user.id,
prompt: { model: 'gpt-5-mini' },
message: {
role: 'assistant',
content: 'assistant reply',
createdAt: new Date(),
},
});
const storedBare = await db.aiSessionMessage.findUniqueOrThrow({
where: { id: appendedBare.id },
select: { params: true },
});
t.deepEqual(appendedBare.params, {});
t.deepEqual(storedBare.params, {});
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
await db.aiSession.update({
where: { id: sessionId },
data: { updatedAt: oneDayAgo },
});
const cleanup = await copilotSession.cleanupEmptySessions(oneDayAgo);
const persisted = await db.aiSession.findUnique({
where: { id: sessionId },
select: { deletedAt: true, messageCost: true },
});
t.deepEqual(cleanup, { removed: 0, cleaned: 0 });
t.truthy(persisted);
t.is(persisted?.deletedAt, null);
t.is(persisted?.messageCost, 1);
});
test('should count action runs without double-counting legacy action sessions', async t => {
const { copilotSession, db, models } = t.context;
await createTestPrompts(copilotSession, db);
const regular = await createTestSession(t);
await copilotSession.appendMessage({
sessionId: regular.sessionId,
userId: user.id,
prompt: { model: 'gpt-5-mini' },
message: {
role: 'user',
content: 'regular message',
createdAt: new Date(),
},
});
const legacyAction = await createTestSession(t, {
promptName: TEST_PROMPTS.ACTION,
promptAction: 'edit',
});
const migratedAction = await createTestSession(t, {
promptName: TEST_PROMPTS.ACTION,
promptAction: 'edit',
});
const run = await models.copilotActionRun.create({
userId: user.id,
workspaceId: workspace.id,
sessionId: migratedAction.sessionId,
actionId: 'mindmap.generate',
actionVersion: 'v1',
});
await models.copilotActionRun.complete(run.id, {
status: 'succeeded',
result: { ok: true },
trace: [{ type: 'action_done', status: 'succeeded' }],
});
const retryRun = await models.copilotActionRun.create({
userId: user.id,
workspaceId: workspace.id,
sessionId: migratedAction.sessionId,
actionId: 'mindmap.generate',
actionVersion: 'v1',
attempt: 2,
retryOf: run.id,
});
await models.copilotActionRun.complete(retryRun.id, {
status: 'aborted',
errorCode: 'action_aborted',
trace: [{ type: 'error', status: 'aborted' }],
});
const persistedRetry = await models.copilotActionRun.get(retryRun.id);
const transcriptTask = await models.copilotTranscriptTask.create({
userId: user.id,
workspaceId: workspace.id,
blobId: 'audio-1',
strategy: 'gemini',
recipeId: 'transcript.audio.gemini',
recipeVersion: 'v1',
});
await models.copilotTranscriptTask.complete(transcriptTask.id, {
status: 'ready',
protectedResult: { normalizedTranscript: '00:00:01 A: Hello' },
});
await models.copilotTranscriptTask.settle(transcriptTask.id);
t.like(persistedRetry, {
status: 'aborted',
attempt: 2,
retryOf: run.id,
errorCode: 'action_aborted',
trace: [{ type: 'error', status: 'aborted' }],
});
t.is(await copilotSession.countUserMessages(user.id), 4);
t.truthy(legacyAction.sessionId);
});
test('should get sessions for title generation correctly', async t => {
const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db);
@@ -1,82 +0,0 @@
import test from 'ava';
import { NativeStreamAdapter } from '../native';
test('NativeStreamAdapter should support buffered and awaited consumption', async t => {
const adapter = new NativeStreamAdapter<number>(undefined);
adapter.push(1);
const first = await adapter.next();
t.deepEqual(first, { value: 1, done: false });
const pending = adapter.next();
adapter.push(2);
const second = await pending;
t.deepEqual(second, { value: 2, done: false });
adapter.push(null);
const done = await adapter.next();
t.true(done.done);
});
test('NativeStreamAdapter return should abort handle and end iteration', async t => {
let abortCount = 0;
const adapter = new NativeStreamAdapter<number>({
abort: () => {
abortCount += 1;
},
});
const ended = await adapter.return();
t.is(abortCount, 1);
t.true(ended.done);
const secondReturn = await adapter.return();
t.true(secondReturn.done);
t.is(abortCount, 1);
const next = await adapter.next();
t.true(next.done);
});
test('NativeStreamAdapter should abort when AbortSignal is triggered', async t => {
let abortCount = 0;
const controller = new AbortController();
const adapter = new NativeStreamAdapter<number>(
{
abort: () => {
abortCount += 1;
},
},
controller.signal
);
const pending = adapter.next();
controller.abort();
const done = await pending;
t.true(done.done);
t.is(abortCount, 1);
});
test('NativeStreamAdapter should end immediately for pre-aborted signal', async t => {
let abortCount = 0;
const controller = new AbortController();
controller.abort();
const adapter = new NativeStreamAdapter<number>(
{
abort: () => {
abortCount += 1;
},
},
controller.signal
);
const next = await adapter.next();
t.true(next.done);
t.is(abortCount, 1);
adapter.push(1);
const stillDone = await adapter.next();
t.true(stillDone.done);
});
File diff suppressed because one or more lines are too long
@@ -1,5 +1,11 @@
import { randomUUID } from 'node:crypto';
import type {
GraphQLQuery,
QueryOptions,
QueryResponse,
} from '@affine/graphql';
import { transformToForm } from '@affine/graphql';
import { INestApplication, ModuleMetadata } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { TestingModuleBuilder } from '@nestjs/testing';
@@ -188,21 +194,59 @@ export class TestingApp extends ApplyType<INestApplication>() {
// TODO(@forehalo): directly make proxy for graphql queries defined in `@affine/graphql`
// by calling with `app.apis.createWorkspace({ ...variables })`
async gql<Data = any>(query: string, variables?: any): Promise<Data> {
const res = await this.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query,
async gql<Data = any>(query: string, variables?: any): Promise<Data>;
async gql<Query extends GraphQLQuery>(
options: QueryOptions<Query>
): Promise<QueryResponse<Query>>;
async gql<Data = any, Query extends GraphQLQuery = GraphQLQuery>(
queryOrOptions: string | QueryOptions<Query>,
variables?: any
): Promise<Data | QueryResponse<Query>> {
const req = this.POST('/graphql').set({ 'x-request-id': 'test' });
let res: supertest.Response;
if (typeof queryOrOptions === 'string') {
res = await req.set('x-operation-name', 'test').send({
query: queryOrOptions,
variables,
});
} else {
const operationName = queryOrOptions.query.op || 'test';
req.set('x-operation-name', operationName);
if (queryOrOptions.query.file) {
const form = transformToForm({
query: queryOrOptions.query.query,
variables: queryOrOptions.variables,
operationName,
});
for (const [key, value] of form.entries()) {
if (value instanceof File) {
req.attach(key, Buffer.from(await value.arrayBuffer()), {
filename: value.name || key,
contentType: value.type || 'application/octet-stream',
});
} else {
req.field(key, value);
}
}
res = await req;
} else {
res = await req.send({
query: queryOrOptions.query.query,
variables: queryOrOptions.variables,
});
}
}
if (res.status !== 200) {
throw new Error(
`Failed to execute gql: ${query}, status: ${res.status}, body: ${JSON.stringify(
res.body,
null,
2
)}`
`Failed to execute gql: ${
typeof queryOrOptions === 'string'
? queryOrOptions
: queryOrOptions.query.query
}, status: ${res.status}, body: ${JSON.stringify(res.body, null, 2)}`
);
}