open-design/apps/web/tests/components/SettingsDialog.test.ts
Tom Huang b5eb8c1647
feat: generic skills + split skills/design-templates + finalize-design API (#955)
* feat: general-purpose skills with @-mention composition and user import

Lift skills from "one mode-bound skill per project" to a generic capability
the user can compose per turn:

- Daemon: scan multiple skill roots (user-skills under runtime data, then
  the bundled `skills/`); user-imported skills can shadow built-ins by id.
- New `POST /api/skills/import` and `DELETE /api/skills/:id` endpoints,
  with CONFLICT/BAD_REQUEST/NOT_FOUND error codes and built-in delete
  protection.
- ChatRequest gains `skillIds: string[]`; the chat run concatenates each
  picked skill's body (and merges craftRequires) into the system prompt
  for that turn only — the project's persistent `skillId` is untouched.
- Web composer: `@` popover now lists skills alongside project files;
  picks render as removable chips above the textarea and ride along with
  the request as `skillIds`.
- Settings → Library: import form (name/description/triggers/body),
  per-card delete for user skills, "user" origin badge.

* chore(web): drop welcome pet teaser + add ds→prompt-template mapping util

- SettingsDialog: remove the inline pet adoption teaser from the welcome
  panel so the first-run modal stays focused on configuration.
- New `inferPromptTemplateCategoriesForDs(ds)` helper that maps a design
  system's authored metadata to prompt-template gallery categories.
  Imported by the design-system gallery wiring on a sibling branch; no
  callers in this branch yet.

* feat: split skills/design-templates and add finalize-design API

Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):

- Move ~104 rendering catalogue entries from skills/ to design-templates/
  and keep skills/ for the small set of functional skills that *do work*
  on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
  contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
  an /api/design-templates surface mirroring /api/skills. Asset/example
  routes still span both registries so existing srcdoc URLs keep
  resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
  rename the EntryView "Examples" tab to "Templates", and update locales
  + the New-project picker accordingly.

Adds the finalize-design endpoint:

- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
  finalize.ts — one-shot synthesis of a project's transcript + active
  design system + current artifact into <projectDir>/DESIGN.md via the
  Anthropic Messages API. Per-project .finalize.lock mirrors the
  transcript-export hygiene from PR #493; provider credentials are not
  persisted by the daemon.

Other supporting changes:

- README + AGENTS.md updates to document the new directory split and
  craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
  server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
  vendor binaries are no longer hidden.

* fix(merge): move clinical-case-report to design-templates/

Origin/main added the clinical-case-report skill under skills/ before
the skills/design-templates split landed. Its od.mode is prototype, so
per specs/current/skills-and-design-templates.md it is a design template
and belongs alongside the other rendering catalogue entries — not under
the slimmed-down functional skills/ root. Moving it keeps the EntryView
Templates tab consistent with origin/main's intent.

* feat(skills): curated design/creative catalogue + collapsible Settings rows

Seed ~100 curated design/creative skill stubs under skills/ sourced from
awesome-claude-skills (ComposioHQ) and awesome-agent-skills (VoltAgent).
Each stub carries an od.category tag so the new filter pill row in
Settings -> Skills can group them. The seed script
(scripts/seed-curated-design-skills.ts, pnpm seed:curated-design-skills)
is idempotent: it only creates folders that don't already exist, so
hand-edited stubs are never overwritten.

- Daemon: parse and surface od.category on SkillInfo with a strict slug
  normaliser; mirror the field on SkillSummary in @open-design/contracts.
  Category is purely a UI hint — system-prompt composition is unchanged.
- Web: rewrite SkillsSection from a left-list / right-detail grid into a
  vertical stack of collapsible rows mirroring the External MCP panel
  (header always visible with name + mode/source/category pills + per-row
  enable toggle; SKILL.md preview, file tree and inline edit form expand
  on demand). Add a Category filter row above the list. Reorder Settings
  nav so Skills + External MCP sit above the Composio/MCP cluster. Update
  composer placeholder/hint across 17 locales to advertise '@ files or
  skills · / for commands'.
- Docs: extend skills/AGENTS.md with the curated catalogue rules
  (idempotency, category vocabulary, no upstream vendoring).

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(skills): teach localized-content + system-prompt tests about the skills/design-templates split

mrcfps blocking review on PR #955: the skills/design-templates split
(b5993385) moved ~110 SKILL.md entries out of `skills/` and into
`design-templates/`, but two repo-level tests still hard-coded the
single-root layout, so CI gates went red on the merged branch:

- `e2e/tests/localized-content.test.ts` only scanned `<repo>/skills`
  while the locale `skillCopy` map keeps id-keyed entries spanning
  both roots (ExamplesTab/Templates uses one lookup regardless of
  origin). Teach the helper to read both `skills/` and
  `design-templates/`, deduplicating ids so the union matches the
  localized claim.
- `apps/daemon/tests/prompts/system.test.ts` read
  `skills/live-artifact/SKILL.md`, which now lives under
  `design-templates/live-artifact/`. Update the absolute path so
  composeSystemPrompt's coverage of the live-artifact preamble is
  exercised again.

Also enroll the curated design/creative catalogue (PR #955, ~91
stubs sourced from awesome-claude-skills / awesome-agent-skills) in
the DE / FR / RU `_SKILL_IDS_WITH_EN_FALLBACK` lists. The stubs are
English-only by design (frontmatter advertises an upstream URL); the
fallback list is exactly the place to acknowledge "we know this id
exists, English copy is fine here" so the localized-content coverage
gate passes without forcing a translation task per locale.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): always quote frontmatter name so importUserSkill round-trips numeric / boolean ids

mrcfps PR #955 review: `buildSkillMarkdown` emitted `name:
${escapeYamlString(name)}` without quotes, so YAML coerced names
like `123`, `true`, `false`, or `null` into non-string scalars on
re-parse. listSkills() then read `data.name` as a number/boolean
and the import flow's follow-up `findSkillById(skills, result.id)`
missed it, falling into `/api/skills/import`'s "imported skill
could not be re-read" 500 path for those ids.

Switch the emitter to a quoted scalar (`name: "..."`) — the
double-escape already in `escapeYamlString` makes the quoted form
safe — and add a round-trip test covering `123`, `true`, `false`,
`null`, and `0` to lock in the contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop staged-skill chips when the matching @<id> token leaves the draft

mrcfps PR #955 review: `submit()` always forwarded every id in
`stagedSkills`, but that state was only mutated on picker click and
chip removal. Hand-deleting an `@<id>` token from the textarea left
the chip staged, so the request still carried `skillIds: [<id>]` and
the daemon composed a skill the prompt no longer referenced.

Sync the chips with the draft inside `handleChange()` by pruning
`stagedSkills` whenever the new value no longer contains the
`@<id>` token (using the same whitespace boundary as
`removeStagedSkill`'s strip regex). Comment explains why this
prune does not run for `staged` file attachments — users frequently
add files via the upload button without leaving an `@<path>` token,
so a symmetric prune there would erase legitimate uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): stage @-composed skills' side files alongside the active skill

codex PR #955 review: composing a per-turn `@`-picked skill into the
system prompt appended its body (with the `withSkillRootPreamble`
guidance pointing at relative paths under `<cwd>/.od-skills/<folder>/`)
but never staged the actual folder. `startChatRun` only copied
`activeSkillDir`, so when the project's primary skill was different
(or absent) the composed skill's references/, examples/, and scripts/
files lived only at their absolute repo path — agents that honour
the cwd-relative form (or that don't get `--add-dir`, e.g. Codex with
allowlisted gpt-image projects) couldn't reach them.

Thread the composed skills' dirs out of `composeDaemonSystemPrompt`
as `extraSkillDirs` and stage each one through the same
`stageActiveSkill` API used for the primary skill. Dedupe by folder
basename so a project whose primary skill is also `@`-composed isn't
copied twice. Each preamble already advertises its own folder, so the
prompt and the staged tree stay aligned without further changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): respect the Library disable toggle in the project @-mention picker

codex PR #955 review: only `EntryView` received `enabledSkills`
(filtered against `config.disabledSkills`); active projects still
got `skills={skills}` raw, so a skill the user disabled in Settings
kept appearing in the project's `@`-mention popover and could ride
along to the daemon via `skillIds`. That broke the Library toggle
for any project opened on the post-split branch.

Compute a functional-skills-only enabled subset
(`enabledFunctionalSkills`) and pass it into `<ProjectView>` instead.
Templates stay separate — design-templates are filtered through their
own `enabledDesignTemplates` memo for the Templates gallery — so
ProjectView's chat composer still only sees skills, never templates,
matching the pre-split prop surface.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): mock /api/design-templates for example-use-prompt flow

The Templates tab in EntryView fetches from /api/design-templates after
the skills/design-templates split (specs/current/skills-and-design-templates.md).
The example-use-prompt Playwright scenario only mocked /api/skills, so the
gallery card never appeared and the test timed out waiting on
example-card-warm-utility-example. Serve the same fixture summary on both
endpoints so the templates gallery renders the card the test clicks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(tools-pack): create design-templates fixture for resources test

The packaging resources copy now bundles the new design-templates tree
alongside skills (see resources.ts BUNDLED_RESOURCE_TREES). The
copyBundledResourceTrees fixture only created skills, design-systems,
craft, etc., so the recursive copy crashed with ENOENT on
design-templates before it could check the prompt-templates assertion.
Add the missing fixture directory so the test exercises the same set
of resource trees the packaged build does.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(skills): clone built-in side files into the shadow on first edit

mrcfps PR #955 review: editing a built-in skill wrote a USER_SKILLS_DIR
shadow folder that contained only a new SKILL.md. The next listSkills()
pass surfaced the shadow as the active dir, but every side-file resolver
(/api/skills/:id/files, /example, /assets/*, the system-prompt preamble,
and the per-turn cwd staging) reads through skill.dir. With nothing but
SKILL.md in the shadow, the bundled assets/, references/, scripts/, and
examples/ disappeared the moment the user hit save — a built-in like
last30days or live-artifact would break immediately after edit instead
of just having its body overridden.

Teach updateUserSkill() to take a `sourceDir` and clone every entry
except SKILL.md / dotfiles into the shadow on the very first edit. The
shadow stays self-contained, so all the resolvers keep working without
fallback bookkeeping. Subsequent edits detect the existing shadow and
skip the clone, so user tweaks under the side tree survive a re-save.

Wire `sourceDir: skill.dir` from server.ts's PUT /api/skills/:id handler
and add two regression tests:
- 'clones built-in side files into the shadow on the first edit' walks
  the file tree after save and asserts assets/template.html, references/
  notes.md, and scripts/helper.sh all round-trip from the built-in.
- 'preserves user-edited side files on subsequent edits' edits the
  staged assets/template.html, re-saves, and confirms the user content
  is still there.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): rename home tab from Examples to Templates

The Examples tab was renamed to Templates in EntryView (b5993385's
skills/design-templates split — entry.tabExamples became entry.tabTemplates
and the tab value moved from 'examples' to 'templates'), but
entry-chrome-flows still asserted the old label and testId. Update both.

* fix(skills+web): preserve template body in API mode and dir-based skill delete

Two follow-ups from PR #955 review:

1. ProjectView only received `enabledFunctionalSkills`, but
   `composedSystemPrompt()` still resolved `project.skillId` through that
   prop and `fetchSkill()`. Projects created from the new
   `/api/design-templates` surface keep a template id in `project.skillId`,
   so opening one in API mode dropped the template body from the system
   prompt and the upstream request ran without the project's primary
   template instructions. Now ProjectView takes a separate
   `designTemplates` prop (the unfiltered template list, so a
   later-disabled template still loads for projects already created from
   it) and `composedSystemPrompt()` plus the metadata / `isDeck` lookups
   fall back to that list, with `fetchDesignTemplate()` as the body-fetch
   fallback to `fetchSkill()`. The chat composer's `@`-picker keeps
   receiving only the enabled functional skills.

2. `DELETE /api/skills/:id` used `deleteUserSkill(USER_SKILLS_DIR, skill.id)`
   which re-slugified the frontmatter id and removed
   `<userSkillsDir>/<slug>/`. That matched the import shape but missed the
   install shape — `installFromTarget` writes the folder at
   `sanitizeRepoName(url)` (GitHub) or `path.basename(realpath)` (local
   symlink), neither of which is guaranteed to equal the slugified
   frontmatter `name`. A duplicate `app.delete('/api/skills/:id', ...)`
   handler at the install routes never fired because Express resolved the
   earlier registration first, leaving the install/uninstall path without
   working teardown. The handler now removes `skill.dir` (the absolute
   path listSkills already discovered) under a USER_SKILLS_DIR safety
   check, using `lstat` + `unlinkSync` so symlinked local installs unlink
   cleanly without recursing into the user's source tree. The dead
   duplicate handler is removed; `deleteUserSkill` is dropped from the
   server.ts import set (still exported and unit-tested in skills.ts).
   Regression coverage in `apps/daemon/tests/skills-delete-route.test.ts`
   pins both shapes plus the symlink-preserves-source case.

* test(daemon): point hyperframes system-prompt test at design-templates

The merge with main brought in a hyperframes system-prompt test that
reads `skills/hyperframes/SKILL.md`, but this branch's split moved
`hyperframes` into `design-templates/` (same migration as `live-artifact`
already handled above in this file). CI was failing with ENOENT on the
old path.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:48:34 +08:00

963 lines
33 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
agentRefreshOptionsForConfig,
canFetchProviderModels,
canRunProviderConnectionTest,
deriveComposioCredentialState,
configForManualOrbitRun,
isOrbitRunDisabled,
isValidApiBaseUrl,
mergeProviderModelOptions,
sanitizeSettingsSavePayload,
shouldEnableSettingsSave,
shouldShowCustomModelInput,
persistConfigAndRunOrbit,
switchApiProtocolConfig,
testStatusVariant,
updateAgentCliEnvValue,
updateCurrentApiProtocolConfig,
} from '../../src/components/SettingsDialog';
import type { AppConfig, ConnectionTestResponse } from '../../src/types';
const originalFetch = globalThis.fetch;
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
};
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('SettingsDialog API protocol switching', () => {
it('stores the current custom protocol config while preserving custom endpoint details', () => {
const config: AppConfig = {
...baseConfig,
apiKey: 'anthropic-key',
apiProviderBaseUrl: null,
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
expect(next.apiProtocolConfigs?.anthropic).toMatchObject({
apiKey: 'anthropic-key',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
});
it('restores each protocol draft instead of leaking shared field values', () => {
const openai = switchApiProtocolConfig(baseConfig, 'openai');
const openaiEdited = updateCurrentApiProtocolConfig(openai, {
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
const google = switchApiProtocolConfig(openaiEdited, 'google');
const googleEdited = updateCurrentApiProtocolConfig(google, {
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
const restoredOpenai = switchApiProtocolConfig(googleEdited, 'openai');
expect(restoredOpenai).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
expect(restoredOpenai.apiProtocolConfigs?.google).toMatchObject({
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
});
it('loads the new protocol default on first visit', () => {
expect(switchApiProtocolConfig(baseConfig, 'openai')).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
});
});
it('auto-fills Google defaults when switching from a selected known provider', () => {
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
mode: 'api',
apiProtocol: 'google',
apiKey: '',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
apiProviderBaseUrl: 'https://generativelanguage.googleapis.com',
});
});
it('keeps Azure API version in the Azure draft only', () => {
const config: AppConfig = {
...baseConfig,
apiProtocol: 'azure',
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
apiProtocol: 'openai',
apiKey: '',
apiVersion: '',
});
expect(next.apiProtocolConfigs?.azure).toMatchObject({
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
});
});
});
describe('SettingsDialog test status variant', () => {
const baseResult: ConnectionTestResponse = { ok: false, kind: 'unknown', latencyMs: 0 };
it('returns success for an ok result', () => {
expect(testStatusVariant({ ok: true, kind: 'success', latencyMs: 12 })).toBe(
'success',
);
});
it('returns warn for rate-limit (config still looks valid)', () => {
expect(testStatusVariant({ ...baseResult, kind: 'rate_limited' })).toBe(
'warn',
);
});
it('returns error for the failure kinds', () => {
for (const kind of [
'auth_failed',
'forbidden',
'not_found_model',
'invalid_model_id',
'invalid_base_url',
'upstream_unavailable',
'timeout',
'agent_not_installed',
'agent_spawn_failed',
'unknown',
] as const) {
expect(testStatusVariant({ ...baseResult, kind })).toBe('error');
}
});
});
describe('SettingsDialog provider connection test requirements', () => {
it('allows Azure tests to use the daemon default API version', () => {
expect(
canRunProviderConnectionTest({
apiKey: 'azure-key',
baseUrl: 'https://my-azure.openai.azure.com',
model: 'deployment-one',
}),
).toBe(true);
});
it('still requires the shared provider fields', () => {
expect(
canRunProviderConnectionTest({ ...baseConfig, apiKey: '' }),
).toBe(false);
expect(
canRunProviderConnectionTest({ ...baseConfig, baseUrl: '' }),
).toBe(false);
expect(
canRunProviderConnectionTest({ ...baseConfig, model: '' }),
).toBe(false);
});
});
describe('SettingsDialog provider model fetch helpers', () => {
it('requires key, valid base URL, and a supported protocol', () => {
expect(
canFetchProviderModels(
{ apiKey: 'sk-openai', baseUrl: 'https://api.openai.com/v1' },
'openai',
),
).toBe(true);
expect(
canFetchProviderModels(
{ apiKey: '', baseUrl: 'https://api.openai.com/v1' },
'openai',
),
).toBe(false);
expect(
canFetchProviderModels(
{ apiKey: 'sk-openai', baseUrl: 'http://10.0.0.5:11434/v1' },
'openai',
),
).toBe(false);
expect(
canFetchProviderModels(
{ apiKey: 'azure-key', baseUrl: 'https://example.openai.azure.com' },
'azure',
),
).toBe(false);
expect(
canFetchProviderModels(
{ apiKey: 'ollama-key', baseUrl: 'https://ollama.com' },
'ollama',
),
).toBe(false);
});
it('merges fetched provider models before static suggestions without duplicates', () => {
expect(
mergeProviderModelOptions(
[
{ id: 'remote-a', label: 'Remote A' },
{ id: 'gpt-4o', label: 'Remote GPT' },
],
['gpt-4o', 'o4-mini'],
),
).toEqual([
{ id: 'remote-a', label: 'Remote A' },
{ id: 'gpt-4o', label: 'Remote GPT' },
{ id: 'o4-mini', label: 'o4-mini' },
]);
});
});
describe('SettingsDialog custom model picker state', () => {
it('keeps custom input visible while an intermediate value matches a known model', () => {
expect(
shouldShowCustomModelInput('gpt-5', ['gpt-5', 'o3'], true),
).toBe(true);
});
it('uses the dropdown when a known model is selected outside custom mode', () => {
expect(
shouldShowCustomModelInput('gpt-5', ['gpt-5', 'o3'], false),
).toBe(false);
});
it('shows custom input for unknown or empty model values', () => {
expect(
shouldShowCustomModelInput('gpt-5.5', ['gpt-5', 'o3'], false),
).toBe(true);
expect(shouldShowCustomModelInput('', ['gpt-5', 'o3'], false)).toBe(true);
});
});
describe('SettingsDialog API Base URL validation', () => {
it('accepts public http/https URLs and loopback local providers', () => {
expect(isValidApiBaseUrl('https://api.openai.com/v1')).toBe(true);
expect(isValidApiBaseUrl('http://localhost:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://127.0.0.1:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::ffff:127.0.0.1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl(' https://resource.openai.azure.com ')).toBe(true);
expect(isValidApiBaseUrl('ddddd')).toBe(false);
expect(isValidApiBaseUrl('api.openai.com/v1')).toBe(false);
expect(isValidApiBaseUrl('ftp://api.example.com')).toBe(false);
expect(isValidApiBaseUrl('http:api.example.com')).toBe(false);
expect(isValidApiBaseUrl('https://')).toBe(false);
expect(isValidApiBaseUrl('http://0.0.0.0:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://10.0.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://100.64.0.1:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://169.254.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://172.16.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://192.168.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://224.0.0.1:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[::]:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[fd00::1]:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[fe80::1]:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[::ffff:192.168.1.5]:11434/v1')).toBe(false);
});
});
describe('SettingsDialog agent CLI env settings', () => {
it('updates supported per-agent CLI env values without dropping sibling agents', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
' ~/.claude-2 ',
);
expect(next.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('updates additional Codex CLI env values without dropping sibling Codex fields', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'codex',
'CODEX_BIN',
' ~/bin/codex-next ',
);
expect(next.agentCliEnv).toEqual({
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next' },
});
});
it('removes empty per-agent CLI env entries', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
'',
);
expect(next.agentCliEnv).toEqual({
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('passes pending CLI env prefs through agent rescan options', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
});
});
it('passes an empty CLI env object through agent rescan after fields are cleared', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {},
});
});
});
describe('deriveComposioCredentialState', () => {
// Issue #741: when a Composio API key is already saved and the user
// starts typing a draft replacement, the saved-key indicator must
// stay visible. The previous code conflated `saved + draft` with
// `draft only` and made the badge vanish on the first keystroke.
it('returns "empty" when nothing is configured and the field is empty', () => {
expect(deriveComposioCredentialState({})).toBe('empty');
expect(deriveComposioCredentialState(null)).toBe('empty');
expect(deriveComposioCredentialState(undefined)).toBe('empty');
expect(deriveComposioCredentialState({ apiKey: '' })).toBe('empty');
expect(deriveComposioCredentialState({ apiKey: ' ' })).toBe('empty');
});
it('returns "saved" when a key is configured and no draft is being typed', () => {
expect(
deriveComposioCredentialState({ apiKeyConfigured: true }),
).toBe('saved');
expect(
deriveComposioCredentialState({ apiKey: '', apiKeyConfigured: true }),
).toBe('saved');
expect(
deriveComposioCredentialState({ apiKey: ' ', apiKeyConfigured: true }),
).toBe('saved');
});
it('returns "pending-new" when only a draft is being typed (no saved key)', () => {
expect(deriveComposioCredentialState({ apiKey: 'sk-draft' })).toBe('pending-new');
expect(
deriveComposioCredentialState({ apiKey: 'sk-draft', apiKeyConfigured: false }),
).toBe('pending-new');
});
it('returns "saved-pending" when a key is saved AND a draft is being typed', () => {
// Regression: this is the state that previously masqueraded as
// "pending-new" and made the saved-key badge disappear.
expect(
deriveComposioCredentialState({ apiKey: 'sk-replacement', apiKeyConfigured: true }),
).toBe('saved-pending');
});
it('treats whitespace-only drafts as no draft so the badge is anchored on "saved"', () => {
expect(
deriveComposioCredentialState({ apiKey: ' \t\n', apiKeyConfigured: true }),
).toBe('saved');
});
});
describe('SettingsDialog Orbit run behavior', () => {
it('keeps manual Orbit runs disabled while connector availability is still loading', () => {
expect(isOrbitRunDisabled(false, null)).toBe(true);
});
it('allows manual Orbit runs once loading finishes and a connector is available', () => {
expect(isOrbitRunDisabled(false, 1)).toBe(false);
});
it('persists the current orbit template config before starting the run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-1' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-1' });
expect(calls).toHaveLength(2);
expect(calls[0]).toMatchObject({
url: '/api/app-config',
method: 'PUT',
});
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
it('does not sync an unsaved Composio draft before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-3' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-3' });
expect(calls.map((call) => call.url)).toEqual([
'/api/media/config',
'/api/app-config',
'/api/orbit/run',
]);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({ force: false });
});
it('does not force an explicit empty media provider map before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-4' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-4' });
expect(calls.map((call) => call.url)).toEqual(['/api/media/config', '/api/app-config', '/api/orbit/run']);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
providers: {},
force: false,
});
});
it('preserves masked daemon media keys before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-preserve' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit(
{
...baseConfig,
mediaProviders: {
openai: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
baseUrl: 'https://custom.example/v1',
},
},
},
{
daemonProviders: {
openai: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: '1234',
baseUrl: '',
},
},
},
),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-preserve' });
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
providers: {
openai: {
preserveApiKey: true,
baseUrl: 'https://custom.example/v1',
},
},
force: false,
});
});
it('does not start a manual Orbit run when saving app config fails', async () => {
const calls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push(url);
if (url === '/api/app-config') {
return new Response(null, { status: 500 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
}),
).rejects.toThrow('Failed to sync app config (500)');
expect(calls).toEqual(['/api/app-config']);
});
it('still starts a manual Orbit run when saving media credentials fails', async () => {
const calls: Array<{ url: string; method: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push({ url, method: init?.method ?? 'GET' });
if (url === '/api/media/config') {
return new Response(null, { status: 500 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-media-failed' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-media-failed' });
expect(calls).toEqual([
{ url: '/api/media/config', method: 'PUT' },
{ url: '/api/app-config', method: 'PUT' },
{ url: '/api/orbit/run', method: 'POST' },
]);
});
it('persists the displayed default template before starting a legacy null-template run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-2' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit(configForManualOrbitRun({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: null,
},
})),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-2' });
expect(calls).toHaveLength(2);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-general',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
});
describe('shouldEnableSettingsSave', () => {
// Issue #739: when the user toggles BYOK on the execution section without
// filling required fields and then navigates to a different sidebar section
// (language, appearance, ...), the footer Save button must reflect the
// destination section's state, not the execution section's incomplete mode.
const validApiCfg: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: 'sk-x',
model: 'claude-sonnet-4-5',
};
const incompleteApiCfg: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: '', // user toggled BYOK but did not fill in fields
model: '',
};
const validDaemonCfg: AppConfig = {
...baseConfig,
mode: 'daemon',
agentId: 'claude-code',
};
const availableAgent = { id: 'claude-code', available: true };
const unavailableAgent = { id: 'claude-code', available: false };
it('returns true on any non-execution section regardless of mode completeness (the fix for #739)', () => {
// The exact scenario from the issue: incomplete BYOK on execution must
// not block save on language, appearance, composio, etc.
expect(shouldEnableSettingsSave(incompleteApiCfg, 'language', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'appearance', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'composio', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'media', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'integrations', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'notifications', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'pet', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'skills', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'designSystems', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'about', [availableAgent], true)).toBe(true);
});
it('on execution + daemon: returns true only when an available agent is selected', () => {
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [availableAgent], false)).toBe(true);
expect(
shouldEnableSettingsSave(
{ ...validDaemonCfg, agentId: null },
'execution',
[availableAgent],
false,
),
).toBe(false);
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [unavailableAgent], false)).toBe(false);
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [], false)).toBe(false);
});
it('on execution + api: returns true only when apiKey, model, and baseUrl are all valid', () => {
expect(shouldEnableSettingsSave(validApiCfg, 'execution', [], true)).toBe(true);
expect(shouldEnableSettingsSave({ ...validApiCfg, apiKey: '' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave({ ...validApiCfg, apiKey: ' ' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave({ ...validApiCfg, model: '' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave(validApiCfg, 'execution', [], false)).toBe(false);
});
it('on execution: incomplete BYOK still disables save (existing behavior preserved)', () => {
// Regression guard so that #739's fix only changes the cross-section
// behavior, not the within-execution-section validity check.
expect(shouldEnableSettingsSave(incompleteApiCfg, 'execution', [availableAgent], true)).toBe(false);
});
});
describe('sanitizeSettingsSavePayload', () => {
// Round-2 review on PR #827 (lefarcen + chatgpt-codex + mrcfps): enabling
// Save on non-execution sections is the right UX, but the click still
// calls onSave(cfg, ...) which writes the entire draft to localStorage.
// If the user toggled BYOK without filling apiKey/model and then saved an
// unrelated Language change, the broken execution mode would persist and
// leave the app unable to run queries. The sanitize helper reverts the
// execution-mode fields to `initial` in that exact case.
const initialDaemon: AppConfig = {
...baseConfig,
mode: 'daemon',
apiKey: 'pre-existing-key',
apiProtocol: 'anthropic',
apiVersion: '2024-01-01',
apiProviderBaseUrl: 'https://api.anthropic.com',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'claude-code',
agentCliEnv: { claude: { CLAUDE_CONFIG_DIR: '~/.claude' } },
maxTokens: 8000,
};
const draftWithIncompleteBYOK: AppConfig = {
...initialDaemon,
mode: 'api',
apiKey: '',
model: '',
// Simulate the user's Appearance change carrying through cfg too.
theme: 'dark',
};
const availableAgent = { id: 'claude-code', available: true };
it('reverts execution-mode fields to initial when saving from a non-execution section with incomplete BYOK', () => {
// The exact P1 from lefarcen + chatgpt-codex + mrcfps: persisting
// mode='api' with empty credentials must NOT happen when the user
// saves from a non-execution section.
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
'language',
[availableAgent],
true,
);
// Execution-mode fields are restored from initial:
expect(sanitized.mode).toBe('daemon');
expect(sanitized.apiKey).toBe('pre-existing-key');
expect(sanitized.apiProtocol).toBe('anthropic');
expect(sanitized.apiVersion).toBe('2024-01-01');
expect(sanitized.apiProviderBaseUrl).toBe('https://api.anthropic.com');
expect(sanitized.baseUrl).toBe('https://api.anthropic.com');
expect(sanitized.model).toBe('claude-sonnet-4-5');
expect(sanitized.agentId).toBe('claude-code');
expect(sanitized.agentCliEnv).toEqual({ claude: { CLAUDE_CONFIG_DIR: '~/.claude' } });
expect(sanitized.maxTokens).toBe(8000);
// The non-execution change (theme) is preserved:
expect(sanitized.theme).toBe('dark');
});
it('passes the cfg through unchanged when execution config is already valid', () => {
// A user with a valid BYOK setup who navigates to a non-execution
// section and saves expects their pre-existing valid execution config
// AND their non-execution change to land. No reversion.
const validApiInitial: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: 'sk-valid',
model: 'claude-sonnet-4-5',
baseUrl: 'https://api.anthropic.com',
};
const draftWithThemeChange: AppConfig = { ...validApiInitial, theme: 'light' };
const sanitized = sanitizeSettingsSavePayload(
draftWithThemeChange,
validApiInitial,
'appearance',
[availableAgent],
true,
);
expect(sanitized).toEqual(draftWithThemeChange);
});
it('passes the cfg through unchanged on the execution section itself', () => {
// Within the execution section, the canSave gate already blocks
// incomplete-BYOK saves, so we explicitly do NOT sanitize here:
// any draft the user CAN save from execution is one they intend to
// commit as a real execution-config change.
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
'execution',
[availableAgent],
true,
);
expect(sanitized).toBe(draftWithIncompleteBYOK);
});
it('reverts on every non-execution section, not just language', () => {
// The fix must cover every sidebar section that does not own execution
// fields, otherwise a save from any one of them could leak the
// incomplete BYOK draft.
const sections: Array<Parameters<typeof sanitizeSettingsSavePayload>[2]> = [
'media',
'composio',
'integrations',
'language',
'appearance',
'notifications',
'pet',
'skills',
'designSystems',
'about',
];
for (const section of sections) {
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
section,
[availableAgent],
true,
);
expect(sanitized.mode).toBe('daemon');
expect(sanitized.apiKey).toBe('pre-existing-key');
expect(sanitized.agentId).toBe('claude-code');
}
});
it('preserves the non-execution change even when the daemon agent is unavailable in the registry passed in', () => {
// Edge case: user originally had a valid daemon mode with available
// agent. They didn't touch execution. The agent later went unavailable
// (e.g., daemon offline). Saving an Appearance change should still
// preserve the user's existing daemon selection because the revert
// path uses initial as the source of truth, not the live agent registry.
const sanitized = sanitizeSettingsSavePayload(
{ ...initialDaemon, theme: 'system' },
initialDaemon,
'appearance',
[{ id: 'claude-code', available: false }],
true,
);
// Execution fields land equal to initial regardless of revert path,
// and the appearance change survives.
expect(sanitized.mode).toBe('daemon');
expect(sanitized.agentId).toBe('claude-code');
expect(sanitized.theme).toBe('system');
});
});