11import assert from "node:assert/strict" ;
22import { spawn } from "node:child_process" ;
3+ import { readFileSync } from "node:fs" ;
34import fs from "node:fs/promises" ;
45import net from "node:net" ;
56import os from "node:os" ;
@@ -22,7 +23,30 @@ import {
2223
2324const CLI_PATH = fileURLToPath ( new URL ( "../src/cli.js" , import . meta. url ) ) ;
2425const MOCK_AGENT_PATH = fileURLToPath ( new URL ( "./mock-agent.js" , import . meta. url ) ) ;
26+ function readPackageVersionForTest ( ) : string {
27+ const candidates = [
28+ fileURLToPath ( new URL ( "../package.json" , import . meta. url ) ) ,
29+ fileURLToPath ( new URL ( "../../package.json" , import . meta. url ) ) ,
30+ path . join ( process . cwd ( ) , "package.json" ) ,
31+ ] ;
32+ for ( const candidate of candidates ) {
33+ try {
34+ const parsed = JSON . parse ( readFileSync ( candidate , "utf8" ) ) as {
35+ version ?: unknown ;
36+ } ;
37+ if ( typeof parsed . version === "string" && parsed . version . trim ( ) . length > 0 ) {
38+ return parsed . version ;
39+ }
40+ } catch {
41+ // continue searching
42+ }
43+ }
44+ throw new Error ( "package.json version is missing" ) ;
45+ }
46+
47+ const PACKAGE_VERSION = readPackageVersionForTest ( ) ;
2548const MOCK_AGENT_COMMAND = `node ${ JSON . stringify ( MOCK_AGENT_PATH ) } ` ;
49+ const MOCK_AGENT_IGNORING_SIGTERM = `${ MOCK_AGENT_COMMAND } --ignore-sigterm` ;
2650const MOCK_CODEX_AGENT_WITH_RUNTIME_SESSION_ID = `${ MOCK_AGENT_COMMAND } --codex-session-id codex-runtime-session` ;
2751const MOCK_CLAUDE_AGENT_WITH_RUNTIME_SESSION_ID = `${ MOCK_AGENT_COMMAND } --claude-session-id claude-runtime-session` ;
2852const MOCK_AGENT_WITH_LOAD_RUNTIME_SESSION_ID = `${ MOCK_AGENT_COMMAND } --supports-load-session --load-runtime-session-id loaded-runtime-session` ;
@@ -44,6 +68,15 @@ type ParsedAcpError = {
4468 } ;
4569} ;
4670
71+ test ( "CLI --version prints package version" , async ( ) => {
72+ await withTempHome ( async ( homeDir ) => {
73+ const result = await runCli ( [ "--version" ] , homeDir ) ;
74+ assert . equal ( result . code , 0 , result . stderr ) ;
75+ assert . equal ( result . stderr . trim ( ) , "" ) ;
76+ assert . equal ( result . stdout . trim ( ) , PACKAGE_VERSION ) ;
77+ } ) ;
78+ } ) ;
79+
4780function parseSingleAcpErrorLine ( stdout : string ) : ParsedAcpError {
4881 const payload = JSON . parse ( stdout . trim ( ) ) as {
4982 jsonrpc ?: string ;
@@ -204,6 +237,62 @@ test("sessions ensure creates when missing and returns existing on subsequent ca
204237 } ) ;
205238} ) ;
206239
240+ test ( "sessions ensure exits even when agent ignores SIGTERM" , async ( ) => {
241+ await withTempHome ( async ( homeDir ) => {
242+ const cwd = path . join ( homeDir , "workspace" ) ;
243+ await fs . mkdir ( cwd , { recursive : true } ) ;
244+ await fs . mkdir ( path . join ( homeDir , ".acpx" ) , { recursive : true } ) ;
245+ await fs . writeFile (
246+ path . join ( homeDir , ".acpx" , "config.json" ) ,
247+ `${ JSON . stringify (
248+ {
249+ agents : {
250+ codex : {
251+ command : MOCK_AGENT_IGNORING_SIGTERM ,
252+ } ,
253+ } ,
254+ } ,
255+ null ,
256+ 2 ,
257+ ) } \n`,
258+ "utf8" ,
259+ ) ;
260+
261+ const result = await runCli (
262+ [ "--cwd" , cwd , "--format" , "json" , "codex" , "sessions" , "ensure" ] ,
263+ homeDir ,
264+ { timeoutMs : 8_000 } ,
265+ ) ;
266+ assert . equal ( result . code , 0 , result . stderr ) ;
267+
268+ const payload = JSON . parse ( result . stdout . trim ( ) ) as {
269+ action ?: unknown ;
270+ created ?: unknown ;
271+ acpxRecordId ?: unknown ;
272+ } ;
273+ assert . equal ( payload . action , "session_ensured" ) ;
274+ assert . equal ( payload . created , true ) ;
275+ assert . equal ( typeof payload . acpxRecordId , "string" ) ;
276+
277+ const storedRecord = JSON . parse (
278+ await fs . readFile (
279+ path . join (
280+ homeDir ,
281+ ".acpx" ,
282+ "sessions" ,
283+ `${ encodeURIComponent ( payload . acpxRecordId as string ) } .json` ,
284+ ) ,
285+ "utf8" ,
286+ ) ,
287+ ) as SessionRecord ;
288+
289+ if ( storedRecord . pid != null ) {
290+ const exited = await waitForPidExit ( storedRecord . pid , 2_000 ) ;
291+ assert . equal ( exited , true ) ;
292+ }
293+ } ) ;
294+ } ) ;
295+
207296test ( "sessions ensure resolves existing session by directory walk" , async ( ) => {
208297 await withTempHome ( async ( homeDir ) => {
209298 const root = path . join ( homeDir , "workspace" ) ;
@@ -1170,6 +1259,7 @@ async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<vo
11701259type CliRunOptions = {
11711260 stdin ?: string ;
11721261 cwd ?: string ;
1262+ timeoutMs ?: number ;
11731263} ;
11741264
11751265async function runCli (
@@ -1206,12 +1296,44 @@ async function runCli(
12061296 child . stdin . end ( ) ;
12071297 }
12081298
1299+ let timedOut = false ;
1300+ let timeout : NodeJS . Timeout | undefined ;
1301+ if ( options . timeoutMs != null && options . timeoutMs > 0 ) {
1302+ timeout = setTimeout ( ( ) => {
1303+ timedOut = true ;
1304+ if ( child . exitCode == null && child . signalCode == null ) {
1305+ child . kill ( "SIGKILL" ) ;
1306+ }
1307+ } , options . timeoutMs ) ;
1308+ }
1309+
12091310 child . once ( "close" , ( code ) => {
1311+ if ( timeout ) {
1312+ clearTimeout ( timeout ) ;
1313+ }
1314+ if ( timedOut ) {
1315+ stderr += `[test] timed out after ${ options . timeoutMs } ms\n` ;
1316+ }
12101317 resolve ( { code, stdout, stderr } ) ;
12111318 } ) ;
12121319 } ) ;
12131320}
12141321
1322+ async function waitForPidExit ( pid : number , timeoutMs : number ) : Promise < boolean > {
1323+ const deadline = Date . now ( ) + Math . max ( 0 , timeoutMs ) ;
1324+ while ( Date . now ( ) < deadline ) {
1325+ try {
1326+ process . kill ( pid , 0 ) ;
1327+ } catch {
1328+ return true ;
1329+ }
1330+ await new Promise < void > ( ( resolve ) => {
1331+ setTimeout ( resolve , 50 ) ;
1332+ } ) ;
1333+ }
1334+ return false ;
1335+ }
1336+
12151337function makeSessionRecord (
12161338 record : Partial < SessionRecord > & {
12171339 acpxRecordId : string ;
0 commit comments