diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift
index 5919633..4a611fc 100644
--- a/Sources/AblyChat/DefaultTyping.swift
+++ b/Sources/AblyChat/DefaultTyping.swift
@@ -6,14 +6,16 @@ internal final class DefaultTyping: Typing {
     private let clientID: String
     private let logger: InternalLogger
     private let timeout: TimeInterval
+    private let maxPresenceGetRetryDuration: TimeInterval // Max duration as specified in CHA-T6c1
     private let timerManager = TimerManager()
 
-    internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval) {
+    internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval, maxPresenceGetRetryDuration: TimeInterval = 30.0) {
         self.roomID = roomID
         self.featureChannel = featureChannel
         self.clientID = clientID
         self.logger = logger
         self.timeout = timeout
+        self.maxPresenceGetRetryDuration = maxPresenceGetRetryDuration
     }
 
     internal nonisolated var channel: any RealtimeChannelProtocol {
@@ -32,18 +34,21 @@ internal final class DefaultTyping: Typing {
             logger.log(message: "Received presence message: \(message)", level: .debug)
             Task {
                 let currentEventID = await eventTracker.updateEventID()
-                let maxRetryDuration: TimeInterval = 30.0 // Max duration as specified in CHA-T6c1
                 let baseDelay: TimeInterval = 1.0 // Initial retry delay
                 let maxDelay: TimeInterval = 5.0 // Maximum delay between retries
 
                 var totalElapsedTime: TimeInterval = 0
                 var delay: TimeInterval = baseDelay
 
-                while totalElapsedTime < maxRetryDuration {
+                while totalElapsedTime < maxPresenceGetRetryDuration {
                     do {
                         // (CHA-T6c) When a presence event is received from the realtime client, the Chat client will perform a presence.get() operation to get the current presence set. This guarantees that we get a fully synced presence set. This is then used to emit the typing clients to the subscriber.
                         let latestTypingMembers = try await get()
-
+                        #if DEBUG
+                            for subscription in testPresenceGetTypingEventSubscriptions {
+                                subscription.emit(.init())
+                            }
+                        #endif
                         // (CHA-T6c2) If multiple presence events are received resulting in concurrent presence.get() calls, then we guarantee that only the “latest” event is emitted. That is to say, if presence event A and B occur in that order, then only the typing event generated by B’s call to presence.get() will be emitted to typing subscribers.
                         let isLatestEvent = await eventTracker.isLatestEvent(currentEventID)
                         guard isLatestEvent else {
@@ -67,9 +72,14 @@ internal final class DefaultTyping: Typing {
 
                         // Exponential backoff (double the delay)
                         delay = min(delay * 2, maxDelay)
+                        #if DEBUG
+                            for subscription in testPresenceGetRetryTypingEventSubscriptions {
+                                subscription.emit(.init())
+                            }
+                        #endif
                     }
                 }
-                logger.log(message: "Failed to fetch presence set after \(maxRetryDuration) seconds. Giving up.", level: .error)
+                logger.log(message: "Failed to fetch presence set after \(maxPresenceGetRetryDuration) seconds. Giving up.", level: .error)
             }
         }
 
@@ -160,6 +170,11 @@ internal final class DefaultTyping: Typing {
             // (CHA-T5b) If typing is in progress, he CHA-T3 timeout is cancelled. The client then leaves presence.
             await timerManager.cancelTimer()
             channel.presence.leaveClient(clientID, data: nil)
+            #if DEBUG
+                for subscription in testStopTypingEventSubscriptions {
+                    subscription.emit(.init())
+                }
+            #endif
         } else {
             // (CHA-T5a) If typing is not in progress, this operation is no-op.
             logger.log(message: "User is not typing. No need to leave presence.", level: .debug)
@@ -209,12 +224,68 @@ internal final class DefaultTyping: Typing {
                             try await stop()
                         }
                     }
+                    #if DEBUG
+                        for subscription in testStartTypingEventSubscriptions {
+                            subscription.emit(.init())
+                        }
+                    #endif
                 }
             }
         }
     }
+
+    #if DEBUG
+        /// The `DefaultTyping` emits a `TestTypingEvent` each time ``start`` or ``stop`` is called.
+        internal struct TestTypingEvent: Equatable {
+            let timestamp = Date()
+        }
+
+        /// Subscription of typing start events for testing purposes.
+        private var testStartTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []
+
+        /// Subscription of typing stop events for testing purposes.
+        private var testStopTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []
+
+        /// Subscription of presence get events for testing purposes.
+        private var testPresenceGetTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []
+
+        /// Subscription of retry presence get events for testing purposes.
+        private var testPresenceGetRetryTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []
+
+        /// Returns a subscription which emits typing start events for testing purposes.
+        internal func testsOnly_subscribeToStartTestTypingEvents() -> Subscription<TestTypingEvent> {
+            let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
+            testStartTypingEventSubscriptions.append(subscription)
+            return subscription
+        }
+
+        /// Returns a subscription which emits typing stop events for testing purposes.
+        internal func testsOnly_subscribeToStopTestTypingEvents() -> Subscription<TestTypingEvent> {
+            let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
+            testStopTypingEventSubscriptions.append(subscription)
+            return subscription
+        }
+
+        /// Returns a subscription which emits presence get events for testing purposes.
+        internal func testsOnly_subscribeToPresenceGetTypingEvents() -> Subscription<TestTypingEvent> {
+            let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
+            testPresenceGetTypingEventSubscriptions.append(subscription)
+            return subscription
+        }
+
+        /// Returns a subscription which emits retry presence get events for testing purposes.
+        internal func testsOnly_subscribeToPresenceGetRetryTypingEvents() -> Subscription<TestTypingEvent> {
+            let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
+            testPresenceGetRetryTypingEventSubscriptions.append(subscription)
+            return subscription
+        }
+    #endif
 }
 
+#if DEBUG
+    extension DefaultTyping: @unchecked Sendable {}
+#endif
+
 private final actor EventTracker {
     private var latestEventID: UUID = .init()
 
diff --git a/Tests/AblyChatTests/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift
new file mode 100644
index 0000000..68fda41
--- /dev/null
+++ b/Tests/AblyChatTests/DefaultRoomTypingTests.swift
@@ -0,0 +1,319 @@
+import Ably
+@testable import AblyChat
+import Testing
+
+struct DefaultRoomTypingTests {
+    // @spec CHA-T2
+    // @spec CHA-T2d
+    @Test
+    func retrieveCurrentlyTypingClientIDs() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) })
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5)
+
+        // When
+        let typingInfo = try await defaultTyping.get()
+
+        // Then
+        #expect(typingInfo.sorted() == ["client1", "client2"])
+    }
+
+    // @specPartial CHA-T2c
+    @Test
+    func retrieveCurrentlyTypingClientIDsWhileAttaching() async throws {
+        // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status
+        let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay))
+        let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])
+
+        // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
+        let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
+        let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5)
+
+        let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents()
+
+        // When: The room is in the attaching state
+        let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded)
+
+        let attachOperationID = UUID()
+        async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID)
+
+        // Wait for room to become ATTACHING
+        _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true })
+
+        // When: And presence get is called
+        _ = try await defaultTyping.get()
+
+        // Then: The manager was waiting for its room status to change before presence `get` was called
+        _ = try #require(await attachingStatusWaitSubscription.first { _ in true })
+    }
+
+    // @specPartial CHA-T2c
+    @Test
+    func retrieveCurrentlyTypingClientIDsWhileAttachingWithFailure() async throws {
+        // Given: attachment failure
+        let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123)
+
+        // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status
+        let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay))
+        let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])
+
+        // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
+        let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
+        let channel = MockRealtimeChannel(mockPresence: realtimePresence)
+        let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5)
+
+        let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents()
+
+        // When: The room is in the attaching state
+        let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded)
+
+        let attachOperationID = UUID()
+        async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID)
+
+        // Wait for room to become ATTACHING
+        _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true })
+
+        // When: And fails to attach
+        await #expect(throws: ARTErrorInfo.self) {
+            do {
+                _ = try await defaultTyping.get()
+            } catch {
+                // Then: An exception with status code of 500 should be thrown
+                let error = try #require(error as? ARTErrorInfo)
+                #expect(error.statusCode == 500)
+                #expect(error.code == ErrorCode.roomInInvalidState.rawValue)
+                throw error
+            }
+        }
+        // Then: The manager were waiting for its room status to change from attaching
+        _ = try #require(await attachingStatusWaitSubscription.first { _ in true })
+    }
+
+    // @spec CHA-T2g
+    @Test
+    func failToRetrieveCurrentlyTypingClientIDsWhenRoomInInvalidState() async throws {
+        // Given
+        let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) })
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
+        let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence))
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5)
+
+        // Then
+        await #expect(throws: ARTErrorInfo.self) {
+            do {
+                _ = try await defaultTyping.get()
+            } catch {
+                let error = try #require(error as? ARTErrorInfo)
+                #expect(error.statusCode == 400)
+                #expect(error.localizedDescription.contains("attach"))
+                throw error
+            }
+        }
+    }
+
+    // @spec CHA-T3
+    @Test
+    func usersMayConfigureTimeoutForTyping() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: [])
+        let channelsList = [
+            MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success),
+            MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success, mockPresence: typingPresence),
+        ]
+        let channels = MockChannels(channels: channelsList)
+        let realtime = MockRealtime.create(channels: channels)
+
+        // Given
+        let timeout = 0.5 // default is 5 (seconds)
+        let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(typing: .init(timeout: timeout)), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory())
+
+        let defaultTyping = try #require(room.typing as? DefaultTyping)
+        let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents()
+
+        try await room.attach()
+
+        // When
+        try await defaultTyping.start()
+        let typingStartedAt = Date()
+
+        // Then: The `DefaultTyping` will emit typing stop event in `timeout` interval +/-
+        let typingStopped = try #require(await typingStoppedSubscription.first { _ in true })
+        let interval = typingStartedAt.distance(to: typingStopped.timestamp)
+        #expect(interval.isEqual(to: timeout, tolerance: 0.1))
+    }
+
+    // @spec CHA-T4a
+    // @spec CHA-T4a1
+    // @spec CHA-T5a
+    // @spec CHA-T5b
+    @Test
+    func usersMayIndicateThatTheyHaveStartedOrStoppedTyping() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: [])
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5)
+
+        // CHA-T4a
+
+        // When
+        try await defaultTyping.start()
+
+        // Then: CHA-T4a1
+        var typingInfo = try await defaultTyping.get()
+        #expect(typingInfo == ["client1"])
+
+        // CHA-T5b
+
+        // When
+        try await defaultTyping.stop()
+
+        // Then
+        typingInfo = try await defaultTyping.get()
+        #expect(typingInfo.isEmpty)
+
+        // CHA-T5a
+
+        // When
+        try await defaultTyping.stop()
+
+        // Then
+        typingInfo = try await defaultTyping.get()
+        #expect(typingInfo.isEmpty)
+    }
+
+    // @spec CHA-T4a2
+    // @spec CHA-T4b
+    @Test
+    func ifTypingIsAlreadyInProgressThenTimeoutIsExtended() async throws {
+        // Given
+        let timeout = 0.5
+        let typingPresence = MockRealtimePresence(members: [])
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: timeout)
+
+        let typingStartedSubscription = defaultTyping.testsOnly_subscribeToStartTestTypingEvents()
+        let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents()
+
+        // When: Typing is already in progress, the CHA-T3 timeout is extended to be timeoutMs from now
+        let timeoutExtension = 0.3
+        try await defaultTyping.start()
+        try? await Task.sleep(nanoseconds: UInt64(timeoutExtension * 1_000_000_000))
+        try await defaultTyping.start() // CHA-T4b
+
+        let typingStarted = try #require(await typingStartedSubscription.first { _ in true })
+        let typingStopped = try #require(await typingStoppedSubscription.first { _ in true }) // CHA-T4a2
+
+        // Then
+        let interval = typingStarted.timestamp.distance(to: typingStopped.timestamp)
+        #expect(interval.isEqual(to: timeout + timeoutExtension, tolerance: 0.1))
+    }
+
+    // @spec CHA-T6a
+    @Test
+    func usersMaySubscribeToTypingEvents() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: [])
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5)
+
+        // CHA-T6a
+
+        // When
+        let subscription = await defaultTyping.subscribe()
+        try await defaultTyping.start()
+
+        // Then
+        let typingEvent = try #require(await subscription.first { _ in true })
+        #expect(typingEvent.currentlyTyping == ["client1"])
+    }
+
+    // @spec CHA-T6c
+    @Test
+    func whenPresenceEventReceivedClientWillPerformPresenceGet() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: [])
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 0.1)
+
+        // Given: subscription to typing events
+        _ = await defaultTyping.subscribe()
+
+        // Given: test presence.get() call subscription
+        let typingPresenceGetSubscription = defaultTyping.testsOnly_subscribeToPresenceGetTypingEvents()
+
+        // When: A presence event is received from the realtime client
+        try await defaultTyping.start()
+
+        // Then: The Chat client will perform a presence.get() operation
+        _ = try #require(await typingPresenceGetSubscription.first { _ in true })
+    }
+
+    // @spec CHA-T6c1
+    @Test
+    func ifPresenceGetFailsItShallBeRetriedUsingBackoffWithJitter() async throws {
+        // Given: presence.get() failure
+        let presenceGetError = ARTErrorInfo(domain: "SomeDomain", code: 123)
+
+        // Given
+        let maxPresenceGetRetryDuration = 3.0 // arbitrary, TODO: improve https://github.com/ably/ably-chat-swift/issues/216
+        let typingPresence = MockRealtimePresence(members: [], presenceGetError: presenceGetError)
+        let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 0.1, maxPresenceGetRetryDuration: maxPresenceGetRetryDuration)
+
+        // Given: subscription to typing events
+        _ = await defaultTyping.subscribe()
+
+        // Given: test presence.get() call failure subscription
+        let typingPresenceGetRetrySubscription = defaultTyping.testsOnly_subscribeToPresenceGetRetryTypingEvents()
+
+        // When: A presence event is received from the realtime client and presence.get() operation fails
+        try await defaultTyping.start()
+
+        // Then: It shall be retried using a backoff with jitter, up to a max timeout
+        let retryStartedAt = Date()
+        let retryShouldStopBefore = retryStartedAt + maxPresenceGetRetryDuration - 1 // TODO: improve
+        for await event in typingPresenceGetRetrySubscription {
+            print("Retrying presence.get() at \(event.timestamp)")
+            if event.timestamp >= retryShouldStopBefore {
+                break
+            }
+        }
+        #expect(Date().distance(to: retryStartedAt) <= maxPresenceGetRetryDuration)
+    }
+
+    // @spec CHA-T6c2
+    @Test
+    func ifMultiplePresenceEventsReceivedThenOnlyTheLatestEventIsEmitted() async throws {
+        // TODO: https://github.com/ably/ably-chat-swift/issues/216
+    }
+
+    // @spec CHA-T7
+    @Test
+    func onDiscontinuity() async throws {
+        // Given
+        let typingPresence = MockRealtimePresence(members: [])
+        let channel = MockRealtimeChannel(mockPresence: typingPresence)
+        let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
+        let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5)
+
+        // When: The feature channel emits a discontinuity through `onDiscontinuity`
+        let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error
+        let discontinuitySubscription = await defaultTyping.onDiscontinuity()
+        await featureChannel.emitDiscontinuity(featureChannelDiscontinuity)
+
+        // Then: The DefaultOccupancy instance emits this discontinuity through `onDiscontinuity`
+        let discontinuity = try #require(await discontinuitySubscription.first { _ in true })
+        #expect(discontinuity == featureChannelDiscontinuity)
+    }
+}
diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift
index ccf0f40..2f810ab 100644
--- a/Tests/AblyChatTests/Helpers/Helpers.swift
+++ b/Tests/AblyChatTests/Helpers/Helpers.swift
@@ -80,3 +80,9 @@ enum RoomLifecycleHelper {
         )
     }
 }
+
+extension Double {
+    func isEqual(to other: Double, tolerance: Double) -> Bool {
+        self >= other && self < other + tolerance
+    }
+}