mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Bug-bash round 2: table scroll, Goose instructions, workflow mention wake (#2034)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -597,6 +597,24 @@ impl AcpClient {
|
||||
.session_id)
|
||||
}
|
||||
|
||||
/// Send Goose's custom system-prompt request after `session/new`.
|
||||
pub async fn session_set_goose_system_prompt(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
) -> Result<serde_json::Value, AcpError> {
|
||||
self.send_request(
|
||||
"_goose/unstable/session/system-prompt/set",
|
||||
serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"mode": "append",
|
||||
"key": "buzz",
|
||||
"text": text,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send `session/set_config_option` (stable ACP path).
|
||||
pub async fn session_set_config_option(
|
||||
&mut self,
|
||||
@@ -2889,6 +2907,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goose_system_prompt_request_uses_append_contract() {
|
||||
let script = r#"
|
||||
read -t 2 REQ
|
||||
echo '{"jsonrpc":"2.0","id":0,"result":{"_receivedRequest":'"$REQ"'}}'
|
||||
sleep 1
|
||||
"#;
|
||||
let mut client = spawn_script(script).await;
|
||||
let result = client
|
||||
.session_set_goose_system_prompt("ses_goose", "Be terse")
|
||||
.await
|
||||
.expect("custom request succeeds");
|
||||
let received = &result["_receivedRequest"];
|
||||
assert_eq!(
|
||||
received["method"],
|
||||
"_goose/unstable/session/system-prompt/set"
|
||||
);
|
||||
assert_eq!(received["params"]["sessionId"], "ses_goose");
|
||||
assert_eq!(received["params"]["mode"], "append");
|
||||
assert_eq!(received["params"]["key"], "buzz");
|
||||
assert_eq!(received["params"]["text"], "Be terse");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goose_system_prompt_preserves_method_not_found_for_fallback() {
|
||||
let script = r#"
|
||||
read -t 2 _REQ
|
||||
echo '{"jsonrpc":"2.0","id":0,"error":{"code":-32601,"message":"Method not found"}}'
|
||||
sleep 1
|
||||
"#;
|
||||
let mut client = spawn_script(script).await;
|
||||
assert!(matches!(
|
||||
client
|
||||
.session_set_goose_system_prompt("ses_goose", "Be terse")
|
||||
.await,
|
||||
Err(AcpError::AgentError { code: -32601, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goose_system_prompt_preserves_invalid_params_as_error() {
|
||||
let script = r#"
|
||||
read -t 2 _REQ
|
||||
echo '{"jsonrpc":"2.0","id":0,"error":{"code":-32602,"message":"Invalid params"}}'
|
||||
sleep 1
|
||||
"#;
|
||||
let mut client = spawn_script(script).await;
|
||||
assert!(matches!(
|
||||
client
|
||||
.session_set_goose_system_prompt("ses_goose", "Be terse")
|
||||
.await,
|
||||
Err(AcpError::AgentError { code: -32602, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_full_omits_system_prompt_when_none() {
|
||||
// When system_prompt is None, the field should not appear in params.
|
||||
|
||||
@@ -984,7 +984,7 @@ struct RespawnResult {
|
||||
/// Tuple: (initialized client, protocol version, supports_goose_steer).
|
||||
/// The third element is always `true` — the supervisor uses
|
||||
/// try-and-tolerate for the steer extension.
|
||||
result: Result<(AcpClient, u32, bool)>,
|
||||
result: Result<(AcpClient, u32, String)>,
|
||||
}
|
||||
|
||||
/// Outcome of a non-cancelling steer attempt, forwarded from a per-attempt
|
||||
@@ -1028,7 +1028,7 @@ impl RespawnGuard {
|
||||
/// Send the result and disarm the guard. Uses `try_send` (sync) so there
|
||||
/// is no await boundary between marking `sent` and actually enqueueing —
|
||||
/// cancellation cannot slip between the two.
|
||||
fn send(mut self, result: Result<(AcpClient, u32, bool)>) {
|
||||
fn send(mut self, result: Result<(AcpClient, u32, String)>) {
|
||||
// Invariant: try_send succeeds because the channel capacity equals the
|
||||
// slot count, and respawn_in_flight guarantees at most one outstanding
|
||||
// result per slot. If this ever fails, the channel sizing or the
|
||||
@@ -1198,6 +1198,7 @@ async fn tokio_main() -> Result<()> {
|
||||
"initializeResult": init_result,
|
||||
}),
|
||||
);
|
||||
let agent_name = normalized_agent_name(&init_result);
|
||||
agent_slots.push(Some(OwnedAgent {
|
||||
index: i,
|
||||
acp,
|
||||
@@ -1205,6 +1206,8 @@ async fn tokio_main() -> Result<()> {
|
||||
model_capabilities: None,
|
||||
desired_model: config.model.clone(),
|
||||
model_overridden: false,
|
||||
agent_name,
|
||||
goose_system_prompt_supported: None,
|
||||
protocol_version,
|
||||
}));
|
||||
}
|
||||
@@ -1640,7 +1643,7 @@ async fn tokio_main() -> Result<()> {
|
||||
while let Ok(rr) = respawn_rx.try_recv() {
|
||||
crash_history[rr.index].respawn_in_flight = false;
|
||||
match rr.result {
|
||||
Ok((acp, protocol_version, _)) => {
|
||||
Ok((acp, protocol_version, agent_name)) => {
|
||||
let agent = OwnedAgent {
|
||||
index: rr.index,
|
||||
acp,
|
||||
@@ -1648,6 +1651,8 @@ async fn tokio_main() -> Result<()> {
|
||||
model_capabilities: None,
|
||||
desired_model: config.model.clone(),
|
||||
model_overridden: false,
|
||||
agent_name,
|
||||
goose_system_prompt_supported: None,
|
||||
protocol_version,
|
||||
};
|
||||
pool.return_agent(agent);
|
||||
@@ -3199,10 +3204,6 @@ fn dispatch_heartbeat(
|
||||
.heartbeat_prompt
|
||||
.clone()
|
||||
.unwrap_or_else(default_heartbeat_prompt);
|
||||
// For legacy agents (protocol_version < 2), prepend base_prompt to the
|
||||
// heartbeat user message since they don't receive it via session/new.
|
||||
let prompt_text =
|
||||
pool::prepend_base_for_legacy(agent.protocol_version, ctx.base_prompt, &prompt_text);
|
||||
let result_tx = pool.result_tx();
|
||||
let ctx_clone = Arc::clone(ctx);
|
||||
let agent_index = agent.index;
|
||||
@@ -3315,6 +3316,17 @@ fn spawn_respawn_task(
|
||||
true
|
||||
}
|
||||
|
||||
fn normalized_agent_name(init_result: &serde_json::Value) -> String {
|
||||
init_result
|
||||
.get("agentInfo")
|
||||
.or_else(|| init_result.get("serverInfo"))
|
||||
.and_then(|info| info.get("name"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
// ── spawn_and_init ────────────────────────────────────────────────────────────
|
||||
/// Spawn an agent subprocess and run the MCP `initialize` handshake.
|
||||
///
|
||||
@@ -3327,7 +3339,7 @@ async fn spawn_and_init(
|
||||
has_generated_codex_config: bool,
|
||||
agent_index: usize,
|
||||
observer: Option<observer::ObserverHandle>,
|
||||
) -> Result<(AcpClient, u32, bool)> {
|
||||
) -> Result<(AcpClient, u32, String)> {
|
||||
let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?;
|
||||
@@ -3344,7 +3356,8 @@ async fn spawn_and_init(
|
||||
"initializeResult": init_result,
|
||||
}),
|
||||
);
|
||||
Ok((acp, protocol_version, true))
|
||||
let agent_name = normalized_agent_name(&init_result);
|
||||
Ok((acp, protocol_version, agent_name))
|
||||
}
|
||||
Err(e) => {
|
||||
// Explicitly shut down the spawned child to prevent zombie/leak.
|
||||
@@ -4287,6 +4300,22 @@ mod error_outcome_emission_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_agent_name_from_initialize_result() {
|
||||
assert_eq!(
|
||||
normalized_agent_name(&serde_json::json!({
|
||||
"agentInfo": { "name": " Goose ", "version": "1.43.0" }
|
||||
})),
|
||||
"goose"
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_agent_name(&serde_json::json!({
|
||||
"serverInfo": { "name": "buzz-agent" }
|
||||
})),
|
||||
"buzz-agent"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawn a real but inert agent subprocess (`cat`) so the error paths have
|
||||
/// an `OwnedAgent` to move into respawn or return to the pool. The error
|
||||
/// branches never talk to the subprocess.
|
||||
@@ -4300,6 +4329,8 @@ mod error_outcome_emission_tests {
|
||||
model_capabilities: None,
|
||||
desired_model: None,
|
||||
model_overridden: false,
|
||||
agent_name: "unknown".into(),
|
||||
goose_system_prompt_supported: None,
|
||||
// Error branches under test never read this; 1 is the legacy
|
||||
// non-systemPrompt path, the simplest valid value.
|
||||
protocol_version: 1,
|
||||
|
||||
@@ -154,11 +154,50 @@ pub struct OwnedAgent {
|
||||
/// desktop reader to distinguish a genuine runtime override from a stale
|
||||
/// session whose persona model was edited. Reset on spawn/restart.
|
||||
pub model_overridden: bool,
|
||||
/// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`).
|
||||
pub agent_name: String,
|
||||
/// Whether Goose accepted its custom system-prompt method. `None` probes on
|
||||
/// the first session; method-not-found is cached as `Some(false)` so legacy
|
||||
/// user-message framing is used for this process thereafter.
|
||||
pub goose_system_prompt_supported: Option<bool>,
|
||||
/// Protocol version reported by the agent in its initialize response.
|
||||
/// Agents declaring >= 2 support `systemPrompt` in session/new.
|
||||
pub protocol_version: u32,
|
||||
}
|
||||
|
||||
fn has_system_prompt_support(
|
||||
protocol_version: u32,
|
||||
agent_name: &str,
|
||||
goose_system_prompt_supported: Option<bool>,
|
||||
) -> bool {
|
||||
if agent_name == "goose" {
|
||||
goose_system_prompt_supported == Some(true)
|
||||
} else {
|
||||
protocol_version >= 2
|
||||
}
|
||||
}
|
||||
|
||||
fn session_new_system_prompt(
|
||||
is_goose: bool,
|
||||
protocol_version: u32,
|
||||
prompt: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
if is_goose || protocol_version < 2 {
|
||||
None
|
||||
} else {
|
||||
prompt
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnedAgent {
|
||||
pub(crate) fn has_system_prompt_support(&self) -> bool {
|
||||
has_system_prompt_support(
|
||||
self.protocol_version,
|
||||
&self.agent_name,
|
||||
self.goose_system_prompt_supported,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pool of agents with take-and-return ownership semantics.
|
||||
///
|
||||
/// Agents are either idle (sitting in `agents[i]`) or checked out
|
||||
@@ -702,37 +741,57 @@ async fn create_session_and_apply_model(
|
||||
agent_core: Option<&str>,
|
||||
agent_canvas: Option<&str>,
|
||||
) -> Result<String, AcpError> {
|
||||
// Combine base_prompt + system_prompt + agent core + canvas metadata into a
|
||||
// single systemPrompt value for the session/new request. Only sent when the
|
||||
// agent declares protocol version >= 2 (supports systemPrompt); legacy agents
|
||||
// ignore it and receive the same content as user-message sections via
|
||||
// `format_prompt`. Core carries its own `[Agent Memory — core]` header, and
|
||||
// canvas carries its own `[Channel Canvas]` header; both are appended with a
|
||||
// blank-line separator.
|
||||
let combined_system_prompt: Option<String> = if agent.protocol_version >= 2 {
|
||||
with_canvas(
|
||||
with_core(
|
||||
with_team(
|
||||
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
|
||||
ctx.team_instructions.as_deref(),
|
||||
),
|
||||
agent_core,
|
||||
// Build base_prompt + system_prompt + agent core + canvas metadata into a
|
||||
// single prompt. Standard protocol-v2 agents receive it in `session/new`;
|
||||
// Goose receives it through the custom request below. Legacy agents receive
|
||||
// the same content as user-message sections via `format_prompt`. Core carries
|
||||
// its own `[Agent Memory — core]` header, and canvas carries its own
|
||||
// `[Channel Canvas]` header; both are appended with a blank-line separator.
|
||||
let is_goose = agent.agent_name == "goose";
|
||||
let combined_system_prompt = with_canvas(
|
||||
with_core(
|
||||
with_team(
|
||||
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
|
||||
ctx.team_instructions.as_deref(),
|
||||
),
|
||||
agent_canvas,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
agent_core,
|
||||
),
|
||||
agent_canvas,
|
||||
);
|
||||
|
||||
let resp = agent
|
||||
.acp
|
||||
.session_new_full(
|
||||
&ctx.cwd,
|
||||
ctx.mcp_servers.clone(),
|
||||
combined_system_prompt.as_deref(),
|
||||
session_new_system_prompt(
|
||||
is_goose,
|
||||
agent.protocol_version,
|
||||
combined_system_prompt.as_deref(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_goose && agent.goose_system_prompt_supported != Some(false) {
|
||||
if let Some(prompt) = combined_system_prompt.as_deref() {
|
||||
match agent
|
||||
.acp
|
||||
.session_set_goose_system_prompt(&resp.session_id, prompt)
|
||||
.await
|
||||
{
|
||||
Ok(_) => agent.goose_system_prompt_supported = Some(true),
|
||||
Err(AcpError::AgentError { code: -32601, .. }) => {
|
||||
agent.goose_system_prompt_supported = Some(false);
|
||||
tracing::warn!(
|
||||
target: "pool::session",
|
||||
"Goose does not support its system-prompt extension; using user-message framing"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate model capabilities on first session creation.
|
||||
if agent.model_capabilities.is_none() {
|
||||
agent.model_capabilities = Some(AgentModelCapabilities {
|
||||
@@ -1421,10 +1480,21 @@ pub async fn run_prompt_task(
|
||||
// Canvas is also injected here for legacy agents: protocol-v2 agents
|
||||
// already have it in systemPrompt; legacy agents need it before the
|
||||
// first prompt, matching the "every turn" per-turn delivery semantics.
|
||||
let init_msg =
|
||||
prepend_base_for_legacy(agent.protocol_version, ctx.base_prompt, initial_msg);
|
||||
let init_msg = prepend_base_for_legacy(
|
||||
if agent.has_system_prompt_support() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
ctx.base_prompt,
|
||||
initial_msg,
|
||||
);
|
||||
let init_msg = prepend_canvas_for_legacy(
|
||||
agent.protocol_version,
|
||||
if agent.has_system_prompt_support() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
agent_canvas.as_deref(),
|
||||
&init_msg,
|
||||
);
|
||||
@@ -1540,7 +1610,17 @@ pub async fn run_prompt_task(
|
||||
// follows as a second block.
|
||||
let mut slash_command: Option<String> = None;
|
||||
let prompt_sections: Vec<String> = if let Some(text) = prompt_text {
|
||||
// Pre-built prompt (heartbeat or legacy path) — a single block.
|
||||
// Heartbeats create their session before this point, so a Goose method-not-found
|
||||
// probe has already selected the correct framing for this process.
|
||||
let text = prepend_base_for_legacy(
|
||||
if agent.has_system_prompt_support() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
ctx.base_prompt,
|
||||
&text,
|
||||
);
|
||||
vec![text]
|
||||
} else if let Some(ref b) = batch {
|
||||
// Build prompt from batch with context enrichment.
|
||||
@@ -1585,7 +1665,7 @@ pub async fn run_prompt_task(
|
||||
channel_info: channel_info.as_ref(),
|
||||
conversation_context: conversation_context.as_ref(),
|
||||
profile_lookup: profile_lookup.as_ref(),
|
||||
has_system_prompt_support: agent.protocol_version >= 2,
|
||||
has_system_prompt_support: agent.has_system_prompt_support(),
|
||||
base_prompt: ctx.base_prompt,
|
||||
system_prompt: ctx.system_prompt.as_deref(),
|
||||
team_instructions: ctx.team_instructions.as_deref(),
|
||||
@@ -3421,6 +3501,27 @@ mod tests {
|
||||
assert_eq!(composed, "hello channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goose_uses_system_prompt_only_after_custom_method_succeeds() {
|
||||
assert!(!has_system_prompt_support(2, "goose", None));
|
||||
assert!(!has_system_prompt_support(2, "goose", Some(false)));
|
||||
assert!(has_system_prompt_support(2, "goose", Some(true)));
|
||||
assert!(has_system_prompt_support(1, "goose", Some(true)));
|
||||
assert!(has_system_prompt_support(2, "buzz-agent", None));
|
||||
assert_eq!(
|
||||
session_new_system_prompt(true, 2, Some("instructions")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
session_new_system_prompt(false, 2, Some("instructions")),
|
||||
Some("instructions")
|
||||
);
|
||||
assert_eq!(
|
||||
session_new_system_prompt(false, 1, Some("instructions")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_message_legacy_agent_without_base_is_unchanged() {
|
||||
// No base_prompt configured: nothing to prepend regardless of version.
|
||||
@@ -4507,6 +4608,8 @@ mod tests {
|
||||
model_capabilities: None,
|
||||
desired_model: None,
|
||||
model_overridden: false,
|
||||
agent_name: "unknown".into(),
|
||||
goose_system_prompt_supported: None,
|
||||
protocol_version: 2,
|
||||
};
|
||||
|
||||
@@ -4562,6 +4665,8 @@ mod tests {
|
||||
model_capabilities: None,
|
||||
desired_model: None,
|
||||
model_overridden: false,
|
||||
agent_name: "unknown".into(),
|
||||
goose_system_prompt_supported: None,
|
||||
protocol_version: 2,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,135 @@ use uuid::Uuid;
|
||||
use crate::handlers::event::dispatch_persistent_event;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Resolves `@Name` mentions in workflow message text to the pubkeys of the
|
||||
/// channel members they name, so the emitted kind:9 carries the `p` tags that
|
||||
/// ACP agent-wake (`event_mentions_agent`) is gated on.
|
||||
///
|
||||
/// The client resolves mentions to `p` tags at compose time from an interactive
|
||||
/// autocomplete pick; the workflow path has only free text, so this reverse-parse
|
||||
/// *defines* the matching contract. It is deliberately conservative to avoid
|
||||
/// waking the wrong agent:
|
||||
///
|
||||
/// - **Members only.** Candidates are the destination channel's members; global
|
||||
/// users are never matched.
|
||||
/// - **Exact display name.** No substring, prefix, or fuzzy matching. Names may
|
||||
/// contain spaces/punctuation (`"Will Pfleger"`, `"Lep (Subagent)"`), so the
|
||||
/// match is anchored on `@` and terminated by a non-name boundary rather than
|
||||
/// whitespace.
|
||||
/// - **Greedy-longest, non-overlapping.** Longer names are matched first and
|
||||
/// consume their span, so `@Will Pfleger` binds *Pfleger* and a bare `@Will`
|
||||
/// does not match the member `"Will Pfleger"`.
|
||||
/// - **Ambiguous names wake no one.** If two or more members share the matched
|
||||
/// display name, no `p` tag is emitted for it — arbitrary selection would
|
||||
/// silently misroute and tagging all of them is a false-wake firehose.
|
||||
///
|
||||
/// Returns deduplicated pubkey hexes, in first-appearance order in `text`.
|
||||
fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec<String> {
|
||||
// Name → pubkey, folding case (client matches case-insensitively). A name
|
||||
// that maps to more than one distinct pubkey is ambiguous → wake no one.
|
||||
let mut by_name: std::collections::HashMap<String, Option<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for (name, pubkey) in members {
|
||||
if name.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
by_name
|
||||
.entry(name.to_lowercase())
|
||||
.and_modify(|slot| {
|
||||
if slot.as_deref() != Some(pubkey.as_str()) {
|
||||
*slot = None; // ambiguous
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| Some(pubkey.clone()));
|
||||
}
|
||||
|
||||
// Match longest names first so a longer name consumes its span before a
|
||||
// shorter substring name can claim part of it.
|
||||
let mut names: Vec<&(String, String)> = members.iter().collect();
|
||||
names.sort_by_key(|(name, _)| std::cmp::Reverse(name.chars().count()));
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut consumed = vec![false; chars.len()];
|
||||
|
||||
// Case-insensitivity folds *both* sides through `char::to_lowercase`, which
|
||||
// can change length: `İ` (U+0130) lowercases to two code points (`i` +
|
||||
// U+0307 combining dot). Comparing a pre-lowercased copy of the whole text
|
||||
// against a lowercased name by index silently desyncs once any earlier char
|
||||
// expands. Instead, fold on the fly: walk the original `chars` at the
|
||||
// candidate `@`, folding each char, and match against the folded-name char
|
||||
// stream — tracking how many *original* chars were consumed so
|
||||
// boundary/`consumed` accounting stays in original coordinates. `None` = no
|
||||
// match; `Some(n)` = matched, consuming `n` original chars after the `@`.
|
||||
let match_name_len = |start: usize, folded_name: &[char]| -> Option<usize> {
|
||||
let mut ci = start;
|
||||
let mut ni = 0;
|
||||
while ni < folded_name.len() {
|
||||
let c = *chars.get(ci)?;
|
||||
for fc in c.to_lowercase() {
|
||||
if folded_name.get(ni) != Some(&fc) {
|
||||
return None;
|
||||
}
|
||||
ni += 1;
|
||||
}
|
||||
ci += 1;
|
||||
}
|
||||
Some(ci - start)
|
||||
};
|
||||
|
||||
// A mention is anchored on `@` at a left boundary (start / whitespace / `(`)
|
||||
// and the matched name must not be followed by a name-continuation char —
|
||||
// otherwise `@Will` would match inside `@Willow`. Combined with matching the
|
||||
// longest member name first, this is the whole rule: no punctuation allowlist
|
||||
// to get wrong, and it is unicode-safe (em-dash, emoji all terminate a name).
|
||||
let is_left_boundary = |i: usize| i == 0 || chars[i - 1].is_whitespace() || chars[i - 1] == '(';
|
||||
let extends_name = |c: char| c.is_alphanumeric() || c == '_';
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut hits: Vec<(usize, String)> = Vec::new();
|
||||
|
||||
for (name, _) in &names {
|
||||
let folded_name: Vec<char> = name.to_lowercase().chars().collect();
|
||||
if folded_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut at = 0;
|
||||
while at < chars.len() {
|
||||
// Anchor on `@` at a left boundary and an unconsumed span; only then
|
||||
// attempt the fold-match. `name_len` is measured in *original* chars,
|
||||
// so `at + 1 + name_len` is the true position just past the name.
|
||||
let name_len = (chars[at] == '@' && is_left_boundary(at) && !consumed[at])
|
||||
.then(|| match_name_len(at + 1, &folded_name))
|
||||
.flatten()
|
||||
.filter(|&n| {
|
||||
chars[at + 1 + n..]
|
||||
.first()
|
||||
.is_none_or(|&c| !extends_name(c))
|
||||
});
|
||||
if let Some(name_len) = name_len {
|
||||
let span = 1 + name_len;
|
||||
if let Some(Some(pubkey)) = by_name.get(&name.to_lowercase()) {
|
||||
hits.push((at, pubkey.clone()));
|
||||
}
|
||||
for slot in consumed.iter_mut().skip(at).take(span) {
|
||||
*slot = true;
|
||||
}
|
||||
at += span;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort_by_key(|(at, _)| *at);
|
||||
for (_, pubkey) in hits {
|
||||
if seen.insert(pubkey.clone()) {
|
||||
out.push(pubkey);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Relay-side action sink — executes workflow side-effects directly.
|
||||
///
|
||||
/// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle:
|
||||
@@ -126,7 +255,9 @@ impl ActionSink for RelayActionSink {
|
||||
// - `p` tag attributes the message to the workflow owner
|
||||
// - `h` tag scopes to the channel (NIP-29, canonical UUID)
|
||||
// - `buzz:workflow` tag prevents recursive workflow triggering
|
||||
let tags = vec![
|
||||
// - one `p` tag per `@Name` that resolves to a channel member,
|
||||
// so mentioned agents are woken (wake is `p`-tag gated)
|
||||
let mut tags = vec![
|
||||
Tag::parse(["p", &author_pubkey_hex])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?,
|
||||
Tag::parse(["h", &channel_id_canonical])
|
||||
@@ -135,6 +266,38 @@ impl ActionSink for RelayActionSink {
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?,
|
||||
];
|
||||
|
||||
// Resolve `@Name` mentions to channel-member pubkeys and append a
|
||||
// `p` tag for each (skipping the author, already tagged above). A
|
||||
// resolution failure must not drop the message, so log and proceed
|
||||
// with the base tags.
|
||||
let members = state
|
||||
.db
|
||||
.get_members(tenant.community(), channel_uuid)
|
||||
.await
|
||||
.map_err(|e| ActionSinkError::Database(e.to_string()))?;
|
||||
let member_pubkeys: Vec<Vec<u8>> = members.iter().map(|m| m.pubkey.clone()).collect();
|
||||
let users = state
|
||||
.db
|
||||
.get_users_bulk(tenant.community(), &member_pubkeys)
|
||||
.await
|
||||
.map_err(|e| ActionSinkError::Database(e.to_string()))?;
|
||||
let named_members: Vec<(String, String)> = users
|
||||
.into_iter()
|
||||
.filter_map(|u| {
|
||||
let name = u.display_name?;
|
||||
Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex()))
|
||||
})
|
||||
.collect();
|
||||
for mentioned in resolve_mention_pubkeys(&text, &named_members) {
|
||||
if mentioned == author_pubkey_hex {
|
||||
continue;
|
||||
}
|
||||
tags.push(
|
||||
Tag::parse(["p", &mentioned])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?,
|
||||
);
|
||||
}
|
||||
|
||||
let kind = Kind::from(KIND_STREAM_MESSAGE as u16);
|
||||
let event = EventBuilder::new(kind, &text)
|
||||
.tags(tags)
|
||||
@@ -200,3 +363,349 @@ impl ActionSink for RelayActionSink {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn m(name: &str, pubkey: &str) -> (String, String) {
|
||||
(name.to_string(), pubkey.to_string())
|
||||
}
|
||||
|
||||
// A 64-char hex pubkey built from a single repeated nibble, for readable tests.
|
||||
fn pk(nibble: char) -> String {
|
||||
std::iter::repeat_n(nibble, 64).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_exact_member_name() {
|
||||
let members = vec![m("Robby", &pk('a'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("heads up @Robby — please take a look", &members),
|
||||
vec![pk('a')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_case_insensitively() {
|
||||
let members = vec![m("Robby", &pk('a'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("ping @robby", &members),
|
||||
vec![pk('a')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_member_and_bare_at() {
|
||||
let members = vec![m("Robby", &pk('a'))];
|
||||
assert!(resolve_mention_pubkeys("hey @Stranger and @", &members).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greedy_longest_binds_full_name_not_prefix() {
|
||||
// Both "Will" and "Will Pfleger" are members. `@Will Pfleger` must bind
|
||||
// Pfleger's key only; a bare `@Will` binds Will.
|
||||
let members = vec![m("Will", &pk('1')), m("Will Pfleger", &pk('2'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("cc @Will Pfleger on this", &members),
|
||||
vec![pk('2')]
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("cc @Will on this", &members),
|
||||
vec![pk('1')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_mid_token_does_not_match() {
|
||||
// `@` must sit at a left boundary (start / whitespace / `(`). An email-ish
|
||||
// or mid-token `@` (`alice@Robby`) must not wake Robby.
|
||||
let members = vec![m("Robby", &pk('a'))];
|
||||
assert!(resolve_mention_pubkeys("alice@Robby", &members).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_member_does_not_match_inside_longer_word() {
|
||||
// "Sam" is a member; `@Sami` (no "Sami" member) must not wake Sam.
|
||||
let members = vec![m("Sam", &pk('3'))];
|
||||
assert!(resolve_mention_pubkeys("hi @Sami", &members).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_with_spaces_and_punctuation() {
|
||||
let members = vec![m("Lep (Subagent)", &pk('4'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@Lep (Subagent) take it", &members),
|
||||
vec![pk('4')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn em_dash_terminates_name() {
|
||||
// Generated prose often writes `@Name—text` with no space.
|
||||
let members = vec![m("Robby", &pk('a'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@Robby—please look", &members),
|
||||
vec![pk('a')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_member_name() {
|
||||
let members = vec![m("Zoë", &pk('5'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("welcome @Zoë!", &members),
|
||||
vec![pk('5')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercase_expansion_does_not_shift_later_mentions() {
|
||||
// Regression (Wren's redteam counterexample): `İ` (U+0130) lowercases to
|
||||
// TWO code points (`i` + U+0307). A design that pre-lowercases the whole
|
||||
// text and indexes it in parallel with the original chars desyncs after
|
||||
// the expansion, dropping every later valid mention. `@İ @Robby` must
|
||||
// resolve BOTH members, in order.
|
||||
let members = vec![m("İ", &pk('c')), m("Robby", &pk('a'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@İ @Robby", &members),
|
||||
vec![pk('c'), pk('a')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharp_s_matches_case_insensitively() {
|
||||
// `ẞ` (U+1E9E capital sharp s) lowercases to `ß` (U+00DF) — a single
|
||||
// char, NOT `ss` (that's uppercase/full-case-fold behavior, not
|
||||
// `char::to_lowercase`). Covers non-ASCII case-insensitive matching, and
|
||||
// that a later mention still resolves after it.
|
||||
let members = vec![m("ẞ", &pk('d')), m("Max", &pk('b'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@ẞ and @Max", &members),
|
||||
vec![pk('d'), pk('b')]
|
||||
);
|
||||
}
|
||||
|
||||
// Adversarial rows from Quinn's re-review (the two `ẞ→ss`-premised ones were
|
||||
// dropped as vacuous — `ẞ` lowercases to `ß`, one char, so it never inverts
|
||||
// original-vs-folded length; only `İ` does).
|
||||
|
||||
#[test]
|
||||
fn combining_mark_in_name_matches() {
|
||||
// A name carrying a combining mark (`é` as `e` + U+0301) matches the same
|
||||
// sequence in text (1:1 folding) and terminates cleanly.
|
||||
let members = vec![m("Jos\u{0065}\u{0301}", &pk('4'))]; // "José" decomposed
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("hi @Jos\u{0065}\u{0301}!", &members),
|
||||
vec![pk('4')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_name_at_trailing_boundary() {
|
||||
// Expansion at the very end: `@İ` with nothing after must match, and
|
||||
// `@İx` (x extends the name, no `İx` member) must NOT match `İ`.
|
||||
let members = vec![m("İ", &pk('5'))];
|
||||
assert_eq!(resolve_mention_pubkeys("@İ", &members), vec![pk('5')]);
|
||||
assert!(resolve_mention_pubkeys("@İx", &members).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_to_back_at_is_one_mention() {
|
||||
// `@İ@Robby`: the second `@` is preceded by a name char (`İ`), so it is
|
||||
// NOT at a left boundary — same rule as `alice@Robby`. Back-to-back
|
||||
// `@a@b` is intentionally one mention; a separator is required to wake
|
||||
// both. The expanding first name (`İ` → 2 folded chars) also proves the
|
||||
// span accounting stays in original coordinates.
|
||||
let members = vec![m("İ", &pk('5')), m("Robby", &pk('a'))];
|
||||
assert_eq!(resolve_mention_pubkeys("@İ@Robby", &members), vec![pk('5')]);
|
||||
// ASCII control: same shape, same outcome — it's the boundary rule, not
|
||||
// a Unicode span-accounting bug.
|
||||
let ascii = vec![m("Sam", &pk('6')), m("Robby", &pk('a'))];
|
||||
assert_eq!(resolve_mention_pubkeys("@Sam@Robby", &ascii), vec![pk('6')]);
|
||||
// With a separator, both wake.
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@İ @Robby", &members),
|
||||
vec![pk('5'), pk('a')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_name_wakes_no_one() {
|
||||
// Six "Fizz" agents (real team case) with distinct pubkeys → tag none.
|
||||
let members = vec![
|
||||
m("Fizz", &pk('6')),
|
||||
m("Fizz", &pk('7')),
|
||||
m("Fizz", &pk('8')),
|
||||
];
|
||||
assert!(resolve_mention_pubkeys("@Fizz status?", &members).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_name_same_pubkey_is_not_ambiguous() {
|
||||
// Same identity listed twice (e.g. two channels) is not a conflict.
|
||||
let members = vec![m("Fizz", &pk('6')), m("Fizz", &pk('6'))];
|
||||
assert_eq!(resolve_mention_pubkeys("@Fizz go", &members), vec![pk('6')]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_repeated_mentions_in_first_appearance_order() {
|
||||
let members = vec![m("Robby", &pk('a')), m("Max", &pk('b'))];
|
||||
assert_eq!(
|
||||
resolve_mention_pubkeys("@Max then @Robby then @Max again", &members),
|
||||
vec![pk('b'), pk('a')]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
//! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message`
|
||||
//! that mentions a channel member by name (`@Name`) must emit a `p` tag for
|
||||
//! that member so ACP agent wake (`event_mentions_agent`, p-tag gated) fires.
|
||||
//!
|
||||
//! Postgres-gated like the other DB-backed relay tests. Run with:
|
||||
//! `cargo test -p buzz-relay --lib workflow_sink -- --ignored`
|
||||
use super::*;
|
||||
use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole};
|
||||
use buzz_db::CreateCommunityWithOwnerResult;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`.
|
||||
async fn test_state() -> Arc<AppState> {
|
||||
let mut config = crate::config::Config::from_env().expect("default config loads");
|
||||
config.require_relay_membership = false;
|
||||
config.redis_url = "redis://127.0.0.1:1".to_string();
|
||||
let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
|
||||
let db = buzz_db::Db::from_pool(pool.clone());
|
||||
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
|
||||
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
||||
.expect("redis pool");
|
||||
let pubsub = Arc::new(
|
||||
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
|
||||
.await
|
||||
.expect("pubsub manager"),
|
||||
);
|
||||
let audit = buzz_audit::AuditService::new(pool.clone());
|
||||
let auth = buzz_auth::AuthService::new(config.auth.clone());
|
||||
let search = buzz_search::SearchService::new(pool.clone());
|
||||
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
|
||||
db.clone(),
|
||||
buzz_workflow::WorkflowConfig::default(),
|
||||
));
|
||||
let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage");
|
||||
let (state, _audit_shutdown) = AppState::new(
|
||||
config,
|
||||
db,
|
||||
redis_pool,
|
||||
audit,
|
||||
pubsub,
|
||||
auth,
|
||||
search,
|
||||
workflow_engine,
|
||||
nostr::Keys::generate(),
|
||||
media_storage,
|
||||
);
|
||||
Arc::new(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn workflow_send_message_p_tags_mentioned_member() {
|
||||
let state = test_state().await;
|
||||
|
||||
let author = nostr::Keys::generate();
|
||||
let author_hex = author.public_key().to_hex();
|
||||
let agent = nostr::Keys::generate();
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let agent_bytes = agent.public_key().to_bytes().to_vec();
|
||||
|
||||
let host = format!("wf-ptag-{}.example", uuid::Uuid::new_v4().simple());
|
||||
let community = match state
|
||||
.db
|
||||
.create_community_with_owner(&host, &author_hex)
|
||||
.await
|
||||
.expect("create community")
|
||||
{
|
||||
CreateCommunityWithOwnerResult::Created(rec) => rec.id,
|
||||
other => panic!("expected fresh community, got {other:?}"),
|
||||
};
|
||||
|
||||
// Open channel; the creator (author) is bootstrapped as an owner-member.
|
||||
let channel = state
|
||||
.db
|
||||
.create_channel(
|
||||
community,
|
||||
"wf-ptag",
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
&author.public_key().to_bytes(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create channel");
|
||||
|
||||
// The mentioned agent is a real member with a resolvable display name.
|
||||
state
|
||||
.db
|
||||
.ensure_user(community, &agent_bytes)
|
||||
.await
|
||||
.expect("ensure agent user row");
|
||||
state
|
||||
.db
|
||||
.update_user_profile(community, &agent_bytes, Some("Robby"), None, None, None)
|
||||
.await
|
||||
.expect("set agent display name");
|
||||
state
|
||||
.db
|
||||
.add_member(
|
||||
community,
|
||||
channel.id,
|
||||
&agent_bytes,
|
||||
MemberRole::Bot,
|
||||
Some(&author.public_key().to_bytes()),
|
||||
)
|
||||
.await
|
||||
.expect("add agent member");
|
||||
|
||||
let sink = RelayActionSink::new(&state);
|
||||
let event_id_hex = sink
|
||||
.send_message(
|
||||
community,
|
||||
&channel.id.to_string(),
|
||||
"heads up @Robby — please take a look",
|
||||
&author_hex,
|
||||
)
|
||||
.await
|
||||
.expect("send_message");
|
||||
|
||||
let id_bytes = nostr::EventId::from_hex(&event_id_hex)
|
||||
.expect("event id")
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
let stored = state
|
||||
.db
|
||||
.get_event_by_id(community, &id_bytes)
|
||||
.await
|
||||
.expect("query event")
|
||||
.expect("event persisted");
|
||||
|
||||
let p_tag_targets: Vec<&str> = stored
|
||||
.event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p"))
|
||||
.filter_map(|t| t.as_slice().get(1).map(|s| s.as_str()))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
p_tag_targets.contains(&author_hex.as_str()),
|
||||
"author should still be attributed via p tag; got {p_tag_targets:?}"
|
||||
);
|
||||
assert!(
|
||||
p_tag_targets.contains(&agent_hex.as_str()),
|
||||
"mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function MarkdownTable({ children }: { children?: React.ReactNode }) {
|
||||
className="overflow-x-auto rounded-2xl border border-border/70"
|
||||
data-table-block=""
|
||||
>
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<table className="w-max min-w-full border-collapse text-left text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -152,6 +152,68 @@ test("long autolink wraps without widening the timeline", async ({ page }) => {
|
||||
.toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("markdown tables overflow wide content and fill the message when narrow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 900, height: 600 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
const longCell = "WIDE TABLE COLUMN VALUE ".repeat(8);
|
||||
await page.evaluate(
|
||||
({ wide, narrow }) => {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: wide,
|
||||
createdAt,
|
||||
});
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: narrow,
|
||||
createdAt: createdAt + 1,
|
||||
});
|
||||
},
|
||||
{
|
||||
wide: `| ${longCell} | ${longCell} | ${longCell} |\n| --- | --- | --- |\n| ${longCell} | ${longCell} | ${longCell} |`,
|
||||
narrow: "| NARROW TABLE | VALUE |\n| --- | --- |\n| alpha | beta |",
|
||||
},
|
||||
);
|
||||
|
||||
const wideTable = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "WIDE TABLE COLUMN VALUE" })
|
||||
.locator("[data-table-block]");
|
||||
const narrowTable = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "NARROW TABLE" })
|
||||
.locator("[data-table-block]");
|
||||
await expect(wideTable).toBeVisible();
|
||||
await expect(narrowTable).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
wideTable.evaluate(
|
||||
(element) => element.scrollWidth - element.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
narrowTable.evaluate((element) => {
|
||||
const table = element.querySelector("table");
|
||||
return table
|
||||
? Math.abs(table.getBoundingClientRect().width - element.clientWidth)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("supported link previews keep the message link visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user