@operor/testing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API_VALIDATION.md +572 -0
- package/dist/index.d.ts +414 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1608 -0
- package/dist/index.js.map +1 -0
- package/fixtures/sample-tests.csv +10 -0
- package/package.json +31 -0
- package/src/CSVLoader.ts +83 -0
- package/src/ConversationEvaluator.ts +254 -0
- package/src/ConversationRunner.ts +267 -0
- package/src/CustomerSimulator.ts +106 -0
- package/src/MockShopifySkill.ts +336 -0
- package/src/SimulationRunner.ts +425 -0
- package/src/SkillTestHarness.ts +220 -0
- package/src/TestCaseEvaluator.ts +296 -0
- package/src/TestSuiteRunner.ts +151 -0
- package/src/__tests__/CSVLoader.test.ts +122 -0
- package/src/__tests__/ConversationEvaluator.test.ts +221 -0
- package/src/__tests__/ConversationRunner.test.ts +270 -0
- package/src/__tests__/CustomerSimulator.test.ts +160 -0
- package/src/__tests__/SimulationRunner.test.ts +281 -0
- package/src/__tests__/SkillTestHarness.test.ts +181 -0
- package/src/__tests__/scenarios.test.ts +71 -0
- package/src/index.ts +32 -0
- package/src/scenarios/edge-cases.ts +52 -0
- package/src/scenarios/general.ts +37 -0
- package/src/scenarios/index.ts +32 -0
- package/src/scenarios/order-tracking.ts +56 -0
- package/src/scenarios.ts +142 -0
- package/src/types.ts +133 -0
- package/src/utils.ts +6 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { Operor } from '@operor/core';
|
|
2
|
+
import type { MockProvider } from '@operor/provider-mock';
|
|
3
|
+
import type {
|
|
4
|
+
ConversationScenario,
|
|
5
|
+
ConversationTurn,
|
|
6
|
+
ConversationTestResult,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
import { CustomerSimulator } from './CustomerSimulator.js';
|
|
9
|
+
import { ConversationEvaluator } from './ConversationEvaluator.js';
|
|
10
|
+
import { formatTimestamp } from './utils.js';
|
|
11
|
+
|
|
12
|
+
export interface ConversationRunnerConfig {
|
|
13
|
+
agentOS: Operor;
|
|
14
|
+
customerSimulator: CustomerSimulator;
|
|
15
|
+
conversationEvaluator: ConversationEvaluator;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
verbose?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ConversationRunner {
|
|
21
|
+
private agentOS: Operor;
|
|
22
|
+
private customerSimulator: CustomerSimulator;
|
|
23
|
+
private conversationEvaluator: ConversationEvaluator;
|
|
24
|
+
private timeout: number;
|
|
25
|
+
private verbose: boolean;
|
|
26
|
+
|
|
27
|
+
constructor(config: ConversationRunnerConfig) {
|
|
28
|
+
this.agentOS = config.agentOS;
|
|
29
|
+
this.customerSimulator = config.customerSimulator;
|
|
30
|
+
this.conversationEvaluator = config.conversationEvaluator;
|
|
31
|
+
this.timeout = config.timeout || 30000;
|
|
32
|
+
this.verbose = config.verbose ?? false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async runScenario(scenario: ConversationScenario): Promise<ConversationTestResult> {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
const turns: ConversationTurn[] = [];
|
|
38
|
+
const toolsCalled: Array<{ name: string; params: any; result: any }> = [];
|
|
39
|
+
let totalCost = 0;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Get MockProvider
|
|
43
|
+
const mockProvider = this.getMockProvider();
|
|
44
|
+
const customerId = `test-customer-${scenario.id}`;
|
|
45
|
+
|
|
46
|
+
// Start conversation with initial message (from scenario or first scripted response)
|
|
47
|
+
const initialMessage = scenario.scriptedResponses?.[0] || 'Hello, I need help';
|
|
48
|
+
|
|
49
|
+
if (this.verbose) {
|
|
50
|
+
console.log(`\n=== Starting Scenario: ${scenario.name} ===`);
|
|
51
|
+
console.log(`Persona: ${scenario.persona}`);
|
|
52
|
+
console.log(`Max Turns: ${scenario.maxTurns}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let shouldContinue = true;
|
|
56
|
+
let currentTurn = 0;
|
|
57
|
+
|
|
58
|
+
while (shouldContinue && currentTurn < scenario.maxTurns) {
|
|
59
|
+
// Determine customer message
|
|
60
|
+
const customerMessage =
|
|
61
|
+
currentTurn === 0
|
|
62
|
+
? initialMessage
|
|
63
|
+
: (
|
|
64
|
+
await this.customerSimulator.generateMessage(scenario.persona, turns, {
|
|
65
|
+
scenario: scenario.description,
|
|
66
|
+
maxTurns: scenario.maxTurns,
|
|
67
|
+
currentTurn,
|
|
68
|
+
scriptedResponses: scenario.scriptedResponses,
|
|
69
|
+
})
|
|
70
|
+
).message;
|
|
71
|
+
|
|
72
|
+
if (this.verbose) {
|
|
73
|
+
console.log(`[${formatTimestamp()}] Turn ${currentTurn + 1} [customer]: ${customerMessage}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wait for agent response
|
|
77
|
+
const agentResponse = await this.waitForAgentResponse(
|
|
78
|
+
mockProvider,
|
|
79
|
+
customerId,
|
|
80
|
+
customerMessage
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (this.verbose) {
|
|
84
|
+
console.log(`[${formatTimestamp()}] Turn ${currentTurn + 1} [agent]: ${agentResponse.text}`);
|
|
85
|
+
if (agentResponse.toolCalls?.length) {
|
|
86
|
+
console.log(
|
|
87
|
+
` Tools called: ${agentResponse.toolCalls.map((tc) => tc.name).join(', ')}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Record turn
|
|
93
|
+
turns.push({
|
|
94
|
+
role: 'customer',
|
|
95
|
+
message: customerMessage,
|
|
96
|
+
});
|
|
97
|
+
turns.push({
|
|
98
|
+
role: 'agent',
|
|
99
|
+
message: agentResponse.text,
|
|
100
|
+
toolCalls: agentResponse.toolCalls,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Track tools and cost
|
|
104
|
+
if (agentResponse.toolCalls) {
|
|
105
|
+
toolsCalled.push(...agentResponse.toolCalls);
|
|
106
|
+
}
|
|
107
|
+
totalCost += agentResponse.cost || 0;
|
|
108
|
+
|
|
109
|
+
currentTurn++;
|
|
110
|
+
|
|
111
|
+
// Check if conversation should continue
|
|
112
|
+
if (scenario.scriptedResponses) {
|
|
113
|
+
// Script mode: continue until scripts exhausted
|
|
114
|
+
shouldContinue = currentTurn < scenario.scriptedResponses.length;
|
|
115
|
+
} else {
|
|
116
|
+
// LLM mode: ask CustomerSimulator
|
|
117
|
+
if (currentTurn < scenario.maxTurns) {
|
|
118
|
+
const nextResponse = await this.customerSimulator.generateMessage(
|
|
119
|
+
scenario.persona,
|
|
120
|
+
turns,
|
|
121
|
+
{
|
|
122
|
+
scenario: scenario.description,
|
|
123
|
+
maxTurns: scenario.maxTurns,
|
|
124
|
+
currentTurn,
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
shouldContinue = nextResponse.shouldContinue;
|
|
128
|
+
if (!shouldContinue && this.verbose) {
|
|
129
|
+
console.log('Customer satisfied, ending conversation.');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (this.verbose && currentTurn >= scenario.maxTurns) {
|
|
136
|
+
console.log(`\nReached max turns (${scenario.maxTurns})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Evaluate conversation
|
|
140
|
+
const evaluation = await this.conversationEvaluator.evaluate({
|
|
141
|
+
scenario: scenario.description,
|
|
142
|
+
persona: scenario.persona,
|
|
143
|
+
turns,
|
|
144
|
+
toolsCalled,
|
|
145
|
+
expectedTools: scenario.expectedTools,
|
|
146
|
+
expectedOutcome: scenario.expectedOutcome,
|
|
147
|
+
successCriteria: scenario.successCriteria,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (this.verbose) {
|
|
151
|
+
console.log(`\n=== Evaluation ===`);
|
|
152
|
+
console.log(`Overall: ${evaluation.overall}`);
|
|
153
|
+
console.log(`Scores: ${JSON.stringify(evaluation.scores)}`);
|
|
154
|
+
console.log(`Feedback: ${evaluation.feedback}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const duration = Date.now() - startTime;
|
|
158
|
+
const passed = evaluation.overall === 'pass';
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
scenario,
|
|
162
|
+
passed,
|
|
163
|
+
turns,
|
|
164
|
+
evaluation,
|
|
165
|
+
duration,
|
|
166
|
+
cost: totalCost,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const duration = Date.now() - startTime;
|
|
170
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
171
|
+
|
|
172
|
+
if (this.verbose) {
|
|
173
|
+
console.error(`\nError in scenario ${scenario.name}: ${errorMessage}\n`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
scenario,
|
|
178
|
+
passed: false,
|
|
179
|
+
turns,
|
|
180
|
+
evaluation: {
|
|
181
|
+
overall: 'fail',
|
|
182
|
+
scores: { accuracy: 1, toolUsage: 1, tone: 1, resolution: 1 },
|
|
183
|
+
feedback: `Error: ${errorMessage}`,
|
|
184
|
+
},
|
|
185
|
+
duration,
|
|
186
|
+
cost: totalCost,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async runScenarios(scenarios: ConversationScenario[]): Promise<ConversationTestResult[]> {
|
|
192
|
+
const results: ConversationTestResult[] = [];
|
|
193
|
+
|
|
194
|
+
for (const scenario of scenarios) {
|
|
195
|
+
const result = await this.runScenario(scenario);
|
|
196
|
+
results.push(result);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private getMockProvider(): MockProvider {
|
|
203
|
+
const mockProvider = Array.from((this.agentOS as any).providers.values()).find(
|
|
204
|
+
(p: any) => p.name === 'mock'
|
|
205
|
+
) as MockProvider | undefined;
|
|
206
|
+
|
|
207
|
+
if (!mockProvider) {
|
|
208
|
+
throw new Error('MockProvider not found in Operor. Add it with agentOS.addProvider()');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return mockProvider;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async waitForAgentResponse(
|
|
215
|
+
mockProvider: MockProvider,
|
|
216
|
+
customerId: string,
|
|
217
|
+
message: string
|
|
218
|
+
): Promise<{ text: string; toolCalls?: Array<{ name: string; params: any; result: any }>; cost: number }> {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let settled = false;
|
|
221
|
+
|
|
222
|
+
const cleanup = () => {
|
|
223
|
+
this.agentOS.removeListener('message:processed', onProcessed);
|
|
224
|
+
this.agentOS.removeListener('error', onError);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const timeoutId = setTimeout(() => {
|
|
228
|
+
if (!settled) {
|
|
229
|
+
settled = true;
|
|
230
|
+
cleanup();
|
|
231
|
+
reject(new Error(`Agent response timed out after ${this.timeout}ms`));
|
|
232
|
+
}
|
|
233
|
+
}, this.timeout);
|
|
234
|
+
|
|
235
|
+
const onProcessed = (event: any) => {
|
|
236
|
+
if (!settled) {
|
|
237
|
+
settled = true;
|
|
238
|
+
clearTimeout(timeoutId);
|
|
239
|
+
cleanup();
|
|
240
|
+
resolve({
|
|
241
|
+
text: event.response.text,
|
|
242
|
+
toolCalls: event.response.toolCalls || [],
|
|
243
|
+
cost: event.cost || 0,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const onError = (event: any) => {
|
|
249
|
+
if (!settled) {
|
|
250
|
+
settled = true;
|
|
251
|
+
clearTimeout(timeoutId);
|
|
252
|
+
cleanup();
|
|
253
|
+
reject(event.error);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Listen for the response
|
|
258
|
+
this.agentOS.once('message:processed', onProcessed);
|
|
259
|
+
|
|
260
|
+
// Listen for errors
|
|
261
|
+
this.agentOS.once('error', onError);
|
|
262
|
+
|
|
263
|
+
// Simulate incoming message
|
|
264
|
+
mockProvider.simulateIncomingMessage(customerId, message);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { LLMProvider, LLMMessage } from '@operor/llm';
|
|
2
|
+
import type { ConversationTurn, CustomerSimulatorResponse } from './types.js';
|
|
3
|
+
|
|
4
|
+
const PERSONA_PROMPTS: Record<string, string> = {
|
|
5
|
+
polite: 'You are polite, patient, and use courteous language.',
|
|
6
|
+
frustrated: 'You are frustrated and impatient. You express dissatisfaction but remain civil.',
|
|
7
|
+
confused: 'You are confused and unsure. You ask clarifying questions and sometimes misunderstand.',
|
|
8
|
+
terse: 'You give very short, minimal responses. One sentence max.',
|
|
9
|
+
verbose: 'You are detailed and talkative. You provide lots of context and background.',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function buildSystemPrompt(
|
|
13
|
+
persona: string,
|
|
14
|
+
context?: { scenario?: string; maxTurns?: number; currentTurn?: number }
|
|
15
|
+
): string {
|
|
16
|
+
const style = PERSONA_PROMPTS[persona] || `You are a customer with a ${persona} communication style.`;
|
|
17
|
+
const scenarioLine = context?.scenario ? `\nScenario: ${context.scenario}` : '';
|
|
18
|
+
const turnInfo = context?.maxTurns
|
|
19
|
+
? `\nThis conversation has a maximum of ${context.maxTurns} turns. You are on turn ${context.currentTurn ?? 1}.`
|
|
20
|
+
: '';
|
|
21
|
+
|
|
22
|
+
return `You are simulating a customer in a support conversation for testing purposes.
|
|
23
|
+
${style}${scenarioLine}${turnInfo}
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- Stay in character throughout the conversation.
|
|
27
|
+
- Escalate naturally if your issue isn't being resolved.
|
|
28
|
+
- Set shouldContinue to false when your issue is resolved or you have no more questions.
|
|
29
|
+
- If the agent asks a question, answer it in character.
|
|
30
|
+
|
|
31
|
+
Respond with ONLY valid JSON (no markdown, no code fences):
|
|
32
|
+
{"message": "your response as the customer", "shouldContinue": true}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatHistory(history: ConversationTurn[]): LLMMessage[] {
|
|
36
|
+
return history.map((turn) => ({
|
|
37
|
+
role: turn.role === 'customer' ? 'user' as const : 'assistant' as const,
|
|
38
|
+
content: turn.message,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseResponse(text: string): CustomerSimulatorResponse {
|
|
43
|
+
// Try to extract JSON from the response, handling markdown fences
|
|
44
|
+
const cleaned = text.replace(/```(?:json)?\s*/g, '').replace(/```/g, '').trim();
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(cleaned);
|
|
47
|
+
return {
|
|
48
|
+
message: String(parsed.message ?? ''),
|
|
49
|
+
shouldContinue: Boolean(parsed.shouldContinue),
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
// If JSON parsing fails, treat the whole text as the message
|
|
53
|
+
return { message: text.trim(), shouldContinue: true };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class CustomerSimulator {
|
|
58
|
+
private llm?: LLMProvider;
|
|
59
|
+
|
|
60
|
+
constructor(options?: { llmProvider?: LLMProvider }) {
|
|
61
|
+
this.llm = options?.llmProvider;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async generateMessage(
|
|
65
|
+
persona: string,
|
|
66
|
+
history: ConversationTurn[],
|
|
67
|
+
context?: {
|
|
68
|
+
scenario?: string;
|
|
69
|
+
maxTurns?: number;
|
|
70
|
+
currentTurn?: number;
|
|
71
|
+
scriptedResponses?: string[];
|
|
72
|
+
}
|
|
73
|
+
): Promise<CustomerSimulatorResponse> {
|
|
74
|
+
// Script mode: use pre-defined responses
|
|
75
|
+
if (context?.scriptedResponses?.length) {
|
|
76
|
+
const turn = context.currentTurn ?? history.filter((t) => t.role === 'customer').length;
|
|
77
|
+
const responses = context.scriptedResponses;
|
|
78
|
+
if (turn < responses.length) {
|
|
79
|
+
return {
|
|
80
|
+
message: responses[turn],
|
|
81
|
+
shouldContinue: turn < responses.length - 1,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Exhausted scripted responses
|
|
85
|
+
return { message: responses[responses.length - 1], shouldContinue: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// LLM mode
|
|
89
|
+
if (!this.llm) {
|
|
90
|
+
throw new Error('CustomerSimulator requires an LLM provider for non-scripted mode');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const systemPrompt = buildSystemPrompt(persona, context);
|
|
94
|
+
const messages: LLMMessage[] = [
|
|
95
|
+
{ role: 'system', content: systemPrompt },
|
|
96
|
+
...formatHistory(history),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const result = await this.llm.complete(messages, {
|
|
100
|
+
temperature: 0.7,
|
|
101
|
+
maxTokens: 500,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return parseResponse(result.text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { Skill, Tool } from '@operor/core';
|
|
2
|
+
|
|
3
|
+
interface MockOrder {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
status: string;
|
|
7
|
+
financialStatus: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
expectedDelivery: Date;
|
|
10
|
+
actualDelivery?: Date;
|
|
11
|
+
tracking?: string;
|
|
12
|
+
trackingUrl?: string;
|
|
13
|
+
items: Array<{ name: string; quantity: number; price: string }>;
|
|
14
|
+
total: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MockProduct {
|
|
18
|
+
id: number;
|
|
19
|
+
title: string;
|
|
20
|
+
vendor: string;
|
|
21
|
+
type: string;
|
|
22
|
+
price: string;
|
|
23
|
+
available: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MockDiscount {
|
|
27
|
+
code: string;
|
|
28
|
+
percent: number;
|
|
29
|
+
validDays: number;
|
|
30
|
+
startsAt: string;
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
priceRuleId: number;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class MockShopifySkill implements Skill {
|
|
37
|
+
public readonly name = 'shopify';
|
|
38
|
+
private ready = false;
|
|
39
|
+
private mockOrders: Map<string, MockOrder> = new Map();
|
|
40
|
+
private mockProducts: MockProduct[] = [];
|
|
41
|
+
private mockDiscounts: MockDiscount[] = [];
|
|
42
|
+
private nextPriceRuleId = 1000;
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
// Seed with mock data
|
|
46
|
+
this.seedMockData();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async initialize(): Promise<void> {
|
|
50
|
+
this.ready = true;
|
|
51
|
+
console.log('✅ Mock Shopify initialized');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @deprecated Use initialize() instead. */
|
|
55
|
+
async authenticate(): Promise<void> {
|
|
56
|
+
return this.initialize();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isReady(): boolean {
|
|
60
|
+
return this.ready;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @deprecated Use isReady() instead. */
|
|
64
|
+
isAuthenticated(): boolean {
|
|
65
|
+
return this.ready;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reset all mock data to initial state (for testing)
|
|
70
|
+
*/
|
|
71
|
+
reset(): void {
|
|
72
|
+
this.mockOrders.clear();
|
|
73
|
+
this.mockProducts = [];
|
|
74
|
+
this.mockDiscounts = [];
|
|
75
|
+
this.nextPriceRuleId = 1000;
|
|
76
|
+
this.seedMockData();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Seed custom test data (for testing)
|
|
81
|
+
*/
|
|
82
|
+
seedData(config: {
|
|
83
|
+
orders?: Array<Partial<MockOrder>>;
|
|
84
|
+
products?: Array<Partial<MockProduct>>;
|
|
85
|
+
discounts?: Array<Partial<MockDiscount>>;
|
|
86
|
+
}): void {
|
|
87
|
+
if (config.orders) {
|
|
88
|
+
for (const order of config.orders) {
|
|
89
|
+
const fullOrder: MockOrder = {
|
|
90
|
+
id: order.id || String(Date.now()),
|
|
91
|
+
name: order.name || `#${order.id || '1001'}`,
|
|
92
|
+
status: order.status || 'unfulfilled',
|
|
93
|
+
financialStatus: order.financialStatus || 'paid',
|
|
94
|
+
createdAt: order.createdAt || new Date().toISOString(),
|
|
95
|
+
expectedDelivery: order.expectedDelivery || new Date(),
|
|
96
|
+
actualDelivery: order.actualDelivery,
|
|
97
|
+
tracking: order.tracking,
|
|
98
|
+
trackingUrl: order.trackingUrl,
|
|
99
|
+
items: order.items || [],
|
|
100
|
+
total: order.total || '0.00',
|
|
101
|
+
};
|
|
102
|
+
this.mockOrders.set(fullOrder.id, fullOrder);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (config.products) {
|
|
107
|
+
for (const product of config.products) {
|
|
108
|
+
const fullProduct: MockProduct = {
|
|
109
|
+
id: product.id || Date.now(),
|
|
110
|
+
title: product.title || 'Product',
|
|
111
|
+
vendor: product.vendor || 'Mock Vendor',
|
|
112
|
+
type: product.type || 'General',
|
|
113
|
+
price: product.price || '0.00',
|
|
114
|
+
available: product.available !== undefined ? product.available : true,
|
|
115
|
+
};
|
|
116
|
+
this.mockProducts.push(fullProduct);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (config.discounts) {
|
|
121
|
+
for (const discount of config.discounts) {
|
|
122
|
+
const fullDiscount: MockDiscount = {
|
|
123
|
+
code: discount.code || 'DISCOUNT',
|
|
124
|
+
percent: discount.percent || 10,
|
|
125
|
+
validDays: discount.validDays || 30,
|
|
126
|
+
startsAt: discount.startsAt || new Date().toISOString(),
|
|
127
|
+
expiresAt: discount.expiresAt || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
128
|
+
priceRuleId: discount.priceRuleId || this.nextPriceRuleId++,
|
|
129
|
+
createdAt: discount.createdAt || new Date(),
|
|
130
|
+
};
|
|
131
|
+
this.mockDiscounts.push(fullDiscount);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private seedMockData(): void {
|
|
137
|
+
// Create a delayed order for testing
|
|
138
|
+
const twoDaysAgo = new Date();
|
|
139
|
+
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
|
140
|
+
|
|
141
|
+
this.mockOrders.set('12345', {
|
|
142
|
+
id: '12345',
|
|
143
|
+
name: '#1001',
|
|
144
|
+
status: 'in_transit',
|
|
145
|
+
financialStatus: 'paid',
|
|
146
|
+
createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
|
147
|
+
expectedDelivery: twoDaysAgo,
|
|
148
|
+
tracking: 'TRACK123456789',
|
|
149
|
+
trackingUrl: 'https://track.example.com/TRACK123456789',
|
|
150
|
+
items: [
|
|
151
|
+
{ name: 'Premium Headphones', quantity: 1, price: '299.99' },
|
|
152
|
+
],
|
|
153
|
+
total: '299.99',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.mockOrders.set('67890', {
|
|
157
|
+
id: '67890',
|
|
158
|
+
name: '#1002',
|
|
159
|
+
status: 'delivered',
|
|
160
|
+
financialStatus: 'paid',
|
|
161
|
+
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
|
162
|
+
expectedDelivery: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
|
|
163
|
+
actualDelivery: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
|
|
164
|
+
tracking: 'TRACK987654321',
|
|
165
|
+
trackingUrl: 'https://track.example.com/TRACK987654321',
|
|
166
|
+
items: [
|
|
167
|
+
{ name: 'Wireless Mouse', quantity: 2, price: '49.99' },
|
|
168
|
+
],
|
|
169
|
+
total: '99.98',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Seed default products
|
|
173
|
+
this.mockProducts = [
|
|
174
|
+
{
|
|
175
|
+
id: 1001,
|
|
176
|
+
title: 'Premium Headphones',
|
|
177
|
+
vendor: 'AudioTech',
|
|
178
|
+
type: 'Electronics',
|
|
179
|
+
price: '299.99',
|
|
180
|
+
available: true,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 1002,
|
|
184
|
+
title: 'Wireless Mouse',
|
|
185
|
+
vendor: 'TechGear',
|
|
186
|
+
type: 'Electronics',
|
|
187
|
+
price: '49.99',
|
|
188
|
+
available: true,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 1003,
|
|
192
|
+
title: 'Mechanical Keyboard',
|
|
193
|
+
vendor: 'KeyMaster',
|
|
194
|
+
type: 'Electronics',
|
|
195
|
+
price: '149.99',
|
|
196
|
+
available: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 1004,
|
|
200
|
+
title: 'USB-C Cable',
|
|
201
|
+
vendor: 'CableCo',
|
|
202
|
+
type: 'Accessories',
|
|
203
|
+
price: '19.99',
|
|
204
|
+
available: false,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public tools: Record<string, Tool> = {
|
|
210
|
+
get_order: {
|
|
211
|
+
name: 'get_order',
|
|
212
|
+
description: 'Get order details by order ID or order name (e.g., #1001)',
|
|
213
|
+
parameters: {
|
|
214
|
+
orderId: { type: 'string', required: true },
|
|
215
|
+
},
|
|
216
|
+
execute: async (params: { orderId: string }) => {
|
|
217
|
+
const orderIdentifier = params.orderId.replace('#', '');
|
|
218
|
+
const order = this.mockOrders.get(orderIdentifier);
|
|
219
|
+
|
|
220
|
+
if (!order) {
|
|
221
|
+
return {
|
|
222
|
+
found: false,
|
|
223
|
+
error: `Order ${params.orderId} not found`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const delayMs = new Date().getTime() - order.expectedDelivery.getTime();
|
|
228
|
+
const delayDays = Math.floor(delayMs / (1000 * 60 * 60 * 24));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
found: true,
|
|
232
|
+
id: order.id,
|
|
233
|
+
name: order.name,
|
|
234
|
+
status: order.status,
|
|
235
|
+
financialStatus: order.financialStatus,
|
|
236
|
+
createdAt: order.createdAt,
|
|
237
|
+
total: order.total,
|
|
238
|
+
items: order.items,
|
|
239
|
+
tracking: order.tracking || null,
|
|
240
|
+
trackingUrl: order.trackingUrl || null,
|
|
241
|
+
// Mock-specific extra fields
|
|
242
|
+
isDelayed: delayDays > 0,
|
|
243
|
+
delayDays: Math.max(0, delayDays),
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
create_discount: {
|
|
249
|
+
name: 'create_discount',
|
|
250
|
+
description: 'Create a percentage discount code',
|
|
251
|
+
parameters: {
|
|
252
|
+
percent: { type: 'number', required: true },
|
|
253
|
+
validDays: { type: 'number', required: true },
|
|
254
|
+
},
|
|
255
|
+
execute: async (params: { percent: number; validDays: number }) => {
|
|
256
|
+
const code = `SORRY${params.percent}`;
|
|
257
|
+
const startsAt = new Date().toISOString();
|
|
258
|
+
const expiresAt = new Date(Date.now() + params.validDays * 24 * 60 * 60 * 1000).toISOString();
|
|
259
|
+
const priceRuleId = this.nextPriceRuleId++;
|
|
260
|
+
|
|
261
|
+
const discount: MockDiscount = {
|
|
262
|
+
code,
|
|
263
|
+
percent: params.percent,
|
|
264
|
+
validDays: params.validDays,
|
|
265
|
+
startsAt,
|
|
266
|
+
expiresAt,
|
|
267
|
+
priceRuleId,
|
|
268
|
+
createdAt: new Date(),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
this.mockDiscounts.push(discount);
|
|
272
|
+
|
|
273
|
+
console.log(`\n💰 Created discount code: ${code}`);
|
|
274
|
+
console.log(` ${params.percent}% off, valid for ${params.validDays} days\n`);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
code,
|
|
278
|
+
percent: params.percent,
|
|
279
|
+
validDays: params.validDays,
|
|
280
|
+
startsAt,
|
|
281
|
+
expiresAt,
|
|
282
|
+
priceRuleId,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
search_products: {
|
|
288
|
+
name: 'search_products',
|
|
289
|
+
description: 'Search for products in the store',
|
|
290
|
+
parameters: {
|
|
291
|
+
query: { type: 'string', required: true },
|
|
292
|
+
limit: { type: 'number', required: false },
|
|
293
|
+
},
|
|
294
|
+
execute: async (params: { query: string; limit?: number }) => {
|
|
295
|
+
const limit = params.limit || 10;
|
|
296
|
+
const queryLower = params.query.toLowerCase();
|
|
297
|
+
|
|
298
|
+
const matched = this.mockProducts
|
|
299
|
+
.filter((p) =>
|
|
300
|
+
p.title.toLowerCase().includes(queryLower) ||
|
|
301
|
+
p.vendor.toLowerCase().includes(queryLower) ||
|
|
302
|
+
p.type.toLowerCase().includes(queryLower)
|
|
303
|
+
)
|
|
304
|
+
.slice(0, limit);
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
found: matched.length,
|
|
308
|
+
products: matched.map((p) => ({
|
|
309
|
+
id: p.id,
|
|
310
|
+
title: p.title,
|
|
311
|
+
vendor: p.vendor,
|
|
312
|
+
type: p.type,
|
|
313
|
+
price: p.price,
|
|
314
|
+
available: p.available,
|
|
315
|
+
})),
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get all created discounts (for testing)
|
|
323
|
+
*/
|
|
324
|
+
getDiscounts(): MockDiscount[] {
|
|
325
|
+
return [...this.mockDiscounts];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get all orders (for testing)
|
|
330
|
+
*/
|
|
331
|
+
getOrders(): MockOrder[] {
|
|
332
|
+
return Array.from(this.mockOrders.values());
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export type { MockOrder, MockProduct, MockDiscount };
|