fix(agent): surface empty assistant answers instead of silent stops (#1830)

The support chat showed 'Tänker' and then went quiet with no answer and
no error. Root cause: the 2026-08-21 RIP-3 cutover moved general.help to
the single-call POST /api/agent/ask, whose 1500-token default cap made
stop_reason max_tokens routine on tool-loop turns. The empty answer then
passed unlogged through the service, the route answered 200 with an
empty string, and the console appended an invisible empty bubble.

Fixes, single-call path:
- ask-service: default maxTokens 1500 -> 5400 (the streaming chat's
  reply ceiling); an empty final answer now logs model + usage and
  throws the typed EmptyModelAnswerError instead of passing through.
- /api/agent/ask: maxDuration 300, logger, empty answer maps to 502
  with 'Assistenten gav inget svar. Försök igen.'; an empty assistant
  turn is never persisted (the question stays, so retry works).
- AskConsole: a 200 with an empty answer shows the error box instead of
  appending an invisible bubble.
- anthropic-family: serialized tool results are bounded at 40000 chars
  (mirrors run-turn) so one big read cannot eat the output budget; the
  step-exhausted fallback keeps tools declared with tool_choice none,
  because replaying tool_use/tool_result without tools is an API 400.

Fixes, streaming path (same silent class):
- /api/agent/invoke: maxDuration 300 so deep thinking turns are not
  killed mid-stream at the platform default cap.
- run-turn: stop_reason max_tokens with no visible text emits an error
  event, not a bare turn_complete.
- AgentChat: an NDJSON stream that ends without turn_complete or error
  (and was not aborted) shows 'Anslutningen bröts innan svaret blev
  klart. Försök igen.'

The lib/ai request-shape tests were updated deliberately for the
fallback change; general.help stays on the single-call runtime
(founder decision, not reverted).


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 13:19:15 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 78525bd391
commit 6aecbdc9b7
13 changed files with 409 additions and 7 deletions
+26 -1
View File
@@ -28,7 +28,8 @@ vi.mock('@/lib/agent/ask/persist', () => ({
persistAssistantTurn: (...a: unknown[]) => persistAssistant(...a),
}))
import { POST } from '../route'
import { POST, maxDuration } from '../route'
import { EmptyModelAnswerError } from '@/lib/agent/ask/errors'
const membershipChain = { select: () => membershipChain, eq: () => membershipChain, maybeSingle: async () => ({ data: { user_id: 'user-1' } }) }
const supabase = { from: () => membershipChain }
@@ -48,6 +49,9 @@ beforeEach(() => {
const body = (o: Record<string, unknown> = {}) => ({ question: 'Hur gick juli?', ...o })
describe('POST /api/agent/ask', () => {
it('declares a 300s function budget so a tool-loop answer is not killed mid-turn', () => {
expect(maxDuration).toBe(300)
})
it('401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({ user: null, supabase, error: NextResponse.json({ error: 'x' }, { status: 401 }) })
expect((await POST(createMockRequest('/api/agent/ask', { method: 'POST', body: body() }))).status).toBe(401)
@@ -84,6 +88,15 @@ describe('POST /api/agent/ask', () => {
expect(answer).not.toHaveBeenCalled()
})
it('502s with a Swedish message when the model answers empty (the silent-stop bug)', async () => {
answer.mockRejectedValue(new EmptyModelAnswerError('claude-sonnet-5'))
const res = await POST(createMockRequest('/x', { method: 'POST', body: body() }))
const { status, body: b } = await parseJsonResponse<{ error: string; code: string }>(res)
expect(status).toBe(502)
expect(b.error).toBe('Assistenten gav inget svar. Försök igen.')
expect(b.code).toBe('empty_model_answer')
})
it('stateless (no persist): never touches the conversation tables', async () => {
await POST(createMockRequest('/x', { method: 'POST', body: body() }))
expect(resolveConv).not.toHaveBeenCalled()
@@ -154,6 +167,18 @@ describe('POST /api/agent/ask', () => {
expect(persistAssistant).not.toHaveBeenCalled()
})
it('502s on an empty answer, keeps the question, never persists a blank assistant turn', async () => {
answer.mockRejectedValue(new EmptyModelAnswerError('claude-sonnet-5'))
const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ persist: true }) }))
const { status, body: b } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(502)
expect(b.error).toBe('Assistenten gav inget svar. Försök igen.')
// The user turn is written before the model call (retry keeps the
// question), but no empty assistant turn may ever land in the thread.
expect(persistUser).toHaveBeenCalled()
expect(persistAssistant).not.toHaveBeenCalled()
})
it('still 503s (no write) when no backend is configured', async () => {
aiStatus.mockReturnValue({ configured: false })
const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ persist: true }) }))