Skip to content

Commit deda60f

Browse files
authored
feat: prompt users to report completed job failures
1 parent 5f71f36 commit deda60f

13 files changed

Lines changed: 654 additions & 12 deletions

File tree

DESIGN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ System notices:
206206
- Account-action notices (billing, connection, setup) should use `Surface Active` with `Border Accent`, `role="status"`, friendly action-oriented copy, and a single primary button-style link CTA when an action URL is available.
207207
- Avoid framing customer account actions as errors; do not surface provider phrases such as “rejected” or “insufficient” in the chat UI when a clearer next step is available.
208208
- Background-job failure cards use fixed, customer-safe copy with one next step, an optional safe last-completed phase, and a correlation ID for support. Never show provider errors, prompts, tool payloads, paths, or stack traces; only show a retry button when retry is the prescribed action.
209+
- After a completed job has an explicit tool or runtime failure, show a compact warning-surface reporting prompt with **No**, **No, never**, **Yes**, and **Yes, always** choices. Explain the sanitized diagnostic payload in supporting text, keep **Yes, always** as the single primary action, and stack the copy and actions on compact screens.
209210

210211
### Cards (suggestion / template)
211212

includes/Feedback/ReportSender.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* normal plugin operation).
1111
*
1212
* The endpoint is fixed — no API key is required. User consent is collected
13-
* per submission via the feedback-consent modal before this method is called.
13+
* through the manual feedback modal or the completed-job failure prompt. The
14+
* prompt can also retain a user-scoped choice to report future failures.
1415
*
1516
* @package SdAiAgent\Feedback
1617
* @license GPL-2.0-or-later
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* Tests for automatic post-failure feedback reporting.
3+
*/
4+
5+
import apiFetch from '@wordpress/api-fetch';
6+
import { useDispatch } from '@wordpress/data';
7+
import { createElement, createRoot } from '@wordpress/element';
8+
import { act } from 'react';
9+
10+
import AutomaticFeedbackPrompt from '../automatic-feedback-prompt';
11+
import {
12+
FEEDBACK_REPORTING_PREFERENCE_KEY,
13+
setFeedbackReportingPreference,
14+
toolCallsContainFailure,
15+
} from '../../utils/feedback-reporting';
16+
17+
global.IS_REACT_ACT_ENVIRONMENT = true;
18+
19+
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
20+
jest.mock( '@wordpress/data', () => ( {
21+
useDispatch: jest.fn(),
22+
} ) );
23+
jest.mock( '@wordpress/i18n', () => ( {
24+
__: ( text ) => text,
25+
} ) );
26+
jest.mock( '../../store', () => 'sd-ai-agent' );
27+
jest.mock( '@wordpress/components', () => ( {
28+
Button: ( { children, ...props } ) => {
29+
const { createElement: createWpElement } =
30+
jest.requireActual( '@wordpress/element' );
31+
return createWpElement( 'button', props, children );
32+
},
33+
} ) );
34+
35+
const failure = { reason: 'tool_call_error', eventId: 'job-17' };
36+
37+
/**
38+
* Render the automatic feedback prompt.
39+
*
40+
* @return {Promise<{container: HTMLElement, root: import('@wordpress/element').Root}>}
41+
* Rendered prompt and root.
42+
*/
43+
async function renderPrompt() {
44+
const container = document.createElement( 'div' );
45+
document.body.appendChild( container );
46+
const root = createRoot( container );
47+
await act( async () => {
48+
root.render(
49+
createElement( AutomaticFeedbackPrompt, {
50+
sessionId: 17,
51+
failure,
52+
} )
53+
);
54+
} );
55+
return { container, root };
56+
}
57+
58+
/**
59+
* Find a rendered button by its visible label.
60+
*
61+
* @param {HTMLElement} container Rendered test container.
62+
* @param {string} label Button label.
63+
* @return {HTMLButtonElement|undefined} Matching button.
64+
*/
65+
function button( container, label ) {
66+
return [ ...container.querySelectorAll( 'button' ) ].find(
67+
( item ) => item.textContent === label
68+
);
69+
}
70+
71+
describe( 'AutomaticFeedbackPrompt', () => {
72+
let setFeedbackBanner;
73+
74+
beforeEach( () => {
75+
delete global.sdAiAgentData;
76+
localStorage.clear();
77+
apiFetch.mockReset();
78+
apiFetch.mockResolvedValue( { success: true } );
79+
setFeedbackBanner = jest.fn();
80+
useDispatch.mockReturnValue( { setFeedbackBanner } );
81+
} );
82+
83+
afterEach( () => {
84+
document.body.innerHTML = '';
85+
jest.clearAllMocks();
86+
} );
87+
88+
test( 'offers all four reporting choices after a detected failure', async () => {
89+
const { container, root } = await renderPrompt();
90+
91+
expect( container.textContent ).toContain(
92+
'It looks like part of this job failed.'
93+
);
94+
expect( button( container, 'No' ) ).toBeDefined();
95+
expect( button( container, 'No, never' ) ).toBeDefined();
96+
expect( button( container, 'Yes' ) ).toBeDefined();
97+
expect( button( container, 'Yes, always' ) ).toBeDefined();
98+
99+
await act( async () => root.unmount() );
100+
} );
101+
102+
test( 'No dismisses once and No, never persists the opt-out', async () => {
103+
let rendered = await renderPrompt();
104+
await act( async () => {
105+
button( rendered.container, 'No' ).click();
106+
} );
107+
expect( setFeedbackBanner ).toHaveBeenCalledWith( null );
108+
expect(
109+
localStorage.getItem( FEEDBACK_REPORTING_PREFERENCE_KEY )
110+
).toBeNull();
111+
await act( async () => rendered.root.unmount() );
112+
113+
setFeedbackBanner.mockClear();
114+
rendered = await renderPrompt();
115+
await act( async () => {
116+
button( rendered.container, 'No, never' ).click();
117+
} );
118+
expect(
119+
localStorage.getItem( FEEDBACK_REPORTING_PREFERENCE_KEY )
120+
).toBe( 'never' );
121+
expect( setFeedbackBanner ).toHaveBeenCalledWith( null );
122+
await act( async () => rendered.root.unmount() );
123+
} );
124+
125+
test( 'Yes sends this report without changing the preference', async () => {
126+
const { container, root } = await renderPrompt();
127+
await act( async () => {
128+
button( container, 'Yes' ).click();
129+
await Promise.resolve();
130+
} );
131+
132+
expect( apiFetch ).toHaveBeenCalledWith(
133+
expect.objectContaining( {
134+
path: '/sd-ai-agent/v1/feedback/send',
135+
method: 'POST',
136+
data: expect.objectContaining( {
137+
report_type: 'self_reported',
138+
session_id: 17,
139+
} ),
140+
} )
141+
);
142+
expect(
143+
localStorage.getItem( FEEDBACK_REPORTING_PREFERENCE_KEY )
144+
).toBeNull();
145+
expect( setFeedbackBanner ).toHaveBeenCalledWith( null );
146+
147+
await act( async () => root.unmount() );
148+
} );
149+
150+
test( 'Yes, always sends now and automatically sends later failures', async () => {
151+
let rendered = await renderPrompt();
152+
await act( async () => {
153+
button( rendered.container, 'Yes, always' ).click();
154+
await Promise.resolve();
155+
} );
156+
expect(
157+
localStorage.getItem( FEEDBACK_REPORTING_PREFERENCE_KEY )
158+
).toBe( 'always' );
159+
expect( apiFetch ).toHaveBeenCalledTimes( 1 );
160+
await act( async () => rendered.root.unmount() );
161+
162+
apiFetch.mockClear();
163+
setFeedbackBanner.mockClear();
164+
rendered = await renderPrompt();
165+
await act( async () => Promise.resolve() );
166+
expect( apiFetch ).toHaveBeenCalledTimes( 1 );
167+
expect( rendered.container.querySelector( 'button' ) ).toBeNull();
168+
expect( setFeedbackBanner ).toHaveBeenCalledWith( null );
169+
await act( async () => rendered.root.unmount() );
170+
} );
171+
} );
172+
173+
describe( 'feedback reporting helpers', () => {
174+
test( 'scopes persistent consent to the current WordPress user', () => {
175+
localStorage.clear();
176+
global.sdAiAgentData = { currentUserId: 42 };
177+
setFeedbackReportingPreference( 'always' );
178+
179+
expect(
180+
localStorage.getItem( `${ FEEDBACK_REPORTING_PREFERENCE_KEY }:42` )
181+
).toBe( 'always' );
182+
expect(
183+
localStorage.getItem( FEEDBACK_REPORTING_PREFERENCE_KEY )
184+
).toBeNull();
185+
} );
186+
187+
test( 'detects explicit tool response failures only', () => {
188+
expect(
189+
toolCallsContainFailure( [
190+
{ type: 'call', id: '1' },
191+
{
192+
type: 'response',
193+
id: '1',
194+
response: { error: 'failed' },
195+
},
196+
] )
197+
).toBe( true );
198+
expect(
199+
toolCallsContainFailure( [
200+
{
201+
type: 'response',
202+
id: '2',
203+
response: { success: true },
204+
},
205+
] )
206+
).toBe( false );
207+
expect(
208+
toolCallsContainFailure( [
209+
{
210+
type: 'response',
211+
response: {
212+
success: true,
213+
result: { success: false, error: 'Validation failed.' },
214+
},
215+
},
216+
] )
217+
).toBe( true );
218+
} );
219+
} );

0 commit comments

Comments
 (0)