feat(linux): Quick Chat streamed replies and precise pairing states (#110632)

* feat(linux): stream Quick Chat gateway events

* feat(linux): render streamed Quick Chat replies

* fix(linux): correlate Quick Chat streams by run

* style(linux): rustfmt

* test(linux): move Quick Chat stream helper tests into the tooling vitest project

* test: type the Linux quickchat stream harness for strict checks

* test: explicit harness shape for the root test typecheck
This commit is contained in:
Peter Steinberger
2026-07-18 13:22:48 +01:00
committed by GitHub
parent 74880cb56f
commit bc8d0da092
8 changed files with 1098 additions and 133 deletions

View File

@@ -61,6 +61,8 @@ pub(crate) struct GatewayDeviceIdentityStore {
identity: GatewayDeviceIdentity,
}
// This credential boundary (gateway_device_identity.rs:65-98) intentionally excludes setup-code
// and bootstrapToken redemption; those require a dedicated product UI and remain follow-up work.
#[derive(Clone, Eq, PartialEq)]
pub(crate) enum GatewayAuth {
DeviceToken(String),

View File

@@ -26,6 +26,7 @@ use tokio_tungstenite::{
use uuid::Uuid;
const GATEWAY_STATE_EVENT: &str = "quickchat:gateway-state";
const CHAT_EVENT: &str = "quickchat:chat-event";
const GATEWAY_DEVICE_IDENTITY_FILE: &str = "quickchat-gateway-device.json";
const AGENTS_CACHE_TTL: Duration = Duration::from_secs(60);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -35,6 +36,8 @@ const COMMAND_TIMEOUT: Duration = Duration::from_secs(35);
const DRIVER_TICK: Duration = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
const PAIRING_REQUIRED_DETAIL_CODE: &str = "PAIRING_REQUIRED";
const AUTH_TOKEN_MISSING_DETAIL_CODE: &str = "AUTH_TOKEN_MISSING";
const AUTH_PASSWORD_MISSING_DETAIL_CODE: &str = "AUTH_PASSWORD_MISSING";
const AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE: &str = "AUTH_DEVICE_TOKEN_MISMATCH";
const TLS_PIN_MISMATCH_ERROR: &str = "Gateway TLS certificate fingerprint mismatch";
@@ -212,6 +215,7 @@ struct ChatSendParams {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChatSendAck {
run_id: String,
status: String,
#[serde(default)]
error: Option<Value>,
@@ -219,10 +223,19 @@ struct ChatSendAck {
message: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ChatRoutingTarget {
session_key: String,
agent_id: Option<String>,
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChatRoutingTarget {
pub(crate) session_key: String,
pub(crate) agent_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ChatSendResult {
#[serde(flatten)]
pub(crate) target: ChatRoutingTarget,
pub(crate) run_id: String,
}
enum GatewayRequest {
@@ -243,63 +256,13 @@ enum DriverCommand {
Reconfigure,
}
struct RequestFailure {
message: String,
disconnect: bool,
detail_code: Option<String>,
pairing_required: bool,
tls_failure: bool,
}
impl RequestFailure {
fn transport(message: impl Into<String>) -> Self {
Self {
message: message.into(),
disconnect: true,
detail_code: None,
pairing_required: false,
tls_failure: false,
}
}
fn tls(message: impl Into<String>) -> Self {
Self {
message: message.into(),
disconnect: true,
detail_code: None,
pairing_required: false,
tls_failure: true,
}
}
fn method_with_detail(message: impl Into<String>, detail_code: Option<String>) -> Self {
Self {
message: message.into(),
disconnect: false,
detail_code,
pairing_required: false,
tls_failure: false,
}
}
fn classify_connect(mut self, auth: &GatewayAuth) -> Self {
let pairing_required = self.detail_code.as_deref() == Some(PAIRING_REQUIRED_DETAIL_CODE);
let missing_bootstrap_auth = auth.is_none()
&& self
.detail_code
.as_deref()
.is_some_and(|code| code.starts_with("AUTH_"));
self.pairing_required = pairing_required || missing_bootstrap_auth;
self
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GatewayConnectionState {
Down = 0,
Up = 1,
PairingRequired = 2,
TlsFailure = 3,
CredentialRequired = 3,
TlsFailure = 4,
}
impl GatewayConnectionState {
@@ -307,7 +270,8 @@ impl GatewayConnectionState {
match value {
1 => Self::Up,
2 => Self::PairingRequired,
3 => Self::TlsFailure,
3 => Self::CredentialRequired,
4 => Self::TlsFailure,
_ => Self::Down,
}
}
@@ -317,18 +281,91 @@ impl GatewayConnectionState {
Self::Down => "down",
Self::Up => "up",
Self::PairingRequired => "pairing-required",
Self::CredentialRequired => "credential-required",
Self::TlsFailure => "tls-failure",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct ConnectErrorDetails {
code: Option<String>,
device_id: Option<String>,
remediation_hint: Option<String>,
retryable: Option<bool>,
pause_reconnect: Option<bool>,
}
impl ConnectErrorDetails {
fn from_value(value: Option<&Value>) -> Self {
let Some(value) = value else {
return Self::default();
};
Self {
code: connect_detail_text(value.get("code"), 80),
device_id: connect_detail_text(value.get("deviceId"), 128),
remediation_hint: connect_detail_text(value.get("remediationHint"), 240),
retryable: value.get("retryable").and_then(Value::as_bool),
pause_reconnect: value.get("pauseReconnect").and_then(Value::as_bool),
}
}
}
struct RequestFailure {
message: String,
disconnect: bool,
connect_details: ConnectErrorDetails,
connect_state: Option<GatewayConnectionState>,
tls_failure: bool,
}
impl RequestFailure {
fn transport(message: impl Into<String>) -> Self {
Self {
message: message.into(),
disconnect: true,
connect_details: ConnectErrorDetails::default(),
connect_state: None,
tls_failure: false,
}
}
fn tls(message: impl Into<String>) -> Self {
Self {
message: message.into(),
disconnect: true,
connect_details: ConnectErrorDetails::default(),
connect_state: None,
tls_failure: true,
}
}
fn method_with_details(message: impl Into<String>, details: Option<&Value>) -> Self {
Self {
message: message.into(),
disconnect: false,
connect_details: ConnectErrorDetails::from_value(details),
connect_state: None,
tls_failure: false,
}
}
fn classify_connect(mut self, auth: &GatewayAuth) -> Self {
self.connect_state =
classify_connect_failure(self.connect_details.code.as_deref(), !auth.is_none());
self
}
}
struct GatewayClientInner {
config: Mutex<Option<GatewayWsConfig>>,
config_generation: AtomicU64,
commands: Mutex<Option<mpsc::Sender<DriverCommand>>>,
agents_cache: Mutex<Option<CachedAgents>>,
identity: Mutex<Option<GatewayDeviceIdentityStore>>,
connection_notice: Mutex<Option<String>>,
connection_state: AtomicU64,
reconnect_paused: AtomicBool,
running: AtomicBool,
}
@@ -346,7 +383,9 @@ impl GatewayClient {
commands: Mutex::new(None),
agents_cache: Mutex::new(None),
identity: Mutex::new(None),
connection_notice: Mutex::new(None),
connection_state: AtomicU64::new(GatewayConnectionState::Down as u64),
reconnect_paused: AtomicBool::new(false),
running: AtomicBool::new(false),
}),
}
@@ -364,7 +403,8 @@ impl GatewayClient {
.lock()
.expect("gateway agents cache mutex poisoned") = None;
self.inner.config_generation.fetch_add(1, Ordering::SeqCst);
self.set_connection_state(app, GatewayConnectionState::Down);
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
self.set_connection_state(app, GatewayConnectionState::Down, None);
if let Some(commands) = self
.inner
.commands
@@ -388,7 +428,8 @@ impl GatewayClient {
.lock()
.expect("gateway agents cache mutex poisoned") = None;
self.inner.config_generation.fetch_add(1, Ordering::SeqCst);
self.set_connection_state(app, GatewayConnectionState::Down);
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
self.set_connection_state(app, GatewayConnectionState::Down, None);
if let Some(commands) = self
.inner
.commands
@@ -417,10 +458,16 @@ impl GatewayClient {
}
pub fn emit_current_state(&self, window: &WebviewWindow) -> Result<(), String> {
let notice = self
.inner
.connection_notice
.lock()
.map_err(|_| "Gateway connection notice is unavailable.".to_string())?
.clone();
window
.emit(
GATEWAY_STATE_EVENT,
GatewayStateEvent::new(self.connection_state()),
GatewayStateEvent::new(self.connection_state(), notice),
)
.map_err(|error| format!("Could not report Gateway connectivity: {error}"))
}
@@ -456,12 +503,12 @@ impl GatewayClient {
scope: &str,
main_key: &str,
idempotency_key: &str,
) -> Result<(), String> {
) -> Result<ChatSendResult, String> {
let target = routing_target(scope, selected_agent_id, main_key);
let response = self
.request(GatewayRequest::ChatSend(ChatSendParams {
session_key: target.session_key,
agent_id: target.agent_id,
session_key: target.session_key.clone(),
agent_id: target.agent_id.clone(),
message,
idempotency_key: idempotency_key.to_string(),
}))
@@ -469,7 +516,26 @@ impl GatewayClient {
let GatewayResponse::ChatSend(ack) = response else {
return Err("Gateway returned the wrong response for chat.send.".to_string());
};
classify_chat_ack(&ack)
classify_chat_ack(&ack)?;
Ok(ChatSendResult {
target,
run_id: ack.run_id,
})
}
pub fn resume_reconnect(&self) {
if !self.inner.reconnect_paused.load(Ordering::SeqCst) {
return;
}
if let Some(commands) = self
.inner
.commands
.lock()
.expect("gateway command mutex poisoned")
.as_ref()
{
let _ = commands.try_send(DriverCommand::Reconfigure);
}
}
async fn request(&self, request: GatewayRequest) -> Result<GatewayResponse, String> {
@@ -498,7 +564,8 @@ impl GatewayClient {
let mut reconnect_attempt = 0_u32;
loop {
if app.get_webview_window(QUICKCHAT_LABEL).is_none() {
self.set_connection_state(&app, GatewayConnectionState::Down);
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
self.set_connection_state(&app, GatewayConnectionState::Down, None);
tokio::time::sleep(DRIVER_TICK).await;
reconnect_attempt = 0;
continue;
@@ -510,7 +577,8 @@ impl GatewayClient {
.expect("gateway config mutex poisoned")
.clone();
let Some(config) = config else {
self.set_connection_state(&app, GatewayConnectionState::Down);
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
self.set_connection_state(&app, GatewayConnectionState::Down, None);
tokio::time::sleep(DRIVER_TICK).await;
continue;
};
@@ -522,12 +590,45 @@ impl GatewayClient {
.connect_and_serve(&app, &config, generation, &mut receiver)
.await;
let reached_hello = self.is_connected();
let disconnected_state = match connection_result.as_ref() {
Err(failure) if failure.pairing_required => GatewayConnectionState::PairingRequired,
Err(failure) if failure.tls_failure => GatewayConnectionState::TlsFailure,
_ => GatewayConnectionState::Down,
};
self.set_connection_state(&app, disconnected_state);
let failure = connection_result.as_ref().err();
let disconnected_state = failure
.and_then(|failure| failure.connect_state)
.or_else(|| {
failure
.is_some_and(|failure| failure.tls_failure)
.then_some(GatewayConnectionState::TlsFailure)
})
.unwrap_or(GatewayConnectionState::Down);
let pause_reconnect = failure
.map(|failure| should_pause_reconnect(&failure.connect_details))
.unwrap_or(false);
let notice = failure.and_then(|failure| {
connection_notice(
disconnected_state,
&failure.connect_details,
pause_reconnect,
)
});
self.inner
.reconnect_paused
.store(pause_reconnect, Ordering::SeqCst);
self.set_connection_state(&app, disconnected_state, notice);
if pause_reconnect {
// Server retry policy is authoritative: explicit pauseReconnect or retryable=false
// waits for a fresh user summon instead of burning the capped backoff loop.
loop {
let Some(command) = receiver.recv().await else {
return;
};
match command {
DriverCommand::Reconfigure => break,
command => reject_disconnected_command(command),
}
}
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
reconnect_attempt = 0;
continue;
}
reconnect_attempt = if reached_hello {
1
} else {
@@ -566,7 +667,7 @@ impl GatewayClient {
let signed_at_ms = unix_time_ms().map_err(RequestFailure::transport)?;
let params = connect_params(&identity, &auth, &nonce, signed_at_ms)
.map_err(RequestFailure::transport)?;
let hello = match request_on_socket(&mut socket, "connect", params).await {
let hello = match request_on_socket(app, &mut socket, "connect", params).await {
Ok(hello) => hello,
Err(failure) => {
let failure = failure.classify_connect(&auth);
@@ -582,12 +683,12 @@ impl GatewayClient {
self.persist_device_token(&config.ws_url, device_token)?;
}
let agents = request_agents_list(&mut socket).await?;
let agents = request_agents_list(app, &mut socket).await?;
if self.inner.config_generation.load(Ordering::SeqCst) != generation {
return Ok(());
}
self.cache_agents(agents);
self.set_connection_state(app, GatewayConnectionState::Up);
self.set_connection_state(app, GatewayConnectionState::Up, None);
let mut last_gateway_activity = Instant::now();
loop {
@@ -604,7 +705,7 @@ impl GatewayClient {
match command {
DriverCommand::Reconfigure => return Ok(()),
DriverCommand::Request { request, reply } => {
let result = perform_request(&mut socket, request).await;
let result = perform_request(app, &mut socket, request).await;
last_gateway_activity = Instant::now();
match result {
Ok(response) => {
@@ -623,7 +724,7 @@ impl GatewayClient {
}
}
incoming = socket.next() => {
handle_idle_message(&mut socket, incoming).await?;
handle_idle_message(app, &mut socket, incoming).await?;
last_gateway_activity = Instant::now();
}
_ = tokio::time::sleep(DRIVER_TICK) => {
@@ -719,7 +820,12 @@ impl GatewayClient {
GatewayConnectionState::from_u64(self.inner.connection_state.load(Ordering::SeqCst))
}
fn set_connection_state(&self, app: &AppHandle, state: GatewayConnectionState) {
fn set_connection_state(
&self,
app: &AppHandle,
state: GatewayConnectionState,
notice: Option<String>,
) {
if state != GatewayConnectionState::Up {
*self
.inner
@@ -727,18 +833,31 @@ impl GatewayClient {
.lock()
.expect("gateway agents cache mutex poisoned") = None;
}
if self
let notice_changed = {
let mut current = self
.inner
.connection_notice
.lock()
.expect("gateway connection notice mutex poisoned");
if *current == notice {
false
} else {
*current = notice.clone();
true
}
};
let state_changed = self
.inner
.connection_state
.swap(state as u64, Ordering::SeqCst)
== state as u64
{
!= state as u64;
if !state_changed && !notice_changed {
return;
}
let _ = app.emit_to(
QUICKCHAT_LABEL,
GATEWAY_STATE_EVENT,
GatewayStateEvent::new(state),
GatewayStateEvent::new(state, notice),
);
}
}
@@ -746,12 +865,15 @@ impl GatewayClient {
#[derive(Clone, Serialize)]
struct GatewayStateEvent {
state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
notice: Option<String>,
}
impl GatewayStateEvent {
fn new(state: GatewayConnectionState) -> Self {
fn new(state: GatewayConnectionState, notice: Option<String>) -> Self {
Self {
state: state.event_name(),
notice,
}
}
}
@@ -777,6 +899,75 @@ fn routing_target(scope: &str, selected_agent_id: &str, main_key: &str) -> ChatR
}
}
fn connect_detail_text(value: Option<&Value>, max_chars: usize) -> Option<String> {
let normalized = value
.and_then(Value::as_str)?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if normalized.is_empty() {
return None;
}
Some(normalized.chars().take(max_chars).collect())
}
fn classify_connect_failure(
detail_code: Option<&str>,
has_local_credential: bool,
) -> Option<GatewayConnectionState> {
if detail_code == Some(PAIRING_REQUIRED_DETAIL_CODE) {
return Some(GatewayConnectionState::PairingRequired);
}
let credential_required = !has_local_credential
&& detail_code.is_some_and(|code| {
code == AUTH_TOKEN_MISSING_DETAIL_CODE
|| code == AUTH_PASSWORD_MISSING_DETAIL_CODE
|| (code.starts_with("AUTH_") && code.ends_with("_MISMATCH"))
});
credential_required.then_some(GatewayConnectionState::CredentialRequired)
}
fn should_pause_reconnect(details: &ConnectErrorDetails) -> bool {
details.pause_reconnect == Some(true) || details.retryable == Some(false)
}
fn short_device_id(device_id: &str) -> Option<String> {
let short = device_id
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.take(8)
.collect::<String>();
(!short.is_empty()).then_some(short)
}
fn connection_notice(
state: GatewayConnectionState,
details: &ConnectErrorDetails,
reconnect_paused: bool,
) -> Option<String> {
let fallback = match state {
GatewayConnectionState::PairingRequired => "Approve this device in the dashboard (Nodes)",
GatewayConnectionState::CredentialRequired => {
"Gateway requires a credential — open the dashboard on the gateway host"
}
_ if reconnect_paused => "Gateway connection paused — reopen Quick Chat to retry",
_ => return None,
};
// The Gateway owns recovery semantics and can give more precise operator guidance than this
// client. Keep only its bounded plain-text hint, then add the safe pairing identifier.
let mut notice = details
.remediation_hint
.clone()
.unwrap_or_else(|| fallback.to_string());
if state == GatewayConnectionState::PairingRequired {
if let Some(device_id) = details.device_id.as_deref().and_then(short_device_id) {
notice.push_str(" · Device ");
notice.push_str(&device_id);
}
}
Some(notice)
}
fn reconnect_backoff(attempt: u32) -> Duration {
let shift = attempt.saturating_sub(1).min(5);
Duration::from_secs((1_u64 << shift).min(MAX_RECONNECT_DELAY.as_secs()))
@@ -784,7 +975,7 @@ fn reconnect_backoff(attempt: u32) -> Duration {
fn should_clear_stored_device_token(failure: &RequestFailure, auth: &GatewayAuth) -> bool {
matches!(auth, GatewayAuth::DeviceToken(_))
&& failure.detail_code.as_deref() == Some(AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE)
&& failure.connect_details.code.as_deref() == Some(AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE)
}
fn connect_params(
@@ -849,6 +1040,7 @@ async fn wait_for_connect_challenge(socket: &mut GatewaySocket) -> Result<String
}
async fn request_on_socket(
app: &AppHandle,
socket: &mut GatewaySocket,
method: &str,
params: Value,
@@ -865,10 +1057,10 @@ async fn request_on_socket(
tokio::time::timeout(REQUEST_TIMEOUT, async {
loop {
let value = next_json(socket).await?;
dispatch_chat_event(app, &value);
if value.get("type").and_then(Value::as_str) != Some("res")
|| value.get("id").and_then(Value::as_str) != Some(id.as_str())
{
// Streamed chat events are a deliberate follow-up; this client only consumes RPC acks.
continue;
}
if value.get("ok").and_then(Value::as_bool) == Some(true) {
@@ -879,13 +1071,11 @@ async fn request_on_socket(
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.unwrap_or("Gateway request failed.");
let detail_code = value
let details = value
.get("error")
.and_then(|error| error.get("details"))
.and_then(|details| details.get("code"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
return Err(RequestFailure::method_with_detail(message, detail_code));
.filter(|details| details.is_object());
return Err(RequestFailure::method_with_details(message, details));
}
})
.await
@@ -893,18 +1083,19 @@ async fn request_on_socket(
}
async fn perform_request(
app: &AppHandle,
socket: &mut GatewaySocket,
request: GatewayRequest,
) -> Result<GatewayResponse, RequestFailure> {
match request {
GatewayRequest::AgentsList => request_agents_list(socket)
GatewayRequest::AgentsList => request_agents_list(app, socket)
.await
.map(GatewayResponse::AgentsList),
GatewayRequest::ChatSend(params) => {
let params = serde_json::to_value(params).map_err(|error| {
RequestFailure::transport(format!("Could not encode chat.send: {error}"))
})?;
let payload = request_on_socket(socket, "chat.send", params).await?;
let payload = request_on_socket(app, socket, "chat.send", params).await?;
serde_json::from_value(payload)
.map(GatewayResponse::ChatSend)
.map_err(|error| {
@@ -915,9 +1106,10 @@ async fn perform_request(
}
async fn request_agents_list(
app: &AppHandle,
socket: &mut GatewaySocket,
) -> Result<AgentsListResult, RequestFailure> {
let payload = request_on_socket(socket, "agents.list", json!({})).await?;
let payload = request_on_socket(app, socket, "agents.list", json!({})).await?;
serde_json::from_value(payload).map_err(|error| {
RequestFailure::transport(format!("Invalid agents.list response: {error}"))
})
@@ -1089,6 +1281,7 @@ async fn next_json(socket: &mut GatewaySocket) -> Result<Value, RequestFailure>
}
async fn handle_idle_message(
app: &AppHandle,
socket: &mut GatewaySocket,
incoming: Option<Result<Message, tokio_tungstenite::tungstenite::Error>>,
) -> Result<(), RequestFailure> {
@@ -1098,6 +1291,12 @@ async fn handle_idle_message(
RequestFailure::transport(format!("Gateway connection failed: {error}"))
})?;
match message {
Message::Text(text) => {
if let Ok(value) = serde_json::from_str::<Value>(text.as_ref()) {
dispatch_chat_event(app, &value);
}
Ok(())
}
Message::Ping(payload) => socket.send(Message::Pong(payload)).await.map_err(|error| {
RequestFailure::transport(format!("Could not answer Gateway ping: {error}"))
}),
@@ -1106,6 +1305,18 @@ async fn handle_idle_message(
}
}
fn dispatch_chat_event(app: &AppHandle, frame: &Value) {
if frame.get("type").and_then(Value::as_str) != Some("event")
|| frame.get("event").and_then(Value::as_str) != Some("chat")
{
return;
}
if let Some(payload) = frame.get("payload") {
// Payload stays raw so the WebView can mirror Gateway delta assembly without native drift.
let _ = app.emit_to(QUICKCHAT_LABEL, CHAT_EVENT, payload.clone());
}
}
fn unix_time_ms() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1133,6 +1344,11 @@ mod tests {
agent_id: None,
}
);
assert_eq!(
serde_json::to_value(routing_target("global", "work", "main"))
.expect("serialized routing target"),
json!({ "sessionKey": "global", "agentId": "work" })
);
}
#[test]
@@ -1169,6 +1385,7 @@ mod tests {
fn chat_ack_acceptance_is_explicit() {
for status in ["ok", "started", "in_flight"] {
assert!(classify_chat_ack(&ChatSendAck {
run_id: "run-1".to_string(),
status: status.to_string(),
error: None,
message: None,
@@ -1177,6 +1394,7 @@ mod tests {
}
for status in ["error", "timeout", "queued"] {
assert!(classify_chat_ack(&ChatSendAck {
run_id: "run-1".to_string(),
status: status.to_string(),
error: Some(json!({ "message": "not accepted" })),
message: None,
@@ -1291,33 +1509,122 @@ mod tests {
}
#[test]
fn pairing_failures_map_to_the_pairing_strip_state() {
let pending = RequestFailure::method_with_detail(
"pairing required",
Some(PAIRING_REQUIRED_DETAIL_CODE.to_string()),
)
.classify_connect(&GatewayAuth::SharedToken("bootstrap".to_string()));
assert!(pending.pairing_required);
fn connect_classification_separates_pairing_and_missing_credentials() {
assert_eq!(
classify_connect_failure(Some(PAIRING_REQUIRED_DETAIL_CODE), true),
Some(GatewayConnectionState::PairingRequired)
);
assert_eq!(
classify_connect_failure(Some(AUTH_TOKEN_MISSING_DETAIL_CODE), false),
Some(GatewayConnectionState::CredentialRequired)
);
assert_eq!(
classify_connect_failure(Some("AUTH_TOKEN_MISMATCH"), false),
Some(GatewayConnectionState::CredentialRequired)
);
assert_eq!(
classify_connect_failure(Some("AUTH_TOKEN_MISMATCH"), true),
None
);
assert_eq!(
GatewayConnectionState::CredentialRequired.event_name(),
"credential-required"
);
let missing_bootstrap = RequestFailure::method_with_detail(
"token missing",
Some("AUTH_TOKEN_MISSING".to_string()),
)
.classify_connect(&GatewayAuth::None);
assert!(missing_bootstrap.pairing_required);
let pairing_details = json!({ "code": PAIRING_REQUIRED_DETAIL_CODE });
let pending =
RequestFailure::method_with_details("pairing required", Some(&pairing_details))
.classify_connect(&GatewayAuth::SharedToken("bootstrap".to_string()));
assert_eq!(
pending.connect_state,
Some(GatewayConnectionState::PairingRequired)
);
let stale_device_auth = RequestFailure::method_with_detail(
let missing_details = json!({ "code": AUTH_TOKEN_MISSING_DETAIL_CODE });
let missing_auth_failure =
RequestFailure::method_with_details("token missing", Some(&missing_details))
.classify_connect(&GatewayAuth::None);
assert_eq!(
missing_auth_failure.connect_state,
Some(GatewayConnectionState::CredentialRequired)
);
let mismatch_details = json!({ "code": "AUTH_TOKEN_MISMATCH" });
let mismatch_without_auth =
RequestFailure::method_with_details("token mismatch", Some(&mismatch_details))
.classify_connect(&GatewayAuth::None);
assert_eq!(
mismatch_without_auth.connect_state,
Some(GatewayConnectionState::CredentialRequired)
);
let mismatch_with_auth =
RequestFailure::method_with_details("token mismatch", Some(&mismatch_details))
.classify_connect(&GatewayAuth::SharedToken("configured".to_string()));
assert_eq!(mismatch_with_auth.connect_state, None);
let stale_device_details = json!({ "code": AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE });
let stale_device_auth = RequestFailure::method_with_details(
"device token mismatch",
Some(AUTH_DEVICE_TOKEN_MISMATCH_DETAIL_CODE.to_string()),
Some(&stale_device_details),
)
.classify_connect(&GatewayAuth::DeviceToken("stale".to_string()));
assert!(!stale_device_auth.pairing_required);
assert_eq!(stale_device_auth.connect_state, None);
assert!(should_clear_stored_device_token(
&stale_device_auth,
&GatewayAuth::DeviceToken("stale".to_string())
));
}
#[test]
fn reconnect_pause_requires_explicit_server_policy() {
let pause_details = json!({ "pauseReconnect": true });
let paused = RequestFailure::method_with_details("pause", Some(&pause_details));
assert!(should_pause_reconnect(&paused.connect_details));
let terminal_details = json!({ "retryable": false });
let terminal = RequestFailure::method_with_details("terminal", Some(&terminal_details));
assert!(should_pause_reconnect(&terminal.connect_details));
let retry_details = json!({ "retryable": true, "pauseReconnect": false });
let retry = RequestFailure::method_with_details("retry", Some(&retry_details));
assert!(!should_pause_reconnect(&retry.connect_details));
assert!(!should_pause_reconnect(
&RequestFailure::transport("transport").connect_details
));
}
#[test]
fn connection_notices_prefer_server_guidance_and_shorten_device_ids() {
let details = ConnectErrorDetails::from_value(Some(&json!({
"remediationHint": "Use the Nodes approval queue.",
"deviceId": "abcdef1234567890"
})));
assert_eq!(
connection_notice(GatewayConnectionState::PairingRequired, &details, true).as_deref(),
Some("Use the Nodes approval queue. · Device abcdef12")
);
assert_eq!(
connection_notice(
GatewayConnectionState::CredentialRequired,
&ConnectErrorDetails::default(),
true,
)
.as_deref(),
Some("Gateway requires a credential — open the dashboard on the gateway host")
);
assert_eq!(
connection_notice(
GatewayConnectionState::Down,
&ConnectErrorDetails::from_value(Some(&json!({
"remediationHint": "Replace the configured credential."
}))),
true,
)
.as_deref(),
Some("Replace the configured credential.")
);
}
#[test]
fn chat_send_frame_matches_gateway_schema() {
let params = ChatSendParams {
@@ -1344,4 +1651,16 @@ mod tests {
})
);
}
#[test]
fn chat_send_result_flattens_route_and_ack_run_id() {
let result = ChatSendResult {
target: routing_target("global", "work", "main"),
run_id: "run-1".to_string(),
};
assert_eq!(
serde_json::to_value(result).expect("serialized chat send result"),
json!({ "sessionKey": "global", "agentId": "work", "runId": "run-1" })
);
}
}

View File

@@ -1,4 +1,4 @@
use crate::gateway_ws::{AgentsListResult, GatewayClient};
use crate::gateway_ws::{AgentsListResult, ChatSendResult, GatewayClient};
use crate::{tray, DesktopState};
use serde::Serialize;
use std::fs;
@@ -513,6 +513,7 @@ pub fn toggle_quickchat(app: &AppHandle) {
fn show_quickchat(app: &AppHandle) -> Result<(), String> {
let window = ensure_quickchat_window(app)?;
app.state::<GatewayClient>().resume_reconnect();
window
.set_size(LogicalSize::new(QUICKCHAT_WIDTH, QUICKCHAT_HEIGHT))
.map_err(|error| format!("Could not reset Quick Chat size: {error}"))?;
@@ -588,7 +589,7 @@ pub async fn quickchat_send(
gateway: State<'_, GatewayClient>,
state: State<'_, QuickChatState>,
message: String,
) -> Result<(), String> {
) -> Result<ChatSendResult, String> {
require_quickchat_window(&window)?;
let message = message.trim().to_string();
if message.is_empty() {

View File

@@ -220,11 +220,98 @@ body.shown .composer {
opacity 120ms ease;
}
.composer.has-error .status {
.composer.has-error > .status,
.reply.has-error .reply-error {
max-height: 16px;
opacity: 1;
}
.reply {
padding: 10px 3px 2px 46px;
border-top: 1px solid rgb(255 255 255 / 7%);
margin-top: 9px;
}
.reply-header {
display: flex;
min-height: 24px;
margin-bottom: 6px;
align-items: center;
justify-content: space-between;
}
.reply-agent {
display: inline-flex;
min-width: 0;
padding: 2px 7px 2px 2px;
gap: 6px;
align-items: center;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
color: #d7d7da;
background: rgb(255 255 255 / 4%);
}
.reply-agent .agent-avatar-mini {
flex-basis: 20px;
width: 20px;
height: 20px;
font-size: 10px;
}
.reply-agent-name {
overflow: hidden;
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-state {
padding-right: 3px;
color: #7f7f84;
font-size: 10px;
letter-spacing: 0.02em;
opacity: 0;
text-transform: uppercase;
transition: opacity 120ms ease;
}
.reply.is-terminal .reply-state {
opacity: 1;
}
.reply-scroll {
max-height: 228px;
padding-right: 5px;
overflow-y: auto;
scrollbar-color: rgb(255 255 255 / 16%) transparent;
scrollbar-width: thin;
}
.reply-text {
overflow-wrap: anywhere;
color: #ededee;
font: 400 13px/1.5 inherit;
white-space: pre-wrap;
}
.reply-thinking {
display: inline-block;
color: transparent;
font-size: 12px;
background: linear-gradient(100deg, #777 20%, #d2d2d4 48%, #777 76%);
-webkit-background-clip: text;
background-clip: text;
background-size: 220% 100%;
animation: shimmer 1.35s ease-in-out infinite;
}
.reply-error {
margin-left: 0;
white-space: normal;
}
.popover {
position: absolute;
z-index: 2;
@@ -356,6 +443,16 @@ body.shown .popover:not([hidden]) {
}
}
@keyframes shimmer {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -369,4 +466,9 @@ body.shown .popover:not([hidden]) {
body.shown .composer {
transform: none;
}
.reply-thinking {
color: #8e8e93;
background: none;
}
}

View File

@@ -43,6 +43,20 @@
</button>
</div>
<p id="status" class="status" role="alert"></p>
<section id="reply" class="reply" aria-label="Agent reply" aria-live="polite" hidden>
<div class="reply-header">
<div class="reply-agent">
<span id="reply-agent-avatar" class="agent-avatar-mini" aria-hidden="true">A</span>
<span id="reply-agent-name" class="reply-agent-name">Agent</span>
</div>
<span id="reply-state" class="reply-state" aria-hidden="true"></span>
</div>
<div id="reply-scroll" class="reply-scroll">
<div id="reply-text" class="reply-text"></div>
<span id="reply-thinking" class="reply-thinking">Thinking…</span>
<p id="reply-error" class="status reply-error" role="alert"></p>
</div>
</section>
</main>
<section id="agent-menu" class="popover" aria-label="Choose agent" hidden>
<div class="popover-title">Agents</div>

View File

@@ -1,3 +1,46 @@
// Pure stream helpers stay above browser bindings so the Node regression test can exercise the
// Gateway-compatible assembler without constructing a WebView.
function chatMessageText(message) {
const content = message?.content;
if (Array.isArray(content)) {
const textBlocks = content
.filter((block) => block?.type === "text" && typeof block.text === "string")
.map((block) => block.text);
if (textBlocks.length > 0) {
return textBlocks.join("\n\n");
}
}
// Match the Control UI fallback contract in ui/src/lib/chat/message-extract.ts: typed content
// blocks, then plain-string content, then top-level message.text.
if (typeof content === "string") {
return content;
}
return typeof message?.text === "string" ? message.text : null;
}
function assembleChatDelta(currentText, payload) {
const snapshot = chatMessageText(payload?.message);
if (typeof payload?.deltaText === "string") {
if (payload.replace === true) {
return payload.deltaText;
}
if (currentText === null) {
return snapshot ?? payload.deltaText;
}
if (snapshot !== null) {
const prefixLength = snapshot.length - payload.deltaText.length;
if (
prefixLength !== currentText.length ||
snapshot.slice(0, prefixLength) !== currentText
) {
return snapshot;
}
}
return `${currentText}${payload.deltaText}`;
}
return snapshot;
}
const tauri = window["__TAURI__"];
const { invoke } = tauri.core;
const { listen } = tauri.event;
@@ -9,6 +52,14 @@ const elements = {
agentMenu: document.querySelector("#agent-menu"),
composer: document.querySelector("#composer"),
input: document.querySelector("#message"),
reply: document.querySelector("#reply"),
replyAgentAvatar: document.querySelector("#reply-agent-avatar"),
replyAgentName: document.querySelector("#reply-agent-name"),
replyError: document.querySelector("#reply-error"),
replyScroll: document.querySelector("#reply-scroll"),
replyState: document.querySelector("#reply-state"),
replyText: document.querySelector("#reply-text"),
replyThinking: document.querySelector("#reply-thinking"),
send: document.querySelector("#send"),
sendIcon: document.querySelector("#send-icon"),
shortcutCapture: document.querySelector("#shortcut-capture"),
@@ -33,9 +84,15 @@ let visibilitySequence = 0;
let popoverSequence = 0;
let sendError = "";
let gatewayState = "down";
let gatewayNotice = "";
let gatewayDisconnectSequence = 0;
let openPopover = null;
let menuIndex = 0;
let capturingShortcut = false;
let activeReply = null;
let pendingChatEvents = [];
const MAX_PENDING_CHAT_EVENTS = 64;
function friendlyError(error, fallback = "Could not send the message.") {
if (typeof error === "string") {
@@ -51,19 +108,32 @@ function setError(message = "") {
function renderStatus() {
if (gatewayState === "pairing-required") {
setError("Pair this device from the dashboard");
setError(gatewayNotice || "Approve this device in the dashboard (Nodes)");
return;
}
if (gatewayState === "credential-required") {
setError(
gatewayNotice || "Gateway requires a credential — open the dashboard on the gateway host",
);
return;
}
if (gatewayState === "tls-failure") {
setError("Gateway TLS trust failed — check the certificate fingerprint");
return;
}
setError(gatewayState === "up" ? sendError : "Gateway unreachable — retrying");
setError(
gatewayState === "up" ? sendError : gatewayNotice || "Gateway unreachable — retrying",
);
}
function setGatewayState(payload) {
const wasUp = gatewayState === "up";
gatewayState = payload?.state || "down";
gatewayNotice = typeof payload?.notice === "string" ? payload.notice : "";
if (gatewayState !== "up") {
gatewayDisconnectSequence += 1;
terminalizeDisconnectedReply();
}
renderStatus();
updateSendButton();
if (gatewayState === "up" && !wasUp) {
@@ -73,12 +143,13 @@ function setGatewayState(payload) {
function updateSendButton() {
const empty = !elements.input.value.trim();
const streaming = activeReply !== null && !activeReply.terminal;
elements.send.disabled =
gatewayState !== "up" || empty || selectingAgent || sending || accepted;
gatewayState !== "up" || empty || selectingAgent || sending || accepted || streaming;
elements.send.classList.toggle("sending", sending);
elements.send.classList.toggle("accepted", accepted);
elements.sendIcon.textContent = sending ? "" : accepted ? "✓" : "↑";
elements.input.readOnly = sending || accepted;
elements.input.readOnly = sending || accepted || streaming;
}
function nameHue(name) {
@@ -115,6 +186,150 @@ function renderAvatar(target, identity) {
target.replaceChildren(image);
}
function resetAccepted() {
window.clearTimeout(acceptedTimer);
acceptedTimer = null;
accepted = false;
}
function clearReply() {
activeReply = null;
elements.reply.hidden = true;
elements.reply.classList.remove("has-error", "is-terminal");
elements.replyError.textContent = "";
elements.replyState.textContent = "";
elements.replyText.textContent = "";
elements.replyThinking.hidden = true;
}
function scrollReplyToEnd() {
window.requestAnimationFrame(() => {
elements.replyScroll.scrollTop = elements.replyScroll.scrollHeight;
});
}
function renderReplyText() {
// Deliberately plain text: this small native surface avoids a Markdown dependency and preserves
// whitespace, leaving Markdown punctuation visible instead of interpreting agent output.
elements.replyText.textContent = activeReply?.text || "";
scrollReplyToEnd();
}
function stopReplyThinking() {
elements.replyThinking.hidden = true;
}
function terminalizeDisconnectedReply() {
if (!activeReply || activeReply.terminal) {
return;
}
// Chat events are not replayed after a socket gap. Unlock the composer instead of leaving a
// reply waiting forever for a terminal frame that may have been lost while disconnected.
activeReply.terminal = true;
stopReplyThinking();
elements.reply.classList.add("has-error", "is-terminal");
elements.replyState.textContent = "Interrupted";
elements.replyError.textContent = "Connection lost before the reply completed.";
scrollReplyToEnd();
}
function replyTargetMatches(target, payload) {
if (!target || payload?.sessionKey !== target.sessionKey) {
return false;
}
return target.agentId == null || payload?.agentId === target.agentId;
}
function startReply(target, identity, runId) {
activeReply = {
runId,
target: {
sessionKey: target.sessionKey,
agentId: typeof target.agentId === "string" ? target.agentId : null,
},
terminal: false,
text: null,
};
elements.reply.hidden = false;
elements.reply.classList.remove("has-error", "is-terminal");
elements.replyError.textContent = "";
elements.replyState.textContent = "";
elements.replyText.textContent = "";
elements.replyThinking.textContent = reducedMotion.matches ? "…" : "Thinking…";
elements.replyThinking.hidden = false;
renderAvatar(elements.replyAgentAvatar, identity);
elements.replyAgentName.textContent = identity?.name?.trim() || "Agent";
void invoke("quickchat_set_expanded", { expanded: true });
}
function applyChatEvent(payload) {
if (!activeReply) {
return;
}
// The chat.send ACK owns this reply. Exact runId equality is primary; the routing target remains
// a secondary guard so concurrent turns from other surfaces never enter this reply area.
if (payload?.runId !== activeReply.runId || !replyTargetMatches(activeReply.target, payload)) {
return;
}
if (activeReply.terminal) {
return;
}
const hasTextUpdate =
typeof payload?.deltaText === "string" || chatMessageText(payload?.message) !== null;
if (hasTextUpdate) {
const nextText = assembleChatDelta(activeReply.text, payload);
if (nextText !== null) {
activeReply.text = nextText;
renderReplyText();
}
}
if (payload?.state === "delta") {
stopReplyThinking();
return;
}
if (!["final", "aborted", "error"].includes(payload?.state)) {
return;
}
activeReply.terminal = true;
pendingChatEvents = [];
stopReplyThinking();
elements.reply.classList.add("is-terminal");
if (payload.state === "final") {
elements.replyState.textContent = "Done";
} else if (payload.state === "aborted") {
activeReply.text = `${activeReply.text || ""}${activeReply.text ? "\n\n" : ""}(stopped)`;
elements.replyState.textContent = "Stopped";
renderReplyText();
} else {
elements.reply.classList.add("has-error");
elements.replyState.textContent = "Error";
elements.replyError.textContent =
typeof payload.errorMessage === "string" && payload.errorMessage.trim()
? payload.errorMessage
: "Gateway reply failed.";
scrollReplyToEnd();
}
updateSendButton();
}
function handleChatEvent(payload) {
if (activeReply) {
applyChatEvent(payload);
return;
}
if (sending) {
// The Gateway may stream before the chat.send ack reaches invoke; replay only after the native
// command returns the accepted routing target, then apply the same session/run filters.
if (pendingChatEvents.length === MAX_PENDING_CHAT_EVENTS) {
pendingChatEvents.shift();
}
pendingChatEvents.push(payload);
}
}
function renderIdentity(identity) {
const name = identity?.name?.trim() || "Agent";
activeIdentity = { ...identity, name };
@@ -240,7 +455,7 @@ function closePopover(focusInput = true, compact = true) {
++popoverSequence;
setPopoverVisibility(null);
if (compact) {
void invoke("quickchat_set_expanded", { expanded: false });
void invoke("quickchat_set_expanded", { expanded: !elements.reply.hidden });
}
if (focusInput) {
elements.input.focus();
@@ -318,13 +533,14 @@ async function saveShortcut(accelerator) {
}
}
async function requestHide(force = false) {
if ((accepted && !force) || hiding) {
async function requestHide() {
if (hiding) {
return;
}
visibilitySequence += 1;
const hideSequence = visibilitySequence;
hiding = true;
pendingChatEvents = [];
closePopover(false, false);
document.body.classList.remove("shown");
window.clearTimeout(hideTimer);
@@ -332,6 +548,8 @@ async function requestHide(force = false) {
async () => {
try {
await invoke("quickchat_hide");
resetAccepted();
clearReply();
} catch (error) {
if (visibilitySequence === hideSequence) {
sendError = friendlyError(error);
@@ -351,11 +569,7 @@ async function requestHide(force = false) {
function reveal() {
window.clearTimeout(hideTimer);
if (accepted) {
window.clearTimeout(acceptedTimer);
acceptedTimer = null;
accepted = false;
}
resetAccepted();
hiding = false;
setPopoverVisibility(null);
renderStatus();
@@ -377,26 +591,57 @@ async function send(openDashboard) {
return;
}
sending = true;
const sendDisconnectSequence = gatewayDisconnectSequence;
const sendVisibilitySequence = visibilitySequence;
clearReply();
pendingChatEvents = [];
void invoke("quickchat_set_expanded", { expanded: false });
sendError = "";
renderStatus();
updateSendButton();
try {
await invoke("quickchat_send", { message });
const sentIdentity = { ...activeIdentity };
const result = await invoke("quickchat_send", { message });
if (!result || typeof result.sessionKey !== "string") {
throw new Error("Gateway accepted the message without a routing target.");
}
if (typeof result.runId !== "string" || !result.runId) {
throw new Error("Gateway accepted the message without a run ID.");
}
sending = false;
accepted = true;
sendError = "";
elements.input.value = "";
if (visibilitySequence !== sendVisibilitySequence || hiding) {
pendingChatEvents = [];
updateSendButton();
return;
}
accepted = true;
startReply(result, sentIdentity, result.runId);
const bufferedEvents = pendingChatEvents;
pendingChatEvents = [];
for (const payload of bufferedEvents) {
applyChatEvent(payload);
}
if (gatewayDisconnectSequence !== sendDisconnectSequence || gatewayState !== "up") {
terminalizeDisconnectedReply();
}
updateSendButton();
if (openDashboard) {
void invoke("quickchat_show_dashboard");
}
acceptedTimer = window.setTimeout(() => {
accepted = false;
acceptedTimer = null;
updateSendButton();
void requestHide(true);
}, 450);
} catch (error) {
sending = false;
pendingChatEvents = [];
if (visibilitySequence !== sendVisibilitySequence || hiding) {
updateSendButton();
return;
}
sendError = friendlyError(error);
renderStatus();
updateSendButton();
@@ -426,7 +671,7 @@ elements.input.addEventListener("keydown", (event) => {
}
if (event.key === "Enter" && !openPopover) {
event.preventDefault();
void send(event.ctrlKey);
void send(event.ctrlKey || event.metaKey);
}
});
elements.agentChip.addEventListener("click", () => {
@@ -510,6 +755,9 @@ await listen("quickchat:hide-requested", () => {
await listen("quickchat:gateway-state", (event) => {
setGatewayState(event.payload);
});
await listen("quickchat:chat-event", (event) => {
handleChatEvent(event.payload);
});
const readySequence = visibilitySequence;
try {
@@ -518,9 +766,9 @@ try {
if (shouldShow) {
reveal();
} else {
void requestHide(true);
void requestHide();
}
}
} catch {
void requestHide(true);
void requestHide();
}

View File

@@ -62,7 +62,12 @@ Chat's WebView receives neither credentials nor the WebSocket.
When the native connection is unavailable, Quick Chat shows **Gateway
unreachable — retrying** and disables send until reconnection. A remote device
that needs approval shows **Pair this device from the dashboard** instead.
that has reached the pairing phase shows **Approve this device in the dashboard
(Nodes)** instead, with a short device ID when the Gateway provides one. A
Gateway that requires a missing shared credential shows **Gateway requires a
credential — open the dashboard on the gateway host**; no pairing request is
waiting for approval in that state. Server-provided remediation guidance
replaces these fallback notices when it is more specific.
For TLS Gateways, the CLI hands the app the Gateway certificate's SHA-256
fingerprint; the native client pins that certificate and reports **Gateway TLS
trust failed — check the certificate fingerprint** separately from downtime.
@@ -70,12 +75,16 @@ Gateways whose shared secret is configured through a SecretRef omit it from the
CLI handoff. Existing paired installs keep working through their stored device
token, but a fresh install cannot create a pending pairing request under shared-secret
authentication without that bootstrap credential.
Setup-code and `bootstrapToken` redemption need dedicated product UI and remain
a follow-up; Quick Chat does not attempt either flow.
On X11, use the gear in Quick Chat to record or reset a custom shortcut. The
**Quick Chat shortcut** tray toggle enables or disables it without disabling the
plain **Quick Chat** tray item. Global shortcuts are not available on Wayland, so
the shortcut settings are hidden and the tray item remains the entry point.
Replies remain in the normal session; open the dashboard to read them.
After an accepted send, Quick Chat stays open and streams the selected agent's
plain-text reply below the composer. Press `Esc` to dismiss the bar and its reply;
`Ctrl+Enter` still opens the dashboard.
### Canvas

View File

@@ -0,0 +1,270 @@
// Exercises the pure stream-assembly helpers extracted from the Linux Quick Chat webview script.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { it as test } from "vitest";
const quickchatSource = readFileSync(
new URL("../apps/linux/ui/quickchat.js", import.meta.url),
"utf8",
);
const browserBindingsStart = quickchatSource.indexOf("const tauri = window");
assert.notEqual(browserBindingsStart, -1, "quickchat pure-helper boundary");
const context: Record<
string,
{
assembleChatDelta: (state: unknown, payload: unknown) => unknown;
chatMessageText: (message: unknown) => string;
}
> &
Record<string, unknown> = {};
vm.runInNewContext(
`${quickchatSource.slice(0, browserBindingsStart)}\nthis.helpers = { assembleChatDelta, chatMessageText };`,
context,
);
const { assembleChatDelta, chatMessageText } = context.helpers as {
assembleChatDelta: (state: unknown, payload: unknown) => { text: string; runId?: string };
chatMessageText: (message: unknown) => string;
};
function createFakeElement() {
const classes = new Set();
return {
classList: {
add: (...names: string[]) => names.forEach((name) => classes.add(name)),
remove: (...names: string[]) => names.forEach((name) => classes.delete(name)),
toggle(name: string, force?: boolean) {
const enabled = force ?? !classes.has(name);
if (enabled) {
classes.add(name);
} else {
classes.delete(name);
}
return enabled;
},
},
style: { setProperty() {} },
value: "",
textContent: "",
hidden: false,
disabled: false,
readOnly: false,
scrollHeight: 0,
scrollTop: 0,
addEventListener() {},
contains() {
return false;
},
focus() {},
querySelectorAll() {
return [];
},
replaceChildren() {},
setAttribute() {},
};
}
function createQuickChatHarness(): Record<string, any> {
const browserBindingsEnd = quickchatSource.indexOf("elements.input.addEventListener");
assert.notEqual(browserBindingsEnd, -1, "quickchat browser binding boundary");
const elements = new Map();
let resolveSend;
const sendResult = new Promise((resolve) => {
resolveSend = resolve;
});
const window = {
__TAURI__: {
core: {
invoke(method: string) {
return method === "quickchat_send" ? sendResult : Promise.resolve(null);
},
},
event: { listen: async () => () => {} },
},
clearTimeout() {},
matchMedia: () => ({ matches: true }),
requestAnimationFrame(callback: () => void) {
callback();
},
setTimeout: () => 1,
};
const document = {
body: createFakeElement(),
createElement: () => createFakeElement(),
createTextNode: (text: string) => ({ textContent: text }),
querySelector(selector: string) {
if (!elements.has(selector)) {
elements.set(selector, createFakeElement());
}
return elements.get(selector);
},
};
const browserContext: Record<string, any> = { document, window };
vm.runInNewContext(
`${quickchatSource.slice(0, browserBindingsEnd)}
this.harness = {
send,
handleChatEvent,
requestHide,
setGatewayUp() { gatewayState = "up"; },
setMessage(value) { elements.input.value = value; },
pendingCount() { return pendingChatEvents.length; },
activeRunId() { return activeReply?.runId ?? null; },
replyText() { return elements.replyText.textContent; },
};`,
browserContext,
);
return { ...(browserContext.harness as Record<string, (...args: any[]) => any>), resolveSend };
}
test("replace deltas are authoritative", () => {
assert.equal(
assembleChatDelta("stale", {
deltaText: "replacement",
replace: true,
message: { content: [{ type: "text", text: "ignored snapshot" }] },
}),
"replacement",
);
});
test("the first delta seeds from its message snapshot", () => {
assert.equal(
assembleChatDelta(null, {
deltaText: "lo",
message: { content: [{ type: "text", text: "Hello" }] },
}),
"Hello",
);
assert.equal(assembleChatDelta(null, { deltaText: "Hi" }), "Hi");
});
test("matching deltas append and mismatched snapshots self-heal", () => {
assert.equal(
assembleChatDelta("Hello", {
deltaText: "!",
message: { content: [{ type: "text", text: "Hello!" }] },
}),
"Hello!",
);
assert.equal(
assembleChatDelta("Hellx", {
deltaText: "!",
message: { content: [{ type: "text", text: "Hello!" }] },
}),
"Hello!",
);
});
test("snapshot-only terminal frames replace the assembled text", () => {
assert.equal(
assembleChatDelta("partial", {
message: { content: [{ type: "text", text: "complete" }] },
}),
"complete",
);
});
test("snapshot extraction joins every text block", () => {
assert.equal(
chatMessageText({
content: [
{ type: "text", text: "first" },
{ type: "image", url: "data:image/png;base64,AA==" },
{ type: "text", text: "second" },
],
}),
"first\n\nsecond",
);
});
test("snapshot extraction skips a leading non-text block", () => {
assert.equal(
chatMessageText({
content: [
{ type: "thinking", thinking: "hidden" },
{ type: "text", text: "visible" },
],
}),
"visible",
);
});
test("snapshot extraction falls back through string content and top-level text", () => {
assert.equal(chatMessageText({ content: "string content", text: "top-level" }), "string content");
assert.equal(chatMessageText({ content: [], text: "top-level" }), "top-level");
});
test("pre-ack frames replay once for only the acknowledged run", async () => {
const harness = createQuickChatHarness();
harness.setGatewayUp();
harness.setMessage("hello");
const sending = harness.send(false);
harness.handleChatEvent({
sessionKey: "global",
agentId: "work",
runId: "wrong-run",
state: "delta",
deltaText: "wrong",
});
harness.handleChatEvent({
sessionKey: "global",
agentId: "work",
runId: "right-run",
state: "delta",
deltaText: "right",
});
assert.equal(harness.pendingCount(), 2);
harness.resolveSend({ sessionKey: "global", agentId: "work", runId: "right-run" });
await sending;
assert.equal(harness.pendingCount(), 0);
assert.equal(harness.activeRunId(), "right-run");
assert.equal(harness.replyText(), "right");
harness.handleChatEvent({
sessionKey: "global",
agentId: "work",
runId: " right-run ",
state: "delta",
deltaText: " whitespace-id",
});
harness.handleChatEvent({
sessionKey: "global",
agentId: "other-agent",
runId: "right-run",
state: "delta",
deltaText: " wrong-agent",
});
harness.handleChatEvent({
sessionKey: "global",
agentId: "work",
runId: "right-run",
state: "delta",
deltaText: "!",
});
assert.equal(harness.replyText(), "right!");
});
test("hiding clears buffered pre-ack frames", async () => {
const harness = createQuickChatHarness();
harness.setGatewayUp();
harness.setMessage("hello");
const sending = harness.send(false);
harness.handleChatEvent({
sessionKey: "global",
agentId: "work",
runId: "right-run",
state: "delta",
deltaText: "buffered",
});
assert.equal(harness.pendingCount(), 1);
await harness.requestHide();
assert.equal(harness.pendingCount(), 0);
harness.resolveSend({ sessionKey: "global", agentId: "work", runId: "right-run" });
await sending;
assert.equal(harness.replyText(), "");
});