* feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
96 lines
3 KiB
TypeScript
96 lines
3 KiB
TypeScript
import 'fake-indexeddb/auto'
|
|
import { expect, test } from 'bun:test'
|
|
|
|
import { simulateReadableStream, type UIMessage } from 'ai'
|
|
import { MockLanguageModelV4 } from 'ai/test'
|
|
|
|
import { createToolLoopTransport } from '@/app/ai/chat/transports'
|
|
import { aiToolOverrides } from '@/app/ai/tools/preferences'
|
|
import { createEditorStore } from '@/app/editor/session/create'
|
|
|
|
test('a reused AI transport refreshes actual request tools for each message', async () => {
|
|
const previous = aiToolOverrides.value
|
|
const store = createEditorStore()
|
|
const model = new MockLanguageModelV4({
|
|
doStream: async () => ({
|
|
stream: simulateReadableStream({
|
|
initialDelayInMs: null,
|
|
chunkDelayInMs: null,
|
|
chunks: [
|
|
{ type: 'text-start', id: 'reply' },
|
|
{ type: 'text-delta', id: 'reply', delta: 'Done' },
|
|
{ type: 'text-end', id: 'reply' },
|
|
{
|
|
type: 'finish',
|
|
finishReason: { unified: 'stop', raw: undefined },
|
|
usage: {
|
|
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
|
|
outputTokens: { total: 1, text: 1, reasoning: undefined }
|
|
}
|
|
}
|
|
]
|
|
})
|
|
})
|
|
})
|
|
try {
|
|
aiToolOverrides.value = {}
|
|
const transport = createToolLoopTransport({
|
|
store,
|
|
providerID: 'openai',
|
|
model,
|
|
effectiveModelID: 'test',
|
|
maxOutputTokens: 100,
|
|
reasoningEffort: ''
|
|
})
|
|
async function send(history: UIMessage[] = []) {
|
|
const stream = await transport.sendMessages({
|
|
trigger: 'submit-message',
|
|
chatId: 'tool-access',
|
|
messageId: undefined,
|
|
messages: [
|
|
...history,
|
|
{ id: 'user', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }
|
|
]
|
|
})
|
|
const reader = stream.getReader()
|
|
try {
|
|
while (true) {
|
|
const next = await reader.read()
|
|
if (next.done) break
|
|
expect(next.value.type).not.toBe('error')
|
|
}
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
return model.doStreamCalls.at(-1)?.tools?.map((tool) => tool.name) ?? []
|
|
}
|
|
expect(await send()).not.toContain('create_component')
|
|
aiToolOverrides.value = { create_component: true, get_components: false }
|
|
const updated = await send()
|
|
expect(updated).toContain('create_component')
|
|
expect(updated).not.toContain('get_components')
|
|
expect(model.doStreamCalls).toHaveLength(2)
|
|
// History remains valid even if an extended tool is now disabled.
|
|
const next = await send([
|
|
{
|
|
id: 'previous-result',
|
|
role: 'assistant',
|
|
parts: [
|
|
{
|
|
type: 'tool-get_current_page',
|
|
toolCallId: 'previous-call',
|
|
state: 'output-available',
|
|
input: {},
|
|
output: { id: 'page', name: 'Page 1' }
|
|
}
|
|
]
|
|
}
|
|
])
|
|
expect(next).not.toContain('get_current_page')
|
|
expect(model.doStreamCalls).toHaveLength(3)
|
|
} finally {
|
|
aiToolOverrides.value = previous
|
|
store.dispose()
|
|
}
|
|
})
|