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