diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 812fb028..69d7bbb1 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -95,6 +95,17 @@ Log `info.detail` (provider text) and `info.code`, not the sentence shown on screen. A successful-but-empty generation is also a visible outcome (`tone="info"` notice), never silence. +**The system prompt goes in `instructions`, never in `messages`.** Since ai@7, a +`role: 'system'` message inside `messages` fails validation before the request +leaves the browser — `InvalidPromptError: System messages are not allowed in the +prompt or messages fields. Use the instructions option instead.` — which reaches +the writer as a generation error on every request. Pass the system prompt as the +`instructions` option (a string) alongside `messages`; the SDK puts it back at +the head of the prompt itself. This bites hardest where a page keeps a +conversation in state: the chat transcript (`pages/chat`) holds only the +doc-context, greeting, user, and assistant messages, with `CHAT_INSTRUCTIONS` +kept outside it. + ### Testing Two runners own two disjoint directories — never mix them: diff --git a/frontend/src/api/__tests__/prompts.test.ts b/frontend/src/api/__tests__/prompts.test.ts new file mode 100644 index 00000000..e188f63a --- /dev/null +++ b/frontend/src/api/__tests__/prompts.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMessages, DRAFT_INSTRUCTIONS } from '../prompts'; + +const docContext: DocContext = { + beforeCursor: 'The opening paragraph. ', + selectedText: '', + afterCursor: 'The closing paragraph.', +}; + +describe('buildMessages', () => { + // ai@7 throws InvalidPromptError ("System messages are not allowed in the + // prompt or messages fields") when a system message reaches `messages`. The + // draft system prompt is DRAFT_INSTRUCTIONS, passed as `instructions`. + it('returns user messages only', () => { + const messages = buildMessages('example_sentences', docContext); + + expect(messages.length).toBeGreaterThan(0); + expect(messages.every((m) => m.role === 'user')).toBe(true); + expect(DRAFT_INSTRUCTIONS).toContain('writing assistant'); + }); + + it('includes the prompt, the brief, and the document', () => { + const [message] = buildMessages( + 'proposal_advice', + docContext, + 'Audience: my thesis committee', + ); + + expect(message.content).toContain('You are assisting a writer'); + expect(message.content).toContain('Audience: my thesis committee'); + expect(message.content).toContain('The opening paragraph.'); + expect(message.content).toContain('The closing paragraph.'); + }); + + it('describes the selection when there is one', () => { + const [message] = buildMessages('example_rewording', { + beforeCursor: 'Before ', + selectedText: 'the selected bit', + afterCursor: ' after', + }); + + expect(message.content).toContain('## Current Selection'); + expect(message.content).toContain('the selected bit'); + }); +}); diff --git a/frontend/src/api/prompts.ts b/frontend/src/api/prompts.ts index 85492222..64f18605 100644 --- a/frontend/src/api/prompts.ts +++ b/frontend/src/api/prompts.ts @@ -60,6 +60,13 @@ Guidelines: `, }; +// The system prompt for every Draft suggestion. It is passed as the +// `instructions` option on the generation call, never as a system message in +// `messages`: ai@7 rejects a system-role message there with "System messages +// are not allowed in the prompt or messages fields." +export const DRAFT_INSTRUCTIONS = + 'You are a helpful and insightful writing assistant.'; + // Builds the messages array that gets sent to OpenAI. // Previously this was done in the backend (nlp.py). Now the frontend does it, // so the backend only needs to forward the messages to OpenAI. @@ -119,11 +126,7 @@ export function buildMessages( '\n\nFormat your response as a plain bulleted list, one item per line starting with "- ". Do not use JSON or array notation.'; } - return [ - { - role: 'system' as const, - content: 'You are a helpful and insightful writing assistant.', - }, - { role: 'user' as const, content: userContent }, - ]; + // User messages only — the system prompt travels separately as + // DRAFT_INSTRUCTIONS. + return [{ role: 'user' as const, content: userContent }]; } diff --git a/frontend/src/pages/chat/__tests__/docContextMessage.test.ts b/frontend/src/pages/chat/__tests__/docContextMessage.test.ts index 0d229d54..e4fbdb54 100644 --- a/frontend/src/pages/chat/__tests__/docContextMessage.test.ts +++ b/frontend/src/pages/chat/__tests__/docContextMessage.test.ts @@ -35,21 +35,19 @@ describe('chat document-context message', () => { afterCursor: 'b', }; - it('seeds system + doc-context + greeting when the chat is empty', () => { + it('seeds doc-context + greeting when the chat is empty', () => { const result = withCurrentDocContext([], docContext); - expect(result).toHaveLength(3); - expect(result[0].role).toBe('system'); - expect(result[1].role).toBe('user'); - expect(result[1].content).toBe( + expect(result).toHaveLength(2); + expect(result[0].role).toBe('user'); + expect(result[0].content).toBe( docContextMessageContent(docContext), ); - expect(result[2].role).toBe('assistant'); + expect(result[1].role).toBe('assistant'); }); - it('replaces only the doc-context message (index 1) on an existing chat', () => { + it('replaces only the doc-context message (index 0) on an existing chat', () => { const existing: ChatMessage[] = [ - { role: 'system', content: 'sys' }, { role: 'user', content: 'STALE CONTEXT' }, { role: 'assistant', content: 'greeting' }, { role: 'user', content: 'a real question' }, @@ -57,19 +55,32 @@ describe('chat document-context message', () => { const result = withCurrentDocContext(existing, docContext); - // Fresh context injected at index 1... - expect(result[1].content).toBe( + // Fresh context injected at index 0... + expect(result[0].content).toBe( docContextMessageContent(docContext), ); // ...without disturbing the rest of the conversation. - expect(result[0]).toEqual(existing[0]); + expect(result[1]).toEqual(existing[1]); expect(result[2]).toEqual(existing[2]); - expect(result[3]).toEqual(existing[3]); + }); + + // ai@7 throws InvalidPromptError ("System messages are not allowed in + // the prompt or messages fields") when a system message reaches + // `messages`; the chat's system prompt goes in `instructions` instead. + it('never puts a system message in the transcript', () => { + const seeded = withCurrentDocContext([], docContext); + const continued = withCurrentDocContext( + [...seeded, { role: 'user', content: 'a real question' }], + docContext, + ); + + expect( + [...seeded, ...continued].some((m) => m.role === 'system'), + ).toBe(false); }); it('does not mutate the input array', () => { const existing: ChatMessage[] = [ - { role: 'system', content: 'sys' }, { role: 'user', content: 'STALE CONTEXT' }, { role: 'assistant', content: 'greeting' }, ]; diff --git a/frontend/src/pages/chat/index.tsx b/frontend/src/pages/chat/index.tsx index 13647e1c..9c70025d 100644 --- a/frontend/src/pages/chat/index.tsx +++ b/frontend/src/pages/chat/index.tsx @@ -36,17 +36,28 @@ const suggestionPrompts = [ 'What am I missing?', ]; -const CHAT_SYSTEM_MESSAGE: ChatMessage = { - role: 'system', - content: - 'Help the user improve their writing. Encourage the user towards critical thinking and self-reflection. Be concise. If the user mentions "here" or "this", assume they are referring to the area near the cursor or selection.', -}; +/** + * The chat's system prompt. It is passed as the `instructions` option on the + * generation call and deliberately kept *out* of the transcript: ai@7 rejects a + * system-role message inside `messages` with "System messages are not allowed + * in the prompt or messages fields." + */ +const CHAT_INSTRUCTIONS = + 'Help the user improve their writing. Encourage the user towards critical thinking and self-reflection. Be concise. If the user mentions "here" or "this", assume they are referring to the area near the cursor or selection.'; const CHAT_GREETING_MESSAGE: ChatMessage = { role: 'assistant', content: 'What do you think about your document so far?', }; +/** + * The messages `withCurrentDocContext` seeds ahead of the real conversation: + * the document context, then the greeting. They're part of what the model + * reads but not part of what the writer sees, so the transcript is rendered + * from this offset on. + */ +const SEEDED_MESSAGE_COUNT = 2; + /** * Renders the document context into the message the model reads. * @@ -67,10 +78,10 @@ export function docContextMessageContent( } /** - * Builds the base conversation with the freshest document context as its - * second message. When the chat is empty it seeds the system + doc-context + - * greeting messages; otherwise it replaces the existing doc-context message so - * the model always sees the current document state at send time. + * Builds the base conversation with the freshest document context as its first + * message. When the chat is empty it seeds the doc-context + greeting + * messages; otherwise it replaces the existing doc-context message so the + * model always sees the current document state at send time. */ export function withCurrentDocContext( chatMessages: ChatMessage[], @@ -82,10 +93,10 @@ export function withCurrentDocContext( content: docContextMessageContent(docContext, brief), }; if (chatMessages.length === 0) { - return [CHAT_SYSTEM_MESSAGE, docContextMessage, CHAT_GREETING_MESSAGE]; + return [docContextMessage, CHAT_GREETING_MESSAGE]; } const updated = chatMessages.slice(); - updated[1] = docContextMessage; + updated[0] = docContextMessage; return updated; } @@ -176,7 +187,9 @@ export default function Chat() { const [message, updateMessage] = useState(''); const visibleMessages = - chatMessages.length > 3 ? chatMessages.slice(3) : []; + chatMessages.length > SEEDED_MESSAGE_COUNT + ? chatMessages.slice(SEEDED_MESSAGE_COUNT) + : []; const resizeTextarea = useCallback(() => { const textarea = textareaRef.current; @@ -228,6 +241,7 @@ export default function Chat() { const deltas = streamTextDeltas({ model: languageModel, providerOptions: openaiProviderOptions, + instructions: CHAT_INSTRUCTIONS, messages: newMessages.slice(0, -1) as ModelMessage[], maxOutputTokens: 1024, abortSignal: requestController.signal, @@ -330,7 +344,7 @@ export default function Chat() { return (