Files
deepseek-harness/packages/ui/tui/tests/prompt.spec.ts
Turtle 4c5f92e0fd feat(tui): personal TUI rework, integrating upstream model reasoning-effort selection
Consolidates the personal dsh-tui customizations (module split into
components/session/extension, prompt template + running-glyph indicator,
copyable transcript, tool-card headers, timing placement, XML tool output,
status/footer rework) and ports upstream's model reasoning-effort selector
(Shift+Tab effort cycling, effort-aware /model, footer, and /status) onto
the personal module layout.
2026-07-27 18:55:13 +08:00

170 lines
7.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import TuiPromptService, {
parseTuiPromptTemplate,
renderTuiPromptTemplate,
} from '../src/prompt.ts'
const tick = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
describe('TUI prompt values', () => {
it('registers, updates, and disposes mutable values', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m')
value.set('next')
expect(ctx.tuiPrompt.get('git/worktree')).toBe('next')
value.set(undefined)
expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined()
value.dispose()
expect(() => { value.set('late') }).toThrow(/disposed/)
await ctx.fiber.dispose()
})
it('coalesces a change burst into one notification and contains each observer', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Capture the containment warnings so the rejected-promise and sync-throw
// paths are each pinned (removing either catch drops its warning).
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
// A synchronous thrower, an async rejecter, and a thrower whose error is
// hostile to string coercion all sit BEFORE the observed listener, so
// proving `after` still runs proves none of them starves it (a naive
// `String(error)` inside the containment would itself throw on the last).
const hostile = { toString() { throw new Error('hostile coercion') } }
const thrower = vi.fn(() => { throw new Error('sync observer boom') })
const rejecter = vi.fn(async () => { throw new Error('async observer boom') })
const hostileThrower = vi.fn(() => { throw hostile })
const after = vi.fn()
ctx.tuiPrompt.subscribe(thrower)
ctx.tuiPrompt.subscribe(rejecter)
ctx.tuiPrompt.subscribe(hostileThrower)
const unsubscribe = ctx.tuiPrompt.subscribe(after)
await tick() // drain the registration notifications
thrower.mockClear()
rejecter.mockClear()
hostileThrower.mockClear()
after.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
value.set('b') // unchanged: no additional schedule
value.set('c')
await tick()
await tick() // settle the contained rejected promise
// One coalesced callback for the whole burst; a throwing, rejecting, or
// hostile-to-render observer is contained and does not stop later observers.
expect(thrower).toHaveBeenCalledTimes(1)
expect(rejecter).toHaveBeenCalledTimes(1)
expect(hostileThrower).toHaveBeenCalledTimes(1)
expect(after).toHaveBeenCalledTimes(1)
// Each contained failure logged its own warning: the sync throw, the
// rejected promise, and the hostile-to-render throw (via non-throwing
// errorChain). Pinning the rejected-promise warning fails if its `.catch`
// containment is removed.
expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true)
expect(warnings.some(w => w.includes('threw: <unrenderable value>'))).toBe(true)
// Unsubscribe stops further notifications for that listener.
unsubscribe()
value.set('d')
await tick()
expect(after).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('removes a subscription when the subscriber fiber disposes', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const observed = vi.fn()
// Subscribe from a child plugin fiber that shares the service, then dispose
// only that fiber; the effect-owned subscription must go with it.
const child = ctx.plugin({
inject: ['tuiPrompt'],
apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) },
})
await tick()
observed.mockClear()
await child.dispose()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(observed).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps one fiber\'s subscription when another disposes the same callback', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
// Both fibers subscribe the SAME function reference. Per-subscription record
// identity (not callback identity) keeps them independent, so disposing one
// must not silence the other.
const shared = vi.fn()
const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
await tick()
await first.dispose()
shared.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
// The second fiber's subscription survives the first's disposal.
expect(shared).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('does not notify a subscription unsubscribed earlier in the same burst', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
const victim = vi.fn()
// This listener is delivered first (subscribed first) and synchronously
// unsubscribes the victim during the same notification. The snapshot must
// re-check liveness so the later victim record does not fire this burst.
ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() })
const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim)
await tick()
victim.mockClear()
const value = ctx.tuiPrompt.register('git/worktree', 'a')
value.set('b')
await tick()
expect(victim).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('rejects invalid and duplicate names', async () => {
const ctx = new Context()
await ctx.plugin(TuiPromptService)
expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/)
ctx.tuiPrompt.register('status')
expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/)
await ctx.fiber.dispose()
})
})
describe('TUI prompt templates', () => {
it('interpolates values and removes separators around unavailable values', () => {
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
})
it('keeps a trailing literal after the last value', () => {
const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ')
const values = new Map([['symbol', 'dsh'], ['indicator', '●']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ')
})
it('preserves trusted ANSI fragments', () => {
const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m'
expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline)
})
})