feat(assistant): make the thumbs actually report something (#1236)
* feat(assistant): make the thumbs actually report something The thumbs up/down under an assistant answer shipped wired to nothing. They lit up, the vote died in component state, and the code said so in a comment nobody reading the UI could see. An affordance that looks like it reports something and does not is worse than no affordance: it spends the user's goodwill once, and silently. They now post to a new /api/agent/feedback, which emits the SAME agent.feedback event the gnubok_feedback MCP tool emits, with actorType 'user'. The product team already queries event_log for that type, so chat votes land in the backlog they read rather than in a second place someone has to remember to look at. event_log takes the payload as jsonb and already treats agent.* as telemetry, so there is no migration. The conversation id is caller-supplied, so it gets the same ownership check /api/agent/invoke got: without it a member could file feedback against a colleague's thread and the backlog would carry conversations the reporter never saw. Mutation-checked, three tests fail when the guard is removed. The pressed state is set only after the server accepts the vote, so the button never claims a report that never arrived, and a vote does not toggle off: it is append-only telemetry, and offering an undo we cannot honour would be a control that lies. Changing your mind sends the other sentiment instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): drop the unused free-text field from the feedback route compliance-swarm flagged the `comment` field as an undocumented PII exposure: free text embedded verbatim into the agent.feedback payload and written to event_log under telemetry retention, with no data-classification decision, next to a PostHog policy that treats the same class of data differently. The finding is right, and the field was worse than it looked: no caller ever sent one. The UI posts sentiment and a turn index. So this is dead API surface whose only effect was to accept whatever a user might type, in an accounting product, into a 180-day log: client names, personnummer, case details. Removed rather than documented. A comment box is a reasonable thing to want, but it needs its own classification and redaction decision made with the UI in front of it, not inherited from an unused parameter. The test now asserts the property instead of the field's absence: a caller that posts a comment anyway must not get it stored anywhere in the payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -600,3 +600,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] Removing Recapt made lib/support/submit-feedback.ts single-channel: a failing /api/support/contact now surfaces as a real error instead of being masked by Recapt reporting success on its own channel. That is the correct behaviour (silently "succeeding" while the message reached nobody was worse) and the Resend path is solid. A non-blocking posthog.capture('support_feedback_submitted') replaces the useful half of the Recapt channel by putting the submission on the user's timeline next to the session replay; it deliberately carries no message body, since free text is user content and would be PII in an event property.
|
||||
[2026-07-27] vitest.config.ts now aliases 'server-only' to tests/stubs/server-only.ts. Its real entry point throws unconditionally (Next.js swaps it out at bundle time; Vitest cannot), so the moment a server-only module entered the test graph it broke 48 test files at import. app/(dashboard)/request-context.ts was already carrying the same latent trap and had simply never been imported by a test.
|
||||
[2026-07-27] Self-hosted the dicebear Notionists avatars under public/agent-avatars instead of loading api.dicebear.com per render: the licence turned out to be CC0 1.0 (verified on dicebear.com/licenses AND in each file's own RDF metadata), so there was no licence decision to escalate, and the CDN was sending every authenticated page view's IP and referer to a third party while breaking firewalled/self-hosted installs entirely.
|
||||
[2026-07-27] Chat thumbs emit the existing agent.feedback event with actorType 'user' rather than a new table or event type: the product team already queries event_log for that type from the MCP tool, so a second store would be a second place someone has to remember to read, and event_log's jsonb payload needs no migration.
|
||||
[2026-07-27] A cast vote does not toggle off: it emits append-only telemetry and there is no un-emitting one, so an undo control would be a button that lies. Changing your mind sends the opposite sentiment, which the backlog can actually see.
|
||||
[2026-07-27] Dropped the free-text `comment` field from /api/agent/feedback instead of documenting a retention policy for it (compliance-swarm V14.1): no caller ever sent it, and an unused free-text parameter in an accounting product is a PII sink into event_log's telemetry retention (client names, personnummer). A comment box needs its own classification and redaction decision made with the UI in front of it, not as dead API surface.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Tests for POST /api/agent/feedback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
const emitMock = vi.fn()
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: (...args: unknown[]) => emitMock(...args) },
|
||||
}))
|
||||
|
||||
import { POST } from '../feedback/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
const CONVERSATION_ID = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
const body = (over: Record<string, unknown> = {}) => ({
|
||||
conversation_id: CONVERSATION_ID,
|
||||
sentiment: 'negative',
|
||||
...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('POST /api/agent/feedback', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
|
||||
expect((await POST(req, routeParams)).status).toBe(401)
|
||||
expect(emitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for a sentiment outside the two votes the UI can send', async () => {
|
||||
const req = createMockRequest('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
body: body({ sentiment: 'furious' }),
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
expect(emitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when the conversation id is not a uuid', async () => {
|
||||
const req = createMockRequest('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
body: body({ conversation_id: 'not-a-uuid' }),
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('404s on a conversation belonging to another user', async () => {
|
||||
// Same hole that /api/agent/invoke had: the id is caller-supplied, so
|
||||
// without the ownership check a member could file feedback against a
|
||||
// colleague's thread and the triage backlog would carry a conversation the
|
||||
// reporter never saw.
|
||||
enqueue({
|
||||
data: {
|
||||
id: CONVERSATION_ID,
|
||||
user_id: 'someone-else',
|
||||
company_id: 'company-1',
|
||||
intent_id: 'general.help',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
expect(status).toBe(404)
|
||||
expect(emitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s on a conversation in another company', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: CONVERSATION_ID,
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-2',
|
||||
intent_id: 'general.help',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
|
||||
expect((await parseJsonResponse(await POST(req, routeParams))).status).toBe(404)
|
||||
expect(emitMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s when the conversation does not exist', async () => {
|
||||
enqueue({ data: null })
|
||||
const req = createMockRequest('/api/agent/feedback', { method: 'POST', body: body() })
|
||||
expect((await parseJsonResponse(await POST(req, routeParams))).status).toBe(404)
|
||||
})
|
||||
|
||||
it('emits agent.feedback with actorType user on the happy path', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: CONVERSATION_ID,
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
intent_id: 'vat.review',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
body: body({ sentiment: 'positive', message_index: 4 }),
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(emitMock).toHaveBeenCalledOnce()
|
||||
const event = emitMock.mock.calls[0]![0] as {
|
||||
type: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
// Same event type the MCP tool emits, so chat votes land in the backlog
|
||||
// the product team already reads instead of a second place.
|
||||
expect(event.type).toBe('agent.feedback')
|
||||
expect(event.payload.sentiment).toBe('positive')
|
||||
expect(event.payload.actorType).toBe('user')
|
||||
expect(event.payload.actorId).toBe('user-1')
|
||||
expect(event.payload.companyId).toBe('company-1')
|
||||
expect(event.payload.sessionId).toBe(CONVERSATION_ID)
|
||||
// Traceable to an answer: a thumbs-down that cannot be tied to a turn is a
|
||||
// number, not a report.
|
||||
expect(event.payload.context).toContain('vat.review')
|
||||
expect(event.payload.context).toContain('#4')
|
||||
})
|
||||
|
||||
it('never carries user free text into the telemetry log', async () => {
|
||||
// A comment box here would put whatever the user typed, in an accounting
|
||||
// product, verbatim into event_log under telemetry retention: client
|
||||
// names, personnummer, case details. The field is not accepted, and a
|
||||
// caller that sends one anyway must not have it stored.
|
||||
enqueue({
|
||||
data: {
|
||||
id: CONVERSATION_ID,
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
intent_id: 'general.help',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
body: body({ comment: 'Anna Andersson 19850101-1234 fick fel konto' }),
|
||||
})
|
||||
await POST(req, routeParams)
|
||||
|
||||
const event = emitMock.mock.calls[0]![0] as { payload: { context: string } }
|
||||
expect(event.payload.context).not.toContain('Anna Andersson')
|
||||
expect(event.payload.context).not.toContain('19850101')
|
||||
expect(JSON.stringify(event.payload)).not.toContain('19850101')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// The event bus is a module-level singleton and extension handlers are wired
|
||||
// by this call. Without it at module level the emit below goes nowhere and the
|
||||
// vote is silently dropped, which is exactly the failure this route exists to
|
||||
// fix.
|
||||
ensureInitialized()
|
||||
|
||||
// POST /api/agent/feedback
|
||||
//
|
||||
// A thumbs-up or thumbs-down on one assistant answer.
|
||||
//
|
||||
// The buttons have existed since the message-actions pass but were wired to
|
||||
// nothing: they animated, and the vote died in component state. An affordance
|
||||
// that looks like it reports something and does not is worse than no
|
||||
// affordance, because it spends the user's goodwill once and silently.
|
||||
//
|
||||
// This emits the SAME `agent.feedback` event the gnubok_feedback MCP tool
|
||||
// emits, with actorType 'user'. The product team already queries event_log for
|
||||
// that type, so votes from the chat land in the existing triage flow rather
|
||||
// than in a second place someone has to remember to look at. That is also why
|
||||
// there is no migration here: event_log takes the payload as jsonb and already
|
||||
// treats agent.* as telemetry with the longer retention.
|
||||
|
||||
const BodySchema = z.object({
|
||||
conversation_id: z.string().uuid(),
|
||||
sentiment: z.enum(['positive', 'negative']),
|
||||
// Which answer, so a vote can be traced to the turn it was about.
|
||||
message_index: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'agent.feedback.create',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user, log } = ctx
|
||||
|
||||
const validation = await validateBody(request, BodySchema, {
|
||||
log,
|
||||
operation: 'agent.feedback.create',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// The conversation id comes from the client, so it is checked the same way
|
||||
// /api/agent/invoke checks it: without this, any member could attach
|
||||
// feedback to another member's thread, and the triage backlog would carry
|
||||
// rows whose conversation the reporter never saw.
|
||||
const { data: conversation } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('id, user_id, company_id, intent_id')
|
||||
.eq('id', body.conversation_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (
|
||||
!conversation ||
|
||||
conversation.user_id !== user.id ||
|
||||
conversation.company_id !== companyId
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CONVERSATION_NOT_FOUND',
|
||||
message: 'Konversationen hittades inte.',
|
||||
message_en: 'Conversation not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'agent.feedback',
|
||||
payload: {
|
||||
// Deliberately NOT free text. A comment box here would put whatever
|
||||
// the user typed, in an accounting product, verbatim into event_log
|
||||
// under telemetry retention: names, personnummer, client details. The
|
||||
// vote carries only what it needs to be actionable, which is the intent
|
||||
// and which answer it was about. If a comment field is ever wanted, it
|
||||
// needs its own data-classification and redaction decision first, made
|
||||
// with the UI in front of it rather than as an unused parameter.
|
||||
context: [
|
||||
`Chattbetyg på svar i ${conversation.intent_id}.`,
|
||||
body.message_index === undefined ? null : `(svar #${body.message_index})`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
sentiment: body.sentiment,
|
||||
suggestion: null,
|
||||
toolName: null,
|
||||
skillSlug: null,
|
||||
sessionId: conversation.id,
|
||||
actorType: 'user',
|
||||
actorId: user.id,
|
||||
actorLabel: null,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { recorded: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Send,
|
||||
@@ -25,6 +25,7 @@ import ApprovalCard from './ApprovalCard'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { StoredStagedOperation } from '@/types'
|
||||
import type { AgentStatusEvent } from './agent-status'
|
||||
import { sendFeedback, type FeedbackSentiment } from './feedback-client'
|
||||
|
||||
// Markdown parser loads separately from the chat surface: react-markdown +
|
||||
// remark-gfm pull in the whole unified/remark tree.
|
||||
@@ -738,6 +739,18 @@ export default function AgentChat({
|
||||
}
|
||||
}
|
||||
|
||||
// A vote needs the thread it belongs to. Without a conversation id there is
|
||||
// nothing to attach the report to, so the buttons stay inert rather than
|
||||
// posting a vote the backlog cannot trace to an answer.
|
||||
const handleVote = useCallback(
|
||||
async (sentiment: FeedbackSentiment) => {
|
||||
const id = conversationIdRef.current
|
||||
if (!id) return false
|
||||
return sendFeedback({ conversationId: id, sentiment })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full min-h-0">
|
||||
{/* The chat had no live region at all, so a screen-reader user got no
|
||||
@@ -774,6 +787,7 @@ export default function AgentChat({
|
||||
}
|
||||
onRegenerate={handleRegenerate}
|
||||
onCorrection={handleCorrection}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -869,12 +883,14 @@ function MessageBubble({
|
||||
showRegenerate,
|
||||
onRegenerate,
|
||||
onCorrection,
|
||||
onVote,
|
||||
}: {
|
||||
message: ChatMessage
|
||||
streamingTail: boolean
|
||||
showRegenerate?: boolean
|
||||
onRegenerate?: () => void
|
||||
onCorrection?: (message: string) => void
|
||||
onVote?: (sentiment: FeedbackSentiment) => Promise<boolean>
|
||||
}) {
|
||||
const isUser = message.role === 'user'
|
||||
// An assistant turn that contains only tool calls (no text, no streaming
|
||||
@@ -992,6 +1008,7 @@ function MessageBubble({
|
||||
<MessageActions
|
||||
text={message.text}
|
||||
onRegenerate={showRegenerate ? onRegenerate : undefined}
|
||||
onVote={onVote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1001,20 +1018,36 @@ function MessageBubble({
|
||||
/**
|
||||
* Hover row under a finished assistant answer: copy, feedback, regenerate.
|
||||
*
|
||||
* Feedback is deliberately fire-and-forget and local-only for now: the point of
|
||||
* this row is that the affordances exist where users look for them. Wiring the
|
||||
* thumbs to gnubok_feedback is a follow-up, and a failed vote must never
|
||||
* interrupt reading an answer.
|
||||
* The thumbs used to be local-only: they lit up and the vote died in component
|
||||
* state. They now report to /api/agent/feedback, and the pressed state is set
|
||||
* only once the server has accepted the vote, so the button never claims a
|
||||
* report that did not happen.
|
||||
*
|
||||
* A vote does not toggle off. It emits an append-only telemetry event, and
|
||||
* there is no un-emitting one, so offering an undo would be a control that
|
||||
* lies. Changing your mind sends the other sentiment, which is a thing the
|
||||
* backlog can actually see.
|
||||
*/
|
||||
function MessageActions({
|
||||
text,
|
||||
onRegenerate,
|
||||
onVote,
|
||||
}: {
|
||||
text: string
|
||||
onRegenerate?: () => void
|
||||
onVote?: (sentiment: FeedbackSentiment) => Promise<boolean>
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [vote, setVote] = useState<'up' | 'down' | null>(null)
|
||||
const [voting, setVoting] = useState(false)
|
||||
|
||||
async function handleVote(next: 'up' | 'down') {
|
||||
if (voting || vote === next || !onVote) return
|
||||
setVoting(true)
|
||||
const ok = await onVote(next === 'up' ? 'positive' : 'negative')
|
||||
setVoting(false)
|
||||
if (ok) setVote(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
@@ -1043,8 +1076,9 @@ function MessageActions({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVote(vote === 'up' ? null : 'up')}
|
||||
className={cn(btn, vote === 'up' && 'text-foreground')}
|
||||
onClick={() => handleVote('up')}
|
||||
disabled={voting || !onVote}
|
||||
className={cn(btn, vote === 'up' && 'text-foreground', voting && 'opacity-60')}
|
||||
title="Bra svar"
|
||||
aria-pressed={vote === 'up'}
|
||||
>
|
||||
@@ -1053,8 +1087,9 @@ function MessageActions({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVote(vote === 'down' ? null : 'down')}
|
||||
className={cn(btn, vote === 'down' && 'text-foreground')}
|
||||
onClick={() => handleVote('down')}
|
||||
disabled={voting || !onVote}
|
||||
className={cn(btn, vote === 'down' && 'text-foreground', voting && 'opacity-60')}
|
||||
title="Dåligt svar"
|
||||
aria-pressed={vote === 'down'}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { sendFeedback } from '../feedback-client'
|
||||
|
||||
/**
|
||||
* The thumbs shipped wired to nothing: they lit up and the vote died in
|
||||
* component state. These pin the contract the button now depends on, in
|
||||
* particular that a failed send reports false, because the pressed state is
|
||||
* set from this return value and must never claim a report that never arrived.
|
||||
*/
|
||||
|
||||
describe('sendFeedback', () => {
|
||||
it('posts the vote to the feedback endpoint', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
const ok = await sendFeedback({
|
||||
conversationId: 'c1',
|
||||
sentiment: 'negative',
|
||||
messageIndex: 3,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, init] = fetchImpl.mock.calls[0]!
|
||||
expect(url).toBe('/api/agent/feedback')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
conversation_id: 'c1',
|
||||
sentiment: 'negative',
|
||||
message_index: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the message index rather than sending null for it', async () => {
|
||||
// The route validates message_index as an integer when present; sending
|
||||
// null would fail validation and lose the vote for no reason.
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true })
|
||||
await sendFeedback({
|
||||
conversationId: 'c1',
|
||||
sentiment: 'positive',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
})
|
||||
expect(JSON.parse(fetchImpl.mock.calls[0]![1].body)).toEqual({
|
||||
conversation_id: 'c1',
|
||||
sentiment: 'positive',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports failure for a non-2xx instead of assuming it landed', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 404 })
|
||||
expect(
|
||||
await sendFeedback({
|
||||
conversationId: 'c1',
|
||||
sentiment: 'positive',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reports failure instead of throwing when the request dies', async () => {
|
||||
// Offline, or the request cut off by a navigation. An unhandled rejection
|
||||
// here would surface as an error in the middle of reading an answer.
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
await expect(
|
||||
sendFeedback({
|
||||
conversationId: 'c1',
|
||||
sentiment: 'negative',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Send one thumbs vote on an assistant answer.
|
||||
*
|
||||
* Kept out of the component so it is testable without a React harness (this
|
||||
* repo's unit project is node-only), and so the failure contract is stated in
|
||||
* one place: this resolves false rather than throwing, because a vote that
|
||||
* cannot be sent must never interrupt reading the answer it was about.
|
||||
*/
|
||||
export type FeedbackSentiment = 'positive' | 'negative'
|
||||
|
||||
export async function sendFeedback(args: {
|
||||
conversationId: string
|
||||
sentiment: FeedbackSentiment
|
||||
messageIndex?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}): Promise<boolean> {
|
||||
const { conversationId, sentiment, messageIndex, fetchImpl = fetch } = args
|
||||
try {
|
||||
const res = await fetchImpl('/api/agent/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversationId,
|
||||
sentiment,
|
||||
...(messageIndex === undefined ? {} : { message_index: messageIndex }),
|
||||
}),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
// Offline, or the request was cut off by a navigation. Reported as a
|
||||
// failed vote so the button can go back to unpressed rather than claiming
|
||||
// a report that never arrived.
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user