Skip to content

Commit 4e1ece6

Browse files
committed
test: cover AgentStop E2E in every SDK
Add Node, Python, Rust, and Java replay tests for natural-stop callback delivery and block-driven continuation using the shared hooks_extended snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5fe27cd commit 4e1ece6

4 files changed

Lines changed: 175 additions & 5 deletions

File tree

java/src/test/java/com/github/copilot/HooksTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import org.junit.jupiter.api.BeforeAll;
1919
import org.junit.jupiter.api.Test;
2020

21+
import com.github.copilot.rpc.AgentStopHookInput;
22+
import com.github.copilot.rpc.AgentStopHookOutput;
2123
import com.github.copilot.rpc.MessageOptions;
2224
import com.github.copilot.rpc.PermissionHandler;
2325
import com.github.copilot.rpc.PostToolUseHookInput;
@@ -225,4 +227,44 @@ void testDenyToolExecutionWhenPreToolUseReturnsDeny() throws Exception {
225227
assertEquals(originalContent, Files.readString(testFile), "Denied preToolUse hook should block file edits");
226228
}
227229
}
230+
231+
/**
232+
* Verifies that agent-stop can block a natural stop and enqueue another turn.
233+
*
234+
* @see Snapshot: hooks_extended/should_invoke_agentstop_hook_and_apply_block_response
235+
*/
236+
@Test
237+
void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception {
238+
ctx.configureForTest("hooks_extended", "should_invoke_agentstop_hook_and_apply_block_response");
239+
240+
var inputs = new ArrayList<AgentStopHookInput>();
241+
final String[] sessionIdHolder = new String[1];
242+
var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
243+
.setHooks(new SessionHooks().setOnAgentStop((input, invocation) -> {
244+
assertEquals(sessionIdHolder[0], invocation.getSessionId());
245+
inputs.add(input);
246+
if (inputs.size() == 1) {
247+
return CompletableFuture.completedFuture(new AgentStopHookOutput().setDecision("block")
248+
.setReason("Reply with exactly: AGENT_STOP_CONTINUED"));
249+
}
250+
return CompletableFuture.completedFuture(null);
251+
}));
252+
253+
try (CopilotClient client = ctx.createClient()) {
254+
CopilotSession session = client.createSession(config).get();
255+
sessionIdHolder[0] = session.getSessionId();
256+
257+
var response = session
258+
.sendAndWait(new MessageOptions().setPrompt("Reply with exactly: AGENT_STOP_INITIAL"))
259+
.get(60, TimeUnit.SECONDS);
260+
261+
assertEquals(2, inputs.size());
262+
assertNotEquals(Boolean.TRUE, inputs.get(0).getStopHookActive());
263+
assertEquals(Boolean.TRUE, inputs.get(1).getStopHookActive());
264+
assertEquals("end_turn", inputs.get(0).getStopReason());
265+
assertFalse(inputs.get(0).getTranscriptPath().isBlank());
266+
assertNotNull(response);
267+
assertTrue(response.getData().content().contains("AGENT_STOP_CONTINUED"));
268+
}
269+
}
228270
}

nodejs/test/e2e/hooks_extended.e2e.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
66
import { z } from "zod";
77
import { approveAll, defineTool } from "../../src/index.js";
88
import type {
9+
AgentStopHookInput,
910
ErrorOccurredHookInput,
1011
PostToolUseFailureHookInput,
1112
PostToolUseHookInput,
@@ -260,6 +261,38 @@ describe("Extended session hooks", async () => {
260261
await session.disconnect();
261262
});
262263

264+
it("should invoke agentStop hook and apply block response", async () => {
265+
const inputs: AgentStopHookInput[] = [];
266+
const session = await client.createSession({
267+
onPermissionRequest: approveAll,
268+
hooks: {
269+
onAgentStop: async (input, invocation) => {
270+
expect(invocation.sessionId).toBe(session.sessionId);
271+
inputs.push(input);
272+
if (inputs.length === 1) {
273+
return {
274+
decision: "block",
275+
reason: "Reply with exactly: AGENT_STOP_CONTINUED",
276+
};
277+
}
278+
},
279+
},
280+
});
281+
282+
const response = await session.sendAndWait({
283+
prompt: "Reply with exactly: AGENT_STOP_INITIAL",
284+
});
285+
286+
expect(inputs).toHaveLength(2);
287+
expect(inputs[0].stopHookActive).not.toBe(true);
288+
expect(inputs[1].stopHookActive).toBe(true);
289+
expect(inputs[0].stopReason).toBe("end_turn");
290+
expect(inputs[0].transcriptPath).toBeTruthy();
291+
expect(response?.data.content ?? "").toContain("AGENT_STOP_CONTINUED");
292+
293+
await session.disconnect();
294+
});
295+
263296
it("should allow preToolUse to return modifiedArgs and suppressOutput", async () => {
264297
const inputs: PreToolUseHookInput[] = [];
265298
const session = await client.createSession({

python/e2e/test_hooks_extended_e2e.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
E2E coverage for every handler exposed on ``SessionHooks``:
55
``on_pre_tool_use``, ``on_post_tool_use``, ``on_post_tool_use_failure``,
66
``on_user_prompt_submitted``, ``on_session_start``, ``on_session_end``,
7-
``on_error_occurred``. Output-shape behavior (modifiedPrompt /
7+
``on_error_occurred``, ``on_agent_stop``. Output-shape behavior (modifiedPrompt /
88
additionalContext / errorHandling / modifiedArgs / modifiedResult /
99
sessionSummary) is asserted alongside hook invocation.
1010
"""
@@ -114,6 +114,36 @@ async def on_error_occurred(input_data, invocation):
114114
finally:
115115
await session.disconnect()
116116

117+
async def test_should_invoke_agentstop_hook_and_apply_block_response(
118+
self, ctx: E2ETestContext
119+
):
120+
inputs: list[dict] = []
121+
122+
async def on_agent_stop(input_data, invocation):
123+
assert invocation["session_id"] == session.session_id
124+
inputs.append(input_data)
125+
if len(inputs) == 1:
126+
return {
127+
"decision": "block",
128+
"reason": "Reply with exactly: AGENT_STOP_CONTINUED",
129+
}
130+
return None
131+
132+
session = await ctx.client.create_session(
133+
on_permission_request=PermissionHandler.approve_all,
134+
hooks={"on_agent_stop": on_agent_stop},
135+
)
136+
try:
137+
response = await session.send_and_wait("Reply with exactly: AGENT_STOP_INITIAL")
138+
assert len(inputs) == 2
139+
assert inputs[0].get("stopHookActive") is not True
140+
assert inputs[1].get("stopHookActive") is True
141+
assert inputs[0].get("stopReason") == "end_turn"
142+
assert inputs[0].get("transcriptPath")
143+
assert "AGENT_STOP_CONTINUED" in (response.data.content or "")
144+
finally:
145+
await session.disconnect()
146+
117147
async def test_should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput(
118148
self, ctx: E2ETestContext
119149
):

rust/tests/e2e/hooks_extended.rs

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use std::sync::Arc;
2+
use std::sync::atomic::{AtomicUsize, Ordering};
23

34
use async_trait::async_trait;
45
use github_copilot_sdk::handler::ApproveAllHandler;
56
use github_copilot_sdk::hooks::{
6-
ErrorOccurredInput, ErrorOccurredOutput, HookContext, PostToolUseFailureInput,
7-
PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, PreToolUseInput,
8-
PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, SessionStartInput,
9-
SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput,
7+
AgentStopInput, AgentStopOutput, ErrorOccurredInput, ErrorOccurredOutput, HookContext,
8+
PostToolUseFailureInput, PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput,
9+
PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks,
10+
SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput,
1011
};
1112
use github_copilot_sdk::tool::ToolHandler;
1213
use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult};
@@ -281,6 +282,49 @@ async fn should_register_erroroccurred_hook() {
281282
.await;
282283
}
283284

285+
#[tokio::test]
286+
async fn should_invoke_agentstop_hook_and_apply_block_response() {
287+
with_e2e_context(
288+
"hooks_extended",
289+
"should_invoke_agentstop_hook_and_apply_block_response",
290+
|ctx| {
291+
Box::pin(async move {
292+
ctx.set_default_copilot_user();
293+
let (tx, mut rx) = mpsc::unbounded_channel();
294+
let client = ctx.start_client().await;
295+
let session = client
296+
.create_session(
297+
ctx.approve_all_session_config()
298+
.with_hooks(Arc::new(AgentStopHooks {
299+
tx,
300+
call_count: AtomicUsize::new(0),
301+
})),
302+
)
303+
.await
304+
.expect("create session");
305+
306+
let answer = session
307+
.send_and_wait("Reply with exactly: AGENT_STOP_INITIAL")
308+
.await
309+
.expect("send")
310+
.expect("assistant message");
311+
let first = recv_with_timeout(&mut rx, "first agentStop hook").await;
312+
let second = recv_with_timeout(&mut rx, "second agentStop hook").await;
313+
314+
assert_ne!(first.stop_hook_active, Some(true));
315+
assert_eq!(second.stop_hook_active, Some(true));
316+
assert_eq!(first.stop_reason.as_deref(), Some("end_turn"));
317+
assert!(first.transcript_path.is_some());
318+
assert!(assistant_message_content(&answer).contains("AGENT_STOP_CONTINUED"));
319+
320+
session.disconnect().await.expect("disconnect session");
321+
client.stop().await.expect("stop client");
322+
})
323+
},
324+
)
325+
.await;
326+
}
327+
284328
#[tokio::test]
285329
async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() {
286330
with_e2e_context(
@@ -441,6 +485,27 @@ struct RecordingHooks {
441485
post_tool_failure: Option<mpsc::UnboundedSender<PostToolUseFailureInput>>,
442486
}
443487

488+
struct AgentStopHooks {
489+
tx: mpsc::UnboundedSender<AgentStopInput>,
490+
call_count: AtomicUsize,
491+
}
492+
493+
#[async_trait]
494+
impl SessionHooks for AgentStopHooks {
495+
async fn on_agent_stop(
496+
&self,
497+
input: AgentStopInput,
498+
ctx: HookContext,
499+
) -> Option<AgentStopOutput> {
500+
assert!(!ctx.session_id.as_str().is_empty());
501+
let _ = self.tx.send(input);
502+
(self.call_count.fetch_add(1, Ordering::SeqCst) == 0).then(|| AgentStopOutput {
503+
decision: Some("block".to_string()),
504+
reason: Some("Reply with exactly: AGENT_STOP_CONTINUED".to_string()),
505+
})
506+
}
507+
}
508+
444509
impl RecordingHooks {
445510
fn session_start(
446511
tx: mpsc::UnboundedSender<SessionStartInput>,

0 commit comments

Comments
 (0)