Files
open-design/e2e/ui/settings-api-protocol.test.ts
T
e254d1280b feat(memory): auto-memory store with chat-protocol-aware extraction (#999)
* feat(memory): auto-memory store with chat-protocol-aware extraction

Markdown memory store at <dataDir>/memory/ with two extractors —
heuristic regex for explicit "remember:" / "我是 X" markers, and a
small-model LLM pass after each turn — folded into the system prompt
so cross-chat preferences, role, and ongoing-work context survive
restarts.

Settings UI:
- Memory tab lists entries, exposes a hand-edited MEMORY.md index, and
  shows an extraction history with per-attempt phase/skip/failure rows.
- Memory model picker is inline next to the chat model picker (CLI and
  BYOK) so the choice "which fast model mines facts each turn?" sits
  next to the chat-model decision instead of a separate panel. The
  picker reuses the same SUGGESTED_MODELS table and "Custom..." pattern
  the chat picker uses.

LLM extractor supports all four protocols (anthropic / openai / azure /
google); pickProvider takes the chat agent id from the chat handler
and constrains its auto-pick to the chat's protocol family — Claude
Code chats no longer surprise users by silently extracting on whatever
OpenAI key happens to be in media-config. When no matching key is
configured the attempt records as 'skipped: no-provider' instead of
quietly switching vendors.

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

* fix(memory): keep hint outside <label> and disambiguate Model selectors

The inline Memory model picker wrapped its hint paragraph inside the
<label>, which made the hint's "API key" / "model" wording bleed into
the <select>'s accessible name and broke Playwright's getByLabel('API
key') / getByLabel('Model') strict-mode matching in the existing
settings-api-protocol e2e suite.

- Move the hint <p> out of the <label> in MemoryModelInline so the
  select's accessible name is just "Memory model".
- Switch the chat-Model selectors in settings-api-protocol.test.ts from
  getByLabel('Model') to getByRole('combobox', { name: 'Model', exact:
  true }) so they no longer collide with the new "Memory model" select
  that sits next to the chat Model picker.

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

* fix(memory): address review changes — BYOK wiring, MEMORY.md index, /v1, label wrapper

Addresses the four blocking review threads on PR #999.

1. MemoryModelInline accessibility (mrcfps)
   The inline picker still wrapped its select + custom input + flash +
   hint inside a single <label>, which made the select's accessible
   name absorb every text descendant — including the "API key" / "model"
   hint copy. The previous fix moved only the hint outside; the
   reviewer asked for a non-label wrapper. Switch to <div className="field">
   and associate just the short title with the controls via
   `aria-labelledby` / `aria-label`. The select's accessible name is
   now exactly "Memory model" so `getByLabel` strict-mode locators
   on the surrounding chat form stop cross-matching the memory copy.

2. Respect the hand-edited MEMORY.md index (mrcfps + codex)
   `composeMemoryBody()` was reading every *.md file in the memory
   dir, ignoring the index. Removing a `- [Name](id.md)` line had no
   effect on future prompts. Parse the index's `INDEX_LINK_RE` bullets
   and filter `listMemoryEntries()` to the linked id set, so the
   editor's "delete this line to disable injection" promise actually
   holds.

3. Versioned OpenAI-compatible base URLs (codex)
   `callOpenAI` and `callAnthropic` hard-coded `/v1` onto
   `provider.baseUrl`, breaking custom endpoints whose saved URL
   already includes `/v1` (`/v1/v1/chat/completions`). Apply the same
   conditional `appendVersionedApiPath` helper the chat proxy and
   connection-test routes already use.

4. Wire memory into BYOK / API-mode chats (mrcfps + codex)
   The previous PR's daemon-only memory hook never fired for BYOK,
   leaving the Memory tab + model picker as a no-op for that mode.
   Add the missing surface and wire it through ProjectView:
   - contracts: extend `composeSystemPrompt` with `memoryBody`,
     mirroring the daemon's local composer; add
     `MemorySystemPromptResponse` and the `attemptedLLM` flag on
     `ExtractMemoryResponse`.
   - daemon: expose `GET /api/memory/system-prompt` (returns the
     composed body) and turn `POST /api/memory/extract` into a
     two-phase endpoint — heuristic-only when only userMessage is
     supplied (pre-turn), LLM-only when assistantMessage is also
     supplied (post-turn), so the extraction-history doesn't double
     up.
   - web: ProjectView's BYOK branch now fetches the memory body
     before composing the system prompt, runs the heuristic
     extractor before the run (so "remember:" markers in this turn
     reach this turn's prompt), accumulates assistant text during
     streaming, and queues the LLM extractor on `onDone` — fire-and-
     forget so it never blocks the chat round-trip.

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

* fix(memory): re-sync BYOK memory override when chat config drifts

The inline memory-model picker captured `apiProtocol` / `chatApiKey` /
`chatBaseUrl` / `chatApiVersion` into the saved override only at the
moment the user clicked a model. If they later swapped the BYOK
protocol tab, rotated the API key, or edited the base URL in the same
settings flow, the daemon's background extractor kept calling the
*old* vendor / credential — directly contradicting the picker's
"borrows the surrounding chat picker's protocol, key, base URL, and
api-version automatically" promise.

Add a debounced effect that compares the persisted (masked) shape
against the live chat props and re-PATCHes /api/memory/config when
they drift. The masked config exposes `apiKeyTail` (last 4 chars), so
key rotation is detectable without ever round-tripping the secret
back to the browser. The 300 ms debounce coalesces the keystroke-
granularity prop updates the parent settings dialog streams during
its autosave loop, so a user editing the base URL doesn't trigger one
PATCH per character. Background re-syncs are silent — the "Saved!"
flash only fires for explicit user clicks, so the picker doesn't feel
like it's fighting them as they edit unrelated chat fields.

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

* fix(memory): thread BYOK chat config through /api/memory/extract default path

Leaving the BYOK memory picker on "Same as chat" still broke the
default LLM extraction path: `MemoryModelInline` clears the override
for that option, both `/api/memory/extract` calls in `ProjectView`
only sent the messages, and the daemon never persists BYOK creds, so
`extractWithLLM(..., { chatAgentId: null })` always reached
`pickProvider()` with no chat context and fell through to env /
media-config — the wrong vendor for a BYOK chat that works for
inference.

Thread the live BYOK chat config through the extract endpoint as a
per-call snapshot:

- contracts: extend `ExtractMemoryRequest` with an optional
  `chatProvider` (provider/apiKey/baseUrl/apiVersion/model) and add
  `'chat-byok'` to the credentialSource enum.
- daemon: parse + validate `chatProvider` on `/api/memory/extract`
  (provider must be one of the five known shapes) and forward to
  `extractWithLLM` as a new option. `pickProvider()` gets a new
  path 2 that uses the snapshot directly with the per-protocol
  fast-model default — so a memory pass on `gpt-4o` / `claude-sonnet-4-5`
  silently turns into a cheap `gpt-4o-mini` / `claude-haiku-4-5` call
  instead of paying chat-tier rates for sediment work. Override and
  CLI-agent-constrained paths still win when they apply.
- web: `ProjectView` snapshots `apiProtocol` / `apiKey` / `baseUrl` /
  `apiVersion` from the live `AppConfig` on each BYOK extract call
  (both pre-turn heuristic-only and post-turn LLM phases). The
  picker's existing drift-resync effect already covers explicit
  overrides; this snapshot covers the implicit "Same as chat"
  default that the override flow can't reach.

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

* fix(memory): treat empty apiKey on PATCH as a real clear

MemoryModelInline silently re-PATCHes /api/memory/config whenever the
surrounding BYOK chat creds drift. The previous reuse branch lumped
`apiKey === ''` together with `apiKey === undefined`, so clearing the
chat API key from the picker quietly preserved the old daemon-side
secret and kept calling the provider on a stale credential.

Distinguish four states for the apiKey field:
- absent       -> preserve stored secret (form re-save without re-typing)
- ''           -> clear stored secret (user removed it from the picker)
- 'sk-...'     -> replace
- new provider -> ignore stored secret entirely

Add tests/memory-config-route.test.ts covering all four cases.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 15:45:42 +08:00

288 lines
9.9 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
async function openExecutionSettings(
page: Page,
config: Record<string, unknown>,
) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 503, body: 'offline' });
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
}
async function readSavedConfig(page: Page) {
return page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY);
}
async function openExecutionSettingsWithAgents(
page: Page,
config: Record<string, unknown>,
agents: Array<{
id: string;
name: string;
bin: string;
available: boolean;
version?: string | null;
models?: Array<{ id: string; label: string }>;
}>,
) {
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
});
await page.route('**/api/agents', async (route) => {
await route.fulfill({ json: { agents } });
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
}
test('legacy known OpenAI provider switches to the matching Anthropic preset', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'sk-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
});
const dialog = page.getByRole('dialog');
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = dialog.getByLabel('Base URL');
// Use getByRole + exact so we only match the chat "Model" picker and
// not the inline "Memory model" picker that sits next to it.
const modelSelect = dialog.getByRole('combobox', { name: 'Model', exact: true });
await expect(openAiTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.deepseek.com');
await expect(modelSelect).toHaveValue('deepseek-chat');
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://api.deepseek.com/anthropic');
await expect(modelSelect).toHaveValue('deepseek-chat');
});
test('legacy custom provider preserves custom baseUrl and model when switching protocols', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: 'sk-test',
baseUrl: 'https://my-proxy.example.com/v1',
model: 'my-custom-model',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
});
const dialog = page.getByRole('dialog');
const protocolTabs = dialog.getByRole('tablist', { name: 'API protocol' });
const openAiTab = protocolTabs.getByRole('tab', { name: 'OpenAI', exact: true });
const anthropicTab = protocolTabs.getByRole('tab', { name: 'Anthropic', exact: true });
const baseUrlInput = dialog.getByLabel('Base URL');
const customModelInput = dialog.getByLabel(/Custom model id/i);
await expect(openAiTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'OpenAI API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
await expect(customModelInput).toHaveValue('my-custom-model');
await anthropicTab.click();
await expect(anthropicTab).toHaveAttribute('aria-selected', 'true');
await expect(dialog.getByRole('heading', { name: 'Anthropic API' })).toBeVisible();
await expect(baseUrlInput).toHaveValue('https://my-proxy.example.com/v1');
await expect(customModelInput).toHaveValue('my-custom-model');
});
test('BYOK quick fill provider updates fields and saved settings persist after closing and reopening', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: 'OpenAI', exact: true }).click();
await dialog.getByLabel('Quick fill provider').selectOption('1');
await expect(dialog.getByRole('combobox', { name: 'Model', exact: true })).toHaveValue('deepseek-chat');
await expect(dialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await dialog.getByRole('button', { name: 'Show' }).click();
const apiKeyInput = dialog.getByLabel('API key');
await expect(apiKeyInput).toHaveAttribute('type', 'text');
await apiKeyInput.fill('sk-openai-test');
await expect
.poll(async () => readSavedConfig(page))
.toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'sk-openai-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const savedConfig = await readSavedConfig(page);
expect(savedConfig).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'sk-openai-test',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await page.getByTitle('Configure execution mode').click();
await expect(page.getByRole('dialog')).toBeVisible();
const reopenedDialog = page.getByRole('dialog');
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(reopenedDialog.getByLabel('Quick fill provider')).toHaveValue('1');
await expect(reopenedDialog.getByRole('combobox', { name: 'Model', exact: true })).toHaveValue('deepseek-chat');
await expect(reopenedDialog.getByLabel('Base URL')).toHaveValue('https://api.deepseek.com');
await expect(reopenedDialog.getByLabel('API key')).toHaveValue('sk-openai-test');
});
test('BYOK save stays disabled until required fields are valid', async ({ page }) => {
await openExecutionSettings(page, {
mode: 'api',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
});
const dialog = page.getByRole('dialog');
const closeButton = dialog.getByRole('button', { name: 'Close', exact: true });
await expect(closeButton).toBeEnabled();
await dialog.getByLabel('API key').fill('sk-ant-test');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({ apiKey: 'sk-ant-test' });
await dialog.getByLabel('Base URL').fill('http://10.0.0.5:11434/v1');
await expect(dialog.locator('#settings-base-url-error')).toContainText('valid public');
await dialog.getByLabel('Base URL').fill('http://localhost:11434/v1');
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
apiKey: 'sk-ant-test',
baseUrl: 'http://localhost:11434/v1',
});
});
test('saving Local CLI updates the entry status pill with the selected agent', async ({ page }) => {
await openExecutionSettingsWithAgents(
page,
{
mode: 'api',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
},
[
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.80.0',
models: [{ id: 'default', label: 'Default' }],
},
{
id: 'gemini',
name: 'Gemini CLI',
bin: 'gemini',
available: false,
version: null,
models: [],
},
],
);
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: /Local CLI.*1 installed/i }).click();
await dialog.getByRole('button', { name: /Codex CLI/i }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
});
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const executionPill = page.getByTitle('Configure execution mode');
await expect(executionPill).toContainText('Local CLI');
await expect(executionPill).toContainText('Codex CLI');
await expect(executionPill).toContainText('0.80.0');
});