2026-01-07 09:02:20 -06:00
import fs from "node:fs/promises" ;
import os from "node:os" ;
import path from "node:path" ;
2026-01-18 06:37:30 +00:00
import { describe , expect , it , vi } from "vitest" ;
2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../../config/config.js" ;
2026-01-07 09:02:20 -06:00
import { saveSessionStore } from "../../config/sessions.js" ;
import { initSessionState } from "./session.js" ;
describe ( "initSessionState thread forking" , ( ) = > {
it ( "forks a new session from the parent session file" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-thread-session-" ) ) ;
2026-01-07 09:02:20 -06:00
const sessionsDir = path . join ( root , "sessions" ) ;
await fs . mkdir ( sessionsDir , { recursive : true } ) ;
const parentSessionId = "parent-session" ;
const parentSessionFile = path . join ( sessionsDir , "parent.jsonl" ) ;
const header = {
type : "session" ,
version : 3 ,
id : parentSessionId ,
timestamp : new Date ( ) . toISOString ( ) ,
cwd : process.cwd ( ) ,
} ;
const message = {
type : "message" ,
id : "m1" ,
parentId : null ,
timestamp : new Date ( ) . toISOString ( ) ,
message : { role : "user" , content : "Parent prompt" } ,
} ;
await fs . writeFile (
parentSessionFile ,
` ${ JSON . stringify ( header ) } \ n ${ JSON . stringify ( message ) } \ n ` ,
"utf-8" ,
) ;
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const parentSessionKey = "agent:main:slack:channel:c1" ;
2026-01-07 09:02:20 -06:00
await saveSessionStore ( storePath , {
[ parentSessionKey ] : {
sessionId : parentSessionId ,
sessionFile : parentSessionFile ,
updatedAt : Date.now ( ) ,
} ,
} ) ;
const cfg = {
session : { store : storePath } ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-07 09:02:20 -06:00
2026-01-24 11:57:04 +00:00
const threadSessionKey = "agent:main:slack:channel:c1:thread:123" ;
2026-01-07 09:02:20 -06:00
const threadLabel = "Slack thread #general: starter" ;
const result = await initSessionState ( {
ctx : {
Body : "Thread reply" ,
SessionKey : threadSessionKey ,
ParentSessionKey : parentSessionKey ,
ThreadLabel : threadLabel ,
} ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . sessionKey ) . toBe ( threadSessionKey ) ;
expect ( result . sessionEntry . sessionId ) . not . toBe ( parentSessionId ) ;
expect ( result . sessionEntry . sessionFile ) . toBeTruthy ( ) ;
expect ( result . sessionEntry . displayName ) . toBe ( threadLabel ) ;
2026-01-07 19:42:50 +01:00
const newSessionFile = result . sessionEntry . sessionFile ;
if ( ! newSessionFile ) {
throw new Error ( "Missing session file for forked thread" ) ;
}
2026-01-07 09:02:20 -06:00
const [ headerLine ] = ( await fs . readFile ( newSessionFile , "utf-8" ) )
. split ( /\r?\n/ )
. filter ( ( line ) = > line . trim ( ) . length > 0 ) ;
const parsedHeader = JSON . parse ( headerLine ) as {
parentSession? : string ;
} ;
expect ( parsedHeader . parentSession ) . toBe ( parentSessionFile ) ;
} ) ;
2026-01-07 22:56:50 +00:00
it ( "records topic-specific session files when MessageThreadId is present" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-topic-session-" ) ) ;
2026-01-07 22:56:50 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
const cfg = {
session : { store : storePath } ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-07 22:56:50 +00:00
const result = await initSessionState ( {
ctx : {
Body : "Hello topic" ,
SessionKey : "agent:main:telegram:group:123:topic:456" ,
MessageThreadId : 456 ,
} ,
cfg ,
commandAuthorized : true ,
} ) ;
const sessionFile = result . sessionEntry . sessionFile ;
expect ( sessionFile ) . toBeTruthy ( ) ;
expect ( path . basename ( sessionFile ? ? "" ) ) . toBe (
` ${ result . sessionEntry . sessionId } -topic-456.jsonl ` ,
) ;
} ) ;
2026-01-07 09:02:20 -06:00
} ) ;
2026-01-10 17:32:19 +01:00
describe ( "initSessionState RawBody" , ( ) = > {
it ( "triggerBodyNormalized correctly extracts commands when Body contains context but RawBody is clean" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-rawbody-" ) ) ;
2026-01-10 17:32:19 +01:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-30 03:15:10 +01:00
const cfg = { session : { store : storePath } } as OpenClawConfig ;
2026-01-10 17:32:19 +01:00
const groupMessageCtx = {
Body : ` [Chat messages since your last reply - for context] \ n[WhatsApp ...] Someone: hello \ n \ n[Current message - respond to this] \ n[WhatsApp ...] Jake: /status \ n[from: Jake McInteer (+6421807830)] ` ,
RawBody : "/status" ,
ChatType : "group" ,
2026-01-24 11:57:04 +00:00
SessionKey : "agent:main:whatsapp:group:g1" ,
2026-01-10 17:32:19 +01:00
} ;
const result = await initSessionState ( {
ctx : groupMessageCtx ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . triggerBodyNormalized ) . toBe ( "/status" ) ;
} ) ;
it ( "Reset triggers (/new, /reset) work with RawBody" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-rawbody-reset-" ) ) ;
2026-01-10 17:32:19 +01:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-30 03:15:10 +01:00
const cfg = { session : { store : storePath } } as OpenClawConfig ;
2026-01-10 17:32:19 +01:00
const groupMessageCtx = {
Body : ` [Context] \ nJake: /new \ n[from: Jake] ` ,
RawBody : "/new" ,
ChatType : "group" ,
2026-01-24 11:57:04 +00:00
SessionKey : "agent:main:whatsapp:group:g1" ,
2026-01-10 17:32:19 +01:00
} ;
const result = await initSessionState ( {
ctx : groupMessageCtx ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . bodyStripped ) . toBe ( "" ) ;
} ) ;
2026-01-20 12:53:45 +01:00
it ( "preserves argument casing while still matching reset triggers case-insensitively" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-rawbody-reset-case-" ) ) ;
2026-01-20 12:53:45 +01:00
const storePath = path . join ( root , "sessions.json" ) ;
const cfg = {
session : {
store : storePath ,
resetTriggers : [ "/new" ] ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-20 12:53:45 +01:00
const ctx = {
RawBody : "/NEW KeepThisCase" ,
ChatType : "direct" ,
2026-01-24 11:57:04 +00:00
SessionKey : "agent:main:whatsapp:dm:s1" ,
2026-01-20 12:53:45 +01:00
} ;
const result = await initSessionState ( {
ctx ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . bodyStripped ) . toBe ( "KeepThisCase" ) ;
expect ( result . triggerBodyNormalized ) . toBe ( "/NEW KeepThisCase" ) ;
} ) ;
2026-01-10 17:32:19 +01:00
it ( "falls back to Body when RawBody is undefined" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-rawbody-fallback-" ) ) ;
2026-01-10 17:32:19 +01:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-30 03:15:10 +01:00
const cfg = { session : { store : storePath } } as OpenClawConfig ;
2026-01-10 17:32:19 +01:00
const ctx = {
Body : "/status" ,
2026-01-24 11:57:04 +00:00
SessionKey : "agent:main:whatsapp:dm:s1" ,
2026-01-10 17:32:19 +01:00
} ;
const result = await initSessionState ( {
ctx ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . triggerBodyNormalized ) . toBe ( "/status" ) ;
} ) ;
2026-02-14 20:07:53 +00:00
it ( "uses the default per-agent sessions store when config store is unset" , async ( ) = > {
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-session-store-default-" ) ) ;
const stateDir = path . join ( root , ".openclaw" ) ;
const agentId = "worker1" ;
const sessionKey = ` agent: ${ agentId } :telegram:12345 ` ;
const sessionId = "sess-worker-1" ;
const sessionFile = path . join ( stateDir , "agents" , agentId , "sessions" , ` ${ sessionId } .jsonl ` ) ;
const storePath = path . join ( stateDir , "agents" , agentId , "sessions" , "sessions.json" ) ;
vi . stubEnv ( "OPENCLAW_STATE_DIR" , stateDir ) ;
try {
await fs . mkdir ( path . dirname ( storePath ) , { recursive : true } ) ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId ,
sessionFile ,
updatedAt : Date.now ( ) ,
} ,
} ) ;
const cfg = { } as OpenClawConfig ;
const result = await initSessionState ( {
ctx : {
Body : "hello" ,
ChatType : "direct" ,
Provider : "telegram" ,
Surface : "telegram" ,
SessionKey : sessionKey ,
} ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . sessionEntry . sessionId ) . toBe ( sessionId ) ;
expect ( result . sessionEntry . sessionFile ) . toBe ( sessionFile ) ;
expect ( result . storePath ) . toBe ( storePath ) ;
} finally {
vi . unstubAllEnvs ( ) ;
}
} ) ;
2026-01-10 17:32:19 +01:00
} ) ;
2026-01-18 06:37:30 +00:00
describe ( "initSessionState reset policy" , ( ) = > {
it ( "defaults to daily reset at 4am local time" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-daily-" ) ) ;
2026-01-18 06:37:30 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:whatsapp:dm:s1" ;
2026-01-18 06:37:30 +00:00
const existingSessionId = "daily-session-id" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 3 , 0 , 0 ) . getTime ( ) ,
} ,
} ) ;
2026-01-30 03:15:10 +01:00
const cfg = { session : { store : storePath } } as OpenClawConfig ;
2026-01-18 06:37:30 +00:00
const result = await initSessionState ( {
ctx : { Body : "hello" , SessionKey : sessionKey } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . sessionId ) . not . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
2026-01-18 06:54:55 +00:00
it ( "treats sessions as stale before the daily reset when updated before yesterday's boundary" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 3 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-daily-edge-" ) ) ;
2026-01-18 06:54:55 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:whatsapp:dm:s-edge" ;
2026-01-18 06:54:55 +00:00
const existingSessionId = "daily-edge-session" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 17 , 3 , 30 , 0 ) . getTime ( ) ,
} ,
} ) ;
2026-01-30 03:15:10 +01:00
const cfg = { session : { store : storePath } } as OpenClawConfig ;
2026-01-18 06:54:55 +00:00
const result = await initSessionState ( {
ctx : { Body : "hello" , SessionKey : sessionKey } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . sessionId ) . not . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
2026-01-18 06:37:30 +00:00
it ( "expires sessions when idle timeout wins over daily reset" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 30 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-idle-" ) ) ;
2026-01-18 06:37:30 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:whatsapp:dm:s2" ;
2026-01-18 06:37:30 +00:00
const existingSessionId = "idle-session-id" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 4 , 45 , 0 ) . getTime ( ) ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
reset : { mode : "daily" , atHour : 4 , idleMinutes : 30 } ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-18 06:37:30 +00:00
const result = await initSessionState ( {
ctx : { Body : "hello" , SessionKey : sessionKey } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . sessionId ) . not . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
it ( "uses per-type overrides for thread sessions" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-thread-" ) ) ;
2026-01-18 06:37:30 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:slack:channel:c1:thread:123" ;
2026-01-18 06:37:30 +00:00
const existingSessionId = "thread-session-id" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 3 , 0 , 0 ) . getTime ( ) ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
reset : { mode : "daily" , atHour : 4 } ,
resetByType : { thread : { mode : "idle" , idleMinutes : 180 } } ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-18 06:37:30 +00:00
const result = await initSessionState ( {
ctx : { Body : "reply" , SessionKey : sessionKey , ThreadLabel : "Slack thread" } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( false ) ;
expect ( result . sessionId ) . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
2026-01-18 06:54:55 +00:00
it ( "detects thread sessions without thread key suffix" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-thread-nosuffix-" ) ) ;
2026-01-18 06:54:55 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:discord:channel:c1" ;
2026-01-18 06:54:55 +00:00
const existingSessionId = "thread-nosuffix" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 3 , 0 , 0 ) . getTime ( ) ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
resetByType : { thread : { mode : "idle" , idleMinutes : 180 } } ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-18 06:54:55 +00:00
const result = await initSessionState ( {
ctx : { Body : "reply" , SessionKey : sessionKey , ThreadLabel : "Discord thread" } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( false ) ;
expect ( result . sessionId ) . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
it ( "defaults to daily resets when only resetByType is configured" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-type-default-" ) ) ;
2026-01-18 06:54:55 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:whatsapp:dm:s4" ;
2026-01-18 06:54:55 +00:00
const existingSessionId = "type-default-session" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 3 , 0 , 0 ) . getTime ( ) ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
resetByType : { thread : { mode : "idle" , idleMinutes : 60 } } ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-18 06:54:55 +00:00
const result = await initSessionState ( {
ctx : { Body : "hello" , SessionKey : sessionKey } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( true ) ;
expect ( result . sessionId ) . not . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
2026-01-18 06:37:30 +00:00
it ( "keeps legacy idleMinutes behavior without reset config" , async ( ) = > {
vi . useFakeTimers ( ) ;
vi . setSystemTime ( new Date ( 2026 , 0 , 18 , 5 , 0 , 0 ) ) ;
try {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-reset-legacy-" ) ) ;
2026-01-18 06:37:30 +00:00
const storePath = path . join ( root , "sessions.json" ) ;
2026-01-24 11:57:04 +00:00
const sessionKey = "agent:main:whatsapp:dm:s3" ;
2026-01-18 06:37:30 +00:00
const existingSessionId = "legacy-session-id" ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId : existingSessionId ,
updatedAt : new Date ( 2026 , 0 , 18 , 3 , 30 , 0 ) . getTime ( ) ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
idleMinutes : 240 ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-18 06:37:30 +00:00
const result = await initSessionState ( {
ctx : { Body : "hello" , SessionKey : sessionKey } ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( false ) ;
expect ( result . sessionId ) . toBe ( existingSessionId ) ;
} finally {
vi . useRealTimers ( ) ;
}
} ) ;
} ) ;
2026-01-21 13:10:31 -06:00
describe ( "initSessionState channel reset overrides" , ( ) = > {
it ( "uses channel-specific reset policy when configured" , async ( ) = > {
2026-01-30 03:15:10 +01:00
const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-channel-idle-" ) ) ;
2026-01-21 13:10:31 -06:00
const storePath = path . join ( root , "sessions.json" ) ;
const sessionKey = "agent:main:discord:dm:123" ;
const sessionId = "session-override" ;
const updatedAt = Date . now ( ) - ( 10080 - 1 ) * 60 _000 ;
await saveSessionStore ( storePath , {
[ sessionKey ] : {
sessionId ,
updatedAt ,
} ,
} ) ;
const cfg = {
session : {
store : storePath ,
idleMinutes : 60 ,
2026-02-08 16:20:52 -08:00
resetByType : { direct : { mode : "idle" , idleMinutes : 10 } } ,
2026-01-21 13:10:31 -06:00
resetByChannel : { discord : { mode : "idle" , idleMinutes : 10080 } } ,
} ,
2026-01-30 03:15:10 +01:00
} as OpenClawConfig ;
2026-01-21 13:10:31 -06:00
const result = await initSessionState ( {
ctx : {
Body : "Hello" ,
SessionKey : sessionKey ,
Provider : "discord" ,
} ,
cfg ,
commandAuthorized : true ,
} ) ;
expect ( result . isNewSession ) . toBe ( false ) ;
expect ( result . sessionEntry . sessionId ) . toBe ( sessionId ) ;
} ) ;
} ) ;