From c01e4a0ee297a16a9b01739ebdaf4569161d4a29 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Tue, 30 Jun 2026 14:56:04 +0530 Subject: [PATCH] feat(ai): Improve ai context (#8404) --- .../src/components/AIAssist/index.js | 7 +- .../src/components/AiChatSidebar/index.js | 14 +- .../src/components/CollectionApp/index.js | 4 +- .../CollectionSettings/Docs/index.js | 5 +- .../CollectionSettings/Script/index.js | 7 +- .../CollectionSettings/Tests/index.js | 7 +- .../src/components/Documentation/index.js | 8 +- .../FolderSettings/Documentation/index.js | 5 +- .../components/FolderSettings/Script/index.js | 7 +- .../components/FolderSettings/Tests/index.js | 7 +- .../RequestPane/AppCodeEditor/index.js | 8 +- .../components/RequestPane/Script/index.js | 9 +- .../src/components/RequestPane/Tests/index.js | 9 +- .../src/providers/ReduxStore/slices/chat.js | 4 +- packages/bruno-app/src/utils/ai/index.js | 151 ++++++++ packages/bruno-app/src/utils/ai/index.spec.js | 186 ++++++++++ .../bruno-electron/src/ipc/ai/chat-prompts.js | 4 +- packages/bruno-electron/src/ipc/ai/chat.js | 215 ++--------- packages/bruno-electron/src/ipc/ai/context.js | 350 ++++++++++++++++++ .../bruno-electron/src/ipc/ai/context.spec.js | 243 ++++++++++++ packages/bruno-electron/src/ipc/ai/index.js | 93 ++++- .../src/ipc/ai/script-prompts.js | 73 ++-- 22 files changed, 1170 insertions(+), 246 deletions(-) create mode 100644 packages/bruno-electron/src/ipc/ai/context.js create mode 100644 packages/bruno-electron/src/ipc/ai/context.spec.js diff --git a/packages/bruno-app/src/components/AIAssist/index.js b/packages/bruno-app/src/components/AIAssist/index.js index b3917ed9b..f8bfa9639 100644 --- a/packages/bruno-app/src/components/AIAssist/index.js +++ b/packages/bruno-app/src/components/AIAssist/index.js @@ -59,7 +59,7 @@ const PREVIEW_LABELS = { const isValidType = (t) => SUGGESTIONS[t] !== undefined; -const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onApply }) => { +const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, onApply }) => { const [isOpen, setIsOpen] = useState(false); const [prompt, setPrompt] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -114,7 +114,8 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onAp prompt: text, currentScript: currentScript || '', requestContext, - docsContext + docsContext, + variables }); if (result?.error) { setError(result.error); @@ -131,7 +132,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onAp setIsLoading(false); } }, - [prompt, isLoading, scriptType, currentScript, requestContext, docsContext] + [prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables] ); const handleApply = useCallback(() => { diff --git a/packages/bruno-app/src/components/AiChatSidebar/index.js b/packages/bruno-app/src/components/AiChatSidebar/index.js index 5394892bd..ba4007e70 100644 --- a/packages/bruno-app/src/components/AiChatSidebar/index.js +++ b/packages/bruno-app/src/components/AiChatSidebar/index.js @@ -44,7 +44,7 @@ import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections'; import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections'; -import { getAiStatus } from 'utils/ai'; +import { buildAiVariablesPayload, getAiStatus } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import DiffView from './DiffView'; @@ -362,6 +362,16 @@ const AiChatSidebar = ({ collection }) => { }; }, [aiContext]); + // Variables payload is collection-scoped — works for request, folder, and + // collection chats alike. Each entry is { name, value, scope, secret }; the + // model gets a name-only preview in the prompt and can call search_variables + // to fetch values (secrets come back redacted). + const aiVariables = useMemo(() => { + if (aiContext?.kind === 'request') return buildAiVariablesPayload(collection, aiContext.item); + if (aiContext?.kind === 'folder') return buildAiVariablesPayload(collection, aiContext.folder); + return buildAiVariablesPayload(collection, null); + }, [collection, aiContext]); + const chatsWithMessages = useMemo(() => { if (!collection) return []; return Object.entries(allChats) @@ -438,7 +448,7 @@ const AiChatSidebar = ({ collection }) => { if (textareaRef.current) textareaRef.current.style.height = 'auto'; try { - await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType)); + await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType, aiVariables)); setProcessingStage('applying'); setTimeout(() => setProcessingStage(null), 500); } catch (err) { diff --git a/packages/bruno-app/src/components/CollectionApp/index.js b/packages/bruno-app/src/components/CollectionApp/index.js index 9d6aeb0f4..dc7e61fcd 100644 --- a/packages/bruno-app/src/components/CollectionApp/index.js +++ b/packages/bruno-app/src/components/CollectionApp/index.js @@ -23,7 +23,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildDocsContextFromCollection } from 'utils/ai'; +import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import EmptyAppState from '../AppView/EmptyAppState'; import { @@ -190,6 +190,7 @@ const CollectionApp = ({ item, collection }) => { [collection?.name, collection?.pathname] ); const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]); const onEdit = useCallback( (value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })), @@ -366,6 +367,7 @@ const CollectionApp = ({ item, collection }) => { scriptType="app-collection" currentScript={code || ''} docsContext={docsContext} + variables={aiVariables} onApply={onEdit} /> diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 73ebbb8d4..b79c44172 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -10,7 +10,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/ import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildDocsContextFromCollection } from 'utils/ai'; +import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import { IconEdit, IconX, IconFileText } from '@tabler/icons'; import Button from 'ui/Button/index'; @@ -28,6 +28,7 @@ const Docs = ({ collection }) => { const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', ''); const preferences = useSelector((state) => state.app.preferences); const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]); // StyledWrapper has overflow-y: auto — use null selector. // Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll. @@ -101,7 +102,7 @@ const Docs = ({ collection }) => { initialScroll={scroll} onScroll={setScroll} /> - + ) : (
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index 5ff55f52f..9fc419ae9 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -1,9 +1,10 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import get from 'lodash/get'; import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; +import { buildAiVariablesPayload } from 'utils/ai'; import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; @@ -101,6 +102,8 @@ const Script = ({ collection }) => { const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage); const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]); + return (
@@ -144,6 +147,7 @@ const Script = ({ collection }) => {
@@ -170,6 +174,7 @@ const Script = ({ collection }) => {
diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js index e2832dca9..5f783380b 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js @@ -1,8 +1,9 @@ -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; +import { buildAiVariablesPayload } from 'utils/ai'; import { updateCollectionTests } from 'providers/ReduxStore/slices/collections'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; @@ -37,6 +38,8 @@ const Tests = ({ collection }) => { scriptPhase: 'test' }); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]); + return (
These tests will run any time a request in this collection is sent.
@@ -56,7 +59,7 @@ const Tests = ({ collection }) => { initialScroll={testsScroll} onScroll={setTestsScroll} /> - +
diff --git a/packages/bruno-app/src/components/Documentation/index.js b/packages/bruno-app/src/components/Documentation/index.js index c6f730f39..8d2cc6c36 100644 --- a/packages/bruno-app/src/components/Documentation/index.js +++ b/packages/bruno-app/src/components/Documentation/index.js @@ -10,7 +10,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildRequestContextFromItem } from 'utils/ai'; +import { buildAiContextPayload } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; @@ -44,7 +44,10 @@ const Documentation = ({ item, collection }) => { }; const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); - const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); + const { requestContext, variables: aiVariables } = useMemo( + () => buildAiContextPayload(item, collection), + [item, collection] + ); if (!item) { return null; @@ -74,6 +77,7 @@ const Documentation = ({ item, collection }) => { scriptType="docs" currentScript={docs || ''} requestContext={requestContext} + variables={aiVariables} onApply={onEdit} />
diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js index 00cc3a295..2a8f21587 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js @@ -10,7 +10,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions' import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildDocsContextFromFolder } from 'utils/ai'; +import { buildAiVariablesPayload, buildDocsContextFromFolder } from 'utils/ai'; import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState'; @@ -46,6 +46,7 @@ const Documentation = ({ collection, folder }) => { const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid)); const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]); if (!folder) { return null; @@ -72,7 +73,7 @@ const Documentation = ({ collection, folder }) => { initialScroll={scroll} onScroll={setScroll} /> - +
@@ -175,6 +179,7 @@ const Script = ({ collection, folder }) => { diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js index b5997591a..43ee11c33 100644 --- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js @@ -1,8 +1,9 @@ -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; +import { buildAiVariablesPayload } from 'utils/ai'; import { updateFolderTests } from 'providers/ReduxStore/slices/collections'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; @@ -38,6 +39,8 @@ const Tests = ({ collection, folder }) => { scriptPhase: 'test' }); + const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]); + return (
These tests will run any time a request in this collection is sent.
@@ -57,7 +60,7 @@ const Tests = ({ collection, folder }) => { initialScroll={testsScroll} onScroll={setTestsScroll} /> - +
diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js index 266f5892d..2576ab80c 100644 --- a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import ToggleSwitch from 'components/ToggleSwitch'; import AIAssist from 'components/AIAssist'; -import { buildRequestContextFromItem } from 'utils/ai'; +import { buildAiContextPayload } from 'utils/ai'; import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; @@ -26,7 +26,10 @@ const AppCodeEditor = ({ item, collection }) => { const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); - const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); + const { requestContext, variables: aiVariables } = useMemo( + () => buildAiContextPayload(item, collection), + [item, collection] + ); return ( @@ -55,6 +58,7 @@ const AppCodeEditor = ({ item, collection }) => { scriptType="app-request" currentScript={code || ''} requestContext={requestContext} + variables={aiVariables} onApply={onEdit} />
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js index ae9d62924..075351b19 100644 --- a/packages/bruno-app/src/components/RequestPane/Script/index.js +++ b/packages/bruno-app/src/components/RequestPane/Script/index.js @@ -4,7 +4,7 @@ import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildRequestContextFromItem } from 'utils/ai'; +import { buildAiContextPayload } from 'utils/ai'; import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; @@ -95,7 +95,10 @@ const Script = ({ item, collection }) => { const onRun = () => dispatch(sendRequest(item, collection.uid)); const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); - const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); + const { requestContext, variables: aiVariables } = useMemo( + () => buildAiContextPayload(item, collection), + [item, collection] + ); const hasPreRequestScript = requestScript && requestScript.trim().length > 0; const hasPostResponseScript = responseScript && responseScript.trim().length > 0; @@ -146,6 +149,7 @@ const Script = ({ item, collection }) => { scriptType="pre-request" currentScript={requestScript || ''} requestContext={requestContext} + variables={aiVariables} onApply={onRequestScriptEdit} /> @@ -175,6 +179,7 @@ const Script = ({ item, collection }) => { scriptType="post-response" currentScript={responseScript || ''} requestContext={requestContext} + variables={aiVariables} onApply={onResponseScriptEdit} /> diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js index a35e956fc..d43c48f85 100644 --- a/packages/bruno-app/src/components/RequestPane/Tests/index.js +++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js @@ -3,7 +3,7 @@ import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import AIAssist from 'components/AIAssist'; -import { buildRequestContextFromItem } from 'utils/ai'; +import { buildAiContextPayload } from 'utils/ai'; import { updateRequestTests } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; @@ -38,7 +38,10 @@ const Tests = ({ item, collection }) => { scriptPhase: 'test' }); - const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); + const { requestContext, variables: aiVariables } = useMemo( + () => buildAiContextPayload(item, collection), + [item, collection] + ); return (
@@ -60,7 +63,7 @@ const Tests = ({ item, collection }) => { initialScroll={testsScroll} onScroll={setTestsScroll} /> - +
); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/chat.js b/packages/bruno-app/src/providers/ReduxStore/slices/chat.js index 12ebed611..3bec40b79 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/chat.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/chat.js @@ -240,7 +240,8 @@ export const sendAiMessage = ( allContent, requestContext, model, - contentType = 'app' + contentType = 'app', + variables = [] ) => async (dispatch, getState) => { const { ipcRenderer } = window; @@ -376,6 +377,7 @@ export const sendAiMessage = ( allContent: normalizedContent, contentType, requestContext, + variables, requestId, model }); diff --git a/packages/bruno-app/src/utils/ai/index.js b/packages/bruno-app/src/utils/ai/index.js index 685a45f85..f0768fa83 100644 --- a/packages/bruno-app/src/utils/ai/index.js +++ b/packages/bruno-app/src/utils/ai/index.js @@ -1,7 +1,10 @@ +import get from 'lodash/get'; import { callIpc } from 'utils/common/ipc'; import { + findEnvironmentInCollection, flattenItems, getAllVariables, + getFormattedCollectionOauth2Credentials, isItemAFolder, isItemARequest, sortItemsBySidebarOrder @@ -44,6 +47,14 @@ export const cancelAiAutocomplete = (requestId) => { } }; +/** + * Lean request context - method/url/headers/params/body. Kept for callers + * that don't need the response (autocomplete + legacy sparkle sites). + * + * Sensitive header/param values are NOT stripped here — that happens in the + * backend formatter via `maskValue`. The renderer ships them verbatim so the + * mask logic stays in one place (packages/bruno-electron/src/ipc/ai/context.js). + */ export const buildRequestContextFromItem = (item) => { if (!item) return null; const req = item.draft ? item.draft.request : item.request; @@ -58,6 +69,146 @@ export const buildRequestContextFromItem = (item) => { }; }; +/** + * Extended request context for chat + generation: adds the request's docs + * field and the last response. The response is redacted shape-only on the + * backend before being formatted into the prompt. + */ +export const buildAiRequestContext = (item) => { + if (!item) return null; + const req = item.draft ? item.draft.request : item.request; + if (!req) return null; + + return { + url: req.url || '', + method: req.method || 'GET', + headers: Array.isArray(req.headers) ? req.headers : [], + params: Array.isArray(req.params) ? req.params : [], + body: req.body || null, + docs: req.docs || null, + responseStatus: get(item, 'response.status', null), + responseData: get(item, 'response.data', null) + }; +}; + +/** + * Sensitive name patterns kept in sync with the backend (context.js). The + * renderer uses these to redact secret values BEFORE sending over IPC so the + * payload itself never carries them — a belt-and-suspenders measure on top + * of the backend masking. + */ +const SENSITIVE_NAME_PATTERNS = [ + /api[_-]?key/i, + // Catches refresh_token, id_token, csrfToken, plain TOKEN, etc. on top of + // the specific access/auth-token forms below. + /token/i, + /access[_-]?token/i, + /auth[_-]?token/i, + /secret/i, + /password/i, + /^authorization$/i, + /^cookie$/i +]; + +const isSensitiveName = (name) => { + if (!name) return false; + return SENSITIVE_NAME_PATTERNS.some((re) => re.test(name)); +}; + +/** + * Flat list of variables the model can search. Each entry: + * { name, value, scope, secret } + * + * Values come from `getAllVariables()` so they match what `bru.*` returns at + * runtime - important because the model's `search_variables` tool would + * otherwise show a lower-precedence value for any name that's overridden by + * a higher-precedence scope (e.g. a folder var hiding behind an env var). + * + * Scope + secret metadata is attached by walking each named source. A name + * marked secret by ANY source stays secret in the output. + * + * - `secret: true` => value is replaced by `` here, not sent in the + * clear over IPC. + * - The backend re-applies redaction in `formatVariableLine`, so even if a + * secret slipped through here it wouldn't reach the provider. + */ +export const buildAiVariablesPayload = (collection, item) => { + if (!collection) return []; + + const REDACTED = ''; + + // Authoritative values - same merge `bru.getEnvVar` / `bru.getVar` resolve. + const resolved = getAllVariables(collection, item) || {}; + + // name -> { scope, secret } - last claim wins for scope (matches the spread + // order in getAllVariables); secret is sticky-on once any source flags it. + const meta = new Map(); + const claim = (name, scope, secret) => { + if (!name) return; + const existing = meta.get(name); + const finalSecret = Boolean(secret) || Boolean(existing?.secret); + meta.set(name, { scope, secret: finalSecret }); + }; + + // Global env - secrets tracked as a separate name list. + const globalSecrets = new Set(collection.globalEnvSecrets || []); + for (const name of Object.keys(collection.globalEnvironmentVariables || {})) { + claim(name, 'global', globalSecrets.has(name)); + } + + // Active environment - explicit `secret` flag per variable. + const env = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); + if (env && Array.isArray(env.variables)) { + for (const v of env.variables) { + if (v?.name && v.enabled) claim(v.name, 'env', Boolean(v.secret)); + } + } + + // Runtime - set via bru.setVar() at runtime. No secret flag. + for (const name of Object.keys(collection.runtimeVariables || {})) { + claim(name, 'runtime', false); + } + + // OAuth2 credentials — always treat as secret. `getAllVariables` already + // surfaces these via the same helper, so claiming here just stamps the + // right scope/secret on names that would otherwise default to 'collection'. + const oauth = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }); + if (oauth) { + for (const name of Object.keys(oauth)) { + claim(name, 'oauth2', true); + } + } + + const out = []; + for (const name of Object.keys(resolved)) { + if (name === 'pathParams' || name === 'maskedEnvVariables' || name === 'process') continue; + const m = meta.get(name); + // Default scope for names not claimed by any explicit source — these come + // from collection/folder/request-level vars that don't carry a secret + // flag of their own, so we rely on `isSensitiveName` to catch token-like + // names by pattern. + const scope = m?.scope || 'collection'; + const isSecret = Boolean(m?.secret) || isSensitiveName(name); + const value = resolved[name]; + out.push({ + name, + value: isSecret ? REDACTED : (value == null ? '' : String(value)), + scope, + secret: isSecret + }); + } + return out; +}; + +/** + * Single entry point for chat + generation. Returns the same payload shape + * for both so the backend formatters / tools behave identically. + */ +export const buildAiContextPayload = (item, collection) => ({ + requestContext: buildAiRequestContext(item), + variables: collection ? buildAiVariablesPayload(collection, item) : [] +}); + const summarizeDocsItems = (items = []) => { const folders = []; const requests = []; diff --git a/packages/bruno-app/src/utils/ai/index.spec.js b/packages/bruno-app/src/utils/ai/index.spec.js index 0ec3cfbcf..ade268801 100644 --- a/packages/bruno-app/src/utils/ai/index.spec.js +++ b/packages/bruno-app/src/utils/ai/index.spec.js @@ -1,4 +1,7 @@ import { + buildAiContextPayload, + buildAiRequestContext, + buildAiVariablesPayload, buildDocsContextFromCollection, buildDocsContextFromFolder, buildRequestContextFromItem @@ -115,4 +118,187 @@ describe('utils/ai', () => { }); }); }); + + describe('buildAiRequestContext', () => { + it('includes docs and the last response on top of the lean request context', () => { + const item = { + request: { + method: 'POST', + url: '/widgets', + headers: [{ name: 'X-Foo', value: 'bar', enabled: true }], + params: [], + body: { mode: 'json', json: '{}' }, + docs: 'Some docs' + }, + response: { status: 201, data: { id: 'abc' } } + }; + + expect(buildAiRequestContext(item)).toEqual({ + url: '/widgets', + method: 'POST', + headers: [{ name: 'X-Foo', value: 'bar', enabled: true }], + params: [], + body: { mode: 'json', json: '{}' }, + docs: 'Some docs', + responseStatus: 201, + responseData: { id: 'abc' } + }); + }); + + it('returns null for an item with no request', () => { + expect(buildAiRequestContext(null)).toBeNull(); + expect(buildAiRequestContext({})).toBeNull(); + }); + }); + + describe('buildAiVariablesPayload', () => { + const variablesCollection = { + activeEnvironmentUid: 'env-1', + environments: [ + { + uid: 'env-1', + variables: [ + { name: 'API_URL', value: 'https://x', enabled: true, secret: false }, + { name: 'API_TOKEN', value: 'real-tok', enabled: true, secret: true }, + { name: 'DISABLED', value: 'ignore', enabled: false, secret: false } + ] + } + ], + globalEnvironmentVariables: { GLOBAL_FOO: 'foo', GLOBAL_SECRET: 's' }, + globalEnvSecrets: ['GLOBAL_SECRET'], + runtimeVariables: { runtimeKey: 'r1' } + }; + + it('redacts secret env vars by the secret flag', () => { + const result = buildAiVariablesPayload(variablesCollection, null); + const token = result.find((v) => v.name === 'API_TOKEN'); + expect(token).toEqual({ name: 'API_TOKEN', value: '', scope: 'env', secret: true }); + }); + + it('redacts global env vars listed in globalEnvSecrets', () => { + const result = buildAiVariablesPayload(variablesCollection, null); + const gs = result.find((v) => v.name === 'GLOBAL_SECRET'); + expect(gs).toEqual({ name: 'GLOBAL_SECRET', value: '', scope: 'global', secret: true }); + }); + + it('drops disabled environment variables', () => { + const result = buildAiVariablesPayload(variablesCollection, null); + expect(result.find((v) => v.name === 'DISABLED')).toBeUndefined(); + }); + + it('falls back to pattern-based redaction for names like *_token even without the secret flag', () => { + const collectionWithRuntimeSecret = { + activeEnvironmentUid: null, + environments: [], + globalEnvironmentVariables: {}, + globalEnvSecrets: [], + runtimeVariables: { access_token: 'should-not-leak' } + }; + const result = buildAiVariablesPayload(collectionWithRuntimeSecret, null); + const v = result.find((x) => x.name === 'access_token'); + expect(v).toEqual({ name: 'access_token', value: '', scope: 'runtime', secret: true }); + }); + + it('does not duplicate a name across scopes (env wins over global)', () => { + const collectionWithCollision = { + activeEnvironmentUid: 'env-1', + environments: [ + { uid: 'env-1', variables: [{ name: 'SHARED', value: 'env-val', enabled: true, secret: false }] } + ], + globalEnvironmentVariables: { SHARED: 'global-val' }, + globalEnvSecrets: [], + runtimeVariables: {} + }; + const result = buildAiVariablesPayload(collectionWithCollision, null); + const shared = result.filter((v) => v.name === 'SHARED'); + expect(shared).toHaveLength(1); + expect(shared[0]).toEqual({ name: 'SHARED', value: 'env-val', scope: 'env', secret: false }); + }); + + it('lets runtime override env when both define the same name', () => { + const collectionWithOverride = { + activeEnvironmentUid: 'env-1', + environments: [ + { uid: 'env-1', variables: [{ name: 'API_URL', value: 'env-url', enabled: true, secret: false }] } + ], + globalEnvironmentVariables: {}, + globalEnvSecrets: [], + runtimeVariables: { API_URL: 'runtime-url' } + }; + const result = buildAiVariablesPayload(collectionWithOverride, null); + const shared = result.filter((v) => v.name === 'API_URL'); + expect(shared).toHaveLength(1); + expect(shared[0]).toEqual({ name: 'API_URL', value: 'runtime-url', scope: 'runtime', secret: false }); + }); + + it('keeps secret stickiness across scopes — env-secret value stays redacted even if runtime overrides', () => { + // If env declares API_TOKEN as secret and runtime overrides it, the + // runtime value should still be redacted. + const collectionWithSecretOverride = { + activeEnvironmentUid: 'env-1', + environments: [ + { uid: 'env-1', variables: [{ name: 'API_TOKEN', value: 'env-tok', enabled: true, secret: true }] } + ], + globalEnvironmentVariables: {}, + globalEnvSecrets: [], + runtimeVariables: { API_TOKEN: 'runtime-tok' } + }; + const result = buildAiVariablesPayload(collectionWithSecretOverride, null); + const tok = result.find((v) => v.name === 'API_TOKEN'); + // scope tracks the source the user would actually hit; the value stays + // redacted because env marked the name secret. + expect(tok.value).toBe(''); + expect(tok.secret).toBe(true); + }); + + it('redacts OAuth2 credentials with scope = oauth2', () => { + const collectionWithOauth = { + activeEnvironmentUid: null, + environments: [], + globalEnvironmentVariables: {}, + globalEnvSecrets: [], + runtimeVariables: {}, + oauth2Credentials: [ + { + credentialsId: 'github', + credentials: { access_token: 'real-tok', token_type: 'Bearer' } + } + ] + }; + const result = buildAiVariablesPayload(collectionWithOauth, null); + const tok = result.find((v) => v.name === '$oauth2.github.access_token'); + expect(tok).toBeDefined(); + expect(tok.value).toBe(''); + expect(tok.secret).toBe(true); + expect(result.some((v) => v.name === '$oauth2.github.access_token' && v.scope === 'collection' && !v.secret)).toBe(false); + }); + + it('redacts generic token names like refresh_token / id_token / TOKEN by pattern', () => { + const collectionWithVariousTokens = { + activeEnvironmentUid: null, + environments: [], + globalEnvironmentVariables: {}, + globalEnvSecrets: [], + runtimeVariables: { refresh_token: 'r', id_token: 'i', csrfToken: 'c', TOKEN: 't' } + }; + const result = buildAiVariablesPayload(collectionWithVariousTokens, null); + for (const name of ['refresh_token', 'id_token', 'csrfToken', 'TOKEN']) { + const v = result.find((x) => x.name === name); + expect(v).toBeDefined(); + expect(v.value).toBe(''); + expect(v.secret).toBe(true); + } + }); + + it('returns an empty array when no collection is supplied', () => { + expect(buildAiVariablesPayload(null, null)).toEqual([]); + }); + }); + + describe('buildAiContextPayload', () => { + it('combines request context and variables into a single payload', () => { + const result = buildAiContextPayload(null, null); + expect(result).toEqual({ requestContext: null, variables: [] }); + }); + }); }); diff --git a/packages/bruno-electron/src/ipc/ai/chat-prompts.js b/packages/bruno-electron/src/ipc/ai/chat-prompts.js index a4aa77804..ce844ee24 100644 --- a/packages/bruno-electron/src/ipc/ai/chat-prompts.js +++ b/packages/bruno-electron/src/ipc/ai/chat-prompts.js @@ -141,6 +141,7 @@ This means: - read_content(type): reads a section. type ∈ { 'app', 'tests', 'pre-request', 'post-response', 'docs' }. MUST be called before write_content for the same type. - write_content(type, content): writes complete new content. The content must be the ENTIRE file, not a diff. read_content must be called first for the same type. - read_response(): returns the redacted shape (keys + types) of the last response body. No parameters. Use it to learn paths and types — not to read actual values. +- search_variables(query?): search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query string when you need to confirm a name before referencing it. Values come back redacted for secrets — never hard-code a returned value. Each result has a \`scope\` field — use it to pick the right runtime accessor: \`bru.getEnvVar\` for \`env\`, \`bru.getGlobalEnvVar\` for \`global\`, \`bru.getCollectionVar\` / \`bru.getFolderVar\` / \`bru.getRequestVar\` for \`collection\`, \`bru.getVar\` for \`runtime\`, and \`bru.getSecretVar\` for any value that came back redacted. Use this when the inline variables list is truncated. ### Rules - ALWAYS call read_content before write_content for the same type @@ -166,7 +167,8 @@ const TOOL_LABELS = { 'post-response': 'Writing post-response script', 'docs': 'Writing documentation' }, - read_response: { default: 'Reading response data' } + read_response: { default: 'Reading response data' }, + search_variables: { default: 'Searching variables' } }; const buildSystemPrompt = (contentType, hasMultipleContent) => { diff --git a/packages/bruno-electron/src/ipc/ai/chat.js b/packages/bruno-electron/src/ipc/ai/chat.js index e713ed0fe..ce1c35886 100644 --- a/packages/bruno-electron/src/ipc/ai/chat.js +++ b/packages/bruno-electron/src/ipc/ai/chat.js @@ -2,6 +2,13 @@ const { ipcMain } = require('electron'); const { streamText, stepCountIs } = require('ai'); const { z } = require('zod'); const { CONTENT_TYPES, TOOL_LABELS, buildSystemPrompt, resolveContentType } = require('./chat-prompts'); +const { + formatRequestContext, + formatResponseShape, + formatVariablesList, + searchVariables, + formatSearchVariablesResult +} = require('./context'); const activeStreams = new Map(); @@ -13,159 +20,18 @@ const CONTENT_LABELS = { 'docs': 'Documentation' }; -// Replace every primitive value with a type-name placeholder so the model -// sees the response *shape* without any real data. Customer responses can -// contain PII / secrets / tokens — we keep keys, types, and array structure -// intact so the AI can write correct property paths and assertions, but -// strip the values themselves. The AI is told these are placeholders so it -// doesn't hard-code them into generated code. -const REDACTED_TRUNCATED = ''; -const REDACTED_NULL = ''; -const REDACTED_BY_TYPE = { - string: '', - number: '', - boolean: '', - bigint: '' -}; - -const redactResponseValues = (data, depth = 0, maxDepth = 6) => { - if (data === null) return REDACTED_NULL; - if (data === undefined) return REDACTED_NULL; - if (depth >= maxDepth) return REDACTED_TRUNCATED; - - if (Array.isArray(data)) { - if (data.length === 0) return []; - // Cap sample size — long arrays only need a few items to convey shape. - const sampleSize = Math.min(data.length, 3); - const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth)); - if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`); - return out; - } - - if (typeof data === 'object') { - const keys = Object.keys(data); - const out = {}; - for (const key of keys.slice(0, 30)) { - out[key] = redactResponseValues(data[key], depth + 1, maxDepth); - } - if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`; - return out; - } - - return REDACTED_BY_TYPE[typeof data] || ''; -}; - -const REDACTION_NOTICE - = 'Values are placeholders (``, ``, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.'; - -const SENSITIVE_HEADER_PATTERNS = [ - /^authorization$/i, - /^proxy-authorization$/i, - /^cookie$/i, - /^set-cookie$/i, - /^x-api-key$/i, - /^x-auth-token$/i, - /^x-access-token$/i, - /^x-csrf-token$/i, - /api[_-]?key/i, - /access[_-]?token/i, - /auth[_-]?token/i, - /secret/i, - /password/i -]; - -const isSensitiveName = (name) => { - if (!name) return false; - return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name)); -}; - -const maskValue = (name, value) => (isSensitiveName(name) ? '' : value); - -const formatRequestContext = (ctx) => { - if (!ctx) return ''; +const buildContextMessage = (contentType, allContent, requestContext, variables) => { const parts = []; - - if (ctx.url || ctx.method) { - parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`); - } - - const headers = (ctx.headers || []).filter((h) => h.enabled); - if (headers.length > 0) { - parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value)}`).join('\n')}`); - } - - const params = (ctx.params || []).filter((p) => p.enabled); - const query = params.filter((p) => p.type === 'query'); - const pathParams = params.filter((p) => p.type === 'path'); - if (query.length > 0) { - parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`); - } - if (pathParams.length > 0) { - parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`); - } - - if (ctx.body && ctx.body.mode && ctx.body.mode !== 'none') { - let content = ''; - switch (ctx.body.mode) { - case 'json': content = ctx.body.json || ''; break; - case 'text': content = ctx.body.text || ''; break; - case 'xml': content = ctx.body.xml || ''; break; - case 'sparql': content = ctx.body.sparql || ''; break; - case 'formUrlEncoded': { - const items = (ctx.body.formUrlEncoded || []).filter((p) => p.enabled); - content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n'); - break; - } - case 'multipartForm': { - const items = (ctx.body.multipartForm || []).filter((p) => p.enabled); - content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value)}`).join('\n'); - break; - } - case 'graphql': - content = ctx.body.graphql?.query || ''; - if (ctx.body.graphql?.variables) { - content += `\n\nVariables:\n${ctx.body.graphql.variables}`; - } - break; - default: - content = ''; - } - if (content) { - parts.push(`**Body (${ctx.body.mode}):**\n\`\`\`\n${content}\n\`\`\``); - } - } - - if (ctx.responseStatus) { - parts.push(`**Last Response Status:** ${ctx.responseStatus}`); - } - if (ctx.responseData) { - try { - const parsed = typeof ctx.responseData === 'string' ? JSON.parse(ctx.responseData) : ctx.responseData; - const redacted = redactResponseValues(parsed); - if (redacted != null) { - parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``); - } - } catch { - if (typeof ctx.responseData === 'string' && ctx.responseData.trim()) { - parts.push(`**Response:** non-JSON, ${ctx.responseData.length} chars (call read_response() for a redacted view)`); - } - } - } - - if (ctx.docs && ctx.docs.trim()) { - parts.push(`**Documentation:**\n${ctx.docs.trim()}`); - } - - return parts.join('\n\n'); -}; - -const buildContextMessage = (contentType, allContent, requestContext) => { - const parts = []; - const ctx = formatRequestContext(requestContext); + const ctx = formatRequestContext(requestContext, { includeResponse: true }); if (ctx) { parts.push(`HTTP Request Context:\n${ctx}`); } + const varsStr = formatVariablesList(variables); + if (varsStr) { + parts.push(`Available Variables (names only — call search_variables(query) for a value):\n${varsStr}`); + } + const activeLabel = CONTENT_LABELS[contentType] || 'Code'; const activeContent = allContent[contentType] || ''; if (activeContent.trim()) { @@ -203,6 +69,12 @@ const WRITE_PARAMS = z.object({ content: z.string().describe('The complete new content for the section.') }); const READ_RESPONSE_PARAMS = z.object({}); +const SEARCH_VARS_PARAMS = z.object({ + query: z + .string() + .optional() + .describe('Substring to match against variable names (case-insensitive). Omit to list the first 50 variables.') +}); const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEnabled }) => { ipcMain.on('renderer:ai-chat-stop', (_event, { requestId } = {}) => { @@ -214,7 +86,7 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna }); ipcMain.on('renderer:ai-chat-stream', async (_event, payload) => { - const { messages, allContent, contentType, requestContext, requestId, model: modelId } = payload || {}; + const { messages, allContent, contentType, requestContext, variables, requestId, model: modelId } = payload || {}; const send = (channel, data) => { if (mainWindow?.webContents && !mainWindow.webContents.isDestroyed()) { @@ -310,45 +182,28 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna execute: async () => { const status = requestContext?.responseStatus; const data = requestContext?.responseData; - if (!status && !data) { + if (!status && data == null) { return '(No response available — the request has not been executed yet. The user needs to run the request first.)'; } - - const parts = []; - if (status) parts.push(`Status: ${status}`); - - if (data !== undefined && data !== null) { - // Try to parse JSON so we can redact structurally. Non-JSON - // payloads only get a type/length summary, we won't echo their - // contents either, since they may contain sensitive text. - let parsed = data; - let parsedOk = false; - if (typeof data === 'string') { - try { - parsed = JSON.parse(data); parsedOk = true; - } catch { parsedOk = false; } - } else if (typeof data === 'object') { - parsedOk = true; - } - - if (parsedOk) { - const redacted = redactResponseValues(parsed); - parts.push(`Response Body (redacted shape):\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``); - parts.push(`Note: ${REDACTION_NOTICE}`); - } else if (typeof data === 'string') { - parts.push(`Response Body: non-JSON text payload, ${data.length} chars (contents withheld for user privacy)`); - } else { - parts.push('Response Body: opaque value (contents withheld for user privacy)'); - } + const formatted = formatResponseShape(status, data); + return formatted || '(empty response)'; + } + }, + search_variables: { + description: 'Search environment / collection / global / runtime variables by name (case-insensitive substring match). Use this when the user has many variables or you need to confirm a name before referencing it in code. Values are returned, but variables marked `secret` (or whose names match patterns like `*_token`, `*_secret`, `password`, etc.) come back as ``. Each result has a `scope` field — use it to pick the right runtime accessor: `bru.getEnvVar` for `env`, `bru.getGlobalEnvVar` for `global`, `bru.getCollectionVar` / `bru.getFolderVar` / `bru.getRequestVar` for `collection`, `bru.getVar` for `runtime`, and `bru.getSecretVar` for any value that came back redacted. Never hard-code a returned value.', + inputSchema: SEARCH_VARS_PARAMS, + execute: async ({ query }) => { + if (!Array.isArray(variables) || variables.length === 0) { + return '(No variables available — the collection has no environment, runtime, or collection variables defined.)'; } - - return parts.join('\n') || '(empty response)'; + const result = searchVariables(variables, query); + return formatSearchVariablesResult(result, query); } } }; const allMessages = [ - { role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext) }, + { role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext, variables) }, ...messages.map((m) => ({ role: m.role, content: m.content })) ]; diff --git a/packages/bruno-electron/src/ipc/ai/context.js b/packages/bruno-electron/src/ipc/ai/context.js new file mode 100644 index 000000000..45cbfa636 --- /dev/null +++ b/packages/bruno-electron/src/ipc/ai/context.js @@ -0,0 +1,350 @@ +/** + * Shared context formatting + redaction primitives used by every AI surface + * (chat sidebar, script generation, autocomplete). + * + * Everything that goes to a provider passes through this module so the rules + * (which header names are sensitive, how response bodies are stripped, how a + * variable marked `secret: true` appears) stay consistent across surfaces. + */ + +// --- Sensitive header / param / variable names --------------------------- + +const SENSITIVE_HEADER_PATTERNS = [ + /^authorization$/i, + /^proxy-authorization$/i, + /^cookie$/i, + /^set-cookie$/i, + /^x-api-key$/i, + /^x-auth-token$/i, + /^x-access-token$/i, + /^x-csrf-token$/i, + /api[_-]?key/i, + // Catches refresh_token, id_token, csrfToken, plain TOKEN, etc. on top of + // the specific access/auth-token forms above. + /token/i, + /access[_-]?token/i, + /auth[_-]?token/i, + /secret/i, + /password/i +]; + +const REDACTED_VALUE = ''; + +const isSensitiveName = (name) => { + if (!name) return false; + return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name)); +}; + +const maskValue = (name, value) => (isSensitiveName(name) ? REDACTED_VALUE : value); + +// --- Response body shape redaction --------------------------------------- + +const REDACTED_TRUNCATED = ''; +const REDACTED_NULL = ''; +const REDACTED_BY_TYPE = { + string: '', + number: '', + boolean: '', + bigint: '' +}; + +const REDACTION_NOTICE = 'Values are placeholders (``, ``, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.'; + +const redactResponseValues = (data, depth = 0, maxDepth = 6) => { + if (data === null || data === undefined) return REDACTED_NULL; + if (depth >= maxDepth) return REDACTED_TRUNCATED; + + if (Array.isArray(data)) { + if (data.length === 0) return []; + // Cap sample size — long arrays only need a few items to convey shape. + const sampleSize = Math.min(data.length, 3); + const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth)); + if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`); + return out; + } + + if (typeof data === 'object') { + const keys = Object.keys(data); + const out = {}; + for (const key of keys.slice(0, 30)) { + out[key] = redactResponseValues(data[key], depth + 1, maxDepth); + } + if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`; + return out; + } + + return REDACTED_BY_TYPE[typeof data] || ''; +}; + +const formatResponseShape = (status, data) => { + if (!status && data == null) return ''; + const parts = []; + if (status) parts.push(`**Last Response Status:** ${status}`); + + if (data != null) { + let parsed = data; + let parsedOk = false; + if (typeof data === 'string') { + try { + parsed = JSON.parse(data); parsedOk = true; + } catch { parsedOk = false; } + } else if (typeof data === 'object') { + parsedOk = true; + } + + if (parsedOk) { + const redacted = redactResponseValues(parsed); + if (redacted != null) { + parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``); + } + } else if (typeof data === 'string' && data.trim()) { + parts.push(`**Response:** non-JSON, ${data.length} chars (call read_response() for the redacted view)`); + } + } + + return parts.join('\n\n'); +}; + +/** + * Walk a JSON-shaped value and replace primitive values whose KEY is sensitive + * (`password`, `*_token`, `secret`, etc.) with ``. Keeps the shape + * intact so the model can still see the body's structure and field names. + * + * Differs from `redactResponseValues` (which replaces ALL primitives with + * type placeholders): we DO want the model to see non-sensitive request body + * values so it can write code that references them correctly. + */ +const redactJsonBodyValues = (data, depth = 0, maxDepth = 8) => { + if (data === null || data === undefined) return data; + if (depth >= maxDepth) return REDACTED_TRUNCATED; + if (Array.isArray(data)) return data.map((item) => redactJsonBodyValues(item, depth + 1, maxDepth)); + if (typeof data === 'object') { + const out = {}; + for (const key of Object.keys(data)) { + const val = data[key]; + if (isSensitiveName(key) && val !== null && typeof val !== 'object') { + out[key] = REDACTED_VALUE; + } else { + out[key] = redactJsonBodyValues(val, depth + 1, maxDepth); + } + } + return out; + } + return data; +}; + +const redactJsonBodyString = (raw) => { + if (typeof raw !== 'string' || !raw.trim()) return raw || ''; + try { + const parsed = JSON.parse(raw); + return JSON.stringify(redactJsonBodyValues(parsed), null, 2); + } catch { + // Not parseable JSON — return as-is. The renderer-side patterns + variable + // redaction are the main line of defense for arbitrary text bodies. + return raw; + } +}; + +// --- Request context (method/url/headers/params/body/+response) --------- + +/** + * Format the renderer-supplied requestContext as Markdown for the model. + * + * @param {object} ctx { url, method, headers, params, body, docs, + * responseStatus?, responseData? } + * @param {object} opts + * includeBody - include body (default true) + * bodyMaxChars - truncate body to this many chars (default null = full) + * includeResponse - inline the redacted response shape (default false) + * includeDocs - include the request's docs field (default true) + */ +const formatRequestContext = (ctx, opts = {}) => { + const { + includeBody = true, + bodyMaxChars = null, + includeResponse = false, + includeDocs = true + } = opts; + if (!ctx) return ''; + const parts = []; + + if (ctx.url || ctx.method) { + parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`); + } + + const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name); + if (headers.length) { + parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value ?? '')}`).join('\n')}`); + } + + const params = (ctx.params || []).filter((p) => p?.enabled && p?.name); + const query = params.filter((p) => p.type === 'query' || !p.type); + const pathParams = params.filter((p) => p.type === 'path'); + if (query.length) { + parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`); + } + if (pathParams.length) { + parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`); + } + + const body = ctx.body; + if (includeBody && body && body.mode && body.mode !== 'none') { + let content = ''; + switch (body.mode) { + // JSON bodies often contain fields like `password`, `client_secret`, + // `refresh_token`. Redact by key so the model sees the structure but + // not the secret values. + case 'json': content = redactJsonBodyString(body.json || ''); break; + case 'text': content = body.text || ''; break; + case 'xml': content = body.xml || ''; break; + case 'sparql': content = body.sparql || ''; break; + case 'formUrlEncoded': { + const items = (body.formUrlEncoded || []).filter((p) => p.enabled); + content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n'); + break; + } + case 'multipartForm': { + const items = (body.multipartForm || []).filter((p) => p.enabled); + content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value ?? '')}`).join('\n'); + break; + } + case 'graphql': + content = body.graphql?.query || ''; + if (body.graphql?.variables) { + // GraphQL variables are stored as a JSON string in Bruno — same + // key-based redaction applies. + content += `\n\nVariables:\n${redactJsonBodyString(body.graphql.variables)}`; + } + break; + default: content = ''; + } + if (content) { + const truncate = bodyMaxChars && content.length > bodyMaxChars; + const shown = truncate ? content.slice(0, bodyMaxChars) + '…' : content; + parts.push(`**Body (${body.mode}):**\n\`\`\`\n${shown}\n\`\`\``); + } + } + + if (includeResponse) { + const responseStr = formatResponseShape(ctx.responseStatus, ctx.responseData); + if (responseStr) parts.push(responseStr); + } + + if (includeDocs && ctx.docs && typeof ctx.docs === 'string' && ctx.docs.trim()) { + parts.push(`**Documentation:**\n${ctx.docs.trim()}`); + } + + return parts.join('\n\n'); +}; + +// --- Variables (env / global / collection / folder / request / runtime) - + +/** + * Variable record shape (what the renderer sends over IPC): + * { name: string, value: string | null, scope: string, secret: boolean } + * + * - `scope` is informational ('env', 'global', 'collection', 'folder', + * 'request', 'runtime', 'process', 'oauth2', ...). Used only for the + * short list shown to the model. + * - `secret: true` means the value is omitted from the renderer's payload + * too — never trust the value field on a secret. The backend re-masks. + */ + +const isSecretVariable = (v) => Boolean(v && (v.secret || isSensitiveName(v.name))); + +const variableValueForModel = (v) => { + if (!v) return ''; + if (isSecretVariable(v)) return REDACTED_VALUE; + if (v.value == null) return ''; + return String(v.value); +}; + +const VAR_NAMES_PREVIEW_PER_SCOPE = 25; + +/** + * Inline preview shown in the prompt. Names + a small per-scope sample so + * the model knows what's available without us dumping 500 env vars. + */ +const formatVariablesList = (variables) => { + if (!Array.isArray(variables) || !variables.length) return ''; + + const byScope = new Map(); + for (const v of variables) { + if (!v || !v.name) continue; + const scope = v.scope || 'unknown'; + if (!byScope.has(scope)) byScope.set(scope, []); + byScope.get(scope).push(v); + } + + const lines = []; + for (const [scope, list] of byScope.entries()) { + const total = list.length; + const preview = list.slice(0, VAR_NAMES_PREVIEW_PER_SCOPE); + const more = total > preview.length ? ` (+${total - preview.length} more — use search_variables to find them)` : ''; + const names = preview + .map((v) => (isSecretVariable(v) ? `${v.name} (secret)` : v.name)) + .join(', '); + lines.push(`- ${scope} (${total}): ${names}${more}`); + } + return lines.join('\n'); +}; + +const SEARCH_LIMIT = 50; + +/** + * Returns `{ items, totalMatched, limit }`. `totalMatched` is the number of + * variables that matched the query BEFORE truncation, so the caller can tell + * the model when there are more matches than were returned. + */ +const searchVariables = (variables, rawQuery, limit = SEARCH_LIMIT) => { + if (!Array.isArray(variables) || !variables.length) { + return { items: [], totalMatched: 0, limit }; + } + const query = String(rawQuery || '').toLowerCase().trim(); + const filtered = query + ? variables.filter((v) => v?.name && v.name.toLowerCase().includes(query)) + : variables.slice(); + return { items: filtered.slice(0, limit), totalMatched: filtered.length, limit }; +}; + +const formatVariableLine = (v) => { + const value = variableValueForModel(v); + const tags = [v.scope || 'unknown']; + if (isSecretVariable(v)) tags.push('secret'); + return ` ${v.name} = ${value} [${tags.join(', ')}]`; +}; + +const formatSearchVariablesResult = ({ items, totalMatched, limit }, query) => { + if (!items.length) { + return query + ? `No variables match "${query}".` + : 'No variables defined for this collection/environment.'; + } + const lines = items.map(formatVariableLine); + const heading = query + ? `Found ${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''} variable(s) matching "${query}":` + : `Variables (${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''}):`; + const trailer = totalMatched > items.length + ? `\n\n(${totalMatched - items.length} more match — narrow the query to see them.)` + : ''; + return `${heading}\n${lines.join('\n')}${trailer}`; +}; + +module.exports = { + // patterns + helpers + SENSITIVE_HEADER_PATTERNS, + REDACTED_VALUE, + REDACTION_NOTICE, + isSensitiveName, + maskValue, + // response shape + redactResponseValues, + formatResponseShape, + // request context + formatRequestContext, + // variables + isSecretVariable, + formatVariablesList, + searchVariables, + formatSearchVariablesResult +}; diff --git a/packages/bruno-electron/src/ipc/ai/context.spec.js b/packages/bruno-electron/src/ipc/ai/context.spec.js new file mode 100644 index 000000000..a0b419be2 --- /dev/null +++ b/packages/bruno-electron/src/ipc/ai/context.spec.js @@ -0,0 +1,243 @@ +const { + isSensitiveName, + maskValue, + redactResponseValues, + formatResponseShape, + formatRequestContext, + formatVariablesList, + searchVariables, + formatSearchVariablesResult +} = require('./context'); + +describe('ipc/ai/context', () => { + describe('isSensitiveName', () => { + it.each([ + ['Authorization'], + ['Cookie'], + ['X-API-Key'], + ['api_key'], + ['accessToken'], + ['refresh_token'], + ['id_token'], + ['csrfToken'], + ['TOKEN'], + ['client_secret'], + ['password'] + ])('flags %s as sensitive', (name) => { + expect(isSensitiveName(name)).toBe(true); + }); + + it.each([ + ['X-Trace-Id'], + ['Content-Type'], + ['User-Agent'], + ['email'] + ])('does not flag %s', (name) => { + expect(isSensitiveName(name)).toBe(false); + }); + }); + + describe('maskValue', () => { + it('redacts the value when the name is sensitive', () => { + expect(maskValue('Authorization', 'Bearer abc')).toBe(''); + }); + it('passes the value through when the name is not sensitive', () => { + expect(maskValue('X-Trace-Id', '123')).toBe('123'); + }); + }); + + describe('redactResponseValues', () => { + it('replaces primitives with type placeholders, preserving keys', () => { + expect(redactResponseValues({ id: 1, name: 'a', active: true })).toEqual({ + id: '', + name: '', + active: '' + }); + }); + + it('samples long arrays and reports the rest', () => { + const out = redactResponseValues([1, 2, 3, 4, 5]); + expect(out).toEqual(['', '', '', '<2 more items>']); + }); + + it('caps deep nesting with a placeholder', () => { + // 8 levels deep — exceeds the default maxDepth of 6. + const deep = { a: { b: { c: { d: { e: { f: { g: { h: 'leaf' } } } } } } } }; + const out = redactResponseValues(deep); + expect(JSON.stringify(out)).toContain(''); + }); + }); + + describe('formatResponseShape', () => { + it('returns an empty string when neither status nor data is present', () => { + expect(formatResponseShape(null, null)).toBe(''); + }); + + it('parses a JSON string body and emits a redacted shape block', () => { + const out = formatResponseShape(200, JSON.stringify({ user: { id: 1, email: 'a@b' } })); + expect(out).toContain('**Last Response Status:** 200'); + expect(out).toContain('"id": ""'); + expect(out).toContain('"email": ""'); + // Real values must not leak. + expect(out).not.toContain('a@b'); + }); + + it('summarizes non-JSON string bodies without echoing them', () => { + const out = formatResponseShape(200, 'plain text body'); + expect(out).toContain('non-JSON'); + expect(out).not.toContain('plain text body'); + }); + }); + + describe('formatRequestContext', () => { + it('masks sensitive header / param values', () => { + const out = formatRequestContext({ + method: 'GET', + url: '/x', + headers: [ + { name: 'Authorization', value: 'Bearer xyz', enabled: true }, + { name: 'X-Trace-Id', value: '123', enabled: true } + ], + params: [{ name: 'api_key', value: 'secret-key', enabled: true, type: 'query' }], + body: null + }); + expect(out).toContain('Authorization: '); + expect(out).toContain('X-Trace-Id: 123'); + expect(out).toContain('api_key: '); + expect(out).not.toContain('Bearer xyz'); + expect(out).not.toContain('secret-key'); + }); + + it('redacts sensitive keys inside JSON bodies but keeps the shape', () => { + const out = formatRequestContext({ + method: 'POST', + url: '/login', + headers: [], + params: [], + body: { + mode: 'json', + json: JSON.stringify({ + username: 'alice', + password: 'hunter2', + nested: { refresh_token: 'tok', safe: 'ok' } + }) + } + }); + expect(out).toContain('"username": "alice"'); + expect(out).toContain('"password": ""'); + expect(out).toContain('"refresh_token": ""'); + expect(out).toContain('"safe": "ok"'); + expect(out).not.toContain('hunter2'); + expect(out).not.toContain('"tok"'); + }); + + it('redacts sensitive keys inside GraphQL variables JSON', () => { + const out = formatRequestContext({ + method: 'POST', + url: '/g', + headers: [], + params: [], + body: { + mode: 'graphql', + graphql: { query: 'mutation X', variables: '{"token": "abc", "id": 1}' } + } + }); + expect(out).toContain('"token": ""'); + expect(out).toContain('"id": 1'); + expect(out).not.toContain('"abc"'); + }); + + it('includes the response shape only when opts.includeResponse is true', () => { + const base = { + method: 'GET', + url: '/x', + headers: [], + params: [], + body: null, + responseStatus: 200, + responseData: { id: 1 } + }; + expect(formatRequestContext(base)).not.toContain('Response Shape'); + expect(formatRequestContext(base, { includeResponse: true })).toContain('Response Shape'); + }); + + it('truncates the body when bodyMaxChars is set', () => { + const long = 'x'.repeat(1000); + const out = formatRequestContext( + { method: 'GET', url: '/x', headers: [], params: [], body: { mode: 'text', text: long } }, + { bodyMaxChars: 50 } + ); + // The shown body should be 50 chars plus the ellipsis marker. + expect(out).toContain('…'); + expect(out).not.toContain('x'.repeat(60)); + }); + }); + + describe('formatVariablesList', () => { + it('groups by scope and tags secret entries', () => { + const out = formatVariablesList([ + { name: 'API_URL', value: 'u', scope: 'env', secret: false }, + { name: 'API_TOKEN', value: '', scope: 'env', secret: true }, + { name: 'runtimeKey', value: 'r', scope: 'runtime', secret: false } + ]); + expect(out).toContain('env (2)'); + expect(out).toContain('API_TOKEN (secret)'); + expect(out).toContain('runtime (1)'); + expect(out).toContain('runtimeKey'); + }); + + it('returns an empty string for no variables', () => { + expect(formatVariablesList([])).toBe(''); + expect(formatVariablesList(null)).toBe(''); + }); + }); + + describe('searchVariables / formatSearchVariablesResult', () => { + const vars = [ + { name: 'API_URL', value: 'https://x', scope: 'env', secret: false }, + { name: 'API_TOKEN', value: '', scope: 'env', secret: true }, + { name: 'runtimeKey', value: 'r1', scope: 'runtime', secret: false } + ]; + + it('returns case-insensitive substring matches with a totalMatched count', () => { + const r = searchVariables(vars, 'api'); + expect(r.items.map((v) => v.name)).toEqual(['API_URL', 'API_TOKEN']); + expect(r.totalMatched).toBe(2); + }); + + it('returns all entries (up to the limit) for an empty query', () => { + const r = searchVariables(vars, ''); + expect(r.items).toHaveLength(3); + expect(r.totalMatched).toBe(3); + }); + + it('truncates to the limit and reports the surplus in totalMatched', () => { + const many = Array.from({ length: 60 }, (_, i) => ({ + name: 'token_' + i, value: 'v' + i, scope: 'env', secret: false + })); + const r = searchVariables(many, 'token', 50); + expect(r.items).toHaveLength(50); + expect(r.totalMatched).toBe(60); + }); + + it('formats matches with scope + secret tags', () => { + const out = formatSearchVariablesResult(searchVariables(vars, 'api'), 'api'); + expect(out).toContain('API_URL = https://x [env]'); + expect(out).toContain('API_TOKEN = [env, secret]'); + }); + + it('says "no matches" when nothing matched the query', () => { + expect(formatSearchVariablesResult(searchVariables(vars, 'zzz'), 'zzz')) + .toBe('No variables match "zzz".'); + }); + + it('includes a trailer when limit was hit', () => { + const many = Array.from({ length: 60 }, (_, i) => ({ + name: 'token_' + i, value: 'v' + i, scope: 'env', secret: false + })); + const out = formatSearchVariablesResult(searchVariables(many, 'token', 50), 'token'); + expect(out).toContain('Found 50 of 60'); + expect(out).toContain('(10 more match'); + }); + }); +}); diff --git a/packages/bruno-electron/src/ipc/ai/index.js b/packages/bruno-electron/src/ipc/ai/index.js index 78216dd7a..25cc08f46 100644 --- a/packages/bruno-electron/src/ipc/ai/index.js +++ b/packages/bruno-electron/src/ipc/ai/index.js @@ -1,5 +1,6 @@ const { ipcMain } = require('electron'); -const { generateText, streamText } = require('ai'); +const { generateText, streamText, stepCountIs } = require('ai'); +const { z } = require('zod'); const { getPreferences } = require('../../store/preferences'); const { aiKeyStore } = require('../../store/ai-keys'); const { @@ -13,7 +14,17 @@ const { validateApiKeyForProvider, providerLabel } = require('./providers'); -const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts'); +const { + SCRIPT_TYPES, + buildScriptSystemPrompt, + buildScriptUserPrompt, + stripCodeFences +} = require('./script-prompts'); +const { + formatResponseShape, + searchVariables, + formatSearchVariablesResult +} = require('./context'); const registerChatIpc = require('./chat'); const activeStreams = new Map(); @@ -150,7 +161,15 @@ const registerAiIpc = (mainWindow) => { }); ipcMain.handle('renderer:ai-generate-script', async (_event, params) => { - const { scriptType, prompt, currentScript, requestContext, docsContext, model: requestedModel } = params || {}; + const { + scriptType, + prompt, + currentScript, + requestContext, + docsContext, + variables, + model: requestedModel + } = params || {}; if (!SCRIPT_TYPES.includes(scriptType)) { return { error: `Unknown scriptType: ${scriptType}` }; @@ -171,14 +190,74 @@ const registerAiIpc = (mainWindow) => { return { error: err.message }; } + // Generation runs through streamText so the model can call tools + // (read_response, search_variables) when the inline context isn't enough. + // We don't stream tokens back to the renderer — the sparkle UI shows a + // spinner and applies the final code as a single blob — but the + // step-based loop gives the model a chance to gather context first. + const tools = { + read_response: { + description: 'Returns the redacted shape (keys + value types) of the last response for this request. Use it before writing tests/post-response code to learn property paths and types. Real values are stripped — reference fields at runtime, don\'t hard-code the placeholder strings.', + inputSchema: z.object({}), + execute: async () => { + const status = requestContext?.responseStatus; + const data = requestContext?.responseData; + if (!status && data == null) { + return '(No response available — the request has not been executed yet.)'; + } + return formatResponseShape(status, data) || '(empty response)'; + } + }, + search_variables: { + description: 'Search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query to confirm a name before referencing it. Secret variables come back as ``. Each result has a `scope` field — use it to pick the right runtime accessor: `bru.getEnvVar` for `env`, `bru.getGlobalEnvVar` for `global`, `bru.getCollectionVar` / `bru.getFolderVar` / `bru.getRequestVar` for `collection`, `bru.getVar` for `runtime`, and `bru.getSecretVar` for any value that came back redacted. Never hard-code a returned value.', + inputSchema: z.object({ + query: z + .string() + .optional() + .describe('Substring to match against variable names. Omit to list the first 50.') + }), + execute: async ({ query }) => { + if (!Array.isArray(variables) || variables.length === 0) { + return '(No variables available — the collection has no environment, runtime, or collection variables defined.)'; + } + const result = searchVariables(variables, query); + return formatSearchVariablesResult(result, query); + } + } + }; + try { - const { text } = await generateText({ + const result = streamText({ model, - system: SCRIPT_PROMPTS[scriptType], - prompt: buildScriptUserPrompt({ userPrompt: prompt, currentScript, requestContext, docsContext, scriptType }), + system: buildScriptSystemPrompt(scriptType), + prompt: buildScriptUserPrompt({ + userPrompt: prompt, + currentScript, + requestContext, + docsContext, + variables, + scriptType + }), + tools, + // Cap tool-call iteration — the model gets a few chances to look + // things up before it MUST produce the final script. + stopWhen: stepCountIs(4), + toolChoice: 'auto', maxOutputTokens: 2048 }); - return { content: stripCodeFences(text), modelId }; + + let fullText = ''; + for await (const part of result.fullStream) { + if (part.type === 'text-delta') { + fullText += part.text; + } + } + + const content = stripCodeFences(fullText); + if (!content || !content.trim()) { + return { error: 'No content was generated. Try rephrasing your prompt.' }; + } + return { content, modelId }; } catch (err) { console.error('AI generate-script error:', err); return { error: err.message || 'Failed to generate script' }; diff --git a/packages/bruno-electron/src/ipc/ai/script-prompts.js b/packages/bruno-electron/src/ipc/ai/script-prompts.js index 7b9a5cf9f..fdeba23e1 100644 --- a/packages/bruno-electron/src/ipc/ai/script-prompts.js +++ b/packages/bruno-electron/src/ipc/ai/script-prompts.js @@ -4,6 +4,7 @@ const BRUNO_API_REFERENCE = `## Bruno API Reference \`\`\`js bru.getEnvVar(key) bru.setEnvVar(key, value) +bru.setEnvVar(key, value, { persist: true }) bru.hasEnvVar(key) bru.deleteEnvVar(key) bru.getEnvName() @@ -290,43 +291,31 @@ const formatDocsContext = (ctx) => { return parts.join('\n\n'); }; -const formatRequestContext = (ctx) => { - if (!ctx) return ''; - const parts = []; +const { formatRequestContext, formatVariablesList } = require('./context'); - if (ctx.url || ctx.method) { - parts.push(`Request: ${ctx.method || 'GET'} ${ctx.url || ''}`); - } - - const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name); - if (headers.length) { - parts.push(`Headers:\n${headers.map((h) => ` ${h.name}: ${h.value ?? ''}`).join('\n')}`); - } - - const params = (ctx.params || []).filter((p) => p?.enabled && p?.name); - if (params.length) { - parts.push(`Params:\n${params.map((p) => ` ${p.name}: ${p.value ?? ''}`).join('\n')}`); - } - - const body = ctx.body; - if (body && body.mode && body.mode !== 'none') { - let bodyText = ''; - if (body.mode === 'json') bodyText = body.json || ''; - else if (body.mode === 'text') bodyText = body.text || ''; - else if (body.mode === 'xml') bodyText = body.xml || ''; - else if (body.mode === 'graphql') bodyText = body.graphql?.query || ''; - if (bodyText) parts.push(`Body (${body.mode}):\n${bodyText.slice(0, 2000)}`); - } - - return parts.join('\n\n'); -}; - -const buildScriptUserPrompt = ({ userPrompt, currentScript, requestContext, docsContext, scriptType }) => { +const buildScriptUserPrompt = ({ + userPrompt, + currentScript, + requestContext, + docsContext, + variables, + scriptType +}) => { const sections = []; const docsContextStr = formatDocsContext(docsContext); if (docsContextStr) sections.push(`Documentation Context\n${docsContextStr}`); - const contextStr = formatRequestContext(requestContext); + + // Same redaction rules as the chat sidebar — sensitive headers/params masked, + // response shape only (no real values). Body is sent in full so the model + // can write code that references real keys. + const contextStr = formatRequestContext(requestContext, { includeResponse: true }); if (contextStr) sections.push(`HTTP Request Context\n${contextStr}`); + + const varsStr = formatVariablesList(variables); + if (varsStr) { + sections.push(`Available Variables (names only — call search_variables(query) for a value)\n${varsStr}`); + } + if (currentScript && currentScript.trim()) { let existingLabel = 'Existing Code'; let fenceLang = 'js'; @@ -355,9 +344,29 @@ const stripCodeFences = (text) => { return out.replace(/^\n+/, ''); }; +// Tool instructions appended to every script system prompt so the model knows +// it can call `read_response` and `search_variables` instead of relying on a +// possibly-truncated inline summary. Generation does NOT have write tools — +// the model still returns the final script as its assistant text. +const TOOL_INSTRUCTIONS = `## Available Tools + +You may call these tools BEFORE producing the final code to gather context. Do not announce the tool calls in your final output — only the generated code goes back to the user. + +- read_response(): returns the redacted shape (keys + value types) of the most recent response for this request. Use it when writing tests / post-response scripts that need to know which fields exist. Values are placeholders (\`\`, \`\`, …) — never hard-code them; reference fields at runtime via \`res.getBody()\` / \`res('path')\`. +- search_variables(query?): search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query to confirm a name exists before referencing it in code. Variables marked \`secret\` come back as \`\`. Each result has a \`scope\` field — use it to pick the right runtime accessor: \`bru.getEnvVar\` for \`env\`, \`bru.getGlobalEnvVar\` for \`global\`, \`bru.getCollectionVar\` / \`bru.getFolderVar\` / \`bru.getRequestVar\` for \`collection\`, \`bru.getVar\` for \`runtime\`, and \`bru.getSecretVar\` for any value that came back redacted. Never paste a returned value. + +Only call a tool when the extra information would change the code you write. For greetings, simple boilerplate, or tasks fully covered by the inline context, skip the tools.`; + +const buildScriptSystemPrompt = (scriptType) => { + const base = SCRIPT_PROMPTS[scriptType]; + if (!base) return SCRIPT_PROMPTS.tests; // sensible fallback + return `${base}\n\n${TOOL_INSTRUCTIONS}`; +}; + module.exports = { SCRIPT_PROMPTS, SCRIPT_TYPES, + buildScriptSystemPrompt, buildScriptUserPrompt, formatDocsContext, stripCodeFences