diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 055b672c140a..b0679f262538 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -5,7 +5,7 @@ 17229 Sources/TerminalController.swift 15966 Sources/ContentView.swift 14501 Sources/AppDelegate.swift -13899 Sources/Workspace.swift +13951 Sources/Workspace.swift 13458 Sources/GhosttyTerminalView.swift 10607 Sources/Panels/BrowserPanel.swift 8286 Sources/cmuxApp.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b1cf239e4639..677444e37230 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; + C0D3499C0D3499C0D349903 /* CodexSessionTitleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3499C0D3499C0D349904 /* CodexSessionTitleParser.swift */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */; }; E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */; }; @@ -285,6 +286,7 @@ F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */; }; F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + C0D3499C0D3499C0D349901 /* RestorableCodexTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3499C0D3499C0D349902 /* RestorableCodexTitleTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */; }; F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; @@ -571,6 +573,7 @@ A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; + C0D3499C0D3499C0D349904 /* CodexSessionTitleParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexSessionTitleParser.swift; sourceTree = ""; }; A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSurfaceTabBarBuiltInAction.swift; sourceTree = ""; }; E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWorkspaceDefinition.swift; sourceTree = ""; }; @@ -652,6 +655,7 @@ F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotTests.swift; sourceTree = ""; }; F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + C0D3499C0D3499C0D349902 /* RestorableCodexTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableCodexTitleTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateEqualizeSplitsShortcutTests.swift; sourceTree = ""; }; F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutUnbindingTests.swift; sourceTree = ""; }; @@ -969,6 +973,7 @@ A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, A5001661 /* RestorableAgentSession.swift */, + C0D3499C0D3499C0D349904 /* CodexSessionTitleParser.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* CmuxConfig.swift */, C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */, @@ -1033,6 +1038,7 @@ F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */, F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + C0D3499C0D3499C0D349902 /* RestorableCodexTitleTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, C34670010000000000000002 /* AppDelegateRenameShortcutContextTests.swift */, @@ -1474,6 +1480,7 @@ A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, + C0D3499C0D3499C0D349903 /* CodexSessionTitleParser.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */, @@ -1576,6 +1583,7 @@ F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */, F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + C0D3499C0D3499C0D349901 /* RestorableCodexTitleTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */, diff --git a/Sources/CodexSessionTitleParser.swift b/Sources/CodexSessionTitleParser.swift new file mode 100644 index 000000000000..f8f25325a40c --- /dev/null +++ b/Sources/CodexSessionTitleParser.swift @@ -0,0 +1,64 @@ +import Foundation + +enum CodexSessionTitleParser { + static let launchSource = "surface-title" + + private static let sessionSlugRegex = try! NSRegularExpression( + pattern: #"(?i)(?:^|[^A-Z0-9_-])(codex-[0-9a-f]{8,}(?:-[A-Z0-9]+)+)(?=$|[^A-Z0-9_-])"# + ) + + static func sessionId(from title: String?) -> String? { + guard let title else { return nil } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let range = NSRange(trimmed.startIndex.. 1, + let slugRange = Range(match.range(at: 1), in: trimmed) else { + return nil + } + return String(trimmed[slugRange]) + } + + static func restorableSnapshot( + fromTitle title: String?, + workingDirectory: String? + ) -> SessionRestorableAgentSnapshot? { + guard let sessionId = sessionId(from: title) else { return nil } + let normalizedWorkingDirectory = normalized(workingDirectory) + return SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: sessionId, + workingDirectory: normalizedWorkingDirectory, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "codex", + arguments: [ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + ], + workingDirectory: normalizedWorkingDirectory, + environment: nil, + capturedAt: nil, + source: launchSource + ) + ) + } + + static func isSurfaceTitleSnapshot(_ snapshot: SessionRestorableAgentSnapshot) -> Bool { + snapshot.kind == .codex && snapshot.launchCommand?.source == launchSource + } + + static func snapshot(_ snapshot: SessionRestorableAgentSnapshot, matchesTitle title: String?) -> Bool { + isSurfaceTitleSnapshot(snapshot) && sessionId(from: title) == snapshot.sessionId + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9cf769417925..3e10f1feef0e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -236,6 +236,7 @@ extension Workspace { restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) restoredAgentSnapshotsByPanelId.removeAll(keepingCapacity: false) restoredAgentAutoResumePendingPanelIds.removeAll(keepingCapacity: false) + restoredAgentAutoResumeRunningPanelIds.removeAll(keepingCapacity: false) invalidatedRestoredAgentFingerprintsByPanelId.removeAll(keepingCapacity: false) let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) @@ -370,10 +371,29 @@ extension Workspace { invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) } } - let effectiveRestorableAgent = restoredAgentSnapshotsByPanelId[panelId] let panelTitle = panelTitle(panelId: panelId) let customTitle = panelCustomTitles[panelId] + let shellActivityState = panelShellActivityStates[panelId] ?? .unknown + if restorableAgent == nil { + let currentSnapshot = restoredAgentSnapshotsByPanelId[panelId] + if shellActivityState == .promptIdle, + let currentSnapshot, + CodexSessionTitleParser.isSurfaceTitleSnapshot(currentSnapshot) { + restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) + restoredAgentAutoResumePendingPanelIds.remove(panelId) + restoredAgentAutoResumeRunningPanelIds.remove(panelId) + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) + } else if shellActivityState != .promptIdle, + let titleRestorableAgent = CodexSessionTitleParser.restorableSnapshot( + fromTitle: panelTitles[panelId] ?? panel.displayTitle, + workingDirectory: panelDirectories[panelId] + ) { + restoredAgentSnapshotsByPanelId[panelId] = titleRestorableAgent + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) + } + } + let effectiveRestorableAgent = restoredAgentSnapshotsByPanelId[panelId] let directory = panelDirectories[panelId] ?? effectiveRestorableAgent?.workingDirectory let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) @@ -648,13 +668,16 @@ extension Workspace { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent if restoredAgentResumeInput != nil { restoredAgentAutoResumePendingPanelIds.insert(terminalPanel.id) + restoredAgentAutoResumeRunningPanelIds.remove(terminalPanel.id) } else { restoredAgentAutoResumePendingPanelIds.remove(terminalPanel.id) + restoredAgentAutoResumeRunningPanelIds.remove(terminalPanel.id) } invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: terminalPanel.id) } else { restoredAgentSnapshotsByPanelId.removeValue(forKey: terminalPanel.id) restoredAgentAutoResumePendingPanelIds.remove(terminalPanel.id) + restoredAgentAutoResumeRunningPanelIds.remove(terminalPanel.id) invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: terminalPanel.id) } applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) @@ -7267,6 +7290,7 @@ final class Workspace: Identifiable, ObservableObject { private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private var restoredAgentSnapshotsByPanelId: [UUID: SessionRestorableAgentSnapshot] = [:] private var restoredAgentAutoResumePendingPanelIds: Set = [] + private var restoredAgentAutoResumeRunningPanelIds: Set = [] private var invalidatedRestoredAgentFingerprintsByPanelId: [UUID: Int] = [:] private var pendingTerminalInputObserversByPanelId: [UUID: [WorkspacePendingTerminalInputObserver]] = [:] @@ -8554,15 +8578,38 @@ final class Workspace: Identifiable, ObservableObject { guard previousState != state else { return } panelShellActivityStates[panelId] = state if state == .commandRunning, let restoredAgent = restoredAgentSnapshotsByPanelId[panelId] { - if restoredAgentAutoResumePendingPanelIds.remove(panelId) == nil { + if restoredAgentAutoResumePendingPanelIds.remove(panelId) != nil { + restoredAgentAutoResumeRunningPanelIds.insert(panelId) + } else if CodexSessionTitleParser.snapshot( + restoredAgent, + matchesTitle: panelTitles[panelId] ?? panels[panelId]?.displayTitle + ) { + restoredAgentAutoResumeRunningPanelIds.insert(panelId) + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) + } else { let fingerprint = TabManager.restorableAgentSnapshotFingerprint(restoredAgent) invalidatedRestoredAgentFingerprintsByPanelId[panelId] = fingerprint restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) + restoredAgentAutoResumeRunningPanelIds.remove(panelId) #if DEBUG cmuxDebugLog( "session.restore.agent.invalidate panel=\(panelId.uuidString.prefix(5)) " + "kind=\(restoredAgent.kind.rawValue) session=\(restoredAgent.sessionId.prefix(8))" ) +#endif + } + } else if state == .promptIdle { + let wasAutoResume = restoredAgentAutoResumeRunningPanelIds.remove(panelId) != nil + let wasPendingAutoResume = restoredAgentAutoResumePendingPanelIds.remove(panelId) != nil + if let restoredAgent = restoredAgentSnapshotsByPanelId[panelId], + wasAutoResume || wasPendingAutoResume || CodexSessionTitleParser.isSurfaceTitleSnapshot(restoredAgent) { + restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) + invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) +#if DEBUG + cmuxDebugLog( + "session.restore.agent.clear panel=\(panelId.uuidString.prefix(5)) " + + "kind=\(restoredAgent.kind.rawValue) session=\(restoredAgent.sessionId.prefix(8))" + ) #endif } } @@ -8796,6 +8843,9 @@ final class Workspace: Identifiable, ObservableObject { restoredAgentAutoResumePendingPanelIds = restoredAgentAutoResumePendingPanelIds.filter { validSurfaceIds.contains($0) } + restoredAgentAutoResumeRunningPanelIds = restoredAgentAutoResumeRunningPanelIds.filter { + validSurfaceIds.contains($0) + } invalidatedRestoredAgentFingerprintsByPanelId = invalidatedRestoredAgentFingerprintsByPanelId.filter { validSurfaceIds.contains($0.key) } @@ -10538,6 +10588,7 @@ final class Workspace: Identifiable, ObservableObject { pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) pruneSurfaceMetadata(validSurfaceIds: []) restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + restoredAgentAutoResumeRunningPanelIds.removeAll(keepingCapacity: false) pendingTerminalInputObserversByPanelId.removeAll(keepingCapacity: false) terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) lastTerminalConfigInheritancePanelId = nil @@ -13302,6 +13353,7 @@ extension Workspace: BonsplitDelegate { restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) restoredAgentSnapshotsByPanelId.removeValue(forKey: panelId) restoredAgentAutoResumePendingPanelIds.remove(panelId) + restoredAgentAutoResumeRunningPanelIds.remove(panelId) invalidatedRestoredAgentFingerprintsByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) diff --git a/cmuxTests/RestorableCodexTitleTests.swift b/cmuxTests/RestorableCodexTitleTests.swift new file mode 100644 index 000000000000..6813f313f3f4 --- /dev/null +++ b/cmuxTests/RestorableCodexTitleTests.swift @@ -0,0 +1,71 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class RestorableCodexTitleTests: XCTestCase { + @MainActor + func testCodexTitleSlugPersistsRestorableAgentWhenHookStoreMissing() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + + workspace.updatePanelDirectory(panelId: panelId, directory: "/tmp/repo") + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "codex-019df0a1-6")) + + let snapshot = workspace.sessionSnapshot( + includeScrollback: false, + restorableAgentIndex: .empty + ) + let agent = try XCTUnwrap(snapshot.panels.first?.terminal?.agent) + + XCTAssertEqual(agent.kind, .codex) + XCTAssertEqual(agent.sessionId, "codex-019df0a1-6") + XCTAssertEqual(agent.launchCommand?.source, "surface-title") + XCTAssertEqual( + agent.resumeCommand, + "cd '/tmp/repo' && 'codex' 'resume' '--dangerously-bypass-approvals-and-sandbox' 'codex-019df0a1-6'" + ) + } + + @MainActor + func testCodexTitleSlugStopsPersistingAfterPromptReturns() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + XCTAssertTrue(workspace.updatePanelTitle(panelId: panelId, title: "codex-019df0a1-6")) + + XCTAssertEqual( + workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, + "codex-019df0a1-6" + ) + + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + XCTAssertNil(workspace.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) + } + + @MainActor + func testRestoredCodexTitleResumeClearsAfterPromptReturns() throws { + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + XCTAssertTrue(source.updatePanelTitle(panelId: sourcePanelId, title: "codex-019df0a1-6")) + let snapshot = source.sessionSnapshot(includeScrollback: false, restorableAgentIndex: .empty) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + XCTAssertEqual( + restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent?.sessionId, + "codex-019df0a1-6" + ) + + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .commandRunning) + restored.updatePanelShellActivityState(panelId: restoredPanelId, state: .promptIdle) + + XCTAssertNil(restored.sessionSnapshot(includeScrollback: false).panels.first?.terminal?.agent) + } +} diff --git a/tests/test_session_relaunch_resumes_agent_sessions.py b/tests/test_session_relaunch_resumes_agent_sessions.py index d4ba4fb42706..0a9899ca00a3 100644 --- a/tests/test_session_relaunch_resumes_agent_sessions.py +++ b/tests/test_session_relaunch_resumes_agent_sessions.py @@ -196,6 +196,10 @@ def main() -> int: snapshot = _snapshot_path(bundle_id) previous_snapshot = _snapshot_path(bundle_id, suffix="-previous") codex_expected = "CMUX_FAKE_CODEX_RESUME:resume codex-session-relaunch-2923" + codex_title_expected = ( + "CMUX_FAKE_CODEX_RESUME:resume --dangerously-bypass-approvals-and-sandbox " + "codex-019df0a1-6" + ) claude_expected = ( "CMUX_FAKE_CLAUDE_RESUME:--resume claude-session-relaunch-2923 " "--dangerously-skip-permissions" @@ -300,6 +304,16 @@ def main() -> int: }, ) + codex_title_workspace_id = client.new_workspace() + time.sleep(0.4) + client.select_workspace(codex_title_workspace_id) + time.sleep(0.4) + if not client.list_surfaces(): + failures.append("expected a Codex title workspace surface during setup") + else: + client.send("printf '\\033]0;codex-019df0a1-6\\007'\n") + time.sleep(0.6) + client.select_workspace(codex_workspace_id) time.sleep(0.4) finally: @@ -315,8 +329,8 @@ def main() -> int: client = _connect(socket_path) try: workspaces = client.list_workspaces() - if len(workspaces) < 3: - failures.append(f"expected >=3 restored workspaces after relaunch, got {len(workspaces)}") + if len(workspaces) < 4: + failures.append(f"expected >=4 restored workspaces after relaunch, got {len(workspaces)}") def workspace_contains(index: int, expected: str) -> bool: if len(client.list_workspaces()) <= index: @@ -347,6 +361,14 @@ def workspace_contains(index: int, expected: str) -> bool: "normal relaunch did not resume the saved OpenCode session; " f"tail:\n{scrollback_tail}" ) + + if not _wait_for_condition(12.0, lambda: workspace_contains(3, codex_title_expected)): + client.select_workspace(3) + scrollback_tail = "\n".join(_read_scrollback(client).splitlines()[-20:]) + failures.append( + "normal relaunch did not resume the Codex session from its title slug; " + f"tail:\n{scrollback_tail}" + ) finally: client.close() _quit(bundle_id, socket_path) @@ -365,7 +387,7 @@ def workspace_contains(index: int, expected: str) -> bool: print(f"- {failure}") return 1 - print("PASS: normal relaunch resumes saved Claude, Codex, and OpenCode sessions") + print("PASS: normal relaunch resumes saved Claude, Codex, OpenCode, and Codex title sessions") return 0