import { describe, expect, it } from 'vitest'
import { surfaceNativeChatCommandOutputs } from './native-chat-command-output'
import { stripNoiseMessages } from './native-chat-noise'
import type { NativeChatMessage } from './native-chat-types'
// The two user rows OpenClaude 0.31.0 writes for `/context` (its caveat row is
// `isMeta` and never decoded), as the transcript decoder emits them.
const CONTEXT_ENVELOPE =
'/context\n context\n '
const CONTEXT_STDOUT =
' \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u001b[38;5;246m\u26c1 \u26c1 \u26c1 \u26c1 \u001b[39m \u001b[38;5;246mgpt-4o \u00b7 16.6k/128k tokens (13%)\u001b[39m\n\n\u001b[38;5;246m\u26c1 \u26c1 \u26c0 \u001b[38;5;220m\u26c0 \u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m\u26c1\u001b[39m System prompt: \u001b[38;5;246m7.9k tokens (6.1%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26c1\u001b[39m System tools: \u001b[38;5;246m8.5k tokens (6.6%)\u001b[39m\n \u001b[38;5;220m\u26c1\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26f6\u001b[39m Free space: \u001b[38;5;246m65k (50.8%)\u001b[39m\n \u001b[38;5;246m\u26dd Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m \u00b7 /skills\u001b[39m'
function userTurn(
id: string,
text: string,
timestamp: number | null = 100,
parentId?: string
): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript',
...(parentId ? { parentId } : {})
}
}
const envelope = (name: string, id = 'env', timestamp = 100): NativeChatMessage =>
userTurn(id, CONTEXT_ENVELOPE.replaceAll('context', name), timestamp)
const reply = (id: string, parentId: string | undefined, timestamp = 100): NativeChatMessage =>
userTurn(id, CONTEXT_STDOUT, timestamp, parentId)
const MODEL_STDOUT = 'Set model to gpt-4o'
describe('surfaceNativeChatCommandOutputs', () => {
it("shows OpenClaude's /context report as plain command output", () => {
const [shown] = surfaceNativeChatCommandOutputs(
[reply('out', 'env'), envelope('context')],
'openclaude'
)
expect(shown).toMatchObject({ id: 'out', role: 'system' })
const block = shown?.blocks[0]
expect(block).toMatchObject({ type: 'text', presentation: 'command-output' })
const text = block?.type === 'text' ? block.text : ''
expect(text).not.toContain('\u001b')
expect(text).not.toContain('local-command-stdout')
// Column layout survives: the legend stays indented past the grid.
expect(text.split('\n').slice(0, 5)).toEqual([
'Context Usage',
'\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 gpt-4o \u00b7 16.6k/128k tokens (13%)',
'',
'\u26c1 \u26c1 \u26c0 \u26c0 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 Estimated usage by category',
' \u26c1 System prompt: 7.9k tokens (6.1%)'
])
})
it('survives the noise filter while the command envelope stays hidden', () => {
const visible = stripNoiseMessages(
surfaceNativeChatCommandOutputs([envelope('context'), reply('out', 'env')], 'openclaude')
)
expect(visible.map(({ id }) => id)).toEqual(['out'])
})
it('pairs each reply with the row it links to, whatever the timestamps say', () => {
// One batch, one timestamp, sorted by id: each reply sits beside the other
// command, and the /model command is the newest row at the /context reply.
const modelReply = userTurn('a-model-out', MODEL_STDOUT, 100, 'd-model')
const messages = [
modelReply,
envelope('context', 'b-context', 100),
reply('c-context-out', 'b-context', 100),
envelope('model', 'd-model', 100)
]
const surfaced = surfaceNativeChatCommandOutputs(messages, 'openclaude')
expect(surfaced.find(({ id }) => id === 'c-context-out')?.role).toBe('system')
// Replies to commands whose effect is the feedback stay hidden.
expect(surfaced.find(({ id }) => id === 'a-model-out')).toBe(modelReply)
// A reply stamped before its command row still answers it.
const early = [reply('out', 'env', 99), envelope('context', 'env', 100)]
expect(surfaceNativeChatCommandOutputs(early, 'openclaude')[0]?.role).toBe('system')
})
it('leaves a reply hidden without a link to a loaded /context row', () => {
// An older host sends no link, even for a reply right after its command.
const unlinked = [envelope('context'), reply('out', undefined)]
expect(surfaceNativeChatCommandOutputs(unlinked, 'openclaude')).toBe(unlinked)
// The command row fell outside the loaded tail window.
const windowed = [reply('out', 'env-before-window'), envelope('context', 'env-2', 200)]
expect(surfaceNativeChatCommandOutputs(windowed, 'openclaude')).toBe(windowed)
// Linked to a row that is not a command envelope.
const prose = [userTurn('env', 'what is /context'), reply('out', 'env')]
expect(surfaceNativeChatCommandOutputs(prose, 'openclaude')).toBe(prose)
})
it('leaves agents that declare no transcript reply untouched', () => {
const messages = [envelope('context'), reply('out', 'env')]
// Claude's TUI writes no reply row; one that appears is left to the noise filter.
expect(surfaceNativeChatCommandOutputs(messages, 'claude')).toBe(messages)
// OMP's /context is answered by the composer, never by a transcript row.
expect(surfaceNativeChatCommandOutputs(messages, 'omp')).toBe(messages)
})
})