diff --git a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx index b734c17a8..514ba52e9 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.test.tsx @@ -1102,7 +1102,7 @@ describe('graph answers and answer limits', () => { expect(document.activeElement?.id).toBe('cbm-chat-output-tokens'); }); - it('does not offer to change an output limit that is already at its maximum, and still lists the larger models (C8)', async () => { + it('does not offer to change an output limit that is already at its maximum, nor larger models with the same limit (C8, W6)', async () => { const { props, runtime } = fixture(); runtime.chat.mockImplementationOnce(async (_messages, _onToken, options) => { options?.onComplete?.({ stopReason: 'length' }); return 'A long answer that'; @@ -1110,9 +1110,11 @@ describe('graph answers and answer limits', () => { await render({ ...props, attachment: selection }); await click('Download & load'); await type('Explain this part'); await click('Send ↑'); const note = container.querySelector('.cbm-chat-turn details.cbm-chat-limit-note'); - expect(note?.textContent).toContain('The output limit is at the maximum of 512 tokens for this model.'); + expect(note?.textContent).toContain('The output limit is at its maximum of 512 tokens.'); expect([...note?.querySelectorAll('button') ?? []].map(item => item.textContent)).not.toContain('Change the output limit'); - expect(note?.textContent).toContain('Qwen3 0.6B · 579 MB download'); + // Every larger model stops at 512 output tokens as well, so none is offered for a longer answer. + expect(note?.textContent).toContain('Larger models have the same limit.'); + expect(note?.textContent).not.toContain('Qwen3 0.6B · 579 MB download'); }); it('says why an automatic explanation stops early and that a question may answer longer (K1)', async () => { diff --git a/graph-ui/src/browser-ai/BrowserChatDock.tsx b/graph-ui/src/browser-ai/BrowserChatDock.tsx index 1b6361da7..2ec19548d 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.tsx @@ -98,17 +98,23 @@ function SourceDisclosure({ children, title }: { children?: ReactNode; title?: s } /** Where an answer stopped and what can be changed: the limits it ran into, the way to - * the output limit and the larger models with their download. */ + * the output limit and the larger models with their download. Only what helps is offered: + * an automatic explanation writes at most AUTO_OUTPUT_TOKENS whatever the limit, and at the + * maximum a larger model stops at the same limit (W6). */ interface LimitNote { limit: TokenLimits; automatic?: boolean; chat: TokenLimits; model: BrowserModel; onChangeOutput: () => void } function TokenLimitNote({ limit, automatic, chat, model, onChangeOutput }: LimitNote): JSX.Element { const larger = BROWSER_MODELS.filter(candidate => candidate.availability === 'available' && candidate.bytes > model.bytes); + const sameLimit = larger.every(candidate => candidate.maxOutputTokens <= model.maxOutputTokens); + const atMaximum = chat.outputTokens >= model.maxOutputTokens; + const raise = automatic ? chat.outputTokens < AUTO_OUTPUT_TOKENS : !atMaximum; return
{browserChatText.shortened} -

{automatic ? browserChatText.limitAutomatic(limit.inputTokens, limit.outputTokens, chat.inputTokens, chat.outputTokens) : browserChatText.limitReached(limit.inputTokens, limit.outputTokens)}

-

{browserChatText.outputRoom(chat.outputTokens, model.maxOutputTokens)}

+

{automatic ? browserChatText.limitAutomatic(limit.inputTokens, limit.outputTokens, chat.inputTokens, chat.outputTokens, AUTO_OUTPUT_TOKENS) : browserChatText.limitReached(limit.inputTokens, limit.outputTokens)}

+ {automatic ? raise &&

{browserChatText.automaticRoom(AUTO_OUTPUT_TOKENS)}

:

{browserChatText.outputRoom(chat.outputTokens, model.maxOutputTokens)}

} + {!automatic && atMaximum && sameLimit &&

{browserChatText.narrower(larger.length > 0)}

} {/* At its maximum the limit cannot be raised, so there is nothing to change (C8). */} - {chat.outputTokens < model.maxOutputTokens && } - {larger.length > 0 && <>

{browserChatText.largerModels}

+ {raise && } + {!automatic && larger.length > 0 && (!atMaximum || !sameLimit) && <>

{browserChatText.largerModels(sameLimit)}

}
; } diff --git a/graph-ui/src/browser-ai/BrowserChatDock.wording.test.tsx b/graph-ui/src/browser-ai/BrowserChatDock.wording.test.tsx index 0a930752d..9c2d2a44e 100644 --- a/graph-ui/src/browser-ai/BrowserChatDock.wording.test.tsx +++ b/graph-ui/src/browser-ai/BrowserChatDock.wording.test.tsx @@ -6,6 +6,8 @@ import BrowserChatDock, { type BrowserChatDockProps, type BrowserChatReaderConte import type { BrowserAiProgress, BrowserChatMessage } from './browser-ai-runtime'; import type { BrowserChatOptions } from './browser-ai-controller'; import { jsonbAggEvidence } from './galaxy-evidence.fixture'; +import { AGENT_PREFERENCES_KEY } from './agent-preferences'; +import { BROWSER_MODELS } from './model-policy'; /* The chat dock shows the wording of the third review (W5 to W10) where it builds it. */ @@ -88,3 +90,62 @@ describe('the note under a left out model sentence (W5)', () => { expect(card()).toContain('Read from the file. The model\'s text named something the file does not show (here: flake8) and was left out.'); }); }); + +describe('the token limit note (W6)', () => { + const outputLimit = (outputTokens: number) => window.localStorage.setItem(AGENT_PREFERENCES_KEY, JSON.stringify({ version: 1, + preferences: { modelId: BROWSER_MODELS[0].id, automatic: true, limits: { [BROWSER_MODELS[0].id]: { inputTokens: 2048, outputTokens } } } })); + const cut = (runtime: ReturnType['runtime'], text = 'A long answer that') => runtime.chat.mockImplementationOnce(async (_messages, _onToken, options) => { + options?.onComplete?.({ stopReason: 'length' }); return text; + }); + const note = (scope: string) => container.querySelector(`${scope} details.cbm-chat-limit-note`); + const noteButtons = (scope: string) => [...note(scope)?.querySelectorAll('button') ?? []].map(item => item.textContent); + + it('at the maximum says that larger models have the same limit and how to get the rest, without offering them', async () => { + const { props, runtime } = fixture(); cut(runtime); + await render({ ...props, proactiveSelection: jsonbAggEvidence() }); await load(); + await ask('Explain this class in detail, line by line.'); + const text = note('.cbm-chat-turn')?.textContent ?? ''; + expect(text).toContain('The output limit is at its maximum of 512 tokens.'); + expect(text).toContain('Larger models have the same limit. Ask about one part of the code, or ask for the rest of the answer.'); + expect(text).not.toMatch(/A larger model|download/); + expect(note('.cbm-chat-turn')?.querySelectorAll('li')).toHaveLength(0); + expect(noteButtons('.cbm-chat-turn')).toEqual([]); + }); + + it('below the maximum offers the higher limit and lists larger models without promising a longer answer', async () => { + outputLimit(256); + const { props, runtime } = fixture(); cut(runtime); + await render({ ...props, proactiveSelection: jsonbAggEvidence() }); await load(); + await ask('Explain this class in detail, line by line.'); + const text = note('.cbm-chat-turn')?.textContent ?? ''; + expect(text).toContain('You can raise the output limit up to 512 tokens in the agent configuration.'); + expect(text).toContain('A larger model may stay closer to the question, but its output limit is the same. Each is a one-time download and needs more memory than its download size:'); + expect(text).not.toContain('memory use is higher than the download'); + expect(text).toContain('Qwen3 0.6B · 579 MB download'); + expect(noteButtons('.cbm-chat-turn')).toEqual(['Change the output limit']); + }); + + it('under an automatic explanation offers a higher limit only below the 128 tokens it can use', async () => { + vi.useFakeTimers(); + outputLimit(256); + const { props, runtime } = fixture(); cut(runtime, 'JSONBAgg sets the function to JSONB_AGG and'); + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence() }); await load(); await settle(); + const text = note('.cbm-chat-explanation')?.textContent ?? ''; + expect(text).toContain('Automatic explanations stop after 128 output tokens so they stay short'); + expect(text).toContain('Ask in the chat for a longer answer.'); + expect(text).not.toMatch(/raise the output limit|A larger model/i); + expect(noteButtons('.cbm-chat-explanation')).toEqual([]); + }); + + it('does not call the reader\'s own lower limit a design choice', async () => { + vi.useFakeTimers(); + outputLimit(32); + const { props, runtime } = fixture(); cut(runtime, 'JSONBAgg sets the function to JSONB_AGG and'); + await render({ ...props, proactive: true, proactiveSelection: jsonbAggEvidence() }); await load(); await settle(); + const text = note('.cbm-chat-explanation')?.textContent ?? ''; + expect(text).toContain('Automatic explanations stop after 32 output tokens, your output limit, and read at most 1,536 input tokens of source and facts.'); + expect(text).not.toContain('so they stay short'); + expect(text).toContain('Raise the output limit in the agent configuration and automatic explanations can use up to 128 output tokens.'); + expect(noteButtons('.cbm-chat-explanation')).toEqual(['Change the output limit']); + }); +}); diff --git a/graph-ui/src/browser-ai/strings.ts b/graph-ui/src/browser-ai/strings.ts index 77d74b37d..3e5c0e313 100644 --- a/graph-ui/src/browser-ai/strings.ts +++ b/graph-ui/src/browser-ai/strings.ts @@ -190,11 +190,19 @@ export const browserChatText = { shortened: 'Token limit reached: the answer was cut short', /** What the expanded token-limit note says, with the limits the answer ran into. */ limitReached: (input: number, output: number) => `This answer used all ${tokens(output)} output tokens it was allowed. The input limit is ${tokens(input)} tokens for the question, its source and earlier messages.`, - limitAutomatic: (automaticInput: number, automatic: number, input: number, output: number) => `Automatic explanations stop after ${tokens(automatic)} output tokens so they stay short, and read at most ${tokens(automaticInput)} input tokens of source and facts. A question in the chat may read up to ${tokens(input)} input tokens and answer with up to ${tokens(output)} output tokens.`, + /** `cap` is what automatic explanations write at most; below it they stop at the reader's own limit, + * which is no design choice to keep them short (W6). */ + limitAutomatic: (automaticInput: number, automatic: number, input: number, output: number, cap: number) => `Automatic explanations stop after ${tokens(automatic)} output tokens` + + `${automatic < cap ? ', your output limit,' : ' so they stay short,'} and read at most ${tokens(automaticInput)} input tokens of source and facts. ` + + `A question in the chat may read up to ${tokens(input)} input tokens and answer with up to ${tokens(output)} output tokens.${output > automatic ? ' Ask in the chat for a longer answer.' : ''}`, + automaticRoom: (cap: number) => `Raise the output limit in the agent configuration and automatic explanations can use up to ${tokens(cap)} output tokens.`, outputRoom: (output: number, max: number) => output < max ? `You can raise the output limit up to ${tokens(max)} tokens in the agent configuration.` - : `The output limit is at the maximum of ${tokens(max)} tokens for this model.`, + : `The output limit is at its maximum of ${tokens(max)} tokens.`, + /** At the maximum a larger model writes no longer answer: every model stops at the same limit (W6). */ + narrower: (sameForLarger: boolean) => `${sameForLarger ? 'Larger models have the same limit. ' : ''}Ask about one part of the code, or ask for the rest of the answer.`, changeOutputLimit: 'Change the output limit', - largerModels: 'A larger model may stay closer to the question. Each needs a one-time download, and its memory use is higher than the download:', + largerModels: (sameLimit: boolean) => `A larger model may stay closer to the question${sameLimit ? ', but its output limit is the same' : ''}. ` + + 'Each is a one-time download and needs more memory than its download size:', modelDownload: (name: string, size: string) => `${name} · ${size} download`, newConversation: 'New conversation', /** The model's state in the agent configuration (K10). */