fix: preserve Console Notify ACK identity and request ID limits

This commit is contained in:
2026-10-30 00:00:05 +09:00
parent 87a1b6f1eb
commit c215c72873
14 changed files with 383 additions and 4 deletions
+2 -1
View File
@@ -34788,7 +34788,8 @@ pub(crate) fn authorize_browser_worker_method(
notification_request_id,
message,
} => Ok(protocol::Method::NotifyTracked {
notification_request_id: format!("browser-ws-notify:{notification_request_id}"),
receipt_scope: protocol::NotificationReceiptScope::BrowserConsole,
notification_request_id,
message,
source: source.clone(),
}),
@@ -777,7 +777,10 @@ fn publish_chat_fixture_profile(api: &WorkspaceApi) {
}
impl ConnectedChat {
async fn new(model: ChatModel, app_layer: impl FnOnce(Router) -> Router) -> Self {
async fn new(
model: impl agen::llm_client::LlmClient + Clone + 'static,
app_layer: impl FnOnce(Router) -> Router,
) -> Self {
const RUNTIME: &str = "connected-chat";
let dir = tempfile::tempdir().unwrap();
let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -1270,3 +1273,239 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
);
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
}
#[derive(Clone)]
struct NotifyObservationModel(tokio::sync::mpsc::Sender<String>);
#[async_trait::async_trait]
impl agen::llm_client::LlmClient for NotifyObservationModel {
async fn stream(
&self,
request: agen::llm_client::Request,
) -> std::result::Result<agen::llm_client::ResponseStream, agen::llm_client::ClientError> {
use agen::llm_client::event::{Event, StopReason};
self.0.send(format!("{:?}", request.items)).await.unwrap();
Ok(Box::pin(futures::stream::iter([
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "internal only")),
Ok(Event::text_block_stop(0, Some(StopReason::EndTurn))),
])))
}
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
Box::new(self.clone())
}
}
/// Real authenticated Backend WS -> signed Runtime WS -> controller -> SQLite,
/// followed by the production Console admission tracker (Deno, the normal test
/// runner prerequisite). Only the model is scripted; no browser rendering claim.
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await;
let subject = open_subjektiv_store(&f.api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap())
.unwrap()
.id;
let operations = [
"session.record",
"memory.resident.read",
"memory.resident.supply",
"journal.startup.read",
"journal.startup.supply",
"chat.read",
"chat.send",
];
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"notify-console-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
let conversation = f
.post(
&conversations_path(),
json!({"operation_id":"notify-console-conversation","subject_id":subject}),
)
.await;
let c = conversation["id"].as_str().unwrap();
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
let worker = created["worker_id"].as_str().unwrap();
let chat = f
.post(
&messages_path(c),
json!({"operation_id":"notify-console-chat","text":"HTTP_CHAT_RECEIPT"}),
)
.await;
assert_eq!(chat["delivery"]["state"], "accepted");
// Deliberately reuse the exact Runtime ID of the accepted HTTP chat input.
// Its receipt must not reject/consume a different Console message.
let canonical = serde_json::to_vec(&(
format!(
"account:{}",
conversation["participant_account_id"].as_str().unwrap()
),
format!("chat-message:{}", chat["message"]["id"].as_str().unwrap()),
))
.unwrap();
let digest = sha2::Sha256::digest(canonical)
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let collision_id = format!("runtime-input:notify:{digest}");
let url = format!(
"{}/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}/protocol/ws",
f.url.replacen("http://", "ws://", 1)
);
let mut request = url.into_client_request().unwrap();
request.headers_mut().insert(
"authorization",
format!("Bearer {}", f.owner).parse().unwrap(),
);
let (mut ws, _) = connect_async(request).await.unwrap();
let cases = [
(collision_id, "WS_ISOLATED_FROM_HTTP", true),
("a".repeat(128), "WS_ASCII_LIMIT", true),
("é".repeat(64), "WS_UTF8_LIMIT", true),
("b".repeat(129), "WS_ASCII_OVER_LIMIT", false),
(format!("{}x", "é".repeat(64)), "WS_UTF8_OVER_LIMIT", false),
("console-next-id".into(), "WS_NEXT_DRAFT", true),
];
let mut exchanges = Vec::new();
for (id, text, accepted) in &cases {
let method = protocol::Method::Notify {
notification_request_id: id.clone(),
message: (*text).into(),
};
let ack = console_notify_ack(&mut ws, &method).await;
assert_eq!(
matches!(ack, protocol::Event::NotificationAccepted { .. }),
*accepted,
"{ack:?}"
);
let mut delivered_ack = ack;
if *accepted {
// Lose the first ACK at the Console boundary, resend the exact wire
// input, and deliver the real retry ACK to the actual tracker below.
delivered_ack = console_notify_ack(&mut ws, &method).await;
assert!(matches!(
delivered_ack,
protocol::Event::NotificationAccepted { .. }
));
}
exchanges.push(
json!({"method":method,"event":delivered_ack,"accepted":accepted,"retry":accepted}),
);
if *accepted {
let changed = protocol::Method::Notify {
notification_request_id: id.clone(),
message: format!("CHANGED_{text}"),
};
let rejected = console_notify_ack(&mut ws, &changed).await;
assert!(
matches!(rejected, protocol::Event::NotificationRejected { .. }),
"{rejected:?}"
);
exchanges
.push(json!({"method":changed,"event":rejected,"accepted":false,"retry":false}));
}
}
let mut console = tokio::process::Command::new("deno");
console
.kill_on_drop(true)
.args(["run", "--quiet"])
.arg(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/support/console_notify_admissions.ts"),
)
.arg(serde_json::to_string(&exchanges).unwrap());
let output = tokio::time::timeout(std::time::Duration::from_secs(30), console.output())
.await
.expect("Console admission tracker deadline")
.expect("Deno (normal test runner prerequisite)");
assert!(
output.status.success(),
"production Console admission tracker failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
tokio::time::timeout(std::time::Duration::from_secs(30), async {
while !inputs
.recv()
.await
.expect("real model input")
.contains("WS_NEXT_DRAFT")
{}
})
.await
.expect("accepted Console input must reach next model request");
let history = session_store::read_retained_session_snapshot(
&f._dir.path().join("runtime/workers").join(worker),
&format!("worker-runtime-{worker}"),
2 * 1024 * 1024,
)
.unwrap();
let notifications = history
.snapshot
.entries
.iter()
.filter_map(|entry| match &entry.data {
protocol::SessionSnapshotEntryData::SystemItem {
item_kind, content, ..
} if item_kind == "notification" => Some(content.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
for (_, text, accepted) in cases {
assert_eq!(
notifications.matches(text).count(),
usize::from(accepted),
"one history input per accepted ID, none for rejection: {notifications}"
);
assert!(!notifications.contains(&format!("CHANGED_{text}")));
}
assert_eq!(notifications.matches("HTTP_CHAT_RECEIPT").count(), 1);
}
async fn console_notify_ack(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
method: &protocol::Method,
) -> protocol::Event {
let protocol::Method::Notify {
notification_request_id: expected,
..
} = method
else {
panic!("Console Notify required")
};
ws.send(TungsteniteMessage::Text(
protocol::stream::encode_method(method).unwrap().into(),
))
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop {
let frame = ws.next().await.expect("real protocol WS frame").unwrap();
if let TungsteniteMessage::Text(text) = frame {
let event = protocol::stream::decode_event(&text).unwrap();
match &event {
protocol::Event::NotificationAccepted {
notification_request_id,
}
| protocol::Event::NotificationRejected {
notification_request_id,
..
} => {
assert_eq!(
notification_request_id, expected,
"client-visible ACK must preserve original request identity"
);
return event;
}
protocol::Event::Error { .. } => panic!("unexpected protocol error: {event:?}"),
_ => {}
}
}
}
})
.await
.expect("Console Notify ACK deadline")
}