pi-lean-dimension 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/node_modules/pi-lean-portal/AGENTS.md +15 -23
  3. package/node_modules/pi-lean-portal/README.md +31 -1
  4. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +12 -1
  5. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +263 -1102
  6. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +6 -32
  7. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +1 -1
  8. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +1 -2
  9. package/node_modules/pi-lean-portal/__tests__/helpers/setup-temp-dir.ts +26 -0
  10. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +1 -1
  11. package/node_modules/pi-lean-portal/__tests__/plugin-contract.test.ts +1 -1
  12. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +1 -1
  13. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +65 -69
  15. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +1 -14
  16. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +31 -16
  17. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +1 -1
  18. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +146 -19
  19. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  20. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  21. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  22. package/node_modules/pi-lean-portal/backends/python-adapter.ts +3 -3
  23. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +2 -4
  24. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  25. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  26. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  27. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  28. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  29. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +572 -81
  30. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  31. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  32. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  33. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  34. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  35. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  36. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +13 -43
  37. package/node_modules/pi-lean-portal/browser-profile.ts +2 -3
  38. package/node_modules/pi-lean-portal/browser-toggle.ts +168 -380
  39. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +2 -2
  40. package/node_modules/pi-lean-portal/contributed/README.md +65 -6
  41. package/node_modules/pi-lean-portal/contributed/camoufox-py/__pycache__/bridge.cpython-312.pyc +0 -0
  42. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +108 -14
  43. package/node_modules/pi-lean-portal/contributed/camoufox-py/pin.json +4 -0
  44. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +1 -1
  45. package/node_modules/pi-lean-portal/core/fetch-backend.ts +10 -30
  46. package/node_modules/pi-lean-portal/core/guides.ts +14 -18
  47. package/node_modules/pi-lean-portal/core/router.ts +2 -20
  48. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +19 -30
  49. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +4 -4
  50. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +2 -2
  51. package/node_modules/pi-lean-portal/core/shared/paths.ts +16 -2
  52. package/node_modules/pi-lean-portal/core/shared/settings-reader.ts +2 -7
  53. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +1 -1
  54. package/node_modules/pi-lean-portal/index.ts +8 -1
  55. package/node_modules/pi-lean-portal/package.json +2 -1
  56. package/node_modules/pi-lean-portal/tools/web-learn.ts +1 -4
  57. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +87 -0
  58. package/node_modules/pi-lean-search/index.ts +134 -64
  59. package/node_modules/pi-lean-search/package.json +2 -1
  60. package/node_modules/playwright/README.md +3 -3
  61. package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +2 -2
  62. package/node_modules/playwright/lib/cli/reportActions.js +2 -2
  63. package/node_modules/playwright/lib/common/index.js +77 -52
  64. package/node_modules/playwright/lib/common/index.js.txt +10 -11
  65. package/node_modules/playwright/lib/errorContext.js +2 -3
  66. package/node_modules/playwright/lib/index.js +30 -8
  67. package/node_modules/playwright/lib/matchers/expect.js +4811 -4379
  68. package/node_modules/playwright/lib/matchers/expect.js.LICENSE +87 -140
  69. package/node_modules/playwright/lib/matchers/expect.js.txt +30 -35
  70. package/node_modules/playwright/lib/program.js +19 -0
  71. package/node_modules/playwright/lib/runner/index.js +309 -153
  72. package/node_modules/playwright/lib/runner/index.js.txt +13 -12
  73. package/node_modules/playwright/lib/transform/babelBundle.js +12899 -11654
  74. package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +379 -354
  75. package/node_modules/playwright/lib/transform/babelBundle.js.txt +39 -39
  76. package/node_modules/playwright/lib/transform/esmLoader.js +3129 -1116
  77. package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +81 -61
  78. package/node_modules/playwright/lib/transform/esmLoader.js.txt +21 -18
  79. package/node_modules/playwright/lib/util.js +2 -29
  80. package/node_modules/playwright/lib/worker/workerProcessEntry.js +14 -32
  81. package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +7 -8
  82. package/node_modules/playwright/package.json +3 -6
  83. package/node_modules/playwright/types/test.d.ts +391 -4
  84. package/node_modules/playwright/types/testReporter.d.ts +79 -4
  85. package/node_modules/playwright-core/browsers.json +12 -18
  86. package/node_modules/playwright-core/lib/bootstrap.js +1 -1
  87. package/node_modules/playwright-core/lib/coreBundle.js +11344 -9900
  88. package/node_modules/playwright-core/lib/server/electron/loader.js +5 -2
  89. package/node_modules/playwright-core/lib/serverRegistry.js +5 -1
  90. package/node_modules/playwright-core/lib/tools/cli-client/help.json +21 -6
  91. package/node_modules/playwright-core/lib/tools/cli-client/program.js +1 -1
  92. package/node_modules/playwright-core/lib/tools/cli-client/session.js +4 -0
  93. package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/SKILL.md +21 -5
  94. package/node_modules/playwright-core/lib/tools/{cli-client/skill/references/spec-driven-testing.md → skills/playwright-cli/references/test-generation.md} +135 -7
  95. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +143 -0
  96. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
  97. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +85 -0
  98. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +67 -0
  99. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +75 -0
  100. package/node_modules/playwright-core/lib/tools/utils/extension.js +31 -8
  101. package/node_modules/playwright-core/lib/utilsBundle.js +37698 -33670
  102. package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +391 -268
  103. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-CyWAfh-p.js +11 -0
  104. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-DhC616m4.css +1 -0
  105. package/node_modules/playwright-core/lib/vite/dashboard/index.html +2 -2
  106. package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -1
  107. package/node_modules/playwright-core/lib/vite/htmlReport/report.js +15 -55
  108. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
  109. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CwYYHbcZ.js +32 -0
  110. package/node_modules/playwright-core/lib/vite/recorder/assets/index-DYjdXIbE.js +129 -0
  111. package/node_modules/playwright-core/lib/vite/recorder/assets/index-l_lX622x.css +1 -0
  112. package/node_modules/playwright-core/lib/vite/recorder/index.html +2 -2
  113. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-By56iMx7.js +32 -0
  114. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-B34OrIms.js +181 -0
  115. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
  116. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-COQkjINf.js +7 -0
  117. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
  118. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.BLFoOugd.css +1 -0
  119. package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
  120. package/node_modules/playwright-core/lib/vite/traceViewer/index.Dl36UVQT.js +1 -0
  121. package/node_modules/playwright-core/lib/vite/traceViewer/index.html +5 -5
  122. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
  123. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +2 -2
  124. package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +3 -4
  125. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.C7UW1sC9.css +1 -0
  126. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.D962mr9b.js +5 -0
  127. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +5 -5
  128. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
  129. package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  130. package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  131. package/node_modules/playwright-core/lib/xdg-open +338 -137
  132. package/node_modules/playwright-core/package.json +2 -2
  133. package/node_modules/playwright-core/types/protocol.d.ts +249 -36
  134. package/node_modules/playwright-core/types/structs.d.ts +3 -1
  135. package/node_modules/playwright-core/types/types.d.ts +2009 -266
  136. package/package.json +3 -3
  137. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +0 -188
  138. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  139. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  140. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +0 -525
  141. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  142. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  143. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  144. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  147. package/node_modules/pi-lean-portal/core/shared/url-safety.ts +0 -164
  148. package/node_modules/playwright-core/lib/server/deviceDescriptorsSource.json +0 -2739
  149. package/node_modules/playwright-core/lib/tools/cli-client/skill/references/test-generation.md +0 -134
  150. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-BY2S1tHT.css +0 -1
  151. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-C_5TMfeg.js +0 -52
  152. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-DYBRYzYX.css +0 -1
  153. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-DeBYQozu.js +0 -32
  154. package/node_modules/playwright-core/lib/vite/recorder/assets/index-4ZiSSCmn.css +0 -1
  155. package/node_modules/playwright-core/lib/vite/recorder/assets/index-Bq-mQf8S.js +0 -193
  156. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-LEHpjmcn.js +0 -32
  157. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-BNmKHKpQ.js +0 -264
  158. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-BYQrIQwR.js +0 -1
  159. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-CsJ4vdCR.js +0 -9
  160. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.DYBRYzYX.css +0 -1
  161. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.CjdS-WJx.css +0 -1
  162. package/node_modules/playwright-core/lib/vite/traceViewer/index.CzXZzn5A.css +0 -1
  163. package/node_modules/playwright-core/lib/vite/traceViewer/index.DMMX1gXU.js +0 -2
  164. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.v8KI4P3m.js +0 -2
  165. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.BZQ54Kgt.css +0 -1
  166. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.Ut8wwJNp.js +0 -6
  167. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.DYP7pi_n.css +0 -32
  168. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/element-attributes.md +0 -0
  169. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/playwright-tests.md +0 -0
  170. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/request-mocking.md +0 -0
  171. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/running-code.md +0 -0
  172. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/session-management.md +0 -0
  173. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/storage-state.md +0 -0
  174. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/tracing.md +0 -0
  175. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/video-recording.md +0 -0
  176. /package/node_modules/playwright-core/lib/tools/{trace → skills/playwright-trace}/SKILL.md +0 -0
@@ -1,47 +1,43 @@
1
1
  /**
2
- * Tests for browser-toggle.ts — the companion extension that provides
3
- * /web on | off | status commands for toggling browser automation tools.
2
+ * Integration tests for browser-toggle.ts — portal-specific concerns.
4
3
  *
5
- * Tests cover:
6
- * - Filtering logic (getRegisteredIn)
7
- * - State queries (isBrowserEnabled)
8
- * - State mutations (applyBrowserState, persistState)
9
- * - Branch-aware restoration (restoreFromBranch)
10
- * - Config-driven default (readBrowserToggleConfig, applyConfigDefault)
11
- * - Command registration and handler dispatch
4
+ * Invariant tests (peer composition, persist shape, restore) live in the
5
+ * pi-tool-masking library. These tests cover portal-specific wiring:
6
+ * - /web command dispatch
7
+ * - defaultProfile persistence under portal-conversation-state
8
+ * - glyph render on session_start
9
+ * - cached state (getToggleState / getLearnState) reflects library state
12
10
  */
13
11
 
14
12
  import { describe, it, expect, vi, beforeEach } from "vitest";
15
- import { existsSync, readFileSync } from "node:fs";
13
+ import { EventEmitter } from "node:events";
16
14
  import type {
17
15
  ExtensionAPI,
18
16
  ExtensionContext,
19
17
  } from "@earendil-works/pi-coding-agent";
20
- import browserToggle, { getToggleState } from "../browser-toggle.js";
21
- import {
22
- getRegisteredIn,
23
- isBrowserEnabled,
24
- applyBrowserState,
25
- persistState,
26
- restoreFromBranch,
18
+ import { TOOLSET_EVENTS, setDefaultResolutionMode } from "pi-tool-masking";
19
+ import browserToggle, {
20
+ getToggleState,
21
+ getLearnState,
22
+ getConversationDefaultProfile,
27
23
  _resetToggleStateForTest,
28
- readBrowserToggleConfig,
29
- applyConfigDefault,
30
- isLearnEnabled,
31
- applyLearnState,
32
- type BrowserToggleState,
33
24
  } from "../browser-toggle.js";
34
25
 
35
- // Mock node:fs so readBrowserToggleConfig and applyConfigDefault can be
36
- // tested without touching the real filesystem.
37
- vi.mock("node:fs", () => ({
38
- existsSync: vi.fn(),
39
- readFileSync: vi.fn(),
40
- }));
26
+ // Clean globalThis registry between test files
27
+ const REGISTRY_KEY = "__piToolMaskingRegistry";
28
+ const RESTORE_EVENT_KEY = "__piToolMaskingLastRestoreEvent";
29
+ const MODULE_STATE_KEY = "__piToolMaskingModuleState";
30
+
31
+ beforeEach(() => {
32
+ _resetToggleStateForTest();
33
+ delete (globalThis as any)[REGISTRY_KEY];
34
+ delete (globalThis as any)[RESTORE_EVENT_KEY];
35
+ delete (globalThis as any)[MODULE_STATE_KEY];
36
+ });
41
37
 
42
38
  // ─── Fixtures ────────────────────────────────────────────────────
43
39
 
44
- const ALL_BROWSER_TOOLS = [
40
+ const ALL_TOOLS = [
45
41
  { name: "web-fetch", description: "fetch" },
46
42
  { name: "browser-navigate", description: "navigate" },
47
43
  { name: "browser-snapshot", description: "snapshot" },
@@ -53,1195 +49,360 @@ const ALL_BROWSER_TOOLS = [
53
49
  { name: "browser-console", description: "console" },
54
50
  { name: "browser-inspect", description: "inspect" },
55
51
  { name: "web-guide", description: "guide" },
56
- ];
57
-
58
- const SOME_BROWSER_TOOLS = [
59
- { name: "web-fetch", description: "fetch" },
60
- { name: "browser-navigate", description: "navigate" },
61
- { name: "browser-console", description: "console" },
62
- ];
63
-
64
- const NON_BROWSER_TOOLS = [
52
+ { name: "web-learn", description: "learn" },
65
53
  { name: "read", description: "read files" },
66
54
  { name: "bash", description: "shell" },
67
55
  { name: "edit", description: "edit files" },
68
56
  { name: "write", description: "write files" },
69
- { name: "grep", description: "search" },
70
- { name: "find", description: "find files" },
71
- { name: "ls", description: "list files" },
72
57
  ];
73
58
 
74
- // ─── Mock builders ───────────────────────────────────────────────
75
-
76
- interface MockPiOptions {
77
- tools?: Array<{ name: string; description?: string }>;
78
- activeTools?: string[];
59
+ const BROWSER_TOOL_NAMES = new Set(
60
+ ALL_TOOLS.filter(
61
+ (t) =>
62
+ t.name !== "web-learn" &&
63
+ t.name !== "read" &&
64
+ t.name !== "bash" &&
65
+ t.name !== "edit" &&
66
+ t.name !== "write",
67
+ ).map((t) => t.name),
68
+ );
69
+
70
+ // ─── Mock builder ────────────────────────────────────────────────
71
+
72
+ interface MockPi {
73
+ pi: ExtensionAPI;
74
+ events: EventEmitter;
75
+ handlers: Map<string, Array<(...args: any[]) => void>>;
76
+ entryCalls: Array<{ customType: string; data: unknown }>;
79
77
  }
80
78
 
81
- /**
82
- * Build a minimal ExtensionAPI mock for testing the helper functions.
83
- * Only implements the methods that the helpers actually call.
84
- */
85
- function mockPi(opts: MockPiOptions = {}): ExtensionAPI {
86
- const allTools = opts.tools ?? [];
87
- let active = opts.activeTools ?? allTools.map((t) => t.name);
79
+ function mockPi(initialTools?: string[]): MockPi {
80
+ let active = initialTools ?? ALL_TOOLS.map((t) => t.name);
81
+ const eventEmitter = new EventEmitter();
82
+ const handlers = new Map<string, Array<(...args: any[]) => void>>();
83
+ const entryCalls: Array<{ customType: string; data: unknown }> = [];
88
84
 
89
- return {
90
- getAllTools: vi.fn(() => allTools as any),
91
- getActiveTools: vi.fn(() => active),
85
+ const pi = {
86
+ getAllTools: vi.fn(() => ALL_TOOLS as any),
87
+ getActiveTools: vi.fn(() => [...active]),
92
88
  setActiveTools: vi.fn((names: string[]) => {
93
- active = names;
89
+ active = [...names];
90
+ }),
91
+ appendEntry: vi.fn((customType: string, data?: unknown) => {
92
+ entryCalls.push({ customType, data });
94
93
  }),
95
- appendEntry: vi.fn(),
96
94
  registerCommand: vi.fn(),
97
- on: vi.fn(),
95
+ registerTool: vi.fn(),
96
+ on: vi.fn(<T>(event: string, handler: (event: T, ctx: any) => void) => {
97
+ if (!handlers.has(event)) handlers.set(event, []);
98
+ handlers.get(event)!.push(handler as any);
99
+ }),
100
+ get events() {
101
+ return {
102
+ emit: (channel: string, data: unknown) =>
103
+ eventEmitter.emit(channel, data),
104
+ on: (channel: string, handler: (data: unknown) => void) => {
105
+ eventEmitter.on(channel, handler);
106
+ return () => eventEmitter.off(channel, handler);
107
+ },
108
+ };
109
+ },
98
110
  } as unknown as ExtensionAPI;
111
+
112
+ return { pi, events: eventEmitter, handlers, entryCalls };
99
113
  }
100
114
 
101
- /**
102
- * Build a minimal ExtensionContext mock for restoreFromBranch tests.
103
- */
104
- function mockContext(
105
- branchEntries: Array<{
106
- type: string;
107
- customType?: string;
108
- data?: unknown;
109
- }>,
110
- ): ExtensionContext {
115
+ function mockCtx(overrides: Partial<ExtensionContext> = {}): any {
111
116
  return {
112
- sessionManager: {
113
- getBranch: vi.fn(() => branchEntries as any),
117
+ sessionManager: { getBranch: () => [] },
118
+ ui: {
119
+ setStatus: vi.fn(),
120
+ theme: { fg: (_c: string, t: string) => t },
121
+ notify: vi.fn(),
114
122
  },
115
- ui: { notify: vi.fn() },
116
- } as unknown as ExtensionContext;
123
+ mode: "tui",
124
+ cwd: "/mock",
125
+ hasUI: true,
126
+ isIdle: () => true,
127
+ modelRegistry: {} as any,
128
+ ...overrides,
129
+ };
117
130
  }
118
131
 
119
- // ==================================================================
120
- // getRegisteredIn
121
- // ==================================================================
122
- describe("getRegisteredIn", () => {
123
- const BROWSER_NAMES = new Set([
124
- "web-fetch",
125
- "browser-navigate",
126
- "browser-snapshot",
127
- "browser-click",
128
- "browser-type",
129
- "browser-scroll",
130
- "browser-back",
131
- "browser-press",
132
- "browser-console",
133
- "browser-inspect",
134
- "web-guide",
135
- ]);
136
-
137
- it("returns empty array when no tools are registered", () => {
138
- const pi = mockPi({ tools: [] });
139
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([]);
140
- });
141
-
142
- it("returns empty array when only non-matching tools are registered", () => {
143
- const pi = mockPi({ tools: NON_BROWSER_TOOLS });
144
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([]);
145
- });
146
-
147
- it("returns only matching tools when mixed with non-matching tools", () => {
148
- const pi = mockPi({
149
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
150
- });
151
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
152
- ALL_BROWSER_TOOLS.map((t) => t.name),
153
- );
154
- });
155
-
156
- it("returns all matching tools when all are present", () => {
157
- const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
158
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
159
- ALL_BROWSER_TOOLS.map((t) => t.name),
160
- );
161
- });
162
-
163
- it("returns a subset when only some matching tools are registered", () => {
164
- const pi = mockPi({ tools: SOME_BROWSER_TOOLS });
165
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
166
- SOME_BROWSER_TOOLS.map((t) => t.name),
167
- );
168
- });
169
-
170
- it("is case-sensitive (tool names are always lowercase)", () => {
171
- const pi = mockPi({
172
- tools: [
173
- { name: "Web-Fetch" },
174
- { name: "BROWSER-NAVIGATE" },
175
- { name: "web-fetch" },
176
- ],
177
- });
178
- // Only the exact-lowercase match should be returned
179
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(["web-fetch"]);
180
- });
181
-
182
- it("ignores tool metadata beyond the name field", () => {
183
- const tools = [
184
- { name: "web-fetch", description: "a", extra: 1 },
185
- { name: "browser-scroll", description: "b", promptGuidelines: [] },
186
- ];
187
- const pi = mockPi({ tools: tools as any });
188
- expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([
189
- "web-fetch",
190
- "browser-scroll",
191
- ]);
192
- });
193
- });
132
+ /** Capture the /web command handler from registerCommand. */
133
+ function captureWebHandler(
134
+ pi: ExtensionAPI,
135
+ ): (args: string, ctx: any) => Promise<void> {
136
+ return (pi.registerCommand as any).mock.calls[0][1].handler;
137
+ }
194
138
 
195
139
  // ==================================================================
196
- // isBrowserEnabled
140
+ // Default export
197
141
  // ==================================================================
198
- describe("isBrowserEnabled", () => {
199
- it("returns true when all browser tools are active", () => {
200
- const pi = mockPi({
201
- tools: ALL_BROWSER_TOOLS,
202
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
203
- });
204
- expect(isBrowserEnabled(pi)).toBe(true);
205
- });
206
-
207
- it("returns false when no browser tools are active", () => {
208
- const pi = mockPi({
209
- tools: ALL_BROWSER_TOOLS,
210
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
211
- });
212
- expect(isBrowserEnabled(pi)).toBe(false);
213
- });
214
-
215
- it("returns true when at least one browser tool is active (partial)", () => {
216
- const pi = mockPi({
217
- tools: ALL_BROWSER_TOOLS,
218
- activeTools: ["read", "bash", "web-fetch", "browser-navigate"],
219
- });
220
- expect(isBrowserEnabled(pi)).toBe(true);
221
- });
222
-
223
- it("returns false when no browser tools exist", () => {
224
- const pi = mockPi({
225
- tools: NON_BROWSER_TOOLS,
226
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
227
- });
228
- expect(isBrowserEnabled(pi)).toBe(false);
142
+ describe("initBrowserToggle", () => {
143
+ it("registers the /web command", () => {
144
+ const { pi } = mockPi();
145
+ browserToggle(pi);
146
+ expect(pi.registerCommand).toHaveBeenCalledWith(
147
+ "web",
148
+ expect.objectContaining({
149
+ description: expect.stringContaining("browser"),
150
+ }),
151
+ );
229
152
  });
230
153
 
231
- it("returns false when active set is empty and browser tools exist", () => {
232
- const pi = mockPi({
233
- tools: ALL_BROWSER_TOOLS,
234
- activeTools: [],
235
- });
236
- expect(isBrowserEnabled(pi)).toBe(false);
154
+ it("registers session_start and session_tree handlers", () => {
155
+ const { pi, handlers } = mockPi();
156
+ browserToggle(pi);
157
+ expect(handlers.has("session_start")).toBe(true);
158
+ expect(handlers.has("session_tree")).toBe(true);
237
159
  });
238
160
  });
239
161
 
240
162
  // ==================================================================
241
- // applyBrowserState
163
+ // /web command dispatch
242
164
  // ==================================================================
243
- describe("applyBrowserState(true) — enable", () => {
244
- it("adds browser tools to the active set", () => {
245
- const pi = mockPi({
246
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
247
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
248
- });
249
- applyBrowserState(pi, true);
250
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
251
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
252
- expect(calledWith).toEqual(
253
- expect.arrayContaining(ALL_BROWSER_TOOLS.map((t) => t.name)),
254
- );
255
- });
165
+ describe("/web command dispatch", () => {
166
+ it("on — enables web tools, disables learn tools", async () => {
167
+ const { pi } = mockPi([]);
168
+ browserToggle(pi);
256
169
 
257
- it("preserves non-browser tools when enabling", () => {
258
- const pi = mockPi({
259
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
260
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
261
- });
262
- applyBrowserState(pi, true);
263
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
264
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
265
- for (const tool of NON_BROWSER_TOOLS.map((t) => t.name)) {
266
- expect(calledWith).toContain(tool);
267
- }
268
- });
170
+ await captureWebHandler(pi)("on", mockCtx());
269
171
 
270
- it("does not duplicate already-active browser tools", () => {
271
- const pi = mockPi({
272
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
273
- activeTools: [
274
- ...NON_BROWSER_TOOLS.map((t) => t.name),
275
- "web-fetch",
276
- "browser-navigate",
277
- ],
278
- });
279
- applyBrowserState(pi, true);
280
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
281
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
282
- // Each browser tool appears exactly once
283
- for (const tool of ALL_BROWSER_TOOLS.map((t) => t.name)) {
284
- expect(calledWith.filter((n) => n === tool)).toHaveLength(1);
172
+ const finalActive = pi.getActiveTools();
173
+ for (const name of BROWSER_TOOL_NAMES) {
174
+ expect(finalActive).toContain(name);
285
175
  }
176
+ expect(finalActive).not.toContain("web-learn");
286
177
  });
287
178
 
288
- it("reports the union of current and browser tools (no duplicates)", () => {
289
- const pi = mockPi({
290
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
291
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
292
- });
293
- applyBrowserState(pi, true);
294
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
295
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
296
- const expected = [
297
- ...NON_BROWSER_TOOLS.map((t) => t.name),
298
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
299
- ];
300
- expect(new Set(calledWith)).toEqual(new Set(expected));
301
- });
179
+ it("off — disables web tools (learn cascades off via requires)", async () => {
180
+ const { pi } = mockPi();
181
+ browserToggle(pi);
302
182
 
303
- it("is a no-op when no browser tools are registered", () => {
304
- const pi = mockPi({
305
- tools: NON_BROWSER_TOOLS,
306
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
307
- });
308
- applyBrowserState(pi, true);
309
- expect(pi.setActiveTools).not.toHaveBeenCalled();
310
- });
311
- });
183
+ await captureWebHandler(pi)("off", mockCtx());
312
184
 
313
- describe("applyBrowserState(false) — disable", () => {
314
- it("removes browser tools from the active set", () => {
315
- const pi = mockPi({
316
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
317
- activeTools: [
318
- ...NON_BROWSER_TOOLS.map((t) => t.name),
319
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
320
- ],
321
- });
322
- applyBrowserState(pi, false);
323
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
324
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
325
- for (const tool of ALL_BROWSER_TOOLS.map((t) => t.name)) {
326
- expect(calledWith).not.toContain(tool);
185
+ const finalActive = pi.getActiveTools();
186
+ for (const name of BROWSER_TOOL_NAMES) {
187
+ expect(finalActive).not.toContain(name);
327
188
  }
189
+ expect(finalActive).not.toContain("web-learn");
328
190
  });
329
191
 
330
- it("preserves non-browser tools when disabling", () => {
331
- const pi = mockPi({
332
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
333
- activeTools: [
334
- ...NON_BROWSER_TOOLS.map((t) => t.name),
335
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
336
- ],
337
- });
338
- applyBrowserState(pi, false);
339
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
340
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
341
- for (const tool of NON_BROWSER_TOOLS.map((t) => t.name)) {
342
- expect(calledWith).toContain(tool);
343
- }
344
- });
345
-
346
- it("is a no-op when no browser tools are registered", () => {
347
- const pi = mockPi({
348
- tools: NON_BROWSER_TOOLS,
349
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
350
- });
351
- applyBrowserState(pi, false);
352
- expect(pi.setActiveTools).not.toHaveBeenCalled();
353
- });
192
+ it("learn — enables both web and learn tools", async () => {
193
+ const { pi } = mockPi([]);
194
+ browserToggle(pi);
354
195
 
355
- it("is a no-op when no browser tools are active (removes nothing)", () => {
356
- const pi = mockPi({
357
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
358
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
359
- });
360
- applyBrowserState(pi, false);
361
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
362
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
363
- expect(calledWith).toEqual(NON_BROWSER_TOOLS.map((t) => t.name));
364
- });
196
+ await captureWebHandler(pi)("learn", mockCtx());
365
197
 
366
- it("can be applied repeatedly without error", () => {
367
- const pi = mockPi({
368
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
369
- activeTools: [
370
- ...NON_BROWSER_TOOLS.map((t) => t.name),
371
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
372
- ],
373
- });
374
- applyBrowserState(pi, false);
375
- applyBrowserState(pi, false);
376
- expect(pi.setActiveTools).toHaveBeenCalledTimes(2);
377
- // Second call should also produce a clean set (no browser tools)
378
- const calledWith: string[] = (pi.setActiveTools as ReturnType<typeof vi.fn>)
379
- .mock.calls[1]![0] as string[];
380
- for (const tool of ALL_BROWSER_TOOLS.map((t) => t.name)) {
381
- expect(calledWith).not.toContain(tool);
198
+ const finalActive = pi.getActiveTools();
199
+ for (const name of BROWSER_TOOL_NAMES) {
200
+ expect(finalActive).toContain(name);
382
201
  }
383
- });
384
- });
385
-
386
- // ==================================================================
387
- // persistState
388
- // ==================================================================
389
- describe("persistState", () => {
390
- it("calls appendEntry with customType 'web-toggle-state'", () => {
391
- const pi = mockPi();
392
- persistState(pi, {
393
- browserToolsEnabled: true,
394
- learnToolsEnabled: false,
395
- defaultProfile: "none",
396
- });
397
- expect(pi.appendEntry).toHaveBeenCalledWith(
398
- "web-toggle-state",
399
- expect.any(Object),
400
- );
202
+ expect(finalActive).toContain("web-learn");
401
203
  });
402
204
 
403
- it("stores browserToolsEnabled: true and learnToolsEnabled: false on /web on", () => {
404
- const pi = mockPi();
405
- persistState(pi, {
406
- browserToolsEnabled: true,
407
- learnToolsEnabled: false,
408
- defaultProfile: "none",
409
- });
410
- expect(pi.appendEntry).toHaveBeenCalledWith("web-toggle-state", {
411
- browserToolsEnabled: true,
412
- learnToolsEnabled: false,
413
- defaultProfile: "none",
414
- });
415
- });
416
-
417
- it("stores browserToolsEnabled: false and learnToolsEnabled: false on /web off", () => {
418
- const pi = mockPi();
419
- persistState(pi, {
420
- browserToolsEnabled: false,
421
- learnToolsEnabled: false,
422
- defaultProfile: "none",
423
- });
424
- expect(pi.appendEntry).toHaveBeenCalledWith("web-toggle-state", {
425
- browserToolsEnabled: false,
426
- learnToolsEnabled: false,
427
- defaultProfile: "none",
428
- });
429
- });
430
-
431
- it("stores browserToolsEnabled: true and learnToolsEnabled: true on /web learn", () => {
432
- const pi = mockPi();
433
- persistState(pi, {
434
- browserToolsEnabled: true,
435
- learnToolsEnabled: true,
436
- defaultProfile: "none",
437
- });
438
- expect(pi.appendEntry).toHaveBeenCalledWith("web-toggle-state", {
439
- browserToolsEnabled: true,
440
- learnToolsEnabled: true,
441
- defaultProfile: "none",
442
- });
443
- });
444
- });
445
-
446
- // ==================================================================
447
- // getToggleState
448
- // ==================================================================
449
- describe("getToggleState", () => {
450
- beforeEach(() => {
451
- _resetToggleStateForTest();
452
- });
453
-
454
- it("returns true by default (browser tools start enabled)", () => {
455
- expect(getToggleState()).toBe(true);
456
- });
457
-
458
- it("returns false after disabling via applyBrowserState", () => {
459
- const pi = mockPi({
460
- tools: ALL_BROWSER_TOOLS,
461
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
462
- });
463
- applyBrowserState(pi, false);
464
- expect(getToggleState()).toBe(false);
465
- });
466
-
467
- it("returns true after re-enabling via applyBrowserState", () => {
468
- const pi = mockPi({
469
- tools: ALL_BROWSER_TOOLS,
470
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
471
- });
472
- applyBrowserState(pi, true);
473
- expect(getToggleState()).toBe(true);
474
- });
475
-
476
- it("is not affected by persistState", () => {
477
- persistState(mockPi(), {
478
- browserToolsEnabled: true,
479
- learnToolsEnabled: false,
480
- defaultProfile: "none",
481
- });
482
- // persistState doesn't call applyBrowserState, so state is unchanged
483
- expect(getToggleState()).toBe(true);
484
- });
485
-
486
- it("returns true when browserToolsEnabled is true (regardless of learn state)", () => {
487
- const pi = mockPi({
488
- tools: ALL_BROWSER_TOOLS,
489
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
490
- });
491
- // getToggleState returns browserToolsEnabled only (status bar compat)
492
- applyBrowserState(pi, true);
493
- applyLearnState(pi, true);
494
- expect(getToggleState()).toBe(true);
495
- });
205
+ it("handles unknown arg — shows status, no state change", async () => {
206
+ const { pi } = mockPi([]);
207
+ browserToggle(pi);
496
208
 
497
- it("reflects the most recent call to applyBrowserState", () => {
498
- const pi = mockPi({
499
- tools: ALL_BROWSER_TOOLS,
500
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
501
- });
502
- applyBrowserState(pi, false);
503
- expect(getToggleState()).toBe(false);
504
- applyBrowserState(pi, true);
505
- expect(getToggleState()).toBe(true);
506
- applyBrowserState(pi, false);
507
- expect(getToggleState()).toBe(false);
508
- });
509
- });
209
+ (pi.setActiveTools as any).mockClear();
210
+ await captureWebHandler(pi)("xyz", mockCtx());
510
211
 
511
- // ==================================================================
512
- // restoreFromBranch
513
- // ==================================================================
514
- describe("restoreFromBranch", () => {
515
- it("does nothing when the branch has no saved state", () => {
516
- const pi = mockPi({
517
- tools: ALL_BROWSER_TOOLS,
518
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
519
- });
520
- const ctx = mockContext([
521
- { type: "message", data: { role: "user" } },
522
- { type: "message", data: { role: "assistant" } },
523
- ]);
524
- vi.spyOn(pi, "setActiveTools");
525
- restoreFromBranch(pi, ctx);
526
212
  expect(pi.setActiveTools).not.toHaveBeenCalled();
527
213
  });
528
214
 
529
- it("applies saved browserToolsEnabled=true learnToolsEnabled=false state ", () => {
530
- const allNames = ALL_BROWSER_TOOLS.map((t) => t.name);
531
- const pi = mockPi({
532
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
533
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name), // browsers off
534
- });
535
- const ctx = mockContext([
536
- {
537
- type: "custom",
538
- customType: "web-toggle-state",
539
- data: {
540
- browserToolsEnabled: true,
541
- learnToolsEnabled: false,
542
- defaultProfile: "none",
543
- } satisfies BrowserToggleState,
544
- },
545
- ]);
546
- restoreFromBranch(pi, ctx);
547
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
548
- expect(setActive).toHaveBeenCalledWith(expect.arrayContaining(allNames));
549
- });
550
-
551
- it("applies saved browserToolsEnabled=false learnToolsEnabled=false state", () => {
552
- const pi = mockPi({
553
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
554
- activeTools: [
555
- ...NON_BROWSER_TOOLS.map((t) => t.name),
556
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
557
- ],
558
- });
559
- const ctx = mockContext([
560
- {
561
- type: "custom",
562
- customType: "web-toggle-state",
563
- data: {
564
- browserToolsEnabled: false,
565
- learnToolsEnabled: false,
566
- defaultProfile: "none",
567
- } satisfies BrowserToggleState,
568
- },
569
- ]);
570
- restoreFromBranch(pi, ctx);
571
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
572
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
573
- for (const tool of ALL_BROWSER_TOOLS.map((t) => t.name)) {
574
- expect(calledWith).not.toContain(tool);
575
- }
576
- });
215
+ it("handles empty args — shows status, no state change", async () => {
216
+ const { pi } = mockPi([]);
217
+ browserToggle(pi);
577
218
 
578
- it("picks the last saved state when multiple toggles exist in branch", () => {
579
- const allNames = ALL_BROWSER_TOOLS.map((t) => t.name);
580
- const pi = mockPi({
581
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
582
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
583
- });
584
- const ctx = mockContext([
585
- {
586
- type: "custom",
587
- customType: "web-toggle-state",
588
- data: { browserToolsEnabled: true, learnToolsEnabled: false },
589
- },
590
- {
591
- type: "custom",
592
- customType: "web-toggle-state",
593
- data: { browserToolsEnabled: false, learnToolsEnabled: false },
594
- },
595
- {
596
- type: "custom",
597
- customType: "web-toggle-state",
598
- data: { browserToolsEnabled: true, learnToolsEnabled: true },
599
- },
600
- ]);
601
- restoreFromBranch(pi, ctx);
602
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
603
- expect(setActive).toHaveBeenCalledWith(expect.arrayContaining(allNames));
604
- });
219
+ (pi.setActiveTools as any).mockClear();
220
+ await captureWebHandler(pi)("", mockCtx());
605
221
 
606
- it("does nothing when no browser tools are registered", () => {
607
- const pi = mockPi({ tools: NON_BROWSER_TOOLS });
608
- const ctx = mockContext([
609
- {
610
- type: "custom",
611
- customType: "web-toggle-state",
612
- data: { enabled: false },
613
- },
614
- ]);
615
- vi.spyOn(pi, "setActiveTools");
616
- restoreFromBranch(pi, ctx);
617
- expect(pi.setActiveTools).not.toHaveBeenCalled();
618
- });
619
-
620
- it("gracefully handles stale state (tool no longer registered)", () => {
621
- const pi = mockPi({
622
- tools: NON_BROWSER_TOOLS, // only web-fetch removed from all tools
623
- activeTools: [...NON_BROWSER_TOOLS.map((t) => t.name), "web-fetch"],
624
- });
625
- const ctx = mockContext([
626
- {
627
- type: "custom",
628
- customType: "web-toggle-state",
629
- data: {
630
- browserToolsEnabled: false,
631
- learnToolsEnabled: false,
632
- defaultProfile: "none",
633
- } satisfies BrowserToggleState,
634
- },
635
- ]);
636
- // Should not throw — with no browser tools currently registered,
637
- // restoreFromBranch returns early (no-op).
638
- expect(() => restoreFromBranch(pi, ctx)).not.toThrow();
639
222
  expect(pi.setActiveTools).not.toHaveBeenCalled();
640
223
  });
641
224
  });
642
225
 
643
226
  // ==================================================================
644
- // Default export — factory & command handler
227
+ // getToggleState / getLearnState
645
228
  // ==================================================================
646
- describe("default export (extension factory)", () => {
647
- it("registers the '/web' command", () => {
648
- const pi = mockPi({
649
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
650
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
651
- });
652
- browserToggle(pi);
653
- expect(pi.registerCommand).toHaveBeenCalledWith(
654
- "web",
655
- expect.objectContaining({
656
- description: expect.stringContaining("browser"),
657
- }),
658
- );
659
- });
660
-
661
- it("registers session_start handler", () => {
662
- const pi = mockPi();
663
- browserToggle(pi);
664
- expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
665
- });
666
-
667
- it("registers session_tree handler", () => {
668
- const pi = mockPi();
669
- browserToggle(pi);
670
- expect(pi.on).toHaveBeenCalledWith("session_tree", expect.any(Function));
671
- });
672
- });
673
-
674
- describe("command handler dispatch (via factory closure)", () => {
675
- it("handles 'on' when browsers are off — updates state", () => {
676
- const pi = mockPi({
677
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
678
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name), // browsers off
679
- });
680
- pi.registerCommand = vi.fn((_name, opts) => {
681
- opts.handler("on", {
682
- ui: {
683
- notify: vi.fn(),
684
- setStatus: vi.fn(),
685
- theme: { fg: vi.fn((_c: string, t: string) => t) },
686
- },
687
- } as any);
688
- }) as any;
689
- pi.setActiveTools = vi.fn();
690
-
691
- browserToggle(pi);
692
- expect(pi.setActiveTools).toHaveBeenCalled();
693
- });
694
-
695
- it("handles 'on' — does not short-circuit (no early return)", () => {
696
- // In the new three-state version, 'on' always calls applyBrowserState/applyLearnState
697
- const pi = mockPi({
698
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
699
- activeTools: [
700
- ...NON_BROWSER_TOOLS.map((t) => t.name),
701
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
702
- ],
703
- });
704
- pi.setActiveTools = vi.fn();
705
- pi.registerCommand = vi.fn((_name, opts) => {
706
- opts.handler("on", {
707
- ui: {
708
- notify: vi.fn(),
709
- setStatus: vi.fn(),
710
- theme: { fg: vi.fn((_c: string, t: string) => t) },
711
- },
712
- } as any);
713
- }) as any;
714
-
715
- browserToggle(pi);
716
- // In the new three-state version, /web on always applies (no short-circuit)
717
- expect(pi.setActiveTools).toHaveBeenCalled();
718
- });
719
-
720
- it("handles 'off' when browsers are on — updates state", () => {
721
- const nonBrowserNames = NON_BROWSER_TOOLS.map((t) => t.name);
722
- const allNames = ALL_BROWSER_TOOLS.map((t) => t.name);
723
- const pi = mockPi({
724
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
725
- activeTools: [...nonBrowserNames, ...allNames],
726
- });
727
- pi.setActiveTools = vi.fn();
728
- pi.registerCommand = vi.fn((_name, opts) => {
729
- opts.handler("off", {
730
- ui: {
731
- notify: vi.fn(),
732
- setStatus: vi.fn(),
733
- theme: { fg: vi.fn((_c: string, t: string) => t) },
734
- },
735
- } as any);
736
- }) as any;
737
-
738
- browserToggle(pi);
739
- expect(pi.setActiveTools).toHaveBeenCalled();
229
+ describe("getToggleState / getLearnState", () => {
230
+ it("start true/false by default", () => {
231
+ expect(getToggleState()).toBe(true);
232
+ expect(getLearnState()).toBe(false);
740
233
  });
741
234
 
742
- it("handles 'off' when already disabled — still updates state", () => {
743
- // In the new three-state version, 'off' always applies (no early return)
744
- const pi = mockPi({
745
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
746
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
747
- });
748
- pi.setActiveTools = vi.fn();
749
- pi.registerCommand = vi.fn((_name, opts) => {
750
- opts.handler("off", {
751
- ui: {
752
- notify: vi.fn(),
753
- setStatus: vi.fn(),
754
- theme: { fg: vi.fn((_c: string, t: string) => t) },
755
- },
756
- } as any);
757
- }) as any;
758
-
235
+ it("update after /web on and /web off", async () => {
236
+ const { pi } = mockPi();
759
237
  browserToggle(pi);
760
- expect(pi.setActiveTools).toHaveBeenCalled();
761
- });
762
238
 
763
- it("handles 'learn' — enables both browser and learn tools", () => {
764
- const pi = mockPi({
765
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
766
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
767
- });
768
- pi.setActiveTools = vi.fn();
769
- pi.registerCommand = vi.fn((_name, opts) => {
770
- opts.handler("learn", {
771
- ui: {
772
- notify: vi.fn(),
773
- setStatus: vi.fn(),
774
- theme: { fg: vi.fn((_c: string, t: string) => t) },
775
- },
776
- } as any);
777
- }) as any;
239
+ const handler = captureWebHandler(pi);
240
+ await handler("off", mockCtx());
241
+ expect(getToggleState()).toBe(false);
778
242
 
779
- browserToggle(pi);
780
- expect(pi.setActiveTools).toHaveBeenCalled();
243
+ await handler("on", mockCtx());
244
+ expect(getToggleState()).toBe(true);
781
245
  });
782
246
 
783
- it("handles unknown arg (e.g. 'xyz') — shows status, no state change", () => {
784
- const pi = mockPi({
785
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
786
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
787
- });
788
- pi.setActiveTools = vi.fn();
789
- pi.registerCommand = vi.fn((_name, opts) => {
790
- opts.handler("xyz", {
791
- ui: {
792
- notify: vi.fn(),
793
- setStatus: vi.fn(),
794
- theme: { fg: vi.fn((_c: string, t: string) => t) },
795
- },
796
- } as any);
797
- }) as any;
798
-
247
+ it("getLearnState true after /web learn", async () => {
248
+ const { pi } = mockPi([]);
799
249
  browserToggle(pi);
800
- expect(pi.setActiveTools).not.toHaveBeenCalled();
801
- });
802
-
803
- it("handles missing pi-lean-portal — does not throw", () => {
804
- const pi = mockPi({ tools: NON_BROWSER_TOOLS });
805
- pi.registerCommand = vi.fn((_name, opts) => {
806
- opts.handler("on", {
807
- ui: {
808
- notify: vi.fn(),
809
- setStatus: vi.fn(),
810
- theme: { fg: vi.fn((_c: string, t: string) => t) },
811
- },
812
- } as any);
813
- }) as any;
814
- // Should not throw even though no browser tools exist
815
- expect(() => browserToggle(pi)).not.toThrow();
816
- });
817
250
 
818
- it("handles empty args string — shows status, no state change", () => {
819
- const pi = mockPi({
820
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
821
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
822
- });
823
- pi.setActiveTools = vi.fn();
824
- pi.registerCommand = vi.fn((_name, opts) => {
825
- opts.handler("", {
826
- ui: {
827
- notify: vi.fn(),
828
- setStatus: vi.fn(),
829
- theme: { fg: vi.fn((_c: string, t: string) => t) },
830
- },
831
- } as any);
832
- }) as any;
251
+ await captureWebHandler(pi)("learn", mockCtx());
833
252
 
834
- browserToggle(pi);
835
- expect(pi.setActiveTools).not.toHaveBeenCalled();
253
+ expect(getToggleState()).toBe(true);
254
+ expect(getLearnState()).toBe(true);
836
255
  });
837
256
  });
838
257
 
839
258
  // ==================================================================
840
- // getRegisteredIn (learn) / isLearnEnabled / applyLearnState
259
+ // appendEntry persist keys
841
260
  // ==================================================================
842
- describe("getRegisteredIn with learn tool names", () => {
843
- const LEARN_NAMES = new Set(["web-learn"]);
844
-
845
- it("returns empty array when no learn tools registered", () => {
846
- const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
847
- expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual([]);
848
- });
849
-
850
- it("returns web-learn when it's in the tool list", () => {
851
- const tools = [
852
- ...ALL_BROWSER_TOOLS,
853
- { name: "web-learn", description: "learn" },
854
- ];
855
- const pi = mockPi({ tools });
856
- expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual(["web-learn"]);
857
- });
858
-
859
- it("is case-sensitive (tool names are always lowercase)", () => {
860
- const tools = [
861
- ...ALL_BROWSER_TOOLS,
862
- { name: "Web-Learn" },
863
- { name: "web-learn" },
864
- ];
865
- const pi = mockPi({ tools });
866
- expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual(["web-learn"]);
867
- });
868
- });
261
+ describe("persistence key split", () => {
262
+ it("appends toolset-state:portal.web on enable", async () => {
263
+ const { pi } = mockPi([]);
264
+ browserToggle(pi);
869
265
 
870
- describe("isLearnEnabled", () => {
871
- it("returns true when web-learn is active", () => {
872
- const tools = [
873
- ...ALL_BROWSER_TOOLS,
874
- { name: "web-learn", description: "learn" },
875
- ];
876
- const pi = mockPi({
877
- tools,
878
- activeTools: [...ALL_BROWSER_TOOLS.map((t) => t.name), "web-learn"],
879
- });
880
- expect(isLearnEnabled(pi)).toBe(true);
881
- });
266
+ await captureWebHandler(pi)("on", mockCtx());
882
267
 
883
- it("returns false when web-learn is not active", () => {
884
- const tools = [
885
- ...ALL_BROWSER_TOOLS,
886
- { name: "web-learn", description: "learn" },
887
- ];
888
- const pi = mockPi({
889
- tools,
890
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
891
- });
892
- expect(isLearnEnabled(pi)).toBe(false);
268
+ const webCalls = (pi.appendEntry as any).mock.calls.filter(
269
+ (c: any) => c[0] === "toolset-state:portal.web",
270
+ );
271
+ expect(webCalls.length).toBeGreaterThanOrEqual(1);
893
272
  });
894
273
 
895
- it("returns false when no learn tools exist", () => {
896
- const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
897
- expect(isLearnEnabled(pi)).toBe(false);
898
- });
899
- });
274
+ it("appends toolset-state:portal.learn on learn", async () => {
275
+ const { pi } = mockPi([]);
276
+ browserToggle(pi);
900
277
 
901
- describe("applyLearnState", () => {
902
- it("enable adds web-learn to active set", () => {
903
- const tools = [
904
- ...ALL_BROWSER_TOOLS,
905
- { name: "web-learn", description: "learn" },
906
- ];
907
- const pi = mockPi({
908
- tools,
909
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
910
- });
911
- applyLearnState(pi, true);
912
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
913
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
914
- expect(calledWith).toContain("web-learn");
915
- });
278
+ await captureWebHandler(pi)("learn", mockCtx());
916
279
 
917
- it("disable removes web-learn from active set", () => {
918
- const tools = [
919
- ...ALL_BROWSER_TOOLS,
920
- { name: "web-learn", description: "learn" },
921
- ];
922
- const allNames = [...ALL_BROWSER_TOOLS.map((t) => t.name), "web-learn"];
923
- const pi = mockPi({
924
- tools,
925
- activeTools: allNames,
926
- });
927
- applyLearnState(pi, false);
928
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
929
- const calledWith: string[] = setActive.mock.calls[0]![0] as string[];
930
- expect(calledWith).not.toContain("web-learn");
931
- });
932
-
933
- it("is a no-op when no learn tools are registered", () => {
934
- const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
935
- applyLearnState(pi, true);
936
- expect(pi.setActiveTools).not.toHaveBeenCalled();
280
+ const learnCalls = (pi.appendEntry as any).mock.calls.filter(
281
+ (c: any) => c[0] === "toolset-state:portal.learn",
282
+ );
283
+ expect(learnCalls.length).toBeGreaterThanOrEqual(1);
937
284
  });
938
285
  });
939
286
 
940
287
  // ==================================================================
941
- // getRegisteredIn (sibling)
288
+ // session_start — renders glyph
942
289
  // ==================================================================
943
- describe("getRegisteredIn with sibling tool names", () => {
944
- const SIBLING_NAMES = new Set(["web-search"]);
290
+ describe("session_start integration", () => {
291
+ it("renders browser glyph on session_start", async () => {
292
+ const { pi, handlers } = mockPi();
293
+ browserToggle(pi);
945
294
 
946
- it("returns web-search when it's in the tool list", () => {
947
- const tools = [
948
- ...ALL_BROWSER_TOOLS,
949
- { name: "web-search", description: "SearXNG search" },
950
- ];
951
- const pi = mockPi({ tools });
952
- expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual(["web-search"]);
953
- });
295
+ const startHandlers = handlers.get("session_start")!;
296
+ const eventObj = {};
297
+ const ctx = mockCtx();
954
298
 
955
- it("uses exact-name Set.has() membership — no regex false positives", () => {
956
- const tools = [
957
- ...ALL_BROWSER_TOOLS,
958
- { name: "web-search-news", description: "news" },
959
- { name: "web-search-images", description: "images" },
960
- ];
961
- const pi = mockPi({ tools });
962
- expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual([]);
963
- });
299
+ for (const h of startHandlers) {
300
+ await h(eventObj, ctx);
301
+ }
964
302
 
965
- it("is case-sensitive — only exact lower-case matches", () => {
966
- const tools = [
967
- ...ALL_BROWSER_TOOLS,
968
- { name: "Web-Search" },
969
- { name: "web-search" },
970
- ];
971
- const pi = mockPi({ tools });
972
- expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual(["web-search"]);
303
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith(
304
+ "browser",
305
+ expect.any(String),
306
+ );
973
307
  });
974
308
  });
975
309
 
976
310
  // ==================================================================
977
- // applyConfigDefault — learn state
311
+ // conversation-default-profile
978
312
  // ==================================================================
979
- describe("applyConfigDefault (learn state)", () => {
980
- beforeEach(() => {
981
- vi.clearAllMocks();
982
- });
983
-
984
- it("learn state starts disabled", () => {
985
- // applyConfigDefault should set learnToolsEnabled to false
986
- const tools = [
987
- ...ALL_BROWSER_TOOLS,
988
- { name: "web-learn", description: "learn" },
989
- ];
990
- const pi = mockPi({
991
- tools,
992
- activeTools: [...ALL_BROWSER_TOOLS.map((t) => t.name), "web-learn"],
993
- });
994
- vi.mocked(existsSync).mockReturnValue(true);
995
- vi.mocked(readFileSync).mockReturnValue(
996
- JSON.stringify({ browserToggle: { defaultEnabled: true } }),
997
- );
998
- applyConfigDefault(pi);
999
-
1000
- // applyConfigDefault calls setActiveTools twice:
1001
- // 1. applyBrowserState(true) — adds browser tools
1002
- // 2. applyLearnState(false) — removes web-learn
1003
- // Check the *last* call's result (not the first)
1004
- const setActive = pi.setActiveTools as ReturnType<typeof vi.fn>;
1005
- const lastCall = setActive.mock.calls[setActive.mock.calls.length - 1]!;
1006
- const calledWith: string[] = lastCall[0] as string[];
1007
- expect(calledWith).not.toContain("web-learn");
1008
- });
1009
-
1010
- it("persists learnToolsEnabled: false", () => {
1011
- const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
1012
- vi.mocked(existsSync).mockReturnValue(true);
1013
- vi.mocked(readFileSync).mockReturnValue(
1014
- JSON.stringify({ browserToggle: { defaultEnabled: true } }),
1015
- );
1016
- applyConfigDefault(pi);
1017
-
1018
- expect(pi.appendEntry).toHaveBeenCalledWith(
1019
- "web-toggle-state",
1020
- expect.objectContaining({ learnToolsEnabled: false }),
1021
- );
313
+ describe("getConversationDefaultProfile", () => {
314
+ it("returns undefined initially", () => {
315
+ expect(getConversationDefaultProfile()).toBeUndefined();
1022
316
  });
1023
317
  });
1024
318
 
1025
319
  // ==================================================================
1026
- // readBrowserToggleConfig
320
+ // Focus-mode guard (Fix 3) — /web on/off/learn refuse during inclusion
1027
321
  // ==================================================================
1028
- describe("readBrowserToggleConfig", () => {
1029
- beforeEach(() => {
1030
- vi.clearAllMocks();
1031
- });
1032
-
1033
- it("returns true when neither settings file exists (default)", () => {
1034
- vi.mocked(existsSync).mockReturnValue(false);
1035
- expect(readBrowserToggleConfig()).toBe(true);
1036
- });
1037
-
1038
- it("returns true when settings files exist but have no browserToggle key", () => {
1039
- vi.mocked(existsSync).mockReturnValue(true);
1040
- vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "dark" }));
1041
- expect(readBrowserToggleConfig()).toBe(true);
322
+ describe("/web focus-mode guard", () => {
323
+ it("refuses /web on/off/learn while inclusion focus is active", async () => {
324
+ const { pi } = mockPi([]);
325
+ browserToggle(pi);
326
+ setDefaultResolutionMode(pi, "inclusion");
327
+
328
+ for (const sub of ["on", "off", "learn"]) {
329
+ (pi.setActiveTools as any).mockClear();
330
+ const ctx = mockCtx();
331
+ await captureWebHandler(pi)(sub, ctx);
332
+
333
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
334
+ expect.stringContaining("Another plugin has active inclusion mode"),
335
+ "warning",
336
+ );
337
+ expect(pi.setActiveTools).not.toHaveBeenCalled();
338
+ }
1042
339
  });
1043
340
 
1044
- it("returns false when browserToggle.defaultEnabled is false (global)", () => {
1045
- vi.mocked(existsSync).mockImplementation(
1046
- (path) => typeof path === "string" && path.includes("settings.json"),
1047
- );
1048
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1049
- typeof path === "string" && path.includes(".pi/agent/settings")
1050
- ? JSON.stringify({ browserToggle: { defaultEnabled: false } })
1051
- : JSON.stringify({ theme: "light" }),
1052
- );
1053
- expect(readBrowserToggleConfig()).toBe(false);
1054
- });
341
+ it("read-only subcommands unaffected by inclusion focus", async () => {
342
+ const { pi } = mockPi([]);
343
+ browserToggle(pi);
344
+ setDefaultResolutionMode(pi, "inclusion");
1055
345
 
1056
- it("returns true when browserToggle.defaultEnabled is true (global)", () => {
1057
- vi.mocked(existsSync).mockReturnValue(true);
1058
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1059
- typeof path === "string" && path.includes(".pi/agent/settings")
1060
- ? JSON.stringify({ browserToggle: { defaultEnabled: true } })
1061
- : JSON.stringify({}),
1062
- );
1063
- expect(readBrowserToggleConfig()).toBe(true);
1064
- });
346
+ for (const sub of ["status", "profile", "cookies", ""]) {
347
+ const ctx = mockCtx();
348
+ await captureWebHandler(pi)(sub, ctx);
1065
349
 
1066
- it("project settings override global settings", () => {
1067
- vi.mocked(existsSync).mockReturnValue(true);
1068
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1069
- typeof path === "string" && path.includes(".pi/agent/settings")
1070
- ? JSON.stringify({ browserToggle: { defaultEnabled: true } })
1071
- : JSON.stringify({ browserToggle: { defaultEnabled: false } }),
1072
- );
1073
- expect(readBrowserToggleConfig()).toBe(false);
350
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(
351
+ expect.stringContaining("Another plugin has active inclusion mode"),
352
+ "warning",
353
+ );
354
+ }
1074
355
  });
1075
356
 
1076
- it("returns true on malformed JSON", () => {
1077
- vi.mocked(existsSync).mockReturnValue(true);
1078
- vi.mocked(readFileSync).mockReturnValue("not valid json");
1079
- expect(readBrowserToggleConfig()).toBe(true);
1080
- });
357
+ it("actuating subcommands work when focus is off (exclusion)", async () => {
358
+ const { pi } = mockPi([]);
359
+ browserToggle(pi);
360
+ // default mode is exclusion after beforeEach reset
1081
361
 
1082
- it("returns true when browserToggle.defaultEnabled is not a boolean", () => {
1083
- vi.mocked(existsSync).mockReturnValue(true);
1084
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1085
- typeof path === "string" && path.includes(".pi/agent/settings")
1086
- ? JSON.stringify({ browserToggle: { defaultEnabled: "yes" } })
1087
- : JSON.stringify({}),
1088
- );
1089
- expect(readBrowserToggleConfig()).toBe(true);
1090
- });
362
+ const ctx = mockCtx();
363
+ await captureWebHandler(pi)("on", ctx);
1091
364
 
1092
- it("returns true when browserToggle is not an object", () => {
1093
- vi.mocked(existsSync).mockReturnValue(true);
1094
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1095
- typeof path === "string" && path.includes(".pi/agent/settings")
1096
- ? JSON.stringify({ browserToggle: "on" })
1097
- : JSON.stringify({}),
365
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(
366
+ expect.stringContaining("Another plugin has active inclusion mode"),
367
+ "warning",
1098
368
  );
1099
- expect(readBrowserToggleConfig()).toBe(true);
1100
- });
1101
-
1102
- it("returns true when settings file content is not an object", () => {
1103
- vi.mocked(existsSync).mockReturnValue(true);
1104
- vi.mocked(readFileSync).mockReturnValue("[]");
1105
- expect(readBrowserToggleConfig()).toBe(true);
369
+ const finalActive = pi.getActiveTools();
370
+ for (const name of BROWSER_TOOL_NAMES) {
371
+ expect(finalActive).toContain(name);
372
+ }
1106
373
  });
1107
374
  });
1108
375
 
1109
376
  // ==================================================================
1110
- // applyConfigDefault
377
+ // Glyph sync on external-plugin changed events
1111
378
  // ==================================================================
1112
- describe("applyConfigDefault", () => {
1113
- beforeEach(() => {
1114
- vi.clearAllMocks();
1115
- });
1116
-
1117
- it("calls applyBrowserState with the config value (true)", () => {
1118
- // Config says enabled
1119
- vi.mocked(existsSync).mockImplementation(
1120
- (path) => typeof path === "string" && path.includes("settings.json"),
1121
- );
1122
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1123
- typeof path === "string" && path.includes(".pi/agent/settings")
1124
- ? JSON.stringify({ browserToggle: { defaultEnabled: true } })
1125
- : JSON.stringify({}),
1126
- );
1127
-
1128
- const pi = mockPi({
1129
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
1130
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name), // browsers off
1131
- });
1132
- vi.spyOn(pi, "setActiveTools");
1133
-
1134
- applyConfigDefault(pi);
1135
-
1136
- expect(pi.setActiveTools).toHaveBeenCalledWith(
1137
- expect.arrayContaining(ALL_BROWSER_TOOLS.map((t) => t.name)),
1138
- );
1139
- });
1140
-
1141
- it("calls applyBrowserState with the config value (false)", () => {
1142
- vi.mocked(existsSync).mockReturnValue(true);
1143
- vi.mocked(readFileSync).mockImplementation((path: unknown) =>
1144
- typeof path === "string" && path.includes(".pi/agent/settings")
1145
- ? JSON.stringify({ browserToggle: { defaultEnabled: false } })
1146
- : JSON.stringify({}),
1147
- );
1148
-
1149
- const pi = mockPi({
1150
- tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
1151
- activeTools: [
1152
- ...NON_BROWSER_TOOLS.map((t) => t.name),
1153
- ...ALL_BROWSER_TOOLS.map((t) => t.name),
1154
- ],
1155
- });
1156
- vi.spyOn(pi, "setActiveTools");
1157
-
1158
- applyConfigDefault(pi);
1159
-
1160
- const calledWith = (pi.setActiveTools as ReturnType<typeof vi.fn>).mock
1161
- .calls[0]![0] as string[];
1162
- expect(calledWith).not.toContain("web-fetch");
1163
- expect(calledWith).not.toContain("browser-navigate");
1164
- });
1165
-
1166
- it("persists the applied state", () => {
1167
- vi.mocked(existsSync).mockReturnValue(true);
1168
- vi.mocked(readFileSync).mockReturnValue(
1169
- JSON.stringify({ browserToggle: { defaultEnabled: true } }),
1170
- );
379
+ describe("external-plugin glyph sync", () => {
380
+ it("re-renders browser glyph off when external plugin fires portal.web disabled", async () => {
381
+ const { pi, handlers, events } = mockPi();
382
+ browserToggle(pi);
1171
383
 
1172
- const pi = mockPi({
1173
- tools: ALL_BROWSER_TOOLS,
1174
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
1175
- });
384
+ // Fire session_start once to establish _lastCtx.
385
+ // After this, the glyph is rendered correctly for default state.
386
+ const startCtx = mockCtx();
387
+ for (const h of handlers.get("session_start") ?? []) {
388
+ await h({}, startCtx);
389
+ }
1176
390
 
1177
- applyConfigDefault(pi);
391
+ // Clear the initial render call so we only see the external-plugin re-render.
392
+ vi.clearAllMocks();
1178
393
 
1179
- expect(pi.appendEntry).toHaveBeenCalledWith(
1180
- "web-toggle-state",
1181
- expect.objectContaining({
1182
- browserToolsEnabled: true,
1183
- learnToolsEnabled: false,
1184
- }),
394
+ // Simulate an external plugin disabling portal.web — it has
395
+ // already removed browser tools from the active set.
396
+ const nonBrowser = ALL_TOOLS.map((t) => t.name).filter(
397
+ (n) => !BROWSER_TOOL_NAMES.has(n),
1185
398
  );
1186
- });
399
+ (pi.getActiveTools as any).mockReturnValue(nonBrowser);
1187
400
 
1188
- it("is a no-op when no browser tools are registered", () => {
1189
- const pi = mockPi({
1190
- tools: NON_BROWSER_TOOLS,
1191
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
401
+ events.emit(TOOLSET_EVENTS.changed, {
402
+ id: "portal.web",
403
+ enabled: false,
1192
404
  });
1193
- vi.spyOn(pi, "setActiveTools");
1194
-
1195
- applyConfigDefault(pi);
1196
-
1197
- expect(pi.setActiveTools).not.toHaveBeenCalled();
1198
- });
1199
- });
1200
-
1201
- // ==================================================================
1202
- // restoreFromBranch — updated return value
1203
- // ==================================================================
1204
- describe("restoreFromBranch (return value)", () => {
1205
- it("returns true when saved state was found and applied", () => {
1206
- const pi = mockPi({
1207
- tools: ALL_BROWSER_TOOLS,
1208
- activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
1209
- });
1210
- const ctx = mockContext([
1211
- {
1212
- type: "custom",
1213
- customType: "web-toggle-state",
1214
- data: {
1215
- browserToolsEnabled: true,
1216
- learnToolsEnabled: false,
1217
- defaultProfile: "none",
1218
- } satisfies BrowserToggleState,
1219
- },
1220
- ]);
1221
- expect(restoreFromBranch(pi, ctx)).toBe(true);
1222
- });
1223
-
1224
- it("returns false when no saved state in branch", () => {
1225
- const pi = mockPi({
1226
- tools: ALL_BROWSER_TOOLS,
1227
- activeTools: ALL_BROWSER_TOOLS.map((t) => t.name),
1228
- });
1229
- const ctx = mockContext([
1230
- { type: "message", data: { role: "user" } },
1231
- { type: "message", data: { role: "assistant" } },
1232
- ]);
1233
- expect(restoreFromBranch(pi, ctx)).toBe(false);
1234
- });
1235
405
 
1236
- it("returns false when no browser tools are registered", () => {
1237
- const pi = mockPi({ tools: NON_BROWSER_TOOLS });
1238
- const ctx = mockContext([
1239
- {
1240
- type: "custom",
1241
- customType: "web-toggle-state",
1242
- data: { browserToolsEnabled: false, learnToolsEnabled: false },
1243
- },
1244
- ]);
1245
- expect(restoreFromBranch(pi, ctx)).toBe(false);
406
+ expect(startCtx.ui.setStatus).toHaveBeenCalledWith("browser", "○ web off");
1246
407
  });
1247
408
  });