diff --git a/.changeset/catalog-rest-and-skill-toggle.md b/.changeset/catalog-rest-and-skill-toggle.md new file mode 100644 index 000000000..45ca3312b --- /dev/null +++ b/.changeset/catalog-rest-and-skill-toggle.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add server endpoints that list installed plugins, enable or disable one, and list subagent profiles, and let a named skill be turned off so it is hidden from the model, the slash menu and the API. diff --git a/.changeset/inline-settings-route.md b/.changeset/inline-settings-route.md new file mode 100644 index 000000000..b00ce16ab --- /dev/null +++ b/.changeset/inline-settings-route.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Open the web settings inside the app shell instead of over it, and add pages for plugins, skills, subagents, connectors, hooks and usage statistics. diff --git a/.github/workflows/desktop-download-badges.yml b/.github/workflows/desktop-download-badges.yml new file mode 100644 index 000000000..62ecf167c --- /dev/null +++ b/.github/workflows/desktop-download-badges.yml @@ -0,0 +1,94 @@ +# Refreshes the per-platform desktop download counters behind the README badges. +# +# shields.io cannot do this on its own: its asset wildcards silently report 0 +# (a tag whose .dmg had 39,877 downloads returns 0 for `*.dmg`), exact asset +# names embed the version so they break on every release, and `dynamic/json` +# rejects every filter expression. So we sum the counts here and publish them +# as shields `endpoint` documents. +# +# The documents land on the orphan `badges` branch, never on `main` — `main` is +# protected and rejects pushes from everyone, CI included. +name: Desktop Download Badges + +on: + schedule: + # Once a day is plenty; the counters move slowly and the API is rate-limited. + - cron: '17 4 * * *' + workflow_dispatch: {} + +permissions: + contents: write + +concurrency: + group: desktop-download-badges + cancel-in-progress: true + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout the badges branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # pinned from v4 + with: + ref: badges + persist-credentials: true + + - name: Sum the .dmg and .exe download counts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Desktop installers shipped from this repo up to v0.1.0 and move to + # pythinker-desktop-releases from the next release on. Summing both + # keeps the counters continuous across that move. + RELEASES_REPOS: PyModel/pythinker-code PyModel/pythinker-desktop-releases + run: | + set -euo pipefail + + # --paginate walks every release, so the totals cover the whole + # history rather than just the latest tag. jq -s concatenates the one + # array each repo produces into a single list of releases. + for repo in ${RELEASES_REPOS}; do + gh api --paginate "repos/${repo}/releases" + done | jq -s 'add' > releases.json + + # An empty suffix matches every asset, which is what the combined + # counter wants; `endswith("")` is true for any string. + write_badge() { + local suffix="$1" label="$2" out="$3" + local total + total="$(jq --arg s "$suffix" ' + [ .[].assets[] + | select(.name | ascii_downcase | endswith($s)) + | .download_count + ] | add // 0 + ' releases.json)" + jq -n --arg label "$label" --arg message "$total" '{ + schemaVersion: 1, + label: $label, + message: $message, + color: "4D6BFE" + }' > "$out" + echo "${label}: ${total}" + } + + write_badge '.dmg' 'macOS .dmg' desktop-dmg.json + write_badge '.exe' 'Windows .exe' desktop-exe.json + # The shields `github/downloads/.../total` route reads one repository, + # so the combined counter has to be summed here like the other two. + write_badge '' 'desktop' desktop-total.json + + rm -f releases.json + + - name: Publish if the counts moved + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + # Stage before comparing: a document added in this run is untracked, + # and `git diff` on an untracked path reports no change at all. + git add desktop-dmg.json desktop-exe.json desktop-total.json + if git diff --cached --quiet; then + echo 'Counts unchanged; nothing to publish.' + exit 0 + fi + git commit -m 'chore: refresh desktop download counts' + git push origin badges diff --git a/README.md b/README.md index 80d96fdff..cf34b20b7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ [![npm version](https://img.shields.io/npm/v/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=CB3837&label=pythinker-code)](https://www.npmjs.com/package/@pymodel/pythinker-code) [![Downloads](https://img.shields.io/npm/dm/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=2b89ff&label=downloads)](https://www.npmjs.com/package/@pymodel/pythinker-code) +[![Desktop downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-total.json&style=for-the-badge&logo=github&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases) +[![macOS .dmg](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-dmg.json&style=for-the-badge&logo=apple&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest) +[![Windows .exe](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-exe.json&style=for-the-badge&logo=windows&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest) [![Node.js](https://img.shields.io/badge/Node.js-26%2B-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://github.com/PyModel/pythinker-code/blob/main/package.json) [![License: MIT](https://img.shields.io/badge/License-MIT-16a34a.svg?style=for-the-badge)](https://github.com/PyModel/pythinker-code/blob/main/LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/PyModel/pythinker-code/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white)](https://github.com/PyModel/pythinker-code/actions/workflows/ci.yml?query=branch%3Amain) diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 742a13445..7078b150e 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -14,7 +14,7 @@ import type { AgentMember } from './types'; import ModelPicker from './components/ModelPicker.vue'; import ProviderManager from './components/ProviderManager.vue'; import NewSessionDialog from './components/NewSessionDialog.vue'; -import SettingsDialog from './components/SettingsDialog.vue'; +import SettingsPane from './components/settings/SettingsPane.vue'; import SessionsDialog from './components/SessionsDialog.vue'; import AddWorkspaceDialog from './components/AddWorkspaceDialog.vue'; import StatusPanel from './components/StatusPanel.vue'; @@ -30,6 +30,7 @@ import { isTraceEnabled } from './debug/trace'; import { usePythinkerWebClient } from './composables/usePythinkerWebClient'; import { useIsMobile } from './composables/useIsMobile'; import { useIsDark } from './composables/useIsDark'; +import { useSettingsNav } from './composables/useSettingsNav'; import type { AppConfig, ThinkingLevel } from './api/types'; import type { FilePreviewRequest, ToolMedia } from './types'; @@ -610,6 +611,43 @@ const showAddWorkspace = ref(false); const showStatusPanel = ref(false); const showSettings = ref(false); +const { + activeTab: activeSettingsTab, + setTab: selectSettingsTab, + refreshActiveTab: refreshSettingsTab, +} = useSettingsNav({ + counts: { + connectors: () => client.connectors.value.length, + plugins: () => client.plugins.value.length, + subagents: () => client.subagents.value.length, + }, + onLoadConnectors: () => { void client.loadConnectors(); }, + onLoadPlugins: () => { void client.loadPlugins(); }, + onLoadSubagents: () => { void client.loadSubagents(); }, +}); + +function openSettings(): void { + showSettings.value = true; + // The active tab persists across visits, so its data has to be refetched on + // open — the session it was loaded for may no longer be the active one. + refreshSettingsTab(); +} + +function toggleSettings(): void { + if (showSettings.value) showSettings.value = false; + else openSettings(); +} + +function loginFromSettings(): void { + showSettings.value = false; + openLogin(); +} + +function openOnboardingFromSettings(): void { + showSettings.value = false; + openOnboarding(); +} + type SubmitPayload = { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[]; @@ -854,6 +892,9 @@ function handleCloseAddWorkspace(): void { // right pane shows the onboarding composer. The session is only created when // the user sends the first message. function handleCreateSession(): void { + // Starting a session leaves the settings route — the new draft has to be + // visible, and the content area can only show one of the two. + showSettings.value = false; const wsId = client.activeWorkspaceId.value; if (wsId) { client.openWorkspaceDraft(wsId); @@ -866,6 +907,7 @@ function handleCreateSession(): void { // state in the chosen workspace. No backend session is created until the user // actually sends a message. function handleCreateSessionInWorkspace(workspaceId: string): void { + showSettings.value = false; client.openWorkspaceDraft(workspaceId); } @@ -918,6 +960,8 @@ function openPr(url: string): void { :attention-by-session="client.attentionBySession.value" :pending-by-session="client.pendingBySession.value" :unread-by-session="client.unreadBySession.value" + :mode="showSettings ? 'settings' : 'sessions'" + :active-settings-tab="activeSettingsTab" @select="client.selectSession($event)" @create="handleCreateSession" @create-in-workspace="handleCreateSessionInWorkspace($event)" @@ -929,7 +973,9 @@ function openPr(url: string): void { @rename-workspace="(id, name) => client.renameWorkspace(id, name)" @delete-workspace="(id) => client.deleteWorkspace(id)" @select-workspaces="handleSelectWorkspaces" - @open-settings="showSettings = true" + @open-settings="openSettings" + @close-settings="showSettings = false" + @select-settings-tab="selectSettingsTab($event)" @collapse="toggleSidebarCollapse" />
+ +
-
+
+ + * { + pointer-events: auto; +} + +.dock-work-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - margin-bottom: 7px; max-height: min(360px, 50vh); display: flex; flex-direction: column; @@ -418,7 +438,6 @@ defineExpose({ loadForEdit }); display: flex; align-items: center; gap: 6px; - padding: 4px var(--dock-inline-right) 2px var(--dock-inline-left); } .dock-work-chip { display: inline-flex; @@ -432,9 +451,11 @@ defineExpose({ loadForEdit }); border: 1px solid var(--line); cursor: pointer; } +/* Opaque on purpose: the chip floats over the conversation, so a translucent + wash would let the text behind it show through. */ .dock-work-chip:hover, .dock-work-chip.on { - background: var(--hover-bg); + background: color-mix(in srgb, var(--ink) 6%, var(--panel)); color: var(--ink); } .dock-work-chip svg { @@ -461,7 +482,7 @@ defineExpose({ loadForEdit }); padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); } - .dock-work-panel { + .dock-float { left: 10px; right: calc(10px + var(--panes-scrollbar-width, 0px)); } diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue index 3216fcdef..ff1bf16ec 100644 --- a/apps/pythinker-web/src/components/Composer.vue +++ b/apps/pythinker-web/src/components/Composer.vue @@ -741,12 +741,15 @@ const hasUpload = computed(() => !!props.uploadImage); const dropdownOpen = ref(false); const modelPillRef = ref(null); const modelDropdownStyle = ref>({}); +/** The dropdown opens on a two-row root menu and drills into one list at a time. */ +const dropdownView = ref<'root' | 'model' | 'effort'>('root'); const permDropdownOpen = ref(false); const toolbarRef = ref(null); function toggleDropdown(): void { dropdownOpen.value = !dropdownOpen.value; if (dropdownOpen.value) { + dropdownView.value = 'root'; const rect = modelPillRef.value?.getBoundingClientRect(); modelDropdownStyle.value = rect ? { maxHeight: `${Math.min(360, Math.max(0, rect.top - 4 - 12))}px` } @@ -1210,8 +1213,46 @@ function selectModel(modelId: string): void {
- -
@@ -1827,6 +1852,35 @@ function selectModel(modelId: string): void { font-size: var(--ui-font-size-xs); flex: none; } + +/* Root menu: two rows, so it sizes to its content and never scrolls. */ +.model-dropdown.is-root { + min-width: 240px; + overflow-y: visible; +} + +.md-value { + color: var(--muted); + flex: none; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.md-chevron { + flex: none; + color: var(--faint); +} + +.md-row-nav:disabled { + cursor: default; + opacity: 0.6; +} + +.md-row-back { + color: var(--muted); +} .md-star { color: var(--star); flex: none; diff --git a/apps/pythinker-web/src/components/SettingsDialog.vue b/apps/pythinker-web/src/components/SettingsDialog.vue deleted file mode 100644 index f7b0102d8..000000000 --- a/apps/pythinker-web/src/components/SettingsDialog.vue +++ /dev/null @@ -1,935 +0,0 @@ - - - - - - - diff --git a/apps/pythinker-web/src/components/Sidebar.vue b/apps/pythinker-web/src/components/Sidebar.vue index e5e213d6d..2c462defe 100644 --- a/apps/pythinker-web/src/components/Sidebar.vue +++ b/apps/pythinker-web/src/components/Sidebar.vue @@ -6,8 +6,10 @@ import { nextTick, onBeforeUnmount, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import type { Session, WorkspaceGroup, WorkspaceView } from '../types'; +import type { SettingsTab } from '../composables/useSettingsNav'; import SessionRow from './SessionRow.vue'; import PythinkerLogo from './PythinkerLogo.vue'; +import SettingsNav from './settings/SettingsNav.vue'; const { t } = useI18n(); @@ -24,6 +26,8 @@ const props = withDefaults( unreadBySession?: Record; /** Width (px) of the session column, driven by the App resize handle. */ colWidth?: number; + mode?: 'sessions' | 'settings'; + activeSettingsTab?: SettingsTab; }>(), { activeWorkspace: null, @@ -32,6 +36,8 @@ const props = withDefaults( pendingBySession: () => ({}), unreadBySession: () => ({}), colWidth: 220, + mode: 'sessions', + activeSettingsTab: 'general', }, ); @@ -48,6 +54,8 @@ const emit = defineEmits<{ renameWorkspace: [id: string, name: string]; deleteWorkspace: [id: string]; openSettings: []; + closeSettings: []; + selectSettingsTab: [tab: SettingsTab]; collapse: []; }>(); @@ -485,6 +493,9 @@ onBeforeUnmount(() => { + + + +
{ } /* Pinned settings action */ +.settings-nav-body { + display: flex; + flex: 1; + min-height: 0; +} .side-foot { flex: none; border-top: none; @@ -1167,6 +1198,12 @@ onBeforeUnmount(() => { text-align: left; cursor: pointer; } +/* The sessions-mode gear sits on the trailing edge; the settings-mode back + arrow keeps the leading edge, where a back control belongs. */ +.settings-row.end { + justify-content: flex-end; + text-align: right; +} .settings-row:hover { color: var(--ink); background: var(--soft); } .settings-row:focus-visible { outline: 2px solid var(--blue); diff --git a/apps/pythinker-web/src/components/settings/ListingRow.vue b/apps/pythinker-web/src/components/settings/ListingRow.vue new file mode 100644 index 000000000..72f1e55d0 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/ListingRow.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/SettingsNav.vue b/apps/pythinker-web/src/components/settings/SettingsNav.vue new file mode 100644 index 000000000..7fd8a03f7 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/SettingsNav.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/SettingsPane.vue b/apps/pythinker-web/src/components/settings/SettingsPane.vue new file mode 100644 index 000000000..99cfa81ce --- /dev/null +++ b/apps/pythinker-web/src/components/settings/SettingsPane.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue b/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue new file mode 100644 index 000000000..2f963a175 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/AdvancedPage.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/AgentPage.vue b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue new file mode 100644 index 000000000..5e573c065 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/AgentPage.vue @@ -0,0 +1,230 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue b/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue new file mode 100644 index 000000000..a5da29532 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/ConnectorsPage.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue b/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue new file mode 100644 index 000000000..4d414b6cf --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/ExperimentalPage.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue new file mode 100644 index 000000000..2bc8fad5c --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/GeneralPage.vue @@ -0,0 +1,241 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/HooksPage.vue b/apps/pythinker-web/src/components/settings/pages/HooksPage.vue new file mode 100644 index 000000000..36a92e1e4 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/HooksPage.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue b/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue new file mode 100644 index 000000000..6c9bf7105 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/PluginsPage.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue b/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue new file mode 100644 index 000000000..445501ccb --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/SkillsPage.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue b/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue new file mode 100644 index 000000000..388255d13 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/SubagentsPage.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/pages/UsagePage.vue b/apps/pythinker-web/src/components/settings/pages/UsagePage.vue new file mode 100644 index 000000000..329da9cd8 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/pages/UsagePage.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/apps/pythinker-web/src/components/settings/settings.css b/apps/pythinker-web/src/components/settings/settings.css new file mode 100644 index 000000000..3f4ffd2b5 --- /dev/null +++ b/apps/pythinker-web/src/components/settings/settings.css @@ -0,0 +1,260 @@ +.panel { display: block; } +.sec { padding: 12px 0; border-bottom: 1px solid var(--line); } +.sec:last-child { border-bottom: none; } +.sec-title { + margin: 0 0 10px; + color: var(--muted); + font-family: var(--mono); + font-size: calc(var(--ui-font-size) - 3px); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.sec-note { + margin: -4px 0 12px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 2px); +} +.sec-empty { + margin: 0; + color: var(--faint); + font-size: calc(var(--ui-font-size) - 1px); +} +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; + padding: 3px 0; +} +.rlabel { + display: flex; + flex-direction: column; + gap: 2px; + color: var(--ink); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 0.5px); +} +.rvalue { + max-width: 60%; + overflow: hidden; + color: var(--muted); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 1.5px); + text-overflow: ellipsis; + white-space: nowrap; +} +.rvalue.mono { font-family: var(--mono); font-size: var(--ui-font-size-xs); } +.hint { color: var(--faint); font-family: var(--sans); font-size: calc(var(--ui-font-size) - 3px); } +.act { + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: calc(var(--ui-font-size) - 1.5px); + cursor: pointer; +} +.act:hover { background: var(--soft); border-color: var(--bd); } +.act.signin { background: var(--blue); color: var(--bg); border-color: var(--blue); } +.act.signin:hover { background: var(--blue2); } +.switch { + position: relative; + flex: none; + width: 40px; + height: 22px; + padding: 0; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel2); + cursor: pointer; + transition: background 0.16s; +} +.switch.on { background: var(--blue); border-color: var(--blue); } +.switch:disabled { opacity: 0.5; cursor: not-allowed; } +.knob { + position: absolute; + top: 1px; + left: 1px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 0.16s; +} +.switch.on .knob { transform: translateX(18px); } +.switch.sm { width: 30px; height: 17px; } +.switch.sm .knob { width: 13px; height: 13px; } +.switch.sm.on .knob { transform: translateX(13px); } +.tag { + flex: none; + padding: 1px 6px; + border-radius: 5px; + background: var(--soft); + color: var(--muted); + font-size: calc(var(--ui-font-size) - 3px); +} +.dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--faint); +} +.dot.s-connected { background: var(--ok); } +.dot.s-connecting { background: var(--warn); } +.dot.s-error { background: var(--err); } +.page-title { + margin: 0 0 6px; + color: var(--ink); + font-size: calc(var(--ui-font-size) + 10px); + font-weight: 700; +} +.page-search { + box-sizing: border-box; + width: 100%; + margin: 0 0 12px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + font-size: var(--ui-font-size); +} +.listing-count { + margin: 0 0 8px; + color: var(--faint); + font-size: calc(var(--ui-font-size) - 2px); +} +.listing { display: flex; flex-direction: column; gap: 2px; } +.listing-head { + margin: 14px 0 6px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 2px); + font-weight: 600; +} +/* Rows are flat rather than carded: the leading glyph, the name and the right + action cluster carry the structure, so a border would only add noise. */ +.listing-row { + display: flex; + flex-direction: column; + padding: 5px 0; +} +.listing-top { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +/* Only the content fades when an entry is off — the switch that turns it back + on has to stay at full contrast. */ +.listing-row.off .listing-main { opacity: 0.5; } +.listing-main { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.row-actions { + display: flex; + flex: none; + align-items: center; + gap: 4px; +} +.listing-glyph { + flex: none; + width: 15px; + height: 15px; + color: var(--faint); +} +.listing-name { + flex: none; + font-weight: 500; + color: var(--ink); +} +.listing-meta { + flex: none; + font-size: calc(var(--ui-font-size) - 2px); + color: var(--faint); +} +.listing-desc, +.listing-path, +.listing-error { + margin: 2px 0 0; + font-size: calc(var(--ui-font-size) - 2px); + color: var(--muted); +} +/* Inside a row the description shares the line with the name, so it truncates + instead of wrapping and pushes the meta text to the right edge. */ +.listing-main .listing-desc { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--faint); +} +.listing-main .listing-meta { margin-left: auto; } +.listing-path { + padding-left: 27px; + color: var(--faint); + word-break: break-all; +} +.listing-error { padding-left: 27px; } +.listing-indent { padding-left: 27px; } + +/* Ghost icon button for the per-row actions — no chrome until hover. */ +.icon-btn { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--faint); + cursor: pointer; +} +.icon-btn svg { width: 15px; height: 15px; } +.icon-btn:hover { background: var(--soft); color: var(--ink); } +.listing-error { + color: var(--err); +} +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + margin-bottom: 6px; +} +.stat-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} +.stat-label { color: var(--muted); font-size: calc(var(--ui-font-size) - 2px); } +.stat-value { color: var(--ink); font-size: calc(var(--ui-font-size) + 8px); font-weight: 700; } +.usage-bar { + height: 4px; + margin-top: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--line2); +} +.usage-bar span { display: block; height: 100%; background: var(--blue); } +.mono { font-family: var(--mono); } + +@media (max-width: 640px) { + .row { align-items: flex-start; flex-direction: column; } +} diff --git a/apps/pythinker-web/src/composables/usePythinkerWebClient.ts b/apps/pythinker-web/src/composables/usePythinkerWebClient.ts index 89bdf0f32..24566178a 100644 --- a/apps/pythinker-web/src/composables/usePythinkerWebClient.ts +++ b/apps/pythinker-web/src/composables/usePythinkerWebClient.ts @@ -18,7 +18,10 @@ import type { AppQuestionRequest, AppSession, AppSessionRuntimeStatus, + AppConnector, + AppPlugin, AppSkill, + AppSubagent, AppTask, AppWarning, AppWorkspace, @@ -1465,6 +1468,66 @@ async function loadSkillsForSession(sessionId: string): Promise { } } +// Configured MCP servers. Global, not session-scoped, and loaded on demand by +// the settings dialog — nothing else in the app needs them. +const connectors = ref([]); +const connectorsLoading = ref(false); + +async function loadConnectors(): Promise { + connectorsLoading.value = true; + try { + connectors.value = await getPythinkerWebApi().listConnectors(); + } catch { + // An older daemon has no /mcp/servers; an empty list is the honest answer. + connectors.value = []; + } finally { + connectorsLoading.value = false; + } +} + +const plugins = ref([]); +const subagents = ref([]); + +async function loadPlugins(): Promise { + try { + plugins.value = await getPythinkerWebApi().listPlugins(); + } catch { + // An older daemon has no /plugins; an empty list is the honest answer. + plugins.value = []; + } +} + +async function setPluginEnabled(pluginId: string, enabled: boolean): Promise { + try { + await getPythinkerWebApi().setPluginEnabled(pluginId, enabled); + } catch { + // The reload below reports whatever state the daemon ended up in. + } + await loadPlugins(); +} + +async function loadSubagents(): Promise { + const workDir = rawState.sessions.find((s) => s.id === rawState.activeSessionId)?.cwd; + if (workDir === undefined || workDir === '') { + subagents.value = []; + return; + } + try { + subagents.value = await getPythinkerWebApi().listSubagents(workDir); + } catch { + subagents.value = []; + } +} + +async function restartConnector(connectorId: string): Promise { + try { + await getPythinkerWebApi().restartConnector(connectorId); + } catch { + // The reload below reports whatever state the server ended up in. + } + await loadConnectors(); +} + function hasLoadedMessages(sessionId: string): boolean { return Object.prototype.hasOwnProperty.call(rawState.messagesBySession, sessionId); } @@ -4372,6 +4435,17 @@ export function usePythinkerWebClient() { loadProviders, skills, activateSkill, + connectors, + connectorsLoading, + plugins, + loadPlugins, + setPluginEnabled, + subagents, + loadSubagents, + /** Raw sessions with their usage totals — the settings usage page reads these. */ + sessionsWithUsage: computed(() => rawState.sessions), + loadConnectors, + restartConnector, setModel, toggleStarModel, addProvider, diff --git a/apps/pythinker-web/src/composables/useSettingsNav.ts b/apps/pythinker-web/src/composables/useSettingsNav.ts new file mode 100644 index 000000000..30b3f148e --- /dev/null +++ b/apps/pythinker-web/src/composables/useSettingsNav.ts @@ -0,0 +1,80 @@ +import { shallowRef, toValue, type MaybeRefOrGetter } from 'vue'; + +export type SettingsTab = + | 'general' + | 'agent' + | 'skills' + | 'connectors' + | 'plugins' + | 'subagents' + | 'hooks' + | 'usage' + | 'advanced' + | 'experimental'; + +export const tabGroups: Array<{ + titleKey: string; + tabs: Array<{ id: SettingsTab; labelKey: string }>; +}> = [ + { + titleKey: 'settings.groups.basics', + tabs: [ + { id: 'general', labelKey: 'settings.tabs.general' }, + { id: 'agent', labelKey: 'settings.tabs.agent' }, + ], + }, + { + titleKey: 'settings.groups.capabilities', + tabs: [ + { id: 'plugins', labelKey: 'settings.tabs.plugins' }, + { id: 'skills', labelKey: 'settings.tabs.skills' }, + { id: 'subagents', labelKey: 'settings.tabs.subagents' }, + { id: 'connectors', labelKey: 'settings.tabs.connectors' }, + { id: 'hooks', labelKey: 'settings.tabs.hooks' }, + ], + }, + { + titleKey: 'settings.groups.data', + tabs: [ + { id: 'usage', labelKey: 'settings.tabs.usage' }, + { id: 'advanced', labelKey: 'settings.tabs.advanced' }, + { id: 'experimental', labelKey: 'settings.tabs.experimental' }, + ], + }, +]; + +type UseSettingsNavOptions = { + counts: { + connectors: MaybeRefOrGetter; + plugins: MaybeRefOrGetter; + subagents: MaybeRefOrGetter; + }; + onLoadConnectors: () => void; + onLoadPlugins: () => void; + onLoadSubagents: () => void; +}; + +export function useSettingsNav(options: UseSettingsNavOptions) { + const activeTab = shallowRef('general'); + + function loadFor(tab: SettingsTab): void { + if (tab === 'connectors' && toValue(options.counts.connectors) === 0) options.onLoadConnectors(); + if (tab === 'plugins' && toValue(options.counts.plugins) === 0) options.onLoadPlugins(); + // Connectors and plugins are daemon-wide, so one load holds. Subagents are + // resolved from the active session's working directory, so a cached list + // belongs to whichever session was active when it loaded — always refetch. + if (tab === 'subagents') options.onLoadSubagents(); + } + + function setTab(tab: SettingsTab): void { + loadFor(tab); + activeTab.value = tab; + } + + /** Call when the settings route opens; the active tab persists across visits. */ + function refreshActiveTab(): void { + loadFor(activeTab.value); + } + + return { activeTab, setTab, refreshActiveTab }; +} diff --git a/apps/pythinker-web/src/i18n/locales/en/settings.ts b/apps/pythinker-web/src/i18n/locales/en/settings.ts index 074945943..c880e8cf7 100644 --- a/apps/pythinker-web/src/i18n/locales/en/settings.ts +++ b/apps/pythinker-web/src/i18n/locales/en/settings.ts @@ -1,11 +1,79 @@ export default { title: 'Settings', + /** Sidebar control that leaves the settings route and restores the session list. */ + backToSessions: 'Back to sessions', + groups: { + basics: 'Basics', + capabilities: 'Agent capabilities', + data: 'Data and statistics', + }, tabs: { general: 'General', agent: 'Agent', + plugins: 'Plugins', + skills: 'Skills', + subagents: 'Subagents', + connectors: 'Connectors', + hooks: 'Hooks', + usage: 'Usage stats', advanced: 'Advanced', experimental: 'Experimental', }, + skills: { + title: 'Skills', + note: 'Skills the agent can use, grouped by source. A skill you turn off is never loaded.', + empty: 'No skills are available. Open a session to load them.', + slashOnly: 'slash only', + toggleAria: 'Enable {name}', + search: 'Search skills…', + count: '{count} items', + }, + connectors: { + title: 'Connectors', + note: 'MCP servers configured for this workspace and their connection state.', + empty: 'No MCP servers are configured.', + loading: 'Loading connectors…', + tools: '{count} tools', + restart: 'Restart', + status: { + connected: 'Connected.', + connecting: 'Connecting…', + disconnected: 'Disconnected.', + error: 'Failed to connect.', + }, + }, + plugins: { + title: 'Plugins', + note: 'Installed plugins and the skills and MCP servers each one contributes.', + empty: 'No plugins are installed.', + counts: '{skills} skills · {servers} servers', + hasErrors: 'This plugin reported errors while loading.', + toggleAria: 'Enable {name}', + }, + subagents: { + title: 'Subagents', + note: 'Profiles the agent can dispatch work to, resolved from the active session folder.', + empty: 'No subagent profiles were found. Open a session to resolve them.', + tools: '{count} tools', + }, + hooks: { + title: 'Hooks', + note: 'Commands the agent runs on lifecycle events. Changes apply to new sessions.', + empty: 'No hooks are configured.', + count: '{count} hooks', + async: 'async', + timeout: '{seconds}s timeout', + }, + usage: { + title: 'Usage stats', + note: 'Totals across every session loaded in this client.', + tokens: 'Token usage', + sessions: 'Sessions', + turns: 'Turns', + cost: 'Cost', + byModel: 'By model', + empty: 'No usage recorded yet.', + }, appearance: 'Appearance', notifications: 'Notifications', notifyOnComplete: 'Notify when a turn completes', diff --git a/apps/pythinker-web/src/i18n/locales/en/status.ts b/apps/pythinker-web/src/i18n/locales/en/status.ts index dabe21ae6..2ba72b0a0 100644 --- a/apps/pythinker-web/src/i18n/locales/en/status.ts +++ b/apps/pythinker-web/src/i18n/locales/en/status.ts @@ -32,7 +32,7 @@ export default { // Thinking selector thinkingLabel: 'thinking', thinkingTooltip: 'Toggle thinking mode', - effortLabel: 'Thinking effort', + effortRow: 'Effort', effortLevels: { off: 'Off', minimal: 'Minimal', diff --git a/apps/pythinker-web/test/composer.test.ts b/apps/pythinker-web/test/composer.test.ts index bd187766e..a618dbcc9 100644 --- a/apps/pythinker-web/test/composer.test.ts +++ b/apps/pythinker-web/test/composer.test.ts @@ -35,10 +35,21 @@ function mountComposer(props: Record = {}) { compact: { desc: 'Compact context' }, }, status: { + modelLabel: 'Model', modelTooltip: 'Switch model', starredModels: 'Starred', moreModels: 'More models…', thinkingLabel: 'thinking', + effortRow: 'Effort', + effortLevels: { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'xHigh', + max: 'Max', + }, }, }, }, @@ -54,6 +65,12 @@ function mountComposer(props: Record = {}) { }); } +/** The dropdown opens on the root menu; step into the model list. */ +async function openModelList(wrapper: ReturnType): Promise { + const modelRow = wrapper.findAll('.md-row-nav').find((row) => row.text().includes('Model')); + await modelRow!.trigger('click'); +} + function waitForCompositionEndTimer(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -371,6 +388,7 @@ describe('Composer model dropdown', () => { }); await wrapper.find('.model-pill').trigger('click'); + await openModelList(wrapper); const rows = wrapper.findAll('.md-row'); expect(rows.length).toBeGreaterThan(0); @@ -387,6 +405,7 @@ describe('Composer model dropdown', () => { }); await wrapper.find('.model-pill').trigger('click'); + await openModelList(wrapper); const starredRow = wrapper.findAll('.md-row').find((row) => row.text().includes('GPT-5')); expect(starredRow).toBeDefined(); await starredRow!.trigger('click'); @@ -426,6 +445,40 @@ describe('Composer model dropdown', () => { expect(wrapper.get('.model-dropdown').element.style.maxHeight).toBe('360px'); }); + it('opens on a two-row root menu and drills into the effort list', async () => { + const wrapper = mountComposer({ + status: { model: 'Pythinker K2', modelId: 'pythinker/k2', ctxUsed: 0, ctxMax: 128000, permission: 'manual' }, + models: [ + { + id: 'pythinker/k2', + provider: 'pythinker', + model: 'k2', + displayName: 'Pythinker K2', + maxContextSize: 128000, + capabilities: ['thinking'], + }, + ], + thinking: 'medium', + }); + + await wrapper.find('.model-pill').trigger('click'); + expect(wrapper.findAll('.md-row-nav')).toHaveLength(2); + expect(wrapper.text()).toContain('Pythinker K2'); + expect(wrapper.text()).toContain('Medium'); + // The model list stays behind the Model row. + expect(wrapper.text()).not.toContain('More models…'); + + const effortRow = wrapper.findAll('.md-row-nav').find((row) => row.text().includes('Effort')); + await effortRow!.trigger('click'); + + const levels = wrapper.findAll('.md-row').map((row) => row.text()); + expect(levels).toEqual(['Effort', 'Off', 'Low', 'Medium', 'High']); + + const high = wrapper.findAll('.md-row').find((row) => row.text() === 'High'); + await high!.trigger('click'); + expect(wrapper.emitted('setThinking')).toEqual([['high']]); + }); + it('replaces the binary thinking toggle with the effort list', () => { expect(composerSource).not.toContain('toggleThinking'); expect(composerSource).not.toContain('md-row-toggle'); diff --git a/apps/pythinker-web/test/css-custom-properties.test.ts b/apps/pythinker-web/test/css-custom-properties.test.ts new file mode 100644 index 000000000..ac9af1c7d --- /dev/null +++ b/apps/pythinker-web/test/css-custom-properties.test.ts @@ -0,0 +1,36 @@ +import { existsSync, globSync, readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * An unresolvable `var(--x)` makes the whole declaration invalid at computed-value + * time, so the property silently falls back to its initial value — a background + * becomes transparent, a colour becomes black. That reads as a rendering bug, not + * a typo, so pin it here: every custom property must be defined somewhere in the + * app, or carry a fallback at the point of use. + */ + +const SRC = ['src', 'apps/pythinker-web/src'].find(existsSync); +if (SRC === undefined) throw new Error('the web app source directory was not found'); + +const sources = globSync('**/*.{vue,css,ts}', { cwd: SRC }).map((file) => + readFileSync(`${SRC}/${file}`, 'utf8'), +); +const all = sources.join('\n'); + +/** Names used without a fallback: `var(--x)` but not `var(--x, …)`. */ +const usedWithoutFallback = new Set( + [...all.matchAll(/var\((--[a-z\d-]+)\s*\)/gu)].map((match) => match[1]!), +); +const defined = new Set([...all.matchAll(/(--[a-z\d-]+)\s*'?\s*:/gu)].map((match) => match[1]!)); + +describe('CSS custom properties', () => { + it('finds custom properties to check', () => { + expect(usedWithoutFallback.size).toBeGreaterThan(50); + }); + + it('resolves every custom property used without a fallback', () => { + const missing = [...usedWithoutFallback].filter((name) => !defined.has(name)).toSorted(); + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/pythinker-web/test/settings-dialog.test.ts b/apps/pythinker-web/test/settings-dialog.test.ts deleted file mode 100644 index 24180a738..000000000 --- a/apps/pythinker-web/test/settings-dialog.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { createI18n } from 'vue-i18n'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import SettingsDialog from '../src/components/SettingsDialog.vue'; -import enSettings from '../src/i18n/locales/en/settings'; -import type { AppConfig, AppModel } from '../src/api/types'; - -const i18n = createI18n({ - legacy: false, - locale: 'en', - messages: { - en: { - settings: enSettings, - theme: { - label: 'Theme', - modern: 'Modern', - pythinker: 'Pythinker', - colorSchemeLabel: 'Color scheme', - light: 'Light', - dark: 'Dark', - system: 'System', - }, - sidebar: { - daemon: 'Daemon', - language: 'Language', - notSignedIn: 'Not signed in', - signIn: 'Sign in', - signOut: 'Sign out', - }, - onboarding: { reopen: 'Open onboarding' }, - newSession: { close: 'Close' }, - }, - }, - missingWarn: false, - fallbackWarn: false, -}); - -const config: AppConfig = { - providers: { - pythinker: { - type: 'pythoughts', - defaultModel: 'pythinker/k2', - hasApiKey: true, - }, - openai: { - type: 'openai', - hasApiKey: false, - }, - }, - defaultModel: 'pythinker/k2', - models: { - 'pythinker/k2': { provider: 'pythinker', model: 'k2' }, - 'openai/gpt-5': { provider: 'openai', model: 'gpt-5' }, - }, - defaultPermissionMode: 'manual', - defaultThinking: true, - defaultPlanMode: false, - mergeAllAvailableSkills: false, - telemetry: true, - raw: { secret: 'must-not-render' }, -}; - -const models: AppModel[] = [ - { - id: 'pythinker/k2', - provider: 'pythinker', - model: 'k2', - displayName: 'Pythinker K2', - maxContextSize: 128000, - }, - { - id: 'openai/gpt-5', - provider: 'openai', - model: 'gpt-5', - displayName: 'GPT-5', - maxContextSize: 256000, - }, -]; - -function mountDialog() { - return mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'pythinker/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - }, - global: { - plugins: [i18n], - }, - }); -} - -afterEach(() => { - document.body.innerHTML = ''; - delete window.pythinkerDesktop; -}); - -describe('SettingsDialog tabs', () => { - it('renders side tabs and switches panels', async () => { - const wrapper = mountDialog(); - - expect(wrapper.text()).toContain('General'); - - const generalTab = wrapper.findAll('.tab').find((button) => button.text() === 'General'); - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - const advancedTab = wrapper.findAll('.tab').find((button) => button.text() === 'Advanced'); - const experimentalTab = wrapper.findAll('.tab').find((button) => button.text() === 'Experimental'); - - expect(generalTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await agentTab!.trigger('click'); - expect(generalTab!.classes('on')).toBe(false); - expect(agentTab!.classes('on')).toBe(true); - - const agentPanel = wrapper.find('#settings-panel-agent'); - expect(agentPanel.isVisible()).toBe(true); - const generalPanel = wrapper.find('#settings-panel-general'); - expect(generalPanel.isVisible()).toBe(false); - - await advancedTab!.trigger('click'); - expect(advancedTab!.classes('on')).toBe(true); - expect(agentTab!.classes('on')).toBe(false); - - await experimentalTab!.trigger('click'); - expect(experimentalTab!.classes('on')).toBe(true); - expect(advancedTab!.classes('on')).toBe(false); - }); -}); - -describe('SettingsDialog config controls', () => { - it('renders redacted daemon config and emits partial config patches', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - expect(wrapper.text()).toContain('Agent defaults'); - expect(wrapper.text()).toContain('Pythinker K2'); - expect(wrapper.text()).toContain('Credential configured'); - expect(wrapper.text()).toContain('Missing credential'); - expect(wrapper.text()).not.toContain('must-not-render'); - - await wrapper.find('.select-field').setValue('openai/gpt-5'); - expect(wrapper.emitted('updateConfig')?.[0]?.[0]).toEqual({ defaultModel: 'openai/gpt-5' }); - - const auto = wrapper.findAll('.opt').find((button) => button.text() === 'Auto'); - await auto!.trigger('click'); - expect(wrapper.emitted('updateConfig')?.[1]?.[0]).toEqual({ defaultPermissionMode: 'auto' }); - - const planRow = wrapper.findAll('.row').find((row) => row.text().includes('Plan mode by default')); - await planRow!.find('button.switch').trigger('click'); - expect(wrapper.emitted('updateConfig')?.[2]?.[0]).toEqual({ defaultPlanMode: true }); - }); - - it('groups default model options by provider', async () => { - const wrapper = mountDialog(); - - const agentTab = wrapper.findAll('.tab').find((button) => button.text() === 'Agent'); - await agentTab!.trigger('click'); - - const groups = wrapper.findAll('optgroup'); - expect(groups.length).toBe(2); - expect(groups[0]!.attributes('label')).toBe('openai'); - expect(groups[1]!.attributes('label')).toBe('pythinker'); - - const openaiOptions = groups[0]!.findAll('option'); - expect(openaiOptions.some((o) => o.attributes('value') === 'openai/gpt-5')).toBe(true); - - const pythinkerOptions = groups[1]!.findAll('option'); - expect(pythinkerOptions.some((o) => o.attributes('value') === 'pythinker/k2')).toBe(true); - }); -}); - -describe('SettingsDialog desktop updates', () => { - it('renders desktop update controls and checks for updates', async () => { - const checkForUpdates = vi.fn().mockResolvedValue(undefined); - window.pythinkerDesktop = { - platform: 'darwin', - getUpdateState: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), - setAutoUpdate: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), - checkForUpdates, - quitAndInstall: vi.fn().mockResolvedValue(undefined), - onUpdateState: vi.fn().mockReturnValue(() => undefined), - }; - - const wrapper = mountDialog(); - - expect(wrapper.text()).toContain('Desktop app'); - const checkButton = wrapper.findAll('button').find((button) => button.text() === 'Check for updates'); - expect(checkButton).toBeDefined(); - - await checkButton!.trigger('click'); - expect(checkForUpdates).toHaveBeenCalledOnce(); - }); - - it('hides desktop update controls outside the desktop app', () => { - const wrapper = mountDialog(); - - expect(wrapper.text()).not.toContain('Desktop app'); - }); -}); - -describe('SettingsDialog dialog focus', () => { - it('is a modal that takes focus on open and restores it on close', async () => { - const opener = document.createElement('button'); - document.body.append(opener); - opener.focus(); - expect(document.activeElement).toBe(opener); - - const wrapper = mount(SettingsDialog, { - props: { - theme: 'modern', - colorScheme: 'system', - uiFontSize: 15, - authReady: true, - accountModel: 'pythinker/k2', - notify: true, - notifyPermission: 'granted', - betaToc: false, - config, - models, - configSaving: false, - }, - global: { plugins: [i18n] }, - attachTo: document.body, - }); - - const dialog = wrapper.find('.dialog'); - expect(dialog.attributes('aria-modal')).toBe('true'); - - await nextTick(); - // Opening moves focus into the dialog. - expect(document.activeElement).toBe(dialog.element); - - wrapper.unmount(); - await nextTick(); - // Closing returns focus to the opener. - expect(document.activeElement).toBe(opener); - - opener.remove(); - }); -}); diff --git a/apps/pythinker-web/test/settings-pane.test.ts b/apps/pythinker-web/test/settings-pane.test.ts new file mode 100644 index 000000000..9d115aa05 --- /dev/null +++ b/apps/pythinker-web/test/settings-pane.test.ts @@ -0,0 +1,515 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { createI18n } from 'vue-i18n'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import App from '../src/App.vue'; +import type { AppConfig, AppConnector, AppModel, AppSession, AppSkill } from '../src/api/types'; +import ConversationPane from '../src/components/ConversationPane.vue'; +import SettingsNav from '../src/components/settings/SettingsNav.vue'; +import SettingsPane from '../src/components/settings/SettingsPane.vue'; +import { messages } from '../src/i18n/locales'; +import { useSettingsNav, type SettingsTab } from '../src/composables/useSettingsNav'; + +vi.mock('../src/composables/useIsMobile', async () => { + const { ref: vueRef } = await import('vue'); + return { useIsMobile: () => vueRef(false) }; +}); + +vi.mock('../src/composables/useIsDark', async () => { + const { ref: vueRef } = await import('vue'); + return { useIsDark: () => vueRef(false) }; +}); + +vi.mock('../src/composables/usePythinkerWebClient', async () => { + const { ref: vueRef } = await import('vue'); + const arrayKeys = [ + 'activationBadges', 'changes', 'connectors', 'dynamicWorkflows', 'models', 'pendingApprovals', + 'plugins', 'providers', 'questions', 'queued', 'recentCwds', 'sessions', 'sessionsForView', + 'sessionsWithUsage', 'sideChatTurns', 'skills', 'starredModelIds', 'subagents', 'tasks', 'todos', + 'turns', 'warnings', 'workspaceGroups', 'workspacesView', + ]; + const recordKeys = ['attentionBySession', 'attentionByWorkspace', 'pendingBySession', 'unreadBySession']; + const client: Record = { + activePullRequest: vueRef(null), + activeSessionId: vueRef(''), + activeWorkspaceId: vueRef(null), + activity: vueRef('idle'), + authReady: vueRef(true), + betaToc: vueRef(false), + colorScheme: vueRef('system'), + compaction: vueRef(null), + config: vueRef(null), + connectorsLoading: vueRef(false), + defaultModel: vueRef(null), + dynamicWorkflowMode: vueRef(false), + fastSpinner: vueRef(false), + fileDiff: vueRef(null), + fileDiffLoading: vueRef(false), + gitDiffStats: vueRef(null), + gitInfo: vueRef(null), + goal: vueRef(null), + goalMode: vueRef(false), + initialized: vueRef(true), + isSending: vueRef(false), + notifyOnComplete: vueRef(false), + notifyPermission: vueRef('default'), + onboarded: vueRef(true), + permission: vueRef('manual'), + planMode: vueRef(false), + selectedDiffPath: vueRef(null), + sessionCost: vueRef(0), + sessionLoading: vueRef(false), + sideChatRunning: vueRef(false), + sideChatSending: vueRef(false), + sideChatVisible: vueRef(false), + status: vueRef({ branch: '', cwd: '/workspace', ctxMax: 0, ctxUsed: 0, model: '', modelId: '', permission: 'manual' }), + theme: vueRef('modern'), + thinking: vueRef('off'), + uiFontSize: vueRef(15), + visibleWorkspace: vueRef(null), + resolveImageUrl: vi.fn(), + }; + for (const key of arrayKeys) client[key] = vueRef([]); + for (const key of recordKeys) client[key] = vueRef({}); + return { + usePythinkerWebClient: () => new Proxy(client, { + get(target, property) { + if (property in target) return Reflect.get(target, property); + const method = vi.fn(); + target[String(property)] = method; + return method; + }, + }), + }; +}); + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages, + missingWarn: false, + fallbackWarn: false, +}); + +const config: AppConfig = { + providers: { + pythinker: { type: 'pythoughts', defaultModel: 'pythinker/k2', hasApiKey: true }, + openai: { type: 'openai', hasApiKey: false }, + }, + defaultModel: 'pythinker/k2', + models: { + 'pythinker/k2': { provider: 'pythinker', model: 'k2' }, + 'openai/gpt-5': { provider: 'openai', model: 'gpt-5' }, + }, + defaultPermissionMode: 'manual', + defaultThinking: true, + defaultPlanMode: false, + mergeAllAvailableSkills: false, + telemetry: true, + raw: { secret: 'must-not-render' }, +}; + +const models: AppModel[] = [ + { id: 'pythinker/k2', provider: 'pythinker', model: 'k2', displayName: 'Pythinker K2', maxContextSize: 128000 }, + { id: 'openai/gpt-5', provider: 'openai', model: 'gpt-5', displayName: 'GPT-5', maxContextSize: 256000 }, +]; + +const skills: AppSkill[] = [ + { name: 'gen-changesets', description: 'Write the changesets for a PR', source: 'project', path: '.pythinker/skills/gen-changesets' }, + { name: 'brainstorm', description: 'Explore a problem first', source: 'builtin' }, + { name: 'archive', description: 'Archive a session', source: 'builtin', disableModelInvocation: true }, +]; + +const connectors: AppConnector[] = [ + { id: 'mcp_1', name: 'context7', transport: 'http', status: 'connected', toolCount: 2 }, + { id: 'mcp_2', name: 'tavily', transport: 'stdio', status: 'error', toolCount: 0, lastError: 'spawn ENOENT' }, +]; + +function mountPane(activeTab: SettingsTab, extraProps: Record = {}) { + return mount(SettingsPane, { + props: { + activeTab, + theme: 'modern', + colorScheme: 'system', + uiFontSize: 15, + authReady: true, + accountModel: 'pythinker/k2', + notify: true, + notifyPermission: 'granted', + betaToc: false, + config, + models, + configSaving: false, + ...extraProps, + }, + global: { plugins: [i18n] }, + }); +} + +afterEach(() => { + document.body.innerHTML = ''; + delete window.pythinkerDesktop; +}); + +describe('settings navigation', () => { + it('renders the ten grouped tabs and emits a selected tab', async () => { + const wrapper = mount(SettingsNav, { + props: { activeTab: 'general' }, + global: { plugins: [i18n] }, + }); + + expect(wrapper.findAll('.tab')).toHaveLength(10); + expect(wrapper.findAll('.tab-group').map((group) => group.text())).toEqual([ + 'Basics', + 'Agent capabilities', + 'Data and statistics', + ]); + expect(wrapper.get('#settings-tab-general').classes()).toContain('on'); + + await wrapper.get('#settings-tab-agent').trigger('click'); + expect(wrapper.emitted('select')).toEqual([['agent']]); + await wrapper.setProps({ activeTab: 'agent' }); + expect(wrapper.get('#settings-tab-agent').attributes('aria-selected')).toBe('true'); + }); + + it('renders only the active settings page without modal markup', async () => { + const wrapper = mountPane('general'); + + expect(wrapper.get('#settings-panel-general').isVisible()).toBe(true); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + expect(wrapper.find('.backdrop').exists()).toBe(false); + + await wrapper.setProps({ activeTab: 'agent' }); + expect(wrapper.get('#settings-panel-general').attributes('style')).toContain('display: none'); + expect(wrapper.get('#settings-panel-agent').isVisible()).toBe(true); + }); + + it('loads connectors each time the empty page is opened', () => { + const onLoadConnectors = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors, + onLoadPlugins: vi.fn(), + onLoadSubagents: vi.fn(), + }); + + setTab('connectors'); + expect(onLoadConnectors).toHaveBeenCalledOnce(); + setTab('general'); + setTab('connectors'); + expect(onLoadConnectors).toHaveBeenCalledTimes(2); + }); + + it('does not load connectors when they are already known', () => { + const onLoadConnectors = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 2, plugins: 0, subagents: 0 }, + onLoadConnectors, + onLoadPlugins: vi.fn(), + onLoadSubagents: vi.fn(), + }); + + setTab('connectors'); + expect(onLoadConnectors).not.toHaveBeenCalled(); + }); + + it('loads plugins when the empty page is opened', () => { + const onLoadPlugins = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors: vi.fn(), + onLoadPlugins, + onLoadSubagents: vi.fn(), + }); + + setTab('plugins'); + expect(onLoadPlugins).toHaveBeenCalledOnce(); + }); + + it('loads subagents when the empty page is opened', () => { + const onLoadSubagents = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 0 }, + onLoadConnectors: vi.fn(), + onLoadPlugins: vi.fn(), + onLoadSubagents, + }); + + setTab('subagents'); + expect(onLoadSubagents).toHaveBeenCalledOnce(); + }); + + it('reloads subagents even when a list is already cached', () => { + // The cached list belongs to whichever session was active when it loaded, + // so a non-empty count must not suppress the refetch. + const onLoadSubagents = vi.fn(); + const { setTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 3 }, + onLoadConnectors: vi.fn(), + onLoadPlugins: vi.fn(), + onLoadSubagents, + }); + + setTab('subagents'); + expect(onLoadSubagents).toHaveBeenCalledOnce(); + }); + + it('reloads the persisted tab when the settings route reopens', () => { + const onLoadSubagents = vi.fn(); + const { setTab, refreshActiveTab } = useSettingsNav({ + counts: { connectors: 0, plugins: 0, subagents: 3 }, + onLoadConnectors: vi.fn(), + onLoadPlugins: vi.fn(), + onLoadSubagents, + }); + + setTab('subagents'); + refreshActiveTab(); + expect(onLoadSubagents).toHaveBeenCalledTimes(2); + }); +}); + +describe('SettingsPane config controls', () => { + it('renders redacted daemon config and emits partial config patches', async () => { + const wrapper = mountPane('agent'); + + expect(wrapper.text()).toContain('Agent defaults'); + expect(wrapper.text()).toContain('Pythinker K2'); + expect(wrapper.text()).toContain('Credential configured'); + expect(wrapper.text()).toContain('Missing credential'); + expect(wrapper.text()).not.toContain('must-not-render'); + + await wrapper.find('.select-field').setValue('openai/gpt-5'); + expect(wrapper.emitted('updateConfig')?.[0]?.[0]).toEqual({ defaultModel: 'openai/gpt-5' }); + const auto = wrapper.findAll('.opt').find((button) => button.text() === 'Auto'); + await auto!.trigger('click'); + expect(wrapper.emitted('updateConfig')?.[1]?.[0]).toEqual({ defaultPermissionMode: 'auto' }); + const planRow = wrapper.findAll('.row').find((row) => row.text().includes('Plan mode by default')); + await planRow!.find('button.switch').trigger('click'); + expect(wrapper.emitted('updateConfig')?.[2]?.[0]).toEqual({ defaultPlanMode: true }); + }); + + it('groups default model options by provider', () => { + const groups = mountPane('agent').findAll('optgroup'); + + expect(groups).toHaveLength(2); + expect(groups[0]!.attributes('label')).toBe('openai'); + expect(groups[1]!.attributes('label')).toBe('pythinker'); + expect(groups[0]!.findAll('option').some((option) => option.attributes('value') === 'openai/gpt-5')).toBe(true); + expect(groups[1]!.findAll('option').some((option) => option.attributes('value') === 'pythinker/k2')).toBe(true); + }); +}); + +describe('SettingsPane desktop updates', () => { + it('renders desktop update controls and checks for updates', async () => { + const checkForUpdates = vi.fn().mockResolvedValue(undefined); + window.pythinkerDesktop = { + platform: 'darwin', + getUpdateState: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), + setAutoUpdate: vi.fn().mockResolvedValue({ status: 'idle', autoUpdate: true }), + checkForUpdates, + quitAndInstall: vi.fn().mockResolvedValue(undefined), + onUpdateState: vi.fn().mockReturnValue(() => undefined), + }; + const wrapper = mountPane('general'); + + expect(wrapper.text()).toContain('Desktop app'); + const checkButton = wrapper.findAll('button').find((button) => button.text() === 'Check for updates'); + await checkButton!.trigger('click'); + expect(checkForUpdates).toHaveBeenCalledOnce(); + }); + + it('hides desktop update controls outside the desktop app', () => { + expect(mountPane('general').text()).not.toContain('Desktop app'); + }); +}); + +describe('SettingsPane agent page', () => { + it('keeps a configured default the catalog no longer offers', () => { + // Without a matching option the browser shows its first one, which reads + // as a saved default that was never chosen. + const wrapper = mountPane('agent', { config: { ...config, defaultModel: 'retired/model' } }); + const select = wrapper.get('#settings-panel-agent select.select-field'); + + expect(select.findAll('option').map((option) => option.attributes('value'))) + .toContain('retired/model'); + expect((select.element as HTMLSelectElement).value).toBe('retired/model'); + }); +}); + +describe('SettingsPane skills page', () => { + it('groups skills by source and marks the slash-only ones', () => { + const panel = mountPane('skills', { skills }).get('#settings-panel-skills'); + + expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual(['builtin', 'project']); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['archive', 'brainstorm', 'gen-changesets']); + expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual(['slash only']); + }); + + it('says so when no skill is available', () => { + expect(mountPane('skills').get('#settings-panel-skills').text()).toContain('No skills are available'); + }); + + it('reads a disabled name that is cased differently as off, and clears it once', async () => { + // The core lowercases disabled names, so a config entry cased differently + // from the catalog still disables the skill and the page has to agree. + const wrapper = mountPane('skills', { + skills, + config: { ...config, disabledSkills: ['Gen-Changesets'] }, + }); + const row = wrapper.get('#settings-panel-skills').findAll('.listing-row') + .find((candidate) => candidate.text().includes('gen-changesets')); + + expect(row?.classes()).toContain('off'); + + await row?.get('button.switch').trigger('click'); + + expect(wrapper.emitted('updateConfig')?.at(-1)).toEqual([{ disabledSkills: [] }]); + }); +}); + +describe('SettingsPane connectors page', () => { + it('shows each server status and restarts one on demand', async () => { + const wrapper = mountPane('connectors', { connectors }); + const panel = wrapper.get('#settings-panel-connectors'); + + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['context7', 'tavily']); + expect(panel.findAll('.dot').map((dot) => dot.classes().join(' '))).toEqual(['dot s-connected', 'dot s-error']); + expect(panel.text()).toContain('spawn ENOENT'); + expect(panel.text()).toContain('2 tools'); + await panel.findAll('.icon-btn')[1]!.trigger('click'); + expect(wrapper.emitted('restartConnector')).toEqual([['mcp_2']]); + }); +}); + +describe('SettingsPane hooks page', () => { + it('groups hooks by event and shows what each one runs', () => { + const panel = mountPane('hooks', { + config: { + ...config, + hooks: [ + { event: 'PreToolUse', matcher: 'Bash', type: 'command', command: 'block-no-verify.sh' }, + { event: 'PreToolUse', type: 'command', command: 'observe.sh pre', timeout: 30 }, + { event: 'SessionStart', type: 'command', command: 'agent-state.sh', async: true }, + ], + }, + }).get('#settings-panel-hooks'); + + expect(panel.findAll('.listing-head').map((head) => head.text())).toEqual(['PreToolUse', 'SessionStart']); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Bash', '*', '*']); + expect(panel.text()).toContain('block-no-verify.sh'); + expect(panel.text()).toContain('30s timeout'); + expect(panel.text()).toContain('async'); + }); + + it('says so when no hook is configured', () => { + expect(mountPane('hooks').get('#settings-panel-hooks').text()).toContain('No hooks are configured'); + }); +}); + +describe('SettingsPane usage page', () => { + const sessions = [ + { id: 'ses_1', model: 'Pythinker K2', usage: { inputTokens: 600, outputTokens: 400, turnCount: 3, totalCostUsd: 1.5 } }, + { id: 'ses_2', model: 'GPT-5', usage: { inputTokens: 800, outputTokens: 200, turnCount: 2, totalCostUsd: 0.75 } }, + ] as AppSession[]; + + it('totals tokens, sessions, turns and cost', () => { + const values = mountPane('usage', { sessions }).get('#settings-panel-usage').findAll('.stat-value').map((value) => value.text()); + expect(values).toEqual(['2k', '2', '5', '$2.25']); + }); + + it('splits the token share per model, largest first', () => { + const panel = mountPane('usage', { sessions }).get('#settings-panel-usage'); + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Pythinker K2', 'GPT-5']); + expect(panel.findAll('.listing-meta').map((meta) => meta.text())).toEqual(['50%', '50%']); + }); +}); + +describe('SettingsPane plugins page', () => { + const plugins = [ + { id: 'plg_1', displayName: 'Cloudflare', version: '1.2.0', enabled: true, state: 'loaded', skillCount: 3, mcpServerCount: 2, hasErrors: false, source: 'github' }, + { id: 'plg_2', displayName: 'Designer', enabled: false, state: 'disabled', skillCount: 1, mcpServerCount: 0, hasErrors: true, source: 'local' }, + ]; + + it('shows each plugin with its counts and toggles one', async () => { + const wrapper = mountPane('plugins', { plugins }); + const panel = wrapper.get('#settings-panel-plugins'); + + expect(panel.findAll('.listing-name').map((name) => name.text())).toEqual(['Cloudflare', 'Designer']); + expect(panel.text()).toContain('3 skills · 2 servers'); + expect(panel.text()).toContain('reported errors'); + expect(panel.findAll('.listing-row')[1]!.classes()).toContain('off'); + await panel.findAll('.switch')[1]!.trigger('click'); + expect(wrapper.emitted('setPluginEnabled')).toEqual([[{ pluginId: 'plg_2', enabled: true }]]); + }); +}); + +describe('SettingsPane subagents page', () => { + const subagents = [{ + name: 'Explore', + description: 'Read-only search agent', + source: 'built-in' as const, + tools: ['Read', 'Grep', 'Glob'], + model: 'Pythinker K2', + effort: 'max', + }]; + + it('shows the profile source, tool count, model and effort', () => { + const panel = mountPane('subagents', { subagents }).get('#settings-panel-subagents'); + expect(panel.get('.listing-name').text()).toBe('Explore'); + expect(panel.findAll('.tag').map((tag) => tag.text())).toEqual(['built-in', '3 tools', 'max']); + expect(panel.text()).toContain('Pythinker K2'); + expect(panel.text()).toContain('Read-only search agent'); + }); +}); + +describe('desktop settings route', () => { + it('swaps the sidebar body and main content while settings is open', async () => { + const wrapper = shallowMount(App, { + global: { + plugins: [i18n], + stubs: { Sidebar: false, SettingsNav: false, SettingsPane: false }, + }, + }); + + expect(wrapper.find('.sessions').exists()).toBe(true); + expect(wrapper.findComponent(ConversationPane).exists()).toBe(true); + + await wrapper.get('.side-foot .settings-row').trigger('click'); + await nextTick(); + + expect(wrapper.find('.settings-tabs').exists()).toBe(true); + expect(wrapper.find('.sessions').exists()).toBe(false); + expect(wrapper.findComponent(SettingsPane).exists()).toBe(true); + expect(wrapper.findComponent(ConversationPane).exists()).toBe(false); + + await wrapper.findAll('.sidebar-rail .rail-btn').at(-1)!.trigger('click'); + await nextTick(); + + expect.soft(wrapper.find('.settings-tabs').exists()).toBe(false); + expect.soft(wrapper.find('.sessions').exists()).toBe(true); + expect.soft(wrapper.findComponent(ConversationPane).exists()).toBe(true); + }); + + it('leaves the settings route when a new session starts', async () => { + const wrapper = shallowMount(App, { + global: { + plugins: [i18n], + stubs: { Sidebar: false, SettingsNav: false, SettingsPane: false }, + }, + }); + + await wrapper.get('.side-foot .settings-row').trigger('click'); + await nextTick(); + expect(wrapper.findComponent(SettingsPane).exists()).toBe(true); + + // New Session shares the sidebar with the settings nav, so it has to close + // the route — the content area can only show one of the two. + await wrapper.get('.btn-new-chat').trigger('click'); + await nextTick(); + + expect(wrapper.findComponent(SettingsPane).exists()).toBe(false); + expect(wrapper.find('.sessions').exists()).toBe(true); + }); +}); diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 8edbfc36d..d391228f6 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -304,6 +304,7 @@ export const PythinkerConfigSchema = z.object({ httpHookAllowedEnvVars: z.array(z.string().min(1)).optional(), services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), + disabledSkills: z.array(z.string()).optional(), extraSkillDirs: z.array(z.string()).optional(), additionalDirs: z.array(z.string().trim().min(1)).optional(), loopControl: LoopControlSchema.optional(), @@ -352,6 +353,7 @@ export const PythinkerConfigPatchSchema = z httpHookAllowedEnvVars: z.array(z.string().min(1)).optional(), services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), + disabledSkills: z.array(z.string()).optional(), extraSkillDirs: z.array(z.string()).optional(), additionalDirs: z.array(z.string().trim().min(1)).optional(), loopControl: LoopControlPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index eb361f12a..bd5912181 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -481,6 +481,7 @@ export function configToTomlData(config: PythinkerConfig): Record { extraDirs: config.extraSkillDirs, pluginSkillRoots: this.plugins.pluginSkillRoots(), mergeAllAvailableSkills: config.mergeAllAvailableSkills, + disabledNames: config.disabledSkills, }; } diff --git a/packages/agent-core/src/services/catalog/catalog.ts b/packages/agent-core/src/services/catalog/catalog.ts new file mode 100644 index 000000000..817559fd8 --- /dev/null +++ b/packages/agent-core/src/services/catalog/catalog.ts @@ -0,0 +1,53 @@ +import { createDecorator } from '../../di'; +import type { AgentProfileSummary } from '../../rpc'; +import type { PluginSummary } from '../../plugin'; +import type { AgentProfile, Plugin } from '@pymodel/protocol'; + +// --------------------------------------------------------------------------- +// Adapter helpers +// --------------------------------------------------------------------------- + +export function toProtocolPlugin(summary: PluginSummary): Plugin { + return { + id: summary.id, + display_name: summary.displayName, + version: summary.version, + enabled: summary.enabled, + state: summary.state, + skill_count: summary.skillCount, + mcp_server_count: summary.mcpServerCount, + has_errors: summary.hasErrors, + source: summary.source, + }; +} + +export function toProtocolAgentProfile(summary: AgentProfileSummary): AgentProfile { + return { + name: summary.name, + description: summary.description, + source: summary.source, + tools: [...summary.tools], + model: summary.model, + effort: summary.effort, + when_to_use: summary.whenToUse, + }; +} + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface ICatalogService { + readonly _serviceBrand: undefined; + + /** Installed plugins and whether each one is currently enabled. */ + listPlugins(): Promise; + + /** Enable or disable one plugin. Unknown ids are reported by the core. */ + setPluginEnabled(pluginId: string, enabled: boolean): Promise; + + /** Subagent profiles resolvable from `workDir` (built-in, plugin, user, project). */ + listAgentProfiles(workDir: string): Promise; +} + +export const ICatalogService = createDecorator('catalogService'); diff --git a/packages/agent-core/src/services/catalog/catalogService.ts b/packages/agent-core/src/services/catalog/catalogService.ts new file mode 100644 index 000000000..a631d7aa7 --- /dev/null +++ b/packages/agent-core/src/services/catalog/catalogService.ts @@ -0,0 +1,33 @@ +/** + * `CatalogService` — implementation of `ICatalogService`. + */ + +import { Disposable, InstantiationType, registerSingleton } from '../../di'; +import type { AgentProfile, Plugin } from '@pymodel/protocol'; + +import { ICoreProcessService } from '../coreProcess/coreProcess'; +import { ICatalogService, toProtocolAgentProfile, toProtocolPlugin } from './catalog'; + +export class CatalogService extends Disposable implements ICatalogService { + readonly _serviceBrand: undefined; + + constructor(@ICoreProcessService private readonly core: ICoreProcessService) { + super(); + } + + async listPlugins(): Promise { + const summaries = await this.core.rpc.listPlugins({}); + return summaries.map(toProtocolPlugin); + } + + async setPluginEnabled(pluginId: string, enabled: boolean): Promise { + await this.core.rpc.setPluginEnabled({ id: pluginId, enabled }); + } + + async listAgentProfiles(workDir: string): Promise { + const catalog = await this.core.rpc.listAgentProfiles({ workDir }); + return catalog.profiles.map(toProtocolAgentProfile); + } +} + +registerSingleton(ICatalogService, CatalogService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/config/configService.ts b/packages/agent-core/src/services/config/configService.ts index 6f0c662b4..39df9b8d7 100644 --- a/packages/agent-core/src/services/config/configService.ts +++ b/packages/agent-core/src/services/config/configService.ts @@ -68,6 +68,7 @@ function toConfigResponse(config: PythinkerConfig): ConfigResponse { hooks: config.hooks, services: config.services, merge_all_available_skills: config.mergeAllAvailableSkills, + disabled_skills: config.disabledSkills, extra_skill_dirs: config.extraSkillDirs, loop_control: config.loopControl, background: config.background, diff --git a/packages/agent-core/src/services/index.ts b/packages/agent-core/src/services/index.ts index 78cbbae76..c411c4477 100644 --- a/packages/agent-core/src/services/index.ts +++ b/packages/agent-core/src/services/index.ts @@ -184,6 +184,13 @@ export { } from './skill/skill'; export { SkillService } from './skill/skillService'; +export { + ICatalogService, + toProtocolAgentProfile, + toProtocolPlugin, +} from './catalog/catalog'; +export { CatalogService } from './catalog/catalogService'; + export { ITaskService, TaskAlreadyFinishedError, diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index b1dea3833..df1be11a1 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -181,6 +181,8 @@ export interface SessionSkillConfig { readonly pluginSkillRoots?: readonly SkillRoot[]; readonly mergeAllAvailableSkills?: boolean; readonly builtinDir?: string; + /** Skill names the user turned off in config. */ + readonly disabledNames?: readonly string[]; } export interface AgentMeta { @@ -476,6 +478,7 @@ export class Session { ); this.skills = new SessionSkillRegistry({ sessionId: options.id, + disabledNames: options.skills?.disabledNames, isPathIgnored: (candidate, cwd) => isGitIgnored(this.persistenceKaos, candidate, cwd), }); diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index e75b2668c..e3d1534f8 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -19,6 +19,8 @@ export interface SkillRegistryOptions { readonly isPathIgnored?: (path: string, cwd: string) => Promise; readonly onWarning?: (message: string, cause?: unknown) => void; readonly sessionId?: string; + /** Skill names the user turned off; they are never registered. */ + readonly disabledNames?: readonly string[]; } export class SessionSkillRegistry implements AgentSkillRegistry { @@ -34,6 +36,7 @@ export class SessionSkillRegistry implements AgentSkillRegistry { private readonly discoverImpl: typeof discoverSkills; private readonly isPathIgnored: (path: string, cwd: string) => Promise; private readonly onWarning: (message: string, cause?: unknown) => void; + private readonly disabledNames: ReadonlySet; readonly sessionId?: string; constructor(options: SkillRegistryOptions = {}) { @@ -41,6 +44,9 @@ export class SessionSkillRegistry implements AgentSkillRegistry { this.isPathIgnored = options.isPathIgnored ?? (() => Promise.resolve(false)); this.onWarning = options.onWarning ?? (() => {}); this.sessionId = options.sessionId; + this.disabledNames = new Set( + (options.disabledNames ?? []).map((name) => normalizeSkillName(name)), + ); } async loadRoots(roots: readonly SkillRoot[]): Promise { @@ -69,6 +75,9 @@ export class SessionSkillRegistry implements AgentSkillRegistry { register(skill: SkillDefinition, options: { readonly replace?: boolean } = {}): void { const key = normalizeSkillName(skill.name); + // A disabled skill is dropped here, the one funnel every source goes + // through, so it is invisible to the model, the slash menu and the API. + if (this.disabledNames.has(key)) return; if ( options.replace !== true && (this.byName.has(key) || this.conditionalByName.has(key)) @@ -158,6 +167,9 @@ export class SessionSkillRegistry implements AgentSkillRegistry { options: { readonly replace?: boolean } = {}, ): void { if (skill.plugin === undefined) return; + // Discovery indexes plugin skills before `register` runs, so the disabled + // check has to repeat here or `getPluginSkill` would still hand one back. + if (this.disabledNames.has(normalizeSkillName(skill.name))) return; const key = pluginSkillKey(skill.plugin.id, skill.name); if (options.replace === true || !this.byPluginAndName.has(key)) { this.byPluginAndName.set(key, skill); diff --git a/packages/agent-core/test/skill/registry.test.ts b/packages/agent-core/test/skill/registry.test.ts index 3821a680d..991b6710e 100644 --- a/packages/agent-core/test/skill/registry.test.ts +++ b/packages/agent-core/test/skill/registry.test.ts @@ -171,6 +171,55 @@ describe('skill registry prompt rendering', () => { }); }); +describe('disabled skills', () => { + it('never registers a skill the user turned off', () => { + const registry = new SessionSkillRegistry({ disabledNames: ['user-a'] }); + + registry.register(makeSkill('user-a', 'user')); + registry.register(makeSkill('user-b', 'user')); + + expect(registry.getSkill('user-a')).toBeUndefined(); + expect(registry.getSkill('user-b')).toBeDefined(); + }); + + it('matches the disabled name regardless of case', () => { + const registry = new SessionSkillRegistry({ disabledNames: ['Gen-Changesets'] }); + + registry.register(makeSkill('gen-changesets', 'project')); + + expect(registry.getSkill('gen-changesets')).toBeUndefined(); + }); + + it('disables built-in skills too', () => { + const registry = new SessionSkillRegistry({ disabledNames: ['loop'] }); + + registerBuiltinSkills(registry); + + expect(registry.getSkill('loop')).toBeUndefined(); + }); + + it('never indexes a disabled plugin skill discovered from a root', async () => { + // Discovery indexes plugin skills before `register` runs, so a disabled one + // stays reachable through `getPluginSkill` unless the check repeats there. + const disabled = { ...makeSkill('deploy', 'extra'), plugin: { id: 'acme' } }; + const kept = { ...makeSkill('rollback', 'extra'), plugin: { id: 'acme' } }; + const registry = new SessionSkillRegistry({ + disabledNames: ['Deploy'], + discover: async (options) => { + options.onDiscoveredSkill?.(disabled); + options.onDiscoveredSkill?.(kept); + return [disabled, kept]; + }, + }); + + await registry.loadRoots([{ path: '/tmp/plugins', source: 'extra' }]); + + expect(registry.getPluginSkill('acme', 'deploy')).toBeUndefined(); + expect(registry.getSkill('deploy')).toBeUndefined(); + expect(registry.getPluginSkill('acme', 'rollback')).toBeDefined(); + }); +}); + function makeRegistry(skills: readonly SkillDefinition[]): SessionSkillRegistry { const registry = new SessionSkillRegistry(); for (const skill of skills) registry.register(skill); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index aec2a6140..fd9ceda88 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -30,6 +30,7 @@ export * from './rest/message'; export * from './rest/prompt'; export * from './rest/approval'; export * from './rest/question'; +export * from './rest/catalog'; export * from './rest/tool'; export * from './rest/skill'; export * from './rest/task'; diff --git a/packages/protocol/src/rest/catalog.ts b/packages/protocol/src/rest/catalog.ts new file mode 100644 index 000000000..978fca284 --- /dev/null +++ b/packages/protocol/src/rest/catalog.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +/** One installed plugin and whether it is currently enabled. */ +export const pluginSchema = z.object({ + id: z.string().min(1), + display_name: z.string().min(1), + version: z.string().optional(), + enabled: z.boolean(), + state: z.string(), + skill_count: z.number().int().nonnegative(), + mcp_server_count: z.number().int().nonnegative(), + has_errors: z.boolean(), + source: z.string(), +}); +export type Plugin = z.infer; + +export const listPluginsResponseSchema = z.object({ + plugins: z.array(pluginSchema), +}); +export type ListPluginsResponse = z.infer; + +export const setPluginEnabledRequestSchema = z.object({ + enabled: z.boolean(), +}); +export type SetPluginEnabledRequest = z.infer; + +export const setPluginEnabledResultSchema = z.object({ + id: z.string().min(1), + enabled: z.boolean(), +}); +export type SetPluginEnabledResult = z.infer; + +/** One subagent profile the agent can dispatch work to. */ +export const agentProfileSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + source: z.enum(['built-in', 'plugin', 'user', 'project']), + tools: z.array(z.string()), + model: z.string().optional(), + effort: z.string().optional(), + when_to_use: z.string().optional(), +}); +export type AgentProfile = z.infer; + +export const listAgentProfilesResponseSchema = z.object({ + profiles: z.array(agentProfileSchema), +}); +export type ListAgentProfilesResponse = z.infer; diff --git a/packages/protocol/src/rest/config.ts b/packages/protocol/src/rest/config.ts index c7a60d8e3..a25070f82 100644 --- a/packages/protocol/src/rest/config.ts +++ b/packages/protocol/src/rest/config.ts @@ -23,6 +23,7 @@ export const configResponseSchema = z.object({ hooks: z.array(z.unknown()).optional(), services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), + disabled_skills: z.array(z.string()).optional(), extra_skill_dirs: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), @@ -47,6 +48,7 @@ export const patchConfigRequestSchema = z.object({ hooks: z.array(z.unknown()).optional(), services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), + disabled_skills: z.array(z.string()).optional(), extra_skill_dirs: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), diff --git a/packages/server/src/routes/catalog.ts b/packages/server/src/routes/catalog.ts new file mode 100644 index 000000000..9d24ed620 --- /dev/null +++ b/packages/server/src/routes/catalog.ts @@ -0,0 +1,144 @@ +/** + * `/plugins*` + `/agent-profiles` REST routes. + * + * 3 endpoints: + * + * GET /plugins - data: {plugins: Plugin[]} + * POST /plugins/{plugin_id}:set-enabled body: {enabled} data: {id, enabled} + * GET /agent-profiles query: {work_dir} data: {profiles: AgentProfile[]} + * + * Both collections are global rather than session-scoped — plugins are loaded + * once per daemon, and subagent profiles are resolved from a working directory + * passed as a query parameter. + * + * **Action suffix**: the POST endpoint uses the shared `parseActionSuffix` + * helper; `:set-enabled` is the only action and there is no bare form. + * + * **Anti-corruption**: route resolves `ICatalogService` via the accessor; no + * SDK imports. + */ + +import { + ErrorCode, + listAgentProfilesResponseSchema, + listPluginsResponseSchema, + setPluginEnabledRequestSchema, + setPluginEnabledResultSchema, +} from '@pymodel/protocol'; +import { ICatalogService, type IInstantiationService } from '@pymodel/agent-core'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { defineRoute } from '../middleware/defineRoute'; +import { parseActionSuffix } from './action-suffix'; + +interface CatalogRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const listAgentProfilesQuerySchema = z.object({ + work_dir: z.string().min(1), +}); + +export function registerCatalogRoutes( + app: CatalogRouteHost, + ix: IInstantiationService, +): void { + // GET /plugins -------------------------------------------------------- + const listPluginsRoute = defineRoute( + { + method: 'GET', + path: '/plugins', + success: { data: listPluginsResponseSchema }, + description: 'List installed plugins', + tags: ['catalog'], + }, + async (req, reply) => { + const plugins = await ix.invokeFunction((a) => a.get(ICatalogService).listPlugins()); + reply.send(okEnvelope({ plugins }, req.id)); + }, + ); + app.get( + listPluginsRoute.path, + listPluginsRoute.options, + listPluginsRoute.handler as Parameters[2], + ); + + // POST /plugins/{plugin_id}:set-enabled ------------------------------- + const setPluginEnabledRoute = defineRoute( + { + method: 'POST', + path: '/plugins/{tail}', + body: setPluginEnabledRequestSchema, + success: { data: setPluginEnabledResultSchema }, + description: 'Enable or disable a plugin by ID', + tags: ['catalog'], + operationId: 'setPluginEnabled', + }, + async (req, reply) => { + const { tail } = req.params as { tail: string }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['set-enabled'] as const, + resourceLabel: 'plugin', + }); + if (parsed.kind === 'invalid' || parsed.kind === 'bare') { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + const { enabled } = req.body as { enabled: boolean }; + await ix.invokeFunction((a) => a.get(ICatalogService).setPluginEnabled(parsed.id, enabled)); + reply.send(okEnvelope({ id: parsed.id, enabled }, req.id)); + }, + ); + app.post( + setPluginEnabledRoute.path, + setPluginEnabledRoute.options, + setPluginEnabledRoute.handler as Parameters[2], + ); + + // GET /agent-profiles ------------------------------------------------- + const listAgentProfilesRoute = defineRoute( + { + method: 'GET', + path: '/agent-profiles', + querystring: listAgentProfilesQuerySchema, + success: { data: listAgentProfilesResponseSchema }, + description: 'List subagent profiles resolvable from a working directory', + tags: ['catalog'], + }, + async (req, reply) => { + const { work_dir: workDir } = req.query as { work_dir: string }; + const profiles = await ix.invokeFunction((a) => + a.get(ICatalogService).listAgentProfiles(workDir), + ); + reply.send(okEnvelope({ profiles }, req.id)); + }, + ); + app.get( + listAgentProfilesRoute.path, + listAgentProfilesRoute.options, + listAgentProfilesRoute.handler as Parameters[2], + ); +} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index 426a19392..2304c193e 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -16,6 +16,7 @@ import { registerPromptsRoutes } from './prompts'; import { registerQuestionsRoutes } from './questions'; import { registerSessionsRoutes } from './sessions'; import { registerShutdownRoutes } from './shutdown'; +import { registerCatalogRoutes } from './catalog'; import { registerSkillsRoutes } from './skills'; import { registerSnapshotRoutes } from './snapshot'; import { registerTasksRoutes } from './tasks'; @@ -91,6 +92,7 @@ export async function registerApiV1Routes( ); registerToolsRoutes(apiV1 as unknown as Parameters[0], ix); registerSkillsRoutes(apiV1 as unknown as Parameters[0], ix); + registerCatalogRoutes(apiV1 as unknown as Parameters[0], ix); registerTasksRoutes(apiV1 as unknown as Parameters[0], ix); registerTerminalsRoutes( apiV1 as unknown as Parameters[0], diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index b8fd11e52..a1a46854a 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -1,4 +1,4 @@ -import { InstantiationService, resolveConfigPath, resolvePythinkerHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@pymodel/agent-core'; +import { InstantiationService, resolveConfigPath, resolvePythinkerHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ICatalogService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@pymodel/agent-core'; import { ErrorCode, createAsyncApiDocument } from '@pymodel/protocol'; import Fastify from 'fastify'; import { promises as fspPromises } from 'node:fs'; @@ -235,6 +235,7 @@ export async function startServer(opts: ServerStartOptions): Promise> = {}) { + return { + _serviceBrand: undefined, + listPlugins: vi.fn(async () => [plugin] as readonly Plugin[]), + setPluginEnabled: vi.fn(async () => {}), + listAgentProfiles: vi.fn(async () => [profile] as readonly AgentProfile[]), + ...overrides, + }; +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pythinker-server-catalog-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'pythinker-server-catalog-home-')); +}); + +afterEach(async () => { + try { + await server?.close(); + } catch { + // ignore + } + server = undefined; + rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +async function bootDaemon(catalog: unknown): Promise { + server = await startServer({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + coreProcessOptions: { homeDir: bridgeHome }, + serviceOverrides: [[ICatalogService, catalog]], + }); + return server; +} + +function appOf(r: RunningServer): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +const envelopeSchema = z.object({ + code: z.number(), + msg: z.string(), + data: z.unknown(), + request_id: z.string().min(1), +}); + +/** Parses the uniform `{code, msg, data, request_id}` envelope every route returns. */ +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; +} { + const envelope = envelopeSchema.parse(body); + return { ...envelope, data: envelope.data as T | null }; +} + +describe('catalog routes', () => { + it('lists installed plugins', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/plugins' }); + + expect(res.statusCode).toBe(200); + const env = envelopeOf<{ plugins: Plugin[] }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.plugins).toEqual([plugin]); + }); + + it('disables a plugin through the set-enabled action', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/plugins/acme:set-enabled', + payload: { enabled: false }, + }); + + expect(res.statusCode).toBe(200); + expect(envelopeOf<{ id: string; enabled: boolean }>(res.json()).data) + .toEqual({ id: 'acme', enabled: false }); + expect(catalog.setPluginEnabled).toHaveBeenCalledWith('acme', false); + }); + + it('rejects the bare plugin path, which carries no action', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/plugins/acme', + payload: { enabled: false }, + }); + + expect(envelopeOf(res.json()).code).toBe(ErrorCode.VALIDATION_FAILED); + expect(catalog.setPluginEnabled).not.toHaveBeenCalled(); + }); + + it('rejects an unknown action suffix', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/plugins/acme:remove', + payload: { enabled: false }, + }); + + expect(envelopeOf(res.json()).code).toBe(ErrorCode.VALIDATION_FAILED); + expect(catalog.setPluginEnabled).not.toHaveBeenCalled(); + }); + + it('lists subagent profiles for a working directory', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/agent-profiles?work_dir=%2Fworkspace%2Fdemo', + }); + + expect(res.statusCode).toBe(200); + expect(envelopeOf<{ profiles: AgentProfile[] }>(res.json()).data?.profiles).toEqual([profile]); + expect(catalog.listAgentProfiles).toHaveBeenCalledWith('/workspace/demo'); + }); + + it('rejects a subagent listing with no working directory', async () => { + const catalog = makeCatalog(); + const r = await bootDaemon(catalog); + + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/agent-profiles' }); + + expect(envelopeOf(res.json()).code).toBe(ErrorCode.VALIDATION_FAILED); + expect(catalog.listAgentProfiles).not.toHaveBeenCalled(); + }); +});