22import { beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
33
44const hoisted = vi . hoisted ( ( ) => {
5- const updateSessionStore = vi . fn ( ) ;
5+ const listSessionEntries = vi . fn ( ) ;
6+ const patchSessionEntry = vi . fn ( ) ;
67 const resolveStorePath = vi . fn ( ( ) => "/tmp/openclaw-sessions.json" ) ;
7- return { updateSessionStore , resolveStorePath } ;
8+ return { listSessionEntries , patchSessionEntry , resolveStorePath } ;
89} ) ;
910
1011vi . mock ( "openclaw/plugin-sdk/session-store-runtime" , async ( ) => {
@@ -13,16 +14,37 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
1314 ) ;
1415 return {
1516 ...actual ,
16- updateSessionStore : hoisted . updateSessionStore ,
17+ listSessionEntries : hoisted . listSessionEntries ,
18+ patchSessionEntry : hoisted . patchSessionEntry ,
1719 resolveStorePath : hoisted . resolveStorePath ,
1820 } ;
1921} ) ;
2022
2123let closeDiscordThreadSessions : typeof import ( "./thread-session-close.js" ) . closeDiscordThreadSessions ;
2224
23- function setupStore ( store : Record < string , { updatedAt : number } > ) {
24- hoisted . updateSessionStore . mockImplementation (
25- async ( _storePath : string , mutator : ( s : typeof store ) => unknown ) => mutator ( store ) ,
25+ function setupStore ( store : Record < string , { sessionId ?: string ; updatedAt : number } > ) {
26+ hoisted . listSessionEntries . mockImplementation ( ( ) =>
27+ Object . entries ( store ) . map ( ( [ sessionKey , entry ] ) => ( { sessionKey, entry } ) ) ,
28+ ) ;
29+ hoisted . patchSessionEntry . mockImplementation (
30+ async ( params : {
31+ sessionKey : string ;
32+ update : ( entry : {
33+ sessionId ?: string ;
34+ updatedAt : number ;
35+ } ) => { sessionId ?: string ; updatedAt : number } | null ;
36+ } ) => {
37+ const entry = store [ params . sessionKey ] ;
38+ if ( ! entry ) {
39+ return null ;
40+ }
41+ const next = params . update ( { ...entry } ) ;
42+ if ( ! next ) {
43+ return entry ;
44+ }
45+ store [ params . sessionKey ] = next ;
46+ return next ;
47+ } ,
2648 ) ;
2749}
2850
@@ -38,7 +60,8 @@ describe("closeDiscordThreadSessions", () => {
3860 } ) ;
3961
4062 beforeEach ( ( ) => {
41- hoisted . updateSessionStore . mockClear ( ) ;
63+ hoisted . listSessionEntries . mockReset ( ) ;
64+ hoisted . patchSessionEntry . mockReset ( ) ;
4265 hoisted . resolveStorePath . mockClear ( ) ;
4366 hoisted . resolveStorePath . mockReturnValue ( "/tmp/openclaw-sessions.json" ) ;
4467 } ) ;
@@ -143,7 +166,8 @@ describe("closeDiscordThreadSessions", () => {
143166 } ) ;
144167
145168 expect ( count ) . toBe ( 0 ) ;
146- expect ( hoisted . updateSessionStore ) . not . toHaveBeenCalled ( ) ;
169+ expect ( hoisted . listSessionEntries ) . not . toHaveBeenCalled ( ) ;
170+ expect ( hoisted . patchSessionEntry ) . not . toHaveBeenCalled ( ) ;
147171 } ) ;
148172
149173 it ( "does not recount sessions that were already reset" , async ( ) => {
@@ -164,6 +188,35 @@ describe("closeDiscordThreadSessions", () => {
164188 expect ( store [ UNMATCHED_KEY ] . updatedAt ) . toBe ( 1_700_000_000_001 ) ;
165189 } ) ;
166190
191+ it ( "does not reset a matching session that changed after the list snapshot" , async ( ) => {
192+ const store = {
193+ [ MATCHED_KEY ] : {
194+ sessionId : "fresh-session" ,
195+ updatedAt : 2_000 ,
196+ } ,
197+ } ;
198+ setupStore ( store ) ;
199+ hoisted . listSessionEntries . mockReturnValue ( [
200+ {
201+ sessionKey : MATCHED_KEY ,
202+ entry : {
203+ sessionId : "old-session" ,
204+ updatedAt : 1_000 ,
205+ } ,
206+ } ,
207+ ] ) ;
208+
209+ const count = await closeDiscordThreadSessions ( {
210+ cfg : { } ,
211+ accountId : "default" ,
212+ threadId : THREAD_ID ,
213+ } ) ;
214+
215+ expect ( count ) . toBe ( 0 ) ;
216+ expect ( store [ MATCHED_KEY ] . updatedAt ) . toBe ( 2_000 ) ;
217+ expect ( store [ MATCHED_KEY ] . sessionId ) . toBe ( "fresh-session" ) ;
218+ } ) ;
219+
167220 it ( "resolves the store path using cfg.session.store and accountId" , async ( ) => {
168221 const store = { } ;
169222 setupStore ( store ) ;
0 commit comments