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([])
  })
})