pi-lean-dimension 0.2.4 → 0.3.1

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