mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
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:
@@ -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
|
||||
|
||||
Binary file not shown.
+703
@@ -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,
|
||||
],
|
||||
}
|
||||
BIN
Binary file not shown.
+505
@@ -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: [],
|
||||
},
|
||||
},
|
||||
]
|
||||
BIN
Binary file not shown.
@@ -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! I’m 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! I’m 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)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user