Mercurial
comparison mrjunejune/inference/copilot_sidecar_test.py @ 265:056790c4fb0d
add role-aware Epi assistant prompts
Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 10:50:30 -0700 |
| parents | 1f9877b637e9 |
| children |
comparison
equal
deleted
inserted
replaced
| 264:04fee26ecce0 | 265:056790c4fb0d |
|---|---|
| 1 import asyncio | 1 import asyncio |
| 2 import hashlib | |
| 2 import types | 3 import types |
| 3 import unittest | 4 import unittest |
| 5 import uuid | |
| 4 from dataclasses import replace | 6 from dataclasses import replace |
| 5 | 7 |
| 6 from mrjunejune.inference.copilot_sidecar import Sidecar, SidecarConfig | 8 from mrjunejune.inference.copilot_sidecar import ( |
| 9 CompiledProfile, | |
| 10 Sidecar, | |
| 11 SidecarConfig, | |
| 12 _derive_sdk_session_id, | |
| 13 _SDK_SESSION_NAMESPACE, | |
| 14 _validate_history, | |
| 15 _HISTORY_MAX_ENTRIES, | |
| 16 _HISTORY_MAX_BYTES, | |
| 17 ) | |
| 7 | 18 |
| 8 | 19 |
| 9 def event(event_type, **data): | 20 def event(event_type, **data): |
| 10 return types.SimpleNamespace( | 21 return types.SimpleNamespace( |
| 11 type=types.SimpleNamespace(value=event_type), | 22 type=types.SimpleNamespace(value=event_type), |
| 12 data=types.SimpleNamespace(**data), | 23 data=types.SimpleNamespace(**data), |
| 13 ) | 24 ) |
| 25 | |
| 26 | |
| 27 # --------------------------------------------------------------------------- | |
| 28 # Fake compiled-profile infrastructure | |
| 29 # --------------------------------------------------------------------------- | |
| 30 | |
| 31 def _make_content(profile: str) -> str: | |
| 32 return f"You are the {profile} assistant. Common rules apply." | |
| 33 | |
| 34 | |
| 35 _FAKE_PROFILES: dict = { | |
| 36 profile: { | |
| 37 "content": _make_content(profile), | |
| 38 "version": 1, | |
| 39 "hash": hashlib.sha256(_make_content(profile).encode()).hexdigest(), | |
| 40 } | |
| 41 for profile in ("public_visitor", "invited_friend", "june_admin") | |
| 42 } | |
| 43 | |
| 44 | |
| 45 def _fake_compile_fn(profile: str) -> dict: | |
| 46 if profile not in _FAKE_PROFILES: | |
| 47 raise ValueError(f"Unknown profile: {profile!r}") | |
| 48 return _FAKE_PROFILES[profile] | |
| 49 | |
| 50 | |
| 51 def _fake_sdk_id(conversation_id: str, profile: str) -> str: | |
| 52 """Compute the deterministic SDK session ID for use in test assertions.""" | |
| 53 p = _FAKE_PROFILES[profile] | |
| 54 compiled = CompiledProfile( | |
| 55 profile=profile, | |
| 56 content=p["content"], | |
| 57 prompt_version=1, | |
| 58 knowledge_version=p["version"], | |
| 59 hash=p["hash"], | |
| 60 ) | |
| 61 return _derive_sdk_session_id(conversation_id, compiled) | |
| 14 | 62 |
| 15 | 63 |
| 16 class FakeSession: | 64 class FakeSession: |
| 17 def __init__(self, session_id, behavior="complete"): | 65 def __init__(self, session_id, behavior="complete"): |
| 18 self.session_id = session_id | 66 self.session_id = session_id |
| 140 model="test-model", | 188 model="test-model", |
| 141 wire_api="responses", | 189 wire_api="responses", |
| 142 base_directory="/not-used-by-fake", | 190 base_directory="/not-used-by-fake", |
| 143 ) | 191 ) |
| 144 | 192 |
| 145 async def make_sidecar(self, behavior="complete"): | 193 async def make_sidecar(self, behavior="complete", compile_fn=_fake_compile_fn): |
| 146 client = FakeClient(behavior) | 194 client = FakeClient(behavior) |
| 147 sidecar = Sidecar(client, self.config, self.capture) | 195 sidecar = Sidecar(client, self.config, self.capture, compile_fn=compile_fn) |
| 148 await sidecar.start() | 196 await sidecar.start() |
| 149 return sidecar, client | 197 return sidecar, client |
| 150 | 198 |
| 151 async def test_health_and_invalid_protocol(self): | 199 async def test_health_and_invalid_protocol(self): |
| 152 sidecar, _ = await self.make_sidecar() | 200 sidecar, _ = await self.make_sidecar() |
| 169 { | 217 { |
| 170 "command": "turn.start", | 218 "command": "turn.start", |
| 171 "request_id": request_id, | 219 "request_id": request_id, |
| 172 "conversation_id": "conversation-a", | 220 "conversation_id": "conversation-a", |
| 173 "prompt": prompt, | 221 "prompt": prompt, |
| 222 "prompt_profile": "public_visitor", | |
| 223 "prompt_version": 1, | |
| 224 "knowledge_version": 1, | |
| 174 } | 225 } |
| 175 ) | 226 ) |
| 176 await sidecar.drain_events() | 227 await sidecar.drain_events() |
| 177 | 228 |
| 178 self.assertEqual(len(client.create_calls), 1) | 229 self.assertEqual(len(client.create_calls), 1) |
| 179 self.assertEqual(len(client.resume_calls), 1) | 230 self.assertEqual(len(client.resume_calls), 1) |
| 180 session = client.sessions["conversation-a"] | 231 sdk_id = _fake_sdk_id("conversation-a", "public_visitor") |
| 232 session = client.sessions[sdk_id] | |
| 181 self.assertEqual(session.prompts, ["first", "second"]) | 233 self.assertEqual(session.prompts, ["first", "second"]) |
| 182 options = client.create_calls[0][1] | 234 options = client.create_calls[0][1] |
| 183 self.assertEqual(options["provider"]["type"], "openai") | 235 self.assertEqual(options["provider"]["type"], "openai") |
| 184 self.assertEqual( | 236 self.assertEqual( |
| 185 options["provider"]["base_url"], "http://litellm.invalid/v1" | 237 options["provider"]["base_url"], "http://litellm.invalid/v1" |
| 200 { | 252 { |
| 201 "command": "turn.start", | 253 "command": "turn.start", |
| 202 "request_id": "left-request", | 254 "request_id": "left-request", |
| 203 "conversation_id": "left", | 255 "conversation_id": "left", |
| 204 "prompt": "left", | 256 "prompt": "left", |
| 257 "prompt_profile": "public_visitor", | |
| 258 "prompt_version": 1, | |
| 259 "knowledge_version": 1, | |
| 205 } | 260 } |
| 206 ), | 261 ), |
| 207 sidecar.dispatch( | 262 sidecar.dispatch( |
| 208 { | 263 { |
| 209 "command": "turn.start", | 264 "command": "turn.start", |
| 210 "request_id": "right-request", | 265 "request_id": "right-request", |
| 211 "conversation_id": "right", | 266 "conversation_id": "right", |
| 212 "prompt": "right", | 267 "prompt": "right", |
| 268 "prompt_profile": "public_visitor", | |
| 269 "prompt_version": 1, | |
| 270 "knowledge_version": 1, | |
| 213 } | 271 } |
| 214 ), | 272 ), |
| 215 ) | 273 ) |
| 216 await sidecar.drain_events() | 274 await sidecar.drain_events() |
| 217 | 275 |
| 231 { | 289 { |
| 232 "command": "turn.start", | 290 "command": "turn.start", |
| 233 "request_id": "turn-request", | 291 "request_id": "turn-request", |
| 234 "conversation_id": "abort-me", | 292 "conversation_id": "abort-me", |
| 235 "prompt": "wait", | 293 "prompt": "wait", |
| 294 "prompt_profile": "public_visitor", | |
| 295 "prompt_version": 1, | |
| 296 "knowledge_version": 1, | |
| 236 } | 297 } |
| 237 ) | 298 ) |
| 238 await sidecar.dispatch( | 299 await sidecar.dispatch( |
| 239 { | 300 { |
| 240 "command": "turn.abort", | 301 "command": "turn.abort", |
| 242 "conversation_id": "abort-me", | 303 "conversation_id": "abort-me", |
| 243 } | 304 } |
| 244 ) | 305 ) |
| 245 await sidecar.drain_events() | 306 await sidecar.drain_events() |
| 246 | 307 |
| 247 self.assertEqual(client.sessions["abort-me"].abort_calls, 1) | 308 self.assertEqual(client.sessions[_fake_sdk_id("abort-me", "public_visitor")].abort_calls, 1) |
| 248 done = [ | 309 done = [ |
| 249 item | 310 item |
| 250 for item in self.output | 311 for item in self.output |
| 251 if item["type"] == "turn.done" | 312 if item["type"] == "turn.done" |
| 252 and item["request_id"] == "turn-request" | 313 and item["request_id"] == "turn-request" |
| 266 { | 327 { |
| 267 "command": "turn.start", | 328 "command": "turn.start", |
| 268 "request_id": "error-request", | 329 "request_id": "error-request", |
| 269 "conversation_id": "error-conversation", | 330 "conversation_id": "error-conversation", |
| 270 "prompt": "fail", | 331 "prompt": "fail", |
| 332 "prompt_profile": "public_visitor", | |
| 333 "prompt_version": 1, | |
| 334 "knowledge_version": 1, | |
| 271 } | 335 } |
| 272 ) | 336 ) |
| 273 await sidecar.drain_events() | 337 await sidecar.drain_events() |
| 274 | 338 |
| 275 errors = [item for item in self.output if item["type"] == "turn.error"] | 339 errors = [item for item in self.output if item["type"] == "turn.error"] |
| 285 { | 349 { |
| 286 "command": "turn.start", | 350 "command": "turn.start", |
| 287 "request_id": "active-request", | 351 "request_id": "active-request", |
| 288 "conversation_id": "abort-error", | 352 "conversation_id": "abort-error", |
| 289 "prompt": "wait", | 353 "prompt": "wait", |
| 354 "prompt_profile": "public_visitor", | |
| 355 "prompt_version": 1, | |
| 356 "knowledge_version": 1, | |
| 290 } | 357 } |
| 291 ) | 358 ) |
| 292 await sidecar.dispatch( | 359 await sidecar.dispatch( |
| 293 { | 360 { |
| 294 "command": "turn.abort", | 361 "command": "turn.abort", |
| 312 { | 379 { |
| 313 "command": "turn.start", | 380 "command": "turn.start", |
| 314 "request_id": "active", | 381 "request_id": "active", |
| 315 "conversation_id": "delete-me", | 382 "conversation_id": "delete-me", |
| 316 "prompt": "wait", | 383 "prompt": "wait", |
| 317 } | 384 "prompt_profile": "public_visitor", |
| 318 ) | 385 "prompt_version": 1, |
| 319 session = client.sessions["delete-me"] | 386 "knowledge_version": 1, |
| 387 } | |
| 388 ) | |
| 389 _delete_sdk_id = _fake_sdk_id("delete-me", "public_visitor") | |
| 390 session = client.sessions[_delete_sdk_id] | |
| 320 await sidecar.dispatch( | 391 await sidecar.dispatch( |
| 321 { | 392 { |
| 322 "command": "conversation.delete", | 393 "command": "conversation.delete", |
| 323 "request_id": "delete-request", | 394 "request_id": "delete-request", |
| 324 "conversation_id": "delete-me", | 395 "conversation_id": "delete-me", |
| 331 "conversation_id": None, | 402 "conversation_id": None, |
| 332 } | 403 } |
| 333 ) | 404 ) |
| 334 | 405 |
| 335 self.assertEqual(session.disconnect_calls, 1) | 406 self.assertEqual(session.disconnect_calls, 1) |
| 336 self.assertEqual(client.delete_calls, ["delete-me"]) | 407 self.assertEqual(client.delete_calls[0], _delete_sdk_id) |
| 408 self.assertEqual( | |
| 409 set(client.delete_calls), | |
| 410 { | |
| 411 _fake_sdk_id("delete-me", "public_visitor"), | |
| 412 _fake_sdk_id("delete-me", "invited_friend"), | |
| 413 _fake_sdk_id("delete-me", "june_admin"), | |
| 414 }, | |
| 415 ) | |
| 337 self.assertTrue(client.stopped) | 416 self.assertTrue(client.stopped) |
| 338 self.assertTrue(sidecar.shutting_down) | 417 self.assertTrue(sidecar.shutting_down) |
| 339 shutdown = [ | 418 shutdown = [ |
| 340 item | 419 item |
| 341 for item in self.output | 420 for item in self.output |
| 355 { | 434 { |
| 356 "command": "turn.start", | 435 "command": "turn.start", |
| 357 "request_id": f"request-{conversation_id}", | 436 "request_id": f"request-{conversation_id}", |
| 358 "conversation_id": conversation_id, | 437 "conversation_id": conversation_id, |
| 359 "prompt": conversation_id, | 438 "prompt": conversation_id, |
| 439 "prompt_profile": "public_visitor", | |
| 440 "prompt_version": 1, | |
| 441 "knowledge_version": 1, | |
| 360 } | 442 } |
| 361 ) | 443 ) |
| 362 await sidecar.drain_events() | 444 await sidecar.drain_events() |
| 363 await asyncio.sleep(0.01) | 445 await asyncio.sleep(0.01) |
| 364 | 446 |
| 365 old_session = client.sessions["old"] | 447 old_session = client.sessions[_fake_sdk_id("old", "public_visitor")] |
| 366 old_session.disconnect_error = True | 448 old_session.disconnect_error = True |
| 367 await sidecar.evict_idle_sessions() | 449 await sidecar.evict_idle_sessions() |
| 368 self.assertEqual(old_session.disconnect_calls, 1) | 450 self.assertEqual(old_session.disconnect_calls, 1) |
| 369 self.assertNotIn("old", sidecar._conversations) | 451 self.assertNotIn("old", sidecar._conversations) |
| 370 self.assertIn("new", sidecar._conversations) | 452 self.assertIn("new", sidecar._conversations) |
| 371 | 453 |
| 372 sidecar._conversations["new"].last_used -= 4000 | 454 sidecar._conversations["new"].last_used -= 4000 |
| 373 await sidecar.evict_idle_sessions() | 455 await sidecar.evict_idle_sessions() |
| 374 self.assertEqual(client.sessions["new"].disconnect_calls, 1) | 456 self.assertEqual(client.sessions[_fake_sdk_id("new", "public_visitor")].disconnect_calls, 1) |
| 375 self.assertEqual(sidecar._conversations, {}) | 457 self.assertEqual(sidecar._conversations, {}) |
| 376 await sidecar.dispatch( | 458 await sidecar.dispatch( |
| 377 { | 459 { |
| 378 "command": "shutdown", | 460 "command": "shutdown", |
| 379 "request_id": "shutdown-eviction", | 461 "request_id": "shutdown-eviction", |
| 383 | 465 |
| 384 async def test_shutdown_waits_for_inflight_session_creation(self): | 466 async def test_shutdown_waits_for_inflight_session_creation(self): |
| 385 client = FakeClient("pending") | 467 client = FakeClient("pending") |
| 386 client.resume_started = asyncio.Event() | 468 client.resume_started = asyncio.Event() |
| 387 client.resume_release = asyncio.Event() | 469 client.resume_release = asyncio.Event() |
| 388 sidecar = Sidecar(client, self.config, self.capture) | 470 sidecar = Sidecar(client, self.config, self.capture, compile_fn=_fake_compile_fn) |
| 389 await sidecar.start() | 471 await sidecar.start() |
| 390 | 472 |
| 391 start_task = asyncio.create_task( | 473 start_task = asyncio.create_task( |
| 392 sidecar.dispatch( | 474 sidecar.dispatch( |
| 393 { | 475 { |
| 394 "command": "turn.start", | 476 "command": "turn.start", |
| 395 "request_id": "starting", | 477 "request_id": "starting", |
| 396 "conversation_id": "race", | 478 "conversation_id": "race", |
| 397 "prompt": "wait", | 479 "prompt": "wait", |
| 480 "prompt_profile": "public_visitor", | |
| 481 "prompt_version": 1, | |
| 482 "knowledge_version": 1, | |
| 398 } | 483 } |
| 399 ) | 484 ) |
| 400 ) | 485 ) |
| 401 await client.resume_started.wait() | 486 await client.resume_started.wait() |
| 402 shutdown_task = asyncio.create_task( | 487 shutdown_task = asyncio.create_task( |
| 412 self.assertTrue(sidecar.shutting_down) | 497 self.assertTrue(sidecar.shutting_down) |
| 413 client.resume_release.set() | 498 client.resume_release.set() |
| 414 await asyncio.gather(start_task, shutdown_task) | 499 await asyncio.gather(start_task, shutdown_task) |
| 415 | 500 |
| 416 self.assertTrue(client.stopped) | 501 self.assertTrue(client.stopped) |
| 417 self.assertEqual(client.sessions["race"].disconnect_calls, 1) | 502 self.assertEqual(client.sessions[_fake_sdk_id("race", "public_visitor")].disconnect_calls, 1) |
| 418 await sidecar.dispatch( | 503 await sidecar.dispatch( |
| 419 { | 504 { |
| 420 "command": "turn.start", | 505 "command": "turn.start", |
| 421 "request_id": "too-late", | 506 "request_id": "too-late", |
| 422 "conversation_id": "late", | 507 "conversation_id": "late", |
| 423 "prompt": "no", | 508 "prompt": "no", |
| 509 "prompt_profile": "public_visitor", | |
| 510 "prompt_version": 1, | |
| 511 "knowledge_version": 1, | |
| 424 } | 512 } |
| 425 ) | 513 ) |
| 426 late_error = [ | 514 late_error = [ |
| 427 item | 515 item |
| 428 for item in self.output | 516 for item in self.output |
| 440 "conversation_id": f"conversation-{index}", | 528 "conversation_id": f"conversation-{index}", |
| 441 } | 529 } |
| 442 ) | 530 ) |
| 443 self.assertEqual(sidecar._conversation_gates, {}) | 531 self.assertEqual(sidecar._conversation_gates, {}) |
| 444 | 532 |
| 533 # ------------------------------------------------------------------ | |
| 534 # New: profile-aware tests | |
| 535 # ------------------------------------------------------------------ | |
| 536 | |
| 537 async def test_three_distinct_profiles_produce_distinct_append_prompts(self): | |
| 538 sidecar, client = await self.make_sidecar() | |
| 539 for profile in ("public_visitor", "invited_friend", "june_admin"): | |
| 540 await sidecar.dispatch( | |
| 541 { | |
| 542 "command": "turn.start", | |
| 543 "request_id": f"req-{profile}", | |
| 544 "conversation_id": f"conv-{profile}", | |
| 545 "prompt": "hello", | |
| 546 "prompt_profile": profile, | |
| 547 "prompt_version": 1, | |
| 548 "knowledge_version": 1, | |
| 549 } | |
| 550 ) | |
| 551 await sidecar.drain_events() | |
| 552 | |
| 553 contents = [opts["system_message"]["content"] for _, opts in client.create_calls] | |
| 554 self.assertEqual(len(contents), 3) | |
| 555 self.assertEqual(len(set(contents)), 3, "all three profiles must produce distinct content") | |
| 556 for _, opts in client.create_calls: | |
| 557 self.assertEqual(opts["system_message"]["mode"], "append") | |
| 558 | |
| 559 async def test_session_options_memory_disabled_and_no_tools(self): | |
| 560 sidecar, client = await self.make_sidecar() | |
| 561 await sidecar.dispatch( | |
| 562 { | |
| 563 "command": "turn.start", | |
| 564 "request_id": "r-opts", | |
| 565 "conversation_id": "conv-opts", | |
| 566 "prompt": "test", | |
| 567 "prompt_profile": "public_visitor", | |
| 568 "prompt_version": 1, | |
| 569 "knowledge_version": 1, | |
| 570 } | |
| 571 ) | |
| 572 await sidecar.drain_events() | |
| 573 _, opts = client.create_calls[0] | |
| 574 self.assertEqual(opts["memory"], {"enabled": False}) | |
| 575 self.assertEqual(opts["tools"], []) | |
| 576 self.assertEqual(opts["available_tools"], []) | |
| 577 self.assertEqual(opts["mcp_servers"], {}) | |
| 578 self.assertTrue(opts["enable_session_store"]) | |
| 579 | |
| 580 async def test_missing_profile_rejected_before_client_calls(self): | |
| 581 sidecar, client = await self.make_sidecar() | |
| 582 await sidecar.dispatch( | |
| 583 { | |
| 584 "command": "turn.start", | |
| 585 "request_id": "r-missing", | |
| 586 "conversation_id": "conv-missing", | |
| 587 "prompt": "hello", | |
| 588 } | |
| 589 ) | |
| 590 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 591 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 592 done = [e for e in self.output if e["type"] == "turn.done"] | |
| 593 self.assertTrue(done[0]["failed"]) | |
| 594 self.assertEqual(len(client.create_calls), 0) | |
| 595 self.assertEqual(len(client.resume_calls), 0) | |
| 596 | |
| 597 async def test_unknown_profile_rejected_before_client_calls(self): | |
| 598 sidecar, client = await self.make_sidecar() | |
| 599 await sidecar.dispatch( | |
| 600 { | |
| 601 "command": "turn.start", | |
| 602 "request_id": "r-unknown", | |
| 603 "conversation_id": "conv-unknown", | |
| 604 "prompt": "hello", | |
| 605 "prompt_profile": "hacker", | |
| 606 "prompt_version": 1, | |
| 607 "knowledge_version": 1, | |
| 608 } | |
| 609 ) | |
| 610 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 611 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 612 self.assertEqual(len(client.create_calls), 0) | |
| 613 | |
| 614 async def test_stale_prompt_version_rejected_before_client_calls(self): | |
| 615 sidecar, client = await self.make_sidecar() | |
| 616 await sidecar.dispatch( | |
| 617 { | |
| 618 "command": "turn.start", | |
| 619 "request_id": "r-stale-pv", | |
| 620 "conversation_id": "conv-stale-pv", | |
| 621 "prompt": "hello", | |
| 622 "prompt_profile": "public_visitor", | |
| 623 "prompt_version": 999, | |
| 624 "knowledge_version": 1, | |
| 625 } | |
| 626 ) | |
| 627 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 628 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 629 self.assertEqual(len(client.create_calls), 0) | |
| 630 | |
| 631 async def test_stale_knowledge_version_rejected_before_client_calls(self): | |
| 632 sidecar, client = await self.make_sidecar() | |
| 633 await sidecar.dispatch( | |
| 634 { | |
| 635 "command": "turn.start", | |
| 636 "request_id": "r-stale-kv", | |
| 637 "conversation_id": "conv-stale-kv", | |
| 638 "prompt": "hello", | |
| 639 "prompt_profile": "public_visitor", | |
| 640 "prompt_version": 1, | |
| 641 "knowledge_version": 999, | |
| 642 } | |
| 643 ) | |
| 644 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 645 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 646 self.assertEqual(len(client.create_calls), 0) | |
| 647 | |
| 648 async def test_bool_prompt_version_rejected_before_client_calls(self): | |
| 649 sidecar, client = await self.make_sidecar() | |
| 650 await sidecar.dispatch( | |
| 651 { | |
| 652 "command": "turn.start", | |
| 653 "request_id": "r-bool-pv", | |
| 654 "conversation_id": "conv-bool-pv", | |
| 655 "prompt": "hello", | |
| 656 "prompt_profile": "public_visitor", | |
| 657 "prompt_version": True, | |
| 658 "knowledge_version": 1, | |
| 659 } | |
| 660 ) | |
| 661 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 662 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 663 self.assertEqual(len(client.create_calls), 0) | |
| 664 | |
| 665 async def test_bool_knowledge_version_rejected_before_client_calls(self): | |
| 666 sidecar, client = await self.make_sidecar() | |
| 667 await sidecar.dispatch( | |
| 668 { | |
| 669 "command": "turn.start", | |
| 670 "request_id": "r-bool-kv", | |
| 671 "conversation_id": "conv-bool-kv", | |
| 672 "prompt": "hello", | |
| 673 "prompt_profile": "public_visitor", | |
| 674 "prompt_version": 1, | |
| 675 "knowledge_version": True, | |
| 676 } | |
| 677 ) | |
| 678 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 679 self.assertEqual(errors[0]["error"]["code"], "invalid_prompt_profile") | |
| 680 self.assertEqual(len(client.create_calls), 0) | |
| 681 | |
| 682 async def test_same_profile_reuses_existing_session(self): | |
| 683 sidecar, client = await self.make_sidecar() | |
| 684 for i in range(3): | |
| 685 await sidecar.dispatch( | |
| 686 { | |
| 687 "command": "turn.start", | |
| 688 "request_id": f"r-reuse-{i}", | |
| 689 "conversation_id": "conv-reuse", | |
| 690 "prompt": f"message {i}", | |
| 691 "prompt_profile": "public_visitor", | |
| 692 "prompt_version": 1, | |
| 693 "knowledge_version": 1, | |
| 694 } | |
| 695 ) | |
| 696 await sidecar.drain_events() | |
| 697 | |
| 698 self.assertEqual(len(client.create_calls), 1) | |
| 699 self.assertEqual(len(client.resume_calls), 1) | |
| 700 session = client.sessions[_fake_sdk_id("conv-reuse", "public_visitor")] | |
| 701 self.assertEqual(session.prompts, ["message 0", "message 1", "message 2"]) | |
| 702 | |
| 703 async def test_profile_switch_disconnects_and_resumes_with_new_prompt(self): | |
| 704 sidecar, client = await self.make_sidecar() | |
| 705 | |
| 706 await sidecar.dispatch( | |
| 707 { | |
| 708 "command": "turn.start", | |
| 709 "request_id": "r-switch-1", | |
| 710 "conversation_id": "conv-switch", | |
| 711 "prompt": "first", | |
| 712 "prompt_profile": "public_visitor", | |
| 713 "prompt_version": 1, | |
| 714 "knowledge_version": 1, | |
| 715 } | |
| 716 ) | |
| 717 await sidecar.drain_events() | |
| 718 visitor_sdk_id = _fake_sdk_id("conv-switch", "public_visitor") | |
| 719 friend_sdk_id = _fake_sdk_id("conv-switch", "invited_friend") | |
| 720 first_session = client.sessions[visitor_sdk_id] | |
| 721 self.assertEqual(len(client.create_calls), 1) | |
| 722 | |
| 723 await sidecar.dispatch( | |
| 724 { | |
| 725 "command": "turn.start", | |
| 726 "request_id": "r-switch-2", | |
| 727 "conversation_id": "conv-switch", | |
| 728 "prompt": "second", | |
| 729 "prompt_profile": "invited_friend", | |
| 730 "prompt_version": 1, | |
| 731 "knowledge_version": 1, | |
| 732 } | |
| 733 ) | |
| 734 await sidecar.drain_events() | |
| 735 | |
| 736 # Old session is disconnected and permanently deleted before new one opens. | |
| 737 self.assertEqual(first_session.disconnect_calls, 1) | |
| 738 self.assertIn(visitor_sdk_id, client.delete_calls) | |
| 739 | |
| 740 # The new session uses the invited_friend derived ID, not the visitor one. | |
| 741 resume_id, resume_opts = client.resume_calls[-1] | |
| 742 self.assertEqual(resume_id, friend_sdk_id) | |
| 743 self.assertNotEqual(friend_sdk_id, visitor_sdk_id) | |
| 744 self.assertEqual(resume_opts["system_message"]["mode"], "append") | |
| 745 expected_content = _FAKE_PROFILES["invited_friend"]["content"] | |
| 746 self.assertEqual(resume_opts["system_message"]["content"], expected_content) | |
| 747 | |
| 748 # The newly opened session is a distinct object. | |
| 749 second_session = client.sessions[friend_sdk_id] | |
| 750 self.assertIsNot(second_session, first_session) | |
| 751 | |
| 752 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "r-switch-2"] | |
| 753 self.assertFalse(done[0].get("failed", False)) | |
| 754 | |
| 755 async def test_concurrent_conversations_profile_isolation(self): | |
| 756 sidecar, client = await self.make_sidecar() | |
| 757 await asyncio.gather( | |
| 758 sidecar.dispatch( | |
| 759 { | |
| 760 "command": "turn.start", | |
| 761 "request_id": "req-visitor", | |
| 762 "conversation_id": "conv-visitor", | |
| 763 "prompt": "hello visitor", | |
| 764 "prompt_profile": "public_visitor", | |
| 765 "prompt_version": 1, | |
| 766 "knowledge_version": 1, | |
| 767 } | |
| 768 ), | |
| 769 sidecar.dispatch( | |
| 770 { | |
| 771 "command": "turn.start", | |
| 772 "request_id": "req-admin", | |
| 773 "conversation_id": "conv-admin", | |
| 774 "prompt": "hello admin", | |
| 775 "prompt_profile": "june_admin", | |
| 776 "prompt_version": 1, | |
| 777 "knowledge_version": 1, | |
| 778 } | |
| 779 ), | |
| 780 ) | |
| 781 await sidecar.drain_events() | |
| 782 | |
| 783 visitor_sdk_id = _fake_sdk_id("conv-visitor", "public_visitor") | |
| 784 admin_sdk_id = _fake_sdk_id("conv-admin", "june_admin") | |
| 785 options_by_sdk_id = {sdk_id: opts for sdk_id, opts in client.create_calls} | |
| 786 visitor_content = options_by_sdk_id[visitor_sdk_id]["system_message"]["content"] | |
| 787 admin_content = options_by_sdk_id[admin_sdk_id]["system_message"]["content"] | |
| 788 self.assertNotEqual(visitor_content, admin_content) | |
| 789 self.assertEqual(visitor_content, _FAKE_PROFILES["public_visitor"]["content"]) | |
| 790 self.assertEqual(admin_content, _FAKE_PROFILES["june_admin"]["content"]) | |
| 791 | |
| 792 async def test_profile_switch_while_active_is_rejected(self): | |
| 793 sidecar, client = await self.make_sidecar("pending") | |
| 794 | |
| 795 await sidecar.dispatch( | |
| 796 { | |
| 797 "command": "turn.start", | |
| 798 "request_id": "active-req", | |
| 799 "conversation_id": "conv-active-switch", | |
| 800 "prompt": "wait", | |
| 801 "prompt_profile": "public_visitor", | |
| 802 "prompt_version": 1, | |
| 803 "knowledge_version": 1, | |
| 804 } | |
| 805 ) | |
| 806 | |
| 807 await sidecar.dispatch( | |
| 808 { | |
| 809 "command": "turn.start", | |
| 810 "request_id": "switch-req", | |
| 811 "conversation_id": "conv-active-switch", | |
| 812 "prompt": "switch", | |
| 813 "prompt_profile": "invited_friend", | |
| 814 "prompt_version": 1, | |
| 815 "knowledge_version": 1, | |
| 816 } | |
| 817 ) | |
| 818 | |
| 819 errors = [e for e in self.output if e["type"] == "turn.error" and e["request_id"] == "switch-req"] | |
| 820 self.assertTrue(len(errors) > 0) | |
| 821 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "switch-req"] | |
| 822 self.assertTrue(done[0]["failed"]) | |
| 823 # Active-turn switch must not trigger any cleanup at all. | |
| 824 sdk_id = _fake_sdk_id("conv-active-switch", "public_visitor") | |
| 825 self.assertEqual(client.sessions[sdk_id].disconnect_calls, 0) | |
| 826 self.assertEqual(client.delete_calls, []) | |
| 827 | |
| 828 async def test_startup_compilation_failure_prevents_readiness(self): | |
| 829 call_count = {"n": 0} | |
| 830 | |
| 831 def failing_compile_fn(profile: str) -> dict: | |
| 832 call_count["n"] += 1 | |
| 833 raise ValueError(f"corrupted assets for {profile!r}") | |
| 834 | |
| 835 client = FakeClient() | |
| 836 sidecar = Sidecar(client, self.config, self.capture, compile_fn=failing_compile_fn) | |
| 837 with self.assertRaises(ValueError) as ctx: | |
| 838 await sidecar.start() | |
| 839 self.assertIn("corrupted", str(ctx.exception)) | |
| 840 self.assertFalse(client.started) | |
| 841 self.assertGreater(call_count["n"], 0) | |
| 842 | |
| 843 # ------------------------------------------------------------------ | |
| 844 # Derived session ID security tests | |
| 845 # ------------------------------------------------------------------ | |
| 846 | |
| 847 async def test_distinct_profiles_produce_distinct_sdk_session_ids(self): | |
| 848 """Different profiles on the same conversation must never share an SDK ID.""" | |
| 849 ids = { | |
| 850 profile: _fake_sdk_id("conv-same", profile) | |
| 851 for profile in ("public_visitor", "invited_friend", "june_admin") | |
| 852 } | |
| 853 self.assertEqual(len(set(ids.values())), 3, "each profile needs a unique SDK ID") | |
| 854 for sid in ids.values(): | |
| 855 uuid.UUID(sid) # every value must be a valid UUID | |
| 856 | |
| 857 async def test_same_profile_produces_same_sdk_id_across_restarts(self): | |
| 858 """The derived ID is deterministic: a sidecar restart resumes the same session.""" | |
| 859 client = FakeClient() | |
| 860 sidecar1 = Sidecar(client, self.config, self.capture, compile_fn=_fake_compile_fn) | |
| 861 await sidecar1.start() | |
| 862 await sidecar1.dispatch({ | |
| 863 "command": "turn.start", | |
| 864 "request_id": "restart-1", | |
| 865 "conversation_id": "conv-restart", | |
| 866 "prompt": "hello", | |
| 867 "prompt_profile": "public_visitor", | |
| 868 "prompt_version": 1, | |
| 869 "knowledge_version": 1, | |
| 870 }) | |
| 871 await sidecar1.drain_events() | |
| 872 | |
| 873 # Simulate sidecar restart: new Sidecar instance, same FakeClient (SDK store). | |
| 874 sidecar2 = Sidecar(client, self.config, self.capture, compile_fn=_fake_compile_fn) | |
| 875 await sidecar2.start() | |
| 876 await sidecar2.dispatch({ | |
| 877 "command": "turn.start", | |
| 878 "request_id": "restart-2", | |
| 879 "conversation_id": "conv-restart", | |
| 880 "prompt": "still here", | |
| 881 "prompt_profile": "public_visitor", | |
| 882 "prompt_version": 1, | |
| 883 "knowledge_version": 1, | |
| 884 }) | |
| 885 await sidecar2.drain_events() | |
| 886 | |
| 887 # Sidecar1 tries resume (fails - no session yet) then creates. | |
| 888 # Sidecar2 tries resume (succeeds - session persists in SDK store). | |
| 889 # Therefore exactly one create, two resume attempts, both on the same derived ID. | |
| 890 self.assertEqual(len(client.create_calls), 1) | |
| 891 self.assertEqual(len(client.resume_calls), 2) | |
| 892 derived = _fake_sdk_id("conv-restart", "public_visitor") | |
| 893 self.assertEqual(client.create_calls[0][0], derived) | |
| 894 self.assertTrue(all(r == derived for r, _ in client.resume_calls)) | |
| 895 | |
| 896 async def test_profile_switch_permanently_deletes_old_sdk_session(self): | |
| 897 """Switching profile must delete the old derived SDK session before creating new.""" | |
| 898 sidecar, client = await self.make_sidecar() | |
| 899 await sidecar.dispatch({ | |
| 900 "command": "turn.start", | |
| 901 "request_id": "admin-turn", | |
| 902 "conversation_id": "conv-priv", | |
| 903 "prompt": "admin question", | |
| 904 "prompt_profile": "june_admin", | |
| 905 "prompt_version": 1, | |
| 906 "knowledge_version": 1, | |
| 907 }) | |
| 908 await sidecar.drain_events() | |
| 909 | |
| 910 admin_sdk_id = _fake_sdk_id("conv-priv", "june_admin") | |
| 911 visitor_sdk_id = _fake_sdk_id("conv-priv", "public_visitor") | |
| 912 self.assertNotEqual(admin_sdk_id, visitor_sdk_id) | |
| 913 | |
| 914 await sidecar.dispatch({ | |
| 915 "command": "turn.start", | |
| 916 "request_id": "visitor-turn", | |
| 917 "conversation_id": "conv-priv", | |
| 918 "prompt": "public question", | |
| 919 "prompt_profile": "public_visitor", | |
| 920 "prompt_version": 1, | |
| 921 "knowledge_version": 1, | |
| 922 }) | |
| 923 await sidecar.drain_events() | |
| 924 | |
| 925 # Admin session must be permanently deleted before visitor session opens. | |
| 926 self.assertIn(admin_sdk_id, client.delete_calls) | |
| 927 # Admin session must not be in the live sessions map. | |
| 928 self.assertNotIn(admin_sdk_id, client.sessions) | |
| 929 # Visitor session is a distinct object. | |
| 930 self.assertIn(visitor_sdk_id, client.sessions) | |
| 931 | |
| 932 async def test_delete_conversation_uses_derived_sdk_id_not_conversation_id(self): | |
| 933 """conversation.delete must call delete_session with the derived UUID.""" | |
| 934 sidecar, client = await self.make_sidecar() | |
| 935 await sidecar.dispatch({ | |
| 936 "command": "turn.start", | |
| 937 "request_id": "setup-turn", | |
| 938 "conversation_id": "conv-del-check", | |
| 939 "prompt": "hi", | |
| 940 "prompt_profile": "june_admin", | |
| 941 "prompt_version": 1, | |
| 942 "knowledge_version": 1, | |
| 943 }) | |
| 944 await sidecar.drain_events() | |
| 945 | |
| 946 admin_sdk_id = _fake_sdk_id("conv-del-check", "june_admin") | |
| 947 | |
| 948 await sidecar.dispatch({ | |
| 949 "command": "conversation.delete", | |
| 950 "request_id": "del-req", | |
| 951 "conversation_id": "conv-del-check", | |
| 952 }) | |
| 953 | |
| 954 self.assertEqual(client.delete_calls[0], admin_sdk_id) | |
| 955 self.assertEqual( | |
| 956 set(client.delete_calls), | |
| 957 { | |
| 958 _fake_sdk_id("conv-del-check", "public_visitor"), | |
| 959 _fake_sdk_id("conv-del-check", "invited_friend"), | |
| 960 _fake_sdk_id("conv-del-check", "june_admin"), | |
| 961 }, | |
| 962 ) | |
| 963 self.assertNotIn("conv-del-check", client.delete_calls) | |
| 964 | |
| 965 async def test_delete_nonexistent_conversation_cleans_known_profile_ids(self): | |
| 966 """An uncached delete purges every currently known derived session ID.""" | |
| 967 sidecar, client = await self.make_sidecar() | |
| 968 await sidecar.dispatch({ | |
| 969 "command": "conversation.delete", | |
| 970 "request_id": "del-ghost", | |
| 971 "conversation_id": "ghost-conv", | |
| 972 }) | |
| 973 | |
| 974 self.assertEqual( | |
| 975 set(client.delete_calls), | |
| 976 { | |
| 977 _fake_sdk_id("ghost-conv", "public_visitor"), | |
| 978 _fake_sdk_id("ghost-conv", "invited_friend"), | |
| 979 _fake_sdk_id("ghost-conv", "june_admin"), | |
| 980 }, | |
| 981 ) | |
| 982 self.assertNotIn("ghost-conv", client.delete_calls) | |
| 983 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "del-ghost"] | |
| 984 self.assertEqual(done[0]["action"], "conversation.delete") | |
| 985 | |
| 986 async def test_admin_session_not_reachable_via_visitor_derived_id(self): | |
| 987 """A visitor SDK ID must differ from the admin one for the same conversation.""" | |
| 988 admin_id = _fake_sdk_id("shared-conv", "june_admin") | |
| 989 visitor_id = _fake_sdk_id("shared-conv", "public_visitor") | |
| 990 self.assertNotEqual(admin_id, visitor_id) | |
| 991 | |
| 992 sidecar, client = await self.make_sidecar() | |
| 993 # Establish an admin session. | |
| 994 await sidecar.dispatch({ | |
| 995 "command": "turn.start", | |
| 996 "request_id": "admin-req", | |
| 997 "conversation_id": "shared-conv", | |
| 998 "prompt": "secret", | |
| 999 "prompt_profile": "june_admin", | |
| 1000 "prompt_version": 1, | |
| 1001 "knowledge_version": 1, | |
| 1002 }) | |
| 1003 await sidecar.drain_events() | |
| 1004 | |
| 1005 # The admin session object must NOT be accessible under the visitor-derived ID. | |
| 1006 admin_session = client.sessions.get(admin_id) | |
| 1007 self.assertIsNotNone(admin_session) | |
| 1008 self.assertIsNone(client.sessions.get(visitor_id), | |
| 1009 "visitor SDK ID must not map to any session object at this point") | |
| 1010 | |
| 1011 | |
| 1012 # --------------------------------------------------------------------------- | |
| 1013 # _validate_history unit tests | |
| 1014 # --------------------------------------------------------------------------- | |
| 1015 | |
| 1016 class ValidateHistoryTest(unittest.TestCase): | |
| 1017 def test_none_returns_empty_list(self): | |
| 1018 self.assertEqual(_validate_history(None), []) | |
| 1019 | |
| 1020 def test_empty_list_accepted(self): | |
| 1021 self.assertEqual(_validate_history([]), []) | |
| 1022 | |
| 1023 def test_valid_two_entries(self): | |
| 1024 hist = [ | |
| 1025 {"role": "user", "content": "hello"}, | |
| 1026 {"role": "assistant", "content": "hi"}, | |
| 1027 ] | |
| 1028 result = _validate_history(hist) | |
| 1029 self.assertEqual(result, hist) | |
| 1030 | |
| 1031 def test_not_a_list_raises(self): | |
| 1032 for bad in (42, "string", True, False, {}, object()): | |
| 1033 with self.assertRaises(ValueError, msg=f"should reject {bad!r}"): | |
| 1034 _validate_history(bad) | |
| 1035 | |
| 1036 def test_too_many_entries_raises(self): | |
| 1037 entries = [{"role": "user", "content": "x"}] * (_HISTORY_MAX_ENTRIES + 1) | |
| 1038 with self.assertRaises(ValueError): | |
| 1039 _validate_history(entries) | |
| 1040 | |
| 1041 def test_exactly_max_entries_accepted(self): | |
| 1042 entries = [ | |
| 1043 {"role": "user" if i % 2 == 0 else "assistant", "content": "x"} | |
| 1044 for i in range(_HISTORY_MAX_ENTRIES) | |
| 1045 ] | |
| 1046 result = _validate_history(entries) | |
| 1047 self.assertEqual(len(result), _HISTORY_MAX_ENTRIES) | |
| 1048 | |
| 1049 def test_non_object_entry_raises(self): | |
| 1050 for bad_entry in (42, "string", True, None, []): | |
| 1051 with self.assertRaises(ValueError): | |
| 1052 _validate_history([bad_entry]) | |
| 1053 | |
| 1054 def test_invalid_role_raises(self): | |
| 1055 for bad_role in ("system", "SYSTEM", "User", "ASSISTANT", "", " user"): | |
| 1056 with self.assertRaises(ValueError, msg=f"role {bad_role!r} must be rejected"): | |
| 1057 _validate_history([{"role": bad_role, "content": "x"}]) | |
| 1058 | |
| 1059 def test_bool_role_raises(self): | |
| 1060 with self.assertRaises(ValueError): | |
| 1061 _validate_history([{"role": True, "content": "x"}]) | |
| 1062 | |
| 1063 def test_none_content_raises(self): | |
| 1064 with self.assertRaises(ValueError): | |
| 1065 _validate_history([{"role": "user", "content": None}]) | |
| 1066 | |
| 1067 def test_bool_content_raises(self): | |
| 1068 with self.assertRaises(ValueError): | |
| 1069 _validate_history([{"role": "user", "content": True}]) | |
| 1070 | |
| 1071 def test_int_content_raises(self): | |
| 1072 with self.assertRaises(ValueError): | |
| 1073 _validate_history([{"role": "user", "content": 42}]) | |
| 1074 | |
| 1075 def test_extra_key_raises(self): | |
| 1076 with self.assertRaises(ValueError): | |
| 1077 _validate_history([{"role": "user", "content": "hi", "injected": "bad"}]) | |
| 1078 | |
| 1079 def test_oversized_total_raises(self): | |
| 1080 # One entry with content just over the byte limit. | |
| 1081 big = "x" * (_HISTORY_MAX_BYTES + 1) | |
| 1082 with self.assertRaises(ValueError): | |
| 1083 _validate_history([{"role": "user", "content": big}]) | |
| 1084 | |
| 1085 def test_total_at_limit_accepted(self): | |
| 1086 # Two entries whose combined bytes sit at or under the limit. | |
| 1087 chunk_size = _HISTORY_MAX_BYTES // 2 - 10 # under limit | |
| 1088 entries = [ | |
| 1089 {"role": "user", "content": "a" * chunk_size}, | |
| 1090 {"role": "assistant", "content": "b" * chunk_size}, | |
| 1091 ] | |
| 1092 result = _validate_history(entries) | |
| 1093 self.assertEqual(len(result), 2) | |
| 1094 | |
| 1095 def test_special_characters_accepted(self): | |
| 1096 entry = {"role": "user", "content": "hello \"world\" \\ \n"} | |
| 1097 result = _validate_history([entry]) | |
| 1098 self.assertEqual(result[0]["content"], entry["content"]) | |
| 1099 | |
| 1100 def test_empty_content_string_accepted(self): | |
| 1101 result = _validate_history([{"role": "user", "content": ""}]) | |
| 1102 self.assertEqual(result[0]["content"], "") | |
| 1103 | |
| 1104 | |
| 1105 # --------------------------------------------------------------------------- | |
| 1106 # Sidecar history integration tests | |
| 1107 # --------------------------------------------------------------------------- | |
| 1108 | |
| 1109 class SidecarHistoryTest(unittest.IsolatedAsyncioTestCase): | |
| 1110 async def asyncSetUp(self): | |
| 1111 self.output = [] | |
| 1112 | |
| 1113 async def capture(payload): | |
| 1114 self.output.append(payload) | |
| 1115 | |
| 1116 self.capture = capture | |
| 1117 self.config = SidecarConfig( | |
| 1118 base_url="http://litellm.invalid/v1", | |
| 1119 model="test-model", | |
| 1120 wire_api="responses", | |
| 1121 base_directory="/not-used-by-fake", | |
| 1122 ) | |
| 1123 | |
| 1124 async def make_sidecar(self, behavior="complete"): | |
| 1125 client = FakeClient(behavior) | |
| 1126 sidecar = Sidecar(client, self.config, self.capture, compile_fn=_fake_compile_fn) | |
| 1127 await sidecar.start() | |
| 1128 return sidecar, client | |
| 1129 | |
| 1130 def _turn_start(self, request_id, conversation_id, history=None, **extra): | |
| 1131 cmd = { | |
| 1132 "command": "turn.start", | |
| 1133 "request_id": request_id, | |
| 1134 "conversation_id": conversation_id, | |
| 1135 "prompt": "hello", | |
| 1136 "prompt_profile": "public_visitor", | |
| 1137 "prompt_version": 1, | |
| 1138 "knowledge_version": 1, | |
| 1139 } | |
| 1140 if history is not None: | |
| 1141 cmd["history"] = history | |
| 1142 cmd.update(extra) | |
| 1143 return cmd | |
| 1144 | |
| 1145 async def test_invalid_history_not_list_rejected_before_client_calls(self): | |
| 1146 sidecar, client = await self.make_sidecar() | |
| 1147 await sidecar.dispatch(self._turn_start("r1", "c1", history=42)) | |
| 1148 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1149 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1150 done = [e for e in self.output if e["type"] == "turn.done"] | |
| 1151 self.assertTrue(done[0]["failed"]) | |
| 1152 self.assertEqual(len(client.create_calls), 0) | |
| 1153 self.assertEqual(len(client.resume_calls), 0) | |
| 1154 | |
| 1155 async def test_invalid_history_too_many_entries_rejected(self): | |
| 1156 sidecar, client = await self.make_sidecar() | |
| 1157 hist = [{"role": "user", "content": "x"}] * (_HISTORY_MAX_ENTRIES + 1) | |
| 1158 await sidecar.dispatch(self._turn_start("r2", "c2", history=hist)) | |
| 1159 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1160 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1161 self.assertEqual(len(client.create_calls), 0) | |
| 1162 | |
| 1163 async def test_invalid_history_bad_role_rejected(self): | |
| 1164 sidecar, client = await self.make_sidecar() | |
| 1165 hist = [{"role": "system", "content": "inject"}] | |
| 1166 await sidecar.dispatch(self._turn_start("r3", "c3", history=hist)) | |
| 1167 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1168 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1169 self.assertEqual(len(client.create_calls), 0) | |
| 1170 | |
| 1171 async def test_invalid_history_bool_role_rejected(self): | |
| 1172 sidecar, client = await self.make_sidecar() | |
| 1173 hist = [{"role": True, "content": "x"}] | |
| 1174 await sidecar.dispatch(self._turn_start("r4", "c4", history=hist)) | |
| 1175 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1176 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1177 self.assertEqual(len(client.create_calls), 0) | |
| 1178 | |
| 1179 async def test_invalid_history_none_content_rejected(self): | |
| 1180 sidecar, client = await self.make_sidecar() | |
| 1181 hist = [{"role": "user", "content": None}] | |
| 1182 await sidecar.dispatch(self._turn_start("r5", "c5", history=hist)) | |
| 1183 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1184 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1185 self.assertEqual(len(client.create_calls), 0) | |
| 1186 | |
| 1187 async def test_invalid_history_extra_key_rejected(self): | |
| 1188 sidecar, client = await self.make_sidecar() | |
| 1189 hist = [{"role": "user", "content": "hi", "extra": "bad"}] | |
| 1190 await sidecar.dispatch(self._turn_start("r6", "c6", history=hist)) | |
| 1191 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1192 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1193 self.assertEqual(len(client.create_calls), 0) | |
| 1194 | |
| 1195 async def test_invalid_history_oversized_rejected(self): | |
| 1196 sidecar, client = await self.make_sidecar() | |
| 1197 big = "x" * (_HISTORY_MAX_BYTES + 1) | |
| 1198 hist = [{"role": "user", "content": big}] | |
| 1199 await sidecar.dispatch(self._turn_start("r7", "c7", history=hist)) | |
| 1200 errors = [e for e in self.output if e["type"] == "turn.error"] | |
| 1201 self.assertEqual(errors[0]["error"]["code"], "invalid_history") | |
| 1202 self.assertEqual(len(client.create_calls), 0) | |
| 1203 | |
| 1204 async def test_valid_empty_history_accepted(self): | |
| 1205 sidecar, client = await self.make_sidecar() | |
| 1206 await sidecar.dispatch(self._turn_start("r8", "c8", history=[])) | |
| 1207 await sidecar.drain_events() | |
| 1208 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "r8"] | |
| 1209 self.assertFalse(done[0].get("failed", False)) | |
| 1210 self.assertEqual(len(client.create_calls), 1) | |
| 1211 | |
| 1212 async def test_fresh_create_receives_history_in_system_message(self): | |
| 1213 """When resume_session fails (no persisted session), create_session must | |
| 1214 include the PRIOR OWNED CONVERSATION TRANSCRIPT in system_message.""" | |
| 1215 sidecar, client = await self.make_sidecar() | |
| 1216 hist = [ | |
| 1217 {"role": "user", "content": "prior question"}, | |
| 1218 {"role": "assistant", "content": "prior answer"}, | |
| 1219 ] | |
| 1220 await sidecar.dispatch(self._turn_start("r-create", "c-create", history=hist)) | |
| 1221 await sidecar.drain_events() | |
| 1222 | |
| 1223 self.assertEqual(len(client.create_calls), 1) | |
| 1224 _, create_opts = client.create_calls[0] | |
| 1225 sys_content = create_opts["system_message"]["content"] | |
| 1226 self.assertIn("PRIOR OWNED CONVERSATION TRANSCRIPT", sys_content) | |
| 1227 self.assertIn("prior question", sys_content) | |
| 1228 self.assertIn("prior answer", sys_content) | |
| 1229 self.assertIn("untrusted", sys_content.lower()) | |
| 1230 self.assertEqual(create_opts["system_message"]["mode"], "append") | |
| 1231 | |
| 1232 async def test_resume_does_not_inject_history(self): | |
| 1233 """An existing persisted session must not receive history in system_message.""" | |
| 1234 sidecar, client = await self.make_sidecar() | |
| 1235 hist = [{"role": "user", "content": "prior"}] | |
| 1236 | |
| 1237 # First turn: creates the session. | |
| 1238 await sidecar.dispatch(self._turn_start("r-resume-1", "c-resume", history=hist)) | |
| 1239 await sidecar.drain_events() | |
| 1240 self.assertEqual(len(client.create_calls), 1) | |
| 1241 first_content = client.create_calls[0][1]["system_message"]["content"] | |
| 1242 | |
| 1243 # Second turn on same conversation: must resume (SDK session persists). | |
| 1244 await sidecar.dispatch(self._turn_start("r-resume-2", "c-resume", history=hist)) | |
| 1245 await sidecar.drain_events() | |
| 1246 self.assertEqual(len(client.resume_calls), 1) | |
| 1247 # resume_session does not receive options from create_session call. | |
| 1248 resume_opts = client.resume_calls[0][1] | |
| 1249 resume_sys = resume_opts["system_message"]["content"] | |
| 1250 # The resume options (base options) must not contain the transcript block. | |
| 1251 self.assertNotIn("PRIOR OWNED CONVERSATION TRANSCRIPT", resume_sys) | |
| 1252 # The create call had the transcript; verify only one create happened. | |
| 1253 self.assertEqual(len(client.create_calls), 1) | |
| 1254 | |
| 1255 async def test_history_with_no_field_uses_base_system_message_on_create(self): | |
| 1256 """Absent history field: create_session uses base system_message without transcript.""" | |
| 1257 sidecar, client = await self.make_sidecar() | |
| 1258 await sidecar.dispatch(self._turn_start("r-nofield", "c-nofield")) | |
| 1259 await sidecar.drain_events() | |
| 1260 self.assertEqual(len(client.create_calls), 1) | |
| 1261 _, opts = client.create_calls[0] | |
| 1262 self.assertNotIn("PRIOR OWNED CONVERSATION TRANSCRIPT", opts["system_message"]["content"]) | |
| 1263 | |
| 1264 async def test_profile_switch_create_fallback_gets_history(self): | |
| 1265 """After a profile switch the new derived session is a fresh create; | |
| 1266 the history must be injected into the new session's system_message only.""" | |
| 1267 sidecar, client = await self.make_sidecar() | |
| 1268 | |
| 1269 # First turn: public_visitor session created; no prior history yet. | |
| 1270 await sidecar.dispatch(self._turn_start("r-sw-1", "c-switch")) | |
| 1271 await sidecar.drain_events() | |
| 1272 visitor_sdk_id = _fake_sdk_id("c-switch", "public_visitor") | |
| 1273 friend_sdk_id = _fake_sdk_id("c-switch", "invited_friend") | |
| 1274 self.assertNotEqual(visitor_sdk_id, friend_sdk_id) | |
| 1275 first_create_content = client.create_calls[0][1]["system_message"]["content"] | |
| 1276 # No transcript on initial create (no history provided). | |
| 1277 self.assertNotIn("PRIOR OWNED CONVERSATION TRANSCRIPT", first_create_content) | |
| 1278 | |
| 1279 # Second turn: invited_friend profile — old session deleted, new fresh create. | |
| 1280 # Now we send history representing the prior visitor exchange. | |
| 1281 friend_hist = [ | |
| 1282 {"role": "user", "content": "visitor msg"}, | |
| 1283 {"role": "assistant", "content": "answer"}, | |
| 1284 ] | |
| 1285 await sidecar.dispatch({ | |
| 1286 "command": "turn.start", | |
| 1287 "request_id": "r-sw-2", | |
| 1288 "conversation_id": "c-switch", | |
| 1289 "prompt": "switch question", | |
| 1290 "prompt_profile": "invited_friend", | |
| 1291 "prompt_version": 1, | |
| 1292 "knowledge_version": 1, | |
| 1293 "history": friend_hist, | |
| 1294 }) | |
| 1295 await sidecar.drain_events() | |
| 1296 | |
| 1297 # Old visitor session must be deleted. | |
| 1298 self.assertIn(visitor_sdk_id, client.delete_calls) | |
| 1299 # New session created under invited_friend derived ID. | |
| 1300 create_ids = [sid for sid, _ in client.create_calls] | |
| 1301 self.assertIn(friend_sdk_id, create_ids) | |
| 1302 | |
| 1303 # Second create must include the history transcript. | |
| 1304 second_create_opts = dict(client.create_calls)[friend_sdk_id] | |
| 1305 second_content = second_create_opts["system_message"]["content"] | |
| 1306 self.assertIn("PRIOR OWNED CONVERSATION TRANSCRIPT", second_content) | |
| 1307 self.assertIn("visitor msg", second_content) | |
| 1308 self.assertIn("answer", second_content) | |
| 1309 self.assertIn("untrusted", second_content.lower()) | |
| 1310 | |
| 1311 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "r-sw-2"] | |
| 1312 self.assertFalse(done[0].get("failed", False)) | |
| 1313 | |
| 1314 async def test_history_not_exposed_in_events(self): | |
| 1315 """History must not appear in any turn.accepted, assistant.delta, or | |
| 1316 turn.done events emitted to the client.""" | |
| 1317 sidecar, client = await self.make_sidecar() | |
| 1318 sensitive = "SENSITIVE_TRANSCRIPT_DATA_XYZ" | |
| 1319 hist = [{"role": "user", "content": sensitive}] | |
| 1320 await sidecar.dispatch(self._turn_start("r-safe", "c-safe", history=hist)) | |
| 1321 await sidecar.drain_events() | |
| 1322 | |
| 1323 for ev in self.output: | |
| 1324 for field in ("delta", "content", "prompt"): | |
| 1325 val = ev.get(field, "") | |
| 1326 if isinstance(val, str): | |
| 1327 self.assertNotIn(sensitive, val, | |
| 1328 f"history leaked into event[{field}]: {ev}") | |
| 1329 | |
| 1330 async def test_existing_conversation_history_compatibility(self): | |
| 1331 """Conversations without history (pre-change) work normally: no | |
| 1332 transcript block is injected when history is absent/empty.""" | |
| 1333 sidecar, client = await self.make_sidecar() | |
| 1334 # Simulate an old-style command with no history key. | |
| 1335 await sidecar.dispatch({ | |
| 1336 "command": "turn.start", | |
| 1337 "request_id": "r-compat", | |
| 1338 "conversation_id": "c-compat", | |
| 1339 "prompt": "legacy", | |
| 1340 "prompt_profile": "june_admin", | |
| 1341 "prompt_version": 1, | |
| 1342 "knowledge_version": 1, | |
| 1343 }) | |
| 1344 await sidecar.drain_events() | |
| 1345 done = [e for e in self.output if e["type"] == "turn.done" and e["request_id"] == "r-compat"] | |
| 1346 self.assertFalse(done[0].get("failed", False)) | |
| 1347 _, opts = client.create_calls[0] | |
| 1348 self.assertNotIn("PRIOR OWNED CONVERSATION TRANSCRIPT", opts["system_message"]["content"]) | |
| 1349 | |
| 445 | 1350 |
| 446 if __name__ == "__main__": | 1351 if __name__ == "__main__": |
| 447 unittest.main() | 1352 unittest.main() |