|
|
|
|
@@ -4,9 +4,10 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import type { Terminal } from '@earendil-works/pi-tui'
|
|
|
|
|
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import LlmService, { LlmAdapter, type GenerateOptions, type LlmCallConfig, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
|
|
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
|
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
|
|
|
@@ -14,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
|
|
|
|
|
import {
|
|
|
|
|
createTuiChat,
|
|
|
|
|
mountTui,
|
|
|
|
|
renderSkillInvocation,
|
|
|
|
|
resolveTuiConfig,
|
|
|
|
|
type TuiRuntime,
|
|
|
|
|
} from '../src/index.ts'
|
|
|
|
|
@@ -135,7 +137,9 @@ describe('TUI config', () => {
|
|
|
|
|
modelDialogMaxHeight: 20,
|
|
|
|
|
showHardwareCursor: false,
|
|
|
|
|
color: true,
|
|
|
|
|
truecolor: false,
|
|
|
|
|
title: 'DeepSeek Harness',
|
|
|
|
|
autoTitle: true,
|
|
|
|
|
})
|
|
|
|
|
expect(resolveTuiConfig({
|
|
|
|
|
showReasoning: false,
|
|
|
|
|
@@ -148,7 +152,9 @@ describe('TUI config', () => {
|
|
|
|
|
modelDialogMaxHeight: 16,
|
|
|
|
|
showHardwareCursor: true,
|
|
|
|
|
color: false,
|
|
|
|
|
truecolor: true,
|
|
|
|
|
title: 'DSH',
|
|
|
|
|
autoTitle: false,
|
|
|
|
|
})).toEqual({
|
|
|
|
|
showReasoning: false,
|
|
|
|
|
maxToolOutputLines: 2,
|
|
|
|
|
@@ -160,11 +166,122 @@ describe('TUI config', () => {
|
|
|
|
|
modelDialogMaxHeight: 16,
|
|
|
|
|
showHardwareCursor: true,
|
|
|
|
|
color: false,
|
|
|
|
|
truecolor: true,
|
|
|
|
|
title: 'DSH',
|
|
|
|
|
autoTitle: false,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('resume command and /resume', () => {
|
|
|
|
|
const RESUME = 'RESUME_SESSION_ID={session} dsh'
|
|
|
|
|
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
|
|
|
|
|
({ version: 0, id: SessionId(id), createdAt, cwd })
|
|
|
|
|
|
|
|
|
|
it('prints the resume command on exit once the session is persisted', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
cwd: '/workspace',
|
|
|
|
|
config: { resumeCommand: RESUME },
|
|
|
|
|
sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] },
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/exit')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh')
|
|
|
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits the exit hint when the session is not yet persisted', async () => {
|
|
|
|
|
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
|
|
|
|
|
result.terminal.send('/exit')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).not.toContain('To resume this session')
|
|
|
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits the exit hint when the session listing fails', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
cwd: '/workspace',
|
|
|
|
|
config: { resumeCommand: RESUME },
|
|
|
|
|
sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) },
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/exit')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).not.toContain('To resume this session')
|
|
|
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('lists this workspace\'s sessions newest-first and marks the current one', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
cwd: '/workspace',
|
|
|
|
|
config: { resumeCommand: RESUME },
|
|
|
|
|
sessionPersistence: {
|
|
|
|
|
list: async () => [
|
|
|
|
|
header('main-session', 1000, '/workspace'),
|
|
|
|
|
header('older-session', 500, '/workspace'),
|
|
|
|
|
header('newer-session', 2000, '/workspace'),
|
|
|
|
|
header('foreign-session', 3000, '/elsewhere'),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/resume')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
const output = result.terminal.output
|
|
|
|
|
expect(output).toContain('Resumable sessions')
|
|
|
|
|
expect(output).toContain('RESUME_SESSION_ID=main-session dsh')
|
|
|
|
|
expect(output).toContain('(current)')
|
|
|
|
|
expect(output).toContain('RESUME_SESSION_ID=newer-session dsh')
|
|
|
|
|
expect(output).not.toContain('foreign-session')
|
|
|
|
|
// Newest-first: the newer session's command precedes the current session's.
|
|
|
|
|
// Match the full resume command, not the bare id: the banner detail line
|
|
|
|
|
// echoes the current session id (`main-session`) above the listing.
|
|
|
|
|
expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan(
|
|
|
|
|
output.indexOf('RESUME_SESSION_ID=main-session'),
|
|
|
|
|
)
|
|
|
|
|
expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan(
|
|
|
|
|
output.indexOf('RESUME_SESSION_ID=older-session'),
|
|
|
|
|
)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('warns from /resume when resume is not configured', async () => {
|
|
|
|
|
const result = await setup({ cwd: '/workspace' })
|
|
|
|
|
result.terminal.send('/resume')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Resume is not configured')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('warns from /resume when no persistence backend is mounted', async () => {
|
|
|
|
|
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
|
|
|
|
|
result.terminal.send('/resume')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('no persistence backend is mounted')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('notes from /resume when no workspace sessions are persisted yet', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
cwd: '/workspace',
|
|
|
|
|
config: { resumeCommand: RESUME },
|
|
|
|
|
sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] },
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/resume')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('No resumable sessions found')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
@@ -322,8 +439,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
)
|
|
|
|
|
await tick()
|
|
|
|
|
|
|
|
|
|
expect(result.terminal.output).toContain('◒ Working · 8s')
|
|
|
|
|
expect(result.terminal.output).toContain('esc interrupt')
|
|
|
|
|
expect(result.terminal.output).toContain('Enter sends steering, Esc cancels')
|
|
|
|
|
expect(result.terminal.output).toContain('Steering')
|
|
|
|
|
expect(result.terminal.output).toContain('user context')
|
|
|
|
|
expect(result.terminal.output).toContain('Prompt blocked')
|
|
|
|
|
@@ -415,6 +531,155 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('badges queued steering on the running status line and clears it as each drains', async () => {
|
|
|
|
|
// Pin a cwd free of the substring under test; the footer renders the path.
|
|
|
|
|
const result = await setup({ status: 'running', cwd: '/workspace' })
|
|
|
|
|
// Running with nothing queued: the plain steering hint, no badge.
|
|
|
|
|
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
|
|
|
|
expect(result.terminal.output).not.toContain('queued')
|
|
|
|
|
|
|
|
|
|
const queueSteering = (text: string): void => {
|
|
|
|
|
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, steering: true })
|
|
|
|
|
}
|
|
|
|
|
const drainSteering = (text: string): void => {
|
|
|
|
|
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A steering queue for a different agent never touches this status line.
|
|
|
|
|
const other = { ...result.agent, id: SessionId('other') } as Agent
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, steering: true })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).not.toContain('queued')
|
|
|
|
|
|
|
|
|
|
// Two steering messages queue while the turn runs.
|
|
|
|
|
queueSteering('first')
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
queueSteering('second')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels')
|
|
|
|
|
|
|
|
|
|
// A non-steering queue (an idle-style send) leaves the badge untouched.
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, steering: false })
|
|
|
|
|
drainSteering('first')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('1 queued')
|
|
|
|
|
expect(result.terminal.output).not.toContain('2 queued')
|
|
|
|
|
|
|
|
|
|
// Draining the last queued message returns the plain hint.
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
drainSteering('second')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
|
|
|
|
expect(result.terminal.output).not.toContain('queued')
|
|
|
|
|
|
|
|
|
|
// A loop-authored steering drain past zero clamps rather than underflowing.
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
drainSteering('continuation')
|
|
|
|
|
queueSteering('after')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('1 queued')
|
|
|
|
|
|
|
|
|
|
// The turn ending resets the badge, so the next running turn starts clean.
|
|
|
|
|
result.agent.status = 'idle'
|
|
|
|
|
result.ctx.emit('agent/status', result.agent, 'idle')
|
|
|
|
|
result.agent.status = 'running'
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.ctx.emit('agent/status', result.agent, 'running')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
|
|
|
|
|
expect(result.terminal.output).not.toContain('queued')
|
|
|
|
|
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('derives the fine-grained turn phase from session lifecycle events', async () => {
|
|
|
|
|
// A live event before the turn runs has no status controller to move.
|
|
|
|
|
const idle = await setup()
|
|
|
|
|
// A steering queue arriving while idle has no status line to badge, so the
|
|
|
|
|
// refresh is a no-op beyond requesting a render.
|
|
|
|
|
idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, steering: true })
|
|
|
|
|
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(idle.terminal.output).not.toContain('Executing tools')
|
|
|
|
|
expect(idle.terminal.output).not.toContain('queued')
|
|
|
|
|
await dispose(idle)
|
|
|
|
|
|
|
|
|
|
const result = await setup({ status: 'running' })
|
|
|
|
|
expect(result.terminal.output).toContain('Waiting for the first token')
|
|
|
|
|
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } })
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Thinking')
|
|
|
|
|
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } })
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Responding')
|
|
|
|
|
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Executing tools')
|
|
|
|
|
|
|
|
|
|
// The next step reopens the wait window and resets the executing label.
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Waiting for the first token')
|
|
|
|
|
expect(result.terminal.output).not.toContain('Executing tools')
|
|
|
|
|
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('refreshes the running status elapsed time on its own timer', async () => {
|
|
|
|
|
const result = await setup({ status: 'running' })
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
// The loader repaints "0s" until the controller's own interval fires; a
|
|
|
|
|
// non-zero elapsed proves the refresh, not just the loader's animation.
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1_300))
|
|
|
|
|
expect(result.terminal.output).toMatch(/Waiting for the first token [1-9]s/)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows minutes and seconds once a step passes a minute', async () => {
|
|
|
|
|
const result = await setup({ status: 'running' })
|
|
|
|
|
const base = Date.now()
|
|
|
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000)
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('total 1m')
|
|
|
|
|
nowSpy.mockRestore()
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => {
|
|
|
|
|
const result = await setup({ status: 'running' })
|
|
|
|
|
const base = Date.now()
|
|
|
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base)
|
|
|
|
|
// Advance into `responding`, anchoring the phase clock at `base`.
|
|
|
|
|
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } })
|
|
|
|
|
await tick()
|
|
|
|
|
|
|
|
|
|
// Four seconds later the terminal reports a light color scheme, rebuilding
|
|
|
|
|
// the status loader; the phase and its elapsed time must survive the rebuild.
|
|
|
|
|
nowSpy.mockReturnValue(base + 4_000)
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
result.terminal.send('\x1b[?997;2n')
|
|
|
|
|
await tick()
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Responding 4s')
|
|
|
|
|
expect(result.terminal.output).not.toContain('Waiting for the first token')
|
|
|
|
|
|
|
|
|
|
nowSpy.mockRestore()
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders the ANSI palette and every markdown/content style', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
cwd: '/workspace',
|
|
|
|
|
@@ -518,6 +783,41 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
await dispose(logicalResult)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows the session cache hit rate in the footer and updates it live', async () => {
|
|
|
|
|
// Empty session: no input billed yet, so the cache segment is hidden.
|
|
|
|
|
// A cwd without "cache" in it keeps the negative assertion unambiguous.
|
|
|
|
|
const empty = await setup({ cwd: '/opt' })
|
|
|
|
|
expect(empty.terminal.output).toContain('↑0 ↓0')
|
|
|
|
|
expect(empty.terminal.output).not.toContain('cache')
|
|
|
|
|
await dispose(empty)
|
|
|
|
|
|
|
|
|
|
const result = await setup({
|
|
|
|
|
// Pin a short cwd so the footer never clips the cache segment: the
|
|
|
|
|
// default is process.cwd(), and a deep worktree path truncates
|
|
|
|
|
// `cache 60%` at the terminal width.
|
|
|
|
|
cwd: '/opt',
|
|
|
|
|
beforeMount(session) {
|
|
|
|
|
// Cold call: 10 billed input tokens, none served from cache.
|
|
|
|
|
appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
expect(result.terminal.output).toContain('cache 0%')
|
|
|
|
|
|
|
|
|
|
result.terminal.output = ''
|
|
|
|
|
// Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed
|
|
|
|
|
// input, so 30 of the 50 total prompt tokens are hits → 60%.
|
|
|
|
|
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
|
|
|
|
|
inputTokens: 5,
|
|
|
|
|
outputTokens: 5,
|
|
|
|
|
cacheReadTokens: 30,
|
|
|
|
|
cacheWriteTokens: 5,
|
|
|
|
|
})
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('cache 60%')
|
|
|
|
|
expect(result.terminal.output).not.toContain('cache 0%')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
|
|
|
|
|
const result = await setup()
|
|
|
|
|
|
|
|
|
|
@@ -539,17 +839,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
result.terminal.send('\x03')
|
|
|
|
|
result.terminal.send('\x12')
|
|
|
|
|
result.terminal.send('\x0f')
|
|
|
|
|
result.terminal.send('/cancel')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
|
|
|
|
|
|
|
|
|
result.agent.status = 'idle'
|
|
|
|
|
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
|
|
|
|
|
for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) {
|
|
|
|
|
result.terminal.send(command)
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
}
|
|
|
|
|
for (const command of ['/clear', '/cancel', '/wat']) {
|
|
|
|
|
for (const command of ['/clear', '/wat']) {
|
|
|
|
|
result.terminal.send(command)
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
}
|
|
|
|
|
@@ -562,8 +860,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
expect(result.terminal.output).toContain('Keyboard shortcuts')
|
|
|
|
|
expect(result.terminal.output).toContain('Reasoning blocks')
|
|
|
|
|
expect(result.terminal.output).toContain('Tool cards')
|
|
|
|
|
expect(result.terminal.output).toContain('already idle')
|
|
|
|
|
expect(result.terminal.output).toContain('Unknown command')
|
|
|
|
|
// /reload without a Loader in the context degrades to a warning.
|
|
|
|
|
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
|
|
|
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
|
|
|
await result.controller.dispose()
|
|
|
|
|
await result.ctx.fiber.dispose()
|
|
|
|
|
@@ -917,6 +1216,153 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('skill slash command', () => {
|
|
|
|
|
const withSkills = async (ctx: Context): Promise<void> => {
|
|
|
|
|
ctx.provide('tools', { get() { return undefined } } as never)
|
|
|
|
|
await ctx.plugin(SkillService)
|
|
|
|
|
const skills = ctx.get('skills')
|
|
|
|
|
if (skills === undefined) throw new Error('skills service not mounted')
|
|
|
|
|
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
|
|
|
|
|
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => {
|
|
|
|
|
const result = await setup({ configureContext: withSkills })
|
|
|
|
|
result.terminal.send('/skill')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('demo-skill')
|
|
|
|
|
expect(result.terminal.output).not.toContain('hidden-skill')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('loads a skill as a user turn, appending typed instructions', async () => {
|
|
|
|
|
const result = await setup({ configureContext: withSkills })
|
|
|
|
|
result.terminal.send('/skill:demo-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>' }]])
|
|
|
|
|
|
|
|
|
|
result.agent.status = 'running'
|
|
|
|
|
result.terminal.send('/skill:demo-skill focus on tests')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.agent.steered).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>\n\nfocus on tests' }]])
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('invokes a model-disabled skill by its exact name', async () => {
|
|
|
|
|
const result = await setup({ configureContext: withSkills })
|
|
|
|
|
result.terminal.send('/skill:hidden-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports an unknown skill and an empty skill name without sending', async () => {
|
|
|
|
|
const result = await setup({ configureContext: withSkills })
|
|
|
|
|
result.terminal.send('/skill:does-not-exist')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
result.terminal.send('/skill:')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
// A space right after the colon parses to an empty name, not a name of
|
|
|
|
|
// "focus"; the documented syntax puts the name immediately after the colon.
|
|
|
|
|
result.terminal.send('/skill: focus')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Unknown skill: does-not-exist')
|
|
|
|
|
expect(result.terminal.output).toContain('Usage: /skill:<name>')
|
|
|
|
|
expect(result.agent.sent).toEqual([])
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('warns when no skill service is mounted', async () => {
|
|
|
|
|
const result = await setup()
|
|
|
|
|
result.terminal.send('/skill:demo-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Skills are not available')
|
|
|
|
|
expect(result.agent.sent).toEqual([])
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('surfaces skill lookup failures as an error notice', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
configureContext: async (ctx) => {
|
|
|
|
|
ctx.provide('tools', { get() { return undefined } } as never)
|
|
|
|
|
ctx.provide('skills', {
|
|
|
|
|
list: () => Promise.reject(new Error('list boom')),
|
|
|
|
|
get: () => Promise.reject(new Error('get boom')),
|
|
|
|
|
} as never)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/skill:demo-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('failed to load')
|
|
|
|
|
expect(result.terminal.output).toContain('get boom')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('drops skill list and lookup results that settle after disposal', async () => {
|
|
|
|
|
const pendingList: Array<(value: SkillSummary[]) => void> = []
|
|
|
|
|
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
|
|
|
|
|
const result = await setup({
|
|
|
|
|
configureContext: async (ctx) => {
|
|
|
|
|
ctx.provide('tools', { get() { return undefined } } as never)
|
|
|
|
|
ctx.provide('skills', {
|
|
|
|
|
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
|
|
|
|
|
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
|
|
|
|
|
} as never)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/skill:demo-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
result.terminal.send('/skill:other-skill')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
await dispose(result)
|
|
|
|
|
|
|
|
|
|
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
|
|
|
|
|
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
|
|
|
|
|
pendingGet[1]?.reject(new Error('late failure'))
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.agent.sent).toEqual([])
|
|
|
|
|
expect(result.terminal.output).not.toContain('late failure')
|
|
|
|
|
expect(result.terminal.output).not.toContain('late body')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('renderSkillInvocation', () => {
|
|
|
|
|
const skill: SkillDefinition = {
|
|
|
|
|
name: 'demo-skill',
|
|
|
|
|
description: 'Demo skill',
|
|
|
|
|
source: 'runtime',
|
|
|
|
|
provider: 'runtime',
|
|
|
|
|
content: 'Body text.',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('renders directory, url, opaque, and absent resource bases', () => {
|
|
|
|
|
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe(
|
|
|
|
|
'<skill name="demo-skill">\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n</skill>',
|
|
|
|
|
)
|
|
|
|
|
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe(
|
|
|
|
|
'<skill name="demo-skill">\nReferences in this skill are relative to https://x/y.\n\nBody text.\n</skill>\n\ngo',
|
|
|
|
|
)
|
|
|
|
|
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe(
|
|
|
|
|
'<skill name="demo-skill">\nheld in memory\n\nBody text.\n</skill>',
|
|
|
|
|
)
|
|
|
|
|
expect(renderSkillInvocation(skill, '')).toBe('<skill name="demo-skill">\nBody text.\n</skill>')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws on an unknown resource base kind', () => {
|
|
|
|
|
expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('tool cards and surface replay', () => {
|
|
|
|
|
const tools: Record<string, ToolDefinition> = {
|
|
|
|
|
bash: {
|
|
|
|
|
@@ -1289,6 +1735,37 @@ describe('terminal mounting', () => {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => {
|
|
|
|
|
// Production shape: the TUI runs inside a plugin fiber, where a bare
|
|
|
|
|
// `ctx.loader` proxy read would THROW `cannot get property without
|
|
|
|
|
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
|
|
await ctx.plugin(UserInteractionService)
|
|
|
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
|
|
|
const session = ctx.sessions.create(SessionId('main'))
|
|
|
|
|
ctx.agents.register({
|
|
|
|
|
id: session.id, options: {}, session, status: 'idle', ctx,
|
|
|
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
|
|
|
})
|
|
|
|
|
const terminal = new FakeTerminal()
|
|
|
|
|
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
|
|
|
|
await ctx.plugin({
|
|
|
|
|
inject: ['agents', 'userInteraction', 'tools'],
|
|
|
|
|
apply: (pluginCtx: Context) => {
|
|
|
|
|
mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() })
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await tick()
|
|
|
|
|
expect(terminal.started).toBe(1)
|
|
|
|
|
terminal.send('/reload')
|
|
|
|
|
terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(terminal.output).toContain('/reload needs the cordis Loader')
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('waits for its configured agent before starting the TUI', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
provideTokenMeter(ctx)
|
|
|
|
|
@@ -1335,7 +1812,7 @@ describe('terminal mounting', () => {
|
|
|
|
|
expect(terminal.output).toBe('')
|
|
|
|
|
expect(exit).not.toHaveBeenCalled()
|
|
|
|
|
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
|
|
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n')
|
|
|
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
|
|
|
|
|
expect(exit).toHaveBeenCalledWith(1)
|
|
|
|
|
|
|
|
|
|
const session = ctx.sessions.create(SessionId('main-session'))
|
|
|
|
|
@@ -1365,7 +1842,7 @@ describe('terminal mounting', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(terminal.started).toBe(0)
|
|
|
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable thrown value>\n')
|
|
|
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable value>\n')
|
|
|
|
|
expect(exit).toHaveBeenCalledWith(1)
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
@@ -1470,4 +1947,394 @@ describe('terminal mounting', () => {
|
|
|
|
|
expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
|
|
|
|
|
await disposeTuiTestHarness(result)
|
|
|
|
|
})
|
|
|
|
|
it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => {
|
|
|
|
|
const refreshed: string[] = []
|
|
|
|
|
let releaseRefresh!: () => void
|
|
|
|
|
const gate = new Promise<void>((resolve) => { releaseRefresh = resolve })
|
|
|
|
|
const result = await setup({
|
|
|
|
|
configureContext: async (ctx) => {
|
|
|
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
|
|
|
// A structural Loader: two file-backed subtrees and one plain entry.
|
|
|
|
|
// The first subtree blocks on a gate so re-entry can be probed
|
|
|
|
|
// deterministically mid-flight.
|
|
|
|
|
ctx.provide('loader', {
|
|
|
|
|
entries: () => [
|
|
|
|
|
{ subtree: { refresh: async () => { refreshed.push('root'); await gate } } },
|
|
|
|
|
{},
|
|
|
|
|
{ subtree: { refresh: async () => { refreshed.push('nested') } } },
|
|
|
|
|
],
|
|
|
|
|
} as never)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Reloading 2 config tree(s)')
|
|
|
|
|
// Second /reload while the first is gated: refused, no extra refreshes.
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('A config reload is already running.')
|
|
|
|
|
expect(refreshed.sort()).toEqual(['nested', 'root'])
|
|
|
|
|
releaseRefresh()
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Config reload complete.')
|
|
|
|
|
// The guard released: a third /reload runs again.
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(refreshed).toHaveLength(4)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a /reload failure if a refresh ever rejects', async () => {
|
|
|
|
|
const result = await setup({
|
|
|
|
|
configureContext: async (ctx) => {
|
|
|
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
|
|
|
ctx.provide('loader', {
|
|
|
|
|
entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }],
|
|
|
|
|
} as never)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Config reload failed: disk gone')
|
|
|
|
|
// The failure arm also releases the re-entrancy guard.
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).not.toContain('A config reload is already running.')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('refuses /reload while the agent is running and allows it back at idle', async () => {
|
|
|
|
|
const refreshed: string[] = []
|
|
|
|
|
const result = await setup({
|
|
|
|
|
status: 'running',
|
|
|
|
|
configureContext: async (ctx) => {
|
|
|
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
|
|
|
ctx.provide('loader', {
|
|
|
|
|
entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }],
|
|
|
|
|
} as never)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).')
|
|
|
|
|
expect(refreshed).toHaveLength(0)
|
|
|
|
|
// Back at idle the same command runs.
|
|
|
|
|
result.agent.status = 'idle'
|
|
|
|
|
result.terminal.send('/reload')
|
|
|
|
|
result.terminal.send('\r')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(refreshed).toHaveLength(1)
|
|
|
|
|
expect(result.terminal.output).toContain('Config reload complete.')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('banner sweep reveal', () => {
|
|
|
|
|
it('renders the product name through the brand-gradient path when truecolor is enabled', async () => {
|
|
|
|
|
// The product name carries a per-letter 24-bit gradient from the brand
|
|
|
|
|
// indigo to light blue; the per-letter layout is pinned by the
|
|
|
|
|
// `banner-gradient` terminal snapshot.
|
|
|
|
|
const result = await setup({ config: { color: true, truecolor: true } })
|
|
|
|
|
expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m')
|
|
|
|
|
expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m')
|
|
|
|
|
expect(result.terminal.output).toContain('HARNESS')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => {
|
|
|
|
|
const intervals = vi.spyOn(globalThis, 'setInterval')
|
|
|
|
|
const cleared = vi.spyOn(globalThis, 'clearInterval')
|
|
|
|
|
const result = await setup({ omitWelcome: true })
|
|
|
|
|
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
|
|
|
|
|
// Run the sweep to natural completion — it clears its own timer at the end.
|
|
|
|
|
const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle)
|
|
|
|
|
const deadline = Date.now() + 5000
|
|
|
|
|
while (!done() && Date.now() < deadline) await tick()
|
|
|
|
|
intervals.mockRestore()
|
|
|
|
|
cleared.mockRestore()
|
|
|
|
|
// The finished banner carries the title and the model • session detail.
|
|
|
|
|
expect(result.terminal.output).toContain('DEEPSEEK')
|
|
|
|
|
expect(result.terminal.output).toContain('HARNESS')
|
|
|
|
|
expect(result.terminal.output).toContain('main-session')
|
|
|
|
|
// Borderless: no box-drawing frame around the banner.
|
|
|
|
|
expect(result.terminal.output).not.toContain('╭')
|
|
|
|
|
expect(result.terminal.output).not.toContain('╮')
|
|
|
|
|
// A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS`
|
|
|
|
|
// on the same line.
|
|
|
|
|
const clipped = result.terminal.output
|
|
|
|
|
.split('\n')
|
|
|
|
|
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
|
|
|
|
|
expect(clipped).toBe(true)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders a configured welcome verbatim in a complete banner with no sweep', async () => {
|
|
|
|
|
const result = await setup()
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output).toContain('Coding agent ready.')
|
|
|
|
|
expect(result.terminal.output).toContain('DEEPSEEK')
|
|
|
|
|
expect(result.terminal.output).not.toContain('╭')
|
|
|
|
|
// No reveal frames: the banner is drawn whole from the first render, so no
|
|
|
|
|
// clipped-title frame ever appears.
|
|
|
|
|
const clipped = result.terminal.output
|
|
|
|
|
.split('\n')
|
|
|
|
|
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
|
|
|
|
|
expect(clipped).toBe(false)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits the subtitle line entirely when no welcome is configured', async () => {
|
|
|
|
|
const result = await setup({ omitWelcome: true })
|
|
|
|
|
const deadline = Date.now() + 5000
|
|
|
|
|
while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick()
|
|
|
|
|
// Banner is title + detail only — no subtitle between them.
|
|
|
|
|
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
|
|
|
|
expect(result.terminal.output).not.toContain('ready.')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('stops a mid-sweep animation on dispose', async () => {
|
|
|
|
|
// The output-stability probe alone is insensitive to a leaked interval
|
|
|
|
|
// (pi-tui's stopped guard silences post-stop renders), so capture the
|
|
|
|
|
// reveal's own interval handle and assert dispose clears exactly it.
|
|
|
|
|
const intervals = vi.spyOn(globalThis, 'setInterval')
|
|
|
|
|
const result = await setup({ omitWelcome: true })
|
|
|
|
|
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
|
|
|
|
|
expect(revealHandle).toBeDefined()
|
|
|
|
|
const cleared = vi.spyOn(globalThis, 'clearInterval')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true)
|
|
|
|
|
intervals.mockRestore()
|
|
|
|
|
cleared.mockRestore()
|
|
|
|
|
const settled = result.terminal.output.length
|
|
|
|
|
await tick()
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.output.length).toBe(settled)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Streams one fixed reply (or throws) so a test can drive the auto-title call. */
|
|
|
|
|
class TitleAdapter extends LlmAdapter {
|
|
|
|
|
lastOptions: GenerateOptions | undefined
|
|
|
|
|
calls = 0
|
|
|
|
|
constructor(private readonly reply: string | Error) {
|
|
|
|
|
super()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
this.calls += 1
|
|
|
|
|
this.lastOptions = options
|
|
|
|
|
if (this.reply instanceof Error) throw this.reply
|
|
|
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
|
|
|
yield { type: 'text-delta', index: 0, text: this.reply }
|
|
|
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
|
|
|
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Provide the `llm` service (with `adapter` on provider `mock`) plus the tools stub the TUI injects. */
|
|
|
|
|
function withLlm(adapter: LlmAdapter): (ctx: Context) => Promise<void> {
|
|
|
|
|
return async (ctx: Context) => {
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('TUI auto-title', () => {
|
|
|
|
|
const agentOptions: Agent['options'] = { provider: 'mock', model: 'mock-model' }
|
|
|
|
|
|
|
|
|
|
it('replaces the title with a model-generated title after the first user message', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('fix the login redirect')
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'the login page throws a 500 on submit, please investigate')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('fix the login redirect')
|
|
|
|
|
// The request carries the task framing, the user's first message, and no tools.
|
|
|
|
|
expect(adapter.lastOptions?.provider).toBe('mock')
|
|
|
|
|
expect(adapter.lastOptions?.model).toBe('mock-model')
|
|
|
|
|
expect(adapter.lastOptions?.system).toContain('short title')
|
|
|
|
|
expect(adapter.lastOptions?.tools).toBeUndefined()
|
|
|
|
|
expect(adapter.lastOptions?.messages).toEqual([
|
|
|
|
|
{ role: 'user', content: [{ type: 'text', text: 'the login page throws a 500 on submit, please investigate' }] },
|
|
|
|
|
])
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('requests a title only once, even after later user messages', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('the settled title')
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'the first request that earns the title')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('the settled title')
|
|
|
|
|
appendUser(result.session, 'a second request that must not re-title')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.calls).toBe(1)
|
|
|
|
|
expect(result.terminal.title).toBe('the settled title')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses the first non-empty line and truncates an over-long title with an ellipsis', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('\n this title is deliberately far too long to fit a terminal tab \nextra')
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'do the big thing')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('this title is deliberately far too long…')
|
|
|
|
|
expect(result.terminal.title.length).toBe(40)
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('skips a whitespace-only first message without consuming the one-shot slot', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('the real title')
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, ' ')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.lastOptions).toBeUndefined()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
appendUser(result.session, 'the first real request')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('the real title')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('leaves the title unchanged when the model returns no usable text', async () => {
|
|
|
|
|
const adapter = new TitleAdapter(' \n ')
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'anything at all')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('leaves the title unchanged when the title request fails', async () => {
|
|
|
|
|
const adapter = new TitleAdapter(new Error('router unavailable'))
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'trigger a failing title request')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('re-derives the title on resume from the already-logged first user message', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('resumed session title')
|
|
|
|
|
const result = await setup({
|
|
|
|
|
config: { autoTitle: true },
|
|
|
|
|
agentOptions,
|
|
|
|
|
configureContext: withLlm(adapter),
|
|
|
|
|
beforeMount: (session) => {
|
|
|
|
|
appendUser(session, 'the original first request')
|
|
|
|
|
appendUser(session, 'a later request that must not seed the title')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await tick()
|
|
|
|
|
// The title comes from the stored first message, not any later one.
|
|
|
|
|
expect(adapter.lastOptions?.messages).toEqual([
|
|
|
|
|
{ role: 'user', content: [{ type: 'text', text: 'the original first request' }] },
|
|
|
|
|
])
|
|
|
|
|
expect(result.terminal.title).toBe('resumed session title')
|
|
|
|
|
// A message that arrives after the resume must not re-title.
|
|
|
|
|
appendUser(result.session, 'a follow-up message')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.calls).toBe(1)
|
|
|
|
|
expect(result.terminal.title).toBe('resumed session title')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the static title when auto-title is disabled', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('should not run')
|
|
|
|
|
const result = await setup({ config: { autoTitle: false }, agentOptions, configureContext: withLlm(adapter) })
|
|
|
|
|
appendUser(result.session, 'a normal message with the feature off')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.lastOptions).toBeUndefined()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the static title when no llm service is available', async () => {
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions })
|
|
|
|
|
appendUser(result.session, 'no model can answer this')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the static title when the agent has no provider', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('unused')
|
|
|
|
|
const result = await setup({
|
|
|
|
|
config: { autoTitle: true },
|
|
|
|
|
agentOptions: { model: 'mock-model' },
|
|
|
|
|
configureContext: withLlm(adapter),
|
|
|
|
|
})
|
|
|
|
|
appendUser(result.session, 'the provider is missing')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.lastOptions).toBeUndefined()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the static title when the agent has no model', async () => {
|
|
|
|
|
const adapter = new TitleAdapter('unused')
|
|
|
|
|
const result = await setup({
|
|
|
|
|
config: { autoTitle: true },
|
|
|
|
|
agentOptions: { provider: 'mock' },
|
|
|
|
|
configureContext: withLlm(adapter),
|
|
|
|
|
})
|
|
|
|
|
appendUser(result.session, 'the model is missing')
|
|
|
|
|
await tick()
|
|
|
|
|
expect(adapter.lastOptions).toBeUndefined()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
await dispose(result)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('aborts an in-flight title request on shutdown', async () => {
|
|
|
|
|
const seen: { aborted: boolean } = { aborted: false }
|
|
|
|
|
class HangingAdapter extends LlmAdapter {
|
|
|
|
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
await new Promise<void>((_resolve, reject) => {
|
|
|
|
|
options.signal?.addEventListener('abort', () => {
|
|
|
|
|
seen.aborted = true
|
|
|
|
|
reject(new Error('aborted'))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new HangingAdapter()) })
|
|
|
|
|
appendUser(result.session, 'start a title request that never resolves')
|
|
|
|
|
await tick()
|
|
|
|
|
await dispose(result)
|
|
|
|
|
expect(seen.aborted).toBe(true)
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not set the title when the UI is torn down before the stream completes', async () => {
|
|
|
|
|
let release: () => void = () => {}
|
|
|
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
|
|
|
class GatedAdapter extends LlmAdapter {
|
|
|
|
|
// Yields a full reply, then blocks on the gate so the post-stream title
|
|
|
|
|
// apply runs only after the test has torn the UI down. Ignores `signal`,
|
|
|
|
|
// so shutdown's abort cannot cut the stream short.
|
|
|
|
|
async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
|
|
|
yield { type: 'text-delta', index: 0, text: 'title that arrives too late' }
|
|
|
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'title that arrives too late' } }
|
|
|
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
|
|
|
await gate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const result = await setup({ config: { autoTitle: true }, agentOptions, configureContext: withLlm(new GatedAdapter()) })
|
|
|
|
|
appendUser(result.session, 'start a title that finishes after teardown')
|
|
|
|
|
await tick()
|
|
|
|
|
await dispose(result)
|
|
|
|
|
release()
|
|
|
|
|
await tick()
|
|
|
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|