mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
170 lines
7.1 KiB
TypeScript
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)
|
|
})
|
|
})
|