-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCoreAiChatServiceEditModeTests.cs
More file actions
632 lines (524 loc) · 24 KB
/
CoreAiChatServiceEditModeTests.cs
File metadata and controls
632 lines (524 loc) · 24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CoreAI.Ai;
using CoreAI.Chat;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace CoreAI.Tests.EditMode
{
/// <summary>
/// EditMode coverage for <see cref="CoreAiChatService"/> streaming selection,
/// smart-send behavior, fake-orchestrator send paths, and persisted chat formatting.
/// </summary>
[TestFixture]
public sealed class CoreAiChatServiceEditModeTests
{
[SetUp]
public void SetUp()
{
CoreAISettings.ResetOverrides();
CoreAISettings.Instance = null;
}
[TearDown]
public void TearDown()
{
CoreAISettings.ResetOverrides();
CoreAISettings.Instance = null;
}
// ===================== Persisted chat (session restore for UI) =====================
[Test]
public void TryGetPersistedChatHistory_NoStore_ReturnsFalse()
{
CoreAiChatService service = new(new FakeAiOrchestrator("ok"), memoryStore: null);
bool ok = service.TryGetPersistedChatHistory("SmartChat", out ChatMessage[] msgs, 0);
Assert.IsFalse(ok);
Assert.IsNotNull(msgs);
Assert.AreEqual(0, msgs.Length);
}
[Test]
public void TryGetPersistedChatHistory_EmptyHistory_ReturnsFalse()
{
ListBackedChatHistoryStore store = new();
CoreAiChatService service = new(new FakeAiOrchestrator("ok"), memoryStore: store);
Assert.IsFalse(service.TryGetPersistedChatHistory("SmartChat", out ChatMessage[] msgs, 0));
}
[Test]
public void TryGetPersistedChatHistory_ReturnsTailWhenMaxMessagesSet()
{
ListBackedChatHistoryStore store = new();
const string role = "SmartChat";
for (int i = 0; i < 5; i++)
{
store.AppendChatMessage(role, i % 2 == 0 ? "user" : "assistant", $"m{i}", false);
}
CoreAiChatService service = new(new FakeAiOrchestrator("ok"), memoryStore: store);
Assert.IsTrue(service.TryGetPersistedChatHistory(role, out ChatMessage[] msgs, 2));
Assert.AreEqual(2, msgs.Length);
Assert.AreEqual("m3", msgs[0].Content);
Assert.AreEqual("m4", msgs[1].Content);
}
[Test]
public void PersistedChat_UiFormattingRoundTrip_MatchesCoreAiChatPanelRules()
{
ListBackedChatHistoryStore store = new();
const string role = "SmartChat";
string userComposer =
"{\"telemetry\":{},\"hint\":\"stored user line\",\"ai_task_source\":\"Chat\"}";
store.AppendChatMessage(role, "user", userComposer, false);
store.AppendChatMessage(role, "assistant", "visible reply", false);
CoreAiChatService service = new(new FakeAiOrchestrator("ok"), memoryStore: store);
Assert.IsTrue(service.TryGetPersistedChatHistory(role, out ChatMessage[] msgs, 0));
Assert.AreEqual(2, msgs.Length);
string userLine = CoreAiChatPanel.FormatPersistedMessageForUi(msgs[0].Content, true);
string assistantLine = CoreAiChatPanel.FormatPersistedMessageForUi(msgs[1].Content, false);
Assert.AreEqual("stored user line", userLine);
Assert.AreEqual("visible reply", assistantLine);
}
// ===================== IsStreamingEnabled — fallbacks =====================
[Test]
public void IsStreamingEnabled_NoPolicyNoSettings_FallsBackToStaticDefault()
{
CoreAiChatService service = new(new FakeAiOrchestrator("ok"));
// Default CoreAISettings.EnableStreaming = true
Assert.IsTrue(service.IsStreamingEnabled("AnyRole", uiOverride: true));
CoreAISettings.EnableStreaming = false;
Assert.IsFalse(service.IsStreamingEnabled("AnyRole", uiOverride: true));
}
[Test]
public void IsStreamingEnabled_WithSettingsOnly_UsesSettingsFlag()
{
StubSettings settings = new() { EnableStreaming = false };
CoreAiChatService service = new(new FakeAiOrchestrator("ok"),
null,
settings);
Assert.IsFalse(service.IsStreamingEnabled("AnyRole", uiOverride: true));
settings.EnableStreaming = true;
Assert.IsTrue(service.IsStreamingEnabled("AnyRole", uiOverride: true));
}
[Test]
public void IsStreamingEnabled_PerRoleOverride_WinsOverSettings()
{
StubSettings settings = new() { EnableStreaming = false };
AgentMemoryPolicy policy = new();
policy.SetStreamingEnabled("FastRole", true);
CoreAiChatService service = new(new FakeAiOrchestrator("ok"),
policy,
settings);
Assert.IsTrue(service.IsStreamingEnabled("FastRole", uiOverride: true), "per-role override wins");
Assert.IsFalse(service.IsStreamingEnabled("OtherRole", uiOverride: true), "other roles → global");
}
// ===================== IsStreamingEnabled — UI layer =====================
[Test]
public void IsStreamingEnabled_UiFallbackFalse_ForcesOff()
{
StubSettings settings = new() { EnableStreaming = true };
AgentMemoryPolicy policy = new();
policy.SetStreamingEnabled("Role", true);
CoreAiChatService service = new(new FakeAiOrchestrator("ok"),
policy,
settings);
// UI слой выключил стриминг → всё остальное игнорируется
Assert.IsFalse(service.IsStreamingEnabled("Role", uiOverride: false));
}
[Test]
public void IsStreamingEnabled_UiOverrideFalse_ForcesOff()
{
StubSettings settings = new() { EnableStreaming = true };
CoreAiChatService service = new(new FakeAiOrchestrator("ok"),
null,
settings);
// Перегрузка bool?: false выключает, true/null — обычное разрешение
Assert.IsFalse(service.IsStreamingEnabled("Role", (bool?)false));
Assert.IsTrue(service.IsStreamingEnabled("Role", (bool?)true));
Assert.IsTrue(service.IsStreamingEnabled("Role", (bool?)null));
}
// ===================== SendMessage — happy path =====================
[Test]
public async Task SendMessageAsync_NonStreaming_ReturnsContent()
{
FakeAiOrchestrator orchestrator = new("Hello, world!");
CoreAiChatService service = new(orchestrator);
string response = await service.SendMessageAsync("hi", "TestRole");
Assert.AreEqual("Hello, world!", response);
Assert.AreEqual(1, orchestrator.CompleteCallCount);
Assert.AreEqual(0, orchestrator.StreamingCallCount);
}
[Test]
public void SendMessageAsync_Error_PropagatesException()
{
// v1.5.1: CoreAiChatService no longer swallows exceptions.
// Errors propagate to the caller (CoreAiChatPanel), which displays them.
FakeAiOrchestrator orchestrator = new(null, "connection refused");
CoreAiChatService service = new(orchestrator);
Exception ex = Assert.ThrowsAsync<Exception>(async () => await service.SendMessageAsync("hi", "TestRole"));
Assert.AreEqual("connection refused", ex.Message);
}
[Test]
public async Task SendMessageStreamingAsync_YieldsChunks_InOrder()
{
FakeAiOrchestrator orchestrator = new(streamChunks: new[] { "Hel", "lo", " world" });
CoreAiChatService service = new(orchestrator);
List<string> visible = new();
await foreach (LlmStreamChunk chunk in
service.SendMessageStreamingAsync("hi", "TestRole"))
{
if (!string.IsNullOrEmpty(chunk.Text))
{
visible.Add(chunk.Text);
}
}
CollectionAssert.AreEqual(new[] { "Hel", "lo", " world" }, visible);
Assert.AreEqual(1, orchestrator.StreamingCallCount);
}
// ===================== SendMessageSmartAsync — auto selection =====================
[Test]
public async Task SendSmart_StreamingEnabled_UsesStreamingPath()
{
FakeAiOrchestrator orchestrator = new(streamChunks: new[] { "A", "B", "C" });
StubSettings settings = new() { EnableStreaming = true };
CoreAiChatService service = new(orchestrator,
null,
settings);
List<string> chunks = new();
string full = await service.SendMessageSmartAsync(
"hi", "Role",
c =>
{
if (!string.IsNullOrEmpty(c.Text))
{
chunks.Add(c.Text);
}
});
Assert.AreEqual("ABC", full);
CollectionAssert.AreEqual(new[] { "A", "B", "C" }, chunks);
Assert.AreEqual(1, orchestrator.StreamingCallCount);
Assert.AreEqual(0, orchestrator.CompleteCallCount);
}
[Test]
public async Task SendSmart_StreamingDisabled_UsesNonStreamingPath()
{
FakeAiOrchestrator orchestrator = new("Full response text");
StubSettings settings = new() { EnableStreaming = false };
CoreAiChatService service = new(orchestrator,
null,
settings);
List<string> chunks = new();
string full = await service.SendMessageSmartAsync(
"hi", "Role",
c =>
{
if (!string.IsNullOrEmpty(c.Text))
{
chunks.Add(c.Text);
}
});
Assert.AreEqual("Full response text", full);
Assert.AreEqual(1, orchestrator.CompleteCallCount);
Assert.AreEqual(0, orchestrator.StreamingCallCount);
// onChunk должен быть вызван даже в non-streaming пути: 1 чанк с текстом + финал
Assert.AreEqual(1, chunks.Count);
Assert.AreEqual("Full response text", chunks[0]);
}
[Test]
public async Task SendSmart_UiOverrideFalse_ForcesNonStreaming()
{
FakeAiOrchestrator orchestrator = new("Non-streaming answer");
StubSettings settings = new() { EnableStreaming = true };
CoreAiChatService service = new(orchestrator,
null,
settings);
string full = await service.SendMessageSmartAsync(
"hi", "Role",
null,
false);
Assert.AreEqual("Non-streaming answer", full);
Assert.AreEqual(1, orchestrator.CompleteCallCount);
Assert.AreEqual(0, orchestrator.StreamingCallCount);
}
// ===================== Control API =====================
[Test]
public void ClearHistory_ClearsMemoryStore()
{
FakeMemoryStore store = new();
CoreAiChatService service = new(new FakeAiOrchestrator("ok"), memoryStore: store);
service.ClearHistory("Role123");
Assert.AreEqual("Role123", store.ClearedRole);
}
[Test]
public void StopAgent_CallsFacade_DoesNotThrowWithoutScope()
{
CoreAiChatService service = new(new FakeAiOrchestrator("ok"));
// В EditMode нет CoreAILifetimeScope — StopAgent должен отработать молча (graceful degradation).
Assert.DoesNotThrow(() => service.StopAgent("Role"));
}
// ===================== v1.5.1 — Timeout + Error Propagation =====================
[Test]
public async Task SendMessageAsync_WithTimeoutSettings_PassesCancellationToken()
{
// v1.5.1: timeout is now enforced by CoreAiChatService via UniTask CancelAfterSlim.
// Verify that when LlmRequestTimeoutSeconds > 0, the orchestrator receives
// a different CancellationToken (linked with timeout) than the caller's original.
TokenCapturingOrchestrator orchestrator = new();
StubSettings settings = new() { LlmRequestTimeoutSecondsOverride = 30f };
CoreAiChatService service = new(orchestrator, settings: settings);
await service.SendMessageAsync("hi", "TestRole");
// The service should have created a linked CTS with CancelAfterSlim
Assert.IsTrue(orchestrator.LastCancellationToken.CanBeCanceled,
"When timeout > 0, orchestrator should receive a cancellable token");
}
[Test]
public async Task SendMessageAsync_NoTimeoutSettings_PassesOriginalToken()
{
// When LlmRequestTimeoutSeconds = 0, no timeout CTS is created
TokenCapturingOrchestrator orchestrator = new();
CoreAiChatService service = new(orchestrator); // no settings = no timeout
using CancellationTokenSource cts = new();
await service.SendMessageAsync(
new AiTaskRequest { RoleId = "Role", Hint = "hi" }, cts.Token);
// Should pass the caller's token directly (not a linked one)
Assert.AreEqual(cts.Token, orchestrator.LastCancellationToken,
"Without timeout settings, original token should pass through");
}
[Test]
public async Task SendMessageAsync_NullResult_ReturnsEmptyString()
{
// AiOrchestrator may return null on soft failures;
// CoreAiChatService should convert to "" (not crash)
FakeAiOrchestrator orchestrator = new(null);
CoreAiChatService service = new(orchestrator);
string response = await service.SendMessageAsync("hi", "TestRole");
Assert.AreEqual("", response, "null result from orchestrator → empty string");
}
/// <summary>
/// <see cref="CoreAiChatService"/> uses UniTask <c>CancelAfterSlim</c> (player loop). A plain
/// <see cref="Test"/> that blocks the main thread on <c>Task.Delay(Infinite, ct)</c> can deadlock
/// because the timer never runs — use <see cref="UnityTest"/> and yield frames.
/// </summary>
[UnityTest]
[Timeout(8000)]
public IEnumerator SendMessageAsync_TimeoutWhenOrchestratorBlocks_ThrowsLlmOperationTimeoutException()
{
BlockUntilCancelledOrchestrator orchestrator = new();
StubSettings settings = new() { LlmRequestTimeoutSecondsOverride = 0.2f };
CoreAiChatService service = new(orchestrator, settings: settings);
Task task = service.SendMessageAsync("hi", "TestRole");
float deadline = Time.realtimeSinceStartup + 6f;
while (!task.IsCompleted && Time.realtimeSinceStartup < deadline)
{
yield return null;
}
Assert.IsTrue(task.IsCompleted, "SendMessageAsync should complete once the timeout token fires.");
// LlmOperationTimeoutException inherits OperationCanceledException — async Task reports
// TaskStatus.Canceled (not Faulted) and task.Exception is null; unwrap via GetResult().
try
{
task.GetAwaiter().GetResult();
Assert.Fail("Expected LlmOperationTimeoutException after timeout.");
}
catch (LlmOperationTimeoutException ex)
{
Assert.That(ex.Message, Does.Contain("timed out"));
}
catch (Exception ex)
{
Assert.Fail($"Expected LlmOperationTimeoutException, got {ex.GetType().Name}: {ex.Message}");
}
}
// ===================== Helpers =====================
private sealed class StubSettings : ICoreAISettings
{
public string UniversalSystemPromptPrefix { get; set; } = "";
public float Temperature { get; set; } = 0.1f;
public int ContextWindowTokens => 8192;
public int MaxLuaRepairRetries => 3;
public int MaxToolCallRetries => 3;
public bool AllowDuplicateToolCalls => false;
public bool EnableHttpDebugLogging => false;
public bool LogMeaiToolCallingSteps => false;
public bool EnableMeaiDebugLogging => false;
public float? LlmRequestTimeoutSecondsOverride { get; set; }
public float LlmRequestTimeoutSeconds => LlmRequestTimeoutSecondsOverride ?? 15f;
public int MaxLlmRequestRetries => 2;
public bool LogTokenUsage => false;
public bool LogLlmLatency => false;
public bool LogLlmConnectionErrors => false;
public bool LogToolCalls => false;
public bool LogToolCallArguments => false;
public bool LogToolCallResults => false;
public bool EnableStreaming { get; set; } = true;
}
/// <summary>
/// Minimal in-memory <see cref="IAgentMemoryStore"/> for chat history only (mirrors tail semantics of <c>FileAgentMemoryStore.GetChatHistory</c>).
/// </summary>
private sealed class ListBackedChatHistoryStore : IAgentMemoryStore
{
private readonly Dictionary<string, List<ChatMessage>> _history = new();
public bool TryLoad(string roleId, out AgentMemoryState state)
{
state = null;
return false;
}
public void Save(string roleId, AgentMemoryState state)
{
}
public void Clear(string roleId)
{
_history.Remove(roleId);
}
public void ClearChatHistory(string roleId)
{
_history.Remove(roleId);
}
public void AppendChatMessage(string roleId, string role, string content, bool persistToDisk = true)
{
if (!_history.TryGetValue(roleId, out List<ChatMessage> list))
{
list = new List<ChatMessage>();
_history[roleId] = list;
}
list.Add(new ChatMessage(role, content));
}
public ChatMessage[] GetChatHistory(string roleId, int maxMessages = 0)
{
if (!_history.TryGetValue(roleId, out List<ChatMessage> list) || list.Count == 0)
{
return Array.Empty<ChatMessage>();
}
if (maxMessages > 0 && list.Count > maxMessages)
{
return list.Skip(list.Count - maxMessages).ToArray();
}
return list.ToArray();
}
}
private sealed class FakeMemoryStore : IAgentMemoryStore
{
public string ClearedRole { get; private set; }
public void Clear(string roleId)
{
}
public void ClearChatHistory(string roleId)
{
ClearedRole = roleId;
}
public void AppendChatMessage(string roleId, string role, string content, bool persistToDisk = true)
{
}
public ChatMessage[] GetChatHistory(string roleId, int maxMessages = 0)
{
return Array.Empty<ChatMessage>();
}
public bool TryLoad(string roleId, out AgentMemoryState state)
{
state = null;
return false;
}
public void Save(string roleId, AgentMemoryState state)
{
}
}
private sealed class FakeAiOrchestrator : IAiOrchestrationService
{
private readonly string _content;
private readonly string _error;
private readonly string[] _streamChunks;
public int CompleteCallCount { get; private set; }
public int StreamingCallCount { get; private set; }
public FakeAiOrchestrator(string content = "OK",
string errorMessage = null,
string[] streamChunks = null)
{
_content = content;
_error = errorMessage;
_streamChunks = streamChunks;
}
public Task<string> RunTaskAsync(AiTaskRequest request, CancellationToken ct = default)
{
CompleteCallCount++;
if (_error != null)
{
throw new Exception(_error);
}
return Task.FromResult(_content ?? "");
}
public async IAsyncEnumerable<LlmStreamChunk> RunStreamingAsync(
AiTaskRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken ct = default)
{
StreamingCallCount++;
if (_error != null)
{
yield return new LlmStreamChunk { IsDone = true, Error = _error };
yield break;
}
if (_streamChunks != null)
{
foreach (string c in _streamChunks)
{
ct.ThrowIfCancellationRequested();
yield return new LlmStreamChunk { Text = c };
await Task.Yield();
}
yield return new LlmStreamChunk { IsDone = true };
yield break;
}
yield return new LlmStreamChunk { Text = _content ?? "" };
yield return new LlmStreamChunk { IsDone = true };
}
public void CancelTasks(string scopeId)
{
}
}
/// <summary>
/// Captures the CancellationToken received by RunTaskAsync so tests can
/// verify whether the service wraps it in a timeout-linked CTS.
/// </summary>
private sealed class TokenCapturingOrchestrator : IAiOrchestrationService
{
public CancellationToken LastCancellationToken { get; private set; }
public Task<string> RunTaskAsync(AiTaskRequest request, CancellationToken ct = default)
{
LastCancellationToken = ct;
return Task.FromResult("ok");
}
public async IAsyncEnumerable<LlmStreamChunk> RunStreamingAsync(
AiTaskRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken ct = default)
{
LastCancellationToken = ct;
yield return new LlmStreamChunk { Text = "ok", IsDone = true };
await Task.CompletedTask;
}
public void CancelTasks(string scopeId)
{
}
}
/// <summary>Blocks <see cref="RunTaskAsync"/> until <paramref name="ct"/> is cancelled (timeout or user).</summary>
private sealed class BlockUntilCancelledOrchestrator : IAiOrchestrationService
{
public async Task<string> RunTaskAsync(AiTaskRequest request, CancellationToken ct = default)
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return "unreachable";
}
public async IAsyncEnumerable<LlmStreamChunk> RunStreamingAsync(
AiTaskRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken ct = default)
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
yield return new LlmStreamChunk { Text = "unreachable", IsDone = true };
}
public void CancelTasks(string scopeId)
{
}
}
}
}