@@ -383,84 +383,180 @@ describe('EditorKeyboardController input changes', () => {
383383 } ) ;
384384} ) ;
385385
386- describe ( 'EditorKeyboardController Shift-Tab plan toggle' , ( ) => {
387- function createShiftTabHarness ( options : { sessionless ?: boolean ; engineV2 ?: boolean } = { } ) {
386+ describe ( 'EditorKeyboardController Shift-Tab effort cycle' , ( ) => {
387+ function createEffortHarness (
388+ options : {
389+ supportEfforts ?: string [ ] ;
390+ capabilities ?: string [ ] ;
391+ thinkingEffort ?: string ;
392+ streamingPhase ?: string ;
393+ sessionless ?: boolean ;
394+ engineV2 ?: boolean ;
395+ setThinkingError ?: Error ;
396+ } = { } ,
397+ ) {
388398 const editor : Record < string , ( ( ...args : never [ ] ) => unknown ) | undefined > = {
389399 setHistoryFilter : vi . fn ( ) as unknown as ( ...args : never [ ] ) => unknown ,
390400 } ;
391- const handlePlanToggle = vi . fn ( ) ;
401+ const setThinking = vi . fn ( async ( ) => { } ) ;
402+ if ( options . setThinkingError !== undefined ) {
403+ setThinking . mockRejectedValue ( options . setThinkingError ) ;
404+ }
405+ const setConfig = vi . fn ( async ( ) => { } ) ;
392406 const track = vi . fn ( ) ;
393407 const showError = vi . fn ( ) ;
394- const ensureSession = vi . fn ( async ( ) : Promise < { id : string } | undefined > => ( { id : 'ses-lazy' } ) ) ;
408+ const showNotice = vi . fn ( ) ;
409+ const appState : Record < string , unknown > = {
410+ streamingPhase : options . streamingPhase ?? 'idle' ,
411+ isCompacting : false ,
412+ model : 'kimi-k2' ,
413+ thinkingEffort : options . thinkingEffort ?? 'off' ,
414+ availableModels : {
415+ 'kimi-k2' :
416+ options . supportEfforts === undefined
417+ ? {
418+ provider : 'managed:pythinker-code' ,
419+ model : 'kimi-k2' ,
420+ maxContextSize : 262144 ,
421+ capabilities : options . capabilities ?? [ 'thinking' ] ,
422+ }
423+ : {
424+ provider : 'managed:pythinker-code' ,
425+ model : 'kimi-k2' ,
426+ maxContextSize : 262144 ,
427+ supportEfforts : options . supportEfforts ,
428+ } ,
429+ } ,
430+ } ;
431+ const statePatches : Array < Record < string , unknown > > = [ ] ;
395432 const host = {
396433 state : {
397434 editor,
398435 activeDialog : null ,
399- appState : { streamingPhase : 'idle' , isCompacting : false , planMode : false } ,
436+ appState,
400437 footer : { setTransientHint : vi . fn ( ) } ,
401438 ui : { requestRender : vi . fn ( ) } ,
402439 } ,
403- session : options . sessionless ? undefined : { cancel : vi . fn ( async ( ) => { } ) } ,
440+ session :
441+ options . sessionless === true
442+ ? undefined
443+ : { cancel : vi . fn ( async ( ) => { } ) , setThinking } ,
404444 engineV2 : options . engineV2 ?? false ,
405- ensureSession,
406- handlePlanToggle,
445+ harness : { setConfig } ,
446+ // Merge like the real host so successive presses read fresh effort.
447+ setAppState : ( patch : Record < string , unknown > ) => {
448+ Object . assign ( appState , patch ) ;
449+ statePatches . push ( patch ) ;
450+ } ,
407451 track,
408452 showError,
453+ showNotice,
409454 btwPanelController : { cancelRunning : vi . fn ( ) , closeOrCancel : vi . fn ( ) } ,
410455 } as unknown as EditorKeyboardHost ;
411456
412457 new EditorKeyboardController ( host , undefined as unknown as ImageAttachmentStore ) . install ( ) ;
413458 const onShiftTab = editor [ 'onShiftTab' ] as unknown as ( ) => void ;
414- return { onShiftTab, handlePlanToggle , track, showError, ensureSession } ;
459+ return { onShiftTab, setThinking , setConfig , track, showError, showNotice , statePatches } ;
415460 }
416461
417- it ( 'toggles plan mode directly with an active session' , ( ) => {
418- const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness ( ) ;
462+ async function settle ( ) : Promise < void > {
463+ await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
464+ await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
465+ }
419466
420- onShiftTab ( ) ;
467+ it ( 'cycles off → low → high → max → off and persists the default' , async ( ) => {
468+ const h = createEffortHarness ( { supportEfforts : [ 'low' , 'high' , 'max' ] } ) ;
469+ const press = async ( ) : Promise < unknown > => {
470+ h . onShiftTab ( ) ;
471+ await settle ( ) ;
472+ return h . statePatches . at ( - 1 ) ?. [ 'thinkingEffort' ] ;
473+ } ;
421474
422- expect ( ensureSession ) . not . toHaveBeenCalled ( ) ;
423- expect ( handlePlanToggle ) . toHaveBeenCalledWith ( true ) ;
475+ await expect ( press ( ) ) . resolves . toBe ( 'low' ) ;
476+ expect ( h . setThinking ) . toHaveBeenCalledWith ( 'low' ) ;
477+ expect ( h . setConfig ) . toHaveBeenCalledWith ( { thinking : { enabled : true , effort : 'low' } } ) ;
478+
479+ await expect ( press ( ) ) . resolves . toBe ( 'high' ) ;
480+ await expect ( press ( ) ) . resolves . toBe ( 'max' ) ;
481+ // The top declared level never becomes the stored default effort.
482+ expect ( h . setConfig ) . toHaveBeenLastCalledWith ( { thinking : { enabled : true } } ) ;
483+
484+ await expect ( press ( ) ) . resolves . toBe ( 'off' ) ;
485+ expect ( h . setConfig ) . toHaveBeenLastCalledWith ( { thinking : { enabled : false } } ) ;
486+ expect ( h . track ) . toHaveBeenLastCalledWith ( 'thinking_toggle' , {
487+ enabled : false ,
488+ effort : 'off' ,
489+ from : 'max' ,
490+ } ) ;
424491 } ) ;
425492
426- it ( 'reports no active session on v1 when session-less' , ( ) => {
427- const { onShiftTab, showError, handlePlanToggle } = createShiftTabHarness ( {
428- sessionless : true ,
493+ it ( 'refuses to cycle while a turn is streaming' , async ( ) => {
494+ const h = createEffortHarness ( {
495+ supportEfforts : [ 'low' , 'high' ] ,
496+ streamingPhase : 'composing' ,
429497 } ) ;
430498
431- onShiftTab ( ) ;
499+ h . onShiftTab ( ) ;
500+ await settle ( ) ;
501+
502+ expect ( h . showError ) . toHaveBeenCalledWith (
503+ 'Cannot change thinking effort while streaming — press Esc or Ctrl-C first.' ,
504+ ) ;
505+ expect ( h . setThinking ) . not . toHaveBeenCalled ( ) ;
506+ } ) ;
507+
508+ it ( 'reports no active session on v1 when session-less' , async ( ) => {
509+ const h = createEffortHarness ( { supportEfforts : [ 'low' , 'high' ] , sessionless : true } ) ;
510+
511+ h . onShiftTab ( ) ;
512+ await settle ( ) ;
432513
433- expect ( showError ) . toHaveBeenCalledWith ( NO_ACTIVE_SESSION_MESSAGE ) ;
434- expect ( handlePlanToggle ) . not . toHaveBeenCalled ( ) ;
514+ expect ( h . showError ) . toHaveBeenCalledWith ( NO_ACTIVE_SESSION_MESSAGE ) ;
515+ expect ( h . statePatches ) . toEqual ( [ ] ) ;
435516 } ) ;
436517
437- it ( 'lazy-creates the session before toggling on v2 when session-less' , async ( ) => {
438- const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness ( {
518+ it ( 'carries the cycled effort into the lazy v2 session when session-less' , async ( ) => {
519+ const h = createEffortHarness ( {
520+ supportEfforts : [ 'low' , 'high' ] ,
439521 sessionless : true ,
440522 engineV2 : true ,
441523 } ) ;
442524
443- onShiftTab ( ) ;
444- expect ( handlePlanToggle ) . not . toHaveBeenCalled ( ) ;
525+ h . onShiftTab ( ) ;
526+ await settle ( ) ;
445527
446- await vi . waitFor ( ( ) => {
447- expect ( handlePlanToggle ) . toHaveBeenCalledWith ( true ) ;
528+ expect ( h . setThinking ) . not . toHaveBeenCalled ( ) ;
529+ expect ( h . statePatches . at ( - 1 ) ) . toMatchObject ( {
530+ thinkingEffort : 'low' ,
531+ lazySessionThinking : 'low' ,
448532 } ) ;
449- expect ( ensureSession ) . toHaveBeenCalledOnce ( ) ;
450- expect ( track ) . toHaveBeenCalledWith ( 'shortcut_plan_toggle' , { enabled : true } ) ;
533+ expect ( h . showError ) . not . toHaveBeenCalled ( ) ;
451534 } ) ;
452535
453- it ( 'does not toggle when the lazy creation fails on v2' , async ( ) => {
454- const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness ( {
455- sessionless : true ,
456- engineV2 : true ,
536+ it ( 'notifies when the model offers no selectable levels' , async ( ) => {
537+ const h = createEffortHarness ( { capabilities : [ 'always_thinking' ] } ) ;
538+
539+ h . onShiftTab ( ) ;
540+ await settle ( ) ;
541+
542+ expect ( h . showNotice ) . toHaveBeenCalledWith (
543+ 'kimi-k2 does not offer selectable thinking effort levels.' ,
544+ ) ;
545+ expect ( h . setThinking ) . not . toHaveBeenCalled ( ) ;
546+ } ) ;
547+
548+ it ( 'surfaces a setThinking failure without changing state' , async ( ) => {
549+ const h = createEffortHarness ( {
550+ supportEfforts : [ 'low' , 'high' ] ,
551+ setThinkingError : new Error ( 'boom' ) ,
457552 } ) ;
458- ensureSession . mockResolvedValue ( undefined ) ;
459553
460- onShiftTab ( ) ;
461- await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
554+ h . onShiftTab ( ) ;
555+ await settle ( ) ;
462556
463- expect ( handlePlanToggle ) . not . toHaveBeenCalled ( ) ;
557+ expect ( h . showError ) . toHaveBeenCalledWith ( 'Failed to set thinking effort: boom' ) ;
558+ expect ( h . statePatches ) . toEqual ( [ ] ) ;
559+ expect ( h . setConfig ) . not . toHaveBeenCalled ( ) ;
464560 } ) ;
465561} ) ;
466562
0 commit comments