@@ -69,44 +69,80 @@ function tryParseYamlCandidate(candidate: string): MenuItem[] | null {
6969 return null ;
7070}
7171
72- export function parsePlanReviewOptions ( text : string , metadata ?: any ) : MenuItem [ ] {
72+ export type ParseOptions = { enableFallback ?: boolean } ;
73+
74+ function sanitizeId ( rawId : string ) : string {
75+ return String ( rawId )
76+ . trim ( )
77+ . replace ( / \s + / g, '_' )
78+ . replace ( / [ ^ a - z A - Z 0 - 9 _ \- : \. ] / g, '' )
79+ . slice ( 0 , 50 ) ;
80+ }
81+
82+ function sanitizeLabel ( rawLabel : string ) : string {
83+ if ( ! rawLabel ) return '' ;
84+ // remove control characters and collapse whitespace
85+ const cleaned = String ( rawLabel ) . replace ( / [ \x00 - \x1F \x7F ] + / g, ' ' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
86+ return cleaned . slice ( 0 , 200 ) ;
87+ }
88+
89+ function sanitizeMenuItem ( item : MenuItem ) : MenuItem {
90+ return {
91+ id : sanitizeId ( item . id || item . label || 'item' ) ,
92+ label : sanitizeLabel ( item . label || item . id || '' ) ,
93+ description : sanitizeLabel ( item . description || '' ) ,
94+ recommended : ! ! item . recommended ,
95+ } ;
96+ }
97+
98+ export function parsePlanReviewOptions ( text : string , metadata ?: any , options ?: ParseOptions ) : MenuItem [ ] {
99+ const enabled = Boolean ( options ?. enableFallback || process . env . COPILOT_PLAN_FALLBACK === '1' || process . env . NODE_ENV === 'test' ) ;
100+
73101 // 1) If metadata contains tool/function call structured actions, prefer that
74102 if ( metadata && metadata . function_call && metadata . function_call . arguments ) {
75103 try {
76104 const args = JSON . parse ( metadata . function_call . arguments ) ;
77- if ( Array . isArray ( args ) ) return ( args as any [ ] ) . map ( ( it , idx ) => normalizeMenuItem ( it , idx ) ) ;
105+ if ( Array . isArray ( args ) ) return ( args as any [ ] ) . map ( ( it , idx ) => sanitizeMenuItem ( normalizeMenuItem ( it , idx ) ) ) ;
78106 } catch {
79107 // ignore and fallthrough
80108 }
81109 }
82110
111+ if ( ! enabled ) {
112+ // Feature-flag off: don't attempt heuristic parsing; return minimal safe fallback
113+ return [
114+ { id : 'accept' , label : 'Accept plan' , description : 'Apply the plan as-is' } ,
115+ { id : 'request_changes' , label : 'Request changes' , description : 'Ask the model for updates' } ,
116+ ] ;
117+ }
118+
83119 // 2) Extract fenced code blocks (json/yaml/none)
84120 const fenceRegex = / ` ` ` (?: j s o n | y a m l | y m l ) ? \n ( [ \s \S ] * ?) \n ` ` ` / gi;
85121 let m : RegExpExecArray | null ;
86122 while ( ( m = fenceRegex . exec ( text ) ) !== null ) {
87123 const candidate = m [ 1 ] . trim ( ) ;
88124 // try JSON
89125 const j = tryParseJsonCandidate ( candidate ) ;
90- if ( j && j . length ) return j ;
126+ if ( j && j . length ) return j . map ( sanitizeMenuItem ) ;
91127 // try YAML
92128 const y = tryParseYamlCandidate ( candidate ) ;
93- if ( y && y . length ) return y ;
129+ if ( y && y . length ) return y . map ( sanitizeMenuItem ) ;
94130 }
95131
96132 // 3) Try to find inline JSON arrays/objects anywhere
97133 const inlineJsonRegex = / ( \[ [ \s \S ] * ?\] | \{ [ \s \S ] * ?\} ) / g;
98134 while ( ( m = inlineJsonRegex . exec ( text ) ) !== null ) {
99135 const candidate = m [ 1 ] ;
100136 const parsed = tryParseJsonCandidate ( candidate ) ;
101- if ( parsed && parsed . length ) return parsed ;
137+ if ( parsed && parsed . length ) return parsed . map ( sanitizeMenuItem ) ;
102138 }
103139
104140 // 4) Try to find YAML-like blocks without fences (look for lines starting with ---)
105141 const yamlDocRegex = / ( ^ - - - [ \s \S ] * ?\n (?: \. { 3 } \s * $ | $ ) ) / gm;
106142 while ( ( m = yamlDocRegex . exec ( text ) ) !== null ) {
107143 const candidate = m [ 1 ] ;
108144 const y = tryParseYamlCandidate ( candidate ) ;
109- if ( y && y . length ) return y ;
145+ if ( y && y . length ) return y . map ( sanitizeMenuItem ) ;
110146 }
111147
112148 // 5) Numbered list heuristic with stronger id extraction
@@ -116,7 +152,7 @@ export function parsePlanReviewOptions(text: string, metadata?: any): MenuItem[]
116152 const numMatch = line . match ( / ^ \s * ( \d + ) \. \s * ( .+ ) $ / ) ;
117153 if ( numMatch ) {
118154 const raw = numMatch [ 2 ] . trim ( ) ;
119- numbered . push ( normalizeMenuItem ( raw , numbered . length ) ) ;
155+ numbered . push ( sanitizeMenuItem ( normalizeMenuItem ( raw , numbered . length ) ) ) ;
120156 }
121157 }
122158 if ( numbered . length ) return numbered ;
@@ -126,15 +162,15 @@ export function parsePlanReviewOptions(text: string, metadata?: any): MenuItem[]
126162 for ( const line of lines ) {
127163 const b = line . match ( / ^ \s * [ - * + ] \s + ( .+ ) $ / ) ;
128164 if ( b ) {
129- bullets . push ( normalizeMenuItem ( b [ 1 ] . trim ( ) , bullets . length ) ) ;
165+ bullets . push ( sanitizeMenuItem ( normalizeMenuItem ( b [ 1 ] . trim ( ) , bullets . length ) ) ) ;
130166 }
131167 }
132168 if ( bullets . length ) return bullets ;
133169
134170 // 7) Minimal fallback: Accept / Request changes
135171 return [
136- { id : 'accept' , label : 'Accept plan' , description : 'Apply the plan as-is' } ,
137- { id : 'request_changes' , label : 'Request changes' , description : 'Ask the model for updates' } ,
172+ sanitizeMenuItem ( { id : 'accept' , label : 'Accept plan' } ) ,
173+ sanitizeMenuItem ( { id : 'request_changes' , label : 'Request changes' } ) ,
138174 ] ;
139175}
140176
0 commit comments