fix: 修复 agent 终端工具审查问题(terminal_list 输出契约、跨会话归属、UTF-8 截断边界等),工具注入改为设置面板开关(默认关闭)
This commit is contained in:
@@ -104,9 +104,10 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
### 💻 终端
|
||||
|
||||
- xterm.js + node-pty **真实 shell**,每会话最多 3 个
|
||||
- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限)
|
||||
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
|
||||
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
|
||||
- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:<uuid>`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用
|
||||
|
||||
### 🌿 Git 面板
|
||||
|
||||
@@ -148,6 +149,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
|
||||
+41
-13
@@ -27,17 +27,29 @@ export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
|
||||
/** Default read page size (lines) when the caller omits `count`. */
|
||||
export const DEFAULT_READ_COUNT = 500
|
||||
|
||||
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MIN = 2
|
||||
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
|
||||
export const TERMINAL_DIM_MAX = 1024
|
||||
|
||||
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
|
||||
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
|
||||
const clamp = (value: number): number =>
|
||||
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
|
||||
return { cols: clamp(cols), rows: clamp(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable snapshot of one agent terminal — the shape the model sees
|
||||
* through `terminal_list` and the sidebar sees through the push endpoint.
|
||||
* Carries no pty reference and no transcript (those are reached through
|
||||
* dedicated read/attach paths).
|
||||
* dedicated read/attach paths), and no sessionId: ownership is registry
|
||||
* internals, scoped by the caller (list filters by session, the push
|
||||
* endpoint scopes by its query param), never part of the serialized view.
|
||||
*/
|
||||
export interface AgentTerminalSnapshot {
|
||||
/** Stable opaque handle the model passes back to other terminal_* tools. */
|
||||
uuid: string
|
||||
/** The conversation id this terminal belongs to (scope filter for list). */
|
||||
sessionId: string
|
||||
/** Display title the model chose at create time. */
|
||||
title: string
|
||||
/** The command the model asked to run at create time (verbatim). */
|
||||
@@ -115,8 +127,6 @@ export interface AgentTerminalReadResult {
|
||||
lineBegin: number
|
||||
/** 0-based index of the last line in `text` (exclusive). */
|
||||
lineEnd: number
|
||||
/** Whether `text` was truncated to fit the read cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
|
||||
@@ -158,7 +168,6 @@ export type AgentTerminalWaitResult =
|
||||
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
|
||||
const out: AgentTerminalSnapshot = {
|
||||
uuid: handle.uuid,
|
||||
sessionId: handle.sessionId,
|
||||
title: handle.title,
|
||||
command: handle.command,
|
||||
exited: handle.exited,
|
||||
@@ -201,10 +210,11 @@ export class AgentPtyRegistry {
|
||||
rows = 24,
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
})
|
||||
@@ -267,6 +277,20 @@ export class AgentPtyRegistry {
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
|
||||
* The model-facing tools call this before every uuid-keyed operation: a
|
||||
* uuid from another session is indistinguishable from an unknown one, so a
|
||||
* model can never reach (or probe) a terminal it does not own.
|
||||
*/
|
||||
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.sessionId !== sessionId) {
|
||||
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Resolve a handle's snapshot, or undefined if it does not exist. */
|
||||
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
|
||||
const handle = this.sessions.get(uuid)
|
||||
@@ -310,15 +334,19 @@ export class AgentPtyRegistry {
|
||||
totalLines,
|
||||
lineBegin: start,
|
||||
lineEnd: end,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Resize a terminal's pty (clamped to a 2..1024 sane range). */
|
||||
resize(uuid: string, cols: number, rows: number): void {
|
||||
/**
|
||||
* Resize a terminal's pty, clamped to the 2..1024 sane range.
|
||||
* @returns the dimensions actually applied (the caller echoes these, so the
|
||||
* reported value always matches the pty).
|
||||
*/
|
||||
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
const handle = this.expect(uuid)
|
||||
if (handle.exited) return
|
||||
handle.pty.resize(Math.max(2, Math.floor(cols)), Math.max(2, Math.floor(rows)))
|
||||
const dims = clampDims(cols, rows)
|
||||
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
|
||||
return dims
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,13 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggleTools = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, agentTerminalTools: next })
|
||||
setError(null)
|
||||
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -193,6 +200,19 @@ export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsToolsTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsToolsDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.agentTerminalTools}
|
||||
aria-label={t('settingsToolsTitle')}
|
||||
onChange={event => { onToggleTools(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
+28
-11
@@ -15,7 +15,7 @@ import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import {
|
||||
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
||||
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||
@@ -36,6 +36,10 @@ import { api } from './api.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-terminals push loop
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
@@ -83,19 +87,21 @@ function TabContent(props: {
|
||||
}
|
||||
}
|
||||
|
||||
/** The + menu options for the current state (terminal cap applied). */
|
||||
/** The + menu options for the current state (UI-tab terminal cap applied).
|
||||
* Agent-owned terminals (`agent:` tabs) are the model's, not the user's:
|
||||
* they never count toward the per-session UI cap. */
|
||||
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
||||
const terminalCount = allLeaves(state.splits)
|
||||
const uiTerminalCount = allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal').length
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
return [
|
||||
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
|
||||
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
|
||||
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
|
||||
{
|
||||
id: 'terminal',
|
||||
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
disabled: terminalCount >= TERMINAL_LIMIT,
|
||||
label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
disabled: uiTerminalCount >= TERMINAL_LIMIT,
|
||||
icon: tabTypeIcon('terminal', 16),
|
||||
},
|
||||
]
|
||||
@@ -145,13 +151,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* create / close / exit; the sidebar reconciles the list into tabs
|
||||
* (id `agent:<uuid>`, title from the agent). A disconnected socket
|
||||
* retries with a short backoff so a refresh or transient drop reattaches
|
||||
* the same shell without losing the agent's work.
|
||||
* the same shell without losing the agent's work — capped like the
|
||||
* terminal view's own reconnect loop, so a refused endpoint never spins
|
||||
* forever (the next session switch restarts the loop).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionId === undefined) return
|
||||
let socket: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let closed = false
|
||||
let failures = 0
|
||||
const connect = (): void => {
|
||||
if (closed) return
|
||||
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
|
||||
@@ -169,7 +178,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
if (!closed) retry = window.setTimeout(connect, 2000)
|
||||
if (closed) return
|
||||
failures += 1
|
||||
if (failures >= FAILURE_LIMIT) {
|
||||
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
|
||||
return
|
||||
}
|
||||
retry = window.setTimeout(connect, 2000)
|
||||
}
|
||||
socket.onerror = () => { socket?.close() }
|
||||
}
|
||||
@@ -265,8 +280,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
||||
store.reduce(s => closeTab(s, paneId, tabId))
|
||||
if (tab?.type === 'terminal') {
|
||||
if (tabId.startsWith('agent:')) {
|
||||
const uuid = tabId.slice('agent:'.length)
|
||||
if (isAgentTabId(tabId)) {
|
||||
const uuid = agentUuidOf(tabId)
|
||||
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
|
||||
} else if (sessionId !== undefined) {
|
||||
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
||||
@@ -350,7 +365,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
|
||||
}
|
||||
if (optionId === 'terminal') {
|
||||
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
|
||||
const count = allLeaves(s.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
if (count >= TERMINAL_LIMIT) return s
|
||||
const tab: SidebarTab = {
|
||||
id: `terminal:${s.nextTerminal}`,
|
||||
|
||||
@@ -25,26 +25,13 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||||
const AGENT_TAB_PREFIX = 'agent:'
|
||||
|
||||
/** Whether a tab id refers to an agent-owned terminal. */
|
||||
function isAgentTab(tabId: string): boolean {
|
||||
return tabId.startsWith(AGENT_TAB_PREFIX)
|
||||
}
|
||||
|
||||
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
|
||||
function agentUuidOf(tabId: string): string {
|
||||
return tabId.slice(AGENT_TAB_PREFIX.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
@@ -126,7 +113,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
// Agent terminals attach by uuid (the host looks them up in the agent
|
||||
// pty registry); UI-tab terminals attach by sessionId+tab (the host
|
||||
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
|
||||
if (isAgentTab(tabId)) {
|
||||
if (isAgentTabId(tabId)) {
|
||||
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
|
||||
} else {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
|
||||
|
||||
@@ -90,6 +90,8 @@ const zh = {
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
@@ -192,6 +194,8 @@ const en: Record<keyof typeof zh, string> = {
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
|
||||
@@ -41,6 +41,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -399,6 +399,21 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
|
||||
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
|
||||
export const AGENT_TAB_PREFIX = 'agent:'
|
||||
|
||||
/** Whether a tab id refers to an agent-owned terminal. */
|
||||
export function isAgentTabId(tabId: string): boolean {
|
||||
return tabId.startsWith(AGENT_TAB_PREFIX)
|
||||
}
|
||||
|
||||
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
|
||||
export function agentUuidOf(tabId: string): string {
|
||||
return tabId.slice(AGENT_TAB_PREFIX.length)
|
||||
}
|
||||
|
||||
/** Build the sidebar tab id for one agent terminal uuid. */
|
||||
export function agentTabId(uuid: string): string {
|
||||
return `${AGENT_TAB_PREFIX}${uuid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
|
||||
* The host pushes the current list of agent terminals (created by the model
|
||||
@@ -416,11 +431,11 @@ export function reconcileAgentTerminals(
|
||||
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
|
||||
): SidebarState {
|
||||
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
const existingAgentTabs = existingTabs.filter(tab => tab.id.startsWith(AGENT_TAB_PREFIX))
|
||||
const existingUuids = new Set(existingAgentTabs.map(tab => tab.id.slice(AGENT_TAB_PREFIX.length)))
|
||||
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
|
||||
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
|
||||
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
|
||||
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
|
||||
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(tab.id.slice(AGENT_TAB_PREFIX.length)))
|
||||
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
|
||||
if (toAdd.length === 0 && toRemove.length === 0) return state
|
||||
// Remove tabs whose uuids vanished from the server list (the agent closed
|
||||
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
|
||||
@@ -436,7 +451,7 @@ export function reconcileAgentTerminals(
|
||||
let next: SidebarState = { ...state, splits }
|
||||
for (const terminal of toAdd) {
|
||||
const tab: SidebarTab = {
|
||||
id: `${AGENT_TAB_PREFIX}${terminal.uuid}`,
|
||||
id: agentTabId(terminal.uuid),
|
||||
type: 'terminal',
|
||||
title: terminal.title,
|
||||
}
|
||||
|
||||
@@ -79,4 +79,5 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
})
|
||||
|
||||
+38
-13
@@ -23,13 +23,14 @@ import {
|
||||
SIDEBAR_PREFS_NS,
|
||||
type ResolvedSidebarConfig,
|
||||
type SidebarConfig,
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
@@ -315,9 +316,34 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
// The model-facing terminal tools are gated on the side-card setting
|
||||
// `agentTerminalTools` (default off): nothing is injected until the user
|
||||
// turns the feature on, and turning it off mid-session unregisters the
|
||||
// tools and releases the agent terminals they created.
|
||||
let toolsDisposers: (() => void) | null = null
|
||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||
if (scope.get().agentTerminalTools) {
|
||||
if (toolsDisposers === null) {
|
||||
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
}
|
||||
} else if (toolsDisposers !== null) {
|
||||
toolsDisposers()
|
||||
toolsDisposers = null
|
||||
// The feature is off: release every agent terminal the model created
|
||||
// while it was on (they are only reachable through the tools). The
|
||||
// registry change fires the push, so the sidebar reconciles them away.
|
||||
agentPtyRegistry.disposeAll()
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
sctx.settings.register(ns, PrefsSchema)
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
const scope = sctx.settings.register(ns, PrefsSchema) as {
|
||||
get(): SidebarPrefs
|
||||
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
|
||||
}
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
@@ -331,15 +357,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
// Register (or unregister) the terminal tools from the current setting,
|
||||
// and keep them in sync with every settings commit.
|
||||
syncToolsGate(scope)
|
||||
scope.watch(() => { syncToolsGate(scope) })
|
||||
})
|
||||
|
||||
// ── Model-facing tools (terminal_create / list / send / read / wait_for / resize / signal / close) ─
|
||||
// Registered once at activation; the disposer is attached to the plugin
|
||||
// fiber by the tools registry, so unloading unregisters them. The cwd
|
||||
// resolver threads the live session cwd (authoritative from the session
|
||||
// store, falling back to the process cwd) so the model never passes it.
|
||||
registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
@@ -467,6 +490,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
||||
|
||||
ctx.effect(() => () => {
|
||||
toolsDisposers?.()
|
||||
ptyManager.disposeAll()
|
||||
agentPtyRegistry.disposeAll()
|
||||
wss.close()
|
||||
@@ -530,7 +554,7 @@ async function attachTerminal(
|
||||
ws.close(1011, `agent terminal "${uuid}" not found`)
|
||||
return
|
||||
}
|
||||
pumpAgentTerminal(agentPtyRegistry, handle, ws, resolved)
|
||||
pumpAgentTerminal(agentPtyRegistry, handle, ws)
|
||||
return
|
||||
}
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
@@ -577,7 +601,8 @@ async function attachTerminal(
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else {
|
||||
handle.pty.write(text)
|
||||
}
|
||||
@@ -606,7 +631,6 @@ function pumpAgentTerminal(
|
||||
registry: AgentPtyRegistry,
|
||||
handle: AgentTerminalHandle,
|
||||
ws: WebSocket,
|
||||
_resolved: ResolvedSidebarConfig,
|
||||
): void {
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
@@ -642,7 +666,8 @@ function pumpAgentTerminal(
|
||||
&& control.type === 'resize'
|
||||
&& typeof control.cols === 'number' && typeof control.rows === 'number'
|
||||
) {
|
||||
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
|
||||
const dims = clampDims(control.cols, control.rows)
|
||||
handle.pty.resize(dims.cols, dims.rows)
|
||||
} else if (control === null) {
|
||||
// Raw text input (a JSON-looking string the pty would have received
|
||||
// verbatim is reachable in theory but is exotic for an agent terminal;
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface SidebarPrefs {
|
||||
* Subagent page when the current conversation spawns a new subagent.
|
||||
*/
|
||||
autoOpenSubagent: boolean
|
||||
/**
|
||||
* Whether the model-facing agent terminal tools (terminal_create / list /
|
||||
* send / read / wait_for / resize / signal / close) are injected into the
|
||||
* model's toolset. Off by default: the feature stays dormant until the
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
@@ -32,6 +39,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
|
||||
+75
-60
@@ -27,17 +27,22 @@ import {
|
||||
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
|
||||
const READ_BYTE_LIMIT = 256 * 1024
|
||||
|
||||
/** Bound a string to a byte limit, marking truncation. */
|
||||
function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
const bytes = Buffer.byteLength(text, 'utf8')
|
||||
if (bytes <= maxBytes) return { text, truncated: false }
|
||||
// Trim by character count proportional to the overage, then walk back to a
|
||||
// valid UTF-8 boundary (Buffer.subarray with garbage on a trailing multibyte
|
||||
// sequence would decode to U+FFFD).
|
||||
const ratio = maxBytes / bytes
|
||||
const cut = Math.floor(text.length * ratio)
|
||||
const buf = Buffer.from(text, 'utf8').subarray(0, maxBytes)
|
||||
return { text: buf.toString('utf8').slice(0, cut), truncated: true }
|
||||
/**
|
||||
* Bound a string to a byte limit, marking truncation. Truncation never
|
||||
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
|
||||
* the walk-back retreats to the sequence's leading byte so the retained
|
||||
* prefix decodes cleanly (a split would decode to U+FFFD).
|
||||
* @internal exported for the unit tests, like {@link snapshotOf}.
|
||||
*/
|
||||
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
const buf = Buffer.from(text, 'utf8')
|
||||
if (buf.byteLength <= maxBytes) return { text, truncated: false }
|
||||
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
|
||||
// character that started before `end` spills past the cap. Stop at the
|
||||
// first non-continuation byte — the retained prefix is then intact.
|
||||
let end = maxBytes
|
||||
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
|
||||
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
|
||||
}
|
||||
|
||||
/** Pure text projection helper (the canonical value is already structured). */
|
||||
@@ -53,27 +58,36 @@ function requireAgent(agent: Agent | undefined): Agent {
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Resolve the calling agent's session id (the registry scope filter). */
|
||||
function sessionOf(exec: ToolRunContext): { agent: Agent; sessionId: string } {
|
||||
const agent = requireAgent(exec.agent)
|
||||
return { agent, sessionId: agent.session.id }
|
||||
/** Resolve the calling agent's session id (the registry scope + ownership key). */
|
||||
function sessionIdOf(exec: ToolRunContext): string {
|
||||
return requireAgent(exec.agent).session.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the seven terminal tools against the host tool registry. The
|
||||
* Register the eight terminal tools against the host tool registry. The
|
||||
* `resolveCwd` callback threads the live session cwd (authoritative from the
|
||||
* session store, falling back to the process cwd) so a freshly-created
|
||||
* terminal lands in the right directory without the model passing it.
|
||||
* Every uuid-keyed tool first asserts the terminal belongs to the calling
|
||||
* session (`registry.assertOwned`), so one agent can never reach another
|
||||
* session's terminals.
|
||||
* @param ctx - host plugin context (carries the tools service).
|
||||
* @param registry - the agent-owned terminal registry.
|
||||
* @param resolveCwd - live cwd resolver for one session id.
|
||||
* @returns a disposer that unregisters all eight tools (the caller gates
|
||||
* registration on the side-card setting and calls this to turn them off).
|
||||
*/
|
||||
export function registerTools(
|
||||
ctx: Context,
|
||||
registry: AgentPtyRegistry,
|
||||
resolveCwd: (sessionId: string) => string,
|
||||
): void {
|
||||
ctx.tools.register(defineTool({
|
||||
): () => void {
|
||||
const disposers: Array<() => void> = []
|
||||
const register = (tool: ReturnType<typeof defineTool>): void => {
|
||||
disposers.push(ctx.tools.register(tool))
|
||||
}
|
||||
|
||||
register(defineTool({
|
||||
name: 'terminal_create',
|
||||
description:
|
||||
'Open a persistent terminal in the sidebar and run a command in it. '
|
||||
@@ -111,14 +125,14 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { title: string; command: string }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const { sessionId } = sessionOf(exec)
|
||||
const sessionId = sessionIdOf(exec)
|
||||
const cwd = resolveCwd(sessionId)
|
||||
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
|
||||
return Promise.resolve({ uuid, title: args.title })
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_list',
|
||||
description:
|
||||
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
|
||||
@@ -154,19 +168,19 @@ export function registerTools(
|
||||
},
|
||||
},
|
||||
execute: (_args, exec) => {
|
||||
const { sessionId } = sessionOf(exec)
|
||||
const sessionId = sessionIdOf(exec)
|
||||
return Promise.resolve(registry.list(sessionId))
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_send',
|
||||
description:
|
||||
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
|
||||
+ 'The text is written verbatim to the pty stdin. '
|
||||
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
|
||||
+ 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. '
|
||||
+ 'Similarly use terminal_signal for Ctrl+D (EOF), Ctrl+Z (suspend), etc. '
|
||||
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
|
||||
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
|
||||
+ 'Throws if the terminal has exited.',
|
||||
parameters: {
|
||||
@@ -200,13 +214,15 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const payload = args.submit === true ? `${args.text}\r` : args.text
|
||||
registry.send(args.uuid, payload)
|
||||
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_read',
|
||||
description:
|
||||
'Read a bounded page of retained output from an agent terminal without sending input. '
|
||||
@@ -249,6 +265,8 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const result = registry.read(args.uuid, args.offset, args.count)
|
||||
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
|
||||
return Promise.resolve({
|
||||
@@ -261,7 +279,7 @@ export function registerTools(
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_wait_for',
|
||||
description:
|
||||
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
|
||||
@@ -298,7 +316,7 @@ export function registerTools(
|
||||
needle: { type: 'string', required: true },
|
||||
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
|
||||
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
|
||||
elapsed_ms: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
|
||||
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -307,8 +325,8 @@ export function registerTools(
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'timeout' },
|
||||
needle: { type: 'string', required: true },
|
||||
timeout_ms: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
|
||||
total_lines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
|
||||
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
|
||||
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -317,55 +335,42 @@ export function registerTools(
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'exited' },
|
||||
needle: { type: 'string', required: true },
|
||||
exit_code: { type: 'integer', description: 'Exit code, if known.' },
|
||||
exit_signal: { type: 'string', description: 'Exit signal name, if killed by a signal.' },
|
||||
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
|
||||
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsed_ms?: number; timeout_ms?: number; line?: number; column?: number; exit_code?: number | null; exit_signal?: string | null }
|
||||
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
|
||||
if (v.kind === 'found') {
|
||||
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsed_ms}ms).` }]
|
||||
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
|
||||
}
|
||||
if (v.kind === 'timeout') {
|
||||
return [{ type: 'text', text: `Timed out after ${v.timeout_ms}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
|
||||
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
|
||||
}
|
||||
const exitInfo = v.exit_code !== undefined ? ` (exit code ${v.exit_code})` : ''
|
||||
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
|
||||
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
|
||||
},
|
||||
},
|
||||
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
|
||||
exec.signal.throwIfAborted()
|
||||
if (args.needle === '') {
|
||||
throw new Error('needle must be a non-empty string')
|
||||
}
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
// The registry validates the needle (empty → bad-request) and returns
|
||||
// the camelCase result the schema above declares directly — no
|
||||
// field-by-field projection.
|
||||
const timeoutMs = args.timeout_ms ?? 10_000
|
||||
const result = await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
|
||||
// Project the registry's camelCase result into the snake_case canonical
|
||||
// value declared by the output schema (the model sees snake_case).
|
||||
if (result.kind === 'found') {
|
||||
return { kind: 'found' as const, needle: result.needle, line: result.line, column: result.column, elapsed_ms: result.elapsedMs }
|
||||
}
|
||||
if (result.kind === 'timeout') {
|
||||
return { kind: 'timeout' as const, needle: result.needle, timeout_ms: result.timeoutMs, total_lines: result.totalLines }
|
||||
}
|
||||
const out: { kind: 'exited'; needle: string; exit_code?: number; exit_signal?: string } = {
|
||||
kind: 'exited',
|
||||
needle: result.needle,
|
||||
}
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) out.exit_code = result.exitCode
|
||||
if (result.exitSignal !== null && result.exitSignal !== undefined) out.exit_signal = result.exitSignal
|
||||
return out
|
||||
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_resize',
|
||||
description:
|
||||
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
|
||||
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
|
||||
+ 'No-op if the terminal has exited.',
|
||||
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
|
||||
parameters: {
|
||||
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
|
||||
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
|
||||
@@ -387,15 +392,17 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
registry.resize(args.uuid, args.cols, args.rows)
|
||||
return Promise.resolve({ uuid: args.uuid, cols: args.cols, rows: args.rows })
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const dims = registry.resize(args.uuid, args.cols, args.rows)
|
||||
return Promise.resolve({ uuid: args.uuid, ...dims })
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_signal',
|
||||
description:
|
||||
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+D, Ctrl+Z, etc. '
|
||||
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
|
||||
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
|
||||
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
|
||||
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
|
||||
@@ -425,12 +432,14 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
registry.signal(args.uuid, args.signal)
|
||||
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
register(defineTool({
|
||||
name: 'terminal_close',
|
||||
description:
|
||||
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
|
||||
@@ -455,8 +464,14 @@ export function registerTools(
|
||||
},
|
||||
execute: (args: { uuid: string }, exec) => {
|
||||
exec.signal.throwIfAborted()
|
||||
const sessionId = sessionIdOf(exec)
|
||||
registry.assertOwned(args.uuid, sessionId)
|
||||
const closed = registry.close(args.uuid)
|
||||
return Promise.resolve({ uuid: args.uuid, closed })
|
||||
},
|
||||
}))
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -95,7 +95,6 @@ describe('AgentPtyRegistry', () => {
|
||||
expect(page.totalLines).toBeGreaterThan(0)
|
||||
expect(page.text).toContain('line1')
|
||||
expect(page.text).toContain('line3')
|
||||
expect(page.truncated).toBe(false)
|
||||
// Negative offset reads from the end.
|
||||
const tail = registry.read(uuid, -2)
|
||||
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
|
||||
@@ -115,6 +114,38 @@ describe('AgentPtyRegistry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
|
||||
// Oversized / undersized requests report the clamped values, so the
|
||||
// echoed result always matches the pty (the tool contract).
|
||||
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
|
||||
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
|
||||
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
|
||||
// create clamps through the same helper.
|
||||
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
|
||||
expect(registry.get(other)!.pty.cols).toBe(2)
|
||||
expect(registry.get(other)!.pty.rows).toBe(1024)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('assertOwned rejects a uuid owned by another session', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
|
||||
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
|
||||
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
|
||||
// A foreign uuid is indistinguishable from an unknown one.
|
||||
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
|
||||
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes a terminal and removes it from the list', () => {
|
||||
const registry = new AgentPtyRegistry(testShell())
|
||||
try {
|
||||
@@ -182,6 +213,10 @@ describe('AgentPtyRegistry', () => {
|
||||
// No pty or transcript fields on the snapshot.
|
||||
expect('pty' in snap).toBe(false)
|
||||
expect('transcript' in snap).toBe(false)
|
||||
// sessionId is registry-internal ownership, never serialized (it would
|
||||
// violate the terminal_list output schema's additionalProperties:false).
|
||||
expect('sessionId' in snap).toBe(false)
|
||||
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
|
||||
} finally {
|
||||
registry.disposeAll()
|
||||
}
|
||||
|
||||
@@ -45,12 +45,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
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 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
})
|
||||
})
|
||||
|
||||
+61
-3
@@ -255,8 +255,7 @@ describe('session cwd resolution over the API route', () => {
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
const createFakeSettings = () => {
|
||||
const namespaces = new Map<string, {
|
||||
const createFakeSettings = () => { const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
@@ -343,7 +342,7 @@ describe('side card settings routes', () => {
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true },
|
||||
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false },
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
@@ -375,3 +374,62 @@ describe('side card settings routes', () => {
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent terminal tool gating', () => {
|
||||
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
let disposed = 0
|
||||
// The tools currently registered (registered minus disposed).
|
||||
const live = (): number => registered - disposed
|
||||
// A ref container: the watch callback is only assigned inside a closure,
|
||||
// which TypeScript's control-flow analysis ignores (the bare variable
|
||||
// would narrow to null and refuse the optional call).
|
||||
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||
let enabled = false
|
||||
const settings = {
|
||||
register() {
|
||||
return {
|
||||
get: () => ({ agentTerminalTools: enabled }),
|
||||
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||
update: async () => {},
|
||||
replace: async () => {},
|
||||
}
|
||||
},
|
||||
describe: () => [],
|
||||
async update() {},
|
||||
}
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
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 () => {}
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
// Default off: no tools are registered even though the settings service is mounted.
|
||||
expect(live()).toBe(0)
|
||||
// Flipping the setting on registers all eight tools.
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(8)
|
||||
expect(disposed).toBe(0)
|
||||
// Flipping it back off unregisters them (and releases any agent terminals).
|
||||
enabled = false
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(0)
|
||||
expect(disposed).toBe(8)
|
||||
// And a redundant toggle registers them fresh (no double-registration per
|
||||
// flip: the guard only skips when the tools are already live).
|
||||
enabled = true
|
||||
watcherRef.current?.()
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Tool-layer tests for the eight agent-terminal tools: registration shape,
|
||||
* the combined disposer, canonical-output contract (validated against each
|
||||
* tool's declared output schema exactly like the registry's
|
||||
* `createSuccessResult` does), per-session ownership, and the UTF-8
|
||||
* truncation boundary. The pty registry is stubbed (no real spawn): the
|
||||
* wiring of the tools themselves is the subject here — pty behavior is
|
||||
* covered by agent-pty.spec.ts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { boundBytes, registerTools } from '../src/tools.ts'
|
||||
import type { AgentPtyRegistry } from '../src/agent-pty.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** One registration captured by the fake tools service. */
|
||||
interface CapturedTool {
|
||||
definition: ToolDefinition
|
||||
}
|
||||
|
||||
/** Stub registry: uuid-keyed rows without a real pty. */
|
||||
class FakeRegistry {
|
||||
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
|
||||
|
||||
create(sessionId: string, title: string, command: string): string {
|
||||
const uuid = `uuid-${this.terminals.size + 1}`
|
||||
this.terminals.set(uuid, { sessionId, title, command, exited: false })
|
||||
return uuid
|
||||
}
|
||||
|
||||
list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> {
|
||||
return [...this.terminals.entries()]
|
||||
.filter(([, row]) => row.sessionId === sessionId)
|
||||
.map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited }))
|
||||
}
|
||||
|
||||
assertOwned(uuid: string, sessionId: string): void {
|
||||
const row = this.terminals.get(uuid)
|
||||
if (row === undefined || row.sessionId !== sessionId) {
|
||||
throw new Error(`agent terminal "${uuid}" not found`)
|
||||
}
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
|
||||
read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } {
|
||||
// A page larger than the tool's 256 KiB read cap, to exercise boundBytes.
|
||||
return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 }
|
||||
}
|
||||
|
||||
resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
return { cols, rows }
|
||||
}
|
||||
|
||||
signal(): void {}
|
||||
|
||||
close(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> {
|
||||
// Mirrors the registry's empty-needle rejection (the tool layer no
|
||||
// longer duplicates this check).
|
||||
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
|
||||
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */
|
||||
function exec(sessionId: string): ToolRunContext {
|
||||
return {
|
||||
signal: { throwIfAborted: () => {}, aborted: false },
|
||||
agent: { session: { id: sessionId } },
|
||||
} as unknown as ToolRunContext
|
||||
}
|
||||
|
||||
/** Register the eight tools against a fake service; the tools bind to `registry`. */
|
||||
function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } {
|
||||
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 FakeRegistry()
|
||||
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd')
|
||||
return { captured, registry, 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('agent terminal tools', () => {
|
||||
it('registers all eight tools and the combined disposer unregisters them all', () => {
|
||||
const { captured, dispose } = mount()
|
||||
expect(captured.map(candidate => candidate.definition.name)).toEqual([
|
||||
'terminal_create',
|
||||
'terminal_list',
|
||||
'terminal_send',
|
||||
'terminal_read',
|
||||
'terminal_wait_for',
|
||||
'terminal_resize',
|
||||
'terminal_signal',
|
||||
'terminal_close',
|
||||
])
|
||||
expect(dispose()).toBe(8)
|
||||
// Disposing twice must not throw (each registry disposer is idempotent).
|
||||
expect(() => mount().dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('terminal_create returns the uuid and echoed title (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_create')
|
||||
const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1'))
|
||||
expect(value).toMatchObject({ title: 'dev server' })
|
||||
expect(typeof (value as { uuid: unknown }).uuid).toBe('string')
|
||||
expect(registry.terminals.size).toBe(1)
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_list output matches the declared schema — no sessionId leaks', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_list')
|
||||
const uuid = registry.create('s1', 'dev server', 'npm run dev')
|
||||
registry.create('s2', 'other session', 'top secret')
|
||||
const value = await tool.execute({}, exec('s1'))
|
||||
const list = value as Array<Record<string, unknown>>
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]!.uuid).toBe(uuid)
|
||||
// The session id is registry-internal ownership, never serialized.
|
||||
expect('sessionId' in list[0]!).toBe(false)
|
||||
// And the whole value validates against the declared output schema —
|
||||
// this is the contract the registry enforces at runtime (additionalProperties: false).
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const mine = registry.create('s1', 'mine', '')
|
||||
const foreign = registry.create('s2', 'theirs', '')
|
||||
for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) {
|
||||
const tool = toolOf(captured, name)
|
||||
const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' }
|
||||
: name === 'terminal_read' ? { uuid: foreign }
|
||||
: name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 }
|
||||
: name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' }
|
||||
: name === 'terminal_close' ? { uuid: foreign }
|
||||
: { uuid: foreign, needle: 'x', timeout_ms: 100 }
|
||||
// A foreign uuid is indistinguishable from an unknown one.
|
||||
await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/)
|
||||
}
|
||||
// Own-session uuids still work.
|
||||
const sendTool = toolOf(captured, 'terminal_send')
|
||||
await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined()
|
||||
const closeTool = toolOf(captured, 'terminal_close')
|
||||
await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true })
|
||||
})
|
||||
|
||||
it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_read')
|
||||
const uuid = registry.create('s1', 'reader', '')
|
||||
const value = await tool.execute({ uuid }, exec('s1'))
|
||||
const result = value as { text: string; truncated: boolean }
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024)
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_resize')
|
||||
const uuid = registry.create('s1', 'resizer', '')
|
||||
const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1'))
|
||||
expect(value).toEqual({ uuid, cols: 120, rows: 40 })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
const uuid = registry.create('s1', 'waiter', '')
|
||||
const value = await tool.execute({ uuid, needle: 'done' }, exec('s1'))
|
||||
expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 })
|
||||
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => {
|
||||
const { captured, registry } = mount()
|
||||
const tool = toolOf(captured, 'terminal_wait_for')
|
||||
const uuid = registry.create('s1', 'waiter', '')
|
||||
await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundBytes', () => {
|
||||
it('never splits a multi-byte UTF-8 sequence at the cap', () => {
|
||||
// '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary.
|
||||
expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true })
|
||||
expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true })
|
||||
expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true })
|
||||
expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false })
|
||||
expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true })
|
||||
// ASCII pages truncate at the byte cap directly.
|
||||
expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true })
|
||||
})
|
||||
|
||||
it('every retained prefix decodes without U+FFFD', () => {
|
||||
for (let n = 1; n <= 18; n += 1) {
|
||||
const { text } = boundBytes('中文字符串测试 123', n)
|
||||
expect(text.includes('\uFFFD')).toBe(false)
|
||||
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n)
|
||||
}
|
||||
})
|
||||
})
|
||||
+15
-8
@@ -602,29 +602,36 @@ describe('side card preferences', () => {
|
||||
})
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false })
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true })
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true })
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => {
|
||||
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true })
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
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 })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user