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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
78525bd391
commit
6aecbdc9b7
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { AgentIntent } from '@/lib/agent/intents/types'
|
||||
import type { StreamEvent } from '../run-turn'
|
||||
|
||||
// Verifies the silent-stop guard: a turn whose last model call ended on
|
||||
// stop_reason max_tokens WITHOUT any visible text must emit an error event,
|
||||
// never a bare turn_complete (which the chat renders as an invisible empty
|
||||
// bubble: "Tänker" collapses and nothing appears). A partial answer that hit
|
||||
// the ceiling still completes: half an answer on screen is a real answer.
|
||||
//
|
||||
// The client mock extends run-turn-thinking.test.ts's: stream().on() records
|
||||
// handlers and finalMessage() replays the queued response's text blocks
|
||||
// through the 'text' handler so assistantText accumulates like production.
|
||||
const messagesCreate = vi.fn()
|
||||
vi.mock('@/lib/agent/composer/client', () => ({
|
||||
getAnthropic: () => ({
|
||||
messages: {
|
||||
stream: (args: unknown) => {
|
||||
const handlers: Record<string, (arg: unknown) => void> = {}
|
||||
const stream = {
|
||||
on: (name: string, fn: (arg: unknown) => void) => {
|
||||
handlers[name] = fn
|
||||
return stream
|
||||
},
|
||||
finalMessage: async () => {
|
||||
const resp = (await messagesCreate(args)) as {
|
||||
content?: { type: string; text?: string }[]
|
||||
}
|
||||
for (const block of resp.content ?? []) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
handlers.text?.(block.text)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
},
|
||||
}
|
||||
return stream
|
||||
},
|
||||
},
|
||||
}),
|
||||
SONNET_MODEL: 'claude-sonnet-5',
|
||||
MAX_TOKENS_NO_THINKING: 5400,
|
||||
MAX_TOKENS_STANDARD: 16000,
|
||||
MAX_TOKENS_DEEP: 24000,
|
||||
}))
|
||||
|
||||
vi.mock('../system-prompt', () => ({
|
||||
buildSystemPrompt: vi.fn().mockResolvedValue({
|
||||
blocks: [],
|
||||
promptHash: 'sha256:test',
|
||||
atomsLoaded: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
const getManyMock = vi.fn()
|
||||
vi.mock('@/lib/agent/tools/registry', () => ({
|
||||
agentToolRegistry: {
|
||||
get: () => undefined,
|
||||
getMany: (...args: unknown[]) => getManyMock(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { runChatTurn } from '../run-turn'
|
||||
|
||||
function fakeSupabase() {
|
||||
const passthrough: Record<string, unknown> = {}
|
||||
const proxy: unknown = new Proxy(passthrough, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
}
|
||||
return () => proxy
|
||||
},
|
||||
})
|
||||
return proxy as unknown as Parameters<typeof runChatTurn>[0]['supabase']
|
||||
}
|
||||
|
||||
function baseIntent(): AgentIntent {
|
||||
return {
|
||||
id: 'general.help',
|
||||
buttonLabel: 'x',
|
||||
sheetTitle: 'x',
|
||||
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
|
||||
tools: [],
|
||||
model: 'claude-sonnet-4-6',
|
||||
capture: async () => ({}),
|
||||
promptTemplate: () => '',
|
||||
}
|
||||
}
|
||||
|
||||
async function runAndCollect(): Promise<StreamEvent[]> {
|
||||
const events: StreamEvent[] = []
|
||||
getManyMock.mockResolvedValue([])
|
||||
await runChatTurn({
|
||||
supabase: fakeSupabase(),
|
||||
userId: 'u',
|
||||
companyId: 'c',
|
||||
companyName: 'X',
|
||||
firstName: 'A',
|
||||
intent: baseIntent(),
|
||||
conversationId: 'conv',
|
||||
userMessage: 'hej',
|
||||
persist: false,
|
||||
emit: (event) => {
|
||||
events.push(event)
|
||||
return true
|
||||
},
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('runChatTurn: max_tokens with no visible text', () => {
|
||||
it('emits an error event instead of a bare turn_complete', async () => {
|
||||
messagesCreate.mockResolvedValueOnce({
|
||||
// Thinking spent the whole budget: no text block at all.
|
||||
content: [{ type: 'thinking', thinking: 'long reasoning', signature: 's' }],
|
||||
stop_reason: 'max_tokens',
|
||||
})
|
||||
const events = await runAndCollect()
|
||||
expect(events.some((e) => e.kind === 'turn_complete')).toBe(false)
|
||||
const error = events.find((e) => e.kind === 'error')
|
||||
expect(error).toBeDefined()
|
||||
expect((error as { message: string }).message).toBe(
|
||||
'Assistenten fick slut på utrymme innan svaret blev klart. Försök igen.',
|
||||
)
|
||||
})
|
||||
|
||||
it('still completes when max_tokens cut a PARTIAL answer', async () => {
|
||||
messagesCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'Din moms för juli är' }],
|
||||
stop_reason: 'max_tokens',
|
||||
})
|
||||
const events = await runAndCollect()
|
||||
expect(events.some((e) => e.kind === 'error')).toBe(false)
|
||||
const complete = events.find((e) => e.kind === 'turn_complete')
|
||||
expect(complete).toBeDefined()
|
||||
expect((complete as { assistant_text: string }).assistant_text).toBe('Din moms för juli är')
|
||||
})
|
||||
|
||||
it('completes normally on end_turn', async () => {
|
||||
messagesCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'Klart svar.' }],
|
||||
stop_reason: 'end_turn',
|
||||
})
|
||||
const events = await runAndCollect()
|
||||
expect(events.some((e) => e.kind === 'error')).toBe(false)
|
||||
expect(events.some((e) => e.kind === 'turn_complete')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -310,6 +310,9 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
|
||||
let assistantText = ''
|
||||
let iterations = 0
|
||||
// stop_reason of the last completed model call: lets the post-loop check
|
||||
// tell "finished" from "ran out of output budget before any visible text".
|
||||
let lastStopReason: string | null = null
|
||||
// One automatic retry per TURN when the Bedrock stream dies on a transient
|
||||
// error (throttling, 5xx, transport cut, stream corruption). The failed
|
||||
// attempt persisted nothing (persist happens after finalMessage() succeeds),
|
||||
@@ -482,6 +485,7 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
}
|
||||
|
||||
const assistantContent: ContentBlock[] = response.content
|
||||
lastStopReason = (response as { stop_reason?: string | null }).stop_reason ?? null
|
||||
|
||||
// Persist the assistant turn (text + tool_use blocks). Thinking blocks are
|
||||
// stripped for storage but kept in `messages` below for the in-turn loop.
|
||||
@@ -654,6 +658,27 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// A turn that hit the output ceiling before producing any visible text
|
||||
// (thinking or tool churn spent the whole max_tokens budget) is a failure,
|
||||
// not a completion. A bare turn_complete here is the silent-stop bug: the
|
||||
// client hides the empty bubble and "Tänker" just disappears. A PARTIAL
|
||||
// answer that hit the ceiling still completes; only the empty case errors.
|
||||
if (lastStopReason === 'max_tokens' && assistantText.trim().length === 0) {
|
||||
log.error('model hit max_tokens with no visible text', {
|
||||
conversationId,
|
||||
companyId,
|
||||
model,
|
||||
iterations,
|
||||
thinking: Boolean(intent.thinking),
|
||||
maxTokens,
|
||||
})
|
||||
emit({
|
||||
kind: 'error',
|
||||
message: 'Assistenten fick slut på utrymme innan svaret blev klart. Försök igen.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emit({ kind: 'turn_complete', assistant_text: assistantText })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user