Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/session-cancel-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": minor
---

Add a session cancellation route and runtime primitive for evicting parked sessions by continuation token, so operators can recover deterministic conversation identities whose runs are no longer progressing.
10 changes: 10 additions & 0 deletions docs/channels/eve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The application exposes a health route plus eve channel routes that inspect the
- `POST /eve/v1/session` (start a session)
- `POST /eve/v1/session/:sessionId` (send a follow-up)
- `POST /eve/v1/session/:sessionId/cancel` (cancel the in-flight turn)
- `DELETE /eve/v1/session` (cancel the active run for a continuation token)
- `GET /eve/v1/session/:sessionId/stream` (stream events, NDJSON)

Start a session with a minimal body. The response returns `sessionId` and the `continuationToken` you reuse for follow-ups:
Expand Down Expand Up @@ -56,6 +57,15 @@ Cancellation is asynchronous: `"accepted"` means a cancellation hook accepted th

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

Cancel a stuck session by sending the session's current continuation token. This is an operator recovery path for a run that still owns a deterministic conversation identity but is no longer progressing:

```bash
curl -X DELETE https://<deployment>/eve/v1/session \
-H "Content-Type: application/json" \
-d '{"continuationToken":"eve:7f3c...","reason":"operator reset"}'
# {"ok":true,"sessionId":"ses_01h..."}
```

## CORS

The eve channel leaves CORS untouched by default. Pass `cors: true` to enable
Expand Down
13 changes: 13 additions & 0 deletions docs/concepts/sessions-runs-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ curl -X POST http://127.0.0.1:2000/eve/v1/session/<sessionId>/cancel
`"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.

Custom channel routes request the same cancellation without knowing the session id: the `cancel` route helper is addressed by the channel-local continuation token, and `Session.cancel()` by session id. See [custom channels](../channels/custom#cancel-a-turn).
## Cancel a stuck session

If a parked session owns a deterministic conversation identity but the run is no longer progressing, an operator can cancel the run that owns the current continuation token:

```bash
curl -X DELETE http://127.0.0.1:3000/eve/v1/session \
-H 'content-type: application/json' \
-d '{"continuationToken":"<token>","reason":"operator reset"}'
```

The route returns `202` when it cancels the owning run and `404` when no active session owns that token. After cancellation, the next ordinary message with the same conversation identity follows the normal no-active-session path and starts a fresh run.

Use this as an authenticated recovery control, not as ordinary chat flow. If your application serves multiple tenants, check that the caller owns the continuation token before proxying the cancellation request.

## Reconnect and rewind

Expand Down
1 change: 1 addition & 0 deletions docs/guides/auth-and-route-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The route-auth policy lives on the HTTP channel factory (`agent/channels/eve.ts`

- `POST /eve/v1/session`
- `POST /eve/v1/session/:sessionId`
- `DELETE /eve/v1/session`
- `GET /eve/v1/session/:sessionId/stream`

These routes are protected by the channel's auth policy. eve fails closed by default: production browser traffic is rejected unless you configure an authenticator that accepts it, and anonymous access requires an explicit `none()`.
Expand Down
1 change: 1 addition & 0 deletions docs/patterns/multi-tenant-approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Policy lookup failures should throw or deny, never silently allow. Recheck autho
An approval durably pauses the session and a later request resumes it. Your HTTP boundary must ensure a caller cannot continue or stream a session owned by another tenant. Persist session ownership in your application and check it before proxying:

- `POST /eve/v1/session/:sessionId`, including `inputResponses`;
- `DELETE /eve/v1/session`;
- `GET /eve/v1/session/:sessionId/stream`.

Built-in approval confirms that a human with access to the session approved the call. It is not a four-eyes workflow that proves a different person or role approved it. For that requirement, create an application-owned approval request, notify eligible approvers through a channel, and have policy return allow only after that request records an authorized decision.
Expand Down
2 changes: 1 addition & 1 deletion docs/patterns/multi-tenant-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,6 @@ The provider must fail closed for unknown tenants, avoid returning secrets in lo
4. eve sends the resulting token and headers directly to the remote service.
5. Neither becomes a model message or tool result.

Also enforce tenant ownership for session create, continue, and stream routes. Route authentication identifies the caller, but your application owns the ACL that decides which session ids that caller may access.
Also enforce tenant ownership for session create, continue, cancel, and stream routes. Route authentication identifies the caller, but your application owns the ACL that decides which session ids and continuation tokens that caller may access.

No framework-native tenant object is involved. The implementation is the composition of route auth, `ctx.session`, tool execution, and async connection auth/header resolvers.
32 changes: 32 additions & 0 deletions packages/eve/src/channel/cancel-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";

import { createCancelSessionFn } from "#channel/cancel-session.js";
import type { Runtime } from "#channel/types.js";

describe("createCancelSessionFn", () => {
it("qualifies identical raw tokens independently for each channel", async () => {
const cancelSession = vi.fn().mockImplementation(async ({ continuationToken }) => ({
sessionId: continuationToken,
}));
const runtime = {
cancelSession,
deliver: vi.fn(),
getEventStream: vi.fn(),
run: vi.fn(),
} satisfies Runtime;
const cancelSlackSession = createCancelSessionFn(runtime, "slack");
const cancelTeamsSession = createCancelSessionFn(runtime, "teams");

await expect(
cancelSlackSession({ continuationToken: "conversation-1", reason: "reset" }),
).resolves.toEqual({ sessionId: "slack:conversation-1" });
await expect(
cancelTeamsSession({ continuationToken: "conversation-1", reason: "reset" }),
).resolves.toEqual({ sessionId: "teams:conversation-1" });

expect(cancelSession.mock.calls).toEqual([
[{ continuationToken: "slack:conversation-1", reason: "reset" }],
[{ continuationToken: "teams:conversation-1", reason: "reset" }],
]);
});
});
13 changes: 13 additions & 0 deletions packages/eve/src/channel/cancel-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { CancelSessionFn } from "#channel/routes.js";
import type { Runtime } from "#channel/types.js";

/**
* Creates a channel-local session cancellation function.
*/
export function createCancelSessionFn(runtime: Runtime, channelName: string): CancelSessionFn {
return async (input) =>
await runtime.cancelSession({
...input,
continuationToken: `${channelName}:${input.continuationToken}`,
});
}
1 change: 1 addition & 0 deletions packages/eve/src/channel/cross-channel-receive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Runtime } from "#channel/types.js";
function makeRuntime(): Runtime {
return {
cancelTurn: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn(),
getEventStream: vi.fn(),
getStreamTailIndex: vi.fn(),
Expand Down
17 changes: 17 additions & 0 deletions packages/eve/src/channel/routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { UserContent } from "ai";

import type { CrossChannelReceiveFn } from "#channel/cross-channel-receive.js";
import type { CancelTurnResult, SessionAuthContext, SessionCallback } from "#channel/types.js";

Check failure on line 4 in packages/eve/src/channel/routes.ts

View workflow job for this annotation

GitHub Actions / lint

oxlint

Identifier `SessionCallback` has already been declared

Check failure on line 4 in packages/eve/src/channel/routes.ts

View workflow job for this annotation

GitHub Actions / lint

oxlint

Identifier `SessionAuthContext` has already been declared
import type {
CancelSessionInput,
CancelSessionResult,
SessionAuthContext,
SessionCallback,
} from "#channel/types.js";
import type { InputResponse } from "#runtime/input/types.js";
import type { Session } from "#channel/session.js";
import type { RunMode } from "#shared/run-mode.js";
Expand Down Expand Up @@ -29,6 +35,11 @@
cancel: CancelFn;
reset: ResetFn;
getSession: GetSessionFn;
/**
* Cancels the parked session that currently owns this channel-local
* continuation token.
*/
cancelSession: CancelSessionFn;
/**
* Starts a session on a different channel to hand off inbound work (e.g. an
* HTTP webhook routing the conversation onto Slack). The target's authored
Expand Down Expand Up @@ -105,6 +116,12 @@
? BaseSendOptions
: BaseSendOptions & { state: TState };

export type CancelSessionFn = (
input: Omit<CancelSessionInput, "continuationToken"> & {
readonly continuationToken: string;
},
) => Promise<CancelSessionResult>;

/**
* Resolves an existing {@link Session} by id, for example to read its event
* stream from within a route handler.
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/channel/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
function createMockRuntime(): Runtime {
return {
cancelTurn: vi.fn(),
deliver: vi.fn().mockRejectedValue(new RuntimeNoActiveSessionError("schedule:token")),

Check warning on line 28 in packages/eve/src/channel/schedule.test.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-dupe-keys)

Duplicate key 'deliver'
resolveSession: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn().mockRejectedValue(new Error("no parked session")),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
getEventStream: vi.fn().mockResolvedValue(new ReadableStream<MessageStreamEvent>()),
getStreamTailIndex: vi.fn().mockResolvedValue(-1),
Expand Down
4 changes: 4 additions & 0 deletions packages/eve/src/channel/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function createMockRunHandle(): RunHandle {
function createRuntime(deliverError: unknown): Runtime {
return {
cancelTurn: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn().mockRejectedValue(deliverError),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
Expand Down Expand Up @@ -81,6 +82,7 @@ describe("createSendFn", () => {
const context = ["thread background"];
const deliverRuntime: Runtime = {
cancelTurn: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
Expand Down Expand Up @@ -116,6 +118,7 @@ describe("createSendFn", () => {
it("adds channel request ids to deliver and run inputs when provided", async () => {
const deliverRuntime: Runtime = {
cancelTurn: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
Expand Down Expand Up @@ -146,6 +149,7 @@ describe("createSendFn", () => {
} as const;
const deliverRuntime: Runtime = {
cancelTurn: vi.fn(),
cancelSession: vi.fn(),
deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }),
resolveSession: vi.fn(),
run: vi.fn().mockResolvedValue(createMockRunHandle()),
Expand Down
26 changes: 26 additions & 0 deletions packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@
readonly results: readonly RuntimeActionResult[];
}

/**
* Framework-owned control payload used to resolve the workflow run that owns a
* continuation-token hook before cancelling the session.
*/
export interface CancelSessionHookPayload {
readonly kind: "cancel-session";
readonly reason?: string;
}

/**
* Event coordinates attached to a proxied `input.requested` batch.
*
Expand Down Expand Up @@ -216,6 +225,7 @@
* Serializable payload sent through the workflow `resumeHook`.
*/
export type HookPayload =
| CancelSessionHookPayload
| DeliverHookPayload
| RuntimeActionResultHookPayload
| SessionTimeoutHookPayload
Expand Down Expand Up @@ -359,6 +369,15 @@
readonly payload: DeliverPayload;
}

export interface CancelSessionInput {
readonly continuationToken: string;
readonly reason?: string;
}

export interface CancelSessionResult {
readonly sessionId: string;
}

/**
* Terminal outcome of a runtime run.
*
Expand Down Expand Up @@ -415,6 +434,13 @@
* owns the token.
*/
resolveSession(continuationToken: string): Promise<{ sessionId: string } | undefined>;
* Cancels the session that currently owns a continuation token.

Check failure on line 437 in packages/eve/src/channel/types.ts

View workflow job for this annotation

GitHub Actions / lint

oxlint

Unexpected token
*
* Operators use this to evict a wedged parked session so the next ordinary
* delivery can follow the normal no-active-session fallback and create a
* fresh run for the same conversation identity.
*/
cancelSession(input: CancelSessionInput): Promise<CancelSessionResult>;

/**
* Returns a readable stream of lifecycle events for an existing session.
Expand Down
3 changes: 3 additions & 0 deletions packages/eve/src/execution/node-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ function createTestNode(
function createNoopRuntime(): Runtime {
return {
cancelTurn: vi.fn(),
cancelSession: vi
.fn()
.mockRejectedValue(new Error("runtime.cancelSession should not be called in this test")),
deliver: vi.fn(),
resolveSession: vi.fn(),
run: vi.fn().mockRejectedValue(new Error("runtime.run should not be called in this test")),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { createWorkflowRuntime } from "#execution/workflow-runtime.js";
import { isRuntimeNoActiveSessionError } from "#execution/runtime-errors.js";
import { cancellableSessionWorkflow } from "#internal/testing/cancellable-session-workflow.js";
import { waitForHook } from "#internal/testing/workflow-test-helpers.js";
import { resumeHook, start } from "#internal/workflow/runtime.js";
import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";

describe("session cancellation integration", () => {
it("releases the hook and allows a fresh run to reclaim the same token", async () => {
const token = `http:session-cancellation:${crypto.randomUUID()}`;
const runtime = createWorkflowRuntime({
compiledArtifactsSource: {} as RuntimeCompiledArtifactsSource,
});
const firstRun = await start(cancellableSessionWorkflow, [token]);

try {
await waitForHook({ runId: firstRun.runId }, { token });

await expect(runtime.cancelSession({ continuationToken: token })).resolves.toEqual({
sessionId: firstRun.runId,
});
await expect(firstRun.status).resolves.toBe("cancelled");
await expect(runtime.cancelSession({ continuationToken: token })).rejects.toSatisfy(
isRuntimeNoActiveSessionError,
);
await expect(
resumeHook(token, { kind: "deliver", payloads: [{ message: "too late" }] }),
).rejects.toMatchObject({ name: "HookNotFoundError" });

const replacementRun = await start(cancellableSessionWorkflow, [token]);
try {
await waitForHook({ runId: replacementRun.runId }, { token });
expect(replacementRun.runId).not.toBe(firstRun.runId);
} finally {
const status = await replacementRun.status;
if (status === "pending" || status === "running") await replacementRun.cancel();
}
} finally {
const status = await firstRun.status;
if (status === "pending" || status === "running") await firstRun.cancel();
}
});
});
37 changes: 37 additions & 0 deletions packages/eve/src/execution/workflow-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,43 @@
getHookByTokenMock.mockRejectedValue(failure);

await expect(buildRuntime().resolveSession("test:token")).rejects.toBe(failure);
describe("createWorkflowRuntime#cancelSession", () => {
const NOT_FOUND_TOKEN = "test:no-such-hook";

function buildRuntime() {
const compiledArtifactsSource = {} as RuntimeCompiledArtifactsSource;
return createWorkflowRuntime({ compiledArtifactsSource });
}

it("normalizes a missing continuation hook into `RuntimeNoActiveSessionError`", async () => {
const { HookNotFoundError } = await import("#compiled/@workflow/errors/index.js");
resumeHookMock.mockRejectedValue(new HookNotFoundError(NOT_FOUND_TOKEN));

await expect(
buildRuntime().cancelSession({
continuationToken: NOT_FOUND_TOKEN,
}),
).rejects.toSatisfy(isRuntimeNoActiveSessionError);
});

it("cancels the run that owns the continuation hook", async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
resumeHookMock.mockResolvedValue({ runId: "driver-run" });
getRunMock.mockReturnValue({ cancel });

await expect(
buildRuntime().cancelSession({
continuationToken: "test:active-hook",
reason: "operator reset",
}),
).resolves.toEqual({ sessionId: "driver-run" });

expect(resumeHookMock).toHaveBeenCalledWith("test:active-hook", {
kind: "cancel-session",
reason: "operator reset",
});
expect(getRunMock).toHaveBeenCalledWith("driver-run");
expect(cancel).toHaveBeenCalledOnce();
});
});

Expand Down Expand Up @@ -568,3 +605,3 @@
expect(getReadable).toHaveBeenCalledTimes(1);
});
});
21 changes: 21 additions & 0 deletions packages/eve/src/execution/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@ export function createWorkflowRuntime(config: {
}
},

async cancelSession(input): Promise<{ sessionId: string }> {
const hookPayload: Extract<HookPayload, { kind: "cancel-session" }> = {
kind: "cancel-session",
reason: input.reason,
};

try {
const hook = normalizeWorkflowHook(await resumeHook(input.continuationToken, hookPayload));
await getRun(hook.runId).cancel();
return { sessionId: hook.runId };
} catch (error) {
if (HookNotFoundError.is(error)) {
throw new RuntimeNoActiveSessionError(input.continuationToken);
}
logError(log, "failed to cancel active session", error, {
continuationToken: input.continuationToken,
});
throw error;
}
},

async getEventStream(
sessionId: string,
options?: GetEventStreamOptions,
Expand Down
Loading
Loading