* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { AgentIntent } from '@/lib/agent/intents/types'
|
|
import type { StreamEvent } from '../run-turn'
|
|
|
|
// Anthropic client mock: `finalMessage()` delegates to a queued mock so tests
|
|
// can make the first stream attempt fail and the retry succeed. Mirrors the
|
|
// adapter in run-turn-memory.test.ts.
|
|
const messagesCreate = vi.fn()
|
|
vi.mock('@/lib/agent/composer/client', () => ({
|
|
getAnthropic: () => ({
|
|
messages: {
|
|
create: messagesCreate,
|
|
stream: (args: unknown) => {
|
|
const stream = {
|
|
on: () => stream,
|
|
finalMessage: () => messagesCreate(args),
|
|
}
|
|
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 getMock = vi.fn()
|
|
const getManyMock = vi.fn()
|
|
vi.mock('@/lib/agent/tools/registry', () => ({
|
|
agentToolRegistry: {
|
|
get: (...args: unknown[]) => getMock(...args),
|
|
getMany: (...args: unknown[]) => getManyMock(...args),
|
|
},
|
|
}))
|
|
|
|
import { runChatTurn, isTransientStreamError } 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 makeIntent(): AgentIntent {
|
|
return {
|
|
id: 'general.help',
|
|
buttonLabel: 'x',
|
|
sheetTitle: 'x',
|
|
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
|
|
tools: [],
|
|
model: 'claude-sonnet-5',
|
|
capture: async () => ({}),
|
|
promptTemplate: () => '',
|
|
}
|
|
}
|
|
|
|
async function runTurn(events: StreamEvent[]): Promise<void> {
|
|
await runChatTurn({
|
|
supabase: fakeSupabase(),
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
companyName: 'Acme AB',
|
|
firstName: 'Anna',
|
|
intent: makeIntent(),
|
|
conversationId: 'conv-1',
|
|
userMessage: 'hej',
|
|
persist: false,
|
|
emit: (e) => {
|
|
events.push(e)
|
|
return true
|
|
},
|
|
})
|
|
}
|
|
|
|
function transientError(message: string, status?: number): Error & { status?: number } {
|
|
const err = new Error(message) as Error & { status?: number }
|
|
if (status !== undefined) err.status = status
|
|
return err
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getManyMock.mockResolvedValue([])
|
|
})
|
|
|
|
describe('isTransientStreamError', () => {
|
|
it('classifies the known stream-corruption signatures as transient', () => {
|
|
expect(isTransientStreamError(new Error('Unexpected event order, got message_stop'))).toBe(true)
|
|
expect(isTransientStreamError(new Error('request ended without sending any chunks'))).toBe(true)
|
|
})
|
|
|
|
it('classifies throttling, 5xx, and transport cuts as transient', () => {
|
|
expect(isTransientStreamError(transientError('Too many requests', 429))).toBe(true)
|
|
expect(isTransientStreamError(transientError('Internal failure', 503))).toBe(true)
|
|
expect(isTransientStreamError(new Error('read ECONNRESET'))).toBe(true)
|
|
expect(isTransientStreamError(new Error('Request timed out'))).toBe(true)
|
|
})
|
|
|
|
it('classifies auth and validation failures as permanent', () => {
|
|
expect(isTransientStreamError(transientError('Forbidden', 403))).toBe(false)
|
|
expect(isTransientStreamError(transientError('Invalid model', 400))).toBe(false)
|
|
// A 4xx stays permanent even when the message contains a transient token.
|
|
expect(isTransientStreamError(transientError('socket auth rejected', 403))).toBe(false)
|
|
expect(isTransientStreamError(new Error('validation failed: max_tokens'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('runChatTurn: transient stream retry', () => {
|
|
it('retries once on a transient failure and completes the turn', async () => {
|
|
messagesCreate
|
|
.mockRejectedValueOnce(new Error('Unexpected event order, got message_stop'))
|
|
.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: 'Hej igen.' }],
|
|
stop_reason: 'end_turn',
|
|
})
|
|
|
|
const events: StreamEvent[] = []
|
|
await runTurn(events)
|
|
|
|
const restarts = events.filter((e) => e.kind === 'stream_restart')
|
|
expect(restarts).toHaveLength(1)
|
|
expect(restarts[0]).toMatchObject({ kind: 'stream_restart', assistant_text: '' })
|
|
expect(events.find((e) => e.kind === 'error')).toBeUndefined()
|
|
expect(events.find((e) => e.kind === 'turn_complete')).toBeDefined()
|
|
expect(messagesCreate).toHaveBeenCalledTimes(2)
|
|
}, 15_000)
|
|
|
|
it('does not retry a non-transient failure (403): error emitted, turn thrown', async () => {
|
|
messagesCreate.mockRejectedValueOnce(transientError('Forbidden', 403))
|
|
|
|
const events: StreamEvent[] = []
|
|
await expect(runTurn(events)).rejects.toThrow('Forbidden')
|
|
|
|
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(0)
|
|
expect(events.find((e) => e.kind === 'error')).toBeDefined()
|
|
expect(messagesCreate).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('retries at most once per turn: a second transient failure surfaces as an error', async () => {
|
|
messagesCreate
|
|
.mockRejectedValueOnce(transientError('Service unavailable', 503))
|
|
.mockRejectedValueOnce(transientError('Service unavailable', 503))
|
|
|
|
const events: StreamEvent[] = []
|
|
await expect(runTurn(events)).rejects.toThrow('Service unavailable')
|
|
|
|
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(1)
|
|
expect(events.find((e) => e.kind === 'error')).toBeDefined()
|
|
expect(messagesCreate).toHaveBeenCalledTimes(2)
|
|
}, 15_000)
|
|
})
|