From cb0b2eac3dd9a1a88913795125304981268713ba Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 30 Sep 2026 20:01:54 +0530 Subject: [PATCH] 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 --- src/ai-assist/inline-assist.js | 107 +++++++++++++++++++++++++++ tests/inline-assist.test.js | 128 +++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/ai-assist/inline-assist.js create mode 100644 tests/inline-assist.test.js diff --git a/src/ai-assist/inline-assist.js b/src/ai-assist/inline-assist.js new file mode 100644 index 0000000..9890393 --- /dev/null +++ b/src/ai-assist/inline-assist.js @@ -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, +}; diff --git a/tests/inline-assist.test.js b/tests/inline-assist.test.js new file mode 100644 index 0000000..e994769 --- /dev/null +++ b/tests/inline-assist.test.js @@ -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'); + }); +});