mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 12:41:35 +00:00
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:
committed by
GitHub
parent
74880cb56f
commit
bc8d0da092
@@ -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),
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
270
test/linux-quickchat-stream.test.ts
Normal file
270
test/linux-quickchat-stream.test.ts
Normal 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(), "");
|
||||
});
|
||||
Reference in New Issue
Block a user