mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
288 lines
9.9 KiB
TypeScript
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');
|
|
});
|