create-workframe 0.1.18 → 0.1.20

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.
Files changed (133) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/workframe-api/action_proxy.py +8 -0
  5. package/workframe-api/chat_sessions.py +37 -6
  6. package/workframe-api/chat_stream.py +16 -0
  7. package/workframe-api/db_schema.py +29 -0
  8. package/workframe-api/handler_modules/handler_admin.py +32 -0
  9. package/workframe-api/handler_modules/handler_auth.py +6 -4
  10. package/workframe-api/handler_modules/handler_chat.py +17 -26
  11. package/workframe-api/handler_modules/handler_provider.py +13 -14
  12. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  13. package/workframe-api/hermes_profiles.py +31 -9
  14. package/workframe-api/llm_proxy.py +18 -0
  15. package/workframe-api/mention_invoke.py +29 -24
  16. package/workframe-api/message_reactions.py +139 -0
  17. package/workframe-api/model_surface.py +329 -113
  18. package/workframe-api/package.json +4 -4
  19. package/workframe-api/profile_gateway.py +14 -6
  20. package/workframe-api/provider_bindings.py +118 -17
  21. package/workframe-api/provider_bootstrap.py +74 -38
  22. package/workframe-api/provider_model_catalog.py +261 -0
  23. package/workframe-api/rooms.py +4 -3
  24. package/workframe-api/route_registry.py +4 -0
  25. package/workframe-api/runtime_cohort.py +118 -23
  26. package/workframe-api/scripts/run-python.mjs +28 -0
  27. package/workframe-api/server.py +44 -12
  28. package/workframe-api/test_agent_identity_authority.py +96 -0
  29. package/workframe-api/test_chat_attribution_history.py +59 -0
  30. package/workframe-api/test_enrich_room_messages.py +43 -2
  31. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  32. package/workframe-api/test_message_reactions.py +151 -0
  33. package/workframe-api/test_model_routing_reconcile.py +122 -0
  34. package/workframe-api/test_model_surface_consistency.py +45 -5
  35. package/workframe-api/test_oauth_llm.py +1 -1
  36. package/workframe-api/test_profile_gateway_health.py +37 -0
  37. package/workframe-api/test_provider_bindings.py +115 -1
  38. package/workframe-api/test_provider_model_catalog.py +27 -0
  39. package/workframe-api/test_provider_model_surface.py +2 -2
  40. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  41. package/workframe-api/test_server_reexports.py +2 -0
  42. package/workframe-api/test_user_llm_prefs.py +27 -0
  43. package/workframe-api/test_workspace_file_actions.py +119 -0
  44. package/workframe-api/turn_credentials.py +41 -0
  45. package/workframe-api/turn_overlay.py +95 -34
  46. package/workframe-api/user_prefs.py +15 -0
  47. package/workframe-api/workspace_bootstrap.py +1 -0
  48. package/workframe-api/workspace_files.py +124 -0
  49. package/workframe-ui/public/assets/{arc-CSddOtMa.js → arc-BkATcoqt.js} +1 -1
  50. package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
  51. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BSiUei3D.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
  52. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BhKTULPo.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
  53. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  54. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bv_xZi-a.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
  55. package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
  56. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bh0WiqFE.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
  57. package/workframe-ui/public/assets/{chunk-3OPIFGDE-qN09pRuK.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
  58. package/workframe-ui/public/assets/{chunk-4BX2VUAB-Dz6FVuMa.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-55IACEB6-DWtPHVfR.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-CyOUeDAo.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-727SXJPM-BrICvWwx.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CQu2SmLM.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-BSJP7CBP-uJbYXNNG.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CCS_FtVZ.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CoLUtPiK.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DLW4gvEW.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CMt0LJAd.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bn-maIlt.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
  69. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CG7yoApR.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-NZK2D7GU-B4dIvbii.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B5spDyXy.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
  72. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
  73. package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
  74. package/workframe-ui/public/assets/{chunk-XPW4576I-D5c0TMX6.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
  75. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
  76. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
  77. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DFRM3DZv.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
  78. package/workframe-ui/public/assets/{dagre-BM42HDAG-CnUdKeJa.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
  79. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ds3-e-hW.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
  80. package/workframe-ui/public/assets/{diagram-5GNKFQAL-B41zx8FF.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D83tI9I8.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-LMA3HP47-h_EuFyLI.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-OG6HWLK6-CpU-d3RZ.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
  84. package/workframe-ui/public/assets/{dist-_igeSJ9r.js → dist-C9bxMexz.js} +1 -1
  85. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-jLFoncRU.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
  86. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
  87. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C9IaNgCy.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
  88. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-A9FhfFc5.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
  89. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  90. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BqK_IjZb.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
  91. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CDVqxhyW.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
  92. package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
  93. package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
  94. package/workframe-ui/public/assets/{info-J43DQDTF-C2EKRglL.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
  95. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-azQtFzpU.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
  96. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BuDOLv1t.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
  97. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-ypTHFoT0.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
  98. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Cgr78J_x.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
  99. package/workframe-ui/public/assets/{line-CcwgaMdv.js → line-Bee1snmU.js} +1 -1
  100. package/workframe-ui/public/assets/{linear-CswIlWaW.js → linear-dfD-zlI7.js} +1 -1
  101. package/workframe-ui/public/assets/{mermaid-parser.core-CQYhP37X.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
  102. package/workframe-ui/public/assets/{mermaid.core-Cz1XNBaK.js → mermaid.core-CuuxYt2r.js} +3 -3
  103. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BJLu0829.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
  104. package/workframe-ui/public/assets/{packet-YPE3B663-AB1qh96V.js → packet-YPE3B663-CiXMDasj.js} +1 -1
  105. package/workframe-ui/public/assets/{pie-LRSECV5Y-CokIXLdT.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
  106. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyrwQn5F.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
  107. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-C_Fdg3F6.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
  108. package/workframe-ui/public/assets/{radar-GUYGQ44K-C21Xfrrg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
  109. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ChhaWgaF.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
  110. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-C5T4sDTt.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
  111. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DRks8i3A.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
  112. package/workframe-ui/public/assets/{src-BQu5waHd.js → src-DC521nE_.js} +1 -1
  113. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BI-AKYta.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
  114. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
  115. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DnAIWzEc.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
  116. package/workframe-ui/public/assets/{treeView-BLDUP644-BEF_sjx7.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
  117. package/workframe-ui/public/assets/{treemap-LRROVOQU-BcbL7g5c.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
  118. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DAoIbCkz.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
  119. package/workframe-ui/public/assets/{wardley-L42UT6IY-3SOmFBFw.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
  120. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DlQV_-LH.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
  121. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-CVoTTRHF.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
  122. package/workframe-ui/public/index.html +2 -2
  123. package/workframe-ui/public/workframe-build.json +3 -3
  124. package/workframe-ui/public/assets/architecture-7EHR7CIX-CDDNM1ni.js +0 -1
  125. package/workframe-ui/public/assets/channel-R52LrLO3.js +0 -1
  126. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_9MbT10.js +0 -1
  127. package/workframe-ui/public/assets/chunk-WU5MYG2G-VJadzJmf.js +0 -1
  128. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CpBjaI3g.js +0 -1
  129. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CpBjaI3g.js +0 -1
  130. package/workframe-ui/public/assets/eventmodeling-FCH6USID-kzOxPvtj.js +0 -1
  131. package/workframe-ui/public/assets/index-Bco9IDMA.css +0 -1
  132. package/workframe-ui/public/assets/index-Do34PZS2.js +0 -132
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-vJyhFZbx.js +0 -1
@@ -0,0 +1,96 @@
1
+ """Shared agent identity vs per-user runtime proxy regression checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+ from unittest.mock import patch
10
+
11
+ API_DIR = Path(__file__).resolve().parent
12
+ if str(API_DIR) not in sys.path:
13
+ sys.path.insert(0, str(API_DIR))
14
+
15
+ os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
16
+ os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
17
+ os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
18
+
19
+ import crew_registry # noqa: E402
20
+ import hermes_profiles # noqa: E402
21
+ import runtime_cohort # noqa: E402
22
+ import server # noqa: E402
23
+
24
+
25
+ def test_runtime_display_name_comes_from_shared_agent() -> None:
26
+ with (
27
+ patch.object(server, "_agent_db_display_name", return_value="Shared Architect"),
28
+ patch.object(server, "_agent_registry_row", return_value={"display_name": "Template Architect"}),
29
+ ):
30
+ assert (
31
+ runtime_cohort._runtime_display_label("bob-user", "architect", "workspace-1")
32
+ == "Shared Architect"
33
+ )
34
+
35
+
36
+ def test_native_display_name_comes_from_shared_agent() -> None:
37
+ with (
38
+ patch.object(server, "NATIVE_PROFILE", "workframe-agent"),
39
+ patch.object(hermes_profiles, "_agent_db_display_name", return_value="Shared Concierge"),
40
+ ):
41
+ assert hermes_profiles._native_display_name() == "Shared Concierge"
42
+
43
+
44
+ def test_runtime_identity_fields_ignore_runtime_registry_metadata() -> None:
45
+ def registry_row(slug: str) -> dict[str, str]:
46
+ if slug.startswith("u-"):
47
+ return {"display_name": "Bob's Clone", "tagline": "Runtime drift"}
48
+ return {"display_name": "Shared Agent", "tagline": "Workspace identity", "role": "Architect"}
49
+
50
+ with (
51
+ patch.object(crew_registry, "_agent_registry_row", side_effect=registry_row),
52
+ patch.object(crew_registry, "_workspace_agent_identities", return_value={}),
53
+ patch.object(server, "_profile_display_name", return_value="Fallback"),
54
+ patch.object(server, "_profile_role", return_value="Fallback role"),
55
+ patch.object(server, "_resolve_avatar_fields", side_effect=lambda row: row),
56
+ ):
57
+ identity = crew_registry._agent_identity_fields(
58
+ "u-bob-user-architect",
59
+ "workspace-1",
60
+ "bob-user",
61
+ )
62
+ assert identity["display_name"] == "Shared Agent"
63
+ assert identity["tagline"] == "Workspace identity"
64
+ assert identity["role"] == "Architect"
65
+
66
+
67
+ def test_runtime_identity_sync_preserves_credentials() -> None:
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ root = Path(tmp)
70
+ template_dir = root / "architect"
71
+ runtime_dir = root / "u-bob-architect"
72
+ template_dir.mkdir()
73
+ runtime_dir.mkdir()
74
+ (template_dir / "SOUL.md").write_text("shared soul\n", encoding="utf-8")
75
+ (template_dir / "AGENTS.md").write_text("shared agents\n", encoding="utf-8")
76
+ (runtime_dir / "SOUL.md").write_text("stale runtime soul\n", encoding="utf-8")
77
+ (runtime_dir / "AGENTS.md").write_text("stale runtime agents\n", encoding="utf-8")
78
+ (runtime_dir / ".env").write_text("OPENROUTER_API_KEY=user-secret\n", encoding="utf-8")
79
+
80
+ with (
81
+ patch.object(server, "_profile_dir", side_effect=lambda slug: root / slug),
82
+ patch.object(runtime_cohort, "_ensure_profile_terminal_cwd", return_value=None),
83
+ ):
84
+ runtime_cohort._backfill_runtime_identity("u-bob-architect", "architect")
85
+
86
+ assert (runtime_dir / "SOUL.md").read_text(encoding="utf-8") == "shared soul\n"
87
+ assert (runtime_dir / "AGENTS.md").read_text(encoding="utf-8") == "shared agents\n"
88
+ assert (runtime_dir / ".env").read_text(encoding="utf-8") == "OPENROUTER_API_KEY=user-secret\n"
89
+
90
+
91
+ if __name__ == "__main__":
92
+ test_runtime_display_name_comes_from_shared_agent()
93
+ test_native_display_name_comes_from_shared_agent()
94
+ test_runtime_identity_fields_ignore_runtime_registry_metadata()
95
+ test_runtime_identity_sync_preserves_credentials()
96
+ print("test_agent_identity_authority: ok")
@@ -0,0 +1,59 @@
1
+ """Historical chat badges use completed run evidence, not the current model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ import chat_sessions
8
+ import server
9
+
10
+
11
+ def test_session_run_attributions_preserve_model_switch_history(tmp_path, monkeypatch) -> None:
12
+ db_path = tmp_path / "workframe.db"
13
+ conn = sqlite3.connect(db_path)
14
+ conn.executescript(
15
+ """
16
+ CREATE TABLE runs (
17
+ run_id TEXT PRIMARY KEY,
18
+ session_id TEXT,
19
+ profile_slug TEXT,
20
+ status TEXT,
21
+ created_at TEXT
22
+ );
23
+ CREATE TABLE run_line_items (
24
+ line_item_id TEXT PRIMARY KEY,
25
+ run_id TEXT,
26
+ kind TEXT,
27
+ model TEXT,
28
+ provider TEXT,
29
+ created_at TEXT
30
+ );
31
+ """,
32
+ )
33
+ conn.executemany(
34
+ "INSERT INTO runs VALUES (?,?,?,?,?)",
35
+ [
36
+ ("r1", "session-1", "u-user-agent", "completed", "2026-01-01T00:00:01Z"),
37
+ ("r2", "session-1", "u-user-agent", "completed", "2026-01-01T00:00:02Z"),
38
+ ],
39
+ )
40
+ conn.executemany(
41
+ "INSERT INTO run_line_items VALUES (?,?,?,?,?,?)",
42
+ [
43
+ ("l1", "r1", "llm_turn", "openrouter/auto", "openrouter", "2026-01-01T00:00:01Z"),
44
+ ("l2", "r2", "llm_turn", "google/gemini-2.5-flash", "openrouter", "2026-01-01T00:00:02Z"),
45
+ ],
46
+ )
47
+ conn.commit()
48
+ conn.close()
49
+
50
+ def _connect():
51
+ db = sqlite3.connect(db_path)
52
+ db.row_factory = sqlite3.Row
53
+ return db
54
+
55
+ monkeypatch.setattr(server, "_workframe_db", _connect)
56
+ assert chat_sessions._session_run_attributions("u-user-agent", "session-1") == [
57
+ ("openrouter/auto", "openrouter"),
58
+ ("google/gemini-2.5-flash", "openrouter"),
59
+ ]
@@ -19,7 +19,7 @@ os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
19
19
  import server # noqa: E402
20
20
 
21
21
 
22
- def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
22
+ def test_enrich_room_messages_preserve_turn_attribution(tmp_path: Path) -> None:
23
23
  db = tmp_path / "workframe.db"
24
24
  conn = sqlite3.connect(db)
25
25
  conn.row_factory = sqlite3.Row
@@ -58,7 +58,8 @@ def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
58
58
  content, content_type, metadata, is_edited, created_at, updated_at, deleted_at
59
59
  ) VALUES (
60
60
  '{msg_id}', '{room_id}', NULL, '{agent_id}', NULL,
61
- 'Hello!', 'text', '{{}}', 0, '{now}', '{now}', NULL
61
+ 'Hello!', 'text', '{{"model":"google/gemini-2.5-flash","llm_provider":"openrouter"}}',
62
+ 0, '{now}', '{now}', NULL
62
63
  );
63
64
  """
64
65
  )
@@ -70,3 +71,43 @@ def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
70
71
  assert enriched[0]["content"] == "Hello!"
71
72
  assert enriched[0]["sender_agent_slug"] == "test-agent"
72
73
  assert enriched[0]["sender_agent_name"] == "Test Agent"
74
+ assert enriched[0]["model"] == "google/gemini-2.5-flash"
75
+ assert enriched[0]["llm_provider"] == "openrouter"
76
+
77
+
78
+ def test_enrich_room_messages_does_not_relabel_legacy_message(tmp_path: Path) -> None:
79
+ db = tmp_path / "workframe.db"
80
+ conn = sqlite3.connect(db)
81
+ conn.row_factory = sqlite3.Row
82
+ conn.executescript(
83
+ """
84
+ CREATE TABLE agent_profiles (
85
+ id TEXT PRIMARY KEY, slug TEXT, display_name TEXT,
86
+ model_name TEXT, model_provider TEXT, deleted_at TEXT
87
+ );
88
+ CREATE TABLE messages (
89
+ id TEXT PRIMARY KEY, room_id TEXT, sender_user_id TEXT,
90
+ sender_agent_id TEXT, parent_message_id TEXT, content TEXT,
91
+ content_type TEXT, metadata TEXT, is_edited INTEGER,
92
+ created_at TEXT, updated_at TEXT, deleted_at TEXT
93
+ );
94
+ INSERT INTO agent_profiles VALUES (
95
+ 'agent-1', 'test-agent', 'Test Agent',
96
+ 'gpt-5.4-mini', 'codex', NULL
97
+ );
98
+ INSERT INTO messages VALUES (
99
+ 'message-1', 'room-1', NULL, 'agent-1', NULL, 'Old reply',
100
+ 'text', '{}', 0, '1700000000', '1700000000', NULL
101
+ );
102
+ """
103
+ )
104
+
105
+ enriched = server._enrich_room_messages(
106
+ conn,
107
+ conn.execute("SELECT * FROM messages").fetchall(),
108
+ )
109
+ conn.close()
110
+
111
+ assert enriched[0]["sender_agent_name"] == "Test Agent"
112
+ assert "model" not in enriched[0]
113
+ assert "llm_provider" not in enriched[0]
@@ -0,0 +1,33 @@
1
+ """Regression coverage for compressed upstream LLM responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import credential_broker
6
+ import llm_proxy
7
+
8
+
9
+ def test_upstream_request_forces_identity_encoding(monkeypatch) -> None:
10
+ monkeypatch.setattr(
11
+ credential_broker,
12
+ "authorize_broker_lease",
13
+ lambda *args, **kwargs: credential_broker.BrokerLeaseAuth(ok=True, secret="test-secret"),
14
+ )
15
+
16
+ request, error = llm_proxy._build_upstream_request(
17
+ "openrouter",
18
+ "/chat/completions",
19
+ "POST",
20
+ {
21
+ "Authorization": "Bearer runtime-llm:test",
22
+ "Accept-Encoding": "gzip, deflate",
23
+ "Content-Type": "application/json",
24
+ },
25
+ b"{}",
26
+ resolve_secret=lambda *_: ("OPENROUTER_API_KEY", "unused"),
27
+ )
28
+
29
+ assert error is None
30
+ assert request is not None
31
+ headers = {key.lower(): value for key, value in request.header_items()}
32
+ assert headers["accept-encoding"] == "identity"
33
+ assert headers["authorization"] == "Bearer test-secret"
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ import pytest
6
+
7
+ import db_schema
8
+ import message_reactions
9
+
10
+
11
+ def reaction_db() -> sqlite3.Connection:
12
+ conn = sqlite3.connect(":memory:")
13
+ conn.row_factory = sqlite3.Row
14
+ conn.execute(
15
+ "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, description TEXT, applied_at TEXT)"
16
+ )
17
+ conn.execute(
18
+ "CREATE TABLE messages (id TEXT PRIMARY KEY, room_id TEXT, deleted_at TEXT)"
19
+ )
20
+ conn.execute(
21
+ "CREATE TABLE users (id TEXT PRIMARY KEY, display_name TEXT, avatar_url TEXT)"
22
+ )
23
+ # Run-ledger already owns migration 14. This regression marker ensures
24
+ # reactions still migrate on installs that have that older row.
25
+ conn.execute(
26
+ "INSERT INTO schema_migrations VALUES ('14', 'runs, run_events, run_line_items ledger tables', 'now')"
27
+ )
28
+ db_schema._migrate_v15_message_reactions(conn)
29
+ assert conn.execute(
30
+ "SELECT 1 FROM schema_migrations WHERE version = '15'"
31
+ ).fetchone()
32
+ conn.execute("INSERT INTO messages (id, room_id, deleted_at) VALUES ('message-1', 'room-1', NULL)")
33
+ conn.executemany(
34
+ "INSERT INTO users (id, display_name, avatar_url) VALUES (?, ?, ?)",
35
+ [
36
+ ("user-1", "Alan", "/avatars/alan.png"),
37
+ ("user-2", "Grace", None),
38
+ ],
39
+ )
40
+ conn.commit()
41
+ return conn
42
+
43
+
44
+ def can_access_room(_conn: sqlite3.Connection, room_id: str, user_id: str) -> bool:
45
+ return room_id == "room-1" and user_id == "user-1"
46
+
47
+
48
+ def test_room_reaction_toggles_and_aggregates() -> None:
49
+ conn = reaction_db()
50
+ try:
51
+ first = message_reactions.toggle_reaction(
52
+ conn, "room:room-1", "message-1", "👍", "user-1", can_access_room
53
+ )
54
+ assert first["reacted"] is True
55
+ assert first["reactions"] == [
56
+ {
57
+ "message_id": "message-1",
58
+ "emoji": "👍",
59
+ "count": 1,
60
+ "reacted": True,
61
+ "reactors": [
62
+ {
63
+ "user_id": "user-1",
64
+ "display_name": "Alan",
65
+ "avatar_url": "/avatars/alan.png",
66
+ }
67
+ ],
68
+ }
69
+ ]
70
+
71
+ second = message_reactions.toggle_reaction(
72
+ conn, "room:room-1", "message-1", "👍", "user-1", can_access_room
73
+ )
74
+ assert second["reacted"] is False
75
+ assert second["reactions"] == []
76
+ finally:
77
+ conn.close()
78
+
79
+
80
+ def test_room_reactions_include_people_for_hover_details() -> None:
81
+ conn = reaction_db()
82
+ try:
83
+ message_reactions.toggle_reaction(
84
+ conn, "room:room-1", "message-1", "🎉", "user-1", can_access_room
85
+ )
86
+ conn.execute(
87
+ """
88
+ INSERT INTO message_reactions (scope_key, message_id, user_id, emoji, created_at)
89
+ VALUES ('room:room-1', 'message-1', 'user-2', '🎉', '2')
90
+ """
91
+ )
92
+ conn.commit()
93
+
94
+ listed = message_reactions.list_reactions(
95
+ conn, "room:room-1", "user-1", can_access_room
96
+ )
97
+ assert listed["reactions"] == [
98
+ {
99
+ "message_id": "message-1",
100
+ "emoji": "🎉",
101
+ "count": 2,
102
+ "reacted": True,
103
+ "reactors": [
104
+ {
105
+ "user_id": "user-1",
106
+ "display_name": "Alan",
107
+ "avatar_url": "/avatars/alan.png",
108
+ },
109
+ {
110
+ "user_id": "user-2",
111
+ "display_name": "Grace",
112
+ "avatar_url": None,
113
+ },
114
+ ],
115
+ }
116
+ ]
117
+ finally:
118
+ conn.close()
119
+
120
+
121
+ def test_room_reactions_require_room_access_and_existing_message() -> None:
122
+ conn = reaction_db()
123
+ try:
124
+ with pytest.raises(PermissionError):
125
+ message_reactions.list_reactions(
126
+ conn, "room:room-1", "user-2", can_access_room
127
+ )
128
+ with pytest.raises(ValueError, match="message_not_found"):
129
+ message_reactions.toggle_reaction(
130
+ conn, "room:room-1", "missing", "🔥", "user-1", can_access_room
131
+ )
132
+ finally:
133
+ conn.close()
134
+
135
+
136
+ def test_dm_reactions_are_namespaced_by_authenticated_user() -> None:
137
+ conn = reaction_db()
138
+ try:
139
+ message_reactions.toggle_reaction(
140
+ conn, "dm:agent:session-1", "external-message", "✅", "user-1", can_access_room
141
+ )
142
+ mine = message_reactions.list_reactions(
143
+ conn, "dm:agent:session-1", "user-1", can_access_room
144
+ )
145
+ theirs = message_reactions.list_reactions(
146
+ conn, "dm:agent:session-1", "user-2", can_access_room
147
+ )
148
+ assert mine["reactions"][0]["count"] == 1
149
+ assert theirs["reactions"] == []
150
+ finally:
151
+ conn.close()
@@ -0,0 +1,122 @@
1
+ """Provider/model routing repairs for vault-backed API-key providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ import yaml
11
+
12
+ API_DIR = Path(__file__).resolve().parent
13
+ if str(API_DIR) not in sys.path:
14
+ sys.path.insert(0, str(API_DIR))
15
+
16
+ import server # noqa: E402
17
+
18
+
19
+ @pytest.fixture()
20
+ def routing_data_dir():
21
+ with tempfile.TemporaryDirectory(prefix="wf-routing-") as tmp:
22
+ yield Path(tmp)
23
+
24
+
25
+ def test_native_openrouter_config_is_reconciled_to_internal_proxy(
26
+ monkeypatch: pytest.MonkeyPatch,
27
+ routing_data_dir: Path,
28
+ ) -> None:
29
+ tmp_path = routing_data_dir
30
+ profile = "dogfood-agent"
31
+ profile_dir = tmp_path / "profiles" / profile
32
+ profile_dir.mkdir(parents=True)
33
+ config_path = profile_dir / "config.yaml"
34
+ config_path.write_text(
35
+ "model:\n"
36
+ " default: google/gemini-2.5-flash\n"
37
+ " provider: openrouter\n"
38
+ " base_url: https://openrouter.ai/api/v1\n",
39
+ encoding="utf-8",
40
+ )
41
+
42
+ monkeypatch.setattr(server, "HERMES_DATA", tmp_path)
43
+ monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
44
+ monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
45
+ monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
46
+ monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
47
+ monkeypatch.setattr(server, "_reload_runtime_profile_gateway", lambda *_args, **_kwargs: None)
48
+
49
+ assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is True
50
+
51
+ cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))
52
+ assert cfg["model"]["default"] == "google/gemini-2.5-flash"
53
+ assert cfg["model"]["provider"] == "custom"
54
+ assert cfg["model"]["base_url"] == "http://workframe-api:8000/internal/llm/openrouter/v1"
55
+ assert isinstance(cfg["fallback_providers"], list)
56
+ assert cfg["fallback_providers"]
57
+ assert all(entry["provider"] == "custom" for entry in cfg["fallback_providers"])
58
+
59
+
60
+ def test_native_openrouter_reconcile_is_idempotent(
61
+ monkeypatch: pytest.MonkeyPatch,
62
+ routing_data_dir: Path,
63
+ ) -> None:
64
+ tmp_path = routing_data_dir
65
+ profile = "dogfood-agent"
66
+ profile_dir = tmp_path / "profiles" / profile
67
+ profile_dir.mkdir(parents=True)
68
+ config_path = profile_dir / "config.yaml"
69
+ config_path.write_text(
70
+ "model:\n"
71
+ " default: google/gemini-2.5-flash\n"
72
+ " provider: custom\n"
73
+ " base_url: http://workframe-api:8000/internal/llm/openrouter/v1\n"
74
+ "fallback_providers:\n"
75
+ " - provider: custom\n"
76
+ " model: meta-llama/llama-3.3-70b-instruct:free\n",
77
+ encoding="utf-8",
78
+ )
79
+
80
+ monkeypatch.setattr(server, "HERMES_DATA", tmp_path)
81
+ monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
82
+ monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
83
+ monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
84
+ monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
85
+
86
+ assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is False
87
+
88
+
89
+ def test_openrouter_reconcile_accepts_pyyaml_indentless_fallback_sequence(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ routing_data_dir: Path,
92
+ ) -> None:
93
+ profile = "dogfood-agent"
94
+ profile_dir = routing_data_dir / "profiles" / profile
95
+ profile_dir.mkdir(parents=True)
96
+ config_path = profile_dir / "config.yaml"
97
+ config_path.write_text(
98
+ "model:\n"
99
+ " default: openrouter/auto\n"
100
+ " provider: custom\n"
101
+ " base_url: http://workframe-api:8000/internal/llm/openrouter/v1\n"
102
+ "fallback_providers:\n"
103
+ "- provider: custom\n"
104
+ " model: anthropic/claude-sonnet-4.5\n"
105
+ "- provider: custom\n"
106
+ " model: meta-llama/llama-3.3-70b-instruct:free\n",
107
+ encoding="utf-8",
108
+ )
109
+
110
+ monkeypatch.setattr(server, "HERMES_DATA", routing_data_dir)
111
+ monkeypatch.setattr(server, "NATIVE_PROFILE", profile)
112
+ monkeypatch.setattr(server, "WORKFRAME_LLM_PROXY_INTERNAL", "http://workframe-api:8000")
113
+ monkeypatch.setattr(server, "_user_llm_providers_for_picker", lambda _user: {"openrouter"})
114
+ monkeypatch.setattr(server, "_user_can_use_llm", lambda *_args, **_kwargs: True)
115
+
116
+ block = server._read_model_block(profile)
117
+ assert block["fallback_chain"] == [
118
+ {"provider": "custom", "model": "anthropic/claude-sonnet-4.5"},
119
+ {"provider": "custom", "model": "meta-llama/llama-3.3-70b-instruct:free"},
120
+ ]
121
+ assert server._profile_llm_proxy_ready(profile, "openrouter") is True
122
+ assert server._reconcile_profile_llm_for_user(profile, "user-1", "workspace-1") is False
@@ -51,7 +51,7 @@ def _run_checks(server) -> None:
51
51
  assert applied.get("billing_provider") == "codex"
52
52
  assert applied.get("provider") == "codex"
53
53
 
54
- # E: reconcile picks model billing over stale openrouter proxy yaml
54
+ # E: credential availability cannot silently change an agent-owned model/provider.
55
55
  (prof_dir / "config.yaml").write_text(
56
56
  "model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
57
57
  " base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
@@ -60,19 +60,24 @@ def _run_checks(server) -> None:
60
60
  _orig_picker = server._user_llm_providers_for_picker
61
61
  _orig_can = server._user_can_use_llm
62
62
  _orig_sync = server._sync_oauth_llm_to_profile
63
+ _orig_reload = server._reload_runtime_profile_gateway
63
64
  server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
64
65
  server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
65
66
  server._sync_oauth_llm_to_profile = lambda *a, **k: None
67
+ server._reload_runtime_profile_gateway = lambda *a, **k: None
66
68
  try:
69
+ # Transport repair may add proxy-safe fallback rows, but it must not
70
+ # switch the configured OpenRouter billing provider to Codex.
67
71
  assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
68
72
  block = server._read_model_block(PROF)
69
- assert block.get("provider") == "openai-codex"
70
- assert block.get("default") == "gpt-5.4-mini"
71
- assert not block.get("base_url")
73
+ assert block.get("provider") == "custom"
74
+ assert block.get("default") == "openai-codex/gpt-5.4-mini"
75
+ assert str(block.get("base_url") or "").endswith("/internal/llm/openrouter/v1")
72
76
  finally:
73
77
  server._user_llm_providers_for_picker = _orig_picker
74
78
  server._user_can_use_llm = _orig_can
75
79
  server._sync_oauth_llm_to_profile = _orig_sync
80
+ server._reload_runtime_profile_gateway = _orig_reload
76
81
 
77
82
  # F: runtime disk wins over template in _read_model_block
78
83
  runtime_prof = "u-test-user-surface-test-agent"
@@ -85,6 +90,7 @@ def _run_checks(server) -> None:
85
90
  block = server._read_model_block(runtime_prof)
86
91
  assert block.get("default") == "gpt-5.4-mini", block
87
92
  assert block.get("provider") == "openai-codex", block
93
+ assert server._resolve_models_profile(runtime_prof) == PROF
88
94
 
89
95
  # G: template model save mirrors to runtime (same model id, not stale template re-read)
90
96
  runtime_prof = "u-test-user2-surface-test-agent"
@@ -98,13 +104,47 @@ def _run_checks(server) -> None:
98
104
  server._restart_runtime_profile_gateway = lambda *a, **k: None
99
105
  try:
100
106
  applied = server.hermes_model_set(
101
- PROF, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
107
+ runtime_prof, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
102
108
  )
103
109
  finally:
104
110
  server._restart_runtime_profile_gateway = _orig_restart
105
111
  assert applied.get("ok"), applied
112
+ assert applied.get("profile") == PROF, applied
106
113
  runtime_block = server._parse_model_block_from_disk(runtime_prof)
107
114
  assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
115
+ template_block = server._parse_model_block_from_disk(PROF)
116
+ assert template_block.get("default") == "gpt-5.4-mini", template_block
117
+ assert template_block.get("provider") == "openai-codex", template_block
118
+
119
+ # H: missing user credentials deny execution elsewhere; provider resolution
120
+ # still reports the agent's configured Codex provider instead of OpenRouter.
121
+ _orig_picker = server._user_llm_providers_for_picker
122
+ _orig_can = server._user_can_use_llm
123
+ server._user_llm_providers_for_picker = lambda _uid: {"openrouter"}
124
+ server._user_can_use_llm = lambda *_args, **_kwargs: False
125
+ try:
126
+ assert server._llm_billing_provider(PROF, user_id="other-user") == "codex"
127
+ finally:
128
+ server._user_llm_providers_for_picker = _orig_picker
129
+ server._user_can_use_llm = _orig_can
130
+
131
+ # I: picker rows expose the billing provider encoded in the proxy URL,
132
+ # never Hermes' internal ``custom`` transport label.
133
+ proxy_block = {
134
+ "default": "google/gemini-2.5-flash",
135
+ "provider": "custom",
136
+ "base_url": "http://workframe-api:8080/internal/llm/openrouter/v1",
137
+ "fallback_chain": [
138
+ {"provider": "custom", "model": "anthropic/claude-sonnet-4.5"},
139
+ ],
140
+ }
141
+ augmented = server._augment_model_suggestions([], proxy_block)
142
+ assert {row.get("billing_provider") for row in augmented} == {"openrouter"}, augmented
143
+ assert augmented[0].get("label") == "gemini-2.5-flash", augmented
144
+ normalized = server._normalized_profile_fallback_chain(proxy_block, "openrouter")
145
+ assert normalized == [
146
+ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"},
147
+ ]
108
148
 
109
149
 
110
150
  def _run_self_check() -> None:
@@ -53,6 +53,6 @@ finally:
53
53
  server._hermes_oauth_tokens_present = _orig
54
54
 
55
55
  mvp = server.PROVIDER_MVP_MODELS["codex"]["primary"]
56
- assert mvp == "gpt-5.4-medium"
56
+ assert mvp == "gpt-5.4-mini"
57
57
 
58
58
  print("oauth llm self-check ok")
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import profile_gateway
4
+
5
+
6
+ def test_wait_profile_api_health_bypasses_negative_cache(monkeypatch) -> None:
7
+ server = profile_gateway._srv()
8
+ monkeypatch.setattr(server, "resolve_hermes_profile", lambda profile: profile)
9
+ monkeypatch.setattr(profile_gateway.time, "sleep", lambda _delay: None)
10
+
11
+ cache_modes: list[bool] = []
12
+
13
+ def health(_profile: str, **kwargs) -> bool:
14
+ cache_modes.append(bool(kwargs.get("use_cache", True)))
15
+ return len(cache_modes) >= 3
16
+
17
+ monkeypatch.setattr(profile_gateway, "_profile_api_healthy", health)
18
+
19
+ assert profile_gateway._wait_profile_api_healthy("dogfood-agent", attempts=4, delay=0.01)
20
+ assert cache_modes == [False, False, False]
21
+
22
+
23
+ def test_wait_profile_api_health_stops_after_requested_attempts(monkeypatch) -> None:
24
+ server = profile_gateway._srv()
25
+ monkeypatch.setattr(server, "resolve_hermes_profile", lambda profile: profile)
26
+ sleeps: list[float] = []
27
+ monkeypatch.setattr(profile_gateway.time, "sleep", sleeps.append)
28
+ monkeypatch.setattr(
29
+ profile_gateway,
30
+ "_profile_api_healthy",
31
+ lambda _profile, **kwargs: False,
32
+ )
33
+
34
+ assert not profile_gateway._wait_profile_api_healthy(
35
+ "dogfood-agent", attempts=3, delay=0.25,
36
+ )
37
+ assert sleeps == [0.25, 0.25, 0.25]