@@ -14,6 +14,7 @@ import { Emitter } from '../../../../util/vs/base/common/event';
1414import { URI } from '../../../../util/vs/base/common/uri' ;
1515import { eventToPromise } from '../../../completions-core/vscode-node/lib/src/prompt/asyncUtils' ;
1616import { ChatSessionWorktreeData , ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService' ;
17+ import { IWorkspaceInfo } from '../../common/workspaceInfo' ;
1718import { getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers' ;
1819import { ChatSessionMetadataStore } from '../chatSessionMetadataStoreImpl' ;
1920
@@ -339,7 +340,7 @@ describe('ChatSessionMetadataStore', () => {
339340 store . dispose ( ) ;
340341 } ) ;
341342
342- it ( 'should not retry entries with no workspaceFolder or worktreeProperties ' , async ( ) => {
343+ it ( 'should not retry entries with no workspaceFolder, worktreeProperties, or additionalWorkspaces ' , async ( ) => {
343344 const existingData = {
344345 'session-empty' : { } ,
345346 'session-folder' : { workspaceFolder : { folderPath : Uri . file ( '/workspace/a' ) . fsPath , timestamp : 100 } } ,
@@ -358,6 +359,32 @@ describe('ChatSessionMetadataStore', () => {
358359 expect ( sessionEmptyStatCalls ) . toHaveLength ( 0 ) ;
359360 store . dispose ( ) ;
360361 } ) ;
362+
363+ it ( 'should retry entries that only have additionalWorkspaces (not delete as invalid data)' , async ( ) => {
364+ // A session with only additionalWorkspaces and writtenToDisc: false
365+ // must be retried, not deleted from cache — otherwise data is lost after a crash.
366+ const existingData = {
367+ 'session-only-additional' : {
368+ additionalWorkspaces : [
369+ { workspaceFolder : { folderPath : Uri . file ( '/extra/workspace' ) . fsPath , timestamp : 100 } } ,
370+ ] ,
371+ } ,
372+ } ;
373+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( existingData ) ) ;
374+
375+ // Pre-create the session directory so the recovery write can succeed
376+ await mockFs . createDirectory ( sessionDirectoryUri ( 'session-only-additional' ) ) ;
377+ const fileCreated = eventToPromise ( mockFs . onDidCreateFile . event ) ;
378+
379+ const store = await createStore ( ) ;
380+ await fileCreated ;
381+
382+ const fileUri = sessionMetadataFileUri ( 'session-only-additional' ) ;
383+ const rawContent = await mockFs . readFile ( fileUri ) ;
384+ const written = JSON . parse ( new TextDecoder ( ) . decode ( rawContent ) ) ;
385+ expect ( written . additionalWorkspaces ) . toHaveLength ( 1 ) ;
386+ store . dispose ( ) ;
387+ } ) ;
361388 } ) ;
362389
363390 // ──────────────────────────────────────────────────────────────────────────
@@ -1409,6 +1436,186 @@ describe('ChatSessionMetadataStore', () => {
14091436 } ) ;
14101437 } ) ;
14111438
1439+ // ──────────────────────────────────────────────────────────────────────────
1440+ // setAdditionalWorkspaces / getAdditionalWorkspaces
1441+ // ──────────────────────────────────────────────────────────────────────────
1442+ describe ( 'setAdditionalWorkspaces / getAdditionalWorkspaces' , ( ) => {
1443+ it ( 'should store and retrieve workspace-folder type additional workspaces' , async ( ) => {
1444+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1445+ const store = await createStore ( ) ;
1446+
1447+ const workspaces : IWorkspaceInfo [ ] = [
1448+ { folder : Uri . file ( '/extra/a' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1449+ { folder : Uri . file ( '/extra/b' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1450+ ] ;
1451+ await store . setAdditionalWorkspaces ( 'session-1' , workspaces ) ;
1452+
1453+ const result = await store . getAdditionalWorkspaces ( 'session-1' ) ;
1454+ expect ( result ) . toHaveLength ( 2 ) ;
1455+ expect ( result [ 0 ] . folder ?. fsPath ) . toBe ( Uri . file ( '/extra/a' ) . fsPath ) ;
1456+ expect ( result [ 1 ] . folder ?. fsPath ) . toBe ( Uri . file ( '/extra/b' ) . fsPath ) ;
1457+ store . dispose ( ) ;
1458+ } ) ;
1459+
1460+ it ( 'should store and retrieve worktree type additional workspaces' , async ( ) => {
1461+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1462+ const store = await createStore ( ) ;
1463+ const props = makeWorktreeV1Props ( ) ;
1464+
1465+ const workspaces : IWorkspaceInfo [ ] = [
1466+ { folder : undefined , repository : Uri . file ( '/repo' ) , worktree : Uri . file ( '/repo/.worktrees/wt' ) , worktreeProperties : props } ,
1467+ ] ;
1468+ await store . setAdditionalWorkspaces ( 'session-wt' , workspaces ) ;
1469+
1470+ const result = await store . getAdditionalWorkspaces ( 'session-wt' ) ;
1471+ expect ( result ) . toHaveLength ( 1 ) ;
1472+ expect ( result [ 0 ] . worktreeProperties ?. branchName ) . toBe ( props . branchName ) ;
1473+ expect ( result [ 0 ] . worktree ?. fsPath ) . toBe ( Uri . file ( '/repo/.worktrees/wt' ) . fsPath ) ;
1474+ // worktreeProperties present → folder should be undefined per getAdditionalWorkspaces logic
1475+ expect ( result [ 0 ] . folder ) . toBeUndefined ( ) ;
1476+ store . dispose ( ) ;
1477+ } ) ;
1478+
1479+ it ( 'should return empty array when no additional workspaces are set' , async ( ) => {
1480+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( {
1481+ 'session-1' : { workspaceFolder : { folderPath : Uri . file ( '/a' ) . fsPath , timestamp : 1 } } ,
1482+ } ) ) ;
1483+ const store = await createStore ( ) ;
1484+
1485+ const result = await store . getAdditionalWorkspaces ( 'session-1' ) ;
1486+ expect ( result ) . toEqual ( [ ] ) ;
1487+ store . dispose ( ) ;
1488+ } ) ;
1489+
1490+ it ( 'should return empty array for unknown session' , async ( ) => {
1491+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1492+ const store = await createStore ( ) ;
1493+
1494+ const result = await store . getAdditionalWorkspaces ( 'nonexistent' ) ;
1495+ expect ( result ) . toEqual ( [ ] ) ;
1496+ store . dispose ( ) ;
1497+ } ) ;
1498+
1499+ it ( 'should write additionalWorkspaces to per-session file' , async ( ) => {
1500+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1501+ const store = await createStore ( ) ;
1502+
1503+ await store . setAdditionalWorkspaces ( 'session-1' , [
1504+ { folder : Uri . file ( '/extra/a' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1505+ ] ) ;
1506+
1507+ const fileUri = sessionMetadataFileUri ( 'session-1' ) ;
1508+ const rawContent = await mockFs . readFile ( fileUri ) ;
1509+ const written = JSON . parse ( new TextDecoder ( ) . decode ( rawContent ) ) ;
1510+ expect ( written . additionalWorkspaces ) . toHaveLength ( 1 ) ;
1511+ expect ( written . additionalWorkspaces [ 0 ] . workspaceFolder ?. folderPath ) . toBe ( Uri . file ( '/extra/a' ) . fsPath ) ;
1512+ store . dispose ( ) ;
1513+ } ) ;
1514+
1515+ it ( 'should preserve existing workspaceFolder when setting additionalWorkspaces' , async ( ) => {
1516+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( {
1517+ 'session-1' : { workspaceFolder : { folderPath : Uri . file ( '/primary' ) . fsPath , timestamp : 100 } } ,
1518+ } ) ) ;
1519+ const store = await createStore ( ) ;
1520+
1521+ await store . setAdditionalWorkspaces ( 'session-1' , [
1522+ { folder : Uri . file ( '/extra/a' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1523+ ] ) ;
1524+
1525+ // Primary workspace folder should still be accessible
1526+ const folder = await store . getSessionWorkspaceFolder ( 'session-1' ) ;
1527+ expect ( folder ?. fsPath ) . toBe ( Uri . file ( '/primary' ) . fsPath ) ;
1528+
1529+ // Additional workspaces should also be present
1530+ const result = await store . getAdditionalWorkspaces ( 'session-1' ) ;
1531+ expect ( result ) . toHaveLength ( 1 ) ;
1532+ expect ( result [ 0 ] . folder ?. fsPath ) . toBe ( Uri . file ( '/extra/a' ) . fsPath ) ;
1533+ store . dispose ( ) ;
1534+ } ) ;
1535+
1536+ it ( 'should replace previous additionalWorkspaces on subsequent call' , async ( ) => {
1537+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1538+ const store = await createStore ( ) ;
1539+
1540+ await store . setAdditionalWorkspaces ( 'session-1' , [
1541+ { folder : Uri . file ( '/old' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1542+ ] ) ;
1543+ await store . setAdditionalWorkspaces ( 'session-1' , [
1544+ { folder : Uri . file ( '/new/a' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1545+ { folder : Uri . file ( '/new/b' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1546+ ] ) ;
1547+
1548+ const result = await store . getAdditionalWorkspaces ( 'session-1' ) ;
1549+ expect ( result ) . toHaveLength ( 2 ) ;
1550+ expect ( result [ 0 ] . folder ?. fsPath ) . toBe ( Uri . file ( '/new/a' ) . fsPath ) ;
1551+ store . dispose ( ) ;
1552+ } ) ;
1553+
1554+ it ( 'should trigger debounced bulk storage update' , async ( ) => {
1555+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( { } ) ) ;
1556+ const store = await createStore ( ) ;
1557+
1558+ await store . setAdditionalWorkspaces ( 'session-1' , [
1559+ { folder : Uri . file ( '/extra' ) , repository : undefined , worktree : undefined , worktreeProperties : undefined } ,
1560+ ] ) ;
1561+ await vi . advanceTimersByTimeAsync ( 1_100 ) ;
1562+
1563+ const rawContent = await mockFs . readFile ( BULK_METADATA_FILE ) ;
1564+ const written = JSON . parse ( new TextDecoder ( ) . decode ( rawContent ) ) ;
1565+ expect ( written [ 'session-1' ] ?. additionalWorkspaces ) . toBeDefined ( ) ;
1566+ store . dispose ( ) ;
1567+ } ) ;
1568+
1569+ it ( 'should restore additionalWorkspaces from bulk file on startup' , async ( ) => {
1570+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( {
1571+ 'session-1' : {
1572+ additionalWorkspaces : [
1573+ { workspaceFolder : { folderPath : Uri . file ( '/restored/a' ) . fsPath , timestamp : 100 } } ,
1574+ ] ,
1575+ writtenToDisc : true ,
1576+ } ,
1577+ } ) ) ;
1578+ const store = await createStore ( ) ;
1579+
1580+ const result = await store . getAdditionalWorkspaces ( 'session-1' ) ;
1581+ expect ( result ) . toHaveLength ( 1 ) ;
1582+ expect ( result [ 0 ] . folder ?. fsPath ) . toBe ( Uri . file ( '/restored/a' ) . fsPath ) ;
1583+ store . dispose ( ) ;
1584+ } ) ;
1585+
1586+ it ( 'should survive crash recovery: entry with only additionalWorkspaces is re-persisted not deleted' , async ( ) => {
1587+ // Simulate VS Code crash: bulk file has the entry but writtenToDisc is falsy
1588+ // (updateSessionMetadata never completed before the crash).
1589+ mockFs . mockFile ( BULK_METADATA_FILE , JSON . stringify ( {
1590+ 'session-crash' : {
1591+ additionalWorkspaces : [
1592+ { workspaceFolder : { folderPath : Uri . file ( '/extra/workspace' ) . fsPath , timestamp : 100 } } ,
1593+ ] ,
1594+ // writtenToDisc intentionally absent (falsy) — simulates crash before write completed
1595+ } ,
1596+ } ) ) ;
1597+
1598+ // Pre-create session directory so recovery write can succeed
1599+ await mockFs . createDirectory ( sessionDirectoryUri ( 'session-crash' ) ) ;
1600+ const fileCreated = eventToPromise ( mockFs . onDidCreateFile . event ) ;
1601+
1602+ const store = await createStore ( ) ;
1603+ await fileCreated ;
1604+
1605+ // Entry should have been re-persisted to per-session file
1606+ const fileUri = sessionMetadataFileUri ( 'session-crash' ) ;
1607+ const rawContent = await mockFs . readFile ( fileUri ) ;
1608+ const written = JSON . parse ( new TextDecoder ( ) . decode ( rawContent ) ) ;
1609+ expect ( written . additionalWorkspaces ) . toHaveLength ( 1 ) ;
1610+ expect ( written . additionalWorkspaces [ 0 ] . workspaceFolder ?. folderPath ) . toBe ( Uri . file ( '/extra/workspace' ) . fsPath ) ;
1611+
1612+ // And still readable via the API
1613+ const result = await store . getAdditionalWorkspaces ( 'session-crash' ) ;
1614+ expect ( result ) . toHaveLength ( 1 ) ;
1615+ store . dispose ( ) ;
1616+ } ) ;
1617+ } ) ;
1618+
14121619 // ──────────────────────────────────────────────────────────────────────────
14131620 // Constructor & edge cases
14141621 // ──────────────────────────────────────────────────────────────────────────
0 commit comments