@@ -97,21 +97,6 @@ vi.mock('@openbitfun/ui', async importOriginal => ({
9797 onChange ?: React . ChangeEventHandler < HTMLInputElement > ;
9898 placeholder ?: string ;
9999 } ) => < input value = { value } onChange = { onChange } placeholder = { placeholder } /> ,
100- Select : ( {
101- value,
102- onChange,
103- options,
104- } : {
105- value ?: string ;
106- onChange ?: ( value : string ) => void ;
107- options ?: Array < { value : string ; label : string } > ;
108- } ) => (
109- < select value = { value } onChange = { ( event ) => onChange ?.( event . target . value ) } >
110- { ( options ?? [ ] ) . map ( ( option ) => (
111- < option key = { option . value } value = { option . value } > { option . label } </ option >
112- ) ) }
113- </ select >
114- ) ,
115100 TabGroup : ( {
116101 items,
117102 onValueChange,
@@ -244,6 +229,19 @@ async function openView(container: HTMLElement, label: string): Promise<void> {
244229 } ) ;
245230}
246231
232+ async function selectPermission ( container : HTMLElement , value : string ) : Promise < void > {
233+ const trigger = container . querySelector < HTMLButtonElement > (
234+ '[data-openbitfun-part="confirmation"] button[role="combobox"]' ,
235+ ) ;
236+ expect ( trigger ) . not . toBeNull ( ) ;
237+ await act ( async ( ) => trigger ! . click ( ) ) ;
238+ const options = Array . from ( document . querySelectorAll < HTMLElement > ( '[role="option"]' ) ) ;
239+ expect ( options . map ( option => option . dataset . value ) ) . toEqual ( [ 'ask' , 'allow_once' ] ) ;
240+ const selected = options . find ( option => option . dataset . value === value ) ;
241+ expect ( selected ) . toBeTruthy ( ) ;
242+ await act ( async ( ) => selected ! . click ( ) ) ;
243+ }
244+
247245describe ( 'AcpAgentsConfig' , ( ) => {
248246 let container : HTMLDivElement ;
249247 let root : Root ;
@@ -300,6 +298,116 @@ describe('AcpAgentsConfig', () => {
300298 vi . clearAllMocks ( ) ;
301299 } ) ;
302300
301+ it . each ( [
302+ [ 'ask' , 'ask' ] ,
303+ [ 'allow_once' , 'allow_once' ] ,
304+ ] ) ( 'loads and saves permission mode %s as %s with only supported choices' , async ( storedMode , expectedMode ) => {
305+ loadJsonConfigMock . mockResolvedValue ( JSON . stringify ( {
306+ acpClients : {
307+ opencode : { command : 'opencode' , args : [ 'acp' ] , permissionMode : storedMode } ,
308+ } ,
309+ } ) ) ;
310+
311+ await act ( async ( ) => {
312+ root . render ( < AcpAgentsConfig /> ) ;
313+ } ) ;
314+
315+ expect ( container . textContent ) . not . toContain ( 'permissionMode.legacyRejectWarning' ) ;
316+ await selectPermission ( container , expectedMode ) ;
317+ expect ( saveJsonConfigMock ) . not . toHaveBeenCalled ( ) ;
318+
319+ await openView ( container , 'views.json' ) ;
320+ const editor = container . querySelector < HTMLTextAreaElement > ( 'textarea' ) ! ;
321+ await act ( async ( ) => {
322+ Object . getOwnPropertyDescriptor ( HTMLTextAreaElement . prototype , 'value' ) ?. set
323+ ?. call ( editor , `${ editor . value } \n` ) ;
324+ editor . dispatchEvent ( new Event ( 'input' , { bubbles : true } ) ) ;
325+ } ) ;
326+ const saveButton = Array . from ( container . querySelectorAll ( 'button' ) )
327+ . find ( button => button . textContent === 'actions.saveJson' ) ;
328+ expect ( saveButton ?. disabled ) . toBe ( false ) ;
329+ await act ( async ( ) => {
330+ saveButton ! . click ( ) ;
331+ } ) ;
332+
333+ const savedConfig = JSON . parse ( saveJsonConfigMock . mock . calls [ 0 ] [ 0 ] ) ;
334+ expect ( savedConfig . acpClients . opencode ) . toMatchObject ( {
335+ command : 'opencode' , args : [ 'acp' ] , permissionMode : expectedMode ,
336+ } ) ;
337+ } ) ;
338+
339+ it . each ( [
340+ [ 'local' , true ] ,
341+ [ 'ssh' , true ] ,
342+ [ 'json' , true ] ,
343+ [ 'local' , false ] ,
344+ ] ) ( 'explicitly applies a legacy permission from %s (wrapped config: %s)' , async ( view , wrapped ) => {
345+ const legacyClient = {
346+ command : 'opencode' , args : [ 'acp' ] , env : { ACP_TEST : 'preserved' } , permissionMode : 'reject_once' ,
347+ } ;
348+ const otherClient = { command : 'custom-agent' , args : [ ] , permissionMode : 'allow_once' } ;
349+ const acpClients = { opencode : legacyClient , custom : otherClient } ;
350+ loadJsonConfigMock . mockResolvedValue ( JSON . stringify ( wrapped ? { acpClients } : acpClients ) ) ;
351+ saveJsonConfigMock . mockImplementation ( async ( rawConfig : string ) => {
352+ loadJsonConfigMock . mockResolvedValue ( rawConfig ) ;
353+ window . dispatchEvent ( new Event ( 'openbitfun:acp-clients-changed' ) ) ;
354+ } ) ;
355+
356+ await act ( async ( ) => root . render ( < AcpAgentsConfig /> ) ) ;
357+ expect ( container . querySelector ( '[data-openbitfun-part="confirmation"]' ) ?. textContent )
358+ . toContain ( 'permissionMode.ask' ) ;
359+ expect ( container . querySelector ( '[role="alert"]' ) ?. textContent )
360+ . toContain ( 'permissionMode.legacyRejectWarning' ) ;
361+ expect ( saveJsonConfigMock ) . not . toHaveBeenCalled ( ) ;
362+
363+ // The real Select does not emit a change when Ask is already selected.
364+ await selectPermission ( container , 'ask' ) ;
365+ expect ( Array . from ( container . querySelectorAll ( 'button' ) )
366+ . find ( button => button . textContent === 'actions.save' ) ) . toBeUndefined ( ) ;
367+ if ( view !== 'local' ) await openView ( container , `views.${ view } ` ) ;
368+ expect ( saveJsonConfigMock ) . not . toHaveBeenCalled ( ) ;
369+
370+ const applyButton = Array . from ( container . querySelectorAll ( 'button' ) )
371+ . find ( button => button . textContent === 'permissionMode.saveAndApply' ) ;
372+ expect ( applyButton ?. disabled ) . toBe ( false ) ;
373+ await act ( async ( ) => applyButton ! . click ( ) ) ;
374+
375+ expect ( saveJsonConfigMock ) . toHaveBeenCalledTimes ( 1 ) ;
376+ const savedConfig = JSON . parse ( saveJsonConfigMock . mock . calls [ 0 ] [ 0 ] ) ;
377+ expect ( savedConfig . acpClients . opencode ) . toMatchObject ( { ...legacyClient , permissionMode : 'ask' } ) ;
378+ expect ( savedConfig . acpClients . custom ) . toMatchObject ( otherClient ) ;
379+ expect ( container . textContent ) . not . toContain ( 'permissionMode.legacyRejectWarning' ) ;
380+ expect ( container . textContent ) . not . toContain ( 'permissionMode.saveAndApply' ) ;
381+
382+ await act ( async ( ) => {
383+ window . dispatchEvent ( new Event ( 'openbitfun:acp-clients-changed' ) ) ;
384+ } ) ;
385+ expect ( container . textContent ) . not . toContain ( 'permissionMode.legacyRejectWarning' ) ;
386+ expect ( saveJsonConfigMock ) . toHaveBeenCalledTimes ( 1 ) ;
387+ } ) ;
388+
389+ it ( 'keeps the migration action after a failed save and applies the current selection on retry' , async ( ) => {
390+ loadJsonConfigMock . mockResolvedValue ( JSON . stringify ( {
391+ acpClients : { opencode : { command : 'opencode' , permissionMode : 'reject_once' } } ,
392+ } ) ) ;
393+ saveJsonConfigMock . mockRejectedValueOnce ( new Error ( 'Save failed' ) ) ;
394+ await act ( async ( ) => root . render ( < AcpAgentsConfig /> ) ) ;
395+ await selectPermission ( container , 'allow_once' ) ;
396+ const applyButton = ( ) => Array . from ( container . querySelectorAll ( 'button' ) )
397+ . find ( button => button . textContent === 'permissionMode.saveAndApply' ) ;
398+
399+ await act ( async ( ) => applyButton ( ) ! . click ( ) ) ;
400+ expect ( notifyErrorMock ) . toHaveBeenCalledWith ( 'Save failed' , expect . anything ( ) ) ;
401+ expect ( container . textContent ) . toContain ( 'permissionMode.legacyRejectWarning' ) ;
402+ expect ( applyButton ( ) ?. disabled ) . toBe ( false ) ;
403+
404+ await act ( async ( ) => applyButton ( ) ! . click ( ) ) ;
405+ expect ( saveJsonConfigMock ) . toHaveBeenCalledTimes ( 2 ) ;
406+ expect ( JSON . parse ( saveJsonConfigMock . mock . calls [ 1 ] [ 0 ] ) . acpClients . opencode . permissionMode )
407+ . toBe ( 'allow_once' ) ;
408+ expect ( container . textContent ) . not . toContain ( 'permissionMode.legacyRejectWarning' ) ;
409+ } ) ;
410+
303411 it ( 'probes requirements when opened and does not treat missing probe data as invalid config' , async ( ) => {
304412 await act ( async ( ) => {
305413 root . render ( < AcpAgentsConfig /> ) ;
0 commit comments