feat(ai-assist): pure prompt builder + result applier

buildAssistPrompt(action, selection) returns the {system, messages}
payload for rewrite/shorten/expand. Enforces 8KB selection cap and
typed errors with codes (unknown_action, bad_selection,
empty_selection, selection_too_large) so the IPC layer can map them
to user-friendly messages.

applyAssistResult(original, newText) trims the response and rejects
empty/unchanged payloads so a no-op streaming chunk doesn't churn
the editor.

15 new tests cover action coverage, prompt content, all four error
codes, cap-edge (exactly 8KB OK, 8KB+1 rejected), and trim semantics.

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 20:01:54 +05:30
parent b70dce9574
commit cb0b2eac3d
2 changed files with 235 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* Inline AI assist — prompt builder + result applier.
*
* Pure module. The actual network call goes through AiProviders.js; this
* module shapes the request (system + user messages) and validates the
* response before it reaches the editor.
*
* Actions (v4.13.0): rewrite / shorten / expand.
*
* Safety: MAX_SELECTION_CHARS caps the selection so a runaway or hostile
* caller can't push an 8KB+ blob at an API. The cap is enforced BEFORE
* the IPC hop so the main process sees only safe payloads.
*
* @module inline-assist
*/
const MAX_SELECTION_CHARS = 8 * 1024;
const ACTIONS = {
rewrite: {
label: 'Rewrite',
systemPrompt:
'You are a precise writing assistant. Rewrite the selected text to improve clarity, ' +
'flow, and word choice while preserving the original meaning, tone, and length. ' +
'Return ONLY the rewritten text — no preamble, no explanation, no surrounding quotes.',
},
shorten: {
label: 'Shorten',
systemPrompt:
'You are a precise writing assistant. Shorten the selected text while preserving all ' +
'key information. Aim for 30–50% reduction in length. Drop filler, redundancy, and ' +
'verbose phrasing. Return ONLY the shortened text — no preamble, no explanation.',
},
expand: {
label: 'Expand',
systemPrompt:
'You are a precise writing assistant. Expand the selected text by adding relevant ' +
'detail, examples, or explanation. Aim for 50–100% expansion. Stay on-topic. ' +
'Return ONLY the expanded text — no preamble, no explanation.',
},
};
const ACTIONS_LIST = Object.keys(ACTIONS);
/**
* Build the {system, messages} payload for `action`.
*
* @param {'rewrite'|'shorten'|'expand'} action
* @param {string} selection
* @returns {{system:string, messages:Array<{role:'user', content:string}>}}
* @throws when action is unknown or selection is empty / too large
*/
function buildAssistPrompt(action, selection) {
const def = ACTIONS[action];
if (!def) {
const err = new Error(`Unknown AI assist action: ${action}`);
err.code = 'unknown_action';
throw err;
}
if (typeof selection !== 'string') {
const err = new Error('Selection must be a string');
err.code = 'bad_selection';
throw err;
}
if (selection.length === 0) {
const err = new Error('Selection is empty');
err.code = 'empty_selection';
throw err;
}
if (selection.length > MAX_SELECTION_CHARS) {
const err = new Error(
`Selection is too large (${selection.length} chars; max ${MAX_SELECTION_CHARS})`
);
err.code = 'selection_too_large';
throw err;
}
return {
system: def.systemPrompt,
messages: [{ role: 'user', content: selection }],
};
}
/**
* Validate the provider's response before applying it to the editor.
*
* - Empty / whitespace-only → return null (don't replace selection with nothing)
* - Unchanged → return null (no-op, don't churn the editor)
*
* @param {string} originalText - The original selection
* @param {string} newText - The provider's response
* @returns {string|null} text to apply, or null to skip
*/
function applyAssistResult(originalText, newText) {
if (typeof newText !== 'string') return null;
const trimmed = newText.trim();
if (trimmed.length === 0) return null;
if (trimmed === String(originalText || '').trim()) return null;
return trimmed;
}
module.exports = {
buildAssistPrompt,
applyAssistResult,
ACTIONS,
ACTIONS_LIST,
MAX_SELECTION_CHARS,
};
+128
View File
@@ -0,0 +1,128 @@
/**
* @jest-environment node
*
* Inline AI assist pure module.
*/
const {
buildAssistPrompt,
applyAssistResult,
ACTIONS,
ACTIONS_LIST,
MAX_SELECTION_CHARS,
} = require('../src/ai-assist/inline-assist');
describe('ACTIONS', () => {
test('the three actions are exposed', () => {
expect(ACTIONS_LIST).toEqual(['rewrite', 'shorten', 'expand']);
expect(ACTIONS.rewrite.label).toBe('Rewrite');
expect(ACTIONS.shorten.label).toBe('Shorten');
expect(ACTIONS.expand.label).toBe('Expand');
});
test('every action has a non-empty system prompt', () => {
for (const a of ACTIONS_LIST) {
expect(typeof ACTIONS[a].systemPrompt).toBe('string');
expect(ACTIONS[a].systemPrompt.length).toBeGreaterThan(20);
}
});
});
describe('buildAssistPrompt', () => {
test('returns {system, messages} for each action', () => {
for (const action of ACTIONS_LIST) {
const out = buildAssistPrompt(action, 'hello world');
expect(out.system).toBe(ACTIONS[action].systemPrompt);
expect(out.messages).toHaveLength(1);
expect(out.messages[0].role).toBe('user');
expect(out.messages[0].content).toBe('hello world');
}
});
test('preserves the exact selection content', () => {
const selection = 'A\nmultiline\nselection with unicode éàü ✓ and trailing spaces ';
const out = buildAssistPrompt('rewrite', selection);
expect(out.messages[0].content).toBe(selection);
});
test('throws on unknown action with unknown_action code', () => {
try {
buildAssistPrompt('polish', 'hi');
throw new Error('expected to throw');
} catch (err) {
expect(err.code).toBe('unknown_action');
}
});
test('throws on non-string selection with bad_selection code', () => {
try {
buildAssistPrompt('rewrite', null);
throw new Error('expected to throw');
} catch (err) {
expect(err.code).toBe('bad_selection');
}
try {
buildAssistPrompt('rewrite', 42);
throw new Error('expected to throw');
} catch (err) {
expect(err.code).toBe('bad_selection');
}
});
test('throws on empty selection with empty_selection code', () => {
try {
buildAssistPrompt('rewrite', '');
throw new Error('expected to throw');
} catch (err) {
expect(err.code).toBe('empty_selection');
}
});
test('throws on oversized selection with selection_too_large code', () => {
const huge = 'a'.repeat(MAX_SELECTION_CHARS + 1);
try {
buildAssistPrompt('rewrite', huge);
throw new Error('expected to throw');
} catch (err) {
expect(err.code).toBe('selection_too_large');
}
});
test('accepts a selection at exactly the cap', () => {
const exact = 'a'.repeat(MAX_SELECTION_CHARS);
expect(() => buildAssistPrompt('rewrite', exact)).not.toThrow();
});
test('MAX_SELECTION_CHARS is 8KB', () => {
expect(MAX_SELECTION_CHARS).toBe(8 * 1024);
});
});
describe('applyAssistResult', () => {
test('returns the trimmed new text when different', () => {
expect(applyAssistResult('hello', 'HELLO')).toBe('HELLO');
expect(applyAssistResult('hello', ' goodbye ')).toBe('goodbye');
});
test('returns null when newText is unchanged from original (after trim)', () => {
expect(applyAssistResult('hello', 'hello')).toBeNull();
expect(applyAssistResult('hello', ' hello ')).toBeNull();
});
test('returns null when newText is empty or whitespace-only', () => {
expect(applyAssistResult('hello', '')).toBeNull();
expect(applyAssistResult('hello', ' ')).toBeNull();
expect(applyAssistResult('hello', '\n\t ')).toBeNull();
});
test('returns null when newText is not a string', () => {
expect(applyAssistResult('hello', null)).toBeNull();
expect(applyAssistResult('hello', undefined)).toBeNull();
expect(applyAssistResult('hello', 42)).toBeNull();
});
test('trims surrounding whitespace before comparing/applying', () => {
// Even if the LLM adds stray newlines, we strip them before writing
expect(applyAssistResult('x', '\n\nresult\n\n')).toBe('result');
});
});