From 7e8c94b08686a11af8ebd15077a4f58f256a4634 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 03:23:24 -0700 Subject: [PATCH 1/3] test: cover hermes session-end restore retention --- .../CLIGenericHookPersistenceTests.swift | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 9f7d205470..4057956c7a 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -679,6 +679,121 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(responseSession["runtimeStatus"] as? String, "running") } + func testHermesAgentSessionEndPreservesRestoreRoute() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("hermes-session-end") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-hermes-session-end-\(UUID().uuidString)", isDirectory: true) + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "hermes-session-restore" + let executable = "/Users/example/.local/bin/hermes" + + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let environment: [String: String] = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": workspace.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_AGENT_LAUNCH_KIND": "hermes-agent", + "CMUX_AGENT_LAUNCH_EXECUTABLE": executable, + "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated([ + executable, + "--model", + "gpt-5.5", + "--resume", + "old-session", + "initial prompt should not persist" + ]), + "CMUX_AGENT_LAUNCH_CWD": workspace.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + + func runHermesHook(_ subcommand: String, input: String) -> ProcessRunResult { + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return self.surfaceListResponse(id: id, surfaceId: surfaceId) + case "feed.push", "surface.resume.set", "surface.resume.clear": + return self.v2Response(id: id, ok: true, result: [:]) + default: + return self.v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "hermes-agent", subcommand], + environment: environment, + standardInput: input, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + return result + } + + func storedHermesSessions() throws -> [String: Any] { + let storeURL = root.appendingPathComponent("hermes-agent-hook-sessions.json", isDirectory: false) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + return try XCTUnwrap(json["sessions"] as? [String: Any]) + } + + let start = runHermesHook( + "session-start", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(workspace.path)","hook_event_name":"on_session_start"}"# + ) + XCTAssertFalse(start.timedOut, start.stderr) + XCTAssertEqual(start.status, 0, start.stderr) + XCTAssertEqual(start.stdout, "{}\n") + + XCTAssertNotNil( + try storedHermesSessions()[sessionId], + "Expected Hermes session-start to persist the route before the per-turn session-end" + ) + + let sessionEndCommandStart = state.commands.count + let sessionEnd = runHermesHook( + "session-end", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(workspace.path)","hook_event_name":"on_session_end"}"# + ) + XCTAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) + XCTAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) + XCTAssertEqual(sessionEnd.stdout, "{}\n") + + let sessionEndCommands = Array(state.commands.dropFirst(sessionEndCommandStart)) + let sessionEndMethods = sessionEndCommands.compactMap { self.jsonObject($0)?["method"] as? String } + XCTAssertEqual( + sessionEndMethods, + ["feed.push"], + "Hermes on_session_end is a turn boundary and should only emit feed telemetry, saw \(sessionEndCommands)" + ) + XCTAssertFalse( + sessionEndCommands.contains { $0.hasPrefix("clear_agent_pid hermes-agent.") }, + "Hermes on_session_end must not clear saved routing, saw \(sessionEndCommands)" + ) + XCTAssertNotNil( + try storedHermesSessions()[sessionId], + "Expected Hermes route to remain available after per-turn on_session_end" + ) + } + func testAntigravityHookInstallUsesNativeHooksJSONShape() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory From a708ba41e37b8b819f2466af4e211d4e951d1ac6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 03:25:25 -0700 Subject: [PATCH 2/3] fix: preserve hermes route across turn end --- CLI/cmux.swift | 16 ++++++++++++- .../CLIGenericHookPersistenceTests.swift | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0158b64168..48fbea530b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27084,6 +27084,20 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return def.feedHookEvents.contains(event) } + func rawAgentHookEventName() -> String? { + input.object.flatMap { + firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) + } ?? input.rawObject.flatMap { + firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) + } + } + func sessionEndIsTurnBoundary() -> Bool { + if def.name == "grok" || def.name == "antigravity" { + return true + } + guard def.name == "hermes-agent" else { return false } + return rawAgentHookEventName()?.caseInsensitiveCompare("on_session_end") == .orderedSame + } func sendAgentFeedTelemetryUnlessSuppressed(workspaceId: String? = nil) { if shouldSuppressGenericFeedTelemetry() { didSendFeedTelemetry = true @@ -28040,7 +28054,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if def.name == "codex", !sessionId.isEmpty { retireCodexMonitorLeases(sessionId: sessionId, turnId: nil, env: env) } - if def.name == "grok" || def.name == "antigravity" { + if sessionEndIsTurnBoundary() { if let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) { sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) _ = try? store.recordPromptStop( diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 4057956c7a..d3e33f99cc 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -792,6 +792,30 @@ extension CLINotifyProcessIntegrationRegressionTests { try storedHermesSessions()[sessionId], "Expected Hermes route to remain available after per-turn on_session_end" ) + + let finalizeCommandStart = state.commands.count + let finalize = runHermesHook( + "session-end", + input: #"{"session_id":"\#(sessionId)","cwd":"\#(workspace.path)","hook_event_name":"on_session_finalize"}"# + ) + XCTAssertFalse(finalize.timedOut, finalize.stderr) + XCTAssertEqual(finalize.status, 0, finalize.stderr) + XCTAssertEqual(finalize.stdout, "{}\n") + + let finalizeCommands = Array(state.commands.dropFirst(finalizeCommandStart)) + let finalizeMethods = finalizeCommands.compactMap { self.jsonObject($0)?["method"] as? String } + XCTAssertTrue( + finalizeMethods.contains("surface.resume.clear"), + "Hermes on_session_finalize is the real session boundary and should clear the resume binding, saw \(finalizeCommands)" + ) + XCTAssertTrue( + finalizeCommands.contains { $0.hasPrefix("clear_agent_pid hermes-agent.") }, + "Hermes on_session_finalize should clear saved routing, saw \(finalizeCommands)" + ) + XCTAssertNil( + try storedHermesSessions()[sessionId], + "Expected Hermes route to be consumed after on_session_finalize" + ) } func testAntigravityHookInstallUsesNativeHooksJSONShape() throws { From 956a492e07d0fcf38ca991e7ca4f58a48f95be31 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 30 May 2026 04:07:51 -0700 Subject: [PATCH 3/3] fix: share hermes hook event lookup --- CLI/cmux.swift | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 48fbea530b..efd16fe226 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27072,18 +27072,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { workspaceId: workspaceId ?? workspaceArg() ) } - func shouldSuppressGenericFeedTelemetry() -> Bool { - guard def.name == "hermes-agent", - let event = input.object.flatMap({ - firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) - }) ?? input.rawObject.flatMap({ - firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) - }) - else { - return false - } - return def.feedHookEvents.contains(event) - } func rawAgentHookEventName() -> String? { input.object.flatMap { firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) @@ -27091,6 +27079,14 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"]) } } + func shouldSuppressGenericFeedTelemetry() -> Bool { + guard def.name == "hermes-agent", + let event = rawAgentHookEventName() + else { + return false + } + return def.feedHookEvents.contains(event) + } func sessionEndIsTurnBoundary() -> Bool { if def.name == "grok" || def.name == "antigravity" { return true