@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -42,6 +42,7 @@ mock.module("../runtime/pending-interactions.js", () => ({
42
42
  const {
43
43
  HostAppControlProxy,
44
44
  _getActiveAppControlSession,
45
+ _getConfirmedAppControlSession,
45
46
  _resetActiveAppControlSession,
46
47
  _setActiveAppControlSession,
47
48
  } = await import("../daemon/host-app-control-proxy.js");
@@ -826,6 +827,246 @@ describe("HostAppControlProxy", () => {
826
827
  proxy.dispose();
827
828
  });
828
829
 
830
+ test("both-succeed older-arrives-last does not overwrite newer confirmed session", async () => {
831
+ // Two same-conversation starts both succeed, but the older one's
832
+ // `running` response arrives AFTER the newer one's. With a
833
+ // conversation-only ownership guard, the older response would
834
+ // overwrite the newer confirmed session — a latent bug that surfaces
835
+ // on a subsequent rollback. Verify the confirmed pointer stays on the
836
+ // newer session (C), and that a later rollback restores to C, not A.
837
+ const proxy = new HostAppControlProxy("conv-1");
838
+ const ctrl = new AbortController();
839
+
840
+ const pA = proxy.request(
841
+ "app_control_start",
842
+ { tool: "start", app: "com.example.a" },
843
+ "conv-1",
844
+ ctrl.signal,
845
+ );
846
+ const reqIdA = (sentMessages[0] as Record<string, unknown>)
847
+ .requestId as string;
848
+
849
+ sentMessages.length = 0;
850
+ const pC = proxy.request(
851
+ "app_control_start",
852
+ { tool: "start", app: "com.example.c" },
853
+ "conv-1",
854
+ ctrl.signal,
855
+ );
856
+ const reqIdC = (sentMessages[0] as Record<string, unknown>)
857
+ .requestId as string;
858
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
859
+
860
+ // C succeeds first → active = C, confirmed = C.
861
+ proxy.resolve(reqIdC, payload({ pngBase64: PNG_A }));
862
+ await pC;
863
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
864
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.c");
865
+
866
+ // A succeeds second (older arrives last). The conversation-ownership
867
+ // check passes for A, but A is no longer the live optimistic write
868
+ // and a session is already confirmed — must NOT overwrite confirmed.
869
+ proxy.resolve(reqIdA, payload({ pngBase64: PNG_B }));
870
+ await pA;
871
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.c");
872
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
873
+
874
+ // A later restart D that fails must roll back to C (the true
875
+ // confirmed), not to A — guards against the latent-bug case.
876
+ sentMessages.length = 0;
877
+ const pD = proxy.request(
878
+ "app_control_start",
879
+ { tool: "start", app: "com.example.d" },
880
+ "conv-1",
881
+ ctrl.signal,
882
+ );
883
+ const reqIdD = (sentMessages[0] as Record<string, unknown>)
884
+ .requestId as string;
885
+ proxy.resolve(reqIdD, payload({ state: "missing" }));
886
+ await pD;
887
+
888
+ const session = _getActiveAppControlSession();
889
+ expect(session?.conversationId).toBe("conv-1");
890
+ expect(session?.app).toBe("com.example.c");
891
+
892
+ proxy.dispose();
893
+ });
894
+
895
+ test("rollback after confirmed-A,dispatch-B,dispatch-C,B-confirms,C-fails restores B (Codex P2)", async () => {
896
+ // A is confirmed, then B and C are dispatched. B's `running` arrives
897
+ // while C is the live write; under the dispatch-counter rule that
898
+ // confirmation must promote because B is newer than A. A subsequent
899
+ // failure of C must then roll back to B, not back to A.
900
+ const proxy = new HostAppControlProxy("conv-1");
901
+ const ctrl = new AbortController();
902
+
903
+ const pA = proxy.request(
904
+ "app_control_start",
905
+ { tool: "start", app: "com.example.a" },
906
+ "conv-1",
907
+ ctrl.signal,
908
+ );
909
+ const reqIdA = (sentMessages[0] as Record<string, unknown>)
910
+ .requestId as string;
911
+ proxy.resolve(reqIdA, payload({ pngBase64: PNG_A }));
912
+ await pA;
913
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.a");
914
+
915
+ sentMessages.length = 0;
916
+ const pB = proxy.request(
917
+ "app_control_start",
918
+ { tool: "start", app: "com.example.b" },
919
+ "conv-1",
920
+ ctrl.signal,
921
+ );
922
+ const reqIdB = (sentMessages[0] as Record<string, unknown>)
923
+ .requestId as string;
924
+
925
+ sentMessages.length = 0;
926
+ const pC = proxy.request(
927
+ "app_control_start",
928
+ { tool: "start", app: "com.example.c" },
929
+ "conv-1",
930
+ ctrl.signal,
931
+ );
932
+ const reqIdC = (sentMessages[0] as Record<string, unknown>)
933
+ .requestId as string;
934
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
935
+
936
+ // B confirms (newer than A): confirmed should move to B even though
937
+ // C is the live optimistic write.
938
+ proxy.resolve(reqIdB, payload({ pngBase64: PNG_B }));
939
+ await pB;
940
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.b");
941
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
942
+
943
+ // C fails → rollback restores to confirmed (B), not A.
944
+ proxy.resolve(reqIdC, payload({ state: "missing" }));
945
+ await pC;
946
+ const session = _getActiveAppControlSession();
947
+ expect(session?.conversationId).toBe("conv-1");
948
+ expect(session?.app).toBe("com.example.b");
949
+
950
+ proxy.dispose();
951
+ });
952
+
953
+ test("three-overlapping-starts A-confirms,B-confirms,C-fails restores B (Devin P2)", async () => {
954
+ // A, B, C dispatched in order with no prior confirmed session.
955
+ // A confirms first (confirmed = A), B confirms second (confirmed
956
+ // must move forward to B because B is newer), C fails → rollback
957
+ // restores to B, not A.
958
+ const proxy = new HostAppControlProxy("conv-1");
959
+ const ctrl = new AbortController();
960
+
961
+ const pA = proxy.request(
962
+ "app_control_start",
963
+ { tool: "start", app: "com.example.a" },
964
+ "conv-1",
965
+ ctrl.signal,
966
+ );
967
+ const reqIdA = (sentMessages[0] as Record<string, unknown>)
968
+ .requestId as string;
969
+
970
+ sentMessages.length = 0;
971
+ const pB = proxy.request(
972
+ "app_control_start",
973
+ { tool: "start", app: "com.example.b" },
974
+ "conv-1",
975
+ ctrl.signal,
976
+ );
977
+ const reqIdB = (sentMessages[0] as Record<string, unknown>)
978
+ .requestId as string;
979
+
980
+ sentMessages.length = 0;
981
+ const pC = proxy.request(
982
+ "app_control_start",
983
+ { tool: "start", app: "com.example.c" },
984
+ "conv-1",
985
+ ctrl.signal,
986
+ );
987
+ const reqIdC = (sentMessages[0] as Record<string, unknown>)
988
+ .requestId as string;
989
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
990
+
991
+ proxy.resolve(reqIdA, payload({ pngBase64: PNG_A }));
992
+ await pA;
993
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.a");
994
+
995
+ proxy.resolve(reqIdB, payload({ pngBase64: PNG_B }));
996
+ await pB;
997
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.b");
998
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
999
+
1000
+ proxy.resolve(reqIdC, payload({ state: "missing" }));
1001
+ await pC;
1002
+ const session = _getActiveAppControlSession();
1003
+ expect(session?.conversationId).toBe("conv-1");
1004
+ expect(session?.app).toBe("com.example.b");
1005
+
1006
+ proxy.dispose();
1007
+ });
1008
+
1009
+ test("confirmed-A,dispatch-B,dispatch-C,C-fails-first,B-confirms syncs active to B", async () => {
1010
+ // A confirmed, B and C dispatched, C returns `missing` first →
1011
+ // rollback restores active to A. Then B returns `running` →
1012
+ // confirmed advances to B; active must advance alongside it,
1013
+ // otherwise the tool reports B started while
1014
+ // app_control_observe/actions for B target A.
1015
+ const proxy = new HostAppControlProxy("conv-1");
1016
+ const ctrl = new AbortController();
1017
+
1018
+ const pA = proxy.request(
1019
+ "app_control_start",
1020
+ { tool: "start", app: "com.example.a" },
1021
+ "conv-1",
1022
+ ctrl.signal,
1023
+ );
1024
+ const reqIdA = (sentMessages[0] as Record<string, unknown>)
1025
+ .requestId as string;
1026
+ proxy.resolve(reqIdA, payload({ pngBase64: PNG_A }));
1027
+ await pA;
1028
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.a");
1029
+
1030
+ sentMessages.length = 0;
1031
+ const pB = proxy.request(
1032
+ "app_control_start",
1033
+ { tool: "start", app: "com.example.b" },
1034
+ "conv-1",
1035
+ ctrl.signal,
1036
+ );
1037
+ const reqIdB = (sentMessages[0] as Record<string, unknown>)
1038
+ .requestId as string;
1039
+
1040
+ sentMessages.length = 0;
1041
+ const pC = proxy.request(
1042
+ "app_control_start",
1043
+ { tool: "start", app: "com.example.c" },
1044
+ "conv-1",
1045
+ ctrl.signal,
1046
+ );
1047
+ const reqIdC = (sentMessages[0] as Record<string, unknown>)
1048
+ .requestId as string;
1049
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.c");
1050
+
1051
+ // C fails first → rollback restores active to confirmed (A).
1052
+ proxy.resolve(reqIdC, payload({ state: "missing" }));
1053
+ await pC;
1054
+ expect(_getActiveAppControlSession()?.app).toBe("com.example.a");
1055
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.a");
1056
+
1057
+ // B confirms after C's rollback. Confirmed advances to B AND active
1058
+ // must advance to B too — otherwise non-start tool calls against B
1059
+ // would be rejected because active still targets A.
1060
+ proxy.resolve(reqIdB, payload({ pngBase64: PNG_B }));
1061
+ await pB;
1062
+ expect(_getConfirmedAppControlSession()?.app).toBe("com.example.b");
1063
+ const session = _getActiveAppControlSession();
1064
+ expect(session?.conversationId).toBe("conv-1");
1065
+ expect(session?.app).toBe("com.example.b");
1066
+
1067
+ proxy.dispose();
1068
+ });
1069
+
829
1070
  test("first-start failure releases the lock (no prior session to restore)", async () => {
830
1071
  const proxy = new HostAppControlProxy("conv-1");
831
1072
  const ctrl = new AbortController();
@@ -1,12 +1,16 @@
1
1
  /**
2
- * Tests for `preactivateHostProxySkills` and `shouldAttachHostProxyForCapability`
3
- * in `host-proxy-preactivation.ts`.
2
+ * Tests for `evaluateHostProxyAttachment`, `preactivateHostProxySkills`, and
3
+ * `shouldAttachHostProxyForCapability` in `host-proxy-preactivation.ts`.
4
4
  *
5
5
  * Covers:
6
6
  * - Source interface natively supports capability → preactivate (regression)
7
7
  * - Source interface doesn't support but capable client connected → preactivate
8
8
  * - Source interface doesn't support and no capable client → don't preactivate
9
9
  * - chrome-extension source + capable client connected → don't preactivate (security boundary)
10
+ * - `evaluateHostProxyAttachment` returns the correct `reason` for each branch
11
+ * - `preactivateHostProxySkills` emits one structured log line per call with
12
+ * conversationId, sourceInterface, per-capability decisions, and final
13
+ * preactivatedSkillIds (used by ATL-609-class silent-gate diagnosis)
10
14
  */
11
15
 
12
16
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -26,31 +30,74 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
26
30
  broadcastMessage: () => {},
27
31
  }));
28
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Mock the logger so we can assert on the structured info call.
35
+ // `info` calls are pushed into `loggedInfoCalls` for inspection.
36
+ // `child()` is exposed for callers that wrap the logger that way.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface LoggedCall {
40
+ fields: Record<string, unknown>;
41
+ message: string;
42
+ }
43
+ const loggedInfoCalls: LoggedCall[] = [];
44
+ function captureInfo(fields: unknown, message: unknown) {
45
+ loggedInfoCalls.push({
46
+ fields: fields as Record<string, unknown>,
47
+ message: message as string,
48
+ });
49
+ }
50
+
29
51
  mock.module("../util/logger.js", () => ({
30
- getLogger: () =>
31
- new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
52
+ getLogger: () => ({
53
+ info: captureInfo,
54
+ warn: () => {},
55
+ error: () => {},
56
+ debug: () => {},
57
+ trace: () => {},
58
+ fatal: () => {},
59
+ child: () => ({
60
+ info: captureInfo,
61
+ warn: () => {},
62
+ error: () => {},
63
+ debug: () => {},
64
+ trace: () => {},
65
+ fatal: () => {},
66
+ }),
67
+ }),
32
68
  }));
33
69
 
34
70
  // ---------------------------------------------------------------------------
35
- // Imports under test (after mocks are registered)
71
+ // Imports under test
72
+ //
73
+ // Type-only imports are erased at runtime and safe to hoist. The value import
74
+ // of `host-proxy-preactivation` must be dynamic (`await import`) so it
75
+ // resolves AFTER the `mock.module(...)` calls above — otherwise ES module
76
+ // hoisting loads the real logger before the mock registers, the production
77
+ // `const log = getLogger(...)` binds to real pino, and assertions against
78
+ // `loggedInfoCalls` see an empty array. Same pattern as
79
+ // `secret-prompt-log-hygiene.test.ts`.
36
80
  // ---------------------------------------------------------------------------
37
81
 
38
82
  import type { HostProxyCapability } from "../channels/types.js";
39
- import {
40
- type HostProxyPreactivationTarget,
83
+ import type { HostProxyPreactivationTarget } from "../daemon/host-proxy-preactivation.js";
84
+
85
+ const {
86
+ evaluateHostProxyAttachment,
41
87
  preactivateHostProxySkills,
42
88
  shouldAttachHostProxyForCapability,
43
- } from "../daemon/host-proxy-preactivation.js";
89
+ } = await import("../daemon/host-proxy-preactivation.js");
44
90
 
45
91
  // ---------------------------------------------------------------------------
46
92
  // Helpers
47
93
  // ---------------------------------------------------------------------------
48
94
 
49
- function makeTarget(): HostProxyPreactivationTarget & {
50
- preactivatedSkillIds: string[];
51
- } {
95
+ function makeTarget(
96
+ conversationId = "conv-test",
97
+ ): HostProxyPreactivationTarget & { preactivatedSkillIds: string[] } {
52
98
  const preactivatedSkillIds: string[] = [];
53
99
  return {
100
+ conversationId,
54
101
  preactivatedSkillIds,
55
102
  addPreactivatedSkillId(id: string) {
56
103
  preactivatedSkillIds.push(id);
@@ -73,6 +120,7 @@ function setCapableClient(
73
120
 
74
121
  beforeEach(() => {
75
122
  mockClientsByCapability = new Map();
123
+ loggedInfoCalls.length = 0;
76
124
  });
77
125
 
78
126
  // ---------------------------------------------------------------------------
@@ -143,7 +191,10 @@ describe("shouldAttachHostProxyForCapability", () => {
143
191
  test("returns false for chrome-extension source even when a capable client is connected", () => {
144
192
  setCapableClient("host_app_control", true);
145
193
  expect(
146
- shouldAttachHostProxyForCapability("host_app_control", "chrome-extension"),
194
+ shouldAttachHostProxyForCapability(
195
+ "host_app_control",
196
+ "chrome-extension",
197
+ ),
147
198
  ).toBe(false);
148
199
  });
149
200
  });
@@ -154,7 +205,7 @@ describe("shouldAttachHostProxyForCapability", () => {
154
205
  // ---------------------------------------------------------------------------
155
206
 
156
207
  describe("preactivateHostProxySkills", () => {
157
- test("no-ops when sourceInterface is undefined", () => {
208
+ test("preactivates no skills when sourceInterface is undefined", () => {
158
209
  const target = makeTarget();
159
210
  preactivateHostProxySkills(target, undefined);
160
211
  expect(target.preactivatedSkillIds).toEqual([]);
@@ -209,3 +260,139 @@ describe("preactivateHostProxySkills", () => {
209
260
  expect(target.preactivatedSkillIds).toEqual([]);
210
261
  });
211
262
  });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // evaluateHostProxyAttachment — reason coverage
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("evaluateHostProxyAttachment", () => {
269
+ test("returns denied_no_interface when sourceInterface is undefined", () => {
270
+ expect(evaluateHostProxyAttachment("host_cu", undefined)).toEqual({
271
+ shouldAttach: false,
272
+ reason: "denied_no_interface",
273
+ });
274
+ });
275
+
276
+ test("returns native_support for macos + host_cu", () => {
277
+ expect(evaluateHostProxyAttachment("host_cu", "macos")).toEqual({
278
+ shouldAttach: true,
279
+ reason: "native_support",
280
+ });
281
+ });
282
+
283
+ test("returns denied_chrome_extension for chrome-extension source even when capable clients exist", () => {
284
+ setCapableClient("host_cu", true);
285
+ expect(evaluateHostProxyAttachment("host_cu", "chrome-extension")).toEqual({
286
+ shouldAttach: false,
287
+ reason: "denied_chrome_extension",
288
+ });
289
+ });
290
+
291
+ test("returns cross_client with clientCount when a capable client is connected", () => {
292
+ setCapableClient("host_cu", true);
293
+ expect(evaluateHostProxyAttachment("host_cu", "web")).toEqual({
294
+ shouldAttach: true,
295
+ reason: "cross_client",
296
+ clientCount: 1,
297
+ });
298
+ });
299
+
300
+ test("returns denied_no_clients with clientCount 0 when no capable client is connected", () => {
301
+ setCapableClient("host_cu", false);
302
+ expect(evaluateHostProxyAttachment("host_cu", "web")).toEqual({
303
+ shouldAttach: false,
304
+ reason: "denied_no_clients",
305
+ clientCount: 0,
306
+ });
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // preactivateHostProxySkills — structured logging
312
+ // ---------------------------------------------------------------------------
313
+
314
+ describe("preactivateHostProxySkills logging", () => {
315
+ test("emits exactly one info log per call", () => {
316
+ const target = makeTarget();
317
+ preactivateHostProxySkills(target, "macos");
318
+ expect(loggedInfoCalls).toHaveLength(1);
319
+ expect(loggedInfoCalls[0].message).toBe(
320
+ "host-proxy preactivation decision",
321
+ );
322
+ });
323
+
324
+ test("log includes conversationId, sourceInterface, per-capability decisions, and preactivatedSkillIds for macos", () => {
325
+ const target = makeTarget("conv-macos-123");
326
+ preactivateHostProxySkills(target, "macos");
327
+
328
+ expect(loggedInfoCalls).toHaveLength(1);
329
+ const { fields } = loggedInfoCalls[0];
330
+ expect(fields.conversationId).toBe("conv-macos-123");
331
+ expect(fields.sourceInterface).toBe("macos");
332
+ expect(fields.decisions).toEqual({
333
+ host_cu: { shouldAttach: true, reason: "native_support" },
334
+ host_app_control: { shouldAttach: true, reason: "native_support" },
335
+ });
336
+ expect(fields.preactivatedSkillIds).toEqual([
337
+ "computer-use",
338
+ "app-control",
339
+ ]);
340
+ });
341
+
342
+ test("log captures denied_no_interface for undefined sourceInterface (silent-gate diagnostic)", () => {
343
+ const target = makeTarget("conv-no-interface");
344
+ preactivateHostProxySkills(target, undefined);
345
+
346
+ expect(loggedInfoCalls).toHaveLength(1);
347
+ const { fields } = loggedInfoCalls[0];
348
+ expect(fields.conversationId).toBe("conv-no-interface");
349
+ expect(fields.sourceInterface).toBeUndefined();
350
+ expect(fields.decisions).toEqual({
351
+ host_cu: { shouldAttach: false, reason: "denied_no_interface" },
352
+ host_app_control: { shouldAttach: false, reason: "denied_no_interface" },
353
+ });
354
+ expect(fields.preactivatedSkillIds).toEqual([]);
355
+ });
356
+
357
+ test("log captures cross_client + clientCount when a web source has a connected host_cu client", () => {
358
+ setCapableClient("host_cu", true);
359
+ const target = makeTarget();
360
+ preactivateHostProxySkills(target, "web");
361
+
362
+ expect(loggedInfoCalls).toHaveLength(1);
363
+ const decisions = loggedInfoCalls[0].fields.decisions as Record<
364
+ string,
365
+ unknown
366
+ >;
367
+ expect(decisions.host_cu).toEqual({
368
+ shouldAttach: true,
369
+ reason: "cross_client",
370
+ clientCount: 1,
371
+ });
372
+ expect(decisions.host_app_control).toEqual({
373
+ shouldAttach: false,
374
+ reason: "denied_no_clients",
375
+ clientCount: 0,
376
+ });
377
+ expect(loggedInfoCalls[0].fields.preactivatedSkillIds).toEqual([
378
+ "computer-use",
379
+ ]);
380
+ });
381
+
382
+ test("log captures denied_chrome_extension reason for chrome-extension source", () => {
383
+ setCapableClient("host_cu", true);
384
+ const target = makeTarget();
385
+ preactivateHostProxySkills(target, "chrome-extension");
386
+
387
+ expect(loggedInfoCalls).toHaveLength(1);
388
+ const decisions = loggedInfoCalls[0].fields.decisions as Record<
389
+ string,
390
+ unknown
391
+ >;
392
+ expect(decisions.host_cu).toEqual({
393
+ shouldAttach: false,
394
+ reason: "denied_chrome_extension",
395
+ });
396
+ expect(loggedInfoCalls[0].fields.preactivatedSkillIds).toEqual([]);
397
+ });
398
+ });
@@ -0,0 +1,153 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ let configBackgroundInjection: string =
4
+ "This is a background turn — your guardian isn't watching. If anything noteworthy comes up, send them a notification so they see it when they're back by invoking the `notifications` skill (`assistant notifications send --message \"...\"`)";
5
+
6
+ const realLoaderForBackgroundTurnTest = await import("../config/loader.js");
7
+ const realGetConfigForBackgroundTurnTest =
8
+ realLoaderForBackgroundTurnTest.getConfig;
9
+ mock.module("../config/loader.js", () => ({
10
+ ...realLoaderForBackgroundTurnTest,
11
+ getConfig: () => {
12
+ const real = realGetConfigForBackgroundTurnTest();
13
+ return {
14
+ ...real,
15
+ conversations: {
16
+ ...real.conversations,
17
+ backgroundInjection: configBackgroundInjection,
18
+ },
19
+ };
20
+ },
21
+ }));
22
+
23
+ import {
24
+ DEFAULT_INJECTOR_ORDER,
25
+ defaultInjectorsPlugin,
26
+ } from "../plugins/defaults/injectors.js";
27
+ import {
28
+ registerPlugin,
29
+ resetPluginRegistryForTests,
30
+ } from "../plugins/registry.js";
31
+ import type { Injector, TurnContext } from "../plugins/types.js";
32
+
33
+ function findInjector(name: string): Injector {
34
+ const injector = defaultInjectorsPlugin.injectors?.find(
35
+ (candidate) => candidate.name === name,
36
+ );
37
+ if (!injector) {
38
+ throw new Error(`injector '${name}' not registered`);
39
+ }
40
+ return injector;
41
+ }
42
+
43
+ function makeContext(overrides: Partial<TurnContext> = {}): TurnContext {
44
+ return {
45
+ requestId: "req-test",
46
+ conversationId: "conv-test",
47
+ turnIndex: 0,
48
+ trust: { sourceChannel: "vellum", trustClass: "guardian" },
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ const backgroundInjector = findInjector("background-turn");
54
+
55
+ const DEFAULT_INJECTION_TEXT =
56
+ "This is a background turn — your guardian isn't watching. If anything noteworthy comes up, send them a notification so they see it when they're back by invoking the `notifications` skill (`assistant notifications send --message \"...\"`)";
57
+
58
+ describe("background-turn injector", () => {
59
+ beforeEach(() => {
60
+ resetPluginRegistryForTests();
61
+ registerPlugin(defaultInjectorsPlugin);
62
+ configBackgroundInjection = DEFAULT_INJECTION_TEXT;
63
+ });
64
+
65
+ test("returns null when isBackgroundConversation is false", async () => {
66
+ const result = await backgroundInjector.produce(
67
+ makeContext({
68
+ injectionInputs: {
69
+ isBackgroundConversation: false,
70
+ isNonInteractive: true,
71
+ },
72
+ }),
73
+ );
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ test("returns null when isBackgroundConversation is unset", async () => {
78
+ const result = await backgroundInjector.produce(
79
+ makeContext({ injectionInputs: { isNonInteractive: true } }),
80
+ );
81
+ expect(result).toBeNull();
82
+ });
83
+
84
+ test("returns null when the guardian is actively connected (interactive turn)", async () => {
85
+ const result = await backgroundInjector.produce(
86
+ makeContext({
87
+ injectionInputs: {
88
+ isBackgroundConversation: true,
89
+ isNonInteractive: false,
90
+ },
91
+ }),
92
+ );
93
+ expect(result).toBeNull();
94
+ });
95
+
96
+ test("returns null when isNonInteractive is unset", async () => {
97
+ const result = await backgroundInjector.produce(
98
+ makeContext({ injectionInputs: { isBackgroundConversation: true } }),
99
+ );
100
+ expect(result).toBeNull();
101
+ });
102
+
103
+ test("wraps configured text in <background_turn> tags when active and non-interactive", async () => {
104
+ const block = await backgroundInjector.produce(
105
+ makeContext({
106
+ injectionInputs: {
107
+ isBackgroundConversation: true,
108
+ isNonInteractive: true,
109
+ },
110
+ }),
111
+ );
112
+
113
+ expect(block).toEqual({
114
+ id: "background-turn",
115
+ text: `<background_turn>\n${DEFAULT_INJECTION_TEXT}\n</background_turn>`,
116
+ placement: "prepend-user-tail",
117
+ });
118
+ expect(backgroundInjector.order).toBe(
119
+ DEFAULT_INJECTOR_ORDER.backgroundTurn,
120
+ );
121
+ });
122
+
123
+ test("returns null when configured text is the empty string", async () => {
124
+ configBackgroundInjection = "";
125
+
126
+ const result = await backgroundInjector.produce(
127
+ makeContext({
128
+ injectionInputs: {
129
+ isBackgroundConversation: true,
130
+ isNonInteractive: true,
131
+ },
132
+ }),
133
+ );
134
+ expect(result).toBeNull();
135
+ });
136
+
137
+ test("uses operator-configured override text verbatim", async () => {
138
+ configBackgroundInjection = "Custom reminder body.";
139
+
140
+ const block = await backgroundInjector.produce(
141
+ makeContext({
142
+ injectionInputs: {
143
+ isBackgroundConversation: true,
144
+ isNonInteractive: true,
145
+ },
146
+ }),
147
+ );
148
+
149
+ expect(block?.text).toBe(
150
+ "<background_turn>\nCustom reminder body.\n</background_turn>",
151
+ );
152
+ });
153
+ });