From bd174680c027b88750a253ce957d2dbd18bdf651 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 28 Jul 2026 16:55:15 -0400 Subject: [PATCH 1/6] test(e2e): cover descendant session limit continuation Add a one-token child whose tool call forces the next model step to park on a session-limit prompt. Exercise both continuation routing back to the child and decline recovery on the reusable root session. Signed-off-by: Rui Conti --- .../agent/subagents/limited-worker/agent.ts | 11 +++ .../subagents/limited-worker/instructions.md | 4 + .../limited-worker/tools/complete-step.ts | 11 +++ .../evals/descendant-session-limit.eval.ts | 77 +++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 e2e/fixtures/agent-subagents/agent/subagents/limited-worker/agent.ts create mode 100644 e2e/fixtures/agent-subagents/agent/subagents/limited-worker/instructions.md create mode 100644 e2e/fixtures/agent-subagents/agent/subagents/limited-worker/tools/complete-step.ts create mode 100644 e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts diff --git a/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/agent.ts b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/agent.ts new file mode 100644 index 000000000..8e30edce5 --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/agent.ts @@ -0,0 +1,11 @@ +import { defineAgent } from "eve"; + +export default defineAgent({ + description: + "Test-only child for session-limit propagation. Call it exactly when the user asks for the limited-worker subagent.", + limits: { + maxInputTokensPerSession: 1, + }, + model: process.env.EVE_E2E_MODEL ?? "openai/gpt-5.6-sol", + reasoning: "high", +}); diff --git a/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/instructions.md b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/instructions.md new file mode 100644 index 000000000..d409190a8 --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/instructions.md @@ -0,0 +1,4 @@ +You are the limited-worker session-limit fixture. + +Call `complete-step` exactly once. After it returns, reply with exactly +`CHILD_LIMIT_CONTINUED` and nothing else. diff --git a/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/tools/complete-step.ts b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/tools/complete-step.ts new file mode 100644 index 000000000..be09ec788 --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/subagents/limited-worker/tools/complete-step.ts @@ -0,0 +1,11 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +export default defineTool({ + description: + "Completes the limited-worker's deterministic test step. Call this exactly once before replying.", + inputSchema: z.object({}), + execute() { + return { completed: true }; + }, +}); diff --git a/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts new file mode 100644 index 000000000..46d40a4f7 --- /dev/null +++ b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts @@ -0,0 +1,77 @@ +import { defineEval } from "eve/evals"; +import { equals, satisfies } from "eve/evals/expect"; + +const CHILD_TOKEN = "CHILD_LIMIT_CONTINUED"; +const ROOT_RECOVERY_TOKEN = "ROOT_AFTER_DESCENDANT_STOP"; + +const DELEGATE_PROMPT = [ + "Call the limited-worker subagent exactly once.", + "Tell it to follow its instructions.", + `After it returns, reply with exactly ${CHILD_TOKEN} and nothing else.`, +].join(" "); + +/** + * The limited child crosses its one-token budget after calling complete-step. + * Its continuation prompt must surface on the root session and the answer must + * route back to the child that minted it. + */ +export default defineEval({ + description: + "A descendant session-limit prompt reaches the root; continue resumes the child and stop leaves the root session reusable.", + async test(t) { + await t.send(DELEGATE_PROMPT); + const continueRequest = t.requireInputRequest({ + display: "confirmation", + optionIds: ["continue", "stop"], + toolName: "session_limit_continuation", + }); + const rootSessionId = t.sessionId; + if (rootSessionId === undefined) { + throw new Error("The root session did not expose its session id."); + } + await t.require( + continueRequest.requestId, + satisfies( + (requestId: string) => !requestId.startsWith(`${rootSessionId}:limit:`), + "continuation request belongs to a descendant session", + ), + ); + + const resumed = await t.respond({ + optionId: "continue", + requestId: continueRequest.requestId, + }); + resumed.expectOk(); + t.succeeded(); + t.calledSubagent("limited-worker", { count: 1, output: CHILD_TOKEN }); + t.messageIncludes(CHILD_TOKEN); + t.noFailedActions(); + + const stopSession = t.newSession(); + await stopSession.send(DELEGATE_PROMPT); + const stopRequest = stopSession.requireInputRequest({ + display: "confirmation", + optionIds: ["continue", "stop"], + toolName: "session_limit_continuation", + }); + + const stopped = await stopSession.respond({ + optionId: "stop", + requestId: stopRequest.requestId, + }); + stopped.expectOk(); + stopSession.notEvent("turn.failed"); + stopSession.notEvent("session.failed"); + stopSession.notEvent("session.completed"); + stopSession.event("turn.cancelled"); + t.check(stopped.status, equals("waiting")); + + const recovered = await stopSession.send( + `Do not call any tool or subagent. Reply with exactly ${ROOT_RECOVERY_TOKEN} and nothing else.`, + ); + recovered.expectOk(); + stopSession.succeeded(); + stopSession.calledSubagent("limited-worker", { count: 1 }); + stopSession.messageIncludes(ROOT_RECOVERY_TOKEN); + }, +}); From cdbb5fdbb9cb2c71373e3ba71e2d5e939c519aaf Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 28 Jul 2026 17:23:37 -0400 Subject: [PATCH 2/6] test(e2e): bound descendant limit continuation Signed-off-by: Rui Conti --- .../agent-subagents/evals/descendant-session-limit.eval.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts index 46d40a4f7..dd519cd27 100644 --- a/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts +++ b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts @@ -18,6 +18,7 @@ const DELEGATE_PROMPT = [ export default defineEval({ description: "A descendant session-limit prompt reaches the root; continue resumes the child and stop leaves the root session reusable.", + timeoutMs: 90_000, async test(t) { await t.send(DELEGATE_PROMPT); const continueRequest = t.requireInputRequest({ From bd8e0daeb87697d23d70b06fdc5fefd60ff5bf8c Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 29 Jul 2026 09:16:40 -0400 Subject: [PATCH 3/6] fix(eve): cancel parent after descendant limit stop Route a descendant session-limit Stop response to its child before the parent owns cancellation and cascades it. This removes the synchronous parent-child wait cycle that left the run parked with unanswered requests. Proxied request metadata stores the descendant continuation token and request kind so routing decisions use protocol data. Signed-off-by: Rui Conti --- .../eve/src/execution/route-child-delivery.ts | 15 ++-- .../execution/settle-cancelled-turn-step.ts | 9 ++- .../execution/subagent-event-proxy-step.ts | 6 +- .../subagent-hitl-proxy.integration.test.ts | 10 ++- .../src/execution/subagent-hitl-proxy.test.ts | 29 ++++++- .../eve/src/execution/subagent-hitl-proxy.ts | 20 +++-- .../eve/src/execution/turn-workflow.test.ts | 75 ++++++++++++++++++- packages/eve/src/execution/turn-workflow.ts | 50 ++++++++----- .../eve/src/execution/workflow-entry.test.ts | 5 +- packages/eve/src/execution/workflow-entry.ts | 25 ++++++- packages/eve/src/execution/workflow-steps.ts | 35 +++------ .../src/harness/proxy-input-requests.test.ts | 49 ++++++++---- .../eve/src/harness/proxy-input-requests.ts | 72 ++++++++++++------ 13 files changed, 295 insertions(+), 105 deletions(-) diff --git a/packages/eve/src/execution/route-child-delivery.ts b/packages/eve/src/execution/route-child-delivery.ts index 5fef594ed..d3424fd66 100644 --- a/packages/eve/src/execution/route-child-delivery.ts +++ b/packages/eve/src/execution/route-child-delivery.ts @@ -1,12 +1,12 @@ import type { DeliverPayload, SessionAuthContext } from "#channel/types.js"; import { coalesceDeliverPayloads } from "#execution/deliver-payloads.js"; import type { DurableSessionState } from "#execution/durable-session-store.js"; -import { routeProxiedDeliverStep } from "#execution/workflow-steps.js"; +import { routeProxiedDeliverStep, type RoutedDeliverResult } from "#execution/workflow-steps.js"; /** * Coalesces inbound deliver payloads and routes any descendant-bound input - * responses down to the owning child, returning the parent-local remainder - * (or `undefined` when the whole payload routed away). + * responses down to the owning child. A descendant session-limit Stop is + * returned as parent-owned turn control after the child consumes the answer. * * Short-circuits via `hasProxyInputRequests` so the common no-active-descendant * path skips a durable step boundary. Lives in its own non-step module so both @@ -18,15 +18,16 @@ export async function routeDeliverToChildren(input: { readonly parentWritable: WritableStream; readonly payloads: readonly DeliverPayload[]; readonly sessionState: DurableSessionState; -}): Promise { +}): Promise { const payload = coalesceDeliverPayloads(input.payloads); - if (!input.sessionState.hasProxyInputRequests) return payload; + if (!input.sessionState.hasProxyInputRequests) { + return { kind: "continue", remainder: payload }; + } - const routed = await routeProxiedDeliverStep({ + return await routeProxiedDeliverStep({ auth: input.auth, parentWritable: input.parentWritable, payload, sessionState: input.sessionState, }); - return routed.remainder; } diff --git a/packages/eve/src/execution/settle-cancelled-turn-step.ts b/packages/eve/src/execution/settle-cancelled-turn-step.ts index 9465e6591..1708ff937 100644 --- a/packages/eve/src/execution/settle-cancelled-turn-step.ts +++ b/packages/eve/src/execution/settle-cancelled-turn-step.ts @@ -21,6 +21,7 @@ import { } from "#harness/emission.js"; import { clearAllProxyInputRequests, + getProxyInputRequests, hasProxyInputRequests, } from "#harness/proxy-input-requests.js"; import { clearPendingRuntimeActionBatch } from "#harness/runtime-actions.js"; @@ -71,8 +72,14 @@ export async function settleCancelledTurnStep(input: { // A descendant HITL wait already streamed this turn's waiting boundary // (the proxy epilogue clears the turn id); re-emitting would fabricate // a turn id and duplicate the boundary. + const proxyRequests = getProxyInputRequests(durableSession.state); + const stoppedAtDescendantLimit = [...proxyRequests.values()].some( + (request) => request.kind === "session-limit", + ); const alreadyEpilogued = - isHarnessBetweenTurns(session) && hasProxyInputRequests(durableSession.state); + isHarnessBetweenTurns(session) && + hasProxyInputRequests(durableSession.state) && + !stoppedAtDescendantLimit; if (!alreadyEpilogued) { const writer = input.parentWritable.getWriter(); diff --git a/packages/eve/src/execution/subagent-event-proxy-step.ts b/packages/eve/src/execution/subagent-event-proxy-step.ts index 7174af75e..bfe1b21c6 100644 --- a/packages/eve/src/execution/subagent-event-proxy-step.ts +++ b/packages/eve/src/execution/subagent-event-proxy-step.ts @@ -19,6 +19,7 @@ import { reconcileSessionContinuationToken } from "#execution/reconcile-session- import { hydrateDurableSession } from "#execution/session.js"; import { emitProxiedInputRequest } from "#execution/subagent-hitl-proxy.js"; import { upsertProxyInputRequests } from "#harness/proxy-input-requests.js"; +import type { ProxyInputRequest } from "#harness/proxy-input-requests.js"; import type { HarnessSession } from "#harness/types.js"; import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; import { encodeMessageStreamEvent, stampMessageStreamEvent } from "#protocol/message.js"; @@ -28,10 +29,7 @@ type SubagentEventHookPayload = | SubagentAuthorizationEventHookPayload | SubagentInputRequestHookPayload; -type ProxyInputRequestEntries = readonly (readonly [ - requestId: string, - childContinuationToken: string, -])[]; +type ProxyInputRequestEntries = readonly (readonly [requestId: string, route: ProxyInputRequest])[]; interface ProxySubagentEventResult { readonly serializedContext: Record; diff --git a/packages/eve/src/execution/subagent-hitl-proxy.integration.test.ts b/packages/eve/src/execution/subagent-hitl-proxy.integration.test.ts index 193362049..4ad5e9d50 100644 --- a/packages/eve/src/execution/subagent-hitl-proxy.integration.test.ts +++ b/packages/eve/src/execution/subagent-hitl-proxy.integration.test.ts @@ -238,7 +238,15 @@ describe("subagent HITL proxy → Slack-style text-approve regression (Finding # expect((afterEmitAdapter.state as SlackishState | undefined)?.pendingRequests).toEqual([ approvalRequest, ]); - expect(entries).toEqual([["req-approve-1", "subagent:parent:call-1"]]); + expect(entries).toEqual([ + [ + "req-approve-1", + { + childContinuationToken: "subagent:parent:call-1", + kind: "tool-approval", + }, + ], + ]); // The parent is in conversation mode, so the helper follows the // proxied `input.requested` with a `turn.completed` + diff --git a/packages/eve/src/execution/subagent-hitl-proxy.test.ts b/packages/eve/src/execution/subagent-hitl-proxy.test.ts index 0421a9ed4..c6af68016 100644 --- a/packages/eve/src/execution/subagent-hitl-proxy.test.ts +++ b/packages/eve/src/execution/subagent-hitl-proxy.test.ts @@ -22,10 +22,10 @@ function createSession(state?: Record): HarnessSession { describe("routeDeliverPayload", () => { it("routes responses to matching descendants and keeps unknown ones on forSelf", () => { const session = upsertProxyInputRequests({ - entries: [["req-a", "child-a"]], + entries: [["req-a", { childContinuationToken: "child-a", kind: "tool-approval" }]], forChildContinuationToken: "child-a", session: upsertProxyInputRequests({ - entries: [["req-b", "child-b"]], + entries: [["req-b", { childContinuationToken: "child-b", kind: "tool-approval" }]], forChildContinuationToken: "child-b", session: createSession(), }), @@ -69,7 +69,7 @@ describe("routeDeliverPayload", () => { it("returns forSelf as undefined when every response routes to a descendant", () => { const session = upsertProxyInputRequests({ - entries: [["req-a", "child-a"]], + entries: [["req-a", { childContinuationToken: "child-a", kind: "tool-approval" }]], forChildContinuationToken: "child-a", session: createSession(), }); @@ -84,4 +84,27 @@ describe("routeDeliverPayload", () => { expect(routed.forChildren).toHaveLength(1); expect(routed.forSelf).toBeUndefined(); }); + + it("asks the parent to cancel after routing Stop to a descendant session-limit request", () => { + const session = upsertProxyInputRequests({ + entries: [["req-limit", { childContinuationToken: "child-a", kind: "session-limit" }]], + forChildContinuationToken: "child-a", + session: createSession(), + }); + + const routed = routeDeliverPayload({ + payload: { + inputResponses: [{ optionId: "stop", requestId: "req-limit" }], + }, + state: session.state, + }); + + expect(routed.forChildren).toEqual([ + { + childContinuationToken: "child-a", + payload: { inputResponses: [{ optionId: "stop", requestId: "req-limit" }] }, + }, + ]); + expect(routed.parentAction).toEqual({ kind: "cancel-turn" }); + }); }); diff --git a/packages/eve/src/execution/subagent-hitl-proxy.ts b/packages/eve/src/execution/subagent-hitl-proxy.ts index 40eb4d362..ef411516a 100644 --- a/packages/eve/src/execution/subagent-hitl-proxy.ts +++ b/packages/eve/src/execution/subagent-hitl-proxy.ts @@ -8,10 +8,12 @@ import { getProxyInputRequests, toProxyInputRequestEntries, } from "#harness/proxy-input-requests.js"; +import type { ProxyInputRequest } from "#harness/proxy-input-requests.js"; import type { HarnessEmitFn, HarnessSession, SessionStateMap } from "#harness/types.js"; import { createInputRequestedEvent } from "#protocol/message.js"; import type { RunMode } from "#shared/run-mode.js"; import type { InputResponse } from "#runtime/input/types.js"; +import { SESSION_LIMIT_STOP_OPTION_ID } from "#harness/session-limit-continuation.js"; // --------------------------------------------------------------------------- // Upward proxy emission @@ -28,7 +30,7 @@ export async function emitProxiedInputRequest(input: { readonly mode: RunMode; readonly session: HarnessSession; }): Promise<{ - readonly entries: readonly (readonly [requestId: string, childContinuationToken: string])[]; + readonly entries: readonly (readonly [requestId: string, route: ProxyInputRequest])[]; readonly session: HarnessSession; }> { await input.emit( @@ -74,6 +76,7 @@ export interface RoutedDeliverPayload { readonly payload: { readonly inputResponses: readonly InputResponse[] }; }[]; readonly forSelf: DeliverPayload | undefined; + readonly parentAction: { readonly kind: "cancel-turn" } | undefined; } /** Splits a deliver payload into parent-local and proxied-child buckets. */ @@ -86,19 +89,24 @@ export function routeDeliverPayload(input: { const responsesByChild = new Map(); const unroutedResponses: InputResponse[] = []; + let parentAction: RoutedDeliverPayload["parentAction"]; for (const response of inputResponses) { - const childContinuationToken = entries.get(response.requestId); + const route = entries.get(response.requestId); - if (childContinuationToken === undefined) { + if (route === undefined) { unroutedResponses.push(response); continue; } - const existing = responsesByChild.get(childContinuationToken); + if (route.kind === "session-limit" && response.optionId === SESSION_LIMIT_STOP_OPTION_ID) { + parentAction = { kind: "cancel-turn" }; + } + + const existing = responsesByChild.get(route.childContinuationToken); if (existing === undefined) { - responsesByChild.set(childContinuationToken, [response]); + responsesByChild.set(route.childContinuationToken, [response]); } else { existing.push(response); } @@ -130,5 +138,5 @@ export function routeDeliverPayload(input: { const forSelf = Object.keys(remainder).length > 0 ? (remainder as DeliverPayload) : undefined; - return { forChildren, forSelf }; + return { forChildren, forSelf, parentAction }; } diff --git a/packages/eve/src/execution/turn-workflow.test.ts b/packages/eve/src/execution/turn-workflow.test.ts index 92ff9d7bc..227743039 100644 --- a/packages/eve/src/execution/turn-workflow.test.ts +++ b/packages/eve/src/execution/turn-workflow.test.ts @@ -680,7 +680,10 @@ describe("turnWorkflow", () => { serializedContext: { state: "proxied" }, sessionState: proxyState, }); - vi.mocked(routeDeliverToChildren).mockResolvedValue(undefined); + vi.mocked(routeDeliverToChildren).mockResolvedValue({ + kind: "continue", + remainder: undefined, + }); vi.mocked(turnStep) .mockResolvedValueOnce({ action: "park", @@ -723,6 +726,71 @@ describe("turnWorkflow", () => { ); }); + it("lets the parent cancel after a descendant consumes a session-limit Stop response", async () => { + const pendingState = createSessionState(); + const proxyState = createSessionState({ hasProxyInputRequests: true }); + const requestId = "child-limit-request"; + installInbox([ + { + callId: "call-1", + childContinuationToken: "subagent:parent:call-1", + childSessionId: "child-session", + event: { requests: [], sequence: 0, stepIndex: 1, turnId: "turn_0" }, + kind: "subagent-input-request", + subagentName: "delegate", + }, + { + delivery: { + kind: "deliver", + payloads: [{ inputResponses: [{ optionId: "stop", requestId }] }], + }, + kind: "driver-delivery", + requestId: "turn-token:inbox:delivery:0", + }, + ]); + vi.mocked(dispatchRuntimeActionsStep).mockResolvedValue({ + results: [], + sessionState: pendingState, + }); + vi.mocked(runProxySubagentEventStep).mockResolvedValue({ + serializedContext: { state: "proxied" }, + sessionState: proxyState, + }); + vi.mocked(routeDeliverToChildren).mockResolvedValue({ + kind: "cancel-turn", + }); + vi.mocked(turnStep).mockResolvedValueOnce({ + action: "park", + hasPendingAuthorization: false, + hasPendingInputBatch: false, + pendingRuntimeActionKeys: ["subagent-call:delegate:call-1"], + serializedContext: { state: "pending" }, + sessionState: pendingState, + }); + + const { input } = createInput({ + driverCapabilities: { cancelledTurnSettle: true, turnInbox: true }, + mode: "conversation", + sessionState: pendingState, + }); + await turnWorkflow(input); + + expect(cancelDescendantTurnsStep).toHaveBeenCalledWith({ + serializedContext: { state: "proxied" }, + sessionState: proxyState, + }); + expect(turnStep).toHaveBeenCalledOnce(); + expect(resumeHookMock).toHaveBeenCalledWith("turn-token", { + action: { + cancelled: true, + kind: "park", + serializedContext: { state: "proxied" }, + sessionState: proxyState, + }, + kind: "turn-result", + }); + }); + it("proxies child authorization lifecycle events while continuing to await its result", async () => { const pendingState = createSessionState(); const requiredState = createSessionState(); @@ -887,7 +955,10 @@ describe("turnWorkflow", () => { results: [], sessionState: pendingState, }); - vi.mocked(routeDeliverToChildren).mockResolvedValue(undefined); + vi.mocked(routeDeliverToChildren).mockResolvedValue({ + kind: "continue", + remainder: undefined, + }); vi.mocked(turnStep) .mockResolvedValueOnce({ action: "park", diff --git a/packages/eve/src/execution/turn-workflow.ts b/packages/eve/src/execution/turn-workflow.ts index 4c892ad81..62ceaf9df 100644 --- a/packages/eve/src/execution/turn-workflow.ts +++ b/packages/eve/src/execution/turn-workflow.ts @@ -115,16 +115,7 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { // epilogue runs in the driver (`settleCancelledTurnStep`), not as // a step in this run, where queued cancel wakes could re-dispatch // it. - await cancelDescendantTurnsStep({ - serializedContext: cursor.serializedContext, - sessionState: cursor.sessionState, - }); - await cancellation?.dispose(); - await cursor.finish( - { sessionState: cursor.sessionState }, - { cancelled: true, kind: "park" }, - bufferedDeliveries, - ); + await finishCancelledTurn({ bufferedDeliveries, cancellation, cursor }); return; } @@ -182,6 +173,10 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { nextStepInput = undefined; continue; } + if (results === "cancel-turn") { + await finishCancelledTurn({ bufferedDeliveries, cancellation, cursor }); + return; + } nextStepInput = { kind: "runtime-action-result", results }; continue; } @@ -223,10 +218,26 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { } } -// `"cancelled"` stays a sentinel rather than a `RuntimeActionResult` -// variant: that union is the schema-validated wire type projected into -// harness resume calls, while cancellation is a control-flow outcome of -// this wait that never leaves the workflow. +async function finishCancelledTurn(input: { + readonly bufferedDeliveries: readonly DeliverHookPayload[]; + readonly cancellation: TurnCancellationControl | undefined; + readonly cursor: TurnExecutionCursor; +}): Promise { + await cancelDescendantTurnsStep({ + serializedContext: input.cursor.serializedContext, + sessionState: input.cursor.sessionState, + }); + await input.cancellation?.dispose(); + await input.cursor.finish( + { sessionState: input.cursor.sessionState }, + { cancelled: true, kind: "park" }, + input.bufferedDeliveries, + ); +} + +// These sentinels stay outside `RuntimeActionResult`. That union is the +// schema-validated wire type projected into harness resume calls; these are +// turn-workflow control outcomes that never leave the workflow. async function waitForRuntimeActionResults(input: { readonly bufferedDeliveries: DeliverHookPayload[]; readonly cancellation: TurnCancellationControl | undefined; @@ -236,7 +247,7 @@ async function waitForRuntimeActionResults(input: { readonly iterator: AsyncIterator; readonly nextDeliveryRequestId: () => string; readonly pendingActionKeys: readonly string[]; -}): Promise { +}): Promise { let pendingDeliveryRequest: string | undefined; const results: RuntimeActionResult[] = [...input.initialResults]; @@ -313,14 +324,17 @@ async function waitForRuntimeActionResults(input: { await input.cursor.send({ kind: "turn-delivery-accepted", requestId: value.requestId }); pendingDeliveryRequest = undefined; - const remainder = await routeDeliverToChildren({ + const routed = await routeDeliverToChildren({ auth: value.delivery.auth, parentWritable: input.cursor.parentWritable, payloads: value.delivery.payloads, sessionState: input.cursor.sessionState, }); - if (remainder !== undefined) { - input.bufferedDeliveries.push({ ...value.delivery, payloads: [remainder] }); + if (routed.kind === "cancel-turn") { + return routed.kind; + } + if (routed.remainder !== undefined) { + input.bufferedDeliveries.push({ ...value.delivery, payloads: [routed.remainder] }); } } } diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index b20aa27c3..f5ea3c340 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -51,7 +51,10 @@ vi.mock("./create-session-step.js", () => ({ })); vi.mock("./route-child-delivery.js", () => ({ - routeDeliverToChildren: vi.fn().mockImplementation(async ({ payloads }) => payloads[0]), + routeDeliverToChildren: vi.fn().mockImplementation(async ({ payloads }) => ({ + kind: "continue", + remainder: payloads[0], + })), })); vi.mock("./delegated-parent-notification.js", () => ({ diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 4127dbdc7..4779f2bb4 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -19,6 +19,7 @@ import { import type { DurableSessionState } from "#execution/durable-session-store.js"; import type { NextDriverAction } from "#execution/next-driver-action.js"; import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; +import { cancelDescendantTurnsStep } from "#execution/cancel-descendant-turns-step.js"; import { dispatchAndAwaitTurn } from "#execution/turn-dispatch.js"; import { normalizeSerializableError } from "#execution/workflow-errors.js"; import { createSessionStep } from "#execution/create-session-step.js"; @@ -336,14 +337,32 @@ async function runDriverLoop(input: { return { kind: "result", result: { output: "" } }; } - const remainder = await routeDeliverToChildren({ + const routed = await routeDeliverToChildren({ auth: nextDeliver.auth, parentWritable: input.driverWritable, payloads: nextDeliver.payloads, sessionState: action.sessionState, }); - if (remainder === undefined) { + if (routed.kind === "cancel-turn") { + await cancelDescendantTurnsStep({ + serializedContext: action.serializedContext, + sessionState: action.sessionState, + }); + const settled = await settleCancelledTurnStep({ + parentWritable: input.driverWritable, + serializedContext: action.serializedContext, + sessionState: action.sessionState, + }); + action = { + ...action, + serializedContext: settled.serializedContext, + sessionState: settled.sessionState, + }; + continue; + } + + if (routed.remainder === undefined) { // Fully routed to a descendant; parent has no turn to run. continue; } @@ -352,7 +371,7 @@ async function runDriverLoop(input: { delivery: { auth: nextDeliver.auth, kind: "deliver", - payloads: [remainder], + payloads: [routed.remainder], requestId: nextDeliver.requestId, }, serializedContext: action.serializedContext, diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index a14c54eee..dfc3c031e 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -20,11 +20,7 @@ import { runStep } from "#context/run-step.js"; import { deserializeContext, serializeContext } from "#context/serialize.js"; import { getHarnessEmissionState } from "#harness/emission.js"; import { preserveSerializedAgentTraceState } from "#harness/agent-trace-context-store.js"; -import { - isSessionLimitDecline, - isTurnCancellation, - throwIfTurnAborted, -} from "#harness/turn-cancellation.js"; +import { isTurnCancellation, throwIfTurnAborted } from "#harness/turn-cancellation.js"; import { setChannelContext } from "#execution/channel-context.js"; import { hasPendingInputBatch } from "#harness/input-requests.js"; import { coalesceTurnInputs } from "#harness/messages.js"; @@ -78,7 +74,6 @@ import { normalizeEveAttributes } from "#runtime/attributes/normalize.js"; import { resolveRuntimeCompiledArtifactsVersionedCacheKey } from "#runtime/cache-key.js"; import { createWorkflowRuntime, - requestWorkflowTurnCancellation, startWorkflowPreferLatest, turnWorkflowReference, } from "#execution/workflow-runtime.js"; @@ -406,19 +401,6 @@ export async function turnStep(rawInput: TurnStepInput): Promise { it("records a fresh batch of proxy entries", () => { const session = createSession(); const next = upsertProxyInputRequests({ - entries: [["req-1", "child-a"]], + entries: [["req-1", { childContinuationToken: "child-a", kind: "question" }]], forChildContinuationToken: "child-a", session, }); expect(hasProxyInputRequests(next.state)).toBe(true); - expect(getProxyInputRequests(next.state).get("req-1")).toBe("child-a"); + expect(getProxyInputRequests(next.state).get("req-1")).toEqual({ + childContinuationToken: "child-a", + kind: "question", + }); }); it("replaces prior entries for the same child continuation token", () => { let session = upsertProxyInputRequests({ - entries: [["req-1", "child-a"]], + entries: [["req-1", { childContinuationToken: "child-a", kind: "question" }]], forChildContinuationToken: "child-a", session: createSession(), }); session = upsertProxyInputRequests({ - entries: [["req-2", "child-a"]], + entries: [["req-2", { childContinuationToken: "child-a", kind: "question" }]], forChildContinuationToken: "child-a", session, }); const entries = getProxyInputRequests(session.state); expect(entries.size).toBe(1); - expect(entries.get("req-2")).toBe("child-a"); + expect(entries.get("req-2")).toEqual({ + childContinuationToken: "child-a", + kind: "question", + }); expect(entries.has("req-1")).toBe(false); }); it("keeps entries from other children when upserting", () => { let session = upsertProxyInputRequests({ - entries: [["req-a", "child-a"]], + entries: [["req-a", { childContinuationToken: "child-a", kind: "question" }]], forChildContinuationToken: "child-a", session: createSession(), }); session = upsertProxyInputRequests({ - entries: [["req-b", "child-b"]], + entries: [["req-b", { childContinuationToken: "child-b", kind: "tool-approval" }]], forChildContinuationToken: "child-b", session, }); const entries = getProxyInputRequests(session.state); expect(entries.size).toBe(2); - expect(entries.get("req-a")).toBe("child-a"); - expect(entries.get("req-b")).toBe("child-b"); + expect(entries.get("req-a")).toEqual({ + childContinuationToken: "child-a", + kind: "question", + }); + expect(entries.get("req-b")).toEqual({ + childContinuationToken: "child-b", + kind: "tool-approval", + }); }); }); describe("clearProxyInputRequestsForChild", () => { it("removes only the target child's entries", () => { let session = upsertProxyInputRequests({ - entries: [["req-a", "child-a"]], + entries: [["req-a", { childContinuationToken: "child-a", kind: "question" }]], forChildContinuationToken: "child-a", session: createSession(), }); session = upsertProxyInputRequests({ - entries: [["req-b", "child-b"]], + entries: [["req-b", { childContinuationToken: "child-b", kind: "tool-approval" }]], forChildContinuationToken: "child-b", session, }); @@ -93,7 +105,10 @@ describe("clearProxyInputRequestsForChild", () => { const entries = getProxyInputRequests(session.state); expect(entries.size).toBe(1); - expect(entries.get("req-b")).toBe("child-b"); + expect(entries.get("req-b")).toEqual({ + childContinuationToken: "child-b", + kind: "tool-approval", + }); }); it("returns the same session when there is nothing to clear", () => { @@ -111,11 +126,17 @@ describe("getProxyInputRequests type safety", () => { it("ignores malformed values in the state map", () => { const session = createSession({ - "eve.runtime.proxyInputRequests": { "req-1": 42, "req-2": "child-b" }, + "eve.runtime.proxyInputRequests": { + "req-1": 42, + "req-2": { childContinuationToken: "child-b", kind: "question" }, + }, }); const entries = getProxyInputRequests(session.state); expect(entries.size).toBe(1); - expect(entries.get("req-2")).toBe("child-b"); + expect(entries.get("req-2")).toEqual({ + childContinuationToken: "child-b", + kind: "question", + }); }); it("ignores a legacy array-shaped value", () => { diff --git a/packages/eve/src/harness/proxy-input-requests.ts b/packages/eve/src/harness/proxy-input-requests.ts index b093e5119..40804963d 100644 --- a/packages/eve/src/harness/proxy-input-requests.ts +++ b/packages/eve/src/harness/proxy-input-requests.ts @@ -1,10 +1,17 @@ import type { SubagentInputRequestHookPayload } from "#channel/types.js"; import type { HarnessSession, SessionStateMap } from "#harness/types.js"; +import type { InputRequestKind } from "#runtime/input/types.js"; const PROXY_INPUT_REQUESTS_KEY = "eve.runtime.proxyInputRequests"; -/** `requestId → childContinuationToken` map stored on the parent session. */ -type ProxyInputRequestMap = Readonly>; +/** Routing and control metadata for one descendant-owned input request. */ +export interface ProxyInputRequest { + readonly childContinuationToken: string; + readonly kind: InputRequestKind; +} + +/** `requestId → route` map stored on the parent session. */ +type ProxyInputRequestMap = Readonly>; /** * Returns the proxy-routing map as a fresh `Map`. Never returns a live @@ -12,7 +19,7 @@ type ProxyInputRequestMap = Readonly>; */ export function getProxyInputRequests( state: SessionStateMap | undefined, -): ReadonlyMap { +): ReadonlyMap { return new Map(Object.entries(readMap(state))); } @@ -33,20 +40,20 @@ export function hasProxyInputRequests(state: SessionStateMap | undefined): boole * batch — the parent never keeps stale request metadata. */ export function upsertProxyInputRequests(input: { - readonly entries: readonly (readonly [requestId: string, childContinuationToken: string])[]; + readonly entries: readonly (readonly [requestId: string, route: ProxyInputRequest])[]; readonly forChildContinuationToken: string; readonly session: HarnessSession; }): HarnessSession { - const next: Record = {}; + const next: Record = {}; - for (const [requestId, childToken] of Object.entries(readMap(input.session.state))) { - if (childToken !== input.forChildContinuationToken) { - next[requestId] = childToken; + for (const [requestId, route] of Object.entries(readMap(input.session.state))) { + if (route.childContinuationToken !== input.forChildContinuationToken) { + next[requestId] = route; } } - for (const [requestId, childToken] of input.entries) { - next[requestId] = childToken; + for (const [requestId, route] of input.entries) { + next[requestId] = route; } return writeMap(input.session, next); @@ -61,15 +68,15 @@ export function clearProxyInputRequestsForChild( childContinuationToken: string, ): HarnessSession { const current = readMap(session.state); - const next: Record = {}; + const next: Record = {}; let changed = false; - for (const [requestId, childToken] of Object.entries(current)) { - if (childToken === childContinuationToken) { + for (const [requestId, route] of Object.entries(current)) { + if (route.childContinuationToken === childContinuationToken) { changed = true; continue; } - next[requestId] = childToken; + next[requestId] = route; } if (!changed) { @@ -92,13 +99,20 @@ export function clearAllProxyInputRequests(session: HarnessSession): HarnessSess /** * Projects a {@link SubagentInputRequestHookPayload} into the - * `(requestId, childContinuationToken)` tuples the session stores. + * `(requestId, route)` tuples the session stores. */ export function toProxyInputRequestEntries( payload: SubagentInputRequestHookPayload, -): readonly (readonly [requestId: string, childContinuationToken: string])[] { +): readonly (readonly [requestId: string, route: ProxyInputRequest])[] { return payload.event.requests.map( - (request) => [request.requestId, payload.childContinuationToken] as const, + (request) => + [ + request.requestId, + { + childContinuationToken: payload.childContinuationToken, + kind: request.kind, + }, + ] as const, ); } @@ -109,16 +123,19 @@ function readMap(state: SessionStateMap | undefined): ProxyInputRequestMap { return {}; } - const result: Record = {}; - for (const [key, value] of Object.entries(raw as Record)) { - if (typeof value === "string") { + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (isProxyInputRequest(value)) { result[key] = value; } } return result; } -function writeMap(session: HarnessSession, entries: Record): HarnessSession { +function writeMap( + session: HarnessSession, + entries: Record, +): HarnessSession { const state = { ...session.state }; if (Object.keys(entries).length === 0) { @@ -132,3 +149,16 @@ function writeMap(session: HarnessSession, entries: Record): Har state[PROXY_INPUT_REQUESTS_KEY] = entries; return { ...session, state }; } + +function isProxyInputRequest(value: unknown): value is ProxyInputRequest { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + if (!("childContinuationToken" in value) || !("kind" in value)) { + return false; + } + return ( + typeof value.childContinuationToken === "string" && + (value.kind === "question" || value.kind === "session-limit" || value.kind === "tool-approval") + ); +} From 04e1c9543f206691f4d814ac710f27ccc03891c1 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 28 Jul 2026 18:59:45 -0400 Subject: [PATCH 4/6] refactor(eve): validate proxy input requests with zod Signed-off-by: Rui Conti --- .../src/harness/proxy-input-requests.test.ts | 8 +++--- .../eve/src/harness/proxy-input-requests.ts | 25 ++++++++----------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/eve/src/harness/proxy-input-requests.test.ts b/packages/eve/src/harness/proxy-input-requests.test.ts index aadcd91d5..9c6197bd3 100644 --- a/packages/eve/src/harness/proxy-input-requests.test.ts +++ b/packages/eve/src/harness/proxy-input-requests.test.ts @@ -128,13 +128,15 @@ describe("getProxyInputRequests type safety", () => { const session = createSession({ "eve.runtime.proxyInputRequests": { "req-1": 42, - "req-2": { childContinuationToken: "child-b", kind: "question" }, + "req-2": { childContinuationToken: 42, kind: "question" }, + "req-3": { childContinuationToken: "child-c", kind: "other" }, + "req-4": { childContinuationToken: "child-d", kind: "question" }, }, }); const entries = getProxyInputRequests(session.state); expect(entries.size).toBe(1); - expect(entries.get("req-2")).toEqual({ - childContinuationToken: "child-b", + expect(entries.get("req-4")).toEqual({ + childContinuationToken: "child-d", kind: "question", }); }); diff --git a/packages/eve/src/harness/proxy-input-requests.ts b/packages/eve/src/harness/proxy-input-requests.ts index 40804963d..f315f16ba 100644 --- a/packages/eve/src/harness/proxy-input-requests.ts +++ b/packages/eve/src/harness/proxy-input-requests.ts @@ -1,14 +1,18 @@ +import { z } from "#compiled/zod/index.js"; + import type { SubagentInputRequestHookPayload } from "#channel/types.js"; import type { HarnessSession, SessionStateMap } from "#harness/types.js"; -import type { InputRequestKind } from "#runtime/input/types.js"; +import { inputRequestKindSchema } from "#runtime/input/types.js"; const PROXY_INPUT_REQUESTS_KEY = "eve.runtime.proxyInputRequests"; +const proxyInputRequestSchema = z.object({ + childContinuationToken: z.string(), + kind: inputRequestKindSchema, +}); + /** Routing and control metadata for one descendant-owned input request. */ -export interface ProxyInputRequest { - readonly childContinuationToken: string; - readonly kind: InputRequestKind; -} +export type ProxyInputRequest = Readonly>; /** `requestId → route` map stored on the parent session. */ type ProxyInputRequestMap = Readonly>; @@ -151,14 +155,5 @@ function writeMap( } function isProxyInputRequest(value: unknown): value is ProxyInputRequest { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false; - } - if (!("childContinuationToken" in value) || !("kind" in value)) { - return false; - } - return ( - typeof value.childContinuationToken === "string" && - (value.kind === "question" || value.kind === "session-limit" || value.kind === "tool-approval") - ); + return proxyInputRequestSchema.safeParse(value).success; } From 523991ca2a17bf6c83bb8aa9646a56cf61256416 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 28 Jul 2026 21:01:51 -0400 Subject: [PATCH 5/6] fix(eve): keep proxy validation out of runtime bundle Signed-off-by: Rui Conti --- .../eve/src/harness/proxy-input-requests.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/eve/src/harness/proxy-input-requests.ts b/packages/eve/src/harness/proxy-input-requests.ts index f315f16ba..3ae2c1f2d 100644 --- a/packages/eve/src/harness/proxy-input-requests.ts +++ b/packages/eve/src/harness/proxy-input-requests.ts @@ -1,18 +1,20 @@ -import { z } from "#compiled/zod/index.js"; - import type { SubagentInputRequestHookPayload } from "#channel/types.js"; import type { HarnessSession, SessionStateMap } from "#harness/types.js"; -import { inputRequestKindSchema } from "#runtime/input/types.js"; +import type { InputRequestKind } from "#runtime/input/types.js"; const PROXY_INPUT_REQUESTS_KEY = "eve.runtime.proxyInputRequests"; -const proxyInputRequestSchema = z.object({ - childContinuationToken: z.string(), - kind: inputRequestKindSchema, -}); +const PROXY_INPUT_REQUEST_KINDS = { + question: true, + "session-limit": true, + "tool-approval": true, +} satisfies Readonly>; /** Routing and control metadata for one descendant-owned input request. */ -export type ProxyInputRequest = Readonly>; +export interface ProxyInputRequest { + readonly childContinuationToken: string; + readonly kind: InputRequestKind; +} /** `requestId → route` map stored on the parent session. */ type ProxyInputRequestMap = Readonly>; @@ -129,8 +131,9 @@ function readMap(state: SessionStateMap | undefined): ProxyInputRequestMap { const result: Record = {}; for (const [key, value] of Object.entries(raw)) { - if (isProxyInputRequest(value)) { - result[key] = value; + const request = parseProxyInputRequest(value); + if (request !== undefined) { + result[key] = request; } } return result; @@ -154,6 +157,22 @@ function writeMap( return { ...session, state }; } -function isProxyInputRequest(value: unknown): value is ProxyInputRequest { - return proxyInputRequestSchema.safeParse(value).success; +function parseProxyInputRequest(value: unknown): ProxyInputRequest | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + if (!("childContinuationToken" in value) || !("kind" in value)) { + return undefined; + } + if (typeof value.childContinuationToken !== "string" || !isInputRequestKind(value.kind)) { + return undefined; + } + return { + childContinuationToken: value.childContinuationToken, + kind: value.kind, + }; +} + +function isInputRequestKind(value: unknown): value is InputRequestKind { + return typeof value === "string" && Object.hasOwn(PROXY_INPUT_REQUEST_KINDS, value); } From 14a8532aed481a0f3336864fa474cf4e964723de Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 28 Jul 2026 18:54:23 -0400 Subject: [PATCH 6/6] test(e2e): match cancelled subagent lifecycle Assert the declined delegation remains pending in parent-derived facts because cancellation intentionally emits no subagent.completed event. The surrounding gates still prove that Stop cancels the turn and leaves the root session reusable. Signed-off-by: Rui Conti --- .../agent-subagents/evals/descendant-session-limit.eval.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts index dd519cd27..6d2e13176 100644 --- a/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts +++ b/e2e/fixtures/agent-subagents/evals/descendant-session-limit.eval.ts @@ -72,7 +72,7 @@ export default defineEval({ ); recovered.expectOk(); stopSession.succeeded(); - stopSession.calledSubagent("limited-worker", { count: 1 }); + stopSession.calledSubagent("limited-worker", { count: 1, status: "pending" }); stopSession.messageIncludes(ROOT_RECOVERY_TOKEN); }, });