File size: 11,280 Bytes
d04f74a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) }
})
vi.mock('@deepseek-ai/dsh-anonymous-user-id', () => ({
getOrCreateAnonymousUserId,
}))
beforeEach(() => getOrCreateAnonymousUserId.mockClear())
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,
inbox: unsupportedInbox(),
ctx: new Context(),
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session }
}
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
const plugin = await ctx.plugin(commandFeedback)
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
await 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
}
/** Authoritative feedback payloads in log order. */
function feedbackRecords(session: Session): FeedbackRecord[] {
return session.snapshotEvents()
.filter(event => event.type === 'feedback/record')
.map(event => event.data)
}
/** The text of each authoritative feedback payload in log order. */
function feedbackTexts(session: Session): (string | undefined)[] {
return feedbackRecords(session).map(record => record.text)
}
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({
definitionId: '@deepseek-ai/dsh-command-feedback',
name: 'feedback',
description: 'Record feedback about this session',
input: { hint: '<text>' },
})
expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
})
})
describe('sessionFeedback Host Remote', () => {
it('publishes the exact Gateway namespace and Remote method name', async () => {
const test = await harness()
const binding = test.ctx.sessionFeedback.typertRemote
expect(binding.serviceKey).toBe('sessionFeedback')
expect(binding.namespace).toBe('sessionFeedback')
expect(remoteMethods(test.ctx.sessionFeedback)).toEqual([
{ method: 'record', invocation: { kind: 'direct' } },
])
})
it('records a remark on the live Session without command bookkeeping', async () => {
const test = await harness()
await expect(test.ctx.sessionFeedback.record({
sessionId: test.session.id, text: ' the diff view is unreadable ', category: 'product-interaction',
})).resolves.toEqual({ ok: true, value: { recorded: true } })
await expect(test.ctx.sessionFeedback.record({ sessionId: test.session.id }))
.resolves.toEqual({ ok: true, value: { recorded: true } })
expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record', 'feedback/record'])
expect(feedbackRecords(test.session)).toEqual([
{ text: 'the diff view is unreadable', category: 'product-interaction' },
{},
])
expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
})
it('reports session-not-found for a Session no live owner carries', async () => {
const test = await harness()
const missing = SessionId('no-such-session')
await expect(test.ctx.sessionFeedback.record({ sessionId: missing, text: 'lost' }))
.resolves.toEqual({ ok: false, error: { code: 'session-not-found', sessionId: missing } })
expect(test.session.snapshotEvents()).toEqual([])
})
it('is mounted and unmounted with the plugin', async () => {
const test = await harness()
expect(test.ctx.get('sessionFeedback')).toBeDefined()
await test.plugin.dispose()
expect(test.ctx.get('sessionFeedback')).toBeUndefined()
})
})
describe('/feedback human command', () => {
it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
const test = await harness()
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
kind: 'success',
text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.`,
})
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run')
expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false)
expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1)
})
it('exports a command-independent feedback producer', async () => {
const test = await harness()
commandFeedback.recordFeedback(test.session, { text: ' recorded outside a command ' })
commandFeedback.recordFeedback(test.session, { text: ' \n\t ', category: 'service-stability' })
commandFeedback.recordFeedback(test.session, {})
expect(test.session.snapshotEvents().map(event => event.type))
.toEqual(['feedback/record', 'feedback/record', 'feedback/record'])
// Blank text is recorded as absent; an entry with neither member still records.
expect(feedbackRecords(test.session)).toEqual([
{ text: 'recorded outside a command' },
{ category: 'service-stability' },
{},
])
})
it('publishes the fixed category taxonomy in presentation order', () => {
expect(commandFeedback.FEEDBACK_CATEGORIES).toEqual([
'task-result', 'instruction-following', 'product-interaction', 'service-stability',
'resource-cost', 'security-privacy-permission', 'other',
])
})
it('keeps command bookkeeping around the authoritative feedback event', async () => {
const test = await harness()
await run(test, ' nothing else happens')
expect(test.session.snapshotEvents().map(event => event.type)).toEqual([
'command/run', 'feedback/record', 'command/done',
])
})
it('normalizes surrounding whitespace without parsing command-like content', async () => {
const test = await harness()
await run(test, ' /plan felt SLOW\n\ttwice today ')
expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today'])
})
it('records each entry separately without replacing earlier ones', async () => {
const test = await harness()
await run(test, ' first')
await run(test, ' second')
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
it('records concurrent submissions in dispatch order', async () => {
const test = await harness()
const signal = new AbortController().signal
// Command adapters may dispatch concurrent requests without awaiting one another.
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 for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
{ kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
])
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
it('keeps every recorded event out of model context and derived history', async () => {
const test = await harness()
await run(test, ' invisible to the model')
for (const event of test.session.snapshotEvents()) {
expect('surfaceOp' in event).toBe(false)
expect(test.session.deriveEventMessage(event)).toBeNull()
}
expect(foldSurface(test.session.snapshotEvents()).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)
expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
expect(feedbackTexts(test.session)).toEqual([])
const done = test.session.snapshotEvents().filter(event => event.type === 'command/done')
expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])
for (const event of test.session.snapshotEvents()) {
if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false)
}
})
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.snapshotEvents()).toEqual([])
})
})
|