feat(ai): Improve ai context (#8404)

This commit is contained in:
naman-bruno
2026-06-30 14:56:04 +05:30
committed by GitHub
parent 49b9af1046
commit c01e4a0ee2
22 changed files with 1170 additions and 246 deletions

View File

@@ -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(() => {

View File

@@ -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) {

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
) : (
<div className="pl-1">

View File

@@ -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 (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -144,6 +147,7 @@ const Script = ({ collection }) => {
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
@@ -170,6 +174,7 @@ const Script = ({ collection }) => {
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -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 (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
@@ -56,7 +59,7 @@ const Tests = ({ collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
</div>
<div className="mt-6">

View File

@@ -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}
/>
</div>

View File

@@ -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}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
<div className="mt-6 flex-shrink-0">
<Button type="submit" size="sm" onClick={onSave}>

View File

@@ -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 { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -102,6 +103,8 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
@@ -149,6 +152,7 @@ const Script = ({ collection, folder }) => {
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
@@ -175,6 +179,7 @@ const Script = ({ collection, folder }) => {
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -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 (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
@@ -57,7 +60,7 @@ const Tests = ({ collection, folder }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
</div>
<div className="mt-6">

View File

@@ -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 (
<StyledWrapper className="w-full h-full flex flex-col">
@@ -55,6 +58,7 @@ const AppCodeEditor = ({ item, collection }) => {
scriptType="app-request"
currentScript={code || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>

View File

@@ -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}
/>
</div>
@@ -175,6 +179,7 @@ const Script = ({ item, collection }) => {
scriptType="post-response"
currentScript={responseScript || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -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 (
<div data-testid="test-script-editor" className="relative h-full">
@@ -60,7 +63,7 @@ const Tests = ({ item, collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} variables={aiVariables} onApply={onEdit} />
</div>
);
};

View File

@@ -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
});

View File

@@ -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 `<redacted>` 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 = '<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 = [];

View File

@@ -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: '<redacted>', 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: '<redacted>', 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: '<redacted>', 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('<redacted>');
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('<redacted>');
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('<redacted>');
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: [] });
});
});
});

View File

@@ -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) => {

View File

@@ -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 = '<truncated>';
const REDACTED_NULL = '<null>';
const REDACTED_BY_TYPE = {
string: '<string>',
number: '<number>',
boolean: '<boolean>',
bigint: '<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] || '<unknown>';
};
const REDACTION_NOTICE
= 'Values are placeholders (`<string>`, `<number>`, …). 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) ? '<redacted>' : 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 `<redacted>`. 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 }))
];

View File

@@ -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 = '<redacted>';
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 = '<truncated>';
const REDACTED_NULL = '<null>';
const REDACTED_BY_TYPE = {
string: '<string>',
number: '<number>',
boolean: '<boolean>',
bigint: '<bigint>'
};
const REDACTION_NOTICE = 'Values are placeholders (`<string>`, `<number>`, …). 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] || '<unknown>';
};
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 `<redacted>`. 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
};

View File

@@ -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('<redacted>');
});
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: '<number>',
name: '<string>',
active: '<boolean>'
});
});
it('samples long arrays and reports the rest', () => {
const out = redactResponseValues([1, 2, 3, 4, 5]);
expect(out).toEqual(['<number>', '<number>', '<number>', '<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('<truncated>');
});
});
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": "<number>"');
expect(out).toContain('"email": "<string>"');
// 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: <redacted>');
expect(out).toContain('X-Trace-Id: 123');
expect(out).toContain('api_key: <redacted>');
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": "<redacted>"');
expect(out).toContain('"refresh_token": "<redacted>"');
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": "<redacted>"');
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: '<redacted>', 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: '<redacted>', 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 = <redacted> [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');
});
});
});

View File

@@ -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 `<redacted>`. 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' };

View File

@@ -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 (\`<string>\`, \`<number>\`, …) — 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 \`<redacted>\`. 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