From 0bc5480cc0abad17f44cce7f080130d057bdd2e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:16:37 +0000 Subject: [PATCH] Move Draft and Chat system prompts into `instructions` ai@7 rejects a `role: 'system'` message inside `messages` (InvalidPromptError: "System messages are not allowed in the prompt or messages fields. Use the instructions option instead."), so every Draft suggestion and every Chat turn failed before the request left the browser. Revise already passed its system prompt as `instructions`, which is why it kept working. - Draft: `buildMessages` now returns user messages only; the system prompt is exported as `DRAFT_INSTRUCTIONS` and passed as `instructions`. - Chat: the system prompt leaves the transcript entirely and becomes `CHAT_INSTRUCTIONS`. The seeded messages are now doc-context + greeting, so the doc-context refresh writes index 0 and the visible transcript starts at `SEEDED_MESSAGE_COUNT`. Tests cover both shapes against the ai@7 validator, and frontend/CLAUDE.md documents the rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013iq2h4nTQWWG4fYHmw1B4z --- frontend/CLAUDE.md | 11 +++++ frontend/src/api/__tests__/prompts.test.ts | 46 +++++++++++++++++++ frontend/src/api/prompts.ts | 17 ++++--- .../chat/__tests__/docContextMessage.test.ts | 37 +++++++++------ frontend/src/pages/chat/index.tsx | 40 ++++++++++------ frontend/src/pages/draft/index.tsx | 3 +- 6 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 frontend/src/api/__tests__/prompts.test.ts 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 (
{chatMessage.role === 'assistant' ? ( diff --git a/frontend/src/pages/draft/index.tsx b/frontend/src/pages/draft/index.tsx index 6ed02637..36b1718d 100644 --- a/frontend/src/pages/draft/index.tsx +++ b/frontend/src/pages/draft/index.tsx @@ -12,7 +12,7 @@ import { import { generateFullText } from '@/api/generate'; import { draftLog } from '@/api/logging'; import { languageModel, openaiProviderOptions } from '@/api/openai'; -import { buildMessages } from '@/api/prompts'; +import { buildMessages, DRAFT_INSTRUCTIONS } from '@/api/prompts'; import { ErrorNotice, GenerationErrorNotice } from '@/components/errorNotice'; import BriefSection from '@/components/briefSection'; import { @@ -93,6 +93,7 @@ class Fetcher { const result = await generateFullText({ model: languageModel, providerOptions: openaiProviderOptions, + instructions: DRAFT_INSTRUCTIONS, messages, abortSignal: AbortSignal.timeout(20000), });