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.
177 lines
7.1 KiB
TypeScript
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([])
|
|
})
|
|
})
|