feat: sidebar_open tool gates model-driven sidebar opens behind a global setting
New global Side card setting `agentOpenTools` (default off, 常规 group): when enabled, one `sidebar_open` tool is injected into the model so it can actively open a local file, a local folder (tree rooted there), or an HTTP(S) page in the calling session's sidebar. - host: AgentOpenRegistry + registerOpenTool (src/agent-opens.ts); the tool classifies http(s) URLs / stat-driven file/folder paths, refuses other schemes, resolves relative paths against the session cwd, and reports target-tab-disabled instead of silently no-oping. Gated like agentTerminalTools (register/drain on settings watch). - delivery: /sidebar/ws/agent-opens push (consume-on-send queue, replay on attach); the tool result reports `delivered` so queued opens for non-active sessions are honest. - client: Sidebar subscribes and routes pushes to editor (file), folder window (meta.dir, tree rooted at the folder — EditorHost), or browser (URL) tabs; settings row in 常规 + 20 locale dictionaries. - tests: agent-opens registry/tool spec, smoke gating + WS route list, prefs/plugin-shape defaults, editor-host folder window, side-card rows. - docs: design doc, AGENTS.md key lists + reference, README feature bullets.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Unit tests for the `sidebar_open` tool layer: registry semantics
|
||||
* (per-session queue, consume-on-send delivery, attach replay, drain) and
|
||||
* the tool itself (URL/file/folder classification, relative-path resolution,
|
||||
* schema-valid canonical output, target-tab gating). The registry is real;
|
||||
* no WebSocket or browser is needed — the sender callbacks are captured.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentOpenRegistry, registerOpenTool } from '../src/agent-opens.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../src/prefs-shared.ts'
|
||||
|
||||
/** One registration captured by the fake tools service. */
|
||||
interface CapturedTool {
|
||||
definition: ToolDefinition
|
||||
}
|
||||
|
||||
/** A minimal ToolRunContext for one calling session. */
|
||||
function exec(sessionId: string): ToolRunContext {
|
||||
return {
|
||||
signal: { throwIfAborted: () => {}, aborted: false },
|
||||
agent: { session: { id: sessionId } },
|
||||
} as unknown as ToolRunContext
|
||||
}
|
||||
|
||||
/** Register the tool against a fake service + real registry. */
|
||||
function mount(overrides: { prefs?: Partial<SidebarPrefs>; resolveCwd?: (sessionId: string) => string } = {}) {
|
||||
const captured: CapturedTool[] = []
|
||||
let disposeCount = 0
|
||||
const ctx = {
|
||||
tools: {
|
||||
register: (tool: unknown): (() => void) => {
|
||||
captured.push({ definition: tool as ToolDefinition })
|
||||
return () => { disposeCount += 1 }
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
const registry = new AgentOpenRegistry()
|
||||
const prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS, ...overrides.prefs }
|
||||
const dispose = registerOpenTool(
|
||||
ctx,
|
||||
registry,
|
||||
overrides.resolveCwd ?? (() => '/cwd'),
|
||||
() => prefs,
|
||||
)
|
||||
return { captured, registry, prefs, dispose: () => { dispose(); return disposeCount } }
|
||||
}
|
||||
|
||||
/** The captured definition for one tool name. */
|
||||
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
|
||||
const found = captured.find(candidate => candidate.definition.name === name)
|
||||
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
|
||||
return found.definition
|
||||
}
|
||||
|
||||
describe('AgentOpenRegistry', () => {
|
||||
it('queues without a subscriber, replays on attach (consume-on-send), and reports delivered', () => {
|
||||
const registry = new AgentOpenRegistry()
|
||||
const queued = registry.enqueue('s1', 'folder', '/work/src', 'src')
|
||||
expect(queued.delivered).toBe(false)
|
||||
|
||||
const received: unknown[] = []
|
||||
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toMatchObject({ kind: 'folder', target: '/work/src', title: 'src', sessionId: 's1' })
|
||||
|
||||
// The request was consumed on send: a second attach sees nothing.
|
||||
const later: unknown[] = []
|
||||
const detach2 = registry.attach('s1', (request) => { later.push(request) })
|
||||
expect(later).toHaveLength(0)
|
||||
|
||||
detach()
|
||||
detach2()
|
||||
})
|
||||
|
||||
it('delivers immediately and does not queue when a subscriber is attached', () => {
|
||||
const registry = new AgentOpenRegistry()
|
||||
const received: unknown[] = []
|
||||
const detach = registry.attach('s2', (request) => { received.push(request) })
|
||||
const result = registry.enqueue('s2', 'url', 'https://example.com/', 'example.com')
|
||||
expect(result.delivered).toBe(true)
|
||||
expect(received).toHaveLength(1)
|
||||
// Consumed on send: a reconnect never replays (no duplicate browser tabs).
|
||||
const later: unknown[] = []
|
||||
const detach2 = registry.attach('s2', (request) => { later.push(request) })
|
||||
expect(later).toHaveLength(0)
|
||||
detach()
|
||||
detach2()
|
||||
})
|
||||
|
||||
it('owner-scopes the queue: one session never leaks into another', () => {
|
||||
const registry = new AgentOpenRegistry()
|
||||
registry.enqueue('s1', 'file', '/a.txt', 'a.txt')
|
||||
|
||||
const other: unknown[] = []
|
||||
const detach = registry.attach('s2', (request) => { other.push(request) })
|
||||
expect(other).toHaveLength(0)
|
||||
|
||||
const own: unknown[] = []
|
||||
const detach2 = registry.attach('s1', (request) => { own.push(request) })
|
||||
expect(own).toHaveLength(1)
|
||||
detach()
|
||||
detach2()
|
||||
})
|
||||
|
||||
it('drainAll drops the queue and dispose drops subscribers', () => {
|
||||
const registry = new AgentOpenRegistry()
|
||||
registry.enqueue('s1', 'file', '/a.txt', 'a.txt')
|
||||
registry.drainAll()
|
||||
const received: unknown[] = []
|
||||
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||
expect(received).toHaveLength(0)
|
||||
|
||||
registry.dispose()
|
||||
// After dispose the previously-returned disposer is a no-op (idempotent).
|
||||
detach()
|
||||
detach()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar_open tool', () => {
|
||||
it('registers the single tool and the disposer unregisters it', () => {
|
||||
const { captured, dispose } = mount()
|
||||
expect(captured.map(candidate => candidate.definition.name)).toEqual(['sidebar_open'])
|
||||
expect(dispose()).toBe(1)
|
||||
expect(() => mount().dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('opens an existing file (schema-valid output, delivered with a subscriber)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||
try {
|
||||
const file = join(dir, 'a.txt')
|
||||
writeFileSync(file, 'hello')
|
||||
const { captured, registry } = mount({ resolveCwd: () => dir })
|
||||
const received: unknown[] = []
|
||||
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
const value = await tool.execute({ target: file }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'file', target: file, title: 'a.txt', delivered: true })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
expect(received).toHaveLength(1)
|
||||
detach()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a relative path against the session cwd and queues when no view is attached', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||
try {
|
||||
writeFileSync(join(dir, 'note.md'), '# hi')
|
||||
const { captured, registry } = mount({ resolveCwd: () => dir })
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
const value = await tool.execute({ target: 'note.md' }, exec('s1'))
|
||||
expect(value).toMatchObject({ kind: 'file', target: join(dir, 'note.md'), title: 'note.md', delivered: false })
|
||||
// Queued: a later attach replays it.
|
||||
const received: unknown[] = []
|
||||
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||
expect(received).toHaveLength(1)
|
||||
detach()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a directory as folder and applies a custom title', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||
try {
|
||||
const sub = join(dir, 'src')
|
||||
mkdirSync(sub)
|
||||
const { captured } = mount({ resolveCwd: () => dir })
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
const value = await tool.execute({ target: sub, title: 'Sources' }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'folder', target: sub, title: 'Sources', delivered: false })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies an http(s) URL, titles it by hostname, and never stats it', async () => {
|
||||
const { captured } = mount()
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
const value = await tool.execute({ target: 'https://example.com/docs' }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'url', target: 'https://example.com/docs', title: 'example.com', delivered: false })
|
||||
})
|
||||
|
||||
it('rejects non-http(s) schemes', async () => {
|
||||
const { captured } = mount()
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
for (const target of ['javascript:alert(1)', 'file:///etc/passwd', 'vscode://file/x']) {
|
||||
await expect(tool.execute({ target }, exec('s1'))).rejects.toThrow(/only accepts http/)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a missing local path instead of opening it', async () => {
|
||||
const { captured } = mount({ resolveCwd: () => '/definitely/missing' })
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
await expect(tool.execute({ target: '/definitely/missing/nope.txt' }, exec('s1')))
|
||||
.rejects.toThrow(/does not exist/)
|
||||
})
|
||||
|
||||
it('refuses a disabled target tab type with a clear cause', async () => {
|
||||
const { captured } = mount({ prefs: { tabsEnabled: { browser: false } } })
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
await expect(tool.execute({ target: 'https://example.com/' }, exec('s1')))
|
||||
.rejects.toThrow(/browser tab is disabled/)
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||
try {
|
||||
writeFileSync(join(dir, 'f.txt'), 'x')
|
||||
const { captured: fileCaptured } = mount({ resolveCwd: () => dir, prefs: { tabsEnabled: { editor: false } } })
|
||||
const fileTool = toolOf(fileCaptured, 'sidebar_open')
|
||||
// A URL is unaffected by the editor gate; a path is refused with cause.
|
||||
await expect(fileTool.execute({ target: 'https://example.com/' }, exec('s1'))).resolves.toMatchObject({ kind: 'url' })
|
||||
await expect(fileTool.execute({ target: 'f.txt' }, exec('s1'))).rejects.toThrow(/editor tab is disabled/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('throws the canonical no-agent error when executed without an agent', async () => {
|
||||
const { captured } = mount()
|
||||
const tool = toolOf(captured, 'sidebar_open')
|
||||
await expect(tool.execute({ target: '/tmp/x' }, { signal: { throwIfAborted: () => {}, aborted: false } } as unknown as ToolRunContext))
|
||||
.rejects.toThrow(/requires an initiating agent/)
|
||||
})
|
||||
})
|
||||
@@ -293,4 +293,31 @@ describe('EditorHost (files window)', () => {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('a folder tab (meta.dir) renders the tree rooted at the folder, no editor chrome', () => {
|
||||
const { store, ctx } = setup()
|
||||
ctx.betterSidebar!.openTab({
|
||||
type: 'editor',
|
||||
title: 'src',
|
||||
path: '/work/src',
|
||||
id: 'editor:/work/src',
|
||||
meta: { dir: true },
|
||||
}, { sessionId: 'editor-home-session' })
|
||||
const dirTab = (): SidebarTab =>
|
||||
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
.find(tab => tab.path === '/work/src')!
|
||||
const { container, unmount } = mountHost(ctx, store, dirTab)
|
||||
try {
|
||||
const html = container.innerHTML
|
||||
// The folder window is the full tree surface: the folder basename is
|
||||
// the tree root row and the search box is present; the editor empty
|
||||
// hint and the file path input are NOT.
|
||||
expect(html).toContain('src')
|
||||
expect(html).toContain('Search files by name…')
|
||||
expect(html).not.toContain('Pick a file from the tree panel')
|
||||
expect(html).not.toContain('File path (relative')
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The sidebar-open tool defaults OFF too (same dormant-until-enabled rule).
|
||||
expect(resolved.agentOpenTools).toBe(false)
|
||||
// The terminal font customizations default to the theme (empty family)
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
@@ -92,6 +94,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+15
-8
@@ -37,7 +37,7 @@ describe('side card preferences', () => {
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: true,
|
||||
agentTerminalTools: true, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
@@ -69,7 +69,7 @@ describe('side card preferences', () => {
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
@@ -101,7 +101,7 @@ describe('side card preferences', () => {
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
@@ -133,6 +133,13 @@ describe('side card preferences', () => {
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
// The sidebar-open tool is OFF by default too; only an explicit true turns it on.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentOpenTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: 1 }))).agentOpenTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: true }))).agentOpenTools)
|
||||
.toBe(true)
|
||||
// The job auto-open is ON by default; only an explicit false turns it off.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
||||
.toBe(true)
|
||||
@@ -260,9 +267,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -298,7 +305,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -308,7 +315,7 @@ describe('side card preferences', () => {
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
@@ -318,7 +325,7 @@ describe('side card preferences', () => {
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
|
||||
@@ -178,10 +178,10 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||
expect(html).not.toContain('<select')
|
||||
expect(html).toContain('>Auto-detect<')
|
||||
// Two general-row switches remain (openByDefault + interceptOpenPath),
|
||||
// only interceptOpenPath checked by default — the scheme row is a
|
||||
// dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
||||
// Three general-row switches remain (openByDefault + interceptOpenPath
|
||||
// + agentOpenTools), only interceptOpenPath checked by default — the
|
||||
// scheme row is a dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
+56
-3
@@ -110,7 +110,7 @@ describe('host plugin smoke', () => {
|
||||
'/sidebar/file',
|
||||
'/sidebar/html',
|
||||
])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals', '/sidebar/ws/agent-opens'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
})
|
||||
@@ -839,7 +839,7 @@ describe('side card settings routes', () => {
|
||||
defaultWidthPercent: 35,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
agentTerminalTools: false, agentOpenTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
@@ -1015,6 +1015,59 @@ describe('agent terminal tool gating', () => {
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('agent sidebar-open tool gating', () => {
|
||||
it('injects the one open tool only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
let disposed = 0
|
||||
const live = (): number => registered - disposed
|
||||
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||
let enabled = false
|
||||
const settings = {
|
||||
register() {
|
||||
return {
|
||||
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
|
||||
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||
update: async () => {},
|
||||
replace: async () => {},
|
||||
}
|
||||
},
|
||||
describe: () => [],
|
||||
async update() {},
|
||||
}
|
||||
const ctx = {
|
||||
webRuntime: { trustedHosts: [] },
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { void route; return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
// Default off: no open tool is registered even though the settings service is mounted.
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers the single sidebar_open tool.
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(1)
|
||||
expect(disposed).toBe(0)
|
||||
// Flipping it back off unregisters it (and drains the undelivered queue).
|
||||
enabled = false
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(0)
|
||||
expect(disposed).toBe(1)
|
||||
// And a redundant toggle registers it fresh (no double-registration).
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(1)
|
||||
expect(registered).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user