From 9a52a7dea443ccf502815512ca9f7694c7bcf38c Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Wed, 22 Jul 2026 08:57:41 -0500 Subject: [PATCH 1/9] Add first-prompt auto-naming regression test --- cmuxTests/AutoNamingHookPayloadAdapterTests.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cmuxTests/AutoNamingHookPayloadAdapterTests.swift b/cmuxTests/AutoNamingHookPayloadAdapterTests.swift index 055eb62d3170..d8e9c122574e 100644 --- a/cmuxTests/AutoNamingHookPayloadAdapterTests.swift +++ b/cmuxTests/AutoNamingHookPayloadAdapterTests.swift @@ -41,11 +41,9 @@ import Testing ]) } - @Test func hookMessageLineEquivalentsReachSharedThrottleFloor() { - let messages = [ - AutoNamingTranscriptMessage(role: "user", text: "Name this workspace"), - AutoNamingTranscriptMessage(role: "assistant", text: "I can summarize it.") - ] + @Test func initialPromptReachesSharedThrottleFloor() { + // The first submitted prompt must be sufficient to start workspace naming. + let messages = [AutoNamingTranscriptMessage(role: "user", text: "Name this workspace")] let lineCount = engine.hookMessageLineEquivalentCount(messages) #expect(lineCount == engine.config.minTranscriptLines) From 8e406966b4d7535374d521b967dc40c8c14b4c62 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Wed, 22 Jul 2026 08:59:11 -0500 Subject: [PATCH 2/9] Name workspaces after the first prompt --- CLI/CMUXCLI+AutoNaming.swift | 3 ++- CLI/CMUXCLI+AutoNamingGenericHooks.swift | 33 ++++++++++++++++++++++++ CLI/cmux.swift | 32 +++++++++++++---------- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/CLI/CMUXCLI+AutoNaming.swift b/CLI/CMUXCLI+AutoNaming.swift index 0fc1bdc71589..581fc4613648 100644 --- a/CLI/CMUXCLI+AutoNaming.swift +++ b/CLI/CMUXCLI+AutoNaming.swift @@ -355,7 +355,8 @@ struct AutoNamingEngine: Sendable { totalMessageCount: Int? = nil ) -> Int { let count = max(messages.count, totalMessageCount ?? 0) - return count * config.minLineGrowth + // A single initial prompt qualifies; later turns retain monotonic growth units. + return max(config.minTranscriptLines, count * config.minLineGrowth) } // MARK: - Prompt and response diff --git a/CLI/CMUXCLI+AutoNamingGenericHooks.swift b/CLI/CMUXCLI+AutoNamingGenericHooks.swift index 4498a19b50f4..af283487149c 100644 --- a/CLI/CMUXCLI+AutoNamingGenericHooks.swift +++ b/CLI/CMUXCLI+AutoNamingGenericHooks.swift @@ -45,6 +45,39 @@ extension CMUXCLI { return engine.extractHookMessages(fromPayloadObjects: [object]) } + /// Starts a detached naming pass when the live workspace still permits generated titles. + func spawnDetachedAgentAutoNameIfEnabled( + def: AgentHookDef, + sessionId: String, + workspaceId: String, + surfaceId: String, + transcriptPath: String?, + cwd: String?, + env: [String: String], + client: SocketClient, + telemetry: CLISocketSentryTelemetry + ) { + guard autoNamingSource(for: def) != nil, !sessionId.isEmpty, + let probe = try? client.sendV2( + method: "workspace.set_auto_title", + params: ["probe": true, "workspace_id": workspaceId] + ), + probe["enabled"] as? Bool == true, + probe["workspace_user_owned"] as? Bool != true else { + return + } + spawnDetachedAgentAutoName( + def: def, + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + transcriptPath: transcriptPath, + cwd: cwd, + env: env, + telemetry: telemetry + ) + } + /// Detached naming pass for non-Codex generic agents. func runGenericAgentAutoNameHook( def: AgentHookDef, diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ba2b99dea761..0107923b9008 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -31872,6 +31872,21 @@ export default CMUXSessionRestore; ) } + // Message-backed agents can name the workspace from the first prompt while work continues. + if usesHookMessageCacheForAutoNaming(def), !suppressVisibleMutations { + spawnDetachedAgentAutoNameIfEnabled( + def: def, + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + transcriptPath: normalizedHookValue(input.transcriptPath ?? mapped?.transcriptPath), + cwd: hookCwd ?? mapped?.cwd, + env: env, + client: client, + telemetry: telemetry + ) + } + case .stop: if def.name == "codex", !sessionId.isEmpty { let stopTurnId = input.turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -32208,19 +32223,9 @@ export default CMUXSessionRestore; } } - // Opt-in auto-naming for generic-agent sessions: a detached pass so the - // summarization subprocess never blocks this short sync hook. - // Gate the fork on the live setting (one cheap socket probe) so a - // disabled feature spawns nothing extra on turn end; the detached - // process re-probes to honor a toggle that lands mid-pass. - if autoNamingSource(for: def) != nil, !suppressVisibleMutations, !sessionId.isEmpty, - let autoNameProbe = try? client.sendV2( - method: "workspace.set_auto_title", - params: ["probe": true, "workspace_id": workspaceId] - ), - autoNameProbe["enabled"] as? Bool == true, - autoNameProbe["workspace_user_owned"] as? Bool != true { - spawnDetachedAgentAutoName( + // Turn-end naming refreshes file-backed agents and incorporates the completed response. + if !suppressVisibleMutations { + spawnDetachedAgentAutoNameIfEnabled( def: def, sessionId: sessionId, workspaceId: workspaceId, @@ -32228,6 +32233,7 @@ export default CMUXSessionRestore; transcriptPath: normalizedHookValue(input.transcriptPath ?? mapped?.transcriptPath), cwd: cwd, env: env, + client: client, telemetry: telemetry ) } From c0224e8402b76509e9f96460f29f3eca9ddccb38 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Wed, 22 Jul 2026 10:04:14 -0500 Subject: [PATCH 3/9] fixup! Name workspaces after the first prompt --- CLI/CMUXCLI+AutoNaming.swift | 3 ++- cmuxTests/AutoNamingHookPayloadAdapterTests.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLI/CMUXCLI+AutoNaming.swift b/CLI/CMUXCLI+AutoNaming.swift index 581fc4613648..b9f032296152 100644 --- a/CLI/CMUXCLI+AutoNaming.swift +++ b/CLI/CMUXCLI+AutoNaming.swift @@ -355,7 +355,8 @@ struct AutoNamingEngine: Sendable { totalMessageCount: Int? = nil ) -> Int { let count = max(messages.count, totalMessageCount ?? 0) - // A single initial prompt qualifies; later turns retain monotonic growth units. + // Empty caches stay ineligible; one initial prompt reaches the naming floor. + guard count > 0 else { return 0 } return max(config.minTranscriptLines, count * config.minLineGrowth) } diff --git a/cmuxTests/AutoNamingHookPayloadAdapterTests.swift b/cmuxTests/AutoNamingHookPayloadAdapterTests.swift index d8e9c122574e..99584331dad1 100644 --- a/cmuxTests/AutoNamingHookPayloadAdapterTests.swift +++ b/cmuxTests/AutoNamingHookPayloadAdapterTests.swift @@ -47,6 +47,7 @@ import Testing let lineCount = engine.hookMessageLineEquivalentCount(messages) #expect(lineCount == engine.config.minTranscriptLines) + #expect(engine.hookMessageLineEquivalentCount([]) == 0) let decision = engine.throttleDecision( snapshot: AutoNamingSessionSnapshot(), From e04236453abeabed93adff4b5cf556fd06ae90e2 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Fri, 24 Jul 2026 08:51:51 -0500 Subject: [PATCH 4/9] fixup! Add first-prompt auto-naming regression test --- ...ifyProcessIntegrationRegressionTests.swift | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 0feece7faefd..c3f23a73a229 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -81,6 +81,44 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + func testPiPromptSubmitStartsWorkspaceAutoNaming() throws { + let context = try makeClaudeHookContext(name: "pi-first-prompt-auto-name") + defer { context.cleanup() } + + // Supply a deterministic Pi summarizer so the detached naming pass reaches the socket apply. + let piURL = context.root.appendingPathComponent("pi", isDirectory: false) + try "#!/bin/sh\nprintf 'Java Workspace\\n'\n".write(to: piURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: piURL.path) + startAgentHookMockServerAccepting( + context: context, + workspaceAutoTitleResult: [ + "enabled": true, + "workspace_user_owned": false, + "workspace_applied": true, + ] + ) + + let result = runAgentHook( + context: context, + agent: "pi", + subcommand: "prompt-submit", + standardInput: #"{"session_id":"pi-first-prompt","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"Fix the Java build"}"#, + extraEnvironment: ["PATH": "\(context.root.path):/usr/bin:/bin:/usr/sbin:/sbin"] + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(waitForCondition(timeout: 5) { + context.state.snapshot().compactMap(self.jsonObject).contains { payload in + guard payload["method"] as? String == "workspace.set_auto_title", + let params = payload["params"] as? [String: Any] else { + return false + } + return params["title"] as? String == "Java Workspace" + } + }, "The first submitted Pi prompt must start and apply workspace auto-naming.") + } + func testClaudePreToolUseFeedContextReadsOnlyRecentTranscriptTail() throws { let context = try makeClaudeHookContext(name: "claude-pretool-tail") defer { context.cleanup() } @@ -9158,10 +9196,17 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { /// Serves this context's agent-hook mock socket for the rest of the test. One /// accept loop answers every connection, including the CLI's extra `system.top` /// lookup connection, and the registry reaps the loop at teardown. - private func startAgentHookMockServerAccepting(context: ClaudeHookContext) { + private func startAgentHookMockServerAccepting( + context: ClaudeHookContext, + workspaceAutoTitleResult: [String: Any]? = nil + ) { let state = context.state let mockResponse: @Sendable (String) -> String = { line in - self.agentHookMockResponse(line: line, context: context) + self.agentHookMockResponse( + line: line, + context: context, + workspaceAutoTitleResult: workspaceAutoTitleResult + ) } CLIMockAcceptLoopRegistry.shared.start(listenerFD: context.listenerFD, onConnection: { clientFD in defer { Darwin.close(clientFD) } @@ -9172,7 +9217,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { }, onListenerClosed: {}) } - private func agentHookMockResponse(line: String, context: ClaudeHookContext) -> String { + private func agentHookMockResponse( + line: String, + context: ClaudeHookContext, + workspaceAutoTitleResult: [String: Any]? = nil + ) -> String { guard let payload = jsonObject(line) else { return "OK" } @@ -9188,6 +9237,15 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { return v2Response(id: id, ok: true, result: ["resume_binding": [:]]) case "surface.resume.clear": return v2Response(id: id, ok: true, result: ["cleared": true]) + case "workspace.set_auto_title": + guard let workspaceAutoTitleResult else { + return v2Response( + id: id, + ok: false, + error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"] + ) + } + return v2Response(id: id, ok: true, result: workspaceAutoTitleResult) default: return v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) } From 22c6e839ee012f62312de7d44601e57321cc97e5 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Fri, 24 Jul 2026 09:18:54 -0500 Subject: [PATCH 5/9] fixup! Name workspaces after the first prompt --- CLI/CMUXCLI+AutoNamingGenericHooks.swift | 15 ++------ CLI/CMUXCLI+AutoNamingHooks.swift | 44 ++++++++++-------------- CLI/cmux.swift | 6 ++-- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/CLI/CMUXCLI+AutoNamingGenericHooks.swift b/CLI/CMUXCLI+AutoNamingGenericHooks.swift index af283487149c..0eda8e685515 100644 --- a/CLI/CMUXCLI+AutoNamingGenericHooks.swift +++ b/CLI/CMUXCLI+AutoNamingGenericHooks.swift @@ -45,8 +45,8 @@ extension CMUXCLI { return engine.extractHookMessages(fromPayloadObjects: [object]) } - /// Starts a detached naming pass when the live workspace still permits generated titles. - func spawnDetachedAgentAutoNameIfEnabled( + /// Starts a detached naming pass; the worker checks live settings and title ownership. + func spawnDetachedAgentAutoNameIfSupported( def: AgentHookDef, sessionId: String, workspaceId: String, @@ -54,18 +54,9 @@ extension CMUXCLI { transcriptPath: String?, cwd: String?, env: [String: String], - client: SocketClient, telemetry: CLISocketSentryTelemetry ) { - guard autoNamingSource(for: def) != nil, !sessionId.isEmpty, - let probe = try? client.sendV2( - method: "workspace.set_auto_title", - params: ["probe": true, "workspace_id": workspaceId] - ), - probe["enabled"] as? Bool == true, - probe["workspace_user_owned"] as? Bool != true else { - return - } + guard autoNamingSource(for: def) != nil, !sessionId.isEmpty else { return } spawnDetachedAgentAutoName( def: def, sessionId: sessionId, diff --git a/CLI/CMUXCLI+AutoNamingHooks.swift b/CLI/CMUXCLI+AutoNamingHooks.swift index a201149ad5bc..a94e4662bf3e 100644 --- a/CLI/CMUXCLI+AutoNamingHooks.swift +++ b/CLI/CMUXCLI+AutoNamingHooks.swift @@ -1,4 +1,3 @@ -import Darwin import Foundation extension CMUXCLI { @@ -119,10 +118,14 @@ extension CMUXCLI { telemetry: CLISocketSentryTelemetry ) { let selfPath: String = { - if let first = ProcessInfo.processInfo.arguments.first, - first.hasPrefix("/"), - FileManager.default.isExecutableFile(atPath: first) { - return first + if let first = ProcessInfo.processInfo.arguments.first { + let resolved = URL( + fileURLWithPath: first, + relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + ).standardizedFileURL.path + if FileManager.default.isExecutableFile(atPath: resolved) { + return resolved + } } if let bundled = normalizedHookValue(env["CMUX_BUNDLED_CLI_PATH"]), FileManager.default.isExecutableFile(atPath: bundled) { @@ -130,19 +133,17 @@ extension CMUXCLI { } return "cmux" }() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = [ - "-c", - "\"$0\" hooks \"$1\" auto-name --session \"$2\" --workspace \"$3\" --surface \"$4\" --transcript \"$5\" --cwd \"$6\" /dev/null 2>&1 &", - selfPath, - def.name, - sessionId, - workspaceId, - surfaceId, - transcriptPath ?? "", - cwd ?? "" + let hookArguments = [ + "hooks", def.name, "auto-name", + "--session", sessionId, + "--workspace", workspaceId, + "--surface", surfaceId, + "--transcript", transcriptPath ?? "", + "--cwd", cwd ?? "" ] + let process = Process() + process.executableURL = URL(fileURLWithPath: selfPath.hasPrefix("/") ? selfPath : "/usr/bin/env") + process.arguments = selfPath.hasPrefix("/") ? hookArguments : [selfPath] + hookArguments var spawnEnv = env spawnEnv["CMUX_CLAUDE_HOOK_STATE_PATH"] = agentHookStatePath(sessionStoreSuffix: def.sessionStoreSuffix, env: env) process.environment = spawnEnv @@ -150,17 +151,10 @@ extension CMUXCLI { process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice do { + // Launch the bounded worker directly; the short-lived hook process must not own its lifetime. try process.run() } catch { telemetry.breadcrumb("\(def.name)-hook.auto-name.spawn-failed") - return - } - if ((try? waitForProcessExit(process, timeout: 2)) ?? false) == false { - process.terminate() - if ((try? waitForProcessExit(process, timeout: 1)) ?? false) == false { - kill(process.processIdentifier, SIGKILL) - _ = try? waitForProcessExit(process, timeout: 1) - } } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0107923b9008..bfb1c6b84811 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -31874,7 +31874,7 @@ export default CMUXSessionRestore; // Message-backed agents can name the workspace from the first prompt while work continues. if usesHookMessageCacheForAutoNaming(def), !suppressVisibleMutations { - spawnDetachedAgentAutoNameIfEnabled( + spawnDetachedAgentAutoNameIfSupported( def: def, sessionId: sessionId, workspaceId: workspaceId, @@ -31882,7 +31882,6 @@ export default CMUXSessionRestore; transcriptPath: normalizedHookValue(input.transcriptPath ?? mapped?.transcriptPath), cwd: hookCwd ?? mapped?.cwd, env: env, - client: client, telemetry: telemetry ) } @@ -32225,7 +32224,7 @@ export default CMUXSessionRestore; // Turn-end naming refreshes file-backed agents and incorporates the completed response. if !suppressVisibleMutations { - spawnDetachedAgentAutoNameIfEnabled( + spawnDetachedAgentAutoNameIfSupported( def: def, sessionId: sessionId, workspaceId: workspaceId, @@ -32233,7 +32232,6 @@ export default CMUXSessionRestore; transcriptPath: normalizedHookValue(input.transcriptPath ?? mapped?.transcriptPath), cwd: cwd, env: env, - client: client, telemetry: telemetry ) } From 4ec0b928a5f7f44de61f50665b8a8d83ba2a9ffd Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Fri, 24 Jul 2026 09:18:54 -0500 Subject: [PATCH 6/9] fixup! Add first-prompt auto-naming regression test --- cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index c3f23a73a229..b45efe21e1b5 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -87,7 +87,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { // Supply a deterministic Pi summarizer so the detached naming pass reaches the socket apply. let piURL = context.root.appendingPathComponent("pi", isDirectory: false) - try "#!/bin/sh\nprintf 'Java Workspace\\n'\n".write(to: piURL, atomically: true, encoding: .utf8) + try "#!/bin/sh\nsleep 1\nprintf 'Java Workspace\\n'\n".write( + to: piURL, + atomically: true, + encoding: .utf8 + ) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: piURL.path) startAgentHookMockServerAccepting( context: context, From 2bd0904c081a67df57474d284c0f20d94eac0a7c Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Fri, 24 Jul 2026 11:17:42 -0500 Subject: [PATCH 7/9] fixup! Name workspaces after the first prompt --- CLI/CMUXCLI+AutoNamingHooks.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+AutoNamingHooks.swift b/CLI/CMUXCLI+AutoNamingHooks.swift index a94e4662bf3e..b0d44d193ad4 100644 --- a/CLI/CMUXCLI+AutoNamingHooks.swift +++ b/CLI/CMUXCLI+AutoNamingHooks.swift @@ -123,12 +123,12 @@ extension CMUXCLI { fileURLWithPath: first, relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) ).standardizedFileURL.path - if FileManager.default.isExecutableFile(atPath: resolved) { + if isExecutableRegularFile(atPath: resolved) { return resolved } } if let bundled = normalizedHookValue(env["CMUX_BUNDLED_CLI_PATH"]), - FileManager.default.isExecutableFile(atPath: bundled) { + isExecutableRegularFile(atPath: bundled) { return bundled } return "cmux" @@ -158,6 +158,16 @@ extension CMUXCLI { } } + private func isExecutableRegularFile(atPath path: String) -> Bool { + // Resolve symlinks before rejecting non-regular filesystem entries. + let resolvedURL = URL(fileURLWithPath: path).resolvingSymlinksInPath() + guard let values = try? resolvedURL.resourceValues(forKeys: [.isRegularFileKey]), + values.isRegularFile == true else { + return false + } + return FileManager.default.isExecutableFile(atPath: path) + } + /// Detached Codex naming pass. func runCodexAutoNameHook( commandArgs: [String], From 2c5e19aa56837e3449d0132d5ae96539c5bc89e5 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Fri, 24 Jul 2026 11:17:42 -0500 Subject: [PATCH 8/9] fixup! Add first-prompt auto-naming regression test --- ...ifyProcessIntegrationRegressionTests.swift | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index b45efe21e1b5..80ca9e82ff49 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -85,9 +85,34 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { let context = try makeClaudeHookContext(name: "pi-first-prompt-auto-name") defer { context.cleanup() } - // Supply a deterministic Pi summarizer so the detached naming pass reaches the socket apply. + // Gate the Pi summarizer so the test controls when detached naming can finish. let piURL = context.root.appendingPathComponent("pi", isDirectory: false) - try "#!/bin/sh\nsleep 1\nprintf 'Java Workspace\\n'\n".write( + let piStartedURL = context.root.appendingPathComponent("pi-started", isDirectory: false) + let piReleaseURL = context.root.appendingPathComponent("pi-release", isDirectory: false) + guard Darwin.mkfifo(piReleaseURL.path, 0o600) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let piReleaseFD = Darwin.open(piReleaseURL.path, O_RDWR | O_CLOEXEC) + guard piReleaseFD >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + var piWasReleased = false + let releasePiSummarizer = { + guard !piWasReleased else { return } + piWasReleased = true + // A newline releases the mock's blocking read exactly once. + _ = "\n".withCString { Darwin.write(piReleaseFD, $0, 1) } + } + defer { + releasePiSummarizer() + Darwin.close(piReleaseFD) + } + try """ + #!/bin/sh + : > "$CMUX_TEST_PI_STARTED" + IFS= read -r _ < "$CMUX_TEST_PI_RELEASE_FIFO" + printf 'Java Workspace\\n' + """.write( to: piURL, atomically: true, encoding: .utf8 @@ -107,11 +132,31 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { agent: "pi", subcommand: "prompt-submit", standardInput: #"{"session_id":"pi-first-prompt","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"Fix the Java build"}"#, - extraEnvironment: ["PATH": "\(context.root.path):/usr/bin:/bin:/usr/sbin:/sbin"] + extraEnvironment: [ + "PATH": "\(context.root.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_TEST_PI_STARTED": piStartedURL.path, + "CMUX_TEST_PI_RELEASE_FIFO": piReleaseURL.path, + ] ) - XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertFalse(result.timedOut, "The prompt hook must return before Pi completes: \(result.stderr)") XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue( + waitForCondition(timeout: 5) { FileManager.default.fileExists(atPath: piStartedURL.path) }, + "The Pi summarizer must reach the controlled release point." + ) + XCTAssertFalse( + context.state.snapshot().compactMap(self.jsonObject).contains { payload in + guard payload["method"] as? String == "workspace.set_auto_title", + let params = payload["params"] as? [String: Any] else { + return false + } + return params["title"] != nil + }, + "Auto-naming must remain blocked until the test releases Pi." + ) + + releasePiSummarizer() XCTAssertTrue(waitForCondition(timeout: 5) { context.state.snapshot().compactMap(self.jsonObject).contains { payload in guard payload["method"] as? String == "workspace.set_auto_title", From 3f9588a9590e40006b611e304049174b351e43f4 Mon Sep 17 00:00:00 2001 From: mrohan-sq Date: Wed, 5 Aug 2026 11:30:37 -0500 Subject: [PATCH 9/9] fixup! fixup! Add first-prompt auto-naming regression test --- .../CLINotifyProcessIntegrationRegressionTests.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 80ca9e82ff49..7f904117bb75 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -107,10 +107,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { releasePiSummarizer() Darwin.close(piReleaseFD) } + // The isolated paths avoid relying on credentials-safe summarizer environment variables. try """ #!/bin/sh - : > "$CMUX_TEST_PI_STARTED" - IFS= read -r _ < "$CMUX_TEST_PI_RELEASE_FIFO" + : > "\(piStartedURL.path)" + IFS= read -r _ < "\(piReleaseURL.path)" printf 'Java Workspace\\n' """.write( to: piURL, @@ -134,15 +135,13 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { standardInput: #"{"session_id":"pi-first-prompt","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"Fix the Java build"}"#, extraEnvironment: [ "PATH": "\(context.root.path):/usr/bin:/bin:/usr/sbin:/sbin", - "CMUX_TEST_PI_STARTED": piStartedURL.path, - "CMUX_TEST_PI_RELEASE_FIFO": piReleaseURL.path, ] ) XCTAssertFalse(result.timedOut, "The prompt hook must return before Pi completes: \(result.stderr)") XCTAssertEqual(result.status, 0, result.stderr) XCTAssertTrue( - waitForCondition(timeout: 5) { FileManager.default.fileExists(atPath: piStartedURL.path) }, + waitForConditionBlocking(timeout: 5) { FileManager.default.fileExists(atPath: piStartedURL.path) }, "The Pi summarizer must reach the controlled release point." ) XCTAssertFalse( @@ -157,7 +156,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) releasePiSummarizer() - XCTAssertTrue(waitForCondition(timeout: 5) { + XCTAssertTrue(waitForConditionBlocking(timeout: 5) { context.state.snapshot().compactMap(self.jsonObject).contains { payload in guard payload["method"] as? String == "workspace.set_auto_title", let params = payload["params"] as? [String: Any] else {