diff --git a/DESCRIPTION b/DESCRIPTION
index 8191952..6ee6361 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,7 +1,7 @@
Package: chat.api
Type: Package
Title: Transport-Agnostic Chat Contract for R Agents
-Version: 0.0.1.22
+Version: 0.0.1.23
Date: 2026-08-18
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
diff --git a/NAMESPACE b/NAMESPACE
index c6506b9..c91f39a 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -10,6 +10,7 @@ export(chat_config)
export(chat_config_save)
export(chat_disconnect)
export(chat_edit)
+export(chat_get_state)
export(chat_history)
export(chat_identity)
export(chat_invite)
@@ -61,6 +62,9 @@ S3method(chat_edit,chat_loopback)
S3method(chat_edit,chat_matrix)
S3method(chat_edit,chat_slack)
S3method(chat_edit,default)
+S3method(chat_get_state,chat_loopback)
+S3method(chat_get_state,chat_matrix)
+S3method(chat_get_state,default)
S3method(chat_history,chat_loopback)
S3method(chat_history,chat_matrix)
S3method(chat_history,chat_slack)
diff --git a/R/contract.R b/R/contract.R
index 7ec645a..4b61b89 100644
--- a/R/contract.R
+++ b/R/contract.R
@@ -118,7 +118,9 @@ chat_resolve <- function(client, name, ...) {
#' \code{\link{chat_addressed}}), \code{channel_create}
#' (\code{\link{chat_channel_create}} works), \code{leave}
#' (\code{\link{chat_leave}} works), \code{set_state}
-#' (\code{\link{chat_set_state}} works), \code{files} (outbound:
+#' (durable channel state: both \code{\link{chat_set_state}} and
+#' \code{\link{chat_get_state}} work -- a transport has the pair or
+#' neither), \code{files} (outbound:
#' \code{chat_send(files =)} works), \code{attachments} (inbound:
#' media comes back out of \code{\link{chat_poll}} as
#' \code{\link{chat_attachment}} records), \code{typing},
@@ -886,6 +888,39 @@ chat_set_state.default <- function(client, channel, type, content,
"). Check chat_capabilities()$set_state.", call. = FALSE)
}
+#' Read durable typed state from a channel
+#'
+#' The counterpart of \code{\link{chat_set_state}}, reading back what
+#' it wrote. Capability-gated on the same
+#' \code{chat_capabilities()$set_state}: a transport with durable
+#' channel state has both halves or neither.
+#'
+#' Absent state is \code{NULL}, not an error. "Nothing was ever
+#' written here" is an ordinary answer for a caller checking whether a
+#' marker is set, and one it should not have to wrap in a handler. A
+#' state store that cannot be reached at all still errors, because
+#' that is a different fact.
+#'
+#' @param client A \code{chat_client}.
+#' @param channel Channel/room identifier.
+#' @param type Character. Namespaced event type.
+#' @param state_key Character. Sub-key within the type, defaulting to
+#' the empty string.
+#' @param ... Adapter-specific options.
+#' @return The stored content as a named list, or \code{NULL} when no
+#' state is set for that \code{type}/\code{state_key} pair.
+#' @export
+chat_get_state <- function(client, channel, type, state_key = "", ...) {
+ UseMethod("chat_get_state")
+}
+
+#' @export
+chat_get_state.default <- function(client, channel, type, state_key = "", ...) {
+ stop("chat_get_state() is not supported by this adapter (",
+ paste(class(client), collapse = "/"),
+ "). Check chat_capabilities()$set_state.", call. = FALSE)
+}
+
#' Refresh this client's credentials
#'
#' Forces the re-authentication that adapters otherwise perform on
diff --git a/R/loopback.R b/R/loopback.R
index e9df0e2..9feac87 100644
--- a/R/loopback.R
+++ b/R/loopback.R
@@ -55,6 +55,15 @@ chat_set_state.chat_loopback <- function(client, channel, type, content,
invisible(sprintf("loopback-state-%d", length(client$env$state)))
}
+#' @export
+chat_get_state.chat_loopback <- function(client, channel, type,
+ state_key = "", ...) {
+ # NULL for state never written, which is the generic's contract and
+ # what makes the reference adapter usable as a test double for a
+ # consumer checking whether a marker is set.
+ client$env$state[[paste(channel, type, state_key, sep = "\r")]]
+}
+
#' @export
chat_send.chat_loopback <- function(client, channel, text,
markup = c("plain", "markdown"),
diff --git a/R/matrix.R b/R/matrix.R
index b1d357f..d7cfbe1 100644
--- a/R/matrix.R
+++ b/R/matrix.R
@@ -159,9 +159,9 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
.crypto = NULL, .save = NULL, .react = NULL,
.info = NULL, .members = NULL, .join = NULL,
.create = NULL, .leave = NULL, .state = NULL,
- .channels = NULL, .history = NULL, .pending = NULL,
- .read = NULL, .identity = NULL, .edit = NULL,
- .rich = NULL) {
+ .get_state = NULL, .channels = NULL, .history = NULL,
+ .pending = NULL, .read = NULL, .identity = NULL,
+ .edit = NULL, .rich = NULL) {
seams <- list(.sync, .extract, .send, .media)
if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) &&
!requireNamespace("mx.client", quietly = TRUE)) {
@@ -216,7 +216,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
typing_fn = .typing, react_fn = .react,
info_fn = .info, members_fn = .members, join_fn = .join,
create_fn = .create, leave_fn = .leave,
- state_fn = .state,
+ state_fn = .state, get_state_fn = .get_state,
channels_fn = .channels, history_fn = .history,
pending_fn = .pending, read_fn = .read,
identity_fn = .identity, edit_fn = .edit,
@@ -300,6 +300,16 @@ matrix_reactions_available <- function() {
"mx_extract_reactions" %in% getNamespaceExports("mx.client")
}
+# Whether the installed mx.client can send into a thread. Probed rather
+# than assumed: mx_send_text() grew `thread` in 0.2.0.5, and against an
+# older one passing it is an unused-argument error on an ordinary
+# reply. Reporting the capability off what is installed lets a consumer
+# gate instead of discovering it mid-conversation.
+matrix_threads_available <- function() {
+ requireNamespace("mx.client", quietly = TRUE) &&
+ "thread" %in% names(formals(mx.client::mx_send_text))
+}
+
# The sync's pending invites, as contract records.
#
# mx_extract_invite_records() carries the inviter, which is the whole of
@@ -348,6 +358,29 @@ matrix_kind <- function(msgtype) {
"message")
}
+# The thread root an event belongs to, from its m.relates_to, or NULL.
+#
+# Only rel_type "m.thread" counts. A rich reply carries an
+# m.in_reply_to with no rel_type, and reading that as a thread would
+# report every quoted reply in a room as one -- chat_message has no
+# reply_to slot to tell them apart afterwards, so the distinction has
+# to be made here. An edit (m.replace) is not a thread either.
+#
+# A threaded message also carries an m.in_reply_to inside the same
+# block as its reply fallback, which is why this reads rel_type and
+# not the presence of a relation.
+matrix_thread_root <- function(relates_to) {
+ if (!is.list(relates_to) ||
+ !identical(relates_to$rel_type %||% "", "m.thread")) {
+ return(NULL)
+ }
+ root <- relates_to$event_id
+ if (is.null(root) || !length(root) || !nzchar(as.character(root)[[1L]])) {
+ return(NULL)
+ }
+ as.character(root)[[1L]]
+}
+
# The same mapping, but NA rather than "message" for a msgtype the
# contract has no word for. chat_poll() can afford the lenient version:
# its extractor has already filtered to the msgtypes it was asked for.
@@ -456,6 +489,7 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
channel = as.character(r$room_id),
sender = as.character(r$sender),
body = as.character(r$body), ts = ts,
+ thread = matrix_thread_root(r$relates_to),
markup = "plain", kind = matrix_kind(r$msgtype),
self = isTRUE(r$is_self),
mentions = unlist(r$mentions, use.names = FALSE),
@@ -475,6 +509,7 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
body = as.character(d$body),
ts = if (is.null(ms)) as.POSIXct(NA) else
as.POSIXct(ms / 1000, origin = "1970-01-01"),
+ thread = matrix_thread_root(d$relates_to),
markup = "plain", kind = matrix_kind(d$msgtype),
self = isTRUE(d$is_self),
mentions = unlist(d$mentions, use.names = FALSE),
@@ -539,6 +574,20 @@ chat_send.chat_matrix <- function(client, channel, text,
crypto <- matrix_crypto_require(client)
encrypted <- !is.null(crypto) &&
client$crypto_ops$encrypted(crypto, client$env$mx, channel)
+ if (encrypted && !is.null(thread)) {
+ # Refused rather than dropped, which is the bargain files gets
+ # and rich does not. crypto_ops$send() has nowhere to put a
+ # relation, so the message would arrive -- in the room's main
+ # timeline instead of the thread it answers. That is a routing
+ # failure, not lost decoration: the reply lands where nobody is
+ # looking, and a consumer keying state off the thread never sees
+ # it again. chat_capabilities()$threads is FALSE on an e2ee
+ # client so a caller can know beforehand.
+ stop("chat.api: cannot send into a thread in the encrypted room ",
+ channel, ". The encrypted send path carries no m.relates_to, ",
+ "so the reply would leave the thread. Check ",
+ "chat_capabilities()$threads.", call. = FALSE)
+ }
if (encrypted && !is.null(files)) {
# mx_send_media() posts an ordinary cleartext m.file event: the
# upload is not encrypted, and neither is the URL. There is no
@@ -588,12 +637,20 @@ chat_send.chat_matrix <- function(client, channel, text,
# A different function, because mx_send_text() renders its
# own HTML from markdown and has no argument for a supplied
# one. mx_send() takes the content wholesale.
- event <- matrix_send_rich(client, channel, text, rich, msgtype)
+ event <- matrix_send_rich(client, channel, text, rich, msgtype,
+ thread = thread)
return(invisible(c(media_ids, as.character(event))))
}
- event <- client$send_fn(client$env$mx, text, room = channel,
- msgtype = msgtype,
- markdown = identical(markup, "markdown"), ...)
+ # thread is passed only when there is one. An mx.client older
+ # than 0.2.0.5 has no such argument, and naming it unconditionally
+ # would turn every ordinary reply into an unused-argument error
+ # on installs that never send into a thread at all.
+ args <- list(client$env$mx, text, room = channel, msgtype = msgtype,
+ markdown = identical(markup, "markdown"), ...)
+ if (!is.null(thread)) {
+ args$thread <- thread
+ }
+ event <- do.call(client$send_fn, args)
return(invisible(c(media_ids, as.character(event))))
}
invisible(media_ids)
@@ -681,6 +738,17 @@ chat_leave.chat_matrix <- function(client, channel, ...) {
invisible(channel)
}
+#' @export
+chat_get_state.chat_matrix <- function(client, channel, type, state_key = "",
+ ...) {
+ # mx_get_state() already answers NULL for state that is not set,
+ # which is this generic's contract, so no error is absorbed here: a
+ # homeserver that cannot be reached still propagates.
+ sess <- mx.client::mx_client_session(client$env$mx)
+ state_fn <- client$get_state_fn %||% mx.api::mx_get_state
+ state_fn(sess, channel, type, state_key = state_key)
+}
+
#' @export
chat_set_state.chat_matrix <- function(client, channel, type, content,
state_key = "", ...) {
@@ -709,17 +777,21 @@ chat_resolve.chat_matrix <- function(client, name, ...) {
#' @export
chat_capabilities.chat_matrix <- function(client, ...) {
- # thread_replies is FALSE because chat_poll cannot populate
- # chat_message$thread. Its only event source is
- # mx.client::mx_extract_text_events(), which returns room_id,
- # event_id, sender, is_self, body, msgtype, and mentions -- it drops
- # content$m.relates_to, so the relation never reaches this adapter.
- # Re-walking the raw sync behind the extractor would duplicate its
- # msgtype filtering and quietly break for anyone supplying .extract.
- # Mapping m.in_reply_to into $thread would be worse: chat_message
- # has no reply_to slot, so plain rich replies would report as
- # threads. Flip this to TRUE when mx.client surfaces relations and
- # chat_poll maps a real m.thread rel_type.
+ # thread_replies is TRUE: mx_extract_text_events() carries the
+ # event's m.relates_to, and chat_poll maps a real m.thread rel_type
+ # onto chat_message$thread. Only that rel_type counts -- a rich
+ # reply's m.in_reply_to is deliberately not read as a thread,
+ # because chat_message has no reply_to slot to tell the two apart
+ # downstream. Unconditional, because reading a relation needs
+ # nothing of the installed mx.client but the field, absent on none
+ # this package supports.
+ #
+ # threads (the send side) is conditional twice over. It needs an
+ # mx.client that can attach the relation, which is 0.2.0.5 and
+ # later; and it is FALSE on an e2ee client, where the encrypted
+ # send path has nowhere to put an m.relates_to and chat_send()
+ # refuses a threaded send rather than posting it to the room's
+ # main timeline.
#
# Every flag here answers "can this adapter do it", not "can Matrix
# do it". Matrix has reactions and E2EE; this adapter reaches
@@ -748,7 +820,8 @@ chat_capabilities.chat_matrix <- function(client, ...) {
# adapter has no encrypted-attachment path, so chat_send() refuses
# attachments to an encrypted room. TRUE would advertise something
# that fails in exactly the rooms such a client exists for.
- list(threads = FALSE, thread_replies = FALSE,
+ list(threads = matrix_threads_available() && !isTRUE(client$e2ee),
+ thread_replies = TRUE,
# Refused in encrypted rooms: an edit carries its replacement
# text in an ordinary event, and there is no Megolm path that
# can carry a relation. Same bargain as files.
@@ -909,6 +982,7 @@ chat_history.chat_matrix <- function(client, channel, limit = 50L,
body = as.character(ev$content$body %||% ""),
ts = if (is.null(ms)) as.POSIXct(NA) else
as.POSIXct(ms / 1000, origin = "1970-01-01"),
+ thread = matrix_thread_root(ev$content[["m.relates_to"]]),
markup = "plain", kind = kind,
self = identical(ev$sender, client$env$mx$user_id),
mentions = unlist(ev$content[["m.mentions"]]$user_ids,
@@ -1017,11 +1091,23 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text,
# text is still the body. Matrix's own model is a plain body plus an
# optional formatted one, and a client that cannot render the markup --
# or a push notification, which never does -- shows the body.
-matrix_send_rich <- function(client, channel, text, rich, msgtype) {
+matrix_send_rich <- function(client, channel, text, rich, msgtype,
+ thread = NULL) {
sess <- mx.client::mx_client_session(client$env$mx)
fn <- client$rich_fn %||% mx.api::mx_send
- fn(sess, channel, text, msgtype = msgtype,
- extra = list(format = "org.matrix.custom.html", formatted_body = rich))
+ extra <- list(format = "org.matrix.custom.html", formatted_body = rich)
+ if (!is.null(thread)) {
+ # Built here rather than borrowed from mx_send_text(), which
+ # this path exists to bypass: it renders its own HTML and has
+ # nowhere to put a supplied fragment. The relation is the same
+ # one, fallback included, so a rich threaded reply is threaded
+ # in exactly the clients a plain one is.
+ extra[["m.relates_to"]] <- list(
+ rel_type = "m.thread", event_id = thread,
+ is_falling_back = TRUE,
+ "m.in_reply_to" = list(event_id = thread))
+ }
+ fn(sess, channel, text, msgtype = msgtype, extra = extra)
}
# The contract's kind vocabulary as a Matrix msgtype, plus a documented
diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R
index 6a9cb9a..3b5327d 100644
--- a/inst/tinytest/test_contract.R
+++ b/inst/tinytest/test_contract.R
@@ -364,6 +364,31 @@ local({
nothing <- structure(list(), class = c("chat_nothing", "chat_client"))
expect_error(chat_set_state(nothing, "c", "t", list()),
"not supported by this adapter")
+ expect_error(chat_get_state(nothing, "c", "t"),
+ "not supported by this adapter")
+})
+
+# Reading state back is the other half of the same capability.
+local({
+ lo <- chat_loopback()
+ # State never written reads as NULL rather than erroring: "no marker
+ # here" is an ordinary answer, and a caller checking for one should
+ # not need a handler to get it.
+ expect_null(chat_get_state(lo, "general", "ai.example.marker"))
+ chat_set_state(lo, "general", "ai.example.marker",
+ list(state = "parked", since = "2026-08-20"))
+ expect_identical(chat_get_state(lo, "general", "ai.example.marker"),
+ list(state = "parked", since = "2026-08-20"))
+ # Keyed by channel, type and state_key alike, so neither a
+ # different room nor a different key sees this one.
+ expect_null(chat_get_state(lo, "other", "ai.example.marker"))
+ expect_null(chat_get_state(lo, "general", "ai.other.marker"))
+ expect_null(chat_get_state(lo, "general", "ai.example.marker",
+ state_key = "alt"))
+ # A write replaces the whole content, and the read sees that.
+ chat_set_state(lo, "general", "ai.example.marker", list(state = "active"))
+ expect_identical(chat_get_state(lo, "general", "ai.example.marker"),
+ list(state = "active"))
})
# An adapter that cannot create says so.
diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R
index 4a44f24..8c2040a 100644
--- a/inst/tinytest/test_matrix.R
+++ b/inst/tinytest/test_matrix.R
@@ -292,14 +292,22 @@ expect_null(mapped$messages[[2L]]$mentions)
threaded_sync <- wrap_sync(list(ev("$4", body = "in a thread",
content = list("m.relates_to" = list(rel_type = "m.thread",
event_id = "$root")))))
-tp <- seam_client(recs = list(rec("$4", body = "in a thread")),
+tp <- seam_client(recs = list(modifyList(rec("$4", body = "in a thread"),
+ list(relates_to = list(rel_type = "m.thread", event_id = "$root")))),
sync = threaded_sync)
tres <- chat_poll(tp)
tmsg <- tres$messages[[1L]]
expect_identical(tmsg$body, "in a thread")
-expect_null(tmsg$thread)
-expect_false(chat_capabilities(tp)$thread_replies)
+# The relation reaches the message through the extractor record, which
+# is the only event source this adapter has -- not by re-walking the
+# sync behind it, which would duplicate the extractor's msgtype
+# filtering and break for anyone supplying their own .extract.
+expect_identical(tmsg$thread, "$root")
+expect_true(chat_capabilities(tp)$thread_replies)
+# The record's field is relates_to; nothing renames it to the wire's
+# m.relates_to on the way through.
expect_null(tmsg$raw[["m.relates_to"]])
+expect_identical(tmsg$raw$relates_to$rel_type, "m.thread")
expect_identical(
tres$raw$rooms$join[["!room:ex"]]$timeline$events[[1L]]$content[["m.relates_to"]]$rel_type,
"m.thread")
@@ -447,12 +455,14 @@ expect_identical(only, c("$media1", "$media2"))
caps <- chat_capabilities(s)
# Every flag answers "can this adapter do it", not "can Matrix do it".
-# Matrix threads are replies, not first-class channels
-expect_false(caps$threads)
-# thread_replies stays FALSE while chat_poll cannot fill in $thread:
-# mx_extract_text_events drops content$m.relates_to, so the relation
-# never reaches the adapter. The flag and the mapping move together.
-expect_false(caps$thread_replies)
+# threads is the send side: it needs an mx.client that can attach an
+# m.relates_to, which is 0.2.0.5 and later, so the flag tracks what is
+# installed rather than asserting it.
+expect_identical(caps$threads, chat.api:::matrix_threads_available())
+# thread_replies is the receive side, and needs nothing but the
+# extractor field every supported mx.client carries. The flag and the
+# mapping move together.
+expect_true(caps$thread_replies)
# reactions is TRUE and there is a verb behind it, which is the whole
# rule: a flag without its implementation is what this suite exists to
# catch. reaction_events tracks the installed mx.client, since an older
@@ -595,6 +605,16 @@ expect_true(chat_capabilities(enc)$e2ee)
# ... and files is FALSE, because mx_send_media posts a cleartext m.file
# event whatever the room's encryption state says.
expect_false(chat_capabilities(enc)$files)
+# ... and so is threads: the encrypted send path has nowhere to put an
+# m.relates_to. A threaded send is refused rather than posted to the
+# room's main timeline, which is files' bargain and not rich's -- a
+# reply that leaves its thread is misrouted, not merely undecorated.
+expect_false(chat_capabilities(enc)$threads)
+expect_error(chat_send(enc, "!enc:ex", "hi", thread = "$root"),
+ "leave the thread")
+# Reading threads still works on an encrypted client; only sending is
+# refused.
+expect_true(chat_capabilities(enc)$thread_replies)
# A construction that would have died now survives to the first poll,
# which is where relogin gets its chance.
@@ -1506,6 +1526,79 @@ local({
expect_error(chat_leave(seam_client(.leave = function(...) stop("M_UNKNOWN")),
"!a:ex"), "M_UNKNOWN")
+# ---- Threads ----
+# Inbound: only rel_type m.thread becomes $thread. A rich reply carries
+# an m.in_reply_to and no rel_type, and reading that as a thread would
+# report every quoted reply in a room as one -- chat_message has no
+# reply_to slot to tell them apart afterwards.
+local({
+ recs <- list(
+ rec("$plain"),
+ modifyList(rec("$thr"), list(relates_to = list(
+ rel_type = "m.thread", event_id = "$root",
+ is_falling_back = TRUE,
+ `m.in_reply_to` = list(event_id = "$root")))),
+ modifyList(rec("$reply"), list(relates_to = list(
+ `m.in_reply_to` = list(event_id = "$orig")))),
+ modifyList(rec("$edit"), list(relates_to = list(
+ rel_type = "m.replace", event_id = "$orig"))))
+ msgs <- chat_poll(seam_client(recs = recs))$messages
+ expect_null(msgs[[1L]]$thread)
+ expect_identical(msgs[[2L]]$thread, "$root")
+ # A threaded message carries m.in_reply_to too, as its fallback, so
+ # this is read off rel_type rather than the relation's presence.
+ expect_null(msgs[[3L]]$thread)
+ expect_null(msgs[[4L]]$thread)
+})
+
+# A relation naming no event is no thread, not a thread pointing nowhere.
+local({
+ recs <- list(modifyList(rec("$x"), list(relates_to = list(
+ rel_type = "m.thread", event_id = ""))))
+ expect_null(chat_poll(seam_client(recs = recs))$messages[[1L]]$thread)
+})
+
+# Outbound: the root reaches the send seam, and only when there is one.
+# Naming the argument unconditionally would break every install whose
+# mx.client predates it, on ordinary replies that use no thread at all.
+local({
+ seen <- NULL
+ cl <- seam_client(send = function(mx, text, room = NULL, ...) {
+ seen <<- list(text = text, args = list(...))
+ "$id"
+ })
+ chat_send(cl, "!a:ex", "ordinary")
+ expect_false("thread" %in% names(seen$args))
+ chat_send(cl, "!a:ex", "threaded", thread = "$root")
+ expect_identical(seen$args$thread, "$root")
+})
+
+# A rich threaded reply is threaded too: that path bypasses
+# mx_send_text, so it builds the same relation itself.
+local({
+ seen <- NULL
+ cl <- seam_client(.rich = function(session, channel, text, ...) {
+ seen <<- list(...)
+ "$id"
+ })
+ chat_send(cl, "!a:ex", "hi", rich = "hi", thread = "$root")
+ rel <- seen$extra$`m.relates_to`
+ expect_identical(rel$rel_type, "m.thread")
+ expect_identical(rel$event_id, "$root")
+ expect_true(rel$is_falling_back)
+ # And an unthreaded rich send carries no relation.
+ chat_send(cl, "!a:ex", "hi", rich = "hi")
+ expect_null(seen$extra$`m.relates_to`)
+})
+
+# Capabilities answer for this client. Reading a relation needs nothing
+# special; attaching one needs an mx.client new enough to carry it.
+local({
+ caps <- chat_capabilities(seam_client())
+ expect_true(caps$thread_replies)
+ expect_identical(caps$threads, chat.api:::matrix_threads_available())
+})
+
# ---- Channel state ----
# The seam replaces mx.api::mx_set_state. The default state_key is the
# empty string, where most Matrix state lives, and it is passed by name
@@ -1536,6 +1629,31 @@ expect_error(
"!a:ex", "ai.example.marker", list()),
"M_FORBIDDEN")
+# Reading it back. mx_get_state() answers NULL for state that is not
+# set, which is the generic's contract, so nothing is absorbed here.
+local({
+ seen <- NULL
+ cl <- seam_client(.get_state = function(session, channel, type,
+ state_key = "") {
+ seen <<- list(channel = channel, type = type, state_key = state_key)
+ list(state = "segment")
+ })
+ expect_identical(chat_get_state(cl, "!a:ex", "ai.example.marker"),
+ list(state = "segment"))
+ expect_identical(seen$channel, "!a:ex")
+ expect_identical(seen$type, "ai.example.marker")
+ expect_identical(seen$state_key, "")
+ chat_get_state(cl, "!a:ex", "ai.example.marker", state_key = "$root")
+ expect_identical(seen$state_key, "$root")
+})
+expect_null(chat_get_state(seam_client(.get_state = function(...) NULL),
+ "!a:ex", "ai.example.marker"))
+# An unreachable state store is a different fact from absent state, and
+# still errors.
+expect_error(
+ chat_get_state(seam_client(.get_state = function(...) stop("HTTP 502")),
+ "!a:ex", "ai.example.marker"), "HTTP 502")
+
# ---- The record ----
iv <- chat_invite(channel = "!a:ex", inviter = "@ann:ex")
expect_inherits(iv, "chat_invite")
diff --git a/inst/tinytest/test_matrix_mxclient.R b/inst/tinytest/test_matrix_mxclient.R
index 29a4d7c..361ba04 100644
--- a/inst/tinytest/test_matrix_mxclient.R
+++ b/inst/tinytest/test_matrix_mxclient.R
@@ -171,14 +171,51 @@ expect_equal(as.numeric(got$messages[[2L]]$ts), 1700000001, tolerance = 1e-6)
# receives what it can send. The notice above does not come back.
expect_false("a notice" %in% vapply(got$messages, `[[`, "", "body"))
-# The extractor drops content$m.relates_to, so a threaded reply arrives
-# with $thread empty and the relation unreachable from the message-level
-# raw. thread_replies reports FALSE for exactly this reason.
+# The record shape the adapter reads. The wire's content$m.relates_to
+# arrives as the record's `relates_to`, so nothing here looks for the
+# wire name.
expect_true(all(c("room_id", "event_id", "sender", "is_self", "body",
"msgtype", "mentions") %in% names(m$raw)))
expect_false("content" %in% names(m$raw))
expect_null(m$raw[["m.relates_to"]])
-expect_false(chat_capabilities(p)$thread_replies)
+# An unthreaded message has no thread, and NULL is the answer.
+expect_null(m$thread)
+expect_true(chat_capabilities(p)$thread_replies)
+
+# ---- A thread survives the real extractor ----
+# The end of the round trip, against the installed mx.client rather
+# than a faked record: if that build ever stops carrying relates_to,
+# $thread goes quietly empty and rehydration downstream loses the
+# conversation it was keyed to. This is the line that would go red.
+local({
+ thr_sync <- list(rooms = list(join = list("!room:ex" = list(
+ timeline = list(events = list(
+ list(type = "m.room.message", event_id = "$t1",
+ sender = "@alice:ex", origin_server_ts = 1700000003000,
+ content = list(msgtype = "m.text", body = "in a thread",
+ "m.relates_to" = list(rel_type = "m.thread",
+ event_id = "$root", is_falling_back = TRUE,
+ "m.in_reply_to" = list(event_id = "$root")))),
+ list(type = "m.room.message", event_id = "$t2",
+ sender = "@alice:ex", origin_server_ts = 1700000004000,
+ content = list(msgtype = "m.text", body = "a rich reply",
+ "m.relates_to" = list(
+ "m.in_reply_to" = list(event_id = "$orig"))))
+ ))))))
+ tp <- chat_matrix(mx = fake_mx(), save_cursor = FALSE,
+ .sync = function(client, ...) {
+ list(sync = thr_sync, client = fake_mx("s2"),
+ first_run = FALSE)
+ },
+ .send = function(...) "$id",
+ .media = function(...) NULL)
+ msgs <- chat_poll(tp)$messages
+ expect_identical(length(msgs), 2L)
+ expect_identical(msgs[[1L]]$thread, "$root")
+ # And a rich reply is still not a thread, through the real
+ # extractor as much as through a faked record.
+ expect_null(msgs[[2L]]$thread)
+})
# ---- Resolve ----
# Offline: mx_resolve_room short-circuits on a !-prefixed id and on an
diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd
index fe7879b..310c706 100644
--- a/man/chat_capabilities.Rd
+++ b/man/chat_capabilities.Rd
@@ -24,7 +24,9 @@ A list with at least: \code{threads} (can post into
\code{\link{chat_addressed}}), \code{channel_create}
(\code{\link{chat_channel_create}} works), \code{leave}
(\code{\link{chat_leave}} works), \code{set_state}
- (\code{\link{chat_set_state}} works), \code{files} (outbound:
+ (durable channel state: both \code{\link{chat_set_state}} and
+ \code{\link{chat_get_state}} work -- a transport has the pair or
+ neither), \code{files} (outbound:
\code{chat_send(files =)} works), \code{attachments} (inbound:
media comes back out of \code{\link{chat_poll}} as
\code{\link{chat_attachment}} records), \code{typing},
diff --git a/man/chat_get_state.Rd b/man/chat_get_state.Rd
new file mode 100644
index 0000000..033078b
--- /dev/null
+++ b/man/chat_get_state.Rd
@@ -0,0 +1,37 @@
+% tinyrox says don't edit this manually, but it can't stop you!
+\name{chat_get_state}
+\alias{chat_get_state}
+\title{Read durable typed state from a channel}
+\usage{
+chat_get_state(client, channel, type, state_key = "", ...)
+}
+\arguments{
+\item{client}{A \code{chat_client}.}
+
+\item{channel}{Channel/room identifier.}
+
+\item{type}{Character. Namespaced event type.}
+
+\item{state_key}{Character. Sub-key within the type, defaulting to
+the empty string.}
+
+\item{...}{Adapter-specific options.}
+}
+\value{
+The stored content as a named list, or \code{NULL} when no
+ state is set for that \code{type}/\code{state_key} pair.
+}
+\description{
+The counterpart of \code{\link{chat_set_state}}, reading back what
+it wrote. Capability-gated on the same
+\code{chat_capabilities()$set_state}: a transport with durable
+channel state has both halves or neither.
+}
+\details{
+Absent state is \code{NULL}, not an error. "Nothing was ever
+written here" is an ordinary answer for a caller checking whether a
+marker is set, and one it should not have to wrap in a handler. A
+state store that cannot be reached at all still errors, because
+that is a different fact.
+
+}
diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd
index e3a24c5..6bf9a17 100644
--- a/man/chat_matrix.Rd
+++ b/man/chat_matrix.Rd
@@ -25,6 +25,7 @@ chat_matrix(
.create = NULL,
.leave = NULL,
.state = NULL,
+ .get_state = NULL,
.channels = NULL,
.history = NULL,
.pending = NULL,