diff --git a/app/composables/useChat.ts b/app/composables/useChat.ts index 4b398c93..16a7baea 100644 --- a/app/composables/useChat.ts +++ b/app/composables/useChat.ts @@ -63,6 +63,8 @@ export interface ChatMessage { /** Ordered narration/tool slices — see `MessageSegment`. */ segments: MessageSegment[] createdAt: string + /** Persisted turn id — used to fold same-turn assistant rows on load. */ + turnId?: string /** Context items attached to this message (user messages only) */ contextItems?: Array<{ type: 'model' | 'entry' | 'field' | 'asset' @@ -172,6 +174,7 @@ interface ConversationRow { content_blocks: unknown tool_calls: unknown created_at: string + turn_id?: string | null } /** @@ -293,16 +296,40 @@ export function useChat(options?: { `/api/workspaces/${workspaceId}/projects/${projectId}/conversations/${convId}/messages`, ) - // Convert DB messages to ChatMessage format - messages.value = data.map(row => ({ - id: row.id, - role: row.role as 'user' | 'assistant', - segments: row.role === 'user' - ? (row.content.trim() ? [{ kind: 'text' as const, text: row.content }] : []) - : rowToSegments(row), - createdAt: row.created_at, - attachments: row.role === 'user' ? hydrateAttachments(row.content_blocks) : undefined, - })) + // Convert DB messages to ChatMessage format. A turn persists one + // assistant row PER iteration (all visible since the trace + // visibility change) — consecutive assistant rows sharing a + // turn_id fold into one message whose segments concatenate in + // order, mirroring how the turn streamed live. The seed user row + // shares the turn_id but user rows always start a new message, so + // grouping stays assistant-only. Legacy rows carry distinct + // turn_ids and degrade to one message per row. + const grouped: ChatMessage[] = [] + for (const row of data) { + if (row.role === 'user') { + grouped.push({ + id: row.id, + role: 'user', + segments: row.content.trim() ? [{ kind: 'text', text: row.content }] : [], + createdAt: row.created_at, + attachments: hydrateAttachments(row.content_blocks), + }) + continue + } + const prev = grouped[grouped.length - 1] + if (prev && prev.role === 'assistant' && row.turn_id && prev.turnId === row.turn_id) { + prev.segments.push(...rowToSegments(row)) + continue + } + grouped.push({ + id: row.id, + role: 'assistant', + segments: rowToSegments(row), + createdAt: row.created_at, + turnId: row.turn_id ?? undefined, + }) + } + messages.value = grouped conversationId.value = convId } diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/conversations/[conversationId]/messages.get.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/conversations/[conversationId]/messages.get.ts index c6826329..d3708f02 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/conversations/[conversationId]/messages.get.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/conversations/[conversationId]/messages.get.ts @@ -17,5 +17,12 @@ export default defineEventHandler(async (event) => { if (!conv) throw createError({ statusCode: 404, message: errorMessage('chat.conversation_not_found') }) - return db.loadConversationMessages(conversationId, 100, 'id, role, content, content_blocks, tool_calls, model, created_at') + // 300 rows: assistant iteration rows are all visible since the trace + // visibility change (up to ~10 visible rows per heavy turn instead of + // 2), and tool_result rows — the biggest blobs — stay internal, so + // the payload cost is narration + tool inputs only. Note the provider + // orders ascending and applies the limit after, so a conversation + // beyond the cap truncates its NEWEST turns — a pre-existing wart, + // tracked separately. + return db.loadConversationMessages(conversationId, 300, 'id, role, content, content_blocks, tool_calls, model, created_at, turn_id') }) diff --git a/server/utils/conversation-engine.ts b/server/utils/conversation-engine.ts index 7037f1a0..fa4efd1f 100644 --- a/server/utils/conversation-engine.ts +++ b/server/utils/conversation-engine.ts @@ -103,6 +103,28 @@ export interface ToolExecutionContext { const DEFAULT_MAX_TOOL_ITERATIONS = 8 const DEFAULT_MAX_TOOL_RESULT_LENGTH = 32000 +/** + * Appended to the last tool_result message before the graceful-close + * wrap call. Without it the wrap just replays the conversation with + * tools disabled and the model can legitimately answer with empty + * content (observed on staging) — leaving the turn with no summary. + * Hardcoded English on purpose (same precedent as the output_truncated + * message): it instructs the model to answer in the conversation's + * language, and it is never persisted or shown verbatim. + */ +const GRACEFUL_CLOSE_INSTRUCTION + = 'You have reached the tool-step limit for this turn. Answer now with a concise summary of what you just did and its results, in the language of the conversation. Do not propose further actions.' + +/** Deterministic last-resort summary when even the instructed wrap returns no text. */ +function buildFallbackSummary(executedToolNames: string[]): string { + if (executedToolNames.length === 0) + return 'The turn reached its step limit before completing any operations. Please retry with a smaller request.' + const counts = new Map() + for (const name of executedToolNames) counts.set(name, (counts.get(name) ?? 0) + 1) + const parts = [...counts.entries()].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)) + return `Completed ${executedToolNames.length} operation(s) before reaching the step limit for this turn: ${parts.join(', ')}. The results above reflect what was applied.` +} + // ─── Conversation Loop ─── /** @@ -136,6 +158,9 @@ export async function* runConversationLoop( let totalCacheReadInputTokens = 0 let lastAssistantContent: AIContentBlock[] = [] let accumulatedAffected: AffectedResources = emptyAffected() + // Names of tools that actually executed this turn — feeds the + // graceful-close fallback summary when the wrap call returns no text. + const executedToolNames: string[] = [] // Full iteration-by-iteration trace surfaced on `done` so the // caller can persist the actual Anthropic-protocol shape Claude // saw — intermediate assistant turns AND tool_result blocks — @@ -291,6 +316,7 @@ export async function* runConversationLoop( const result = await executeToolWithAutoMerge( tc.name, tc.input, toolCtx.engine, toolCtx.git, toolCtx.userEmail, toolCtx.userId, toolCtx.contentRoot, toolCtx.workflow, toolCtx.permissions, toolCtx.plan, toolCtx.projectId, toolCtx.workspaceId, toolCtx.uiContext, turnMerge, ) + executedToolNames.push(tc.name) // Accumulate affected resources accumulatedAffected = mergeAffected(accumulatedAffected, result.affected) @@ -325,15 +351,46 @@ export async function* runConversationLoop( // tools-disabled streaming call so the model summarizes what it did — // streamed live, and recorded as the final visible assistant turn. if (!config.abortSignal?.aborted && iteration >= maxIterations && lastStopReason === 'tool_use') { + // Nudge the wrap with an explicit summarize instruction appended + // after the final tool_result blocks. REPLACE the message — never + // mutate its content array, which `trace` still references — so + // the instruction lives only in this one API call and is never + // persisted or replayed on resume. + const lastMsg = config.messages[config.messages.length - 1] + if (lastMsg?.role === 'user' && Array.isArray(lastMsg.content)) { + config.messages[config.messages.length - 1] = { + role: 'user', + content: [...lastMsg.content, { type: 'text', text: GRACEFUL_CLOSE_INSTRUCTION }], + } + } + const wrap = yield* runModelStream(maxOutputTokens, []) totalInputTokens += wrap.usage.inputTokens totalOutputTokens += wrap.usage.outputTokens totalCacheCreationInputTokens += wrap.usage.cacheCreationInputTokens totalCacheReadInputTokens += wrap.usage.cacheReadInputTokens - if (wrap.assistantBlocks.length > 0) { + + const wrapText = wrap.assistantBlocks + .filter(b => b.type === 'text') + .map(b => (b as { text: string }).text) + .join('') + .trim() + if (wrapText) { lastAssistantContent = wrap.assistantBlocks trace.push({ iteration: iteration + 1, assistantBlocks: wrap.assistantBlocks, toolResultBlocks: [] }) } + else { + // Observed on staging: the wrap can return zero blocks without + // erroring, which used to leave the turn with no summary at all. + // Synthesize a deterministic one so the transcript never ends + // tool-only. + // eslint-disable-next-line no-console + console.warn('[conversation] graceful-close wrap returned no text; synthesizing fallback summary') + const fallbackText = buildFallbackSummary(executedToolNames) + yield { type: 'text', content: fallbackText } + lastAssistantContent = [{ type: 'text', text: fallbackText }] + trace.push({ iteration: iteration + 1, assistantBlocks: lastAssistantContent, toolResultBlocks: [] }) + } } // Flush BEFORE the done event so the client's done-triggered refresh diff --git a/server/utils/db.ts b/server/utils/db.ts index c0329ea8..5aaacb1c 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -239,15 +239,14 @@ export async function inviteOrLookupUser( * landing at the same `created_at` tick still resolve in order. * * Visibility (`internal=true`): - * - Intermediate assistant rows AND every tool_result row are - * internal; they live in the protocol replay but should not - * appear in the user-facing transcript. - * - The LAST assistant row in the trace is visible — usually the - * final `end_turn` text reply; on max-iteration cutoff it's the - * last tool-use turn (UI sees the `[tool calls]` placeholder - * content text, same fallback the pre-trace path used). + * - Every assistant iteration row is visible — the transcript shows + * the full chronological narration + tool trace of the turn (the + * client groups same-turn assistant rows into one message and + * renders their `content_blocks` as ordered segments). + * - tool_result rows stay internal: they are protocol-replay + * payload (often large truncated JSON) and never render. * - * Token columns land only on the final visible assistant row — + * Token columns land only on the final assistant row — * Anthropic's `usage` is per-call total, not per-iteration. */ function buildTraceRows(input: { @@ -312,7 +311,7 @@ function buildTraceRows(input: { turnId, turnSequence: seq++, iteration: iter.iteration, - internal: !isFinal, + internal: false, ...(isFinal ? { tokenCountInput: input.inputTokens, diff --git a/tests/contract/conversations.contract.test.ts b/tests/contract/conversations.contract.test.ts index 6e345965..c6ce9634 100644 --- a/tests/contract/conversations.contract.test.ts +++ b/tests/contract/conversations.contract.test.ts @@ -80,23 +80,28 @@ describe('postgres-db conversations (contract)', () => { iteration: null, } + // Mirrors the trace writer: assistant iteration rows are visible, + // tool_result payload rows stay internal. await methods.insertMessages([ { ...base, role: 'user', content: 'Hello', turnSequence: 0 }, - { ...base, role: 'assistant', content: 'Working…', turnSequence: 1, internal: true, iteration: 1, toolCalls: [{ name: 'contentrain_status', args: {} }] }, - { ...base, role: 'assistant', content: 'Done', turnSequence: 2, contentBlocks: [{ type: 'text', text: 'Done' }], tokenCountInput: 10, tokenCountOutput: 5, model: 'claude-sonnet-5' }, + { ...base, role: 'assistant', content: 'Working…', turnSequence: 1, iteration: 1, toolCalls: [{ name: 'contentrain_status', args: {} }] }, + { ...base, role: 'user', content: '[tool results]', turnSequence: 2, internal: true, iteration: 1, contentBlocks: [{ type: 'tool_result', toolUseId: 'tu', content: '{"ok":true}' }] }, + { ...base, role: 'assistant', content: 'Done', turnSequence: 3, contentBlocks: [{ type: 'text', text: 'Done' }], tokenCountInput: 10, tokenCountOutput: 5, model: 'claude-sonnet-5' }, ]) await methods.insertMessage({ ...base, role: 'user', content: 'Thanks', turnSequence: 0, turnId: randomUUID() }) const visible = await methods.loadConversationMessages(conversationId, 20) - expect(visible.map(m => m.content)).toEqual(['Hello', 'Done', 'Thanks']) + expect(visible.map(m => m.content)).toEqual(['Hello', 'Working…', 'Done', 'Thanks']) expect(Object.keys(visible[0]!).sort()).toEqual( ['content', 'content_blocks', 'internal', 'iteration', 'role', 'tool_calls', 'turn_id', 'turn_sequence'], ) const all = await methods.loadConversationMessages(conversationId, 20, 'role, content, tool_calls, internal', { includeInternal: true }) - expect(all).toHaveLength(4) - expect(all[1]!.internal).toBe(true) + expect(all).toHaveLength(5) + expect(all[1]!.internal).toBe(false) expect(all[1]!.tool_calls).toEqual([{ name: 'contentrain_status', args: {} }]) + expect(all[2]!.internal).toBe(true) + expect(all[2]!.content).toBe('[tool results]') // jsonb round-trip: content_blocks stays a JSON array, not a pg array const blocks = await sql<{ content_blocks: unknown }>` @@ -107,7 +112,7 @@ describe('postgres-db conversations (contract)', () => { // limit applies after protocol ordering const limited = await methods.loadConversationMessages(conversationId, 2) - expect(limited.map(m => m.content)).toEqual(['Hello', 'Done']) + expect(limited.map(m => m.content)).toEqual(['Hello', 'Working…']) await methods.updateConversationTimestamp(conversationId) }) diff --git a/tests/nuxt/composables/use-chat.nuxt.test.ts b/tests/nuxt/composables/use-chat.nuxt.test.ts index fac5919c..bd0f5153 100644 --- a/tests/nuxt/composables/use-chat.nuxt.test.ts +++ b/tests/nuxt/composables/use-chat.nuxt.test.ts @@ -90,6 +90,66 @@ describe('useChat', () => { expect(messageText(msg)).not.toContain('[tool calls]') }) + it('folds same-turn assistant rows into one message with concatenated segments', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([ + { id: 'r1', role: 'user', content: 'Makaleleri ekle', turn_id: 'turn-1', created_at: '2026-03-25T00:00:00.000Z' }, + { + id: 'r2', + role: 'assistant', + content: 'Şimdi kaydediyorum.', + content_blocks: [ + { type: 'text', text: 'Şimdi kaydediyorum.' }, + { type: 'tool_use', id: 't1', name: 'save_content', input: { locale: 'tr' } }, + ], + turn_id: 'turn-1', + created_at: '2026-03-25T00:00:01.000Z', + }, + { + id: 'r3', + role: 'assistant', + content: 'Tamamlandı.', + content_blocks: [{ type: 'text', text: 'Tamamlandı.' }], + turn_id: 'turn-1', + created_at: '2026-03-25T00:00:02.000Z', + }, + // Next turn: the user row breaks the fold even before turn_id changes. + { id: 'r4', role: 'user', content: 'Teşekkürler', turn_id: 'turn-2', created_at: '2026-03-25T00:01:00.000Z' }, + { + id: 'r5', + role: 'assistant', + content: 'Rica ederim.', + content_blocks: [{ type: 'text', text: 'Rica ederim.' }], + turn_id: 'turn-2', + created_at: '2026-03-25T00:01:01.000Z', + }, + ])) + + const chat = useChat() + await chat.loadConversation('workspace-1', 'project-1', 'conv-1') + + expect(chat.messages.value).toHaveLength(4) + const turn1 = chat.messages.value[1]! + expect(turn1.id).toBe('r2') + expect(turn1.segments.map(s => s.kind)).toEqual(['text', 'tool', 'text']) + expect(turn1.segments[2]).toEqual({ kind: 'text', text: 'Tamamlandı.' }) + expect(chat.messages.value[2]?.role).toBe('user') + expect(chat.messages.value[3]?.id).toBe('r5') + }) + + it('keeps legacy rows with distinct turn ids as separate messages', async () => { + vi.stubGlobal('$fetch', vi.fn().mockResolvedValue([ + { id: 'r1', role: 'assistant', content: 'First reply', turn_id: 'legacy-1', created_at: '2026-03-25T00:00:00.000Z' }, + { id: 'r2', role: 'assistant', content: 'Second reply', turn_id: 'legacy-2', created_at: '2026-03-25T00:00:01.000Z' }, + ])) + + const chat = useChat() + await chat.loadConversation('workspace-1', 'project-1', 'conv-1') + + expect(chat.messages.value).toHaveLength(2) + expect(messageText(chat.messages.value[0]!)).toBe('First reply') + expect(messageText(chat.messages.value[1]!)).toBe('Second reply') + }) + it('streams assistant text, tool results, and affected resources from sse', async () => { const onContentChanged = vi.fn() const fetchConversations = vi.fn().mockResolvedValue([]) diff --git a/tests/unit/conversation-engine-regression.test.ts b/tests/unit/conversation-engine-regression.test.ts index cbeae87c..c5d51da3 100644 --- a/tests/unit/conversation-engine-regression.test.ts +++ b/tests/unit/conversation-engine-regression.test.ts @@ -307,12 +307,14 @@ describe('conversation engine regression', () => { // model summarizes — and that summary becomes the visible answer. let call = 0 const toolsPerCall: number[] = [] + const lastMessagePerCall: AIMessage[] = [] const { events } = await collectConversationEvents({ maxToolIterations: 1, aiProvider: { streamCompletion(request) { const idx = call++ toolsPerCall.push(request.tools.length) + lastMessagePerCall.push(request.messages[request.messages.length - 1]!) return (async function* () { if (idx === 0) { yield { type: 'text', content: 'Working on it.' } @@ -334,12 +336,69 @@ describe('conversation engine regression', () => { expect(call).toBe(2) // The summary call runs with tools disabled. expect(toolsPerCall).toEqual([1, 0]) + // The wrap call's last message keeps the tool_result blocks and + // appends the explicit summarize instruction after them. + const wrapLast = lastMessagePerCall[1]! + expect(wrapLast.role).toBe('user') + const wrapBlocks = wrapLast.content as Array<{ type: string, text?: string }> + expect(wrapBlocks[0]!.type).toBe('tool_result') + expect(wrapBlocks[wrapBlocks.length - 1]).toMatchObject({ + type: 'text', + text: expect.stringContaining('concise summary'), + }) // The streamed summary is the final visible assistant content. expect(events).toContainEqual({ type: 'text', content: 'All done — fixed everything.' }) - expect(events[events.length - 1]).toMatchObject({ + const done = events[events.length - 1]! + expect(done).toMatchObject({ type: 'done', lastContent: [{ type: 'text', text: 'All done — fixed everything.' }], }) + // Trace purity: the synthetic instruction is never persisted. + expect(JSON.stringify(done.iterations)).not.toContain('concise summary') + }) + + it('synthesizes a fallback summary when the graceful-close wrap returns no text', async () => { + // Observed on staging: the wrap call can return zero content blocks + // without erroring, which used to leave the whole multi-minute turn + // with a tool-only trace and no visible answer. + let call = 0 + const { events } = await collectConversationEvents({ + maxToolIterations: 1, + aiProvider: { + streamCompletion() { + const idx = call++ + return (async function* () { + if (idx === 0) { + yield { type: 'tool_use_start', toolId: 'tool-1', toolName: 'test_tool' } + yield { type: 'tool_use_end', toolId: 'tool-1', toolName: 'test_tool', toolInput: {} } + yield { type: 'tool_use_start', toolId: 'tool-2', toolName: 'test_tool' } + yield { type: 'tool_use_end', toolId: 'tool-2', toolName: 'test_tool', toolInput: {} } + yield { type: 'message_end', stopReason: 'tool_use', usage: { inputTokens: 5, outputTokens: 2 } } + } + else { + // Wrap returns nothing at all. + yield { type: 'message_end', stopReason: 'end_turn', usage: { inputTokens: 1, outputTokens: 0 } } + } + })() + }, + createCompletion: vi.fn(), + }, + }) + + expect(call).toBe(2) + // A deterministic fallback summary streams to the client… + const fallbackText = events.filter(e => e.type === 'text') + .map(e => e.content as string) + .join('') + expect(fallbackText).toContain('step limit') + // …and lands as the final trace iteration, so the transcript never + // ends tool-only. + const done = events[events.length - 1]! + const iterations = done.iterations as Array<{ assistantBlocks: Array<{ type: string, text?: string }> }> + const finalBlocks = iterations[iterations.length - 1]!.assistantBlocks + expect(finalBlocks).toHaveLength(1) + expect(finalBlocks[0]).toMatchObject({ type: 'text', text: expect.stringContaining('step limit') }) + expect(done.lastContent).toEqual(finalBlocks) }) it('does not truncate large tool results below the 32k cap', async () => { diff --git a/tests/unit/conversation-message-persistence.test.ts b/tests/unit/conversation-message-persistence.test.ts index 9276bcb2..70eb1128 100644 --- a/tests/unit/conversation-message-persistence.test.ts +++ b/tests/unit/conversation-message-persistence.test.ts @@ -28,7 +28,7 @@ describe('conversation message persistence', () => { const heterogeneousTrace: MessageInsertInput[] = [ { conversationId: 'c1', role: 'user', content: 'add a blog post', turnId: 't1', turnSequence: 0, internal: false }, - { conversationId: 'c1', role: 'assistant', content: '[tool calls]', contentBlocks: [{ type: 'tool_use', id: 'tu', name: 'save_content', input: { slug: 'x' } }], turnId: 't1', turnSequence: 1, iteration: 1, internal: true }, + { conversationId: 'c1', role: 'assistant', content: '[tool calls]', contentBlocks: [{ type: 'tool_use', id: 'tu', name: 'save_content', input: { slug: 'x' } }], turnId: 't1', turnSequence: 1, iteration: 1, internal: false }, { conversationId: 'c1', role: 'user', content: '[tool results]', contentBlocks: [{ type: 'tool_result', toolUseId: 'tu', content: '{"merged":true}' }], turnId: 't1', turnSequence: 2, iteration: 1, internal: true }, { conversationId: 'c1', role: 'assistant', content: 'Done', contentBlocks: [{ type: 'text', text: 'Done' }], turnId: 't1', turnSequence: 3, iteration: 2, internal: false, tokenCountInput: 100, tokenCountOutput: 20, cacheCreationInputTokens: 5, cacheReadInputTokens: 9, model: 'claude-haiku-4-5-20251001' }, ] diff --git a/tests/unit/conversation-routes.test.ts b/tests/unit/conversation-routes.test.ts index 78f6b7a7..63e5f992 100644 --- a/tests/unit/conversation-routes.test.ts +++ b/tests/unit/conversation-routes.test.ts @@ -60,7 +60,7 @@ describe('conversation routes', () => { expect(result).toEqual([{ id: 'msg-1', role: 'user', content: 'Hello', tool_calls: null, model: null, created_at: 'now' }]) expect(getConversation).toHaveBeenCalledWith('conv-1', 'project-1', { userId: 'user-1' }) - expect(loadConversationMessages).toHaveBeenCalledWith('conv-1', 100, 'id, role, content, content_blocks, tool_calls, model, created_at') + expect(loadConversationMessages).toHaveBeenCalledWith('conv-1', 300, 'id, role, content, content_blocks, tool_calls, model, created_at, turn_id') }) it('returns 404 for foreign or missing conversations', async () => { diff --git a/tests/unit/db.test.ts b/tests/unit/db.test.ts index 31fa9c64..b78e60fd 100644 --- a/tests/unit/db.test.ts +++ b/tests/unit/db.test.ts @@ -148,7 +148,7 @@ describe('db helpers', () => { expect(mockDb.updateConversationTimestamp).toHaveBeenCalledWith('conv-1') }) - it('writes intermediate iterations as internal rows and the final as visible', async () => { + it('writes every assistant iteration as visible and tool_result rows as internal', async () => { const { saveChatResult } = await loadDbModule() await saveChatResult({ conversationId: 'conv-1', @@ -180,7 +180,9 @@ describe('db helpers', () => { // 1 seed user + 2 assistant + 1 tool_result = 4 rows expect(rows).toHaveLength(4) expect(rows[0]).toMatchObject({ role: 'user', internal: false, iteration: null }) - expect(rows[1]).toMatchObject({ role: 'assistant', internal: true, iteration: 1 }) + // Every assistant iteration is visible — the transcript shows the + // full chronological trace; only tool_result payload rows stay internal. + expect(rows[1]).toMatchObject({ role: 'assistant', internal: false, iteration: 1 }) expect(rows[2]).toMatchObject({ role: 'user', internal: true, iteration: 1 }) expect(rows[3]).toMatchObject({ role: 'assistant', internal: false, iteration: 2 }) // turnSequence is monotonically increasing within the turn so a @@ -188,7 +190,7 @@ describe('db helpers', () => { expect(rows.map(r => r.turnSequence)).toEqual([0, 1, 2, 3]) // All four rows share the single turn_id. expect(new Set(rows.map(r => r.turnId)).size).toBe(1) - // Token counts land only on the final visible assistant row. + // Token counts land only on the final assistant row. expect(rows[1]).not.toHaveProperty('tokenCountInput') expect(rows[3]).toMatchObject({ tokenCountInput: 50,