Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 9 additions & 13 deletions crates/webcodex-runner/src/webcodex_runner/lsp/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1541,7 +1541,7 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() {
initialize_timeout: Duration::from_millis(80),
shutdown_timeout,
idle_ttl: Duration::from_secs(60),
background_reaper: true,
background_reaper: false,
});

let started = Instant::now();
Expand All @@ -1550,14 +1550,16 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() {
.unwrap_err();
let elapsed = started.elapsed();
assert!(
matches!(
error,
LspError::RestartExhausted(_) | LspError::InitializeFailed(_)
),
matches!(error, LspError::RestartExhausted(_)),
"unexpected error: {error:?}"
);
// Two attempts each: initialize_timeout + shutdown_timeout, plus slack.
// Must stay well below using the multi-second DEFAULT_SHUTDOWN_TIMEOUT.
// The supervisor consumes its one restart after the first initialize
// failure. The child-owned start marker is not an authoritative attempt
// counter: on Windows a newly spawned process can remain unscheduled past
// this intentionally tiny initialize deadline and be killed before main()
// writes the marker. RestartExhausted proves the second attempt was
// consumed; this test owns only the configured cleanup-budget invariant.
// Both attempts must stay well below using the multi-second default.
assert!(
elapsed < Duration::from_secs(2),
"initialize cleanup used an oversized budget: {elapsed:?}"
Expand All @@ -1566,12 +1568,6 @@ fn lsp_initialize_timeout_cleanup_uses_configured_shutdown_budget() {
elapsed < DEFAULT_SHUTDOWN_TIMEOUT.saturating_mul(2),
"cleanup appears to use DEFAULT_SHUTDOWN_TIMEOUT: {elapsed:?}"
);
let starts = fs::read_to_string(&marker)
.unwrap_or_default()
.lines()
.filter(|line| line.starts_with("start:"))
.count();
assert_eq!(starts, 2);
for line in fs::read_to_string(&marker).unwrap_or_default().lines() {
if let Some(rest) = line.strip_prefix("start:") {
if let Some(pid) = rest.split(':').next().and_then(|p| p.parse::<u32>().ok()) {
Expand Down
27 changes: 25 additions & 2 deletions src/mcp_tests/http_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,8 +694,31 @@ async fn http_mcp_accepts_chatgpt_2025_11_25_protocol_header() {
assert!(body["result"].get("resultType").is_none());
}

#[tokio::test]
async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution() {
#[test]
fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution() {
// This integration fixture intentionally keeps several large MCP response
// trees alive across many await points. Run the test harness itself on an
// explicit stack so CI/libtest thread-stack variance cannot abort the
// process; production request handling is unchanged and each HTTP request
// still executes through the normal Server runtime path.
std::thread::Builder::new()
.name("mcp-request-scoped-ack-test".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build ACK integration test runtime");
runtime.block_on(
http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution_body(),
);
})
.expect("spawn ACK integration test thread")
.join()
.expect("ACK integration test thread panicked");
}

async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution_body() {
let config = test_config(Some("secret"));
let (_tmp, db) = test_db();
let runtime = Arc::new(test_runtime_with_surface(ModelSurface::FullOperatorRuntime));
Expand Down
67 changes: 40 additions & 27 deletions src/runtime_http_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2112,10 +2112,12 @@ async fn oauth2_tools_call_scope_matrix() {
async fn session_tools_oauth_scope_policy() {
let (_tmp, service, tokens) = phase2_oauth_service_with_scopes(&[
crate::auth::SCOPE_RUNTIME_READ,
crate::auth::SCOPE_SESSION_COLLABORATE,
crate::auth::SCOPE_PROJECT_READ,
]);
let runtime_read = &tokens[0];
let project_read = &tokens[1];
let session_collaborate = &tokens[1];
let project_read = &tokens[2];

let (status, body, _) = oauth_tools_call(
&service,
Expand All @@ -2127,33 +2129,44 @@ async fn session_tools_oauth_scope_policy() {
assert_eq!(status, StatusCode::OK, "body: {body}");
let session_id = body["output"]["session_id"].as_str().unwrap();

for (tool, params) in [
("session_summary", json!({"session_id": session_id})),
(
"post_session_message",
json!({"session_id": session_id, "kind": "note", "message": "oauth metadata"}),
),
] {
let (status, body, _) = oauth_tools_call(&service, runtime_read, tool, params).await;
assert_eq!(status, StatusCode::OK, "{tool}: {body}");
}
let (status, body, _) = oauth_tools_call(
&service,
runtime_read,
"session_summary",
json!({"session_id": session_id}),
)
.await;
assert_eq!(status, StatusCode::OK, "session_summary: {body}");
let (status, body, _) = oauth_tools_call(
&service,
session_collaborate,
"post_session_message",
json!({"session_id": session_id, "kind": "note", "message": "oauth metadata"}),
)
.await;
assert_eq!(status, StatusCode::OK, "post_session_message: {body}");

for (tool, params) in [
("start_session", json!({})),
(
"post_session_message",
json!({"session_id": "wc_sess_missing", "kind": "note", "message": "denied"}),
),
] {
let (status, body, challenge) =
oauth_tools_call(&service, project_read, tool, params).await;
assert_oauth_scope_rejected(
status,
&body,
challenge.as_deref(),
Some(crate::auth::SCOPE_RUNTIME_READ),
);
}
let (status, body, challenge) =
oauth_tools_call(&service, project_read, "start_session", json!({})).await;
assert_oauth_scope_rejected(
status,
&body,
challenge.as_deref(),
Some(crate::auth::SCOPE_RUNTIME_READ),
);
let (status, body, challenge) = oauth_tools_call(
&service,
runtime_read,
"post_session_message",
json!({"session_id": "wc_sess_missing", "kind": "note", "message": "denied"}),
)
.await;
assert_oauth_scope_rejected(
status,
&body,
challenge.as_deref(),
Some(crate::auth::SCOPE_SESSION_COLLABORATE),
);
}

#[tokio::test]
Expand Down
2 changes: 2 additions & 0 deletions src/tool_runtime/registry/output_schemas/coding_tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,14 @@ fn startup_workflow_schema() -> Value {
"session_context_ack": {"type": "string", "maxLength": 640},
"session_recording": {"type": "string", "maxLength": 720},
"session_message_ack": {"type": "string", "maxLength": 720},
"session_message_resolution": {"type": "string", "maxLength": 480},
"normal_closeout": {"type": "string", "maxLength": 480}
},
"required": [
"session_context_ack",
"session_recording",
"session_message_ack",
"session_message_resolution",
"normal_closeout"
],
"additionalProperties": false
Expand Down
6 changes: 3 additions & 3 deletions src/tool_runtime/startup_brief.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ pub(crate) fn builtin_coding_workflow_projection() -> Value {
"role_selection": "Apply a named role only when the task says so; role guidance creates no Session mode or authority.",
"model_protocol": {
"session_context_ack": "Schema has ack_session_context_revision: copy latest returned session_context_revision exactly; never increment/derive. No returned revision: keep ACK. If unavailable/unknown, omit. Missing/stale ACK is nonblocking.",
"session_recording": "After work_on_project creates or continues an execution Workflow Session, when a later WebCodex schema exposes recording_session_id, keep passing that execution/recording Session as recording_session_id. It is recorder provenance/context only: a concrete business session_id may target a different Session, and recording_session_id grants no business authority.",
"session_message_ack": "When session_attention returns open requires_ack guidance still present in the model context, keep echoing those message ids in ack_session_message_ids on later calls. This ACK is request-scoped model-context proof only: it does not resolve messages, grant authority, or gate execution; missing/stale ACK remains nonblocking.",
"session_message_resolution": "After a non-todo Session message is already handled, a stateless MCP schema may expose session_message_resolution. Piggyback {message_id, resolution} on the next ordinary WebCodex call together with recording_session_id instead of making a separate resolve call. For requires_ack guidance, echo the same id in ack_session_message_ids on that request. The target is always the exact recording Session; the wrapper field is removed before concrete tool parsing. Do not use it to predict the success of the current tool call. Todo completion still uses complete_session_message.",
"session_recording": "When work_on_project creates or continues a Workflow Session, pass it as recording_session_id. It is recorder provenance/context only: business session_id may target a different Session; recording_session_id grants no business authority.",
"session_message_ack": "When session_attention shows open requires_ack guidance still in context, echo ids in ack_session_message_ids. This is request-scoped model-context proof only: it does not resolve messages, grant authority, or gate execution; missing/stale ACK remains nonblocking.",
"session_message_resolution": "For a handled non-todo message, put session_message_resolution {message_id,resolution} on the next ordinary WebCodex call with recording_session_id; for requires_ack also echo ack_session_message_ids. It targets only that Session. Do not use it to predict the main call. Todos use complete_session_message.",
"normal_closeout": "Normal success: finish_coding_task(summary_only=true); full closeout only for unresolved validation/evidence or handoff/debug detail."
},
"roles": {
Expand Down
1 change: 1 addition & 0 deletions src/tool_runtime/tests/collaboration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,7 @@ async fn project_scoped_session_authority_rejects_recycled_project_identity() {
"recycled-authority-a",
&[
crate::auth::SCOPE_RUNTIME_READ,
crate::auth::SCOPE_SESSION_COLLABORATE,
crate::auth::SCOPE_PROJECT_READ,
crate::auth::SCOPE_PROJECT_WRITE,
crate::auth::SCOPE_JOB_RUN,
Expand Down
1 change: 1 addition & 0 deletions src/tool_runtime/tests/support/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub(in crate::tool_runtime::tests) fn shared_key_auth_context(
role: Some("shared-key".to_string()),
scopes: vec![
crate::auth::SCOPE_RUNTIME_READ.to_string(),
crate::auth::SCOPE_SESSION_COLLABORATE.to_string(),
crate::auth::SCOPE_PROJECT_READ.to_string(),
crate::auth::SCOPE_PROJECT_WRITE.to_string(),
crate::auth::SCOPE_JOB_RUN.to_string(),
Expand Down
Loading