Skip to content

Commit 00e8ed6

Browse files
benbrandtclaude
andauthored
fix(test): reject empty v2 prompts in Testy and simple_agent_v2 (#463)
ACP v2 forbids clients from sending an empty prompt array and lets agents reject one with -32602 without inserting it. Testy v2 and the simple_agent_v2 example now do so during prompt admission. Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5af768 commit 00e8ed6

4 files changed

Lines changed: 103 additions & 1 deletion

File tree

‎md/testy.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ replay was not requested. Neither is a replayed historical change, and the
116116
snapshot is not another completed turn.
117117

118118
Prompts may contain only the baseline `text` and `resource_link` content blocks; any other block
119-
is rejected with `-32602` before the turn begins. `session/list` returns every session in one page
119+
is rejected with `-32602` before the turn begins. An empty `prompt` array is likewise rejected with
120+
`-32602` without inserting a user message. `session/list` returns every session in one page
120121
and never issues `nextCursor`, so any `cursor` is rejected with `-32602`.
121122

122123
A prompt that arrives while the session has foreground work is held, not rejected. Held prompts

‎src/agent-client-protocol-test/src/testy/v2.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,11 +681,15 @@ impl ConnectTo<Client> for V2Testy {
681681
}
682682
}
683683

684+
/// Clients must not send an empty prompt, and Testy rejects one with `-32602`.
684685
/// Testy advertises no prompt capabilities, so only the baseline `text` and `resource_link`
685686
/// blocks are supported. v2 Agents must reject any other block with `-32602`.
686687
fn validate_prompt_content(
687688
blocks: &[acp::ContentBlock],
688689
) -> Result<(), agent_client_protocol::Error> {
690+
if blocks.is_empty() {
691+
return Err(invalid_params("prompt must not be empty"));
692+
}
689693
for block in blocks {
690694
match block {
691695
acp::ContentBlock::Text(_) | acp::ContentBlock::ResourceLink(_) => {}

‎src/agent-client-protocol-test/tests/testy_v2.rs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,69 @@ async fn testy_v2_rejects_unadvertised_prompt_content() {
654654
.expect("Testy v2 prompt content validation failed");
655655
}
656656

657+
#[tokio::test(flavor = "current_thread")]
658+
async fn testy_v2_rejects_empty_prompt_without_inserting_it() {
659+
let (event_tx, mut event_rx) = unbounded_channel();
660+
let update_tx = event_tx.clone();
661+
Client
662+
.v2()
663+
.on_receive_notification(
664+
async move |update: v2::UpdateSessionNotification,
665+
_connection: V2ConnectionTo<agent_client_protocol::Agent>| {
666+
update_tx
667+
.send(TestEvent::Update(Box::new(update)))
668+
.map_err(Error::into_internal_error)
669+
},
670+
agent_client_protocol::on_receive_notification!(),
671+
)
672+
.connect_with(Testy::new().v2(), async move |connection| {
673+
connection
674+
.send_request(v2::InitializeRequest::new(
675+
ProtocolVersion::V2,
676+
implementation(),
677+
))
678+
.block_task()
679+
.await?;
680+
let session_id = connection
681+
.send_request(v2::NewSessionRequest::new(PathBuf::from("/tmp")))
682+
.block_task()
683+
.await?
684+
.session_id;
685+
686+
let error = connection
687+
.send_request(v2::PromptRequest::new(session_id.clone(), vec![]))
688+
.block_task()
689+
.await
690+
.expect_err("an empty prompt should be rejected");
691+
assert_eq!(error.code, agent_client_protocol::ErrorCode::InvalidParams);
692+
693+
let accepted = connection
694+
.send_request(v2::PromptRequest::new(
695+
session_id,
696+
vec![TestyCommand::Greet.to_prompt().into()],
697+
))
698+
.block_task()
699+
.await
700+
.expect("the session accepts a prompt after an empty one is rejected");
701+
702+
// The first user message belongs to the accepted prompt, so the
703+
// empty prompt inserted nothing.
704+
loop {
705+
match next_update(&mut event_rx).await.update {
706+
v2::SessionUpdate::UserMessage(message) => {
707+
assert_eq!(message.message_id, accepted.message_id);
708+
break;
709+
}
710+
v2::SessionUpdate::SessionInfoUpdate(_) => {}
711+
other => panic!("expected the accepted user message, got {other:?}"),
712+
}
713+
}
714+
Ok(())
715+
})
716+
.await
717+
.expect("Testy v2 empty prompt validation failed");
718+
}
719+
657720
#[tokio::test(flavor = "current_thread")]
658721
async fn testy_v2_unimplemented_scenarios_end_with_error_stop_reason() {
659722
let (event_tx, mut event_rx) = unbounded_channel();

‎src/agent-client-protocol/examples/simple_agent_v2.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ impl EchoAgent {
146146
.get_mut(session_id)
147147
.filter(|session| session.active)
148148
.ok_or_else(|| session_not_found(session_id))?;
149+
// Clients must not send an empty prompt; reject one without inserting it.
150+
if prompt.is_empty() {
151+
return Err(invalid_params("prompt must not be empty"));
152+
}
149153
// No prompt capabilities are advertised, so only the baseline `text` and
150154
// `resource_link` blocks are supported. Anything else must be rejected.
151155
if prompt.iter().any(|block| {
@@ -676,3 +680,33 @@ fn reject_mcp_servers(mcp_servers: &[v2::McpServer]) -> Result<()> {
676680
))
677681
}
678682
}
683+
684+
#[cfg(test)]
685+
mod tests {
686+
use super::*;
687+
688+
#[test]
689+
fn rejects_empty_prompt_without_inserting_it() {
690+
let agent = EchoAgent::default();
691+
let session_id = agent
692+
.create_session(v2::NewSessionRequest::new("/tmp"))
693+
.expect("session should be created");
694+
695+
let Err(error) = agent.admit_prompt(&session_id, &[]) else {
696+
panic!("an empty prompt should be rejected");
697+
};
698+
assert_eq!(error.code, agent_client_protocol::ErrorCode::InvalidParams);
699+
{
700+
let state = agent.state.lock().expect("session state lock poisoned");
701+
let session = &state.sessions[&session_id];
702+
assert!(!session.foreground_work, "no work should start");
703+
assert!(session.held_prompts.is_empty(), "nothing should be held");
704+
assert!(session.history.is_empty(), "nothing should be inserted");
705+
}
706+
707+
assert!(matches!(
708+
agent.admit_prompt(&session_id, &[v2::ContentBlock::from("hello")]),
709+
Ok(PromptAdmission::Inserted)
710+
));
711+
}
712+
}

0 commit comments

Comments
 (0)