@@ -28,6 +28,110 @@ const logger = createLogger('CopilotSseToolExecution')
2828
2929const OUTPUT_PATH_TOOLS = new Set ( [ 'function_execute' , 'user_table' ] )
3030
31+ /**
32+ * Try to pull a flat array of row-objects out of the various shapes that
33+ * `function_execute` and `user_table` can return.
34+ */
35+ function extractTabularData ( output : unknown ) : Record < string , unknown > [ ] | null {
36+ if ( ! output || typeof output !== 'object' ) return null
37+
38+ if ( Array . isArray ( output ) ) {
39+ if ( output . length > 0 && typeof output [ 0 ] === 'object' && output [ 0 ] !== null ) {
40+ return output as Record < string , unknown > [ ]
41+ }
42+ return null
43+ }
44+
45+ const obj = output as Record < string , unknown >
46+
47+ // function_execute shape: { result: [...], stdout: "..." }
48+ if ( Array . isArray ( obj . result ) ) {
49+ const rows = obj . result
50+ if ( rows . length > 0 && typeof rows [ 0 ] === 'object' && rows [ 0 ] !== null ) {
51+ return rows as Record < string , unknown > [ ]
52+ }
53+ }
54+
55+ // user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } }
56+ if ( obj . data && typeof obj . data === 'object' && ! Array . isArray ( obj . data ) ) {
57+ const data = obj . data as Record < string , unknown >
58+ if ( Array . isArray ( data . rows ) && data . rows . length > 0 ) {
59+ const rows = data . rows as Record < string , unknown > [ ]
60+ // user_table rows nest actual values inside .data
61+ if ( typeof rows [ 0 ] . data === 'object' && rows [ 0 ] . data !== null ) {
62+ return rows . map ( ( r ) => r . data as Record < string , unknown > )
63+ }
64+ return rows
65+ }
66+ }
67+
68+ return null
69+ }
70+
71+ function escapeCsvValue ( value : unknown ) : string {
72+ if ( value === null || value === undefined ) return ''
73+ const str = typeof value === 'object' ? JSON . stringify ( value ) : String ( value )
74+ if ( str . includes ( ',' ) || str . includes ( '"' ) || str . includes ( '\n' ) || str . includes ( '\r' ) ) {
75+ return `"${ str . replace ( / " / g, '""' ) } "`
76+ }
77+ return str
78+ }
79+
80+ function convertRowsToCsv ( rows : Record < string , unknown > [ ] ) : string {
81+ if ( rows . length === 0 ) return ''
82+
83+ const headerSet = new Set < string > ( )
84+ for ( const row of rows ) {
85+ for ( const key of Object . keys ( row ) ) {
86+ headerSet . add ( key )
87+ }
88+ }
89+ const headers = [ ...headerSet ]
90+
91+ const lines = [ headers . map ( escapeCsvValue ) . join ( ',' ) ]
92+ for ( const row of rows ) {
93+ lines . push ( headers . map ( ( h ) => escapeCsvValue ( row [ h ] ) ) . join ( ',' ) )
94+ }
95+ return lines . join ( '\n' )
96+ }
97+
98+ type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
99+
100+ const EXT_TO_FORMAT : Record < string , OutputFormat > = {
101+ '.json' : 'json' ,
102+ '.csv' : 'csv' ,
103+ '.txt' : 'txt' ,
104+ '.md' : 'md' ,
105+ '.html' : 'html' ,
106+ }
107+
108+ const FORMAT_TO_CONTENT_TYPE : Record < OutputFormat , string > = {
109+ json : 'application/json' ,
110+ csv : 'text/csv' ,
111+ txt : 'text/plain' ,
112+ md : 'text/markdown' ,
113+ html : 'text/html' ,
114+ }
115+
116+ function resolveOutputFormat ( fileName : string , explicit ?: string ) : OutputFormat {
117+ if ( explicit && explicit in FORMAT_TO_CONTENT_TYPE ) return explicit as OutputFormat
118+ const ext = fileName . slice ( fileName . lastIndexOf ( '.' ) ) . toLowerCase ( )
119+ return EXT_TO_FORMAT [ ext ] ?? 'json'
120+ }
121+
122+ function serializeOutputForFile ( output : unknown , format : OutputFormat ) : string {
123+ if ( typeof output === 'string' ) return output
124+
125+ if ( format === 'csv' ) {
126+ const rows = extractTabularData ( output )
127+ if ( rows && rows . length > 0 ) {
128+ return convertRowsToCsv ( rows )
129+ }
130+ }
131+
132+ return JSON . stringify ( output , null , 2 )
133+ }
134+
31135async function maybeWriteOutputToFile (
32136 toolName : string ,
33137 params : Record < string , unknown > | undefined ,
@@ -38,30 +142,19 @@ async function maybeWriteOutputToFile(
38142 if ( ! OUTPUT_PATH_TOOLS . has ( toolName ) ) return result
39143 if ( ! context . workspaceId || ! context . userId ) return result
40144
145+ const args = params ?. args as Record < string , unknown > | undefined
41146 const outputPath =
42- ( params ?. outputPath as string | undefined ) ??
43- ( ( params ?. args as Record < string , unknown > | undefined ) ?. outputPath as string | undefined )
147+ ( params ?. outputPath as string | undefined ) ?? ( args ?. outputPath as string | undefined )
44148 if ( ! outputPath ) return result
45149
150+ const explicitFormat =
151+ ( params ?. outputFormat as string | undefined ) ?? ( args ?. outputFormat as string | undefined )
46152 const fileName = outputPath . replace ( / ^ f i l e s \/ / , '' )
153+ const format = resolveOutputFormat ( fileName , explicitFormat )
47154
48155 try {
49- let content : string
50- if ( typeof result . output === 'string' ) {
51- content = result . output
52- } else {
53- content = JSON . stringify ( result . output , null , 2 )
54- }
55-
56- const contentType = fileName . endsWith ( '.json' )
57- ? 'application/json'
58- : fileName . endsWith ( '.csv' )
59- ? 'text/csv'
60- : fileName . endsWith ( '.md' )
61- ? 'text/markdown'
62- : fileName . endsWith ( '.html' )
63- ? 'text/html'
64- : 'text/plain'
156+ const content = serializeOutputForFile ( result . output , format )
157+ const contentType = FORMAT_TO_CONTENT_TYPE [ format ]
65158
66159 const buffer = Buffer . from ( content , 'utf-8' )
67160 const uploaded = await uploadWorkspaceFile (
0 commit comments