Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/api/__tests__/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
17 changes: 10 additions & 7 deletions frontend/src/api/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }];
}
37 changes: 24 additions & 13 deletions frontend/src/pages/chat/__tests__/docContextMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,41 +35,52 @@ 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' },
];

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' },
];
Expand Down
40 changes: 27 additions & 13 deletions frontend/src/pages/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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[],
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -330,7 +344,7 @@ export default function Chat() {

return (
<div
key={index + 3}
key={index + SEEDED_MESSAGE_COUNT}
className={`${classes.chatMsg} ${chatMessage.role === 'user' ? classes.user : classes.ai}`}
>
{chatMessage.role === 'assistant' ? (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/draft/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
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 {
Expand Down Expand Up @@ -93,6 +93,7 @@
const result = await generateFullText({
model: languageModel,
providerOptions: openaiProviderOptions,
instructions: DRAFT_INSTRUCTIONS,
messages,
abortSignal: AbortSignal.timeout(20000),
});
Expand Down Expand Up @@ -413,10 +414,10 @@
docContext,
});
getSuggestion(request, false);
}, [getFetcher, getSuggestion, shouldAutoRefresh, log, refreshDocContext]);

Check warning on line 417 in frontend/src/pages/draft/index.tsx

View workflow job for this annotation

GitHub Actions / Run linters

React Hook useCallback has a missing dependency: 'modesToShow'. Either include it or remove the dependency array

const resetAutoRefresh = useResettableInterval(
autoRefreshCallback,

Check warning on line 420 in frontend/src/pages/draft/index.tsx

View workflow job for this annotation

GitHub Actions / Run linters

Promise returned in function argument where a void return was expected
autoRefreshInterval,
);

Expand Down
Loading