diff --git a/.changeset/session-cancel-recovery.md b/.changeset/session-cancel-recovery.md new file mode 100644 index 0000000000..3d977e8955 --- /dev/null +++ b/.changeset/session-cancel-recovery.md @@ -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. diff --git a/docs/channels/eve.mdx b/docs/channels/eve.mdx index 0c8f7e6e37..782ed1d12c 100644 --- a/docs/channels/eve.mdx +++ b/docs/channels/eve.mdx @@ -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: @@ -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:///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 diff --git a/docs/concepts/sessions-runs-and-streaming.md b/docs/concepts/sessions-runs-and-streaming.md index 0859c5163e..266c0d4338 100644 --- a/docs/concepts/sessions-runs-and-streaming.md +++ b/docs/concepts/sessions-runs-and-streaming.md @@ -166,6 +166,19 @@ curl -X POST http://127.0.0.1:2000/eve/v1/session//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":"","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 diff --git a/docs/guides/auth-and-route-protection.md b/docs/guides/auth-and-route-protection.md index 461636996b..f613d4918c 100644 --- a/docs/guides/auth-and-route-protection.md +++ b/docs/guides/auth-and-route-protection.md @@ -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()`. diff --git a/docs/patterns/multi-tenant-approvals.md b/docs/patterns/multi-tenant-approvals.md index ad25afa8f4..10a0afd81f 100644 --- a/docs/patterns/multi-tenant-approvals.md +++ b/docs/patterns/multi-tenant-approvals.md @@ -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. diff --git a/docs/patterns/multi-tenant-auth.md b/docs/patterns/multi-tenant-auth.md index f43db80f91..e4cf13cb13 100644 --- a/docs/patterns/multi-tenant-auth.md +++ b/docs/patterns/multi-tenant-auth.md @@ -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. diff --git a/packages/eve/src/channel/cancel-session.test.ts b/packages/eve/src/channel/cancel-session.test.ts new file mode 100644 index 0000000000..04b205445e --- /dev/null +++ b/packages/eve/src/channel/cancel-session.test.ts @@ -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" }], + ]); + }); +}); diff --git a/packages/eve/src/channel/cancel-session.ts b/packages/eve/src/channel/cancel-session.ts new file mode 100644 index 0000000000..b194b53fc0 --- /dev/null +++ b/packages/eve/src/channel/cancel-session.ts @@ -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}`, + }); +} diff --git a/packages/eve/src/channel/cross-channel-receive.test.ts b/packages/eve/src/channel/cross-channel-receive.test.ts index 4edea0e26b..bd6cf9ba7b 100644 --- a/packages/eve/src/channel/cross-channel-receive.test.ts +++ b/packages/eve/src/channel/cross-channel-receive.test.ts @@ -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(), diff --git a/packages/eve/src/channel/routes.ts b/packages/eve/src/channel/routes.ts index d2f5f2bfc3..be61afdf14 100644 --- a/packages/eve/src/channel/routes.ts +++ b/packages/eve/src/channel/routes.ts @@ -2,6 +2,12 @@ import type { UserContent } from "ai"; import type { CrossChannelReceiveFn } from "#channel/cross-channel-receive.js"; import type { CancelTurnResult, SessionAuthContext, SessionCallback } from "#channel/types.js"; +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"; @@ -29,6 +35,11 @@ export interface RouteHandlerArgs { 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 @@ -105,6 +116,12 @@ export type SendOptions = [TState] extends [undefined] ? BaseSendOptions : BaseSendOptions & { state: TState }; +export type CancelSessionFn = ( + input: Omit & { + readonly continuationToken: string; + }, +) => Promise; + /** * Resolves an existing {@link Session} by id, for example to read its event * stream from within a route handler. diff --git a/packages/eve/src/channel/schedule.test.ts b/packages/eve/src/channel/schedule.test.ts index fa4c68f969..8e6f982525 100644 --- a/packages/eve/src/channel/schedule.test.ts +++ b/packages/eve/src/channel/schedule.test.ts @@ -27,6 +27,8 @@ function createMockRuntime(): Runtime { cancelTurn: vi.fn(), deliver: vi.fn().mockRejectedValue(new RuntimeNoActiveSessionError("schedule:token")), 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()), getStreamTailIndex: vi.fn().mockResolvedValue(-1), diff --git a/packages/eve/src/channel/send.test.ts b/packages/eve/src/channel/send.test.ts index 17c078cb0e..f75bf829db 100644 --- a/packages/eve/src/channel/send.test.ts +++ b/packages/eve/src/channel/send.test.ts @@ -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()), @@ -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()), @@ -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()), @@ -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()), diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index 564b47dfdf..2e1b2295b4 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -162,6 +162,15 @@ export interface RuntimeActionResultHookPayload { 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. * @@ -216,6 +225,7 @@ export interface SubagentAuthorizationEventHookPayload { * Serializable payload sent through the workflow `resumeHook`. */ export type HookPayload = + | CancelSessionHookPayload | DeliverHookPayload | RuntimeActionResultHookPayload | SessionTimeoutHookPayload @@ -359,6 +369,15 @@ export interface DeliverInput { readonly payload: DeliverPayload; } +export interface CancelSessionInput { + readonly continuationToken: string; + readonly reason?: string; +} + +export interface CancelSessionResult { + readonly sessionId: string; +} + /** * Terminal outcome of a runtime run. * @@ -415,6 +434,13 @@ export interface Runtime { * owns the token. */ resolveSession(continuationToken: string): Promise<{ sessionId: string } | undefined>; + * Cancels the session that currently owns a continuation 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; /** * Returns a readable stream of lifecycle events for an existing session. diff --git a/packages/eve/src/execution/node-step.test.ts b/packages/eve/src/execution/node-step.test.ts index 01848a05bb..4c43e2e61e 100644 --- a/packages/eve/src/execution/node-step.test.ts +++ b/packages/eve/src/execution/node-step.test.ts @@ -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")), diff --git a/packages/eve/src/execution/session-cancellation.integration.test.ts b/packages/eve/src/execution/session-cancellation.integration.test.ts new file mode 100644 index 0000000000..d7c45cdf8d --- /dev/null +++ b/packages/eve/src/execution/session-cancellation.integration.test.ts @@ -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(); + } + }); +}); diff --git a/packages/eve/src/execution/workflow-runtime.test.ts b/packages/eve/src/execution/workflow-runtime.test.ts index 79346d5ccc..d5d01138f4 100644 --- a/packages/eve/src/execution/workflow-runtime.test.ts +++ b/packages/eve/src/execution/workflow-runtime.test.ts @@ -277,6 +277,43 @@ describe("createWorkflowRuntime#resolveSession", () => { 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(); }); }); diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index d63ab146ee..61bfc1eb47 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -223,6 +223,27 @@ export function createWorkflowRuntime(config: { } }, + async cancelSession(input): Promise<{ sessionId: string }> { + const hookPayload: Extract = { + 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, diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts index a12c9db45a..f4018c9b07 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts @@ -236,6 +236,7 @@ describe("dispatchChannelRequest", () => { it("tags route sends with Vercel's request id", async () => { const runtimeForTest: Runtime = { cancelTurn: vi.fn(), + cancelSession: vi.fn(), deliver: vi.fn().mockResolvedValue({ sessionId: "sess_route" }), resolveSession: vi.fn(), getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), @@ -337,6 +338,7 @@ describe("dispatchChannelRequest", () => { it("does not invent a channel request id when Vercel did not send one", async () => { const runtimeForTest: Runtime = { cancelTurn: vi.fn(), + cancelSession: vi.fn(), deliver: vi.fn().mockResolvedValue({ sessionId: "sess_route" }), resolveSession: vi.fn(), getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), @@ -381,6 +383,7 @@ describe("dispatchChannelRequest", () => { it("does not mutate route-owned run and deliver inputs", async () => { const runtimeForTest: Runtime = { cancelTurn: vi.fn().mockResolvedValue({ status: "accepted" }), + cancelSession: vi.fn(), deliver: vi.fn().mockResolvedValue({ sessionId: "sess_deliver" }), resolveSession: vi.fn(), getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts index e3684ab4d7..8ddbbaf1bb 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts @@ -1,5 +1,6 @@ import type { H3Event } from "nitro"; import type { Agent, RouteContext } from "#public/definitions/channel.js"; +import { createCancelSessionFn } from "#channel/cancel-session.js"; import { createCrossChannelReceiveFn, toCrossChannelTargets, @@ -193,6 +194,7 @@ function buildRouteArgs( const resolveActiveSession = createResolveActiveSessionFn(bundle.runtime, channelName); const cancel = createCancelFn(bundle.runtime, channelName); const reset = createResetFn(bundle.runtime, channelName); + const cancelSession = createCancelSessionFn(bundle.runtime, channelName); const getSession = createGetSessionFn(bundle.runtime); const receive = createCrossChannelReceiveFn( bundle.runtime, @@ -218,6 +220,20 @@ function buildRouteArgs( }, ), agent, + const args = attachAgentInfoRouteResponse( + { + send, + getSession, + cancelSession, + receive, + params, + waitUntil, + requestIp, + }, + async () => { + const { handleAgentInfoRequest } = await import("#internal/nitro/routes/info.js"); + return await handleAgentInfoRequest(config); + }, ); return { @@ -231,6 +247,8 @@ function createRouteAgent(runtime: Runtime, requestId: string | undefined): Agen return { async cancelTurn(input) { return await runtime.cancelTurn(input); + async cancelSession(input) { + return await runtime.cancelSession(input); }, async deliver(input) { const deliverInput: DeliverInput = { ...input, requestId }; // Avoid mutating a frozen caller input. diff --git a/packages/eve/src/internal/testing/cancellable-session-workflow.ts b/packages/eve/src/internal/testing/cancellable-session-workflow.ts new file mode 100644 index 0000000000..bec42012f6 --- /dev/null +++ b/packages/eve/src/internal/testing/cancellable-session-workflow.ts @@ -0,0 +1,16 @@ +import type { HookPayload } from "#channel/types.js"; +import { createHook } from "#compiled/@workflow/core/index.js"; + +export async function cancellableSessionWorkflow(token: string): Promise { + "use workflow"; + + const cancellationHook = createHook({ token }); + const holdHook = createHook({ token: `${token}:hold` }); + + try { + await cancellationHook; + await holdHook; + } finally { + await Promise.all([cancellationHook.dispose(), holdHook.dispose()]); + } +} diff --git a/packages/eve/src/internal/testing/route-harness.ts b/packages/eve/src/internal/testing/route-harness.ts index 08c080562d..f9965f7e04 100644 --- a/packages/eve/src/internal/testing/route-harness.ts +++ b/packages/eve/src/internal/testing/route-harness.ts @@ -18,6 +18,7 @@ import type { Agent, RouteContext } from "#public/definitions/channel.js"; */ export interface MockAgent extends Agent { readonly cancelTurn: Mock; + readonly cancelSession: Mock; readonly run: Mock; readonly deliver: Mock; readonly getEventStream: Mock; @@ -36,6 +37,7 @@ export interface MockAgent extends Agent { export function createMockAgent(): MockAgent { return { cancelTurn: vi.fn().mockResolvedValue({ status: "no_active_turn" }), + cancelSession: vi.fn().mockResolvedValue({ sessionId: "test-session-id" }), deliver: vi.fn().mockResolvedValue(undefined), getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), run: vi.fn().mockResolvedValue({ diff --git a/packages/eve/src/protocol/routes.ts b/packages/eve/src/protocol/routes.ts index d1f4037655..fcbc30767d 100644 --- a/packages/eve/src/protocol/routes.ts +++ b/packages/eve/src/protocol/routes.ts @@ -33,6 +33,12 @@ export const EVE_RESET_SESSION_ROUTE_PATH = `${EVE_ROUTE_PREFIX}/session/reset`; */ export const EVE_CONTINUE_SESSION_ROUTE_PATTERN = `${EVE_ROUTE_PREFIX}/session/:sessionId`; +/** + * Stable framework-owned route path for cancelling the run that owns a + * continuation token. + */ +export const EVE_CANCEL_SESSION_ROUTE_PATH = EVE_CREATE_SESSION_ROUTE_PATH; + /** * Stable framework-owned message stream route pattern. */ diff --git a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.test.ts b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.test.ts index ef4aaf71ea..00dd7e3ff0 100644 --- a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.test.ts +++ b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.test.ts @@ -119,6 +119,7 @@ async function firePost( method: "POST", }), { + cancelSession: vi.fn() as any, getSession: vi.fn() as any, resolveActiveSession: async () => undefined, cancel: vi.fn(), @@ -176,6 +177,7 @@ describe("chatSdkChannel", () => { const response = await get.handler( new Request("https://example.com/eve/v1/test?crc_token=abc123", { method: "GET" }), { + cancelSession: vi.fn() as any, getSession: vi.fn() as any, resolveActiveSession: async () => undefined, cancel: vi.fn(), diff --git a/packages/eve/src/public/channels/eve.test.ts b/packages/eve/src/public/channels/eve.test.ts index 909fcf753c..496809be6b 100644 --- a/packages/eve/src/public/channels/eve.test.ts +++ b/packages/eve/src/public/channels/eve.test.ts @@ -11,6 +11,7 @@ import { eveChannel, defaultEveAuth, type EveChannelInput } from "#public/channe import type { SessionAuthContext } from "#channel/types.js"; import type { RouteHandlerArgs, SendFn, SendOptions, SendPayload } from "#channel/routes.js"; import type { Session as ChannelSession } from "#channel/session.js"; +import { RuntimeNoActiveSessionError } from "#execution/runtime-errors.js"; import { ContextContainer, contextStorage } from "#context/container.js"; import type { ContextAccessor } from "#context/key.js"; import { @@ -81,6 +82,7 @@ function createEveCreateHandler(input: EveChannelInput) { cancel: vi.fn(), reset: vi.fn(), getSession: vi.fn(), + cancelSession: vi.fn(), receive: vi.fn() as any, params: {}, waitUntil: () => undefined, @@ -128,6 +130,7 @@ function createEveContinueHandler(input: EveChannelInput) { cancel: vi.fn(), reset: vi.fn(), getSession: mockGetSession, + cancelSession: vi.fn(), receive: vi.fn() as any, params: { sessionId: "test-session-id" }, waitUntil: () => undefined, @@ -208,6 +211,22 @@ function createEveResetHandler(input: EveChannelInput) { cancel: vi.fn(), reset, getSession: vi.fn(), +function createEveCancelHandler(input: EveChannelInput) { + const channel = eveChannel(input); + const cancelRoute = channel.routes.find( + (r) => r.method === "DELETE" && r.path === "/eve/v1/session", + ); + if (!cancelRoute) throw new Error("No cancel DELETE route found"); + + const mockCancelSession = vi.fn().mockResolvedValue({ sessionId: "test-session-id" }); + + return { + cancelSession: mockCancelSession, + async fetch(req: Request) { + const args: RouteHandlerArgs = { + send: vi.fn(), + getSession: vi.fn(), + cancelSession: mockCancelSession, receive: vi.fn() as any, params: {}, waitUntil: () => undefined, @@ -260,6 +279,7 @@ function createEveStreamHandler(input: EveChannelInput) { requestIp: "127.0.0.1", }; return (streamRoute as any).handler(new Request(url), args); + return (cancelRoute as any).handler(req, args); }, }; } @@ -656,6 +676,83 @@ describe("eveChannel — onMessage", () => { }); }); +describe("eveChannel — cancel session", () => { + it("authenticates and cancels a session by continuation token", async () => { + const handler = createEveCancelHandler({ auth: () => ACCEPTED_AUTH }); + const response = await handler.fetch( + new Request("https://example.com/eve/v1/session", { + body: JSON.stringify({ + continuationToken: "eve:conversation", + reason: "operator reset", + }), + headers: { "content-type": "application/json" }, + method: "DELETE", + }), + ); + + expect(response.status).toBe(202); + expect(response.headers.get("x-eve-session-id")).toBe("test-session-id"); + await expect(response.json()).resolves.toEqual({ + ok: true, + sessionId: "test-session-id", + }); + expect(handler.cancelSession).toHaveBeenCalledWith({ + continuationToken: "eve:conversation", + reason: "operator reset", + }); + }); + + it("does not cancel when auth rejects", async () => { + const handler = createEveCancelHandler({ auth: [] }); + const response = await handler.fetch( + new Request("https://example.com/eve/v1/session", { + body: JSON.stringify({ continuationToken: "eve:conversation" }), + headers: { "content-type": "application/json" }, + method: "DELETE", + }), + ); + + expect(response.status).toBe(401); + expect(handler.cancelSession).not.toHaveBeenCalled(); + }); + + it("rejects a missing continuation token", async () => { + const handler = createEveCancelHandler({ auth: none() }); + const response = await handler.fetch( + new Request("https://example.com/eve/v1/session", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "DELETE", + }), + ); + + expect(response.status).toBe(400); + expect(handler.cancelSession).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining("continuationToken"), + }); + }); + + it("returns 404 when no active session owns the token", async () => { + const handler = createEveCancelHandler({ auth: none() }); + handler.cancelSession.mockRejectedValueOnce(new RuntimeNoActiveSessionError("eve:missing")); + + const response = await handler.fetch( + new Request("https://example.com/eve/v1/session", { + body: JSON.stringify({ continuationToken: "eve:missing" }), + headers: { "content-type": "application/json" }, + method: "DELETE", + }), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: "Session not found.", + ok: false, + }); + }); +}); + describe("eveChannel — create session (text)", () => { it("accepts a plain-string message and opens a new session", async () => { const handler = createEveCreateHandler({ auth: none() }); diff --git a/packages/eve/src/public/channels/eve.ts b/packages/eve/src/public/channels/eve.ts index cba4629c6e..a2e117b733 100644 --- a/packages/eve/src/public/channels/eve.ts +++ b/packages/eve/src/public/channels/eve.ts @@ -37,6 +37,7 @@ import { } from "#public/channels/upload-policy.js"; import { defineChannel, + DELETE, POST, GET, type Channel, @@ -44,6 +45,7 @@ import { type ChannelEvents, type ChannelSessionOps, } from "#public/definitions/channel.js"; +import { isRuntimeNoActiveSessionError } from "#execution/runtime-errors.js"; import type { ChannelMethod } from "#public/definitions/channel.js"; import type { RunMode } from "#shared/run-mode.js"; import { parseJsonObject, type JsonObject } from "#shared/json.js"; @@ -424,6 +426,37 @@ export function eveChannel(input: EveChannelInput): EveChannel { status: 202, }, ); + DELETE("/eve/v1/session", async (req, { cancelSession }) => { + const authResult = await routeAuth(req, input.auth); + if (authResult instanceof Response) return authResult; + + const body = await parseCancelBody(req); + if (body instanceof Response) return body; + + try { + const cancelled = await cancelSession({ + continuationToken: body.continuationToken, + reason: body.reason, + }); + return Response.json( + { + ok: true, + sessionId: cancelled.sessionId, + }, + { + headers: { + "cache-control": "no-store", + [EVE_SESSION_ID_HEADER]: cancelled.sessionId, + }, + status: 202, + }, + ); + } catch (error) { + if (isRuntimeNoActiveSessionError(error)) { + return Response.json({ error: "Session not found.", ok: false }, { status: 404 }); + } + throw error; + } }), GET("/eve/v1/session/:sessionId/stream", async (req, { getSession, params }) => { @@ -630,6 +663,48 @@ interface ParsedContinueBody { outputSchema?: JsonObject; } +interface ParsedCancelBody { + continuationToken: string; + reason?: string; +} + +async function parseCancelBody(req: Request): Promise { + let payload: unknown; + try { + payload = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body.", ok: false }, { status: 400 }); + } + + if (payload === null || typeof payload !== "object") { + return Response.json({ error: "Expected a JSON object.", ok: false }, { status: 400 }); + } + + const body = payload as Record; + const continuationToken = + typeof body.continuationToken === "string" && body.continuationToken.length > 0 + ? body.continuationToken + : undefined; + + if (continuationToken === undefined) { + return Response.json( + { error: "Missing or empty 'continuationToken' field.", ok: false }, + { status: 400 }, + ); + } + + if (body.reason !== undefined && typeof body.reason !== "string") { + return Response.json( + { error: "Expected 'reason' to be a string when present.", ok: false }, + { status: 400 }, + ); + } + + return body.reason === undefined + ? { continuationToken } + : { continuationToken, reason: body.reason }; +} + function parseContinueBody(payload: Record): ParsedContinueBody | Response { // Fail loud instead of silently running the delivery as the transport // principal: principal forwarding is create-only today. diff --git a/packages/eve/src/public/channels/github/githubChannel.test.ts b/packages/eve/src/public/channels/github/githubChannel.test.ts index 62c7bc8d63..72e774dc45 100644 --- a/packages/eve/src/public/channels/github/githubChannel.test.ts +++ b/packages/eve/src/public/channels/github/githubChannel.test.ts @@ -174,6 +174,7 @@ async function firePost( cancel: vi.fn(), reset: vi.fn(), resolveActiveSession: async () => undefined, + cancelSession: vi.fn() as any, getSession: vi.fn() as any, params: {}, receive: vi.fn() as any, diff --git a/packages/eve/src/public/channels/linear/linearChannel.test.ts b/packages/eve/src/public/channels/linear/linearChannel.test.ts index dfb8123cbd..5a17523bcc 100644 --- a/packages/eve/src/public/channels/linear/linearChannel.test.ts +++ b/packages/eve/src/public/channels/linear/linearChannel.test.ts @@ -129,6 +129,7 @@ async function firePost( cancel: vi.fn(), reset: vi.fn(), resolveActiveSession: async () => undefined, + cancelSession: vi.fn() as any, getSession: vi.fn() as any, params: {}, receive: vi.fn() as any, diff --git a/packages/eve/src/public/channels/teams/teamsChannel.test.ts b/packages/eve/src/public/channels/teams/teamsChannel.test.ts index fba2ea53c5..89d5cd29c1 100644 --- a/packages/eve/src/public/channels/teams/teamsChannel.test.ts +++ b/packages/eve/src/public/channels/teams/teamsChannel.test.ts @@ -45,6 +45,7 @@ async function firePost( method: "POST", }), { + cancelSession: vi.fn(), getSession: vi.fn(), resolveActiveSession: overrides.resolveActiveSession ?? vi.fn().mockResolvedValue(undefined), cancel: vi.fn(), diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index 7e0b095790..9a705c07c8 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -9,6 +9,8 @@ import type { Session, SessionHandle } from "#channel/session.js"; import type { CancelTurnInput, CancelTurnResult, + CancelSessionInput, + CancelSessionResult, DeliverInput, DeliverPayload, GetEventStreamOptions, @@ -23,6 +25,11 @@ import type { GenericChannelDefinition, GenericReceiveInput } from "#shared/chan declare const CHANNEL_METADATA_TYPE: unique symbol; export type { CancelTurnInput, CancelTurnResult, GetEventStreamOptions } from "#channel/types.js"; +export type { + CancelSessionInput, + CancelSessionResult, + GetEventStreamOptions, +} from "#channel/types.js"; export type { Session, SessionHandle } from "#channel/session.js"; export type { ChannelCors, ChannelCorsOptions } from "#channel/cors.js"; export { GET, POST, PUT, PATCH, DELETE, WS } from "#channel/routes.js"; @@ -35,6 +42,7 @@ export type { HttpRouteDefinition, RouteDefinition, RouteHandlerArgs, + CancelSessionFn, SendFn, SendOptions, SendPayload, @@ -130,6 +138,7 @@ export interface Agent { * with the same token resume the same session. */ run(input: RunInput): Promise; + /** * Requests cancellation of a session's in-flight turn. A `turnId` limits * the request to the turn the caller observed. @@ -147,6 +156,12 @@ export interface Agent { * to `run()` to start a new session. */ deliver(input: DeliverInput): Promise<{ sessionId: string }>; + + /** + * Cancels the session that currently owns the supplied continuation token. + */ + cancelSession(input: CancelSessionInput): Promise; + /** * Returns a readable NDJSON-style stream of lifecycle events for an * existing session. Used by the framework's HTTP session-stream route and by diff --git a/packages/eve/src/runtime/session-callback-route.test.ts b/packages/eve/src/runtime/session-callback-route.test.ts index 8c4ac568e1..5d2795c71f 100644 --- a/packages/eve/src/runtime/session-callback-route.test.ts +++ b/packages/eve/src/runtime/session-callback-route.test.ts @@ -179,6 +179,8 @@ function createRouteContext(params: Record): RouteContext { agent: { async cancelTurn() { throw new Error("unexpected cancelTurn"); + async cancelSession() { + throw new Error("unexpected cancelSession"); }, async deliver() { throw new Error("unexpected deliver"); diff --git a/packages/eve/test/eve-run-stream-channel.test.ts b/packages/eve/test/eve-run-stream-channel.test.ts index 26b839aa31..167b37616c 100644 --- a/packages/eve/test/eve-run-stream-channel.test.ts +++ b/packages/eve/test/eve-run-stream-channel.test.ts @@ -188,6 +188,7 @@ function createArgs(input: { readonly params: Readonly>; }): RouteHandlerArgs { return { + cancelSession: vi.fn(), send: vi.fn(), resolveActiveSession: async () => undefined, cancel: vi.fn(), diff --git a/packages/eve/test/scenarios/cross-channel-receive.scenario.test.ts b/packages/eve/test/scenarios/cross-channel-receive.scenario.test.ts index 30250f14ca..287df1220d 100644 --- a/packages/eve/test/scenarios/cross-channel-receive.scenario.test.ts +++ b/packages/eve/test/scenarios/cross-channel-receive.scenario.test.ts @@ -85,6 +85,8 @@ function createCapturingRuntime(captured: CapturedRun[]): Runtime { }, async resolveSession() { throw new Error("resolveSession should not be called in this scenario"); + async cancelSession() { + throw new Error("cancelSession should not be called in this scenario"); }, async run(input) { captured.push({ @@ -179,6 +181,9 @@ describe("cross-channel receive end-to-end", () => { { receive, resolveActiveSession: async () => undefined, + cancelSession: async () => { + throw new Error("webhook should not cancel sessions directly"); + }, send: async () => { throw new Error("webhook should delegate to args.receive()"); }, diff --git a/packages/eve/test/scenarios/schedule-trigger.scenario.test.ts b/packages/eve/test/scenarios/schedule-trigger.scenario.test.ts index d005a10084..9bfd59c00c 100644 --- a/packages/eve/test/scenarios/schedule-trigger.scenario.test.ts +++ b/packages/eve/test/scenarios/schedule-trigger.scenario.test.ts @@ -62,6 +62,8 @@ function createCapturingRuntime(captured: CapturedRun[]): Runtime { }, async resolveSession() { throw new Error("resolveSession should not be called in this scenario"); + async cancelSession() { + throw new Error("cancelSession should not be called in this scenario"); }, async run(input) { captured.push({