Skip to content

Commit 2d249c5

Browse files
committed
fix(eve): cancellation - align accepted response contract
Signed-off-by: Andrew Barba <barba@hey.com>
1 parent e245ac5 commit 2d249c5

28 files changed

Lines changed: 170 additions & 89 deletions

.changeset/eve-channel-cancel-turn-route.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"eve": patch
33
---
44

5-
Added `POST /eve/v1/session/:sessionId/cancel` to the eve HTTP channel for requesting cancellation of an in-flight turn. The optional `{ turnId }` body limits the request to the turn the caller observed; the response reports `"cancelling"` when a cancellation hook accepts it or the benign `"no_active_turn"` when no resumable target exists.
5+
Added `POST /eve/v1/session/:sessionId/cancel` to the eve HTTP channel for requesting cancellation of an in-flight turn. The optional `{ turnId }` body limits the request to the turn the caller observed; the response reports `"accepted"` when a cancellation hook accepts it or the benign `"no_active_turn"` when no resumable target exists.

docs/channels/eve.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ Cancel a session's in-flight turn with an empty body, or scope the cancel to the
4949

5050
```bash
5151
curl -X POST https://<deployment>/eve/v1/session/ses_01h.../cancel
52-
# {"ok":true,"sessionId":"ses_01h...","status":"cancelling"}
52+
# {"ok":true,"sessionId":"ses_01h...","status":"accepted"}
5353
```
5454

55-
Cancellation is asynchronous: `"cancelling"` means a cancellation hook accepted the request. Confirm the effective outcome on the stream as `turn.cancelled` followed by `session.waiting` — never as a failure. Active local and remote subagents are cancelled recursively before the parent settles; their own streams carry their cancellation boundaries, and cancelled work does not emit `subagent.completed` on the parent. Content emitted before the cancel stays on the event stream, while durable model history keeps only content that had already settled. The session accepts the next message normally. When there is no resumable cancellation target (including an unknown session, a settled turn, or a duplicate cancel), the route responds with `"no_active_turn"` — also a success, so a stop button can fire and forget. A `turnId` naming any other turn is accepted and consumed as a no-op, so a guarded cancel racing a turn boundary cannot stop a turn the caller never saw.
55+
Cancellation is asynchronous: `"accepted"` means a cancellation hook accepted the request. Confirm the effective outcome on the stream as `turn.cancelled` followed by `session.waiting` — never as a failure. Active local and remote subagents are cancelled recursively before the parent settles; their own streams carry their cancellation boundaries, and cancelled work does not emit `subagent.completed` on the parent. Content emitted before the cancel stays on the event stream, while durable model history keeps only content that had already settled. The session accepts the next message normally. When there is no resumable cancellation target (including an unknown session, a settled turn, or a duplicate cancel), the route responds with `"no_active_turn"` — also a success, so a stop button can fire and forget. A `turnId` naming any other turn is accepted and consumed as a no-op, so a guarded cancel racing a turn boundary cannot stop a turn the caller never saw.
5656

5757
See [Sessions, runs & streaming](../concepts/sessions-runs-and-streaming) for the full request and stream flow, including the complete event set.
5858

docs/concepts/sessions-runs-and-streaming.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ POST to the session's cancel endpoint to stop the turn that is currently running
9595

9696
```bash
9797
curl -X POST http://127.0.0.1:3000/eve/v1/session/<sessionId>/cancel
98-
# {"ok":true,"sessionId":"<sessionId>","status":"cancelling"}
98+
# {"ok":true,"sessionId":"<sessionId>","status":"accepted"}
9999
```
100100

101-
`"cancelling"` means a cancellation hook accepted the request. Confirm cancellation on the stream as `turn.cancelled` followed by `session.waiting`; the session then accepts the next message normally. If the turn is waiting on active local or remote subagents, eve also requests cancellation of every adopted child, recursively, before settling the parent. Each child reports its own cancellation boundary on its child-session stream; the parent does not emit `subagent.completed` for cancelled work. `"no_active_turn"` means no resumable cancellation target exists, including an unknown session or an already-settled turn. Both statuses are success, so clients can fire and forget. See the [eve channel](../channels/eve) for the full route contract.
101+
`"accepted"` means a cancellation hook accepted the request. Confirm cancellation on the stream as `turn.cancelled` followed by `session.waiting`; the session then accepts the next message normally. If the turn is waiting on active local or remote subagents, eve also requests cancellation of every adopted child, recursively, before settling the parent. Each child reports its own cancellation boundary on its child-session stream; the parent does not emit `subagent.completed` for cancelled work. `"no_active_turn"` means no resumable cancellation target exists, including an unknown session or an already-settled turn. Both statuses are success, so clients can fire and forget. See the [eve channel](../channels/eve) for the full route contract.
102102

103103
## Reconnect and rewind
104104

docs/evals/cases.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export default defineEval({
103103

104104
- `t.send(input)` sends a turn and waits for it to settle. It accepts the same input as `ClientSession.send()` (a string or a structured message) and resolves to a turn carrying `.message` and `.expectOk()`.
105105
- `t.start(input)` starts a turn but returns as soon as the server accepts it. The returned live turn exposes `.sessionId`, `.waitForEvent(...)`, `.cancel()`, and `.result()` for coordinating with work that is still running.
106-
- `t.cancel()` requests cooperative cancellation of the primary session's active turn. Both `cancelling` and `no_active_turn` are successful outcomes.
106+
- `t.cancel()` requests cooperative cancellation of the primary session's active turn. Both `accepted` and `no_active_turn` are successful outcomes.
107107
- `t.sendFile(text, path, mediaType?)` attaches a local file as a data URL.
108108
- `t.requireInputRequest(filter?)` records a gate, requires exactly one pending request, and returns it. Filters match tool name, action input, prompt, display, and option ids.
109109
- `t.respond(...responses)` answers specific pending input requests and sends them as the next turn.

docs/guides/client/streaming.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: "Consume eve client stream events live, reconnect by event index, a
55

66
Every `ClientSession.send()` call posts the turn, then reads the session's NDJSON (newline-delimited JSON) event stream. `MessageResponse` gives you two ways to consume that stream, aggregating it with `result()` or iterating it live.
77

8-
Once `send()` is accepted, the session exposes its assigned `sessionId` and can request cooperative cancellation with `session.cancel()`, even while the response stream is still running. The result status is `cancelling` when the active turn accepted the signal or `no_active_turn` when it already settled:
8+
Once `send()` is accepted, the session exposes its assigned `sessionId` and can request cooperative cancellation with `session.cancel()`, even while the response stream is still running. The result status is `accepted` when the active turn accepted the signal or `no_active_turn` when it already settled:
99

1010
```ts
1111
const session = client.session();

e2e/fixtures/agent-cancellation/evals/cancellation/cancel-subagent.eval.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default defineEval({
2525
await t.require(
2626
cancelled,
2727
satisfies(
28-
(value: typeof cancelled) => value.status === "cancelling",
28+
(value: typeof cancelled) => value.status === "accepted",
2929
"parent cancel request is accepted",
3030
),
3131
);

e2e/fixtures/agent-cancellation/evals/cancellation/cancel-turn.eval.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export default defineEval({
3131
cancelled,
3232
satisfies(
3333
(value: typeof cancelled) =>
34-
value.sessionId === live.sessionId && value.status === "cancelling",
35-
"cancel request is accepted with status 'cancelling'",
34+
value.sessionId === live.sessionId && value.status === "accepted",
35+
"cancel request is accepted with status 'accepted'",
3636
),
3737
);
3838

@@ -50,7 +50,7 @@ export default defineEval({
5050
followUp.notEvent("session.failed");
5151
followUp.messageIncludes(/CANCELLATION-FOLLOW-UP-OK/i);
5252

53-
let lateStatus: "cancelling" | "no_active_turn" | undefined;
53+
let lateStatus: "accepted" | "no_active_turn" | undefined;
5454
for (let attempt = 0; attempt < 30; attempt += 1) {
5555
lateStatus = (await t.cancel()).status;
5656
if (lateStatus === "no_active_turn") break;

packages/eve/src/channel/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { UserContent } from "ai";
22

33
import type { HandleMessageStreamEvent } from "#protocol/message.js";
4+
import type { CancelTurnStatus } from "#protocol/cancel-turn.js";
45
import type { RunMode } from "#shared/run-mode.js";
56
import type { RuntimeActionResult } from "#runtime/actions/types.js";
67
import type { InputRequest, InputResponse } from "#runtime/input/types.js";
@@ -27,7 +28,7 @@ export interface CancelTurnInput {
2728

2829
/** Result of requesting turn cancellation. Both statuses are successful. */
2930
export interface CancelTurnResult {
30-
readonly status: "cancelling" | "no_active_turn";
31+
readonly status: CancelTurnStatus;
3132
}
3233

3334
// ---------------------------------------------------------------------------

packages/eve/src/client/session.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("ClientSession", () => {
6666
});
6767
if (new URL(url).pathname.endsWith("/cancel")) {
6868
return Response.json(
69-
{ ok: true, sessionId: "session_1", status: "cancelling" },
69+
{ ok: true, sessionId: "session_1", status: "accepted" },
7070
{ status: 202 },
7171
);
7272
}
@@ -88,7 +88,7 @@ describe("ClientSession", () => {
8888

8989
expect(turn.sessionId).toBe("session_1");
9090
expect(session.state.sessionId).toBe("session_1");
91-
expect(cancelled).toEqual({ sessionId: "session_1", status: "cancelling" });
91+
expect(cancelled).toEqual({ sessionId: "session_1", status: "accepted" });
9292
expect(requests).toHaveLength(2);
9393
expect(new URL(requests[1]!.url).pathname).toBe("/eve/v1/session/session_1/cancel");
9494
expect(requests[1]!.method).toBe("POST");
@@ -99,6 +99,18 @@ describe("ClientSession", () => {
9999
await expect(createSession().cancel()).rejects.toThrow("Session has no session ID");
100100
});
101101

102+
it("rejects a cancel response for a different session", async () => {
103+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
104+
Response.json(
105+
{ ok: true, sessionId: "another-session", status: "accepted" },
106+
{ status: 202 },
107+
),
108+
);
109+
const session = createSession({ sessionId: "session_1", streamIndex: 0 });
110+
111+
await expect(session.cancel()).rejects.toThrow("Cancel route returned an invalid response");
112+
});
113+
102114
it("serializes clientContext when sending a create-session message", async () => {
103115
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(createAcceptedResponse());
104116
const session = createSession();

packages/eve/src/client/session.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { HandleMessageStreamEvent } from "#protocol/message.js";
22
import { EVE_SESSION_ID_HEADER, isCurrentTurnBoundaryEvent } from "#protocol/message.js";
3+
import { CancelTurnResponseSchema } from "#protocol/cancel-turn.js";
34
import {
45
EVE_CREATE_SESSION_ROUTE_PATH,
56
createEveCancelTurnRoutePath,
@@ -91,7 +92,7 @@ export class ClientSession {
9192
/**
9293
* Requests cooperative cancellation of this session's active turn.
9394
*
94-
* Both `cancelling` and `no_active_turn` are successful outcomes. The latter
95+
* Both `accepted` and `no_active_turn` are successful outcomes. The latter
9596
* means the active turn settled before the request arrived or the session is
9697
* already parked. Credentials are resolved immediately before the request.
9798
*
@@ -123,12 +124,12 @@ export class ClientSession {
123124
throw new Error(`Cancel route returned invalid JSON (${response.status}).`);
124125
}
125126

126-
const status = readCancelStatus(payload);
127-
if (status === undefined) {
127+
const result = CancelTurnResponseSchema.safeParse(payload);
128+
if (!result.success || result.data.sessionId !== sessionId) {
128129
throw new Error(`Cancel route returned an invalid response (${response.status}).`);
129130
}
130131

131-
return { sessionId, status };
132+
return { sessionId: result.data.sessionId, status: result.data.status };
132133
}
133134

134135
/**
@@ -427,12 +428,6 @@ function createHandleMessageBody(input: {
427428
return body;
428429
}
429430

430-
function readCancelStatus(value: unknown): CancelSessionResult["status"] | undefined {
431-
if (typeof value !== "object" || value === null) return undefined;
432-
const status = (value as { readonly status?: unknown }).status;
433-
return status === "cancelling" || status === "no_active_turn" ? status : undefined;
434-
}
435-
436431
function withRedirectPolicy(init: RequestInit, redirect?: ClientRedirectPolicy): RequestInit {
437432
return redirect === undefined ? init : { ...init, redirect };
438433
}

0 commit comments

Comments
 (0)