@@ -23,53 +23,103 @@ struct CodexPresenceTests {
2323
2424 @Test
2525 func theTurnEndIsReportedThroughTheOneChannelCodexHas( ) {
26- let override = PresenceHooks . codexNotifyOverride ( zmxPath: zmx)
26+ let script = PresenceHooks . codexNotifyScript ( zmxPath: zmx)
2727
28- #expect( override. hasPrefix ( " notify=[ " ) )
29- #expect( override. contains ( " presence=idle " ) )
28+ #expect( script. contains ( " presence=idle " ) )
3029 // Same session-owned label store the other two backends report into, so one reader
3130 // serves all three.
32- #expect( override. contains ( #"$ZMX_SESSION"# ) )
33- #expect( override. contains ( " thread-id " ) )
34- #expect( override. contains ( " .history " ) )
35- #expect( override. contains ( " .id " ) )
31+ #expect( script. contains ( #"$ZMX_SESSION"# ) )
32+ #expect( script. contains ( " thread-id " ) )
33+ #expect( script. contains ( " .history " ) )
34+ #expect( script. contains ( " .id " ) )
35+ #expect( script. contains ( " ' \( zmx) ' " ) )
3636 }
3737
3838 @Test
3939 func theOverrideIsValidTOMLForAnAwkwardPath( ) {
40- // The value is TOML parsed out of one argv element, so the inner quotes around
41- // `$ZMX_SESSION` have to survive as escapes rather than closing the string early.
42- let override = PresenceHooks . codexNotifyOverride ( zmxPath: " /Users/o'brien/bin/zmx " )
43-
44- #expect( override. contains ( #"\"$ZMX_SESSION\""# ) )
45- // Two layers, and the doubled backslash is both of them doing their job: shell
46- // quoting turns the apostrophe into `'\''`, then TOML escapes that backslash to
47- // `\\`. Codex decodes the TOML back to `'\''`, which is what the shell must see.
48- #expect( override. contains ( #"o'\\''brien"# ) )
49- // Three array elements: the program, its flag, and the script.
50- #expect( override. hasPrefix ( #"notify=["/bin/sh","-c",""# ) )
51- #expect( override. hasSuffix ( " ] " ) )
40+ // The value is TOML parsed out of one argv element, so a quote in the script's path
41+ // has to survive as an escape rather than closing the string early.
42+ let override = PresenceHooks . codexNotifyOverride ( scriptPath: #"/Users/o"brien/codex-notify.sh"# )
43+
44+ #expect( override == #"notify=["/bin/sh","/Users/o\"brien/codex-notify.sh"]"# )
45+ // The remote form's `$HOME` and `$0` stay escaped for the shell on the host to expand.
46+ #expect(
47+ PresenceHooks . remoteCodexNotifyOverride
48+ == #"notify=["/bin/sh","-c","exec /bin/sh \"$HOME/.graphcode/hooks/codex-notify.sh\" \"$0\""]"#
49+ )
50+ #expect( PresenceHooks . codexNotifyScript ( zmxPath: " /Users/o'brien/zmx " ) . contains ( #"o'\''brien"# ) )
51+ }
52+
53+ @Test
54+ func theNotifyScriptBanksTheThreadAndReportsIdleWhenCodexRunsIt( ) async throws {
55+ // Run the way Codex runs it: the script by path, the event JSON appended as one more
56+ // argument — then again through the remote form's `$HOME` hop.
57+ let root = FileManager . default. temporaryDirectory
58+ . appendingPathComponent ( " codex-notify- \( UUID ( ) . uuidString) " , isDirectory: true )
59+ defer { try ? FileManager . default. removeItem ( at: root) }
60+ let hooks = root. appendingPathComponent ( " .graphcode/hooks " , isDirectory: true )
61+ try FileManager . default. createDirectory ( at: hooks, withIntermediateDirectories: true )
62+ let calls = root. appendingPathComponent ( " zmx-calls " )
63+ let fakeZmx = root. appendingPathComponent ( " zmx " )
64+ try " #!/bin/sh \n echo \" $@ \" >> ' \( calls. path) ' \n " . write (
65+ to: fakeZmx, atomically: true , encoding: . utf8)
66+ try FileManager . default. setAttributes ( [ . posixPermissions: 0o755 ] , ofItemAtPath: fakeZmx. path)
67+ let sessions = root. appendingPathComponent ( " sessions " , isDirectory: true )
68+ let script = hooks. appendingPathComponent ( " codex-notify.sh " )
69+ try PresenceHooks . codexNotifyScript (
70+ zmxPath: fakeZmx. path, sessionsDirectory: PresenceHooks . singleQuoted ( sessions. path)
71+ ) . write ( to: script, atomically: true , encoding: . utf8)
72+ let nodeID = UUID ( ) . uuidString
73+ let event = #"{"type":"agent-turn-complete","thread-id":"t-42"}"#
74+ let environment = [
75+ " HOME " : root. path, " PATH " : " /usr/bin:/bin " , " ZMX_SESSION " : " graphcode- \( nodeID) " ,
76+ ]
77+
78+ #expect( try await run ( [ " /bin/sh " , script. path, event] , environment: environment) == 0 )
79+ #expect(
80+ try String ( contentsOf: sessions. appendingPathComponent ( " \( nodeID) .id " ) , encoding: . utf8)
81+ == " t-42 " )
82+ #expect(
83+ try await run (
84+ [ " /bin/sh " , " -c " , PresenceHooks . remoteCodexNotifyCommand, event] , environment: environment)
85+ == 0 )
86+ let reports = try String ( contentsOf: calls, encoding: . utf8)
87+ #expect( reports == String ( repeating: " set graphcode- \( nodeID) presence=idle \n " , count: 2 ) )
88+ }
89+
90+ private func run( _ argv: [ String ] , environment: [ String : String ] ) async throws -> Int32 {
91+ let process = Process ( )
92+ process. executableURL = URL ( fileURLWithPath: argv [ 0 ] )
93+ process. arguments = Array ( argv. dropFirst ( ) )
94+ process. environment = environment
95+ return try await withCheckedThrowingContinuation { continuation in
96+ process. terminationHandler = { continuation. resume ( returning: $0. terminationStatus) }
97+ do { try process. run ( ) } catch { continuation. resume ( throwing: error) }
98+ }
5299 }
53100
54101 @Test
55102 func codexTakesTheFlagAndNothingMeantForTheOthers( ) {
56103 let arguments = CLISessionBackendKind . codex. presenceArguments (
57- hooksFile: URL ( fileURLWithPath: " /tmp/hooks.json " ) ,
104+ hooksFile: URL ( fileURLWithPath: " /tmp/codex-notify.sh " ) ,
58105 sessionName: " graphcode-A " , zmxPath: zmx)
59106
60- #expect( arguments. first == " -c " )
61- #expect( arguments. count == 2 )
107+ #expect( arguments == [ " -c " , #"notify=["/bin/sh","/tmp/codex-notify.sh"]"# ] )
62108 // Codex has no `--settings` to layer hooks into and no `--name` to label a session.
63109 #expect( !arguments. contains ( " --settings " ) )
64110 #expect( !arguments. contains ( " --name " ) )
65111 }
66112
67113 @Test
68114 func nowhereToReportMeansNoFlagRatherThanABrokenOne( ) {
69- // A machine with no zmx cannot accept a presence or resume-ID report.
115+ // A machine with no zmx writes no script, and a local launch must not fall back to
116+ // naming the remote one.
70117 #expect(
71118 CLISessionBackendKind . codex. presenceArguments (
72119 hooksFile: nil , sessionName: " graphcode-A " , zmxPath: nil ) == [ ] )
120+ #expect(
121+ CLISessionBackendKind . codex. presenceArguments (
122+ hooksFile: nil , sessionName: " graphcode-A " , zmxPath: zmx) == [ ] )
73123 }
74124
75125 @Test
@@ -86,6 +136,7 @@ struct CodexPresenceTests {
86136 #expect( command. contains ( " notify= " ) )
87137 #expect( command. contains ( " thread-id " ) )
88138 #expect( command. contains ( " $HOME/.graphcode/sessions " ) )
139+ #expect( command. contains ( " codex-notify.sh " ) )
89140 #expect( !command. contains ( ZmxLocator . binaryURL. path) )
90141 }
91142
0 commit comments