Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions app/composables/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -172,6 +174,7 @@ interface ConversationRow {
content_blocks: unknown
tool_calls: unknown
created_at: string
turn_id?: string | null
}

/**
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
59 changes: 58 additions & 1 deletion server/utils/conversation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>()
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 ───

/**
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 8 additions & 9 deletions server/utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -312,7 +311,7 @@ function buildTraceRows(input: {
turnId,
turnSequence: seq++,
iteration: iter.iteration,
internal: !isFinal,
internal: false,
...(isFinal
? {
tokenCountInput: input.inputTokens,
Expand Down
17 changes: 11 additions & 6 deletions tests/contract/conversations.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>`
Expand All @@ -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)
})
Expand Down
60 changes: 60 additions & 0 deletions tests/nuxt/composables/use-chat.nuxt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
Expand Down
Loading
Loading