( userMessage: string, threadContext?: ThreadContextEntry[], options?: BuildMessageTurnsOptions )
| 586 | * Use this when you need visibility into whether conversation was trimmed. |
| 587 | */ |
| 588 | export function buildMessageTurnsWithMetadata( |
| 589 | userMessage: string, |
| 590 | threadContext?: ThreadContextEntry[], |
| 591 | options?: BuildMessageTurnsOptions |
| 592 | ): BuildMessageTurnsResult { |
| 593 | const maxMessages = options?.maxMessages ?? 50; |
| 594 | // Pass toolCount for more accurate token budget when available |
| 595 | const tokenLimit = options?.tokenLimit ?? getConversationTokenLimit(options?.model, options?.toolCount); |
| 596 | |
| 597 | let messages: MessageTurn[] = []; |
| 598 | |
| 599 | // Detect whether the thread has multiple distinct human speakers. When it |
| 600 | // does, prefix every user-role turn with the speaker's name so the model |
| 601 | // can tell when the speaker switches mid-thread (e.g. an admin replying to |
| 602 | // a non-member's question). 'User' is the unknown-speaker sentinel and is |
| 603 | // not counted. Names are re-sanitized here as defense in depth — callers |
| 604 | // are expected to sanitize at ingest, but stored rows or hand-built |
| 605 | // entries shouldn't be able to break out of the `[name] text` envelope. |
| 606 | const sanitizedCurrent = sanitizeSpeakerName(options?.currentSpeakerName); |
| 607 | const distinctSpeakers = new Set<string>(); |
| 608 | if (threadContext) { |
| 609 | for (const e of threadContext) { |
| 610 | if (e.user && e.user !== 'Addie' && e.user !== 'User') { |
| 611 | const clean = sanitizeSpeakerName(e.user); |
| 612 | if (clean) distinctSpeakers.add(clean); |
| 613 | } |
| 614 | } |
| 615 | } |
| 616 | if (sanitizedCurrent) distinctSpeakers.add(sanitizedCurrent); |
| 617 | const isMultiSpeaker = distinctSpeakers.size > 1; |
| 618 | |
| 619 | if (threadContext && threadContext.length > 0) { |
| 620 | // First pass: apply message count limit if specified |
| 621 | let recentHistory = maxMessages > 0 |
| 622 | ? threadContext.slice(-maxMessages) |
| 623 | : threadContext; |
| 624 | |
| 625 | // Convert each entry to proper message turn |
| 626 | // Skip empty messages defensively |
| 627 | for (const entry of recentHistory) { |
| 628 | const trimmedText = entry.text?.trim(); |
| 629 | if (!trimmedText) continue; |
| 630 | const role: 'user' | 'assistant' = entry.user === 'Addie' ? 'assistant' : 'user'; |
| 631 | const cleanSpeaker = role === 'user' && entry.user !== 'User' |
| 632 | ? sanitizeSpeakerName(entry.user) |
| 633 | : undefined; |
| 634 | // Skip the prefix when content already starts with `[` to avoid |
| 635 | // double-bracketed turns like `[Brian] [User reacted with ...]`. |
| 636 | const content = (isMultiSpeaker && role === 'user' && cleanSpeaker && !trimmedText.startsWith('[')) |
| 637 | ? `[${cleanSpeaker}] ${trimmedText}` |
| 638 | : trimmedText; |
| 639 | // Pass through tool calls so claude-client can reconstruct proper API blocks |
| 640 | const toolCalls = (role === 'assistant' && entry.toolCalls && entry.toolCalls.length > 0) |
| 641 | ? entry.toolCalls.map(tc => ({ |
| 642 | name: tc.name, |
| 643 | input: tc.input, |
| 644 | result: typeof tc.result === 'string' ? tc.result : tc.result != null ? JSON.stringify(tc.result) : '', |
| 645 | is_error: tc.is_error, |
no test coverage detected