opentui(copy): assistant-text extraction helpers

This commit is contained in:
alt-glitch 2026-06-09 07:09:07 +00:00
parent aa5489e804
commit 0f53d67ee4
2 changed files with 130 additions and 0 deletions

View file

@ -0,0 +1,36 @@
/**
* Assistant-text extraction (the /copy command's pure logic). An assistant turn's
* answer lives in `parts` (the `type:'text'` fragments, concatenated) while live,
* OR in `.text` once settled/resumed. We copy the ANSWER only reasoning and tool
* parts are excluded. `nthAssistantResponse` indexes newest-first (1-based).
*/
import type { Message } from './store.ts'
/** The answer text of one message: concat the `text` parts (trimmed) when live, else `.text`. */
export function messageText(m: Message): string {
if (m.parts && m.parts.length) {
return m.parts
.filter(p => p.type === 'text')
.map(p => p.text)
.join('')
.trim()
}
return m.text
}
/** Newest-first list of the non-empty answer text for every assistant message. */
export function assistantResponses(messages: Message[]): string[] {
const out: string[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (!m || m.role !== 'assistant') continue
const text = messageText(m)
if (text) out.push(text)
}
return out
}
/** The n-th newest assistant response (1-based; n=1 → last). `undefined` if out of range. */
export function nthAssistantResponse(messages: Message[], n: number): string | undefined {
return assistantResponses(messages)[n - 1]
}

View file

@ -0,0 +1,94 @@
/**
* Assistant-text extraction helpers (the /copy command's logic). Pure functions:
* pull the answer text out of a live (parts) or settled (.text) assistant turn,
* excluding reasoning/tool parts; pick the n-th newest assistant response.
*/
import { describe, expect, test } from 'bun:test'
import { assistantResponses, messageText, nthAssistantResponse } from '../logic/copy.ts'
import type { Message } from '../logic/store.ts'
describe('messageText', () => {
test('a live parts turn concatenates text parts; excludes reasoning/tool', () => {
const m: Message = {
role: 'assistant',
text: '',
parts: [
{ type: 'reasoning', id: 'p1', text: 'thinking…' },
{ type: 'text', id: 'p2', text: 'Hello' },
{ type: 'tool', id: 't1', name: 'bash', state: 'complete', resultText: 'ran' },
{ type: 'text', id: 'p3', text: ' world' }
]
}
expect(messageText(m)).toBe('Hello world')
})
test('trims surrounding whitespace from concatenated text parts', () => {
const m: Message = {
role: 'assistant',
text: '',
parts: [{ type: 'text', id: 'p1', text: ' spaced ' }]
}
expect(messageText(m)).toBe('spaced')
})
test('a settled/resumed turn (no parts) returns .text', () => {
const m: Message = { role: 'assistant', text: 'resumed answer' }
expect(messageText(m)).toBe('resumed answer')
})
test('empty parts array falls back to .text', () => {
const m: Message = { role: 'assistant', text: 'flat body', parts: [] }
expect(messageText(m)).toBe('flat body')
})
})
describe('assistantResponses', () => {
test('picks only assistant rows, newest-first, non-empty', () => {
const messages: Message[] = [
{ role: 'system', text: 'welcome' },
{ role: 'user', text: 'hi' },
{ role: 'assistant', text: 'first reply' },
{ role: 'user', text: 'and?' },
{ role: 'assistant', text: '', parts: [{ type: 'text', id: 'p1', text: 'second reply' }] }
]
expect(assistantResponses(messages)).toEqual(['second reply', 'first reply'])
})
test('skips assistant rows that resolve to empty text', () => {
const messages: Message[] = [
{ role: 'assistant', text: 'kept' },
{ role: 'assistant', text: '', parts: [{ type: 'reasoning', id: 'r1', text: 'only thinking' }] }
]
expect(assistantResponses(messages)).toEqual(['kept'])
})
test('empty messages → []', () => {
expect(assistantResponses([])).toEqual([])
})
})
describe('nthAssistantResponse', () => {
const messages: Message[] = [
{ role: 'assistant', text: 'oldest' },
{ role: 'user', text: 'q' },
{ role: 'assistant', text: 'newest' }
]
test('n=1 is the last assistant response', () => {
expect(nthAssistantResponse(messages, 1)).toBe('newest')
})
test('n=2 is the previous assistant response', () => {
expect(nthAssistantResponse(messages, 2)).toBe('oldest')
})
test('n past the end → undefined', () => {
expect(nthAssistantResponse(messages, 3)).toBeUndefined()
})
test('no assistant responses → undefined', () => {
expect(nthAssistantResponse([{ role: 'user', text: 'hi' }], 1)).toBeUndefined()
expect(nthAssistantResponse([], 1)).toBeUndefined()
})
})