Files
deepseek-harness/packages/feedback/command-feedback/tests/command-feedback.spec.ts
Turtle 0ccd3ed463 feat(feedback): add a /feedback command recorded through the command plane
Register a global `/feedback` command so a user can record a remark about the
session without spending a model turn. `/feedback <text>` acknowledges; empty
or whitespace-only input returns a usage error.

The plugin appends no session event of its own. `dsh-commands` already writes
a `command/run` / `command/done` pair for every dispatched command, carrying
the verbatim text and the settled outcome, and both records are log-only and
non-surface. The feedback is therefore durably in the session log and invisible
to the model without this package touching the log format.

Text is never parsed, so `/feedback /plan felt slow` records that literal
content. Nothing consumes the records; capture is deliberately inert.

New group `packages/feedback/` — no existing group owns feedback capture. Its
row raises the packages/README.md word ceiling by 10, which had no headroom;
one redundant sentence there was removed to offset most of the cost.
2026-07-29 13:42:05 +08:00

177 lines
7.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly session: Session
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Build a live idle agent over a store-owned session, as an app's spine does. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
const session = ctx.sessions.create(SessionId(id))
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() { status = 'idle' },
whenIdle() { return Promise.resolve() },
}
return { agent, session }
}
/** Mount the real command registry and this producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
const plugin = await ctx.plugin(commandFeedback)
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
/** Execute `/feedback` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
const settled = await test.ctx.commands.execute(
test.agent,
`/feedback${suffix}`,
new AbortController().signal,
)
if (settled === undefined) throw new Error('feedback command was not registered')
return settled.result
}
/** The registry's durable record of each accepted command, in log order. */
function commandRecords(session: Session): { name: string; args: string; kind: string }[] {
const runs = session.events.filter(event => event.type === 'command/run')
return runs.map((event) => {
const done = session.events.find(item =>
item.type === 'command/done' && item.data.commandId === event.data.commandId)
if (done?.type !== 'command/done') throw new Error('every command/run must be paired')
return { name: event.data.name, args: event.data.args, kind: done.data.kind }
})
}
describe('@deepseek-ai/dsh-command-feedback registration', () => {
it('registers one global command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandFeedback.name).toBe('command-feedback')
expect(commandFeedback.inject).toEqual(['commands'])
expect('default' in commandFeedback).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
})
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined()
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
})
})
describe('/feedback human command', () => {
it('acknowledges feedback and leaves the registry record as its durable trace', async () => {
const test = await harness()
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
kind: 'success',
text: 'Feedback recorded.',
})
expect(commandRecords(test.session)).toEqual([
{ name: 'feedback', args: ' the diff view is unreadable', kind: 'success' },
])
})
it('adds no event of its own beyond the registry pairing', async () => {
const test = await harness()
await run(test, ' nothing else happens')
// The whole point of the command: record and do nothing. Only the
// registry's own pairing appears, and no turn of model work starts.
expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
})
it('records verbatim text, including input that looks like another command', async () => {
const test = await harness()
await run(test, ' /plan felt SLOW\n\ttwice today ')
expect(commandRecords(test.session)).toEqual([
{ name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' },
])
})
it('records each entry separately without replacing earlier ones', async () => {
const test = await harness()
await run(test, ' first')
await run(test, ' second')
expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
})
it('records concurrent submissions in dispatch order', async () => {
const test = await harness()
const signal = new AbortController().signal
// The shipped TUI dispatches commands fire-and-forget.
const settled = await Promise.all([
test.ctx.commands.execute(test.agent, '/feedback first', signal),
test.ctx.commands.execute(test.agent, '/feedback second', signal),
])
expect(settled.map(item => item?.result)).toEqual([
{ kind: 'success', text: 'Feedback recorded.' },
{ kind: 'success', text: 'Feedback recorded.' },
])
expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
})
it('keeps every recorded event off the model surface and out of derived history', async () => {
const test = await harness()
await run(test, ' invisible to the model')
for (const event of test.session.events) {
expect('surfaceOp' in event).toBe(false)
expect(test.session.deriveEventMessage(event)).toBeNull()
}
expect(foldSurface(test.session.events).nodes).toEqual([])
expect(test.session.surface.nodes).toEqual([])
expect(test.session.deriveMessages()).toEqual([])
})
it('rejects empty and whitespace-only input as a failed command record', async () => {
const test = await harness()
const expected = {
kind: 'error',
text: 'Feedback text is required. Usage: /feedback <text>',
}
await expect(run(test)).resolves.toEqual(expected)
await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
// Rejected input still leaves the registry's own pairing, settled as an
// error, so no entry is mistaken for accepted feedback.
expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error'])
})
it('records nothing when dispatch rejects an already-cancelled request', async () => {
const test = await harness()
const controller = new AbortController()
controller.abort(new Error('user cancelled the command'))
await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal))
.rejects.toThrow('user cancelled the command')
expect(test.session.events).toEqual([])
})
})