@@ -846,6 +846,84 @@ const runtimeConfig = {
846846} ;
847847const runtimeSecrets = { } ;
848848
849+ let nextAgentMailCounter = 1 ;
850+
851+ function createAgentMailThreadFixture ( { kind, subject, unreadCount, ageMs } ) {
852+ const id = nextAgentMailCounter ++ ;
853+ const at = Date . now ( ) - ageMs ;
854+ return {
855+ thread_id : `mail-thread-${ id } ` ,
856+ kind,
857+ subject,
858+ created_by_principal : "agent-root" ,
859+ participant_count : 2 ,
860+ message_count : 1 ,
861+ latest_message_at : at ,
862+ latest_message_preview : `Latest note for ${ subject } ` ,
863+ latest_sender_principal : "agent-root" ,
864+ unread_count : unreadCount ,
865+ created_at : at - 60_000 ,
866+ updated_at : at ,
867+ } ;
868+ }
869+
870+ // Nine direct threads make the exact eight-item Mail pagination provable in a
871+ // real browser; the first thread carries unread mail so the Directory room's
872+ // nav badge is genuinely nonzero beside its DF mark.
873+ const agentMailThreads = Array . from ( { length : 9 } , ( _ , index ) =>
874+ createAgentMailThreadFixture ( {
875+ kind : "direct" ,
876+ subject : `Front desk handoff ${ index + 1 } ` ,
877+ unreadCount : index === 0 ? 3 : 0 ,
878+ ageMs : ( index + 1 ) * 120_000 ,
879+ } ) ,
880+ ) ;
881+
882+ const agentMailMessagesByThread = new Map (
883+ agentMailThreads . map ( ( thread ) => [ thread . thread_id , [ ] ] ) ,
884+ ) ;
885+ agentMailMessagesByThread . set ( agentMailThreads [ 0 ] . thread_id , [
886+ {
887+ message_id : `mail-msg-${ nextAgentMailCounter ++ } ` ,
888+ thread_id : agentMailThreads [ 0 ] . thread_id ,
889+ sender_principal : "agent-root" ,
890+ sender_kind : "agent" ,
891+ body_text : "Front desk: the visitor log is ready for review." ,
892+ metadata_json : null ,
893+ created_at : Date . now ( ) - 300_000 ,
894+ recipients : [
895+ {
896+ recipient_principal : "default" ,
897+ delivered_at : Date . now ( ) - 300_000 ,
898+ acked_at : null ,
899+ } ,
900+ {
901+ recipient_principal : "agent-root" ,
902+ delivered_at : Date . now ( ) - 300_000 ,
903+ acked_at : Date . now ( ) - 240_000 ,
904+ } ,
905+ ] ,
906+ attachments : [ ] ,
907+ } ,
908+ ] ) ;
909+
910+ // Seven active leases make the exact six-item File-lock pagination provable,
911+ // and releasing one proves the live shrink path against the stored page.
912+ const agentMailLeases = Array . from ( { length : 7 } , ( _ , index ) => {
913+ const id = nextAgentMailCounter ++ ;
914+ return {
915+ lease_id : `mail-lease-${ id } ` ,
916+ holder_principal : "agent-root" ,
917+ glob_pattern : `src/area-${ index + 1 } /**` ,
918+ exclusive : index % 2 === 0 ,
919+ ttl_ms : 900_000 ,
920+ note : null ,
921+ created_at : Date . now ( ) - 60_000 ,
922+ expires_at : Date . now ( ) + 840_000 ,
923+ released_at : null ,
924+ } ;
925+ } ) ;
926+
849927const authProfiles = [ ] ;
850928const connectorCatalog = [
851929 {
@@ -947,6 +1025,14 @@ const seedState = {
9471025 connectorAssignments : cloneSeed ( connectorAssignments ) ,
9481026 connectorAuthBindings : cloneSeed ( connectorAuthBindings ) ,
9491027 connectorInteractions : cloneSeed ( connectorInteractions ) ,
1028+ agentMailThreads : cloneSeed ( agentMailThreads ) ,
1029+ agentMailMessages : Object . fromEntries (
1030+ Array . from ( agentMailMessagesByThread . entries ( ) , ( [ threadId , messages ] ) => [
1031+ threadId ,
1032+ cloneSeed ( messages ) ,
1033+ ] ) ,
1034+ ) ,
1035+ agentMailLeases : cloneSeed ( agentMailLeases ) ,
9501036} ;
9511037
9521038
@@ -1340,6 +1426,13 @@ function resetMockState() {
13401426 replaceArray ( connectorAssignments , seedState . connectorAssignments ) ;
13411427 replaceArray ( connectorAuthBindings , seedState . connectorAuthBindings ) ;
13421428 replaceArray ( connectorInteractions , seedState . connectorInteractions ) ;
1429+ replaceArray ( agentMailThreads , seedState . agentMailThreads ) ;
1430+ agentMailMessagesByThread . clear ( ) ;
1431+ for ( const [ threadId , messages ] of Object . entries ( seedState . agentMailMessages ) ) {
1432+ agentMailMessagesByThread . set ( threadId , cloneSeed ( messages ) ) ;
1433+ }
1434+ replaceArray ( agentMailLeases , seedState . agentMailLeases ) ;
1435+ nextAgentMailCounter = 1000 ;
13431436 providerOrder . clear ( ) ;
13441437 nextAuthProfileCounter = 1 ;
13451438 nextEventCounter = 1 ;
@@ -5245,19 +5338,223 @@ async function routeRequest(req, res) {
52455338 }
52465339
52475340 if ( req . method === "GET" && requestUrl . pathname === "/api/v1/agent-mail/threads" ) {
5341+ const kind = requestUrl . searchParams . get ( "kind" ) ;
5342+ const search = ( requestUrl . searchParams . get ( "search" ) ?? "" ) . trim ( ) . toLowerCase ( ) ;
5343+ const items = agentMailThreads
5344+ . filter ( ( thread ) => ( kind ? thread . kind === kind : true ) )
5345+ . filter ( ( thread ) =>
5346+ search ? thread . subject . toLowerCase ( ) . includes ( search ) : true ,
5347+ ) ;
52485348 sendJson ( res , 200 , {
5249- items : [ ] ,
5349+ items,
52505350 } ) ;
52515351 return ;
52525352 }
52535353
5354+ if ( req . method === "POST" && requestUrl . pathname === "/api/v1/agent-mail/threads" ) {
5355+ const payload = await readJson ( req ) ;
5356+ const thread = createAgentMailThreadFixture ( {
5357+ kind : typeof payload . kind === "string" ? payload . kind : "direct" ,
5358+ subject : typeof payload . subject === "string" ? payload . subject : "Untitled" ,
5359+ unreadCount : 0 ,
5360+ ageMs : 0 ,
5361+ } ) ;
5362+ agentMailThreads . unshift ( thread ) ;
5363+ agentMailMessagesByThread . set ( thread . thread_id , [ ] ) ;
5364+ sendJson ( res , 200 , {
5365+ thread,
5366+ } ) ;
5367+ return ;
5368+ }
5369+
5370+ {
5371+ const threadDetailMatch = requestUrl . pathname . match (
5372+ / ^ \/ a p i \/ v 1 \/ a g e n t - m a i l \/ t h r e a d s \/ ( [ ^ / ] + ) $ / ,
5373+ ) ;
5374+ if ( req . method === "GET" && threadDetailMatch ) {
5375+ const thread = agentMailThreads . find (
5376+ ( item ) => item . thread_id === decodeURIComponent ( threadDetailMatch [ 1 ] ) ,
5377+ ) ;
5378+ if ( ! thread ) {
5379+ sendJson ( res , 404 , { error : "thread not found" } ) ;
5380+ return ;
5381+ }
5382+ sendJson ( res , 200 , {
5383+ thread,
5384+ participants : [
5385+ {
5386+ principal_id : thread . created_by_principal ,
5387+ role : "owner" ,
5388+ joined_at : thread . created_at ,
5389+ last_read_at : null ,
5390+ muted : false ,
5391+ } ,
5392+ ] ,
5393+ } ) ;
5394+ return ;
5395+ }
5396+ }
5397+
5398+ {
5399+ const threadMessagesMatch = requestUrl . pathname . match (
5400+ / ^ \/ a p i \/ v 1 \/ a g e n t - m a i l \/ t h r e a d s \/ ( [ ^ / ] + ) \/ m e s s a g e s $ / ,
5401+ ) ;
5402+ if ( threadMessagesMatch ) {
5403+ const threadId = decodeURIComponent ( threadMessagesMatch [ 1 ] ) ;
5404+ const thread = agentMailThreads . find ( ( item ) => item . thread_id === threadId ) ;
5405+ if ( ! thread ) {
5406+ sendJson ( res , 404 , { error : "thread not found" } ) ;
5407+ return ;
5408+ }
5409+ const messages = agentMailMessagesByThread . get ( threadId ) ?? [ ] ;
5410+ if ( req . method === "GET" ) {
5411+ sendJson ( res , 200 , {
5412+ items : messages ,
5413+ } ) ;
5414+ return ;
5415+ }
5416+ if ( req . method === "POST" ) {
5417+ const payload = await readJson ( req ) ;
5418+ const bodyText =
5419+ typeof payload . body_text === "string" ? payload . body_text : "" ;
5420+ const message = {
5421+ message_id : `mail-msg-${ nextAgentMailCounter ++ } ` ,
5422+ thread_id : threadId ,
5423+ sender_principal :
5424+ typeof payload . sender_principal === "string" &&
5425+ payload . sender_principal . trim ( )
5426+ ? payload . sender_principal
5427+ : "agent-root" ,
5428+ sender_kind :
5429+ typeof payload . sender_kind === "string" ? payload . sender_kind : "agent" ,
5430+ body_text : bodyText ,
5431+ metadata_json : null ,
5432+ created_at : Date . now ( ) ,
5433+ recipients : ( Array . isArray ( payload . recipients ) &&
5434+ payload . recipients . length > 0
5435+ ? payload . recipients
5436+ : [ "default" ]
5437+ ) . map ( ( principal ) => ( {
5438+ recipient_principal : String ( principal ) ,
5439+ delivered_at : Date . now ( ) ,
5440+ acked_at : null ,
5441+ } ) ) ,
5442+ attachments : [ ] ,
5443+ } ;
5444+ messages . push ( message ) ;
5445+ agentMailMessagesByThread . set ( threadId , messages ) ;
5446+ thread . message_count = messages . length ;
5447+ thread . latest_message_at = message . created_at ;
5448+ thread . latest_message_preview = bodyText . slice ( 0 , 80 ) ;
5449+ thread . latest_sender_principal = message . sender_principal ;
5450+ thread . updated_at = message . created_at ;
5451+ sendJson ( res , 200 , {
5452+ message,
5453+ } ) ;
5454+ return ;
5455+ }
5456+ }
5457+ }
5458+
5459+ {
5460+ const ackMatch = requestUrl . pathname . match (
5461+ / ^ \/ a p i \/ v 1 \/ a g e n t - m a i l \/ m e s s a g e s \/ ( [ ^ / ] + ) \/ a c k $ / ,
5462+ ) ;
5463+ if ( req . method === "POST" && ackMatch ) {
5464+ const messageId = decodeURIComponent ( ackMatch [ 1 ] ) ;
5465+ const payload = await readJson ( req ) ;
5466+ const wantedPrincipal =
5467+ typeof payload . recipient_principal === "string" &&
5468+ payload . recipient_principal . trim ( )
5469+ ? payload . recipient_principal . trim ( )
5470+ : null ;
5471+ for ( const messages of agentMailMessagesByThread . values ( ) ) {
5472+ const message = messages . find ( ( item ) => item . message_id === messageId ) ;
5473+ if ( ! message ) continue ;
5474+ const recipient = wantedPrincipal
5475+ ? message . recipients . find (
5476+ ( item ) => item . recipient_principal === wantedPrincipal ,
5477+ )
5478+ : message . recipients . find ( ( item ) => item . acked_at === null ) ;
5479+ if ( ! recipient ) {
5480+ sendJson ( res , 404 , { error : "recipient not found" } ) ;
5481+ return ;
5482+ }
5483+ recipient . acked_at = Date . now ( ) ;
5484+ const thread = agentMailThreads . find (
5485+ ( item ) => item . thread_id === message . thread_id ,
5486+ ) ;
5487+ if ( thread && thread . unread_count > 0 ) {
5488+ thread . unread_count -= 1 ;
5489+ }
5490+ sendJson ( res , 200 , {
5491+ message_id : messageId ,
5492+ recipient_principal : recipient . recipient_principal ,
5493+ acked_at : recipient . acked_at ,
5494+ } ) ;
5495+ return ;
5496+ }
5497+ sendJson ( res , 404 , { error : "message not found" } ) ;
5498+ return ;
5499+ }
5500+ }
5501+
52545502 if ( req . method === "GET" && requestUrl . pathname === "/api/v1/agent-mail/leases" ) {
5503+ const includeReleased =
5504+ requestUrl . searchParams . get ( "include_released" ) === "true" ;
52555505 sendJson ( res , 200 , {
5256- items : [ ] ,
5506+ items : agentMailLeases . filter ( ( lease ) =>
5507+ includeReleased ? true : lease . released_at === null ,
5508+ ) ,
5509+ } ) ;
5510+ return ;
5511+ }
5512+
5513+ if ( req . method === "POST" && requestUrl . pathname === "/api/v1/agent-mail/leases" ) {
5514+ const payload = await readJson ( req ) ;
5515+ const ttlMs = Number . isFinite ( payload . ttl_ms ) ? Number ( payload . ttl_ms ) : 900000 ;
5516+ const lease = {
5517+ lease_id : `mail-lease-${ nextAgentMailCounter ++ } ` ,
5518+ holder_principal :
5519+ typeof payload . holder_principal === "string" && payload . holder_principal . trim ( )
5520+ ? payload . holder_principal
5521+ : "agent-root" ,
5522+ glob_pattern :
5523+ typeof payload . glob_pattern === "string" ? payload . glob_pattern : "**/*" ,
5524+ exclusive : Boolean ( payload . exclusive ) ,
5525+ ttl_ms : ttlMs ,
5526+ note : typeof payload . note === "string" ? payload . note : null ,
5527+ created_at : Date . now ( ) ,
5528+ expires_at : Date . now ( ) + ttlMs ,
5529+ released_at : null ,
5530+ } ;
5531+ agentMailLeases . push ( lease ) ;
5532+ sendJson ( res , 200 , {
5533+ lease,
52575534 } ) ;
52585535 return ;
52595536 }
52605537
5538+ {
5539+ const releaseMatch = requestUrl . pathname . match (
5540+ / ^ \/ a p i \/ v 1 \/ a g e n t - m a i l \/ l e a s e s \/ ( [ ^ / ] + ) \/ r e l e a s e $ / ,
5541+ ) ;
5542+ if ( req . method === "POST" && releaseMatch ) {
5543+ const lease = agentMailLeases . find (
5544+ ( item ) => item . lease_id === decodeURIComponent ( releaseMatch [ 1 ] ) ,
5545+ ) ;
5546+ if ( ! lease ) {
5547+ sendJson ( res , 404 , { error : "lease not found" } ) ;
5548+ return ;
5549+ }
5550+ lease . released_at = Date . now ( ) ;
5551+ sendJson ( res , 200 , {
5552+ lease,
5553+ } ) ;
5554+ return ;
5555+ }
5556+ }
5557+
52615558 if ( req . method === "POST" && requestUrl . pathname === "/api/v1/e2e/ws-event" ) {
52625559 const payload = await readJson ( req ) ;
52635560 const event = broadcastWsEvent ( {
0 commit comments