From 63766dc95c0b82ed4468952653bed4dcee87ceea Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 10:22:10 +0200 Subject: [PATCH 001/133] feat(web): support durable approval responses Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- web/mobile/src/features/chat/ApprovalDock.tsx | 2 + .../src/features/chat/LiveConversation.tsx | 10 ++- .../src/features/chat/useApprovalActions.ts | 10 ++- .../AgentChatSlice/AgentConversation.tsx | 2 +- .../components/AgentComposerDock.tsx | 6 +- .../components/ApprovalDock.tsx | 46 +++++++++-- .../hooks/useAgentChatSession.ts | 34 ++++---- web/packages/agenta-chat/src/assets/index.ts | 1 + .../src/assets/serverOwnedApproval.ts | 18 +++++ .../src/components/ApprovalCard.tsx | 22 +++++- .../src/hooks/useAgentConversation.ts | 30 ++++--- .../agenta-chat/src/hooks/useApprovalDock.ts | 39 ++++++++-- .../tests/unit/ApprovalCard.test.tsx | 36 +++++++++ .../unit/assets/serverOwnedApproval.test.ts | 24 ++++++ .../tests/unit/hooks/useApprovalDock.test.ts | 36 +++++++++ .../agenta-entities/src/session/api/api.ts | 53 ++++++++++--- .../agenta-entities/src/session/index.ts | 2 +- .../src/session/state/interactionAnswer.ts | 53 ++++++++++++- .../src/session/state/interactionStatus.ts | 4 + .../session-interaction-response-api.test.ts | 78 +++++++++++++++++++ 20 files changed, 438 insertions(+), 68 deletions(-) create mode 100644 web/packages/agenta-chat/src/assets/serverOwnedApproval.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts diff --git a/web/mobile/src/features/chat/ApprovalDock.tsx b/web/mobile/src/features/chat/ApprovalDock.tsx index 7cb8fc03f6e..7bfaadd967f 100644 --- a/web/mobile/src/features/chat/ApprovalDock.tsx +++ b/web/mobile/src/features/chat/ApprovalDock.tsx @@ -30,6 +30,7 @@ export const ApprovalDock = ({ bottomMost?: boolean }) => { const busy = actions.phase === "resuming" + const answered = actions.phase === "answered" if (approvals.length === 0) return null return ( @@ -44,6 +45,7 @@ export const ApprovalDock = ({ ({ - phase: conversation.approvals.responding ? "resuming" : steerActions.phase, - errorText: steerActions.errorText, + phase: conversation.approvals.answered + ? "answered" + : conversation.approvals.responding + ? "resuming" + : conversation.approvals.errorText + ? "error" + : steerActions.phase, + errorText: conversation.approvals.errorText ?? steerActions.errorText, respond: ({approved, message, approvalId}) => { if (message) { steerActions.respond({approvalId, approved, message}) diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 89ce7bb2e25..727376b3e5d 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -9,7 +9,7 @@ import { import {hasSettledResume, selectApprovalTargets, type ApprovalTarget} from "./approvalTargets" import {buildApprovalAnswer} from "./steer" -export type ResumePhase = "idle" | "resuming" | "error" +export type ResumePhase = "idle" | "resuming" | "answered" | "error" /** Fern's `AgentaApiError` message is transport jargon — show the status instead. */ const respondErrorText = (error: unknown): string => { @@ -67,13 +67,13 @@ export const useApprovalActions = ({ useEffect(() => { const pending = pendingKey ? pendingKey.split(" ") : [] if (!hasSettledResume(submittedRef.current, pending)) return - setPhase((current) => (current === "resuming" ? "idle" : current)) + setPhase((current) => (current === "resuming" || current === "answered" ? "idle" : current)) }, [pendingKey]) // Failure-path re-arm: if the respond was accepted but the run dies before the gate // resolves, the poll never settles us — drop back to idle so the buttons re-arm. useEffect(() => { - if (phase !== "resuming") return + if (phase !== "resuming" && phase !== "answered") return const handle = setTimeout(() => setPhase("idle"), 60_000) return () => clearTimeout(handle) }, [phase]) @@ -112,6 +112,8 @@ export const useApprovalActions = ({ interactionId: row.id as string, projectId, answer: buildApprovalAnswer(approved, message), + expectedExecutionId: row.turn_id ?? undefined, + idempotencyKey: `approval:${row.id}:${approved ? "approve" : "deny"}`, }) answered += 1 } catch (err) { @@ -124,6 +126,8 @@ export const useApprovalActions = ({ if (answered === 0) { submittedRef.current = [] setPhase("idle") + } else { + setPhase("answered") } } catch (err) { submittedRef.current = [] diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 618e1c751da..4b19b9fc665 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -417,7 +417,7 @@ const AgentConversation = ({ // harness owns the reject continuation and exposes no reject-with-feedback seam; killing // that flail needs an upstream ACP change, not an FE one.) const steer = args.message?.trim() - void answerApproval(args.id, args.approved).then(() => { + return answerApproval(args.id, args.approved).then(() => { // After the answer for the same reason the flip is: a steer starts its own turn. if (!args.approved && steer) submit({text: steer}) }) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index dc150505d1d..29a73c358fb 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -107,7 +107,11 @@ const AgentComposerDock = ({ /** The agent empty-chat template strip is on (owned by AgentConversation — see its comment). */ showTemplateStrip: boolean pendingApprovals: ReturnType - onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void + onApprovalResponse: (args: { + id: string + approved: boolean + message?: string + }) => void | Promise connects: ConnectionDockState /** Parked question forms the run is blocked on (from `useElicitationDock`). */ elicits: ElicitationDockState diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index b63a2955ee1..1e664e00267 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -9,7 +9,11 @@ import {isAgentChatSteerEnabled} from "../assets/constants" interface ApprovalDockProps { /** Pending gates for the paused turn (index 0 is acted on first). */ approvals: PendingApproval[] - onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void + onApprovalResponse: (args: { + id: string + approved: boolean + message?: string + }) => void | Promise /** Selected agent revision — enables the always-allow grant. */ entityId?: string className?: string @@ -38,6 +42,8 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr const current = shown[0] const [responding, setResponding] = useState(false) + const [answered, setAnswered] = useState(false) + const [errorText, setErrorText] = useState(null) // Feature flag: the "Redirect" (steer) control is OFF by default. The UI is complete, but the // redirect runs as a follow-up turn — the model reasons about the bare denial before it lands — // so we hide the entry point until the runner-level reject-and-redirect lands. @@ -46,6 +52,8 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr // The current gate changed (we answered one, the next slid in) — re-enable. useEffect(() => { setResponding(false) + setAnswered(false) + setErrorText(null) }, [current?.approvalId]) // Once every gate we fired has settled, drop the latch — the dock then closes if nothing @@ -56,11 +64,30 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr } }, [approvals, resolvingIds]) + const settle = async (responses: (Promise | void)[]) => { + const results = await Promise.allSettled(responses) + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + if (!failed) { + setAnswered(true) + return + } + setResponding(false) + setResolvingIds(null) + setErrorText( + failed.reason instanceof Error + ? failed.reason.message + : "Approval failed. Please try again.", + ) + } + const respondMany = (ids: string[], approved: boolean) => { if (responding) return setResponding(true) + setErrorText(null) setResolvingIds(ids) - ids.forEach((id) => onApprovalResponse({id, approved})) + void settle(ids.map((id) => onApprovalResponse({id, approved}))) } // Always mounted; enter + leave animate via the shared HeightCollapse. `inert` while closed @@ -72,17 +99,22 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr { if (responding) return setResponding(true) - onApprovalResponse({ - id: approvalId, - approved, - ...(message?.trim() ? {message: message.trim()} : {}), - }) + setErrorText(null) + void settle([ + onApprovalResponse({ + id: approvalId, + approved, + ...(message?.trim() ? {message: message.trim()} : {}), + }), + ]) }} onApproveAll={(ids) => respondMany(ids, true)} onDenyAll={(ids) => respondMany(ids, false)} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 3ebf4e73a76..70fca85522b 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -5,6 +5,7 @@ import { getMessageTraceId, latestTurnId, startupLabelFromDataPart, + submitServerOwnedApproval, } from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import {useSessionChat} from "@agenta/chat/hooks" @@ -42,6 +43,7 @@ import { invalidateSessionListQueries, killSession, recordInteractionAnswerAtom, + respondInteractionAnswerAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, } from "@agenta/entities/session" @@ -49,7 +51,6 @@ import {markTraceAsFresh} from "@agenta/entities/trace" import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" import { agentShouldResumeAfterApproval, - approvalResolution, buildAgentRequest, buildTurnCapture, isHitlPending, @@ -145,6 +146,7 @@ export const useAgentChatSession = ({ const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) const setSessionStatus = useSetAtom(setSessionStatusAtom) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) + const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. // `null` means "no live gate" — voided by a stop, or spent once a resume really went out; @@ -377,26 +379,24 @@ export const useAgentChatSession = ({ liveGateInteractionRef.current = interaction }, []) - /** - * Answer an approval: record the decision on the row the runner parked, THEN flip the part. - * - * Ordered, not raced. This hook dispatches no resume of its own — the park stream ends with a - * clean finish, so the SDK's `sendAutomaticallyWhen` sends it — but the flip is what lets the - * SDK dispatch, and that resume's stale sweep cancels rows still `pending`, this one included. - * Released early, the sweep reached the API first and cancelled the row being answered. - */ + /** Submit an approval to the server-owned dispatcher. Durable mode returns 202 after recording + * the command; flag-off mode returns 200 after enqueueing the existing detached resume. */ const answerApproval = useCallback( - (approvalId: string, approved: boolean) => - recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ + async (approvalId: string, approved: boolean) => { + await submitServerOwnedApproval({ + submit: () => + respondInteractionAnswer({ sessionId, toolCallId: approvalId, - resolution: approvalResolution(approvalId, approved), + approved, }), - release: () => addToolApprovalResponse({id: approvalId, approved}), - }), - [addToolApprovalResponse, recordInteractionAnswer, sessionId], + retire: () => { + // A lost HTTP response may still follow a committed continuation. + liveGateInteractionRef.current = null + }, + }) + }, + [respondInteractionAnswer, sessionId], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 50b4f932ab4..37e8515415b 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -10,5 +10,6 @@ export * from "./loadSession" export * from "./conversationLayout" export * from "./jumpToLatest" export * from "./boundedRequest" +export * from "./serverOwnedApproval" export {startupLabelFromDataPart} from "./startupPhases" export {getMessageTurnId, latestTurnId} from "./agentTurn" diff --git a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts new file mode 100644 index 00000000000..d9c5cfb94e3 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts @@ -0,0 +1,18 @@ +/** + * Keep the server as the sole continuation owner even when its HTTP response is ambiguous. + * A rejected request may have committed before the connection failed, so the browser must retire + * its local auto-resume marker on both success and failure while still propagating the error. + */ +export async function submitServerOwnedApproval({ + submit, + retire, +}: { + submit: () => Promise + retire: () => void +}): Promise { + try { + return await submit() + } finally { + retire() + } +} diff --git a/web/packages/agenta-chat/src/components/ApprovalCard.tsx b/web/packages/agenta-chat/src/components/ApprovalCard.tsx index 0b68010ee67..96f8c89485e 100644 --- a/web/packages/agenta-chat/src/components/ApprovalCard.tsx +++ b/web/packages/agenta-chat/src/components/ApprovalCard.tsx @@ -26,6 +26,8 @@ export interface ApprovalCardProps { approvals: PendingApproval[] /** A fired decision is settling (disables the controls, drives the spinner). */ responding?: boolean + /** The durable response was accepted; the card stays put while records catch up. */ + answered?: boolean /** The agent revision — enables the always-allow row (a draft-config grant). */ entityId?: string /** Show the Redirect (deny + note) entry point — hosts gate it by their own flag. */ @@ -47,6 +49,7 @@ export interface ApprovalCardProps { export const ApprovalCard = ({ approvals, responding = false, + answered = false, entityId, steerEnabled = false, touch = false, @@ -197,7 +200,9 @@ export const ApprovalCard = ({ {/* Eyebrow: a quiet cue that a decision is owed, not an error tint. */}
- Needs your approval + + {answered ? "Answered" : "Needs your approval"} +
{/* The whole ask, in one sentence — what happens, and what it costs. */} @@ -274,7 +279,7 @@ export const ApprovalCard = ({ {/* Actions. The whole row collapses while steering: an explicit deny+redirect shouldn't leave Approve competing, so the redirect panel becomes the entire action surface. */} - + {/* Wraps rather than squeezes: with Redirect on, the buttons drop to their own line instead of shoving Approve off a narrow screen. */}
@@ -338,7 +343,7 @@ export const ApprovalCard = ({ {/* Steer: an inline redirect note. Unmounted (not merely collapsed) while the flag is off — a collapsed HeightCollapse still leaves its controls in the tab order. */} - {steerEnabled ? ( + {steerEnabled && !answered ? (
@@ -385,7 +390,16 @@ export const ApprovalCard = ({ ) : null} - {errorText ?

{errorText}

: null} + {answered ? ( +

+ The agent is continuing. Waiting for the next update… +

+ ) : null} + {errorText ? ( +

+ {errorText} +

+ ) : null}
) } diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 0e8be415746..dfa5c42e2da 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -20,6 +20,7 @@ import { invalidateSessionListQueries, invalidateSessionLivenessQueries, recordInteractionAnswerAtom, + respondInteractionAnswerAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, shouldAdoptServerTranscript, @@ -28,7 +29,6 @@ import {markTraceAsFresh} from "@agenta/entities/trace" import {buildRenderMap} from "@agenta/playground" import { agentShouldResumeAfterApproval, - approvalResolution, buildAgentRequest, isResumeSend, recordAnswerThenRelease, @@ -48,6 +48,7 @@ import { type SessionTranscript, } from "../assets/loadSession" import {messageText, sideEffectingToolsInRange} from "../assets/rewind" +import {submitServerOwnedApproval} from "../assets/serverOwnedApproval" import {startupLabelFromDataPart} from "../assets/startupPhases" import {getMessageTraceId} from "../assets/trace" import {isClientToolPart as defaultIsClientToolPart} from "../clientTools" @@ -274,6 +275,7 @@ export const useAgentConversation = ({ // `undefined` means "no live marker", which falls back to the predicate's tail heuristics. const liveGateInteractionRef = useRef(null) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) + const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches // `onData` and never the transcript — this is the only place the answer survives. A stream that @@ -400,7 +402,6 @@ export const useAgentConversation = ({ stop, regenerate, setMessages, - addToolApprovalResponse, addToolOutput, error, clearError, @@ -626,27 +627,24 @@ export const useAgentConversation = ({ sessionId, }) - // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision - // made in THIS mount marks the resume as live — a restored approval-requested tail the user - // answers after a reload genuinely auto-resumes, so the queue's pre-resume hold applies. + // Approval responses flow through the server-owned dispatcher. Retire the local marker even + // on an ambiguous transport error: the server may already have committed the continuation. const handleApprovalResponse = useCallback( - (args: {id: string; approved: boolean}) => { + async (args: {id: string; approved: boolean}) => { liveGateInteractionRef.current = {kind: "approval", id: args.id} - // Ordered, not raced: the DECISION lands on the interaction row first, and only then - // does the part flip that lets the SDK dispatch its resume. Flipped first, that - // resume's stale sweep cancelled the row being answered. No resume from here either — - // the park stream finishes cleanly, so the SDK is the only sender. - void recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ + await submitServerOwnedApproval({ + submit: () => + respondInteractionAnswer({ sessionId, toolCallId: args.id, - resolution: approvalResolution(args.id, args.approved), + approved: args.approved, }), - release: () => addToolApprovalResponse(args), + retire: () => { + liveGateInteractionRef.current = null + }, }) }, - [addToolApprovalResponse, recordInteractionAnswer, sessionId], + [respondInteractionAnswer, sessionId], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts index 7b23c92372f..c099350fb83 100644 --- a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -15,7 +15,7 @@ import {getPendingApprovals, type PendingApproval} from "../model/approvals" export interface UseApprovalDockArgs { messages: UIMessage[] /** Answer one gate — the host's approval-response path (which marks the resume live). */ - respond: (args: {id: string; approved: boolean}) => void + respond: (args: {id: string; approved: boolean}) => void | Promise } export interface ApprovalDock { @@ -27,6 +27,9 @@ export interface ApprovalDock { count: number /** A fired decision hasn't settled yet — disable the action buttons. */ responding: boolean + /** The server accepted the durable response; wait for records to replace the parked gate. */ + answered: boolean + errorText: string | null /** Answer the current gate. */ respond: (approved: boolean) => void /** Approve every pending gate in one step (the shown set is frozen while they settle). */ @@ -64,13 +67,35 @@ export const useApprovalDock = ({ const count = shown.length const [responding, setResponding] = useState(false) + const [answered, setAnswered] = useState(false) + const [errorText, setErrorText] = useState(null) // The current gate changed (we answered one, the next slid in) — re-enable. Held during a // resolve (current is frozen), so it fires only on a real step or a new batch. useEffect(() => { setResponding(false) + setAnswered(false) + setErrorText(null) }, [current?.approvalId]) + const settle = useCallback(async (responses: (void | Promise)[]) => { + const results = await Promise.allSettled(responses) + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + if (!failed) { + setAnswered(true) + return + } + setResponding(false) + setResolvingIds(null) + setErrorText( + failed.reason instanceof Error + ? failed.reason.message + : "Approval failed. Please try again.", + ) + }, []) + // Once every gate we fired has settled (left the pending set), drop the latch — the dock then // closes if nothing remains, or re-latches onto the uncovered gates (a mixed batch). useEffect(() => { @@ -83,19 +108,21 @@ export const useApprovalDock = ({ (approved: boolean) => { if (responding || !current) return setResponding(true) - onRespond({id: current.approvalId, approved}) + setErrorText(null) + void settle([onRespond({id: current.approvalId, approved})]) }, - [responding, current, onRespond], + [responding, current, onRespond, settle], ) const approveAll = useCallback(() => { if (responding || shown.length === 0) return setResponding(true) + setErrorText(null) // Freeze the card so the dock doesn't step through the batch as each response settles — // it holds "1 of N" and closes once all are answered (see `resolvingIds`). setResolvingIds(shown.map((a) => a.approvalId)) - shown.forEach((a) => onRespond({id: a.approvalId, approved: true})) - }, [responding, shown, onRespond]) + void settle(shown.map((a) => onRespond({id: a.approvalId, approved: true}))) + }, [responding, shown, onRespond, settle]) - return {open, current, count, responding, respond, approveAll} + return {open, current, count, responding, answered, errorText, respond, approveAll} } diff --git a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx index c3c64c5f8bf..dfad97bf8fe 100644 --- a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx @@ -100,6 +100,42 @@ describe("the auto-approve row", () => { }) }) +describe("durable response state", () => { + it("shows that the answer was accepted while the continuation catches up", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Answered") + expect(markup).toContain("The agent is continuing") + // HeightCollapse keeps its child mounted for the leave animation, but removes it from + // layout, accessibility, and interaction while the answered state is visible. + expect(markup).toContain('aria-hidden="true" inert=""') + expect(markup).toContain('disabled=""') + }) + + it("keeps a failed answer pending and surfaces the retryable error", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Needs your approval") + expect(markup).toContain("Approval failed. Please try again.") + expect(markup).toContain(">Approve<") + }) +}) + describe("granting a batch", () => { const mount = (approvals: {approvalId: string; toolName: string; input: unknown}[]) => { const host = document.createElement("div") diff --git a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts new file mode 100644 index 00000000000..dbd8c8bc7db --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it, vi} from "vitest" + +import {submitServerOwnedApproval} from "../../../src/assets/serverOwnedApproval" + +describe("submitServerOwnedApproval", () => { + it("retires local resume ownership after a successful response", async () => { + const retire = vi.fn() + + await expect( + submitServerOwnedApproval({submit: () => Promise.resolve("accepted"), retire}), + ).resolves.toBe("accepted") + expect(retire).toHaveBeenCalledOnce() + }) + + it("retires local resume ownership when a committed response may have been lost", async () => { + const retire = vi.fn() + const lostResponse = new Error("connection closed") + + await expect( + submitServerOwnedApproval({submit: () => Promise.reject(lostResponse), retire}), + ).rejects.toBe(lostResponse) + expect(retire).toHaveBeenCalledOnce() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index 62fa011ce9b..a81fde59a7b 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -111,4 +111,40 @@ describe("useApprovalDock", () => { // The latched gate is still available for the closing animation frame. expect(result.current.current?.approvalId).toBe("g1") }) + + it("moves from sending to answered only after the response promise resolves", async () => { + let accept: (() => void) | undefined + const respond = vi.fn( + () => + new Promise((resolve) => { + accept = resolve + }), + ) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + act(() => result.current.respond(true)) + expect(result.current.responding).toBe(true) + expect(result.current.answered).toBe(false) + + await act(async () => accept?.()) + expect(result.current.answered).toBe(true) + expect(result.current.errorText).toBeNull() + }) + + it("re-arms the pending decision and shows an error when submission fails", async () => { + const respond = vi.fn(() => Promise.reject(new Error("Network unavailable"))) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + await act(async () => result.current.respond(false)) + + expect(result.current.responding).toBe(false) + expect(result.current.answered).toBe(false) + expect(result.current.errorText).toBe("Network unavailable") + act(() => result.current.respond(false)) + expect(respond).toHaveBeenCalledTimes(2) + }) }) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 6d2fd8dd08a..8a73cbed278 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -250,12 +250,25 @@ export async function fetchInteraction({ export interface RespondInteractionParams extends InteractionScopedParams { /** The answer payload (e.g. an approval decision). Shape is interaction-kind specific. */ answer: Record + /** The execution the approval belongs to. Durable mode serializes this against Stop. */ + expectedExecutionId?: string + /** Stable retry identity. Reusing it with a different answer is a conflict. */ + idempotencyKey?: string +} + +export interface RespondInteractionResult { + interaction: SessionInteraction | null + /** True only when the durable continuation transaction was accepted with HTTP 202. */ + accepted: boolean + command?: {id?: string; state?: string} + execution?: {id?: string; state?: string} } /** True for the backend's `409 Interaction is no longer pending` (someone already answered). * Fern stashes the HTTP status on the thrown `AgentaApiError` as `statusCode`. */ export const isInteractionConflict = (error: unknown): boolean => - (error as {statusCode?: number} | null)?.statusCode === 409 + (error as {statusCode?: number; response?: {status?: number}} | null)?.statusCode === 409 || + (error as {response?: {status?: number}} | null)?.response?.status === 409 /** True for the backend's `404 No such file or folder`. */ const isNotFound = (error: unknown): boolean => @@ -318,20 +331,42 @@ export async function respondInteraction({ appId, abortSignal, answer, -}: RespondInteractionParams): Promise { + expectedExecutionId, + idempotencyKey, +}: RespondInteractionParams): Promise { if (!projectId || !interactionId) return null - const data = await getSessionsClient().respondInteraction( - {interaction_id: interactionId, answer}, - projectScopedRequest(projectId, appId, abortSignal), - ) - + // Fern preserves the status through `withRawResponse`: 200 is the flag-off dispatcher and + // 202 is durable command acceptance. Both are server-owned continuations; callers must never + // also release the local AI SDK gate. + const request = { + interaction_id: interactionId, + answer, + ...(expectedExecutionId ? {expected_execution_id: expectedExecutionId} : {}), + } + const {data, rawResponse} = await getSessionsClient() + .respondInteraction(request, { + ...projectScopedRequest(projectId, appId, abortSignal), + headers: idempotencyKey ? {"Idempotency-Key": idempotencyKey} : undefined, + }) + .withRawResponse() + + const responseData = data as { + interaction?: unknown + command?: {id?: string; state?: string} + execution?: {id?: string; state?: string} + } const validated = safeParseWithLogging( sessionInteractionResponseSchema, - data, + responseData, "[respondInteraction]", ) - return validated?.interaction ?? null + return { + interaction: validated?.interaction ?? null, + accepted: rawResponse.status === 202, + ...(responseData.command ? {command: responseData.command} : {}), + ...(responseData.execution ? {execution: responseData.execution} : {}), + } } /** diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 1ade6569aca..ceaf3d9997d 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -141,7 +141,7 @@ export { type SessionInteractionRowState, type SessionInteractionRowStates, } from "./state/interactionStatus" -export {recordInteractionAnswerAtom} from "./state/interactionAnswer" +export {recordInteractionAnswerAtom, respondInteractionAnswerAtom} from "./state/interactionAnswer" export { sessionMountsQueryFamily, mountFilesQueryFamily, diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index 8d29fda8603..9648e51502a 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -2,7 +2,7 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import {transitionInteraction} from "../api/api" +import {respondInteraction, transitionInteraction} from "../api/api" import { fetchSessionInteractionStatesAtom, @@ -21,6 +21,57 @@ const tokenForToolCall = ( return states.has(toolCallId) ? toolCallId : null } +const rowForToolCall = (states: SessionInteractionRowStates, toolCallId: string) => { + for (const state of states.values()) { + if (state.toolCallId === toolCallId) return state + } + return states.get(toolCallId) ?? null +} + +/** + * Submit an approval through the response endpoint and preserve its failure for the card. + * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off legacy path and + * tells the caller to release the local AI SDK gate exactly as before. + */ +export const respondInteractionAnswerAtom = atom( + null, + async ( + get, + set, + params: { + sessionId: string + toolCallId: string + approved: boolean + }, + ): Promise<{durable: boolean}> => { + const {sessionId, toolCallId, approved} = params + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") + + const queryClient = get(queryClientAtom) + const rowsQueryKey = sessionInteractionRowsQueryKey(projectId, sessionId) + let states = await set(fetchSessionInteractionStatesAtom, sessionId) + let row = rowForToolCall(states, toolCallId) + if (!row) { + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + states = await set(fetchSessionInteractionStatesAtom, sessionId) + row = rowForToolCall(states, toolCallId) + } + if (!row?.id) throw new Error("This approval is no longer pending. Refresh and retry.") + + const result = await respondInteraction({ + interactionId: row.id, + projectId, + answer: {approved, tool_call_id: toolCallId}, + expectedExecutionId: row.turnId, + idempotencyKey: `approval:${row.id}:${approved ? "approve" : "deny"}`, + }) + if (!result) throw new Error("Approval could not be submitted.") + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + return {durable: result.accepted} + }, +) + /** * Best-effort by design: failures preserve today's in-band resume behavior. * It never blocks or rejects the client-tool resume path. diff --git a/web/packages/agenta-entities/src/session/state/interactionStatus.ts b/web/packages/agenta-entities/src/session/state/interactionStatus.ts index f3478bcf5d5..1ed74d5e122 100644 --- a/web/packages/agenta-entities/src/session/state/interactionStatus.ts +++ b/web/packages/agenta-entities/src/session/state/interactionStatus.ts @@ -26,7 +26,9 @@ const sessionInteractionRowsQueryOptions = (projectId: string, sessionId: string }) export interface SessionInteractionRowState { + id?: string token: string + turnId?: string status: SessionInteractionStatusCode kind: SessionInteractionKind resolution?: Record @@ -42,11 +44,13 @@ function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteracti const toolCallId = row.data?.request?.tool_call_id states.set(row.token, { + id: row.id ?? row.token, token: row.token, status: row.status as SessionInteractionStatusCode, kind: row.kind as SessionInteractionKind, ...(row.data?.resolution ? {resolution: row.data.resolution} : {}), ...(typeof toolCallId === "string" && toolCallId ? {toolCallId} : {}), + ...(typeof row.turn_id === "string" && row.turn_id ? {turnId: row.turn_id} : {}), }) } return states diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts new file mode 100644 index 00000000000..2b3bbd4cf61 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts @@ -0,0 +1,78 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {respond} = vi.hoisted(() => ({respond: vi.fn()})) + +vi.mock("@agenta/sdk/resources", () => ({ + getSessionsClient: () => ({respondInteraction: respond}), + getLowPrioritySessionsClient: vi.fn(), + getMountsClient: vi.fn(), + getLowPriorityMountsClient: vi.fn(), +})) + +import {respondInteraction} from "../../src/session/api/api" + +const interaction = { + id: "interaction-1", + session_id: "session-1", + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: {resolution: {approved: true}}, +} + +const response = (status: number, data: unknown) => ({ + withRawResponse: () => Promise.resolve({data, rawResponse: {status}}), +}) + +beforeEach(() => respond.mockReset()) + +describe("respondInteraction", () => { + it("forwards the execution guard and stable retry key and recognizes durable 202", async () => { + respond.mockReturnValue( + response(202, { + interaction, + command: {id: "command-1", state: "pending"}, + execution: {id: "turn-2", state: "pending"}, + }), + ) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answer: {approved: true, tool_call_id: "approval-1"}, + expectedExecutionId: "turn-1", + idempotencyKey: "approval:interaction-1:approve", + }) + + expect(respond).toHaveBeenCalledWith( + { + interaction_id: "interaction-1", + answer: {approved: true, tool_call_id: "approval-1"}, + expected_execution_id: "turn-1", + }, + expect.objectContaining({ + queryParams: {project_id: "project-1"}, + headers: {"Idempotency-Key": "approval:interaction-1:approve"}, + }), + ) + expect(result).toMatchObject({ + accepted: true, + interaction: {id: "interaction-1"}, + command: {id: "command-1"}, + execution: {id: "turn-2"}, + }) + }) + + it("keeps the flag-off server dispatcher response distinguishable without local resume", async () => { + respond.mockReturnValue(response(200, {interaction})) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answer: {approved: true}, + }) + + expect(result?.accepted).toBe(false) + }) +}) From 2eac81069f0df55cd6be550ed69d374411ea4b87 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 10:26:21 +0200 Subject: [PATCH 002/133] feat(runner): admit durable continuations once Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- .../agenta/sdk/agents/adapters/local.py | 3 + .../sdk/agents/adapters/sandbox_agent.py | 15 + sdks/python/agenta/sdk/agents/dtos.py | 6 + sdks/python/agenta/sdk/agents/handler.py | 13 + sdks/python/agenta/sdk/agents/interfaces.py | 6 + sdks/python/agenta/sdk/agents/utils/wire.py | 3 + sdks/python/agenta/sdk/agents/wire_models.py | 3 + .../agents/_fake_runner_backend.py | 15 + .../oss/tests/pytest/unit/agents/conftest.py | 7 + .../agents/test_agent_composition_seam.py | 34 ++ .../unit/agents/test_redaction_scope.py | 3 + .../pytest/unit/agents/test_wire_contract.py | 23 ++ ...test_batch_fold_stream_contract_routing.py | 3 + ...nvoke_real_handlers_negotiation_routing.py | 3 + .../oss/tests/pytest/unit/agent/conftest.py | 3 + services/runner/src/protocol.ts | 5 + services/runner/src/server.ts | 308 +++++++++++++---- services/runner/src/sessions/alive.ts | 34 +- .../src/sessions/continuation-admission.ts | 114 +++++++ .../runner/src/sessions/control-channel.ts | 76 ++++- .../tests/unit/continuation-admission.test.ts | 72 ++++ services/runner/tests/unit/server.test.ts | 318 ++++++++++++++++++ .../runner/tests/unit/wire-contract.test.ts | 1 + 23 files changed, 993 insertions(+), 75 deletions(-) create mode 100644 services/runner/src/sessions/continuation-admission.ts create mode 100644 services/runner/tests/unit/continuation-admission.test.ts diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 7417a93640a..b67342faca8 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -49,6 +49,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index f4429357f6d..ca978875be7 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -69,6 +69,9 @@ def __init__( run_context: Optional[RunContext], session_id: Optional[str], detached: bool = False, + turn_id: Optional[str], + project_id: Optional[str], + control_command_id: Optional[str], effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -80,6 +83,9 @@ def __init__( self._run_context = run_context self._session_id = session_id self._detached = detached + self._turn_id = turn_id + self._project_id = project_id + self._control_command_id = control_command_id self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -98,6 +104,9 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: run_context=self._run_context, session_id=self._session_id, detached=self._detached, + turn_id=self._turn_id, + project_id=self._project_id, + control_command_id=self._control_command_id, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -172,6 +181,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> SandboxAgentSession: @@ -188,6 +200,9 @@ async def create_session( run_context=run_context, session_id=session_id, detached=detached, + turn_id=turn_id, + project_id=project_id, + control_command_id=control_command_id, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 88e32459c4c..f725c6e3774 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -1170,6 +1170,12 @@ class SessionConfig(BaseModel): session_id: Optional[str] = None # Explicit per-invoke ownership handoff. False preserves request-owned cancellation. detached: bool = False + # Coordination identities supplied by the workflow service in request.meta. They remain + # per-turn transport metadata: the harness never consumes them, but the runner uses turn_id as + # the fresh execution guard and control_command_id to deduplicate durable continuation delivery. + turn_id: Optional[str] = None + project_id: Optional[str] = None + control_command_id: Optional[str] = None # The post-hydration config this turn runs, carried verbatim so the runner can stamp it on # the interaction row of any HITL gate the turn parks (see # ``agents/utils/effective_config.py``). Wire-emitted only for a session run; never consumed diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index ebd467640ce..ce8c0bf56a9 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -304,6 +304,16 @@ async def _agent( base = rc or RunContext() rc = base.model_copy(update={"run": RunContextRun(kind=run_kind)}) + # Detached session coordination rides the generic workflow request metadata. Keep these + # values out of harness configuration: they identify this delivery/execution only. In + # particular, a durable approval retry repeats control_command_id while run_id names the + # one fresh continuation execution the runner must admit at most once. + request_meta = request.meta or {} + + def _meta_string(name: str) -> Optional[str]: + value = request_meta.get(name) + return value.strip() if isinstance(value, str) and value.strip() else None + session_config = SessionConfig( agent=agent_template, resolved_connection=resolved_connection, @@ -312,6 +322,9 @@ async def _agent( run_context=rc, session_id=session_id, detached=bool(flags.detached), + turn_id=_meta_string("run_id"), + project_id=_meta_string("project_id"), + control_command_id=_meta_string("control_command_id"), # POST-hydration: the normalizer hands the handler `request.data.parameters` AFTER # the resolver has hydrated references (or kept the caller's inline config), so this # is the config the turn actually runs — the thing a HITL gate must be resumable diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 6e6ed92a83c..8c78b46a4bb 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -131,6 +131,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: @@ -203,6 +206,9 @@ async def create_session( run_context=session_config.run_context, session_id=session_config.session_id, detached=session_config.detached, + turn_id=session_config.turn_id, + project_id=session_config.project_id, + control_command_id=session_config.control_command_id, effective_parameters=session_config.effective_parameters, gateway_policy=session_config.gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index b1293a64ab4..07b35fb2ab2 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -96,6 +96,7 @@ def request_to_wire( detached: bool = False, turn_id: Optional[str] = None, project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Dict[str, Any]: @@ -177,6 +178,8 @@ def request_to_wire( payload["detached"] = True if project_id is not None: payload["projectId"] = project_id + if control_command_id is not None: + payload["controlCommandId"] = control_command_id if session_id: stamped = stamp_effective_parameters(effective_parameters) if stamped: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 4a68139afb2..8cf142af770 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -522,6 +522,9 @@ class WireRunRequest(_WireModel): # persist the transcript independently of any client connection. Omitted on ad-hoc runs. turn_id: Optional[str] = Field(default=None, alias="turnId") project_id: Optional[str] = Field(default=None, alias="projectId") + # Stable id of the durable continuation command that caused this run. The runner admits one + # execution per id even when delivery is retried. + control_command_id: Optional[str] = Field(default=None, alias="controlCommandId") agents_md: Optional[str] = Field(default=None, alias="agentsMd") # Model id stays scalar. The author's connection CHOICE and what that choice RESOLVED to are # two separate fields: `connection` is non-secret routing config the runner reads directly, diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index 21cb67e9e21..1a379144ac0 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -58,6 +58,9 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + turn_id: Optional[str], + project_id: Optional[str], + control_command_id: Optional[str], effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -67,6 +70,9 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._turn_id = turn_id + self._project_id = project_id + self._control_command_id = control_command_id self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -84,6 +90,9 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + turn_id=self._turn_id, + project_id=self._project_id, + control_command_id=self._control_command_id, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -162,6 +171,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> FakeRunnerSession: @@ -172,6 +184,9 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + turn_id=turn_id, + project_id=project_id, + control_command_id=control_command_id, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index 8b7f9abc2e9..58b88197d36 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -146,6 +146,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> FakeSession: @@ -158,6 +161,10 @@ async def create_session( "trace": trace, "run_context": run_context, "session_id": session_id, + "detached": detached, + "turn_id": turn_id, + "project_id": project_id, + "control_command_id": control_command_id, "effective_parameters": effective_parameters, "gateway_policy": gateway_policy, } diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index 5412f0d68ed..756fa8dcf50 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -96,6 +96,7 @@ def __init__(self, *, output: str = "hi") -> None: self.created_effective_parameters: List[Any] = [] self.created_gateway_policies: List[Any] = [] self.created_detached: List[bool] = [] + self.created_coordination: List[Any] = [] # The per-harness config the adapter built. Capturing it alongside neutral backend # arguments checks both sides of the composition boundary rather than one hop. self.created_configs: List[Any] = [] @@ -114,6 +115,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: @@ -121,6 +125,9 @@ async def create_session( self.created_effective_parameters.append(effective_parameters) self.created_gateway_policies.append(gateway_policy) self.created_detached.append(detached) + self.created_coordination.append( + (session_id, turn_id, project_id, control_command_id) + ) self.created_configs.append(config) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) @@ -215,6 +222,33 @@ async def test_absent_run_kind_leaves_composition_run_context_untouched(): assert ctx.to_wire() == {"trace": {"trace_id": "trace-1"}} +async def test_detached_coordination_meta_reaches_the_backend_session(): + backend = _FakeBackend() + handler = make_agent_handler( + AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_no_connection, + ) + ) + + await handler( + request=WorkflowServiceRequest( + session_id="session-1", + meta={ + "run_id": "turn-continuation-1", + "project_id": "project-1", + "control_command_id": "command-1", + }, + ), + messages=[{"role": "user", "content": "approved"}], + parameters=_params(), + ) + + assert backend.created_coordination == [ + ("session-1", "turn-continuation-1", "project-1", "command-1") + ] + + async def test_handler_carries_the_effective_config_onto_the_session(): """The config the handler RAN with reaches the session, verbatim. diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py index bd9978ab54e..32f46fca91a 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py @@ -98,6 +98,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, # Interface parity only; these tests assert on the redaction scope, not the wire. effective_parameters=None, gateway_policy=None, diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 0dedfcea60d..c421d1601e6 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -102,6 +102,7 @@ "turnId", "detached", "projectId", + "controlCommandId", "effectiveParameters", } @@ -1047,6 +1048,28 @@ def test_known_request_keys_match_the_wire_schema(): assert declared == KNOWN_REQUEST_KEYS +def test_request_to_wire_carries_durable_continuation_coordination_ids(): + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5.5"), + messages=[Message(role="user", content="approved")], + session_id="session-1", + turn_id="turn-continuation-1", + project_id="project-1", + control_command_id="command-1", + ) + + assert payload["sessionId"] == "session-1" + assert payload["turnId"] == "turn-continuation-1" + assert payload["projectId"] == "project-1" + assert payload["controlCommandId"] == "command-1" + assert set(payload) <= KNOWN_REQUEST_KEYS + + parsed = WireRunRequest.model_validate(payload) + assert parsed.control_command_id == "command-1" + + def test_named_connection_choice_is_a_declared_schema_field(): """A named Agenta connection reaches the runner as a first-class field, not as an extra. diff --git a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py index a5f7e22b4a3..e36b2aee898 100644 --- a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py @@ -141,6 +141,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py index edee6ae64ff..815e39c1512 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py @@ -145,6 +145,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index fadc73abe7f..f8c391c35f1 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -122,6 +122,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, # Interface parity: the SDK passes this through on every session run. These tests # assert on the config and run context, not on the stamped parameters. effective_parameters=None, diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 027a326041f..8ffdca54623 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -776,6 +776,11 @@ export interface AgentRunRequest { * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. */ projectId?: string; + /** + * Stable id of the durable continuation command that admitted this request. Repeated delivery + * carries the same id; the runner starts at most one execution for it. Omitted for ordinary runs. + */ + controlCommandId?: string; /** * The post-hydration config this turn runs, produced by the SDK (`agents/utils/wire.py`) and * OPAQUE here: the runner never reads inside it and never derives behavior from it. It is diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 64fa32669da..3194d089572 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -98,9 +98,11 @@ import { import { applyCommand, holdsSession, + reportContinuationAdmission, type ControlCommand, type ParkedSessionControl, } from "./sessions/control-channel.ts"; +import { claimContinuationAdmission } from "./sessions/continuation-admission.ts"; import { noteExecutionProject, registerExecution, @@ -475,11 +477,102 @@ async function runAndStreamWithApiBaseResolved( const sessionOwned = isSessionOwned(request); const detached = sessionOwned && request.detached === true; const sessionId = request.sessionId!; + const requestedTurnId = request.turnId?.trim(); const turnId = resolveTurnId(request); // Write the resolved id back: every downstream reader of `request.turnId` (the turns-ledger // append, interaction rows) must see the SAME execution id the alive-lock and records use. request.turnId = turnId; + const writeRecord = (record: StreamRecord): void => { + if (res.writableEnded) return; + res.write(JSON.stringify(record) + "\n"); + }; + const liveEmit: EmitEvent = (event) => writeRecord({ kind: "event", event }); + const turn = currentUserTurn(request); + const attachmentError = attachmentCountError(turn.attachments.length); + if (attachmentError) { + writeRecord({ + kind: "result", + result: { ok: false, error: attachmentError, events: [] }, + }); + res.end(); + return; + } + + const rawControlCommandId = request.controlCommandId; + const controlCommandId = + typeof rawControlCommandId === "string" + ? rawControlCommandId.trim() + : undefined; + if ( + rawControlCommandId !== undefined && + (!controlCommandId || !sessionOwned || !requestedTurnId) + ) { + writeRecord({ + kind: "result", + result: { + ok: false, + error: "A continuation command requires explicit sessionId and turnId.", + events: [], + }, + }); + res.end(); + return; + } + + // This is the continuation's exactly-once admission boundary. Everything above it is pure + // request validation and remains retryable. A duplicate never creates a controller, never + // replaces the live-execution registry entry, and never calls the engine. + const continuationAdmission = controlCommandId + ? claimContinuationAdmission(controlCommandId, turnId) + : undefined; + if (continuationAdmission?.role === "duplicate") { + const admitted = await continuationAdmission.admitted; + if (!admitted) { + writeRecord({ + kind: "result", + result: { + ok: false, + error: + "Continuation admission failed before execution started; retry delivery.", + events: [], + }, + }); + res.end(); + return; + } + try { + await reportContinuationAdmission({ + commandId: controlCommandId!, + sessionId, + executionId: continuationAdmission.executionId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[control] duplicate continuation report failed command=${controlCommandId}: ${message}\n`, + ); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } + writeRecord({ + kind: "result", + result: { + ok: true, + output: "", + stopReason: "control_command_duplicate", + events: [], + sessionId, + }, + }); + res.end(); + return; + } + // Diagnostic: surface whether the session-owned persist/alive path is entered and whether the // invoke credential arrived. Empty cred => heartbeat/persist would 401. The two empty cases have // different fixes, so name them apart: ABSENT means the caller sent no credential, DROPPED means @@ -504,6 +597,16 @@ async function runAndStreamWithApiBaseResolved( const interrupted = new Promise((resolve) => { markInterrupted = resolve; }); + let aliveWatchdog: + | { + release: () => Promise; + abandon: () => void; + credential: () => string; + streamId: () => string | undefined; + firstBeatOwned: boolean; + admitted: boolean; + } + | undefined; if (!sessionOwned && !detached) { // Listen on the response, not the request: the request body is already fully read, so // its `close` can fire early on a keep-alive connection. `res` `close` fires when the @@ -521,11 +624,6 @@ async function runAndStreamWithApiBaseResolved( }); } - const writeRecord = (record: StreamRecord): void => { - if (res.writableEnded) return; - res.write(JSON.stringify(record) + "\n"); - }; - const liveEmit: EmitEvent = (event) => writeRecord({ kind: "event", event }); if (detached) { // The invoke stream's sole positive payload in shared mode: correlation/acceptance. Live // text and tools arrive through /sessions/{id}/events and are filtered from invoke client-side. @@ -536,15 +634,128 @@ async function runAndStreamWithApiBaseResolved( transient: true, }); } - const turn = currentUserTurn(request); - const attachmentError = attachmentCountError(turn.attachments.length); - if (attachmentError) { - writeRecord({ - kind: "result", - result: { ok: false, error: attachmentError, events: [] }, + + // Make this execution reachable by a control command. Registered as early as the abort + // controller exists, so a Stop that arrives while the environment is still being acquired + // still aborts the run rather than waiting for the heartbeat to notice. + // + // A run with no project scope is not registered. `poolKeyFor` forms no key for it either, so + // it can never park, and Stop falls back to the heartbeat path exactly as it did before. + if (sessionOwned) { + registerExecution({ + // Usually undefined here: `runContext.project.id` is empty on the live invoke path, and + // the real scope comes from the signed mount. The coordinator fills it in through + // `onScopeResolved` a moment later. + projectId: projectScopeFor(request, undefined)?.id, + sessionId, + turnId, + startedAt: Date.now(), + // Labelled, because a command from the control plane IS a cooperative user Stop and + // `shouldPark` parks only an abort the runner can prove was one. An unlabelled abort here + // would end the turn `cancelled` and then DESTROY the sandbox, which is the exact failure + // Stop exists to avoid. See `sessions/stop-signal.ts`. + abort: () => controller.abort(USER_STOP_ABORT_REASON), }); - res.end(); - return; + } + + if (sessionOwned) { + try { + // Await ownership before a durable continuation reports `started`. An ordinary run keeps + // its historical fail-open heartbeat behavior; only a continuation requires the first beat + // to affirm that this exact turn owns the coordination row. + aliveWatchdog = await startAliveWatchdog( + sessionId, + turnId, + platformCredentialForRequest(request), + () => { + markInterrupted?.( + "the platform reported this turn is no longer current (stopped, taken over, or " + + "declared lost)", + ); + controller.abort(USER_STOP_ABORT_REASON); + }, + { + name: proposeSessionName(request), + references: buildWorkflowReferenceList(request.runContext?.workflow), + }, + ); + request.streamId = aliveWatchdog.streamId(); + } catch (error) { + if (continuationAdmission?.role !== "leader") throw error; + continuationAdmission.release(); + unregisterExecution(sessionId, turnId); + const message = error instanceof Error ? error.message : String(error); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } + + if ( + continuationAdmission?.role === "leader" && + !aliveWatchdog.firstBeatOwned + ) { + continuationAdmission.release(); + aliveWatchdog.abandon(); + unregisterExecution(sessionId, turnId); + writeRecord({ + kind: "result", + result: { + ok: false, + error: + "Continuation could not establish alive ownership; retry delivery.", + events: [], + }, + }); + res.end(); + return; + } + } + + if (continuationAdmission?.role === "leader") { + try { + // The API settles the durable command and marks this execution running before the harness + // can observe the approval. If the callback fails, release the process-local claim and live + // registry entry: no engine work started, so redelivery is safe. + const admitted = await reportContinuationAdmission({ + commandId: controlCommandId!, + sessionId, + executionId: turnId, + }); + continuationAdmission.admit(); + if (!admitted) { + aliveWatchdog?.abandon(); + unregisterExecution(sessionId, turnId); + writeRecord({ + kind: "result", + result: { + ok: true, + output: "", + stopReason: "control_command_duplicate", + events: [], + sessionId, + }, + }); + res.end(); + return; + } + } catch (error) { + continuationAdmission.release(); + aliveWatchdog?.abandon(); + unregisterExecution(sessionId, turnId); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[control] continuation admission failed command=${controlCommandId}: ${message}\n`, + ); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } } // For session-owned runs: wrap the live emitter so every event is also persisted @@ -564,50 +775,27 @@ async function runAndStreamWithApiBaseResolved( | undefined; let persistTerminal: ((stopReason?: string) => void) | undefined; let terminalRecordEmitted = false; - let aliveWatchdog: - | { - release: () => Promise; - credential: () => string; - } - | undefined; try { if (sessionOwned) { - // The request's api base (if any) is already scoped for this call via - // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. - // The runner authenticates session calls AS the invoke caller (the run credential), - // refreshing it for the turn's lifetime — never the admin key. Project scope is - // resolved server-side from the credential, so no project_id rides the request. - // - // onInterrupted (W7.4): a cancel/steer/kill against this session (via - // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. - // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to - // `controller.abort()` is what makes the control-plane signal actually reach this - // in-flight run — before this, a session-owned run's controller was never aborted. - // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. - // - // The beat also proposes the two things a headless session otherwise never gets: a name - // (no browser ever renders it, and the browser is the only other title writer) and the - // run's workflow references (they ride only a fire-and-forget turn append today, so a - // dropped append leaves a row the UI cannot open). Both are fill-once server-side. - const watchdog = await startAliveWatchdog( - sessionId, - turnId, - platformCredentialForRequest(request), - () => { - markInterrupted?.( - "the platform reported this turn is no longer current (stopped, taken over, or " + - "declared lost)", - ); - // LABELLED, not a bare abort: `shouldPark` parks only an abort it can prove was a - // cooperative Stop. See `sessions/stop-signal.ts`. - controller.abort(USER_STOP_ABORT_REASON); - }, - { - name: proposeSessionName(request), - references: buildWorkflowReferenceList(request.runContext?.workflow), - }, - ); + // The request's api base (if any) is already scoped for this call via + // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. + // The runner authenticates session calls AS the invoke caller (the run credential), + // refreshing it for the turn's lifetime — never the admin key. Project scope is + // resolved server-side from the credential, so no project_id rides the request. + // + // onInterrupted (W7.4): a cancel/steer/kill against this session (via + // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. + // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to + // `controller.abort()` is what makes the control-plane signal actually reach this + // in-flight run — before this, a session-owned run's controller was never aborted. + // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. + // + // The beat also proposes the two things a headless session otherwise never gets: a name + // (no browser ever renders it, and the browser is the only other title writer) and the + // run's workflow references (they ride only a fire-and-forget turn append today, so a + // dropped append leaves a row the UI cannot open). Both are fill-once server-side. + const watchdog = aliveWatchdog!; aliveWatchdog = watchdog; // The heartbeat response already carries the session_streams row id — free, no extra // round-trip. Thread it onto the request so the engine's turn-append write has it. @@ -944,11 +1132,7 @@ function parkedSessionControl( keepaliveConfigs[provider].ttlMs, ), teardown: () => - pool.evictIfCurrent( - live, - "stop-approval-failed", - "failed-turn", - ), + pool.evictIfCurrent(live, "stop-approval-failed", "failed-turn"), }); }, }; @@ -1143,11 +1327,7 @@ export function createRequestListener( : "", }; if ( - !holdsSession( - cancelProjectId, - cancelSessionId, - parkedSessionControl, - ) + !holdsSession(cancelProjectId, cancelSessionId, parkedSessionControl) ) { // 404 is ambiguous on purpose and the API disambiguates it: a `not_held` for a // session whose row is alive and beating means the call reached the wrong replica. diff --git a/services/runner/src/sessions/alive.ts b/services/runner/src/sessions/alive.ts index eeeef93baf3..84906c194bc 100644 --- a/services/runner/src/sessions/alive.ts +++ b/services/runner/src/sessions/alive.ts @@ -132,12 +132,12 @@ export function ownedSessionCount(now: number = Date.now()): number { * Authenticates AS the invoke caller (the run credential) — project scope is resolved server-side * from that credential, so no `project_id` rides the request. * - * Returns both signals the one response body carries: `streamId` (the `session_streams` row + * Returns the signals the one response body carries: `streamId` (the `session_streams` row * uuid — the free gift of a call the runner already makes every turn, no new round-trip) and * `interrupted: true` when the API reports `is_current_turn: false` (a cancel/steer/kill took * this turn's alive/running lock since the last beat — W7.4, the control-signal path). A - * network/HTTP failure yields `confirmed: false`; callers use that to fail closed for initial - * admission while later watchdog beats remain best effort for a turn already admitted. + * network/HTTP failure is unconfirmed and unowned. Initial admission fails closed, and a durable + * continuation additionally requires an explicit ownership response before reporting `started`. */ async function sendHeartbeat( sessionId: string, @@ -149,6 +149,7 @@ async function sendHeartbeat( streamId: string | undefined; interrupted: boolean; confirmed: boolean; + owned: boolean; }> { try { const url = `${apiBase()}/sessions/streams/heartbeat`; @@ -172,7 +173,12 @@ async function sendHeartbeat( }); if (!res.ok) { log(`heartbeat HTTP ${res.status} session=${sessionId} turn=${turnId}`); - return { streamId: undefined, interrupted: false, confirmed: false }; + return { + streamId: undefined, + interrupted: false, + confirmed: false, + owned: false, + }; } const body = (await res.json()) as { stream?: { id?: unknown } | null; @@ -194,12 +200,18 @@ async function sendHeartbeat( log( `heartbeat OK session=${sessionId} turn=${turnId} running=${isRunning}${interrupted ? " INTERRUPTED" : ""}`, ); - return { streamId, interrupted, confirmed: true }; + const owned = body.is_current_turn === true; + return { streamId, interrupted, confirmed: true, owned }; } catch (err) { log( `heartbeat failed session=${sessionId} turn=${turnId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, ); - return { streamId: undefined, interrupted: false, confirmed: false }; + return { + streamId: undefined, + interrupted: false, + confirmed: false, + owned: false, + }; } } @@ -283,10 +295,14 @@ export async function startAliveWatchdog( proposal?: SessionProposal, ): Promise<{ release: () => Promise; + /** Stop heartbeating without publishing turn-end; used before durable admission. */ + abandon: () => void; credential: () => string; streamId: () => string | undefined; /** False when the FIRST beat reported `is_current_turn: false` — another turn owns the session. */ admitted: boolean; + /** True only when the awaited first heartbeat confirmed this turn owns the session. */ + firstBeatOwned: boolean; }> { // Session coordination and standalone turns share this lease. The watchdog owns it here so // heartbeat, persistence, and trace export all observe the same current credential. @@ -300,6 +316,7 @@ export async function startAliveWatchdog( const handleBeat = (result: { streamId: string | undefined; interrupted: boolean; + owned: boolean; }): void => { if (result.streamId) streamId = result.streamId; if (result.interrupted && !interruptedFired) { @@ -365,8 +382,13 @@ export async function startAliveWatchdog( proposal, ); }, + abandon() { + clearInterval(interval); + credentialLease.release(); + }, credential: credentialLease.credential, streamId: () => streamId, + firstBeatOwned: first.owned, }; } diff --git a/services/runner/src/sessions/continuation-admission.ts b/services/runner/src/sessions/continuation-admission.ts new file mode 100644 index 00000000000..57b3d14f934 --- /dev/null +++ b/services/runner/src/sessions/continuation-admission.ts @@ -0,0 +1,114 @@ +/** + * Process-local admission barrier for durable continuation commands. + * + * The API may deliver one committed command more than once. Every delivery carries the same + * `controlCommandId`; only its leader may cross the boundary into execution registration. A + * concurrent duplicate waits for that leader to finish the durable outcome callback, then + * acknowledges the same admission without starting an engine run. + * + * This is deliberately an admission cache, not the durable source of truth. The API command row is + * durable. A leader that cannot report admission releases its cache entry, so a later delivery may + * retry. Once the API accepts the `started` outcome, duplicates remain no-ops for the cache TTL. + */ + +const ADMISSION_TTL_MS = 30 * 60 * 1000; + +interface PendingAdmission { + phase: "pending"; + executionId: string; + insertedAt: number; + settled: Promise; + settle: (admitted: boolean) => void; +} + +interface AppliedAdmission { + phase: "applied"; + executionId: string; + insertedAt: number; +} + +type Admission = PendingAdmission | AppliedAdmission; + +export type ContinuationAdmissionClaim = + | { + role: "leader"; + executionId: string; + admit: () => void; + release: () => void; + } + | { + role: "duplicate"; + /** The first delivery's execution id is authoritative for duplicate outcome reports. */ + executionId: string; + /** False means the leader failed before durable admission and this delivery may be retried. */ + admitted: Promise; + }; + +const admissions = new Map(); + +/** Claim a command immediately before creating/registering its fresh execution guard. */ +export function claimContinuationAdmission( + commandId: string, + executionId: string, + now = Date.now(), +): ContinuationAdmissionClaim { + prune(now); + const existing = admissions.get(commandId); + if (existing) { + return { + role: "duplicate", + executionId: existing.executionId, + admitted: + existing.phase === "applied" ? Promise.resolve(true) : existing.settled, + }; + } + + let settle!: (admitted: boolean) => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const pending: PendingAdmission = { + phase: "pending", + executionId, + insertedAt: now, + settled, + settle, + }; + admissions.set(commandId, pending); + + return { + role: "leader", + executionId, + admit: () => { + if (admissions.get(commandId) !== pending) return; + admissions.set(commandId, { + phase: "applied", + executionId, + insertedAt: now, + }); + pending.settle(true); + }, + release: () => { + if (admissions.get(commandId) !== pending) return; + admissions.delete(commandId); + pending.settle(false); + }, + }; +} + +function prune(now: number): void { + for (const [commandId, admission] of admissions) { + if (now - admission.insertedAt >= ADMISSION_TTL_MS) { + admissions.delete(commandId); + if (admission.phase === "pending") admission.settle(false); + } + } +} + +/** Test seam: admission state belongs to the process and must not leak between cases. */ +export function resetContinuationAdmissionsForTest(): void { + for (const admission of admissions.values()) { + if (admission.phase === "pending") admission.settle(false); + } + admissions.clear(); +} diff --git a/services/runner/src/sessions/control-channel.ts b/services/runner/src/sessions/control-channel.ts index bc45e57f1ca..752f7ab32ea 100644 --- a/services/runner/src/sessions/control-channel.ts +++ b/services/runner/src/sessions/control-channel.ts @@ -25,6 +25,7 @@ */ import { apiBase } from "../apiBase.ts"; +import { envTimerMs } from "../env.ts"; import { REPLICA_ID } from "./alive.ts"; import { recallCommand, @@ -77,7 +78,10 @@ export interface ParkedLookup { export interface ApplyCommandDeps { /** Overridden in tests. Defaults to the module-level execution registry. */ - findLive?: (projectId: string, sessionId: string) => LiveExecution | undefined; + findLive?: ( + projectId: string, + sessionId: string, + ) => LiveExecution | undefined; /** Whether the keep-alive pool holds this session parked awaiting an approval. */ isParked?: ParkedLookup; /** Overridden in tests. Defaults to the HTTP report below. */ @@ -167,12 +171,19 @@ export async function applyCommand( } } catch (error) { const message = - error instanceof Error ? error.message : String(error ?? "abort failed"); + error instanceof Error + ? error.message + : String(error ?? "abort failed"); outcome.result = "applied"; outcome.execution.state = "failed"; outcome.execution.error = message.slice(0, 2000); - updateCommandOutcome(command.id, { result: "applied", executionState: "failed" }); - log(`abort FAILED command=${command.id} session=${command.sessionId}: ${message}`); + updateCommandOutcome(command.id, { + result: "applied", + executionState: "failed", + }); + log( + `abort FAILED command=${command.id} session=${command.sessionId}: ${message}`, + ); } } @@ -241,7 +252,10 @@ function decideOutcome( // execution that can still be stopped. return { result: "obsolete", - execution: { id: command.target.turnId ?? live.turnId, state: "not_running" }, + execution: { + id: command.target.turnId ?? live.turnId, + state: "not_running", + }, }; } @@ -297,3 +311,55 @@ export async function reportOutcome( `outcome reported command=${command.id} session=${command.sessionId} state=${outcome.execution.state}`, ); } + +/** + * Confirm that a durable continuation command crossed the runner's admission barrier. + * + * Unlike Stop's best-effort terminal report, this acknowledgement is a prerequisite for starting + * the continuation engine: without it the API could redeliver after a transport failure and run the + * approved side effect twice. The API returns `admitted: true` only to the report that wins the + * pending/claimed-to-applied transition. An already-applied duplicate returns `false`, which is a + * successful acknowledgement but never permission to start an engine. A 409 is a real + * command/execution mismatch and must not start the engine. + */ +export async function reportContinuationAdmission(input: { + commandId: string; + sessionId: string; + executionId: string; +}): Promise { + const token = process.env.AGENTA_RUNNER_TOKEN; + if (!token) { + throw new Error("AGENTA_RUNNER_TOKEN is not set"); + } + const url = `${apiBase()}/sessions/control/commands/${encodeURIComponent(input.commandId)}/outcome`; + const res = await fetch(url, { + method: "POST", + signal: AbortSignal.timeout( + envTimerMs("AGENTA_RUNNER_CONTROL_OUTCOME_TIMEOUT_MS", 5_000), + ), + headers: { + "content-type": "application/json", + "x-agenta-runner-token": token, + }, + body: JSON.stringify({ + replica_id: REPLICA_ID, + result: "applied", + execution: { + id: input.executionId, + state: "started", + }, + }), + }); + if (!res.ok) { + throw new Error(`continuation admission outcome HTTP ${res.status}`); + } + const response = (await res.json()) as { admitted?: unknown }; + if (typeof response.admitted !== "boolean") { + throw new Error("continuation admission outcome omitted boolean admitted"); + } + log( + `continuation outcome command=${input.commandId} session=${input.sessionId} ` + + `turn=${input.executionId} admitted=${response.admitted}`, + ); + return response.admitted; +} diff --git a/services/runner/tests/unit/continuation-admission.test.ts b/services/runner/tests/unit/continuation-admission.test.ts new file mode 100644 index 00000000000..47f685b7ba0 --- /dev/null +++ b/services/runner/tests/unit/continuation-admission.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "vitest"; + +import { + claimContinuationAdmission, + resetContinuationAdmissionsForTest, +} from "../../src/sessions/continuation-admission.ts"; + +afterEach(resetContinuationAdmissionsForTest); + +describe("durable continuation admission", () => { + it("allows one leader and makes a concurrent duplicate wait for its durable outcome", async () => { + const leader = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(leader.role, "leader"); + + const duplicate = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(duplicate.role, "duplicate"); + let duplicateSettled = false; + void duplicate.admitted.then(() => { + duplicateSettled = true; + }); + await Promise.resolve(); + assert.equal(duplicateSettled, false); + + leader.admit(); + assert.equal(await duplicate.admitted, true); + }); + + it("makes a failure before durable admission retryable", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + const waiting = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(waiting.role, "duplicate"); + + first.release(); + assert.equal(await waiting.admitted, false); + + const retry = claimContinuationAdmission("command-1", "turn-1", 3); + assert.equal(retry.role, "leader"); + }); + + it("pins duplicate reports to the first delivery's execution id", async () => { + const leader = claimContinuationAdmission("command-1", "turn-original", 1); + assert.equal(leader.role, "leader"); + leader.admit(); + + const duplicate = claimContinuationAdmission( + "command-1", + "turn-conflicting", + 2, + ); + assert.equal(duplicate.role, "duplicate"); + assert.equal(duplicate.executionId, "turn-original"); + assert.equal(await duplicate.admitted, true); + }); + + it("requires a fresh API admission decision after the applied cache expires", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + + const cached = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(cached.role, "duplicate"); + + const afterTtl = claimContinuationAdmission( + "command-1", + "turn-1", + 30 * 60 * 1000 + 1, + ); + assert.equal(afterTtl.role, "leader"); + }); +}); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index 0437d7b096a..142093a9c75 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -30,6 +30,7 @@ import { liveExecutions, resetExecutionsForTest, } from "../../src/sessions/execution-registry.ts"; +import { resetContinuationAdmissionsForTest } from "../../src/sessions/continuation-admission.ts"; const TOKEN_ENV = "AGENTA_RUNNER_TOKEN"; const previousToken = process.env[TOKEN_ENV]; @@ -39,6 +40,7 @@ const previousLimit = process.env[LIMIT_ENV]; afterEach(() => { resetExecutionsForTest(); + resetContinuationAdmissionsForTest(); vi.restoreAllMocks(); vi.unstubAllEnvs(); if (previousToken === undefined) delete process.env[TOKEN_ENV]; @@ -928,6 +930,322 @@ describe("createAgentServer", () => { }); } + it("admits one execution for duplicate durable continuation delivery and re-reports started", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + const signals: AbortSignal[] = []; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.ok(signal); + signals.push(signal); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const admissionReports: Array> = []; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.includes("/sessions/control/commands/command-1/outcome")) { + const headers = new Headers(init?.headers); + assert.equal( + headers.get("x-agenta-runner-token"), + TEST_TOKEN, + "continuation outcome authenticates with the runner token", + ); + admissionReports.push(JSON.parse(String(init?.body))); + return Response.json({ + command: { id: "command-1", state: "applied" }, + admitted: admissionReports.length === 1, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-1" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-1", + turnId: "continuation-turn-1", + projectId: "project-1", + controlCommandId: "command-1", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = () => + fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + const first = await deliver(); + assert.equal(first.status, 200); + const firstRecords = (await first.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(firstRecords.at(-1).result.ok, true); + + const duplicate = await deliver(); + assert.equal(duplicate.status, 200); + const duplicateRecords = (await duplicate.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(duplicateRecords.at(-1).result.ok, true); + assert.equal( + duplicateRecords.at(-1).result.stopReason, + "control_command_duplicate", + ); + + assert.equal(runCalls, 1); + assert.equal(signals.length, 1); + assert.equal(signals[0].aborted, false); + assert.equal(admissionReports.length, 2); + for (const report of admissionReports) { + assert.equal(report.result, "applied"); + assert.equal(report.execution.id, "continuation-turn-1"); + assert.equal(report.execution.state, "started"); + assert.equal(typeof report.replica_id, "string"); + } + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("keeps a continuation retryable when its admission outcome cannot be reported", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.equal(signal?.aborted, false); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.includes("/sessions/control/commands/command-retry/outcome")) { + reportCalls += 1; + return reportCalls === 1 + ? new Response("unavailable", { status: 503 }) + : Response.json({ + command: { id: "command-retry", state: "applied" }, + admitted: true, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-retry" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-retry", + turnId: "continuation-turn-retry", + projectId: "project-1", + controlCommandId: "command-retry", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const failed = await deliver(); + assert.equal(failed.at(-1).result.ok, false); + assert.equal( + runCalls, + 0, + "engine does not start before durable admission", + ); + + const retried = await deliver(); + assert.equal(retried.at(-1).result.ok, true); + assert.equal(runCalls, 1); + assert.equal(reportCalls, 2); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("does not report or run a continuation until a fresh controller owns the alive lock", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let activeHeartbeatCalls = 0; + let reportCalls = 0; + const engineSignals: AbortSignal[] = []; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.ok(signal); + engineSignals.push(signal); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + const body = JSON.parse(String(init?.body)); + if (body.is_running) activeHeartbeatCalls += 1; + return Response.json({ + stream: { id: "stream-ownership" }, + is_current_turn: + body.is_running === false || activeHeartbeatCalls > 1, + }); + } + if ( + url.includes("/sessions/control/commands/command-ownership/outcome") + ) { + reportCalls += 1; + return Response.json({ + command: { id: "command-ownership", state: "applied" }, + admitted: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-ownership", + turnId: "continuation-turn-ownership", + projectId: "project-1", + controlCommandId: "command-ownership", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const rejected = await deliver(); + assert.equal(rejected.at(-1).result.ok, false); + assert.equal(reportCalls, 0, "ownership rejection precedes outcome"); + assert.equal( + runCalls, + 0, + "ownership rejection precedes engine invocation", + ); + + const retried = await deliver(); + assert.equal(retried.at(-1).result.ok, true); + assert.equal(reportCalls, 1); + assert.equal(runCalls, 1); + assert.equal(engineSignals.length, 1); + assert.equal( + engineSignals[0].aborted, + false, + "the retry receives a fresh, un-aborted controller", + ); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("does not run when the API says another replica already admitted the command", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async () => { + runCalls += 1; + return { ok: true, output: "must not run", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-cross-replica" }, + is_current_turn: true, + }); + } + if ( + url.includes("/sessions/control/commands/command-applied/outcome") + ) { + reportCalls += 1; + return Response.json({ + command: { id: "command-applied", state: "applied" }, + admitted: false, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-cross-replica", + turnId: "continuation-turn-cross-replica", + projectId: "project-1", + controlCommandId: "command-applied", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const first = await deliver(); + assert.equal(first.at(-1).result.ok, true); + assert.equal(first.at(-1).result.stopReason, "control_command_duplicate"); + const duplicate = await deliver(); + assert.equal( + duplicate.at(-1).result.stopReason, + "control_command_duplicate", + ); + assert.equal(runCalls, 0); + assert.equal(reportCalls, 2, "same-process duplicate re-acknowledges"); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + it("redacts this run's credentials from the stderr stack log when a run throws", async () => { // A per-run provider key rides ONLY the typed request (never process env). When the run // throws with that key captured in the error message/stack (an auth failure echoing it, diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index c8514beaa61..4da54c790ee 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -59,6 +59,7 @@ const KNOWN_REQUEST_KEYS = [ "turnId", "detached", "projectId", + "controlCommandId", "effectiveParameters", ] as const; From fb514c59971ef28e3a238d8b1d11496d99b82fc0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 12:32:13 +0200 Subject: [PATCH 003/133] feat(api): persist durable approval continuations Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/entrypoints/routers.py | 18 +- api/entrypoints/worker_streams.py | 6 +- ...7_add_durable_interaction_continuations.py | 76 ++ api/oss/src/apis/fastapi/sessions/models.py | 32 +- api/oss/src/apis/fastapi/sessions/router.py | 141 ++- api/oss/src/core/sessions/commands/dtos.py | 2 + .../src/core/sessions/commands/interfaces.py | 42 +- api/oss/src/core/sessions/commands/service.py | 818 ++++++++++++++++- api/oss/src/core/sessions/commands/types.py | 18 + api/oss/src/core/sessions/executions/dtos.py | 22 +- .../core/sessions/executions/interfaces.py | 48 + .../core/sessions/interactions/interfaces.py | 3 + .../src/core/sessions/interactions/service.py | 27 +- .../src/core/sessions/records/interfaces.py | 9 + api/oss/src/core/sessions/records/service.py | 78 +- api/oss/src/core/workflows/service.py | 70 +- .../http/sessions/control_delivery_direct.py | 26 +- .../src/dbs/postgres/sessions/commands/dao.py | 213 ++++- .../dbs/postgres/sessions/commands/dbes.py | 5 +- .../dbs/postgres/sessions/executions/dao.py | 167 +++- .../dbs/postgres/sessions/executions/dbes.py | 18 +- .../dbs/postgres/sessions/interactions/dao.py | 24 +- .../src/dbs/postgres/sessions/records/dao.py | 31 + .../sessions/interactions_dispatcher.py | 20 +- .../tasks/asyncio/sessions/orphan_sweep.py | 89 ++ ...test_interaction_continuation_admission.py | 826 ++++++++++++++++++ .../sessions/test_late_record_quarantine.py | 63 +- .../sessions/test_orphan_sweep_thresholds.py | 95 ++ .../test_respond_interaction_durable.py | 177 ++++ .../sessions/test_session_cancel_admission.py | 3 + .../sessions/test_session_commands_dao.py | 480 +++++++++- .../unit/workflows/test_invoke_detached.py | 79 ++ 32 files changed, 3577 insertions(+), 149 deletions(-) create mode 100644 api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py create mode 100644 api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py create mode 100644 api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 3683c9a9978..f565da866af 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager import asyncio import time +from uuid import UUID import agenta as ag from fastapi import FastAPI @@ -868,11 +869,12 @@ async def lifespan(*args, **kwargs): # Detached workflow start: hand the run to the runner and return on the started handshake # (no awaiting the run). Shared by both detached consumers (triggers + interactions respond). -async def _dispatch_detached_run(*, project_id, user_id, request) -> str: +async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) -> str: result = await workflows_service.invoke_workflow_detached( project_id=project_id, user_id=user_id, request=request, + run_id=run_id, ) return result.run_id @@ -1148,9 +1150,21 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: streams_service=session_streams_service, interactions_service=interactions_service, lock_engine=_lock_engine, - delivery=DirectControlDelivery(), + delivery=DirectControlDelivery( + continue_interaction=lambda command: _interactions_dispatcher.respond( + project_id=command.project_id, + user_id=command.created_by_id, + interaction_id=UUID(str(command.data["interaction_id"])), + answer=command.data["answer"], + control_command_id=command.id, + continuation_execution_id=command.target_turn_id, + ) + ), executions_dao=session_executions_dao, ) +workflows_service.set_session_continuation_resumer( + session_commands_service.resume_recoverable_continuation +) sessions = SessionsRouter( streams_service=session_streams_service, diff --git a/api/entrypoints/worker_streams.py b/api/entrypoints/worker_streams.py index e9125c1eb00..2aad6cf942a 100644 --- a/api/entrypoints/worker_streams.py +++ b/api/entrypoints/worker_streams.py @@ -35,6 +35,7 @@ from oss.src.dbs.postgres.events.dao import EventsDAO from oss.src.dbs.postgres.secrets.dao import SecretsDAO from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO from oss.src.dbs.postgres.tracing.dao import TracingDAO from oss.src.dbs.postgres.webhooks.dao import WebhooksDAO @@ -88,7 +89,10 @@ async def _build_spans_worker(redis_client: Redis) -> StreamConsumer: async def _build_records_worker(redis_client: Redis) -> StreamConsumer: watch_publisher = SessionsWatchPublisher(redis_client=redis_client) return RecordsWorker( - service=RecordsService(records_dao=RecordsDAO()), + service=RecordsService( + records_dao=RecordsDAO(), + executions_dao=SessionExecutionsDAO(), + ), redis_client=redis_client, stream_name=RECORD_STREAM_NAME, consumer_group="worker-records", diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py new file mode 100644 index 00000000000..daef355161b --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py @@ -0,0 +1,76 @@ +"""add durable interaction continuation executions + +Revision ID: oss000000027 +Revises: oss000000026 +Create Date: 2026-09-04 12:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000027" +down_revision: Union[str, None] = "oss000000026" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction')", + ) + + op.alter_column("session_executions", "terminal_outcome", nullable=True) + op.alter_column("session_executions", "settled_by", nullable=True) + op.alter_column("session_executions", "settled_at", nullable=True) + op.add_column( + "session_executions", + sa.Column("state", sa.String(), server_default="terminal", nullable=False), + ) + op.add_column( + "session_executions", + sa.Column("parent_execution_id", sa.String(), nullable=True), + ) + op.add_column( + "session_executions", + sa.Column("source_interaction_id", sa.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "session_executions", + sa.Column("error", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.alter_column( + "session_executions", "state", server_default="active", nullable=False + ) + op.create_index( + "uq_session_executions_source_interaction", + "session_executions", + ["project_id", "source_interaction_id"], + unique=True, + postgresql_where=sa.text("source_interaction_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "uq_session_executions_source_interaction", table_name="session_executions" + ) + op.drop_column("session_executions", "error") + op.drop_column("session_executions", "source_interaction_id") + op.drop_column("session_executions", "parent_execution_id") + op.drop_column("session_executions", "state") + op.execute("DELETE FROM session_executions WHERE terminal_outcome IS NULL") + op.alter_column("session_executions", "settled_at", nullable=False) + op.alter_column("session_executions", "settled_by", nullable=False) + op.alter_column("session_executions", "terminal_outcome", nullable=False) + + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", "session_commands", "kind IN ('cancel')" + ) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index f54a1bbe00d..6b13652b032 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -258,6 +258,7 @@ class SessionInteractionRespondRequest(BaseModel): # message?: str} — the dispatcher composes the full resume conversation server-side # (interactions_dispatcher.compose_approval_messages). Other kinds pass through as-is. answer: Optional[Dict[str, Any]] = None + expected_execution_id: Optional[str] = None # --------------------------------------------------------------------------- @@ -461,6 +462,17 @@ class SessionExecutionRef(BaseModel): state: Literal["stopping", "idle"] +class SessionInteractionContinuationExecution(BaseModel): + id: str + state: Literal["pending_delivery", "recoverable", "running"] + + +class SessionInteractionContinuationResponse(BaseModel): + interaction: SessionInteraction + command: SessionCommandRef + execution: SessionInteractionContinuationExecution + + class SessionCancelResponse(BaseModel): command: SessionCommandRef execution: SessionExecutionRef @@ -474,7 +486,13 @@ class SessionExecutionOutcome(BaseModel): # stopped: cancelled as asked. not_running: no such execution on this runner. # superseded_by_newer_turn: the held execution started after the command arrived. # failed: the cancel itself failed. - state: Literal["stopped", "failed", "not_running", "superseded_by_newer_turn"] + state: Literal[ + "stopped", + "failed", + "not_running", + "superseded_by_newer_turn", + "started", + ] # Short and human-readable, present only when `state` is "failed". error: Optional[str] = Field(default=None, max_length=2000) @@ -493,10 +511,20 @@ class SessionCommandSettlement(BaseModel): id: UUID state: Literal["applied", "obsolete"] outcome: Literal[ - "stopped", "not_running", "superseded_by_newer_turn", "failed", "lost" + "stopped", + "not_running", + "superseded_by_newer_turn", + "failed", + "lost", + "started", ] settled_at: Optional[datetime] = None class SessionControlOutcomeResponse(BaseModel): command: SessionCommandSettlement + admitted: bool = False + + +class SessionContinuationResumeResponse(BaseModel): + resumed: bool diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 96281be9e4f..1fa9e42ef7e 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -82,6 +82,7 @@ from oss.src.core.sessions.commands.types import ( ExecutionExpectationFailed, SessionCommandIdempotencyConflict, + InteractionResponseConflict, SessionCommandNotClaimable, SessionCommandNotFound, ) @@ -147,6 +148,7 @@ SessionCommandSettlement, SessionControlOutcomeRequest, SessionControlOutcomeResponse, + SessionContinuationResumeResponse, SessionExecutionRef, # streams SessionDetachRequest, @@ -166,6 +168,8 @@ SessionInteractionCreateRequest, SessionInteractionQueryRequest, SessionInteractionRespondRequest, + SessionInteractionContinuationExecution, + SessionInteractionContinuationResponse, SessionInteractionResolution, SessionInteractionResponse, SessionInteractionsResponse, @@ -1024,11 +1028,13 @@ def __init__( # import the tasks layer). When present, the no-worker respond fallback goes through # it so both paths share ONE answer-composition implementation. interactions_dispatcher: Optional[Any] = None, + commands_service: Optional[SessionCommandsService] = None, ) -> None: self.interactions_service = interactions_service self.workflows_service = workflows_service self.respond_task = respond_task self.interactions_dispatcher = interactions_dispatcher + self.commands_service = commands_service self.router = APIRouter() @@ -1259,7 +1265,7 @@ async def respond_interaction( request: Request, interaction_id: UUID, body: SessionInteractionRespondRequest, - ) -> SessionInteractionResponse: + ) -> Any: project_id: UUID = request.state.project_id user_id: UUID = request.state.user_id @@ -1271,6 +1277,80 @@ async def respond_interaction( if not authorized: raise FORBIDDEN_EXCEPTION + if env.agenta.sessions.durable_stop and self.commands_service is not None: + idempotency_key = (request.headers.get("Idempotency-Key") or "").strip() + if not idempotency_key: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "Idempotency-Key is required for a durable response.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "required"}, + "next_step": "Retry with a stable Idempotency-Key header.", + }, + ) + if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_CHARACTERS: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + }, + ) + if body.answer is None: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "answer is required for a durable response.", + "retryable": False, + "details": {"field": "answer", "reason": "required"}, + }, + ) + try: + admission = await self.commands_service.respond_interaction( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_id=interaction_id, + answer=body.answer, + expected_execution_id=body.expected_execution_id, + idempotency_key=idempotency_key, + ) + except InteractionResponseConflict as error: + return JSONResponse( + status_code=( + status.HTTP_422_UNPROCESSABLE_ENTITY + if error.code == "validation_error" + else status.HTTP_409_CONFLICT + ), + content={ + "code": error.code, + "message": error.message, + "retryable": False, + **({"details": error.details} if error.details else {}), + }, + ) + + response = SessionInteractionContinuationResponse( + interaction=admission.interaction, + command=SessionCommandRef( + id=admission.command.id, + state=admission.command.state.value, + ), + execution=SessionInteractionContinuationExecution( + id=admission.execution_id, + state=admission.execution_state.value, + ), + ) + return JSONResponse( + status_code=status.HTTP_202_ACCEPTED, + content=response.model_dump(mode="json"), + ) + try: interaction = await self.interactions_service.fetch_interaction( project_id=project_id, @@ -2208,6 +2288,16 @@ async def wrapper(*args, **kwargs): status_code=status.HTTP_409_CONFLICT, detail={"message": e.message, "state": e.state}, ) from e + except InteractionResponseConflict as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": e.code, + "message": e.message, + "retryable": False, + **({"details": e.details} if e.details else {}), + }, + ) from e return wrapper @@ -2243,6 +2333,13 @@ def __init__( operation_id="cancel_session_execution", tags=["Sessions"], ) + self.router.add_api_route( + "/sessions/{session_id}/continuations/resume", + self.resume_session_continuation, + methods=["POST"], + operation_id="resume_session_continuation", + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/control/commands/{command_id}/outcome", self.report_command_outcome, @@ -2318,6 +2415,32 @@ async def cancel_session_execution( content=body.model_dump(mode="json"), ) + @intercept_exceptions() + @_handle_command_exceptions() + async def resume_session_continuation( + self, + request: Request, + session_id: str, + ) -> SessionContinuationResumeResponse: + project_id = request.state.project_id + user_id = request.state.user_id + + has_permission = await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + resumed = False + if env.agenta.sessions.durable_stop: + resumed = await self._service.resume_recoverable_continuation( + project_id=UUID(str(project_id)), + session_id=session_id, + ) + return SessionContinuationResumeResponse(resumed=resumed) + @intercept_exceptions() @_handle_command_exceptions() async def report_command_outcome( @@ -2328,7 +2451,7 @@ async def report_command_outcome( ) -> SessionControlOutcomeResponse: _assert_runner_token(request) - settled = await self._service.report_outcome( + report = await self._service.report_outcome( command_id=command_id, replica_id=payload.replica_id, result=payload.result, @@ -2338,11 +2461,14 @@ async def report_command_outcome( ) return SessionControlOutcomeResponse( command=SessionCommandSettlement( - id=settled.id, - state=settled.state.value, - outcome=settled.outcome.value if settled.outcome else "failed", - settled_at=settled.settled_at, - ) + id=report.command.id, + state=report.command.state.value, + outcome=( + report.command.outcome.value if report.command.outcome else "failed" + ), + settled_at=report.command.settled_at, + ), + admitted=report.admitted, ) @@ -2420,6 +2546,7 @@ def __init__( workflows_service=workflows_service, respond_task=respond_task, interactions_dispatcher=interactions_dispatcher, + commands_service=commands_service, ) self.attachments = SessionAttachmentsRouter( attachments_service=attachments_service, diff --git a/api/oss/src/core/sessions/commands/dtos.py b/api/oss/src/core/sessions/commands/dtos.py index 27b2f9c2ad2..adb427bf9d4 100644 --- a/api/oss/src/core/sessions/commands/dtos.py +++ b/api/oss/src/core/sessions/commands/dtos.py @@ -24,6 +24,7 @@ class SessionCommandKind(str, Enum): cancel = "cancel" + continue_interaction = "continue_interaction" class SessionCommandState(str, Enum): @@ -45,6 +46,7 @@ class SessionCommandOutcome(str, Enum): ) failed = "failed" # the cancel itself failed lost = "lost" # nobody ever reported; the sweep settled it + started = "started" # a continuation was admitted by the runner class SessionCommand(Identifier, Lifecycle): diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index 9f87cd73106..8daa58c6794 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -80,10 +80,22 @@ async def create_command( user_id: Optional[UUID], command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> SessionCommand: """Insert one command and, in the SAME transaction, stamp the session row's `stopping_turn_id`. Idempotent on `(project_id, session_id, idempotency_key)`.""" + @abstractmethod + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """The command previously created for this session-scoped retry key.""" + @abstractmethod async def create_command_with_status( self, @@ -95,26 +107,38 @@ async def create_command_with_status( """Create a command and report whether this call inserted it.""" @abstractmethod - async def fetch_by_idempotency_key( + async def fetch_open_command( self, *, project_id: UUID, session_id: str, - idempotency_key: str, + kind: SessionCommandKind, + target_turn_id: Optional[str], + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - """The command previously created for this session-scoped retry key.""" + """The open (`pending` or `claimed`) command for this exact target, if one exists. + This is what collapses two Stops in a row onto one command.""" - @abstractmethod - async def fetch_open_command( + async def fetch_resumable_continuation( self, *, project_id: UUID, session_id: str, - kind: SessionCommandKind, - target_turn_id: Optional[str], ) -> Optional[SessionCommand]: - """The open (`pending` or `claimed`) command for this exact target, if one exists. - This is what collapses two Stops in a row onto one command.""" + """The continuation whose open/recoverable execution owns the next turn.""" + raise NotImplementedError + + async def reopen_continuation( + self, + *, + project_id: UUID, + command_id: UUID, + target_turn_id: str, + replacement_turn_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """Atomically retarget an exhausted continuation to a fresh execution attempt.""" + raise NotImplementedError @abstractmethod async def fetch_command( diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 3382652cdbb..26a17af3f2f 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, List, Optional, Tuple -from uuid import UUID +from uuid import UUID, uuid4 from oss.src.core.sessions.commands.dtos import ( SessionCommand, @@ -46,15 +46,24 @@ from oss.src.core.sessions.commands.interfaces import ( CommandCreateResult, ControlDeliveryPort, + DeliveryReceipt, SessionCommandsDAOInterface, ) from oss.src.core.sessions.commands.types import ( ExecutionExpectationFailed, SessionCommandIdempotencyConflict, + IdempotencyKeyReused, + InteractionResponseConflict, SessionCommandNotClaimable, SessionCommandNotFound, ) +from oss.src.core.sessions.executions.dtos import SessionExecutionState from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionStatus, + SessionInteractionTransition, +) from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.streams.dtos import ( SessionStreamCommandRequest, @@ -79,6 +88,10 @@ log = get_module_logger(__name__) +class _ContinuationReopenLost(Exception): + """The durable command changed while a fresh recovery attempt was being created.""" + + class CancelAdmission: """What admission decided, in the shape the route answers with.""" @@ -97,6 +110,27 @@ def __init__( self.accepted = accepted +class InteractionContinuationAdmission: + def __init__( + self, + *, + interaction: SessionInteraction, + command: SessionCommand, + execution_id: str, + execution_state: SessionExecutionState = SessionExecutionState.pending_delivery, + ) -> None: + self.interaction = interaction + self.command = command + self.execution_id = execution_id + self.execution_state = execution_state + + +class CommandOutcomeReport: + def __init__(self, *, command: SessionCommand, admitted: bool) -> None: + self.command = command + self.admitted = admitted + + class _SettlementRejected(Exception): pass @@ -241,8 +275,6 @@ async def request_cancel( command = created.command return CancelAdmission(command=command, execution_id=None, accepted=False) - # Two Stops in a row are one intent. Collapse onto the open command for the same target - # BEFORE inserting, so this holds even when the caller sends a different idempotency key. open_command = await self._dao.fetch_open_command( project_id=project_id, session_id=session_id, @@ -251,8 +283,6 @@ async def request_cancel( ) if open_command is not None: if open_command.state == SessionCommandState.pending: - # Nobody has taken it. The first delivery may have failed, so try again; the - # runner deduplicates by command id, so a duplicate arrival aborts nothing twice. await self._deliver(open_command) return CancelAdmission( command=open_command, @@ -260,27 +290,423 @@ async def request_cancel( accepted=True, ) - created = await self._insert( - project_id=project_id, - user_id=user_id, - session_id=session_id, - received_at=received_at, - target_turn_id=target_turn_id, - expected_turn_id=expected_execution_id, - idempotency_key=idempotency_key, - state=SessionCommandState.pending, - outcome=None, - stopping_turn_id=target_turn_id, - ) - if not created.inserted: - return self._admission_for_existing(created.command) - command = created.command + if self._executions is None or not hasattr( + self._executions, "lock_for_control" + ): + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command + else: + cancelled_interactions = 0 + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=target_turn_id, + transaction=transaction, + ) + if execution.terminal_outcome is not None: + raise ExecutionExpectationFailed( + expected=expected_execution_id or target_turn_id, + current=None, + ) + open_command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + transaction=transaction, + ) + if open_command is not None: + command = open_command + else: + await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=target_turn_id, + state=SessionExecutionState.stopping, + transaction=transaction, + ) + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + transaction=transaction, + ) + command = created.command + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_turn_id, + transaction=transaction, + publish=False, + ) + ) + if cancelled_interactions: + await self._interactions.publish_session_pending_cancelled( + project_id=project_id, session_id=session_id + ) # The row is committed. Everything from here is promptness, not correctness. - await self._deliver(command) + if command.state == SessionCommandState.pending: + await self._deliver(command) return CancelAdmission( command=command, execution_id=target_turn_id, accepted=True ) + async def respond_interaction( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_id: UUID, + answer: dict[str, Any], + expected_execution_id: Optional[str], + idempotency_key: str, + ) -> InteractionContinuationAdmission: + if self._executions is None: + raise RuntimeError( + "durable interaction responses require executions storage" + ) + + async with self._dao.transaction() as transaction: + interaction = await self._interactions.fetch_interaction( + project_id=project_id, + interaction_id=interaction_id, + transaction=transaction, + ) + source_execution_id = interaction.turn_id + if source_execution_id is None: + raise InteractionResponseConflict( + code="validation_error", + message="The interaction is not linked to an execution.", + ) + if ( + expected_execution_id is not None + and expected_execution_id != source_execution_id + ): + raise InteractionResponseConflict( + code="execution_mismatch", + message="The interaction belongs to a different execution.", + details={"current_execution_id": source_execution_id}, + ) + + source = await self._executions.lock_for_control( + project_id=project_id, + session_id=interaction.session_id, + execution_id=source_execution_id, + transaction=transaction, + ) + interaction = await self._interactions.fetch_interaction( + project_id=project_id, + interaction_id=interaction_id, + transaction=transaction, + for_update=True, + ) + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=interaction.session_id, + idempotency_key=idempotency_key, + transaction=transaction, + ) + if existing is not None: + same_request = ( + existing.kind == SessionCommandKind.continue_interaction + and existing.expected_turn_id == source_execution_id + and existing.data is not None + and existing.data.get("interaction_id") == str(interaction_id) + and interaction.data is not None + and interaction.data.resolution == answer + ) + if not same_request: + raise IdempotencyKeyReused() + execution_id = str(existing.data["continuation_execution_id"]) + admission = InteractionContinuationAdmission( + interaction=interaction, + command=existing, + execution_id=execution_id, + ) + else: + if interaction.status != SessionInteractionStatus.pending: + raise InteractionResponseConflict( + code="execution_terminal", + message="The interaction is no longer pending.", + details={ + "interaction_status": ( + interaction.status.value + if interaction.status is not None + else None + ) + }, + ) + if source.terminal_outcome is not None or source.state in ( + SessionExecutionState.stopping, + SessionExecutionState.terminal, + ): + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={"execution_state": source.state.value}, + ) + + transitioned = await self._interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=interaction.session_id, + token=interaction.token, + status=SessionInteractionStatus.responded, + resolution=answer, + ), + transaction=transaction, + publish=False, + ) + result = await self._executions.settle( + project_id=project_id, + session_id=interaction.session_id, + execution_id=source_execution_id, + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + if not result.won: + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={ + "terminal_outcome": result.settlement.terminal_outcome + }, + ) + + execution_id = str(uuid4()) + await self._executions.create_continuation( + project_id=project_id, + session_id=interaction.session_id, + execution_id=execution_id, + parent_execution_id=source_execution_id, + source_interaction_id=interaction_id, + transaction=transaction, + ) + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=interaction.session_id, + kind=SessionCommandKind.continue_interaction, + target_turn_id=execution_id, + expected_turn_id=source_execution_id, + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": execution_id, + }, + idempotency_key=idempotency_key, + ), + transaction=transaction, + ) + if ( + command.kind != SessionCommandKind.continue_interaction + or command.target_turn_id != execution_id + or command.data is None + or command.data.get("interaction_id") != str(interaction_id) + ): + raise IdempotencyKeyReused() + admission = InteractionContinuationAdmission( + interaction=transitioned, + command=command, + execution_id=execution_id, + ) + + try: + await self._interactions.publish_interaction_responded( + project_id=project_id, + session_id=admission.interaction.session_id, + ) + except Exception as error: # noqa: BLE001 - the durable transaction already committed + log.warning( + "interaction response watch publish failed interaction=%s: %s", + interaction_id, + error, + ) + if admission.command.state == SessionCommandState.pending: + try: + receipt = await self._deliver(admission.command) + except Exception as error: # noqa: BLE001 - admission remains accepted + log.warning( + "continuation post-commit delivery failed command=%s: %s", + admission.command.id, + error, + ) + receipt = None + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(admission) + admission.execution_state = SessionExecutionState.recoverable + return admission + + async def _mark_continuation_recoverable( + self, admission: InteractionContinuationAdmission + ) -> None: + if self._executions is None: + return + try: + await self._executions.set_state( + project_id=admission.command.project_id, + session_id=admission.command.session_id, + execution_id=admission.execution_id, + state=SessionExecutionState.recoverable, + error={ + "code": "continuation_delivery_failed", + "retryable": True, + "message": "The continuation is durable and awaiting redelivery.", + }, + ) + except Exception as error: # noqa: BLE001 - recovery projection is best effort + log.error( + "continuation recoverable projection failed command=%s execution=%s: %s", + admission.command.id, + admission.execution_id, + error, + ) + + async def resume_recoverable_continuation( + self, *, project_id: UUID, session_id: str + ) -> bool: + if not env.agenta.sessions.durable_stop: + return False + command = await self._dao.fetch_resumable_continuation( + project_id=project_id, + session_id=session_id, + ) + if command is None: + return False + execution_id = command.target_turn_id + if execution_id is None or self._executions is None: + return True + execution = await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if execution is None: + return True + if execution.state == SessionExecutionState.running: + # A stale heartbeat is not a fencing token: a partitioned runner can still be + # executing the approved side effect. Only the watchdog may turn `running` into + # `recoverable`, after it has collapsed and tombstoned the old ownership. Until + # then this durable continuation still owns Send, but it is never redelivered. + return True + if command.state not in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ): + command = await self._reopen_continuation_attempt( + project_id=project_id, + session_id=session_id, + command=command, + execution=execution, + ) + if command is None: + return True + execution_id = command.target_turn_id + if execution_id is None: + return True + admission = InteractionContinuationAdmission( + interaction=await self._interaction_for_command(command), + command=command, + execution_id=execution_id, + execution_state=SessionExecutionState.recoverable, + ) + try: + receipt = await self._deliver(command) + except Exception as error: # noqa: BLE001 - keep ownership with the durable continuation + log.warning( + "continuation resume delivery failed command=%s: %s", command.id, error + ) + receipt = None + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(admission) + return True + + async def _reopen_continuation_attempt( + self, + *, + project_id: UUID, + session_id: str, + command: SessionCommand, + execution: Any, + ) -> Optional[SessionCommand]: + """Fence a tombstoned attempt and retarget its command in one transaction.""" + if ( + self._executions is None + or execution.state != SessionExecutionState.recoverable + ): + return None + data = command.data or {} + root_execution_id = data.get("continuation_execution_id") + if not isinstance(root_execution_id, str) or not root_execution_id: + return None + replacement_execution_id = str(uuid4()) + try: + async with self._dao.transaction() as transaction: + stored = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=execution.execution_id, + transaction=transaction, + ) + if ( + stored.state != SessionExecutionState.recoverable + or stored.terminal_outcome is not None + ): + return None + settled = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=stored.execution_id, + terminal_outcome=SessionCommandOutcome.lost.value, + settled_by="watchdog", + transaction=transaction, + ) + if not settled.won: + return None + await self._executions.create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=replacement_execution_id, + parent_execution_id=root_execution_id, + source_interaction_id=None, + transaction=transaction, + ) + reopened = await self._dao.reopen_continuation( + project_id=project_id, + command_id=command.id, + target_turn_id=stored.execution_id, + replacement_turn_id=replacement_execution_id, + transaction=transaction, + ) + if reopened is None: + raise _ContinuationReopenLost + return reopened + except _ContinuationReopenLost: + return None + async def _resolve_target( self, *, @@ -326,7 +752,27 @@ async def _insert( state: SessionCommandState, outcome: Optional[SessionCommandOutcome], stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> CommandCreateResult: + if transaction is not None: + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + expected_turn_id=expected_turn_id, + state=state, + outcome=outcome, + settled_at=received_at if outcome is not None else None, + idempotency_key=idempotency_key, + created_at=received_at, + ), + stopping_turn_id=stopping_turn_id, + transaction=transaction, + ) + return CommandCreateResult(command=command, inserted=True) return await self._dao.create_command_with_status( user_id=user_id, command=SessionCommandCreate( @@ -355,19 +801,31 @@ def _admission_for_existing(command: SessionCommand) -> CancelAdmission: # -- delivery ----------------------------------------------------------- # - async def _deliver(self, command: SessionCommand) -> None: + async def _deliver(self, command: SessionCommand) -> Optional[DeliveryReceipt]: """Hand the command to the transport, then record what the transport learned. Never raises. The user's request has already succeeded by the time this runs. """ - command = await self._dao.record_delivery_attempt( - project_id=command.project_id, - command_id=command.id, - now=datetime.now(timezone.utc), - max_deliveries=env.agenta.sessions.commands.max_deliveries, - ) + try: + command = await self._dao.record_delivery_attempt( + project_id=command.project_id, + command_id=command.id, + now=datetime.now(timezone.utc), + max_deliveries=env.agenta.sessions.commands.max_deliveries, + ) + except Exception as error: # noqa: BLE001 - delivery bookkeeping is post-commit + log.warning("control delivery reservation failed: %s", error) + return None if command is None: - return + return None + + try: + command = await self._command_for_delivery(command) + except Exception as error: # noqa: BLE001 - a later sweep or Send can retry + log.warning( + "control delivery hydration failed command=%s: %s", command.id, error + ) + return DeliveryReceipt(status="unreachable", detail=str(error)) try: receipt = await self._delivery.deliver(command=command) @@ -378,22 +836,31 @@ async def _deliver(self, command: SessionCommand) -> None: command.session_id, e, ) - return + return DeliveryReceipt(status="unreachable", detail=str(e)) if receipt.status == "accepted": # Take the claim on the runner's behalf, so the outcome route's guard reads the same # way on every transport: only the holder of the claim writes the outcome. - await self._dao.claim_for_delivery( - project_id=command.project_id, - command_id=command.id, - replica_id=receipt.replica_id or "direct", - lease_seconds=env.agenta.sessions.commands.lease_seconds, - ) - return + try: + await self._dao.claim_for_delivery( + project_id=command.project_id, + command_id=command.id, + replica_id=receipt.replica_id or "direct", + lease_seconds=env.agenta.sessions.commands.lease_seconds, + ) + except Exception as error: # noqa: BLE001 - runner outcome still owns settlement + log.warning( + "control delivery claim projection failed command=%s: %s", + command.id, + error, + ) + return receipt if receipt.status == "not_held": + if command.kind == SessionCommandKind.continue_interaction: + return receipt await self._settle_not_held(command) - return + return receipt log.warning( "control delivery unreachable for command=%s session=%s: %s", @@ -401,6 +868,33 @@ async def _deliver(self, command: SessionCommand) -> None: command.session_id, receipt.detail or "no detail", ) + return receipt + + async def _interaction_for_command( + self, command: SessionCommand + ) -> SessionInteraction: + interaction_id = (command.data or {}).get("interaction_id") + if not isinstance(interaction_id, str): + raise ValueError("continuation command has no interaction id") + return await self._interactions.fetch_interaction( + project_id=command.project_id, + interaction_id=UUID(interaction_id), + ) + + async def _command_for_delivery(self, command: SessionCommand) -> SessionCommand: + if command.kind != SessionCommandKind.continue_interaction: + return command + interaction = await self._interaction_for_command(command) + if interaction.data is None or interaction.data.resolution is None: + raise ValueError("continuation interaction has no durable resolution") + return command.model_copy( + update={ + "data": { + **(command.data or {}), + "answer": interaction.data.resolution, + } + } + ) async def _settle_not_held(self, command: SessionCommand) -> None: """A reachable runner said it does not hold this session. Two different things look @@ -483,6 +977,16 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: ) settled = 0 for command in abandoned: + if command.kind == SessionCommandKind.continue_interaction: + if not env.agenta.sessions.durable_stop: + continue + if command.claim_count < max_deliveries: + await self._deliver(command) + continue + result = await self._settle_exhausted_continuation(command) + if result: + settled += 1 + continue beating = await self._session_is_beating( project_id=command.project_id, session_id=command.session_id, @@ -507,6 +1011,35 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: settled += 1 return settled + async def _settle_exhausted_continuation(self, command: SessionCommand) -> bool: + transition = SessionCommandSettle( + project_id=command.project_id, + command_id=command.id, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.lost, + expected_states=[SessionCommandState.pending, SessionCommandState.claimed], + ) + async with self._dao.transaction() as transaction: + settled = await self._dao.settle_command( + settle=transition, transaction=transaction + ) + if settled is None: + return False + if self._executions is not None and command.target_turn_id is not None: + await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=command.target_turn_id, + state=SessionExecutionState.recoverable, + error={ + "code": "continuation_delivery_exhausted", + "message": "Continuation delivery exhausted its automatic retry budget.", + "retryable": True, + }, + transaction=transaction, + ) + return True + # -- settlement --------------------------------------------------------- # async def settle_execution_lost( @@ -520,6 +1053,40 @@ async def settle_execution_lost( ) -> bool: if self._executions is None: return True + execution = await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if ( + execution is not None + and ( + execution.source_interaction_id is not None + or execution.parent_execution_id is not None + ) + and execution.terminal_outcome is None + ): + recovered = await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + state=SessionExecutionState.recoverable, + error={ + "code": "continuation_execution_lost", + "message": "The continuation runner disappeared before completion.", + "retryable": True, + }, + expected_states=[ + SessionExecutionState.pending_delivery, + SessionExecutionState.running, + ], + ) + if recovered is not None: + return False + # A recoverable continuation deliberately receives no watchdog terminal record. + # Its next delivery resumes the same logical execution. A concurrent terminal + # winner likewise already owns the ending, so neither race permits a lost record. + return False result = await self._executions.settle( project_id=project_id, session_id=session_id, @@ -535,6 +1102,37 @@ async def settle_execution_lost( and winner.settled_by == "watchdog" ) + async def settle_execution_completed( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + ) -> bool: + """Reconcile a persisted runner ending before stale ownership is collapsed.""" + if self._executions is None: + return True + execution = await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if execution is None or ( + execution.source_interaction_id is None + and execution.parent_execution_id is None + ): + return True + if execution.terminal_outcome is not None: + return True + result = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome="completed", + settled_by="runner", + ) + return result.won or result.settlement.terminal_outcome is not None + async def repair_terminal_redis(self) -> int: if self._executions is None: return 0 @@ -578,13 +1176,23 @@ async def report_outcome( execution_id: Optional[str], execution_state: str, error: Optional[str] = None, - ) -> SessionCommand: + ) -> CommandOutcomeReport: """The runner reporting what happened to the execution. Both adapters land here, so settlement has one path on every transport.""" command = await self._dao.fetch_command(command_id=command_id) if command is None: raise SessionCommandNotFound(command_id=str(command_id)) + if command.kind == SessionCommandKind.continue_interaction: + return await self._report_continuation_outcome( + command=command, + replica_id=replica_id, + result=result, + execution_id=execution_id, + execution_state=execution_state, + error=error, + ) + outcome = _OUTCOME_BY_EXECUTION_STATE.get(execution_state) if outcome is None: outcome = SessionCommandOutcome.failed @@ -625,7 +1233,135 @@ async def report_outcome( command_id=str(command_id), state=stored.state.value if stored else "unknown", ) - return settled + return CommandOutcomeReport(command=settled, admitted=True) + + async def _report_continuation_outcome( + self, + *, + command: SessionCommand, + replica_id: str, + result: str, + execution_id: Optional[str], + execution_state: str, + error: Optional[str], + ) -> CommandOutcomeReport: + target = execution_id or command.target_turn_id + if target is None or target != command.target_turn_id: + raise SessionCommandNotClaimable( + command_id=str(command.id), state="execution_mismatch" + ) + if ( + command.state == SessionCommandState.applied + and command.outcome == SessionCommandOutcome.started + ): + return await self._readmit_recoverable_continuation( + command=command, + replica_id=replica_id, + execution_id=target, + ) + started = result == "applied" and execution_state == "started" + settle = SessionCommandSettle( + project_id=command.project_id, + command_id=command.id, + state=( + SessionCommandState.applied if started else SessionCommandState.obsolete + ), + outcome=( + SessionCommandOutcome.started + if started + else SessionCommandOutcome.failed + ), + expected_states=[SessionCommandState.pending, SessionCommandState.claimed], + replica_id=replica_id, + ) + execution_blocked = False + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=command.project_id, + session_id=command.session_id, + execution_id=target, + transaction=transaction, + ) + execution_blocked = ( + execution.terminal_outcome is not None + or execution.state + not in ( + SessionExecutionState.pending_delivery, + SessionExecutionState.recoverable, + ) + ) + stored = None + if not execution_blocked: + stored = await self._dao.settle_command( + settle=settle, transaction=transaction + ) + if stored is not None: + transitioned = await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=target, + state=( + SessionExecutionState.running + if started + else SessionExecutionState.recoverable + ), + error=( + None + if started + else { + "code": "continuation_start_failed", + "message": error or "The runner rejected the continuation.", + "retryable": True, + } + ), + expected_states=[execution.state], + transaction=transaction, + ) + if transitioned is None: + raise RuntimeError( + "continuation execution changed while its control lock was held" + ) + if execution_blocked: + return CommandOutcomeReport(command=command, admitted=False) + if stored is None: + latest = await self._dao.fetch_command(command_id=command.id) + if ( + latest is not None + and latest.state == SessionCommandState.applied + and latest.outcome == SessionCommandOutcome.started + ): + return await self._readmit_recoverable_continuation( + command=latest, + replica_id=replica_id, + execution_id=target, + ) + raise SessionCommandNotClaimable( + command_id=str(command.id), + state=latest.state.value if latest else command.state.value, + ) + return CommandOutcomeReport(command=stored, admitted=True) + + async def _readmit_recoverable_continuation( + self, + *, + command: SessionCommand, + replica_id: str, + execution_id: str, + ) -> CommandOutcomeReport: + if command.claimed_by != replica_id or self._executions is None: + return CommandOutcomeReport(command=command, admitted=False) + transitioned = await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=execution_id, + state=SessionExecutionState.running, + error=None, + expected_states=[SessionExecutionState.recoverable], + ) + return CommandOutcomeReport( + command=command, + admitted=transitioned is not None, + ) async def settle( self, @@ -659,6 +1395,7 @@ async def settle( return None terminal = outcome in ( SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, SessionCommandOutcome.lost, ) settled_by = ( @@ -793,5 +1530,6 @@ async def settle( __all__ = [ "CancelAdmission", + "InteractionContinuationAdmission", "SessionCommandsService", ] diff --git a/api/oss/src/core/sessions/commands/types.py b/api/oss/src/core/sessions/commands/types.py index 47092a44c01..2923c658bfb 100644 --- a/api/oss/src/core/sessions/commands/types.py +++ b/api/oss/src/core/sessions/commands/types.py @@ -49,3 +49,21 @@ def __init__(self, *, command_id: str, state: str) -> None: self.state = state self.message = f"session command '{command_id}' is '{state}' and cannot be settled by this caller" super().__init__(self.message) + + +class InteractionResponseConflict(SessionCommandError): + def __init__( + self, *, code: str, message: str, details: Optional[dict] = None + ) -> None: + self.code = code + self.message = message + self.details = details or {} + super().__init__(message) + + +class IdempotencyKeyReused(InteractionResponseConflict): + def __init__(self) -> None: + super().__init__( + code="idempotency_key_reused", + message="This idempotency key was already used for a different response.", + ) diff --git a/api/oss/src/core/sessions/executions/dtos.py b/api/oss/src/core/sessions/executions/dtos.py index 84c3887161e..a266eb8f699 100644 --- a/api/oss/src/core/sessions/executions/dtos.py +++ b/api/oss/src/core/sessions/executions/dtos.py @@ -1,17 +1,31 @@ from datetime import datetime -from typing import Optional +from enum import Enum +from typing import Any, Dict, Optional from uuid import UUID from pydantic import BaseModel +class SessionExecutionState(str, Enum): + active = "active" + stopping = "stopping" + pending_delivery = "pending_delivery" + recoverable = "recoverable" + running = "running" + terminal = "terminal" + + class SessionExecutionSettlement(BaseModel): project_id: UUID session_id: str execution_id: str - terminal_outcome: str - settled_by: str - settled_at: datetime + state: SessionExecutionState = SessionExecutionState.terminal + parent_execution_id: Optional[str] = None + source_interaction_id: Optional[UUID] = None + error: Optional[Dict[str, Any]] = None + terminal_outcome: Optional[str] = None + settled_by: Optional[str] = None + settled_at: Optional[datetime] = None ending_written_at: Optional[datetime] = None redis_reconciled_at: Optional[datetime] = None diff --git a/api/oss/src/core/sessions/executions/interfaces.py b/api/oss/src/core/sessions/executions/interfaces.py index 92e87e927c2..c1af50e7365 100644 --- a/api/oss/src/core/sessions/executions/interfaces.py +++ b/api/oss/src/core/sessions/executions/interfaces.py @@ -4,12 +4,60 @@ from uuid import UUID from oss.src.core.sessions.executions.dtos import ( + SessionExecutionState, SessionExecutionSettlement, SessionExecutionSettlementResult, ) class SessionExecutionsDAOInterface(ABC): + async def fetch_execution( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + """Fetch one execution without creating or locking it.""" + raise NotImplementedError + + async def lock_for_control( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Any, + ) -> SessionExecutionSettlement: + """Ensure and row-lock the source execution for Stop/answer arbitration.""" + raise NotImplementedError + + async def create_continuation( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + parent_execution_id: str, + source_interaction_id: Optional[UUID], + transaction: Any, + ) -> SessionExecutionSettlement: + raise NotImplementedError + + async def set_state( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + state: SessionExecutionState, + error: Optional[dict] = None, + expected_states: Optional[Sequence[SessionExecutionState]] = None, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + raise NotImplementedError + @abstractmethod async def settle( self, diff --git a/api/oss/src/core/sessions/interactions/interfaces.py b/api/oss/src/core/sessions/interactions/interfaces.py index 7a11646a6b4..9255a3aab0a 100644 --- a/api/oss/src/core/sessions/interactions/interfaces.py +++ b/api/oss/src/core/sessions/interactions/interfaces.py @@ -29,6 +29,8 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> Optional[SessionInteraction]: ... @abstractmethod @@ -36,6 +38,7 @@ async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, ) -> Optional[SessionInteraction]: ... @abstractmethod diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 14c5187174a..84c30746045 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -75,10 +75,14 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> SessionInteraction: result = await self.interactions_dao.fetch_interaction( project_id=project_id, interaction_id=interaction_id, + transaction=transaction, + for_update=for_update, ) if result is None: raise InteractionNotFound(f"Interaction {interaction_id} not found") @@ -88,19 +92,23 @@ async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, + publish: bool = True, ) -> Optional[SessionInteraction]: result = await self.interactions_dao.transition_interaction( transition=transition, + transaction=transaction, ) if result is None: raise InteractionNotFound( f"Interaction with token {transition.token!r} not found or already terminal" ) - await self._publish_interaction( - project_id=transition.project_id, - session_id=transition.session_id, - status=WATCH_INTERACTION_RESOLVED, - ) + if publish: + await self._publish_interaction( + project_id=transition.project_id, + session_id=transition.session_id, + status=WATCH_INTERACTION_RESOLVED, + ) return result async def cancel_session_pending( @@ -174,6 +182,15 @@ async def publish_session_pending_cancelled( status=WATCH_INTERACTION_RESOLVED, ) + async def publish_interaction_responded( + self, *, project_id: UUID, session_id: str + ) -> None: + await self._publish_interaction( + project_id=project_id, + session_id=session_id, + status=WATCH_INTERACTION_RESOLVED, + ) + async def query_interactions( self, *, diff --git a/api/oss/src/core/sessions/records/interfaces.py b/api/oss/src/core/sessions/records/interfaces.py index a6e516a4005..08443be6355 100644 --- a/api/oss/src/core/sessions/records/interfaces.py +++ b/api/oss/src/core/sessions/records/interfaces.py @@ -92,3 +92,12 @@ async def settled_turns( """ raise NotImplementedError + + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + """Turns with an effective, non-paused runner terminal record.""" + raise NotImplementedError diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index c6637c6509a..a1f65b653d6 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -79,9 +79,10 @@ async def append_many( return [] guarded = await self._handle_late_events(events=events) - appended = await self.records_dao.append_many(events=guarded) + records = await self.records_dao.append_many(events=guarded) + await self._settle_completed_continuations(records=records) await self._mark_endings_written(events=guarded) - return appended + return records async def _mark_endings_written( self, @@ -116,6 +117,68 @@ async def _mark_endings_written( exc_info=True, ) + async def _settle_completed_continuations( + self, + *, + records: List[SessionRecord], + ) -> None: + """Make a runner's durable ending the continuation's durable outcome. + + The runner flushes its terminal record before releasing its heartbeat. Without this + bridge, an admitted continuation remained ``running`` forever; once its heartbeat went + stale, recovery could replay work that had already completed. Only an effective runner + ending qualifies: paused turns remain continuable, watchdog endings are not successful + completion, and quarantined late endings already lost the Stop/watchdog race. + + Record persistence is the source of truth and happened immediately above. Settlement is + best effort here because records and executions use different database engines; the + watchdog repeats the reconciliation before it collapses stale ownership. + """ + if self.executions_dao is None or not env.agenta.sessions.durable_stop: + return + + candidates = { + (record.project_id, record.session_id, record.turn_id) + for record in records + if record.record_type == TERMINAL_RECORD_TYPE + and record.turn_id + and record.quarantined_at is None + and (record.attributes or {}).get(RECORD_SETTLED_BY_ATTRIBUTE) + != SETTLED_BY_WATCHDOG + and (record.attributes or {}).get("stopReason") != "paused" + } + for project_id, session_id, execution_id in candidates: + try: + execution = await self.executions_dao.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if ( + execution is None + or ( + execution.source_interaction_id is None + and execution.parent_execution_id is None + ) + or execution.terminal_outcome is not None + ): + continue + await self.executions_dao.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome="completed", + settled_by="runner", + ) + except Exception: + log.warning( + "[RECORDS] Continuation completion settlement failed", + project_id=str(project_id), + session_id=session_id, + turn_id=execution_id, + exc_info=True, + ) + async def _handle_late_events( self, *, @@ -381,3 +444,14 @@ async def settled_turns( keys=keys, settled_by=settled_by, ) + + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + return await self.records_dao.runner_completed_turns( + project_id=project_id, + keys=keys, + ) diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 5b83462740b..8d5559baca2 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Optional, List, Union, TYPE_CHECKING +from typing import Any, Awaitable, Callable, Dict, Optional, List, Union, TYPE_CHECKING from uuid import UUID, uuid4 import httpx @@ -277,6 +277,31 @@ def __init__( self.embeds_service = embeds_service self.static_catalog = static_catalog self._watch = watch_publisher + self._session_continuation_resumer: Optional[Callable[..., Awaitable[bool]]] = ( + None + ) + + def set_session_continuation_resumer( + self, callback: Callable[..., Awaitable[bool]] + ) -> None: + self._session_continuation_resumer = callback + + async def _resume_pending_session_continuation( + self, *, project_id: UUID, request: WorkflowServiceRequest + ) -> bool: + session_id = request.session_id + meta = request.meta or {} + if ( + not env.agenta.sessions.durable_stop + or not session_id + or meta.get("control_command_id") + or self._session_continuation_resumer is None + ): + return False + return await self._session_continuation_resumer( + project_id=project_id, + session_id=session_id, + ) @staticmethod def _artifact_cache_key(artifact_id: UUID) -> str: @@ -777,11 +802,31 @@ async def _stream_service_started( # exiting the context closes the connection (run keeps going on the runner). try: record = json.loads(line) - except json.JSONDecodeError: - record = None - record_run_id = ( - record.get("run_id") if isinstance(record, dict) else None - ) + except json.JSONDecodeError as error: + raise WorkflowDetachedStartFailed( + "Workflow service emitted malformed NDJSON before detached start." + ) from error + if not isinstance(record, dict): + raise WorkflowDetachedStartFailed( + "Workflow service emitted a non-object record before detached start." + ) + kind = record.get("kind") + if kind == "result": + result = record.get("result") + if not isinstance(result, dict) or result.get("ok") is not True: + detail = ( + result.get("error") + if isinstance(result, dict) + else "malformed result record" + ) + raise WorkflowDetachedStartFailed( + f"Workflow service rejected detached start: {detail}" + ) + elif kind != "event": + raise WorkflowDetachedStartFailed( + "Workflow service emitted an unknown record before detached start." + ) + record_run_id = record.get("run_id") return WorkflowServiceDetachedResponse( run_id=record_run_id or run_id, accepted=True, @@ -2875,6 +2920,19 @@ async def invoke_workflow( WorkflowServiceBatchResponse, WorkflowServiceStreamResponse, ]: + if await self._resume_pending_session_continuation( + project_id=project_id, request=request + ): + return WorkflowServiceBatchResponse( + status=WorkflowServiceStatus( + type="https://agenta.ai/docs/errors#continuation-resumed", + code=409, + message=( + "A durable approval continuation already owns this session; " + "it was redelivered instead of starting a competing turn." + ), + ) + ) credentials, service_url = await self._prepare_invoke( project_id=project_id, user_id=user_id, diff --git a/api/oss/src/dbs/http/sessions/control_delivery_direct.py b/api/oss/src/dbs/http/sessions/control_delivery_direct.py index dd470dcfb4d..455fc6954c3 100644 --- a/api/oss/src/dbs/http/sessions/control_delivery_direct.py +++ b/api/oss/src/dbs/http/sessions/control_delivery_direct.py @@ -32,10 +32,10 @@ broke Stop for the whole window after every deploy, which is worse than the failure it guarded. """ -from typing import Optional +from typing import Awaitable, Callable, Optional from uuid import UUID -from oss.src.core.sessions.commands.dtos import SessionCommand +from oss.src.core.sessions.commands.dtos import SessionCommand, SessionCommandKind from oss.src.core.sessions.commands.interfaces import ( ControlDeliveryPort, DeliveryReceipt, @@ -51,14 +51,34 @@ class DirectControlDelivery(ControlDeliveryPort): - def __init__(self, *, timeout_seconds: Optional[float] = None) -> None: + def __init__( + self, + *, + timeout_seconds: Optional[float] = None, + continue_interaction: Optional[ + Callable[[SessionCommand], Awaitable[None]] + ] = None, + ) -> None: self._timeout = ( timeout_seconds if timeout_seconds is not None else env.agenta.sessions.commands.delivery_timeout_seconds ) + self._continue_interaction = continue_interaction async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + if command.kind == SessionCommandKind.continue_interaction: + if self._continue_interaction is None: + return DeliveryReceipt( + status="unreachable", + detail="continuation delivery is not configured", + ) + try: + await self._continue_interaction(command) + except Exception as error: # noqa: BLE001 - transport maps failures to receipts + return DeliveryReceipt(status="unreachable", detail=str(error)) + return DeliveryReceipt(status="accepted", replica_id="direct") + answer = await cancel_runner_execution( command_id=str(command.id), project_id=str(command.project_id), diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 482e33d0e7f..05e99544fd4 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -32,6 +32,7 @@ map_command_dbe_to_dto, map_command_dto_to_dbe_create, ) +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE from oss.src.dbs.postgres.shared.engine import ( TransactionsEngine, @@ -95,13 +96,56 @@ async def create_command( user_id: Optional[UUID], command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> SessionCommand: - result = await self.create_command_with_status( - user_id=user_id, - command=command, - stopping_turn_id=stopping_turn_id, - ) - return result.command + if transaction is None: + result = await self.create_command_with_status( + user_id=user_id, + command=command, + stopping_turn_id=stopping_turn_id, + ) + return result.command + + dbe = map_command_dto_to_dbe_create(user_id=user_id, command=command) + + async def execute(session: Any) -> SessionCommand: + session.add(dbe) + if stopping_turn_id is not None: + await session.execute( + sa_update(SessionStreamDBE) + .where( + SessionStreamDBE.project_id == command.project_id, + SessionStreamDBE.session_id == command.session_id, + SessionStreamDBE.deleted_at.is_(None), + ) + .values(stopping_turn_id=stopping_turn_id) + ) + await session.flush() + return map_command_dbe_to_dto(dbe) + + try: + async with transaction.begin_nested(): + return await execute(transaction) + except IntegrityError: + if command.idempotency_key is not None: + existing = await self.fetch_by_idempotency_key( + project_id=command.project_id, + session_id=command.session_id, + idempotency_key=command.idempotency_key, + transaction=transaction, + ) + if existing is not None: + return existing + open_command = await self.fetch_open_command( + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + transaction=transaction, + ) + if open_command is None: + raise + return open_command async def create_command_with_status( self, @@ -110,15 +154,7 @@ async def create_command_with_status( command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, ) -> CommandCreateResult: - """Insert the command and stamp the session row's `stopping_turn_id` together. - - One transaction, on purpose. A user whose Stop was recorded but whose session row never - learned it is waiting has a session that renders as plainly running while a command - exists to stop it, and nothing later reconciles the two. - - `session_streams` is written from here rather than through the streams DAO because - sharing one transaction is the whole requirement, and the streams DAO opens its own. - """ + """Insert the command and stamp the session row's status atomically.""" dbe = map_command_dto_to_dbe_create(user_id=user_id, command=command) try: @@ -140,15 +176,6 @@ async def create_command_with_status( command=map_command_dbe_to_dto(dbe), inserted=True ) except IntegrityError: - # One of two unique constraints refused this insert, and both mean the same thing: - # a command for this intent already exists. Return it rather than a second command. - # - # uq_session_commands_idempotency — the caller retried with the same key. - # uq_session_commands_open_target — another request is already stopping this - # execution, which is what makes two Stops in - # the SAME INSTANT one command. Admission's own - # read cannot see a row that has not committed - # yet, so the database is the decider. if command.idempotency_key is not None: existing = await self.fetch_by_idempotency_key( project_id=command.project_id, @@ -173,8 +200,9 @@ async def fetch_by_idempotency_key( project_id: UUID, session_id: str, idempotency_key: str, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = select(SessionCommandDBE).where( SessionCommandDBE.project_id == project_id, SessionCommandDBE.session_id == session_id, @@ -182,7 +210,12 @@ async def fetch_by_idempotency_key( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() - return map_command_dbe_to_dto(dbe) if dbe is not None else None + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def fetch_open_command( self, @@ -191,8 +224,9 @@ async def fetch_open_command( session_id: str, kind: SessionCommandKind, target_turn_id: Optional[str], + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = ( select(SessionCommandDBE) .where( @@ -212,8 +246,123 @@ async def fetch_open_command( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_resumable_continuation( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionCommand]: + async with self.engine.session() as session: + stmt = ( + select(SessionCommandDBE) + .join( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id == SessionCommandDBE.project_id, + SessionExecutionDBE.session_id == SessionCommandDBE.session_id, + SessionExecutionDBE.execution_id + == SessionCommandDBE.target_turn_id, + ), + ) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind + == SessionCommandKind.continue_interaction.value, + or_( + and_( + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionExecutionDBE.state.in_( + ("pending_delivery", "recoverable") + ), + ), + and_( + SessionCommandDBE.state + == SessionCommandState.obsolete.value, + SessionCommandDBE.outcome.in_(("lost", "failed")), + SessionExecutionDBE.state == "recoverable", + ), + and_( + SessionCommandDBE.state + == SessionCommandState.applied.value, + SessionCommandDBE.outcome == "started", + SessionExecutionDBE.state.in_(("recoverable", "running")), + ), + ), + SessionCommandDBE.deleted_at.is_(None), + ) + .order_by(SessionCommandDBE.created_at) + .limit(1) + ) + dbe = (await session.execute(stmt)).scalar_one_or_none() return map_command_dbe_to_dto(dbe) if dbe is not None else None + async def reopen_continuation( + self, + *, + project_id: UUID, + command_id: UUID, + target_turn_id: str, + replacement_turn_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + async def execute(session: Any) -> Optional[SessionCommand]: + stmt = ( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.target_turn_id == target_turn_id, + SessionCommandDBE.kind + == SessionCommandKind.continue_interaction.value, + or_( + and_( + SessionCommandDBE.state + == SessionCommandState.obsolete.value, + SessionCommandDBE.outcome.in_(("lost", "failed")), + ), + and_( + SessionCommandDBE.state + == SessionCommandState.applied.value, + SessionCommandDBE.outcome == "started", + ), + ), + select(SessionExecutionDBE.execution_id) + .where( + SessionExecutionDBE.project_id == SessionCommandDBE.project_id, + SessionExecutionDBE.session_id == SessionCommandDBE.session_id, + SessionExecutionDBE.execution_id == replacement_turn_id, + SessionExecutionDBE.state == "pending_delivery", + ) + .exists(), + ) + .values( + target_turn_id=replacement_turn_id, + state=SessionCommandState.pending.value, + outcome=None, + settled_at=None, + claimed_by=None, + claim_expires_at=None, + claim_count=0, + updated_at=datetime.now(timezone.utc), + ) + .returning(SessionCommandDBE) + ) + dbe = (await session.execute(stmt)).scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def fetch_command( self, *, @@ -411,12 +560,18 @@ async def execute(session: Any) -> Optional[SessionCommand]: SessionCommandDBE.claimed_by == settle.replica_id, ) ) - stmt = stmt.values( + values = dict( state=settle.state.value, outcome=settle.outcome.value, settled_at=now, updated_at=now, - ).returning(SessionCommandDBE) + ) + if settle.replica_id is not None: + # A pending continuation can report before the API records its delivery claim. + # Persisting that reporter makes a lost HTTP response retryable by the same + # runner, while a different replica still receives admitted=false. + values["claimed_by"] = settle.replica_id + stmt = stmt.values(**values).returning(SessionCommandDBE) result = await session.execute(stmt) dbe = result.scalar_one_or_none() return map_command_dbe_to_dto(dbe) if dbe is not None else None diff --git a/api/oss/src/dbs/postgres/sessions/commands/dbes.py b/api/oss/src/dbs/postgres/sessions/commands/dbes.py index f4a755aba9a..7a7d2da66de 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dbes.py @@ -25,7 +25,10 @@ class SessionCommandDBE(Base, SessionCommandDBA): "idempotency_key", name="uq_session_commands_idempotency", ), - CheckConstraint("kind IN ('cancel')", name="ck_session_commands_kind"), + CheckConstraint( + "kind IN ('cancel', 'continue_interaction')", + name="ck_session_commands_kind", + ), CheckConstraint( "state IN ('pending', 'claimed', 'applied', 'obsolete')", name="ck_session_commands_state", diff --git a/api/oss/src/dbs/postgres/sessions/executions/dao.py b/api/oss/src/dbs/postgres/sessions/executions/dao.py index 69c646835a5..2772a216cd4 100644 --- a/api/oss/src/dbs/postgres/sessions/executions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/executions/dao.py @@ -2,10 +2,11 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple from uuid import UUID -from sqlalchemy import and_, literal_column, or_, select, tuple_, update as sa_update +from sqlalchemy import and_, or_, select, tuple_, update as sa_update from sqlalchemy.dialects.postgresql import insert from oss.src.core.sessions.executions.dtos import ( + SessionExecutionState, SessionExecutionSettlement, SessionExecutionSettlementResult, ) @@ -22,6 +23,10 @@ def _to_dto(row: SessionExecutionDBE) -> SessionExecutionSettlement: project_id=row.project_id, session_id=row.session_id, execution_id=row.execution_id, + state=SessionExecutionState(row.state), + parent_execution_id=row.parent_execution_id, + source_interaction_id=row.source_interaction_id, + error=row.error, terminal_outcome=row.terminal_outcome, settled_by=row.settled_by, settled_at=row.settled_at, @@ -34,41 +39,164 @@ class SessionExecutionsDAO(SessionExecutionsDAOInterface): def __init__(self, engine: Optional[TransactionsEngine] = None): self.engine = engine or get_transactions_engine() - async def settle( + async def fetch_execution( self, *, project_id: UUID, session_id: str, execution_id: str, - terminal_outcome: str, - settled_by: str, - settled_at: Optional[datetime] = None, transaction: Optional[Any] = None, - ) -> SessionExecutionSettlementResult: - settled_at = settled_at or datetime.now(timezone.utc) - stmt = ( + ) -> Optional[SessionExecutionSettlement]: + async def execute(session: Any) -> Optional[SessionExecutionSettlement]: + row = ( + await session.execute( + select(SessionExecutionDBE).where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def lock_for_control( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Any, + ) -> SessionExecutionSettlement: + await transaction.execute( insert(SessionExecutionDBE) .values( project_id=project_id, session_id=session_id, execution_id=execution_id, - terminal_outcome=terminal_outcome, - settled_by=settled_by, - settled_at=settled_at, + state=SessionExecutionState.active.value, ) - .on_conflict_do_update( - index_elements=["project_id", "session_id", "execution_id"], - set_={"terminal_outcome": SessionExecutionDBE.terminal_outcome}, + .on_conflict_do_nothing( + index_elements=["project_id", "session_id", "execution_id"] ) - .returning( - SessionExecutionDBE, - literal_column("xmax = 0").label("won"), + ) + row = ( + await transaction.execute( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + .with_for_update() ) + ).scalar_one() + return _to_dto(row) + + async def create_continuation( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + parent_execution_id: str, + source_interaction_id: Optional[UUID], + transaction: Any, + ) -> SessionExecutionSettlement: + row = SessionExecutionDBE( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + state=SessionExecutionState.pending_delivery.value, + parent_execution_id=parent_execution_id, + source_interaction_id=source_interaction_id, ) + transaction.add(row) + await transaction.flush() + return _to_dto(row) + + async def set_state( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + state: SessionExecutionState, + error: Optional[dict] = None, + expected_states: Optional[Sequence[SessionExecutionState]] = None, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + async def execute(session: Any) -> Optional[SessionExecutionSettlement]: + stmt = sa_update(SessionExecutionDBE).where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + SessionExecutionDBE.terminal_outcome.is_(None), + ) + if expected_states is not None: + stmt = stmt.where( + SessionExecutionDBE.state.in_( + [expected.value for expected in expected_states] + ) + ) + row = ( + await session.execute( + stmt.values(state=state.value, error=error).returning( + SessionExecutionDBE + ) + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def settle( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + terminal_outcome: str, + settled_by: str, + settled_at: Optional[datetime] = None, + transaction: Optional[Any] = None, + ) -> SessionExecutionSettlementResult: + settled_at = settled_at or datetime.now(timezone.utc) async def execute(session: Any) -> SessionExecutionSettlementResult: - stored, won = (await session.execute(stmt)).one() - return SessionExecutionSettlementResult(settlement=_to_dto(stored), won=won) + stored = await self.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + transaction=session, + ) + if stored.terminal_outcome is not None: + return SessionExecutionSettlementResult(settlement=stored, won=False) + row = ( + await session.execute( + sa_update(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + .values( + state=SessionExecutionState.terminal.value, + terminal_outcome=terminal_outcome, + settled_by=settled_by, + settled_at=settled_at, + ) + .returning(SessionExecutionDBE) + ) + ).scalar_one() + return SessionExecutionSettlementResult(settlement=_to_dto(row), won=True) if transaction is not None: return await execute(transaction) @@ -97,6 +225,7 @@ async def query_settled( await session.execute( select(SessionExecutionDBE).where( SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.terminal_outcome.is_not(None), key_filter, ) ) diff --git a/api/oss/src/dbs/postgres/sessions/executions/dbes.py b/api/oss/src/dbs/postgres/sessions/executions/dbes.py index 2a13846bb91..d00e1255868 100644 --- a/api/oss/src/dbs/postgres/sessions/executions/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/executions/dbes.py @@ -6,6 +6,7 @@ String, text, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy import TIMESTAMP from sqlalchemy.dialects.postgresql import UUID @@ -18,15 +19,26 @@ class SessionExecutionDBE(Base): project_id = Column(UUID(as_uuid=True), nullable=False) session_id = Column(String, nullable=False) execution_id = Column(String, nullable=False) - terminal_outcome = Column(String, nullable=False) - settled_by = Column(String, nullable=False) - settled_at = Column(TIMESTAMP(timezone=True), nullable=False) + state = Column(String, nullable=False, default="active", server_default="active") + parent_execution_id = Column(String, nullable=True) + source_interaction_id = Column(UUID(as_uuid=True), nullable=True) + error = Column(JSONB(none_as_null=True), nullable=True) + terminal_outcome = Column(String, nullable=True) + settled_by = Column(String, nullable=True) + settled_at = Column(TIMESTAMP(timezone=True), nullable=True) ending_written_at = Column(TIMESTAMP(timezone=True), nullable=True) redis_reconciled_at = Column(TIMESTAMP(timezone=True), nullable=True) __table_args__ = ( ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), PrimaryKeyConstraint("project_id", "session_id", "execution_id"), + Index( + "uq_session_executions_source_interaction", + "project_id", + "source_interaction_id", + unique=True, + postgresql_where=text("source_interaction_id IS NOT NULL"), + ), Index( "ix_session_executions_project_session", "project_id", diff --git a/api/oss/src/dbs/postgres/sessions/interactions/dao.py b/api/oss/src/dbs/postgres/sessions/interactions/dao.py index ef46fcbd0a8..a599ecce7df 100644 --- a/api/oss/src/dbs/postgres/sessions/interactions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/interactions/dao.py @@ -77,24 +77,32 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> Optional[SessionInteraction]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionInteraction]: stmt = select(SessionInteractionDBE).where( SessionInteractionDBE.project_id == project_id, SessionInteractionDBE.id == interaction_id, ) + if for_update: + stmt = stmt.with_for_update() result = await session.execute(stmt) dbe = result.scalar_one_or_none() - if dbe is None: - return None - return map_interaction_dbe_to_dto(dbe) + return map_interaction_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, ) -> Optional[SessionInteraction]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionInteraction]: # Only non-terminal interactions transition: pending (responded|resolved| # cancelled) and responded (resolved, when the runner consumes an API-plane # answer). resolved/cancelled are terminal. @@ -129,11 +137,15 @@ async def transition_interaction( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() - await session.commit() if dbe is None: return None return map_interaction_dbe_to_dto(dbe) + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def cancel_session_pending( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index 019deb5ceac..8767d09fac8 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -7,6 +7,7 @@ from oss.src.core.sessions.records.dtos import ( RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, SESSION_MESSAGE_PREVIEW_TEXT_LIMIT, TERMINAL_RECORD_TYPE, SessionMessagePreview, @@ -522,6 +523,36 @@ async def settled_turns( return {(row.session_id, row.turn_id) for row in rows} + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + if not keys: + return set() + async with self.engine.session() as session: + stmt = ( + select(RecordDBE.session_id, RecordDBE.turn_id) + .where( + RecordDBE.project_id == project_id, + RecordDBE.record_type == TERMINAL_RECORD_TYPE, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + func.coalesce( + RecordDBE.attributes[RECORD_SETTLED_BY_ATTRIBUTE].astext, + "", + ) + != SETTLED_BY_WATCHDOG, + func.coalesce(RecordDBE.attributes["stopReason"].astext, "") + != "paused", + tuple_(RecordDBE.session_id, RecordDBE.turn_id).in_(keys), + ) + .distinct() + ) + rows = (await session.execute(stmt)).all() + return {(row.session_id, row.turn_id) for row in rows} + async def get_event( self, *, diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 30e5f1b1bcb..d9ce243d340 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -350,6 +350,8 @@ async def respond( # interaction_id: UUID, answer: Any, + control_command_id: Optional[UUID] = None, + continuation_execution_id: Optional[str] = None, ) -> None: interaction = await self.interactions_service.fetch_interaction( project_id=project_id, @@ -384,14 +386,22 @@ async def respond( data=WorkflowServiceRequestData(inputs=inputs, parameters=parameters), session_id=interaction.session_id, ) + if control_command_id is not None: + invoke_request.meta = { + **(invoke_request.meta or {}), + "control_command_id": str(control_command_id), + } if self._dispatch_fn is not None: # Detached path: hand off to the runner, return immediately. - await self._dispatch_fn( - project_id=project_id, - user_id=user_id, - request=invoke_request, - ) + kwargs = { + "project_id": project_id, + "user_id": user_id, + "request": invoke_request, + } + if continuation_execution_id is not None: + kwargs["run_id"] = continuation_execution_id + await self._dispatch_fn(**kwargs) return await self.workflows_service.invoke_workflow( diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index d80258dc09d..e30995b41c7 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -308,6 +308,68 @@ async def _mark_endings_written( ) +async def _reconcile_completed_executions( + *, + commands_service: Optional[Any], + candidates: Sequence[Tuple[UUID, str, str]], +) -> Set[Tuple[UUID, str, str]]: + """Return persisted endings whose execution could not be terminalized yet.""" + if commands_service is None: + return set(candidates) + failed: Set[Tuple[UUID, str, str]] = set() + for project_id, session_id, turn_id in candidates: + try: + reconciled = await commands_service.settle_execution_completed( + project_id=project_id, + session_id=session_id, + execution_id=turn_id, + ) + except Exception: + reconciled = False + log.warning( + "watchdog: failed to settle a completed continuation execution", + project_id=str(project_id), + session_id=session_id, + turn_id=turn_id, + exc_info=True, + ) + if not reconciled: + failed.add((project_id, session_id, turn_id)) + return failed + + +async def _runner_completed_executions( + *, + records_service: RecordsService, + candidates: Sequence[Tuple[UUID, str, str]], +) -> Tuple[Set[Tuple[UUID, str, str]], Set[Tuple[UUID, str, str]]]: + by_project: Dict[UUID, List[Tuple[str, str]]] = {} + for project_id, session_id, turn_id in candidates: + by_project.setdefault(project_id, []).append((session_id, turn_id)) + completed: Set[Tuple[UUID, str, str]] = set() + failed: Set[Tuple[UUID, str, str]] = set() + for project_id, keys in by_project.items(): + try: + matches = await records_service.runner_completed_turns( + project_id=project_id, + keys=keys, + ) + except Exception: + log.warning( + "watchdog: runner-completion lookup failed", + project_id=str(project_id), + exc_info=True, + ) + failed.update( + (project_id, session_id, turn_id) for session_id, turn_id in keys + ) + continue + completed.update( + (project_id, session_id, turn_id) for session_id, turn_id in matches + ) + return completed, failed + + async def _settle_abandoned_commands( commands_service: Optional[Any], now: datetime, @@ -471,6 +533,33 @@ async def run_orphan_sweep( written_at=now_utc, ) + # A runner can persist `done` and die before the post-append execution settlement. + # Reconcile that durable proof before clearing its stale heartbeat; otherwise the next + # Send would see a recoverable continuation and replay already-completed work. + completion_failures: Set[Tuple[UUID, str, str]] = set() + if ( + records_service is not None + and commands_service is not None + and env.agenta.sessions.durable_stop + ): + runner_completed, completion_failures = await _runner_completed_executions( + records_service=records_service, + candidates=claimed, + ) + completion_failures.update( + await _reconcile_completed_executions( + commands_service=commands_service, + candidates=sorted(runner_completed, key=lambda t: t[1]), + ) + ) + if completion_failures: + orphan_rows = [ + row + for row in orphan_rows + if (row[1], row[2], str(row[3])) + not in completion_failures + ] + if not orphan_rows and not unsettled: # No stale row and nothing owed an ending, but a command can still be abandoned: # its execution may have ended normally between the claim and the report. diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py new file mode 100644 index 00000000000..af28f1b7f7a --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -0,0 +1,826 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import IdempotencyKeyReused +from oss.src.utils.env import env +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, + SessionExecutionState, +) +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionData, + SessionInteractionKind, + SessionInteractionStatus, +) + + +class _Commands: + def __init__(self): + self.command = None + self.abandoned = [] + + @asynccontextmanager + async def transaction(self): + yield object() + + async def fetch_by_idempotency_key(self, **kwargs): + return self.command + + async def fetch_command(self, **kwargs): + return self.command + + async def create_command(self, *, user_id, command, transaction=None, **kwargs): + self.command = SessionCommand( + id=uuid4(), + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + expected_turn_id=command.expected_turn_id, + data=command.data, + state=command.state, + idempotency_key=command.idempotency_key, + created_at=datetime.now(timezone.utc), + ) + return self.command + + async def record_delivery_attempt(self, **kwargs): + return self.command + + async def claim_for_delivery(self, **kwargs): + return self.command + + async def fetch_resumable_continuation(self, **kwargs): + if self.command and ( + self.command.state + in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ) + or ( + self.command.state == SessionCommandState.obsolete + and self.command.outcome + in (SessionCommandOutcome.lost, SessionCommandOutcome.failed) + ) + or ( + self.command.state == SessionCommandState.applied + and self.command.outcome == SessionCommandOutcome.started + ) + ): + return self.command + return None + + async def reopen_continuation(self, **kwargs): + self.command = self.command.model_copy( + update={ + "target_turn_id": kwargs["replacement_turn_id"], + "state": SessionCommandState.pending, + "outcome": None, + "claimed_by": None, + "settled_at": None, + "claim_count": 0, + } + ) + return self.command + + async def expire_claims(self, **kwargs): + return self.abandoned + + async def settle_command(self, *, settle, **kwargs): + if self.command is None or self.command.state not in settle.expected_states: + return None + self.command = self.command.model_copy( + update={ + "state": settle.state, + "outcome": settle.outcome, + **({"claimed_by": settle.replica_id} if settle.replica_id else {}), + } + ) + return self.command + + +@pytest.fixture(autouse=True) +def _durable_stop_enabled(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + + +class _Interactions: + def __init__(self, interaction): + self.interaction = interaction + + async def fetch_interaction(self, **kwargs): + return self.interaction + + async def transition_interaction(self, *, transition, **kwargs): + data = self.interaction.data or SessionInteractionData() + self.interaction = self.interaction.model_copy( + update={ + "status": transition.status, + "data": data.model_copy(update={"resolution": transition.resolution}), + } + ) + return self.interaction + + async def publish_interaction_responded(self, **kwargs): + return None + + +class _Executions: + def __init__(self, *, project_id, session_id, source_id): + self.source = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id=source_id, + state=SessionExecutionState.active, + ) + self.continuation = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id="continuation-1", + state=SessionExecutionState.recoverable, + source_interaction_id=uuid4(), + ) + self.states = [] + + async def fetch_execution(self, **kwargs): + if kwargs["execution_id"] == self.source.execution_id: + return self.source + if kwargs["execution_id"] == self.continuation.execution_id: + return self.continuation + return None + + async def lock_for_control(self, **kwargs): + execution = await self.fetch_execution(**kwargs) + assert execution is not None + return execution + + async def settle(self, **kwargs): + current = ( + self.source + if kwargs["execution_id"] == self.source.execution_id + else self.continuation + ) + settled = current.model_copy( + update={ + "state": SessionExecutionState.terminal, + "terminal_outcome": kwargs["terminal_outcome"], + "settled_by": kwargs["settled_by"], + "settled_at": datetime.now(timezone.utc), + } + ) + if current is self.source: + self.source = settled + else: + self.continuation = settled + return SessionExecutionSettlementResult(settlement=settled, won=True) + + async def create_continuation(self, **kwargs): + self.continuation = SessionExecutionSettlement( + project_id=kwargs["project_id"], + session_id=kwargs["session_id"], + execution_id=kwargs["execution_id"], + state=SessionExecutionState.pending_delivery, + parent_execution_id=kwargs["parent_execution_id"], + source_interaction_id=kwargs["source_interaction_id"], + ) + return self.continuation + + async def set_state(self, **kwargs): + self.states.append((kwargs["execution_id"], kwargs["state"], kwargs["error"])) + current = ( + self.source + if kwargs["execution_id"] == self.source.execution_id + else self.continuation + ) + expected = kwargs.get("expected_states") + if expected is not None and current.state not in expected: + return None + updated = current.model_copy( + update={"state": kwargs["state"], "error": kwargs["error"]} + ) + if current is self.source: + self.source = updated + else: + self.continuation = updated + return updated + + +class _Unreachable: + def __init__(self): + self.delivered = [] + + async def deliver(self, **kwargs): + self.delivered.append(kwargs["command"]) + return DeliveryReceipt(status="unreachable", detail="runner unavailable") + + async def acknowledge(self, **kwargs): + return None + + +@pytest.mark.asyncio +async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + commands = _Commands() + interactions = _Interactions(interaction) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + admission = await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert admission.interaction.status == SessionInteractionStatus.responded + assert admission.execution_state == SessionExecutionState.recoverable + assert commands.command.data == { + "interaction_id": str(interaction_id), + "continuation_execution_id": admission.execution_id, + } + assert delivery.delivered[0].data["answer"] == {"approved": True} + assert executions.source.terminal_outcome == "continued" + assert executions.states[-1][1] == SessionExecutionState.recoverable + + retry = await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + assert retry.command.id == admission.command.id + assert retry.execution_id == admission.execution_id + + with pytest.raises(IdempotencyKeyReused): + await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": False}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + +@pytest.mark.asyncio +async def test_post_commit_failures_do_not_reject_an_accepted_answer(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + commands = _Commands() + interactions = _Interactions(interaction) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + interactions.publish_interaction_responded = AsyncMock( + side_effect=RuntimeError("watch unavailable") + ) + commands.record_delivery_attempt = AsyncMock( + side_effect=RuntimeError("attempt write unavailable") + ) + executions.set_state = AsyncMock( + side_effect=RuntimeError("recoverable projection unavailable") + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + admission = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert admission.interaction.status == SessionInteractionStatus.responded + assert admission.execution_state == SessionExecutionState.recoverable + + +def _continuation_command(project_id, interaction_id, *, claim_count=1): + return SessionCommand( + id=uuid4(), + project_id=project_id, + session_id="session-1", + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-1", + expected_turn_id="source-1", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-1", + }, + state=SessionCommandState.pending, + claim_count=claim_count, + created_at=datetime.now(timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_sweep_redelivers_continuation_without_a_heartbeat(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + commands.abandoned = [commands.command] + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + settled = await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 0 + assert [item.id for item in delivery.delivered] == [commands.command.id] + + +@pytest.mark.asyncio +async def test_next_send_resumes_the_same_open_continuation(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed is True + assert delivery.delivered[0].id == commands.command.id + assert delivery.delivered[0].target_turn_id == "continuation-1" + + +@pytest.mark.asyncio +async def test_exhausted_continuation_stays_recoverable(monkeypatch): + maximum = 2 + monkeypatch.setattr(env.agenta.sessions.commands, "max_deliveries", maximum) + project_id = uuid4() + interaction_id = uuid4() + command = _continuation_command(project_id, interaction_id, claim_count=maximum) + commands = _Commands() + commands.command = command + commands.abandoned = [command] + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + settled = await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 1 + assert commands.command.state == SessionCommandState.obsolete + assert executions.states[-1][1] == SessionExecutionState.recoverable + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert resumed is True + assert commands.command.state == SessionCommandState.pending + assert delivery.delivered[-1].id == command.id + assert delivery.delivered[-1].target_turn_id != "continuation-1" + assert executions.continuation.execution_id == delivery.delivered[-1].target_turn_id + assert executions.continuation.parent_execution_id == "continuation-1" + assert executions.continuation.source_interaction_id is None + + +@pytest.mark.asyncio +async def test_only_the_winning_started_outcome_is_admitted(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + first = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + duplicate = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-2", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + + assert first.admitted is True + assert first.command.outcome == SessionCommandOutcome.started + assert duplicate.admitted is False + assert duplicate.command.id == first.command.id + + same_replica_retry = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + assert same_replica_retry.admitted is False + + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.recoverable} + ) + recovered = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + concurrent_retry = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + assert recovered.admitted is True + assert concurrent_retry.admitted is False + + +@pytest.mark.asyncio +async def test_running_continuation_blocks_send_while_heartbeat_is_live(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id).model_copy( + update={ + "state": SessionCommandState.applied, + "outcome": SessionCommandOutcome.started, + "claimed_by": "runner-1", + } + ) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + delivery = _Unreachable() + streams = SimpleNamespace( + fetch_header=AsyncMock( + return_value=SimpleNamespace( + turn_id="continuation-1", + updated_at=datetime.now(timezone.utc), + flags=SimpleNamespace(is_alive=True), + ) + ) + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + assert await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert delivery.delivered == [] + assert commands.command.state == SessionCommandState.applied + + +@pytest.mark.asyncio +async def test_stale_heartbeat_never_replays_an_admitted_continuation(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id).model_copy( + update={ + "state": SessionCommandState.applied, + "outcome": SessionCommandOutcome.started, + "claimed_by": "runner-1", + } + ) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + delivery = _Unreachable() + streams = SimpleNamespace( + fetch_header=AsyncMock( + return_value=SimpleNamespace( + turn_id="continuation-1", + updated_at=datetime.now(timezone.utc) - timedelta(minutes=5), + flags=SimpleNamespace(is_alive=True), + ) + ) + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + assert await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert commands.command.state == SessionCommandState.applied + assert commands.command.claimed_by == "runner-1" + assert executions.continuation.state == SessionExecutionState.running + assert delivery.delivered == [] + + +@pytest.mark.asyncio +async def test_stop_winner_blocks_continuation_admission(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.stopping} + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + report = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + + assert report.admitted is False + assert commands.command.state == SessionCommandState.pending + assert executions.continuation.state == SessionExecutionState.stopping + + +@pytest.mark.asyncio +async def test_watchdog_keeps_lost_continuation_recoverable(): + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert not await service.settle_execution_lost( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + settled_at=datetime.now(timezone.utc), + ) + assert executions.continuation.state == SessionExecutionState.recoverable + assert executions.continuation.terminal_outcome is None + + +@pytest.mark.asyncio +async def test_persisted_completion_terminalizes_continuation_before_recovery(): + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + ) + assert executions.continuation.state == SessionExecutionState.terminal + assert executions.continuation.terminal_outcome == "completed" + assert executions.continuation.settled_by == "runner" + + +@pytest.mark.asyncio +async def test_recovery_hooks_are_disabled_with_durable_stop(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + commands.abandoned = [commands.command] + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + assert ( + await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + is False + ) + assert await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) == 0 + assert delivery.delivered == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py index 0ea2507f981..4eea488d5bd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -118,6 +118,18 @@ async def settle( ): key = (session_id, execution_id) if key in self.rows: + if self.rows[key].terminal_outcome is None: + self.rows[key] = self.rows[key].model_copy( + update={ + "state": "terminal", + "terminal_outcome": terminal_outcome, + "settled_by": settled_by, + "settled_at": settled_at or datetime.now(timezone.utc), + } + ) + return SessionExecutionSettlementResult( + settlement=self.rows[key], won=True + ) return SessionExecutionSettlementResult( settlement=self.rows[key], won=False ) @@ -132,6 +144,11 @@ async def settle( self.rows[key] = row return SessionExecutionSettlementResult(settlement=row, won=True) + async def fetch_execution(self, *, project_id, session_id, execution_id): + if self.raises: + raise RuntimeError("core database is unreachable") + return self.rows.get((session_id, execution_id)) + async def query_settled(self, *, project_id, keys): if self.raises: raise RuntimeError("core database is unreachable") @@ -332,16 +349,56 @@ async def test_execution_lookup_failure_appends_the_batch_unguarded(monkeypatch) assert _quarantined(dao) == [] -async def test_ingest_does_not_write_a_terminal_execution(monkeypatch): +async def test_runner_done_terminalizes_a_continuation_execution(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + await service.append_many(events=[_event("done")]) + + execution = executions.rows[(_SESSION, _TURN)] + assert execution.terminal_outcome == "completed" + assert execution.settled_by == "runner" + + +async def test_paused_or_quarantined_done_does_not_complete_a_continuation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) + service = RecordsService( + records_dao=_StubDAO(watchdog_settled={(_SESSION, _TURN)}), + executions_dao=executions, + ) + await service.append_many( - events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + events=[_event("done", attributes={"type": "done", "stopReason": "paused"})] + ) + assert executions.rows[(_SESSION, _TURN)].terminal_outcome is None + + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="lost", + settled_by="watchdog", ) + await service.append_many(events=[_event("done")]) - assert executions.rows == {} + assert executions.rows[(_SESSION, _TURN)].terminal_outcome == "lost" + assert _quarantined(service.records_dao)[-1].record_type == "done" async def test_ingest_marks_the_runners_terminal_record_written(monkeypatch): diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 9c949b42682..ab5b07b2ec9 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -37,6 +37,7 @@ ORPHAN_THRESHOLD_SECONDS, run_orphan_sweep, ) +from oss.src.utils.env import env _PROJECT_ID = "proj-sweep-1" @@ -267,6 +268,29 @@ async def repair_terminal_redis(self): return 0 +class _CompletedRecords: + async def settled_turns(self, *, project_id, keys): + return set(keys) + + async def runner_completed_turns(self, *, project_id, keys): + return set(keys) + + +class _CompletionLookupFailure(_CompletedRecords): + async def runner_completed_turns(self, *, project_id, keys): + raise RuntimeError("records database unavailable") + + +class _CompletionCommands(_OrderedCommandsService): + def __init__(self, *, succeeds: bool) -> None: + super().__init__() + self.succeeds = succeeds + + async def settle_execution_completed(self, **kwargs): + self.calls.append(("completed", kwargs["execution_id"])) + return self.succeeds + + @pytest.mark.anyio async def test_redis_repair_runs_after_the_sweeps_main_work(anyio_backend): commands = _OrderedCommandsService() @@ -295,6 +319,77 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): ) +@pytest.mark.anyio +async def test_persisted_done_is_terminalized_before_stale_ownership_is_cleared( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + row = _FakeRow( + session_id="sess-completed-continuation", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + commands = _CompletionCommands(succeeds=True) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletedRecords(), + commands_service=commands, + ) + + assert ("completed", "continuation-1") in commands.calls + assert _swept(row) + + +@pytest.mark.anyio +async def test_completion_settlement_failure_keeps_ownership_blocking_replay( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + row = _FakeRow( + session_id="sess-completion-race", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletedRecords(), + commands_service=_CompletionCommands(succeeds=False), + ) + + assert not _swept(row) + + +@pytest.mark.anyio +async def test_completion_lookup_failure_keeps_ownership_blocking_replay( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + row = _FakeRow( + session_id="sess-completion-lookup-race", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletionLookupFailure(), + commands_service=_CompletionCommands(succeeds=True), + ) + + assert not _swept(row) + + @pytest.mark.anyio async def test_idle_row_survives_the_short_threshold(anyio_backend): """The regression: a turn parked awaiting approval stops beating but stays resumable.""" diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py new file mode 100644 index 00000000000..75cdd863a63 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -0,0 +1,177 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +from oss.src.apis.fastapi.sessions import router as router_module +from oss.src.apis.fastapi.sessions.models import SessionInteractionRespondRequest +from oss.src.apis.fastapi.sessions.router import InteractionsRouter +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.core.sessions.commands.dtos import SessionCommandState +from oss.src.core.sessions.commands.types import IdempotencyKeyReused +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionKind, + SessionInteractionStatus, +) +from oss.src.utils.env import env + + +async def test_durable_response_returns_202_and_stable_refs(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + command_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="turn-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=SimpleNamespace(id=command_id, state=SessionCommandState.pending), + execution_id="turn-2", + execution_state=SessionExecutionState.pending_delivery, + ) + commands = SimpleNamespace(respond_interaction=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest( + answer={"approved": True}, expected_execution_id="turn-1" + ), + ) + + assert response.status_code == 202 + assert json.loads(response.body) == { + "interaction": interaction.model_dump(mode="json"), + "command": {"id": str(command_id), "state": "pending"}, + "execution": {"id": "turn-2", "state": "pending_delivery"}, + } + commands.respond_interaction.assert_awaited_once_with( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-1", + idempotency_key="answer-1", + ) + + +async def test_durable_response_returns_the_conflict_envelope(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + commands = SimpleNamespace( + respond_interaction=AsyncMock(side_effect=IdempotencyKeyReused()) + ) + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest(answer={"approved": False}), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "code": "idempotency_key_reused", + "message": "This idempotency key was already used for a different response.", + "retryable": False, + } + + +async def test_durable_validation_error_returns_422(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace( + respond_interaction=AsyncMock( + side_effect=router_module.InteractionResponseConflict( + code="validation_error", + message="The interaction is not linked to an execution.", + ) + ) + ) + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ), + interaction_id=uuid4(), + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + assert response.status_code == 422 + assert json.loads(response.body)["code"] == "validation_error" + + +async def test_continuation_resume_endpoint_is_feature_gated(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace( + resume_recoverable_continuation=AsyncMock(return_value=True) + ) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionControlRouter(commands_service=commands) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id) + ) + + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + disabled = await router.resume_session_continuation( + request=request, session_id="session-1" + ) + assert disabled.resumed is False + commands.resume_recoverable_continuation.assert_not_awaited() + + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + enabled = await router.resume_session_continuation( + request=request, session_id="session-1" + ) + assert enabled.resumed is True + commands.resume_recoverable_continuation.assert_awaited_once_with( + project_id=project_id, session_id="session-1" + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py index 35c66fbb1f5..ac03638be9a 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -341,6 +341,9 @@ def __init__(self) -> None: self.commands = None self.interactions = None + async def fetch_execution(self, *, project_id, session_id, execution_id): + return self.rows.get((session_id, execution_id)) + async def settle( self, *, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index acca6be0006..94d03d20135 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -11,6 +11,7 @@ import asyncio import uuid from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock import pytest from sqlalchemy import text @@ -23,11 +24,22 @@ SessionCommandState, ) from oss.src.core.sessions.commands.interfaces import SessionScope +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt from oss.src.core.sessions.commands.service import SessionCommandsService -from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.commands.types import ( + ExecutionExpectationFailed, + IdempotencyKeyReused, + InteractionResponseConflict, +) from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.interactions.dtos import ( + SessionInteractionStatus, + SessionInteractionTransition, +) +from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO import oss.src.dbs.postgres.shared.engine as engine_module @@ -191,6 +203,33 @@ async def test_a_repeated_idempotency_key_returns_the_first_row(command_scope): ) +async def test_concurrent_shared_transactions_replay_one_idempotent_command( + command_scope, +): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + async def insert(): + async with command_scope["engine"].session() as transaction: + return await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, idempotency_key="shared-retry"), + transaction=transaction, + ) + + first, second = await asyncio.wait_for( + asyncio.gather(insert(), insert()), timeout=5 + ) + + assert first.id == second.id + assert ( + await dao.count_open( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + == 1 + ) + + async def test_two_open_commands_for_one_execution_collapse_to_one(command_scope): # Two Stops for the same execution are one intent, even with no idempotency key and even # when admission's own read cannot see the other because it has not committed yet. The @@ -563,7 +602,13 @@ async def test_old_pending_commands_are_returned_for_redelivery(command_scope): now = datetime.now(timezone.utc) command = await dao.create_command( user_id=command_scope["user_id"], - command=_create(command_scope, created_at=now - timedelta(minutes=5)), + # The integration database is intentionally reused between runs. Put this row ahead of + # any accumulated abandoned-command backlog so the DAO's production batch limit does not + # make the assertion depend on how many earlier test runs used the same database. + command=_create( + command_scope, + created_at=datetime(1970, 1, 1, tzinfo=timezone.utc), + ), ) rows = await dao.expire_claims( @@ -680,6 +725,437 @@ async def test_execution_ending_marker_is_one_way(command_scope): ) +async def _insert_pending_interaction(scope, *, token: str): + interaction_id = uuid.uuid4() + async with scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_interactions " + "(project_id, id, session_id, turn_id, token, kind, status) " + "VALUES (:project_id, :id, :session_id, 'turn-A', :token, " + "'user_approval', 'pending')" + ), + { + "project_id": scope["project_id"], + "id": interaction_id, + "session_id": scope["session_id"], + "token": token, + }, + ) + return interaction_id + + +class _UnreachableDelivery: + async def deliver(self, **kwargs): + return DeliveryReceipt(status="unreachable") + + async def acknowledge(self, **kwargs): + return None + + +def _commands_service(scope, *, executions=None): + interactions = SessionInteractionsDAO(engine=scope["engine"]) + service = SessionCommandsService( + commands_dao=SessionCommandsDAO(engine=scope["engine"]), + streams_service=None, + interactions_service=SessionInteractionsService(interactions_dao=interactions), + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=executions or SessionExecutionsDAO(engine=scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("turn-A", None)) + return service + + +async def test_full_service_stop_and_answer_have_one_postgres_winner(command_scope): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-race" + ) + service = _commands_service(command_scope) + + results = await asyncio.gather( + service.request_cancel( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + session_id=command_scope["session_id"], + expected_execution_id="turn-A", + idempotency_key="stop-race", + ), + service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-race", + ), + return_exceptions=True, + ) + + assert sum(not isinstance(result, Exception) for result in results) == 1 + loser = next(result for result in results if isinstance(result, Exception)) + assert isinstance(loser, (ExecutionExpectationFailed, InteractionResponseConflict)) + + interaction = await SessionInteractionsDAO( + engine=command_scope["engine"] + ).fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status in ( + SessionInteractionStatus.cancelled, + SessionInteractionStatus.responded, + ) + + +async def test_full_service_failure_rolls_back_answer_execution_and_command( + command_scope, +): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-rollback" + ) + async with command_scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_executions " + "(project_id, session_id, execution_id, state) " + "VALUES (:project_id, :session_id, 'turn-A', 'active')" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + service = _commands_service(command_scope) + create_command = service._dao.create_command + + async def fail_after_command_insert(**kwargs): + await create_command(**kwargs) + raise RuntimeError("abort transaction") + + service._dao.create_command = fail_after_command_insert + + with pytest.raises(RuntimeError, match="abort transaction"): + await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-rollback", + ) + + interaction = await SessionInteractionsDAO( + engine=command_scope["engine"] + ).fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status == SessionInteractionStatus.pending + async with command_scope["engine"].session() as session: + execution = ( + await session.execute( + text( + "SELECT state, terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ).one() + continuation_count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + commands = await session.scalar( + text( + "SELECT count(*) FROM session_commands " + "WHERE project_id = :project_id AND session_id = :session_id" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + assert execution == ("active", None) + assert continuation_count == 0 + assert commands == 0 + + +async def test_full_service_concurrent_same_key_same_answer_replays_ids(command_scope): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-same" + ) + service = _commands_service(command_scope) + request = dict( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-same", + ) + + first, second = await asyncio.gather( + service.respond_interaction(**request), + service.respond_interaction(**request), + ) + + assert first.command.id == second.command.id + assert first.execution_id == second.execution_id + + +async def test_full_service_concurrent_same_key_conflicting_answer_is_409_domain( + command_scope, +): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-conflict" + ) + service = _commands_service(command_scope) + common = dict( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + expected_execution_id="turn-A", + idempotency_key="answer-conflict", + ) + + results = await asyncio.gather( + service.respond_interaction(answer={"approved": True}, **common), + service.respond_interaction(answer={"approved": False}, **common), + return_exceptions=True, + ) + + assert sum(not isinstance(result, Exception) for result in results) == 1 + conflict = next(result for result in results if isinstance(result, Exception)) + assert isinstance(conflict, IdempotencyKeyReused) + + +async def test_running_continuation_blocks_and_only_reopens_after_recovery( + command_scope, +): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction( + command_scope, token="running-blocker" + ) + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + command = await commands.create_command( + user_id=command_scope["user_id"], + command=_create( + command_scope, + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-running", + expected_turn_id="turn-A", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-running", + }, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.started, + settled_at=datetime.now(timezone.utc), + ), + transaction=transaction, + ) + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + state=SessionExecutionState.running, + transaction=transaction, + ) + + blocker = await commands.fetch_resumable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + assert blocker is not None and blocker.id == command.id + assert ( + await commands.reopen_continuation( + project_id=command_scope["project_id"], + command_id=command.id, + target_turn_id="continuation-running", + replacement_turn_id="continuation-retry", + ) + is None + ) + + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + state=SessionExecutionState.recoverable, + expected_states=[SessionExecutionState.running], + ) + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-retry", + parent_execution_id="continuation-running", + source_interaction_id=None, + transaction=transaction, + ) + reopened = await commands.reopen_continuation( + project_id=command_scope["project_id"], + command_id=command.id, + target_turn_id="continuation-running", + replacement_turn_id="continuation-retry", + ) + assert reopened is not None + assert reopened.state == SessionCommandState.pending + assert reopened.claimed_by is None + assert reopened.target_turn_id == "continuation-retry" + + +async def test_stop_and_answer_have_one_postgres_serialized_winner(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token="race") + + async def stop(): + async with commands.transaction() as transaction: + source = await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + if source.terminal_outcome is not None: + return False + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + state=SessionExecutionState.stopping, + transaction=transaction, + ) + await interactions.cancel_session_pending( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + only_turn_id="turn-A", + transaction=transaction, + ) + return True + + async def answer(): + async with commands.transaction() as transaction: + source = await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], + interaction_id=interaction_id, + transaction=transaction, + for_update=True, + ) + if ( + source.terminal_outcome is not None + or source.state == SessionExecutionState.stopping + or interaction.status != SessionInteractionStatus.pending + ): + return False + await interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + token="race", + status=SessionInteractionStatus.responded, + resolution={"approved": True}, + ), + transaction=transaction, + ) + await executions.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + return True + + winners = await asyncio.gather(stop(), answer()) + assert sum(winners) == 1 + + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status in ( + SessionInteractionStatus.cancelled, + SessionInteractionStatus.responded, + ) + + +async def test_continuation_transaction_rolls_back_the_answer_on_failure(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token="rollback") + + with pytest.raises(RuntimeError, match="abort transaction"): + async with commands.transaction() as transaction: + await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + await interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + token="rollback", + status=SessionInteractionStatus.responded, + resolution={"approved": True}, + ), + transaction=transaction, + ) + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-rollback", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + raise RuntimeError("abort transaction") + + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status == SessionInteractionStatus.pending + async with command_scope["engine"].session() as session: + count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND execution_id = 'continuation-rollback'" + ), + {"project_id": command_scope["project_id"]}, + ) + assert count == 0 + + async def test_terminal_core_facts_commit_in_one_transaction(command_scope): commands = SessionCommandsDAO(engine=command_scope["engine"]) executions = SessionExecutionsDAO(engine=command_scope["engine"]) diff --git a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py index 59782cfd83c..394a3979f2d 100644 --- a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py +++ b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py @@ -14,6 +14,7 @@ from oss.src.core.workflows.service import WorkflowsService from oss.src.core.workflows.types import WorkflowDetachedStartFailed +from oss.src.utils.env import env class _FakeStreamResponse: @@ -126,6 +127,42 @@ async def test_stream_service_started_raises_on_http_error(): ) +@pytest.mark.parametrize( + "line", + [ + "not-json", + "[]", + '{"kind": "result", "result": {"ok": false, "error": "rejected"}}', + '{"kind": "result"}', + '{"kind": "unknown"}', + ], +) +async def test_stream_service_started_rejects_failure_or_malformed_first_record(line): + response = _FakeStreamResponse(lines=[line]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed): + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + ) + + +async def test_stream_service_started_accepts_success_result_record(): + response = _FakeStreamResponse( + lines=['{"kind": "result", "result": {"ok": true}}'], + ) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + ) + assert result.accepted is True + + async def test_invoke_workflow_detached_returns_run_id_and_threads_meta(): svc = _service() project_id = uuid4() @@ -194,6 +231,48 @@ async def test_invoke_workflow_batch_still_returns_400_when_no_service_url(): assert result.status.code == 400 +async def test_ordinary_session_invoke_redelivers_recoverable_continuation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + svc = _service() + resume = AsyncMock(return_value=True) + svc.set_session_continuation_resumer(resume) + svc._prepare_invoke = AsyncMock() + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + project_id = uuid4() + result = await svc.invoke_workflow( + project_id=project_id, + user_id=uuid4(), + request=WorkflowServiceRequest(session_id="session-1"), + ) + + assert result.status.code == 409 + resume.assert_awaited_once_with(project_id=project_id, session_id="session-1") + svc._prepare_invoke.assert_not_awaited() + + +async def test_control_continuation_bypasses_ordinary_send_recovery_hook(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + svc = _service() + resume = AsyncMock(return_value=True) + svc.set_session_continuation_resumer(resume) + svc._prepare_invoke = AsyncMock(return_value=("Secret tok", None)) + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + result = await svc.invoke_workflow( + project_id=uuid4(), + user_id=uuid4(), + request=WorkflowServiceRequest( + session_id="session-1", meta={"control_command_id": "command-1"} + ), + ) + + assert result.status.code == 400 + resume.assert_not_awaited() + + def test_dispatch_fn_injected_into_both_consumers(): """The entrypoint wires a real dispatch_fn into both detached consumers.""" from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( From b0ada37bd7c33d89c958f4e273918cf7b3eb6b1b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 12:49:06 +0200 Subject: [PATCH 004/133] fix(sessions): fence continuation recovery Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/oss/src/apis/fastapi/sessions/router.py | 37 +++++++- .../sessions/test_orphan_sweep_thresholds.py | 3 + .../sessions/test_record_ingest_endpoint.py | 87 +++++++++++++++++++ services/runner/src/server.ts | 68 ++++++++++----- .../src/sessions/continuation-admission.ts | 69 ++++++++++----- .../tests/unit/continuation-admission.test.ts | 79 +++++++++++++++++ services/runner/tests/unit/server.test.ts | 76 ++++++++++++++++ 7 files changed, 372 insertions(+), 47 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 1fa9e42ef7e..374e4b39cdc 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -91,6 +91,7 @@ MAX_LIVE_FRAME_BYTES, SessionLiveFrame, SessionRecordEvent, + TERMINAL_RECORD_TYPE, ) from oss.src.core.sessions.records.streaming import publish_live_frame, publish_record from oss.src.core.sessions.interactions.dtos import ( @@ -814,8 +815,13 @@ async def watch_project( class RecordsRouter: """Records sub-router — /sessions/records/*""" - def __init__(self, records_service: RecordsService): + def __init__( + self, + records_service: RecordsService, + commands_service: Optional[SessionCommandsService] = None, + ): self.records_service = records_service + self.commands_service = commands_service self.router = APIRouter() self.router.add_api_route( @@ -996,7 +1002,24 @@ async def ingest_record_event( return {"ok": True} assert not isinstance(body, list) - await publish_record( + # For a finished continuation, commit the core execution outcome before accepting its + # terminal record into the asynchronous tracing stream. This closes the cross-database + # window where the watchdog could see no `done`, expose recovery, and replay work that + # had already finished while the records worker was still settling core state. + if ( + env.agenta.sessions.durable_stop + and self.commands_service is not None + and body.record_type == TERMINAL_RECORD_TYPE + and body.turn_id + and (body.attributes or {}).get("stopReason") != "paused" + ): + await self.commands_service.settle_execution_completed( + project_id=UUID(project_id), + session_id=body.session_id, + execution_id=body.turn_id, + ) + + published = await publish_record( organization_id=UUID(request.state.organization_id), project_id=UUID(project_id), record_event=SessionRecordEvent( @@ -1012,6 +1035,11 @@ async def ingest_record_event( span_id=body.span_id, ), ) + if not published: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Record ingestion is temporarily unavailable.", + ) return {"ok": True} @@ -2540,7 +2568,10 @@ def __init__( interactions_service=interactions_service, records_service=records_service, ) - self.records = RecordsRouter(records_service=records_service) + self.records = RecordsRouter( + records_service=records_service, + commands_service=commands_service, + ) self.interactions = InteractionsRouter( interactions_service=interactions_service, workflows_service=workflows_service, diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index ab5b07b2ec9..e716dc301fa 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -139,6 +139,9 @@ def __init__( self.flags = flags self.created_at = datetime.now(timezone.utc) - timedelta(days=1) self.updated_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) + self.terminal_outcome = None + self.ending_written_at = None + self.settled_at = None class _FakeScalars: diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 2ee452d4b44..59b1eb5c59e 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -145,6 +145,93 @@ async def test_record_ingest_threads_turn_id_and_span_id(): assert event.span_id == span_id +async def test_terminal_continuation_settles_core_before_stream_acceptance(monkeypatch): + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_stop", True + ) + records_service = AsyncMock() + commands_service = AsyncMock() + router = RecordsRouter( + records_service=records_service, + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + attributes={"stopReason": "end_turn"}, + ) + order = [] + commands_service.settle_execution_completed.side_effect = lambda **_: order.append( + "settled" + ) + + async def publish(**kwargs): + order.append("published") + return True + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", side_effect=publish + ), + ): + await router.ingest_record_event(request=request, body=body) + + assert order == ["settled", "published"] + commands_service.settle_execution_completed.assert_awaited_once_with( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + ) + + +async def test_terminal_publish_failure_is_retryable_after_core_settlement(monkeypatch): + from fastapi import HTTPException + + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_stop", True + ) + commands_service = AsyncMock() + router = RecordsRouter( + records_service=AsyncMock(), + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + ) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=False, + ), + pytest.raises(HTTPException) as exc_info, + ): + await router.ingest_record_event(request=request, body=body) + + assert exc_info.value.status_code == 503 + commands_service.settle_execution_completed.assert_awaited_once() + + async def test_record_ingest_defaults_turn_id_and_span_id_to_none(): records_service = AsyncMock() router = RecordsRouter(records_service=records_service) diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 3194d089572..0aee969202b 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -104,6 +104,7 @@ import { } from "./sessions/control-channel.ts"; import { claimContinuationAdmission } from "./sessions/continuation-admission.ts"; import { + findExecution, noteExecutionProject, registerExecution, unregisterExecution, @@ -523,12 +524,13 @@ async function runAndStreamWithApiBaseResolved( // This is the continuation's exactly-once admission boundary. Everything above it is pure // request validation and remains retryable. A duplicate never creates a controller, never // replaces the live-execution registry entry, and never calls the engine. - const continuationAdmission = controlCommandId + let continuationAdmission = controlCommandId ? claimContinuationAdmission(controlCommandId, turnId) : undefined; + let continuationAlreadyAdmitted = false; if (continuationAdmission?.role === "duplicate") { - const admitted = await continuationAdmission.admitted; - if (!admitted) { + const priorGenerationAdmitted = await continuationAdmission.admitted; + if (!priorGenerationAdmitted) { writeRecord({ kind: "result", result: { @@ -542,11 +544,40 @@ async function runAndStreamWithApiBaseResolved( return; } try { - await reportContinuationAdmission({ + const admitted = await reportContinuationAdmission({ commandId: controlCommandId!, sessionId, - executionId: continuationAdmission.executionId, + executionId: turnId, }); + if (!admitted) { + const live = findExecution( + projectScopeFor(request, undefined)?.id ?? "", + sessionId, + ); + if (!live || live.turnId !== turnId) { + continuationAdmission.forget(); + } + writeRecord({ + kind: "result", + result: { + ok: true, + output: "", + stopReason: "control_command_duplicate", + events: [], + sessionId, + }, + }); + res.end(); + return; + } + const promoted = continuationAdmission.promote(); + if (!promoted) { + throw new Error( + "Continuation admission changed while recovering; retry delivery.", + ); + } + continuationAdmission = promoted; + continuationAlreadyAdmitted = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write( @@ -559,18 +590,6 @@ async function runAndStreamWithApiBaseResolved( res.end(); return; } - writeRecord({ - kind: "result", - result: { - ok: true, - output: "", - stopReason: "control_command_duplicate", - events: [], - sessionId, - }, - }); - res.end(); - return; } // Diagnostic: surface whether the session-owned persist/alive path is entered and whether the @@ -719,13 +738,15 @@ async function runAndStreamWithApiBaseResolved( // The API settles the durable command and marks this execution running before the harness // can observe the approval. If the callback fails, release the process-local claim and live // registry entry: no engine work started, so redelivery is safe. - const admitted = await reportContinuationAdmission({ - commandId: controlCommandId!, - sessionId, - executionId: turnId, - }); - continuationAdmission.admit(); + const admitted = continuationAlreadyAdmitted + ? true + : await reportContinuationAdmission({ + commandId: controlCommandId!, + sessionId, + executionId: turnId, + }); if (!admitted) { + continuationAdmission.release(); aliveWatchdog?.abandon(); unregisterExecution(sessionId, turnId); writeRecord({ @@ -741,6 +762,7 @@ async function runAndStreamWithApiBaseResolved( res.end(); return; } + continuationAdmission.admit(); } catch (error) { continuationAdmission.release(); aliveWatchdog?.abandon(); diff --git a/services/runner/src/sessions/continuation-admission.ts b/services/runner/src/sessions/continuation-admission.ts index 57b3d14f934..8fa92b92f17 100644 --- a/services/runner/src/sessions/continuation-admission.ts +++ b/services/runner/src/sessions/continuation-admission.ts @@ -29,40 +29,34 @@ interface AppliedAdmission { type Admission = PendingAdmission | AppliedAdmission; +export type ContinuationAdmissionLeader = { + role: "leader"; + executionId: string; + admit: () => void; + release: () => void; +}; + export type ContinuationAdmissionClaim = - | { - role: "leader"; - executionId: string; - admit: () => void; - release: () => void; - } + | ContinuationAdmissionLeader | { role: "duplicate"; /** The first delivery's execution id is authoritative for duplicate outcome reports. */ executionId: string; /** False means the leader failed before durable admission and this delivery may be retried. */ admitted: Promise; + /** Replace a stale applied cache entry after the API grants a recoverable generation. */ + promote: () => ContinuationAdmissionLeader | undefined; + /** Evict only the cache generation this duplicate observed. */ + forget: () => void; }; const admissions = new Map(); -/** Claim a command immediately before creating/registering its fresh execution guard. */ -export function claimContinuationAdmission( +function createLeader( commandId: string, executionId: string, - now = Date.now(), -): ContinuationAdmissionClaim { - prune(now); - const existing = admissions.get(commandId); - if (existing) { - return { - role: "duplicate", - executionId: existing.executionId, - admitted: - existing.phase === "applied" ? Promise.resolve(true) : existing.settled, - }; - } - + now: number, +): ContinuationAdmissionLeader { let settle!: (admitted: boolean) => void; const settled = new Promise((resolve) => { settle = resolve; @@ -96,6 +90,39 @@ export function claimContinuationAdmission( }; } +/** Claim a command immediately before creating/registering its fresh execution guard. */ +export function claimContinuationAdmission( + commandId: string, + executionId: string, + now = Date.now(), +): ContinuationAdmissionClaim { + prune(now); + const existing = admissions.get(commandId); + if (existing) { + return { + role: "duplicate", + executionId: existing.executionId, + admitted: + existing.phase === "applied" ? Promise.resolve(true) : existing.settled, + promote: () => { + const current = admissions.get(commandId); + if (current && current !== existing) return undefined; + // A losing concurrent probe may evict the stale generation before this API-winning + // response returns. The durable API CAS is authoritative: its sole winner may recreate + // the local barrier when the old cache entry is still present or gone. It must never + // overwrite a newer pending generation: doing that strands every duplicate awaiting + // the newer generation's promise. + return createLeader(commandId, executionId, Date.now()); + }, + forget: () => { + if (admissions.get(commandId) === existing) + admissions.delete(commandId); + }, + }; + } + return createLeader(commandId, executionId, now); +} + function prune(now: number): void { for (const [commandId, admission] of admissions) { if (now - admission.insertedAt >= ADMISSION_TTL_MS) { diff --git a/services/runner/tests/unit/continuation-admission.test.ts b/services/runner/tests/unit/continuation-admission.test.ts index 47f685b7ba0..805112a0faa 100644 --- a/services/runner/tests/unit/continuation-admission.test.ts +++ b/services/runner/tests/unit/continuation-admission.test.ts @@ -69,4 +69,83 @@ describe("durable continuation admission", () => { ); assert.equal(afterTtl.role, "leader"); }); + + it("evicts a stale applied generation after the API finds no live execution", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const stale = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(stale.role, "duplicate"); + stale.forget(); + assert.equal( + claimContinuationAdmission("command-1", "turn-1", 3).role, + "leader", + ); + }); + + it("promotes only the API-winning duplicate into a fresh generation", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const winner = claimContinuationAdmission("command-1", "turn-1", 2); + const loser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(winner.role, "duplicate"); + assert.equal(loser.role, "duplicate"); + const promoted = winner.promote(); + assert.equal(promoted?.role, "leader"); + loser.forget(); + assert.equal( + claimContinuationAdmission("command-1", "turn-1", 3).role, + "duplicate", + "the prior loser cannot evict the recovered generation", + ); + }); + + it("promotes a recovered command with its fresh execution generation", () => { + const first = claimContinuationAdmission("command-1", "turn-old", 1); + assert.equal(first.role, "leader"); + first.admit(); + + const recovered = claimContinuationAdmission("command-1", "turn-fresh", 2); + assert.equal(recovered.role, "duplicate"); + assert.equal(recovered.executionId, "turn-old"); + + const promoted = recovered.promote(); + assert.equal(promoted?.executionId, "turn-fresh"); + const waiter = claimContinuationAdmission("command-1", "turn-fresh", 3); + assert.equal(waiter.role, "duplicate"); + assert.equal(waiter.executionId, "turn-fresh"); + }); + + it("lets the API winner recover after a losing probe evicts the stale cache", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const winner = claimContinuationAdmission("command-1", "turn-1", 2); + const loser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(winner.role, "duplicate"); + assert.equal(loser.role, "duplicate"); + loser.forget(); + assert.equal(winner.promote()?.role, "leader"); + }); + + it("never overwrites a newer pending generation during promotion", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const staleWinner = claimContinuationAdmission("command-1", "turn-1", 2); + const staleLoser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(staleWinner.role, "duplicate"); + assert.equal(staleLoser.role, "duplicate"); + + staleLoser.forget(); + const freshLeader = claimContinuationAdmission("command-1", "turn-1", 3); + const freshWaiter = claimContinuationAdmission("command-1", "turn-1", 4); + assert.equal(freshLeader.role, "leader"); + assert.equal(freshWaiter.role, "duplicate"); + + assert.equal(staleWinner.promote(), undefined); + freshLeader.release(); + assert.equal(await freshWaiter.admitted, false); + }); }); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index 142093a9c75..8e3fecc992b 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -1092,6 +1092,82 @@ describe("createAgentServer", () => { } }); + it("recovers after the API committed admission but its response was lost", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async () => { + runCalls += 1; + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if ( + url.includes( + "/sessions/control/commands/command-response-loss/outcome", + ) + ) { + reportCalls += 1; + if (reportCalls === 1) { + // The API committed applied/running, but the runner never observed the response. + throw new Error("connection reset after response commit"); + } + return Response.json({ + command: { id: "command-response-loss", state: "applied" }, + // The immediate retry sees running. The later retry represents API watchdog/preflight + // recovery, whose recoverable->running CAS grants exactly one fresh admission. + admitted: reportCalls >= 3, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-response-loss" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-response-loss", + turnId: "continuation-turn-response-loss", + projectId: "project-1", + controlCommandId: "command-response-loss", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + assert.equal((await deliver()).at(-1).result.ok, false); + assert.equal( + (await deliver()).at(-1).result.stopReason, + "control_command_duplicate", + ); + assert.equal(runCalls, 0); + assert.equal((await deliver()).at(-1).result.ok, true); + assert.equal(runCalls, 1); + assert.equal(reportCalls, 3); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + it("does not report or run a continuation until a fresh controller owns the alive lock", async () => { vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); let runCalls = 0; From 7fc342f2b93678db7bba2653e59255398ed428ef Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 12:50:38 +0200 Subject: [PATCH 005/133] fix(web): preflight durable continuation retries Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- web/mobile/src/features/chat/TurnRow.tsx | 8 ++- .../src/features/chat/continuationRetry.ts | 10 ++++ .../tests/unit/continuationRetry.test.ts | 21 +++++++ .../components/AgentMessage.tsx | 1 + .../hooks/useAgentChatSession.ts | 47 +++++++++------ .../api/resources/sessions/client/Client.ts | 60 +++++++++++++++++++ .../ResumeSessionContinuationRequest.ts | 11 ++++ .../sessions/client/requests/index.ts | 1 + .../SessionContinuationResumeResponse.ts | 5 ++ .../src/generated/api/types/index.ts | 1 + .../src/assets/continuationPreflight.ts | 31 ++++++++++ web/packages/agenta-chat/src/assets/index.ts | 1 + .../src/hooks/useAgentConversation.ts | 45 ++++++++------ web/packages/agenta-chat/src/model/error.ts | 11 +++- .../unit/assets/continuationPreflight.test.ts | 43 +++++++++++++ .../unit/hooks/useAgentConversation.test.ts | 27 +++++++++ .../tests/unit/model/error.test.ts | 14 +++++ .../agenta-entities/src/session/api/api.ts | 29 +++++++++ .../agenta-entities/src/session/index.ts | 8 ++- .../src/session/state/interactionAnswer.ts | 19 +++++- .../session-continuation-resume-api.test.ts | 40 +++++++++++++ 21 files changed, 387 insertions(+), 46 deletions(-) create mode 100644 web/mobile/src/features/chat/continuationRetry.ts create mode 100644 web/mobile/tests/unit/continuationRetry.test.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts create mode 100644 web/packages/agenta-chat/src/assets/continuationPreflight.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts create mode 100644 web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 5029b066803..203de3ed36e 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -42,6 +42,7 @@ import { import {Button} from "@/components/ui/button" import {AssistantMarkdown} from "./AssistantMarkdown" +import {continuationRetryAction} from "./continuationRetry" import {isLiveTextItem} from "./markdownStream" type ToolsItem = Extract @@ -358,9 +359,10 @@ export const TurnRow = ({ {turn.status.showError ? ( onRewind(turn) : undefined} + onRetry={continuationRetryAction( + turn, + onRewind ? () => onRewind(turn) : undefined, + )} /> ) : null}
diff --git a/web/mobile/src/features/chat/continuationRetry.ts b/web/mobile/src/features/chat/continuationRetry.ts new file mode 100644 index 00000000000..5b1ca068124 --- /dev/null +++ b/web/mobile/src/features/chat/continuationRetry.ts @@ -0,0 +1,10 @@ +import type {TurnViewModel} from "@agenta/chat/model" + +type RetryableTurn = Pick + +/** Only the latest continuation-race error can safely replay its originating message. */ +export const continuationRetryAction = ( + turn: RetryableTurn, + retry?: () => void, +): (() => void) | undefined => + turn.isLast && turn.status.errorCode === "continuation_resumed" ? retry : undefined diff --git a/web/mobile/tests/unit/continuationRetry.test.ts b/web/mobile/tests/unit/continuationRetry.test.ts new file mode 100644 index 00000000000..878ae78412b --- /dev/null +++ b/web/mobile/tests/unit/continuationRetry.test.ts @@ -0,0 +1,21 @@ +import {describe, expect, it, vi} from "vitest" + +import {continuationRetryAction} from "../../src/features/chat/continuationRetry" + +const turn = (isLast: boolean, errorCode: string | null) => + ({isLast, status: {errorCode}}) as Parameters[0] + +describe("continuationRetryAction", () => { + it("retries the latest continuation race error", () => { + const retry = vi.fn() + continuationRetryAction(turn(true, "continuation_resumed"), retry)?.() + expect(retry).toHaveBeenCalledOnce() + }) + + it("does not offer retry on historical or unrelated failures", () => { + const retry = vi.fn() + expect(continuationRetryAction(turn(false, "continuation_resumed"), retry)).toBeUndefined() + expect(continuationRetryAction(turn(true, "rate_limited"), retry)).toBeUndefined() + expect(retry).not.toHaveBeenCalled() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 443ddb59c53..c3fe1af46e6 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -154,6 +154,7 @@ const STARTER_CREDIT_CODES = new Set([ /** Transient failure classes where the honest advice is simply to run the turn again. */ const RETRYABLE_CODES = new Set([ + "continuation_resumed", "credential_delivery_failed", "starter_credits_unavailable", "rate_limited", diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 70fca85522b..a469ab6ffa9 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -4,6 +4,7 @@ import { buildRequestWithinDeadline, getMessageTraceId, latestTurnId, + prepareAfterContinuationPreflight, startupLabelFromDataPart, submitServerOwnedApproval, } from "@agenta/chat/assets" @@ -44,6 +45,7 @@ import { killSession, recordInteractionAnswerAtom, respondInteractionAnswerAtom, + resumeSessionContinuationAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, } from "@agenta/entities/session" @@ -147,6 +149,7 @@ export const useAgentChatSession = ({ const setSessionStatus = useSetAtom(setSessionStatusAtom) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. // `null` means "no live gate" — voided by a stop, or spent once a resume really went out; @@ -181,26 +184,32 @@ export const useAgentChatSession = ({ // instead of sticking to the revision this session first mounted on. const hooks: SessionChatHooks = { prepareRequest: async ({messages, id}) => { - clearSessionTurnId(sessionId) - turnAcceptedRef.current = false - acceptedExecutionIdRef.current = null - acceptedRunBySession.delete(sessionId) - setAcceptedRunPending(false) - const sharedResponse = sharedSenderReadyRef.current - const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" - turnDeliverySourceBySession.set(sessionId, deliverySource) - setTurnDeliverySource(deliverySource) - // Bounded: retries while the invocation URL is still loading and rejects if the build - // hangs, so a failed send surfaces as an error bubble instead of an eternal spinner - // (#6042). The helper owns the not-ready / timed-out errors. - const req = await buildRequestWithinDeadline(() => - buildAgentRequest(entityId, messages, { - sessionId: id ?? sessionId, - sharedResponse, - }), + return prepareAfterContinuationPreflight( + resumeSessionContinuation, + id ?? sessionId, + async () => { + clearSessionTurnId(sessionId) + turnAcceptedRef.current = false + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + const sharedResponse = sharedSenderReadyRef.current + const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" + turnDeliverySourceBySession.set(sessionId, deliverySource) + setTurnDeliverySource(deliverySource) + // Bounded: retries while the invocation URL is still loading and rejects if + // the build hangs, so a failed send surfaces as an error bubble instead of an + // eternal spinner (#6042). + const req = await buildRequestWithinDeadline(() => + buildAgentRequest(entityId, messages, { + sessionId: id ?? sessionId, + sharedResponse, + }), + ) + captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, ) - captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, // ── #6047 startup states: capture the runner's observed startup boundary as it streams ── onData: (part) => { diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 6f796e6e95c..e5b33539a7f 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -25,6 +25,66 @@ export class SessionsClient { this._options = normalizeClientOptionsWithAuth(options); } + /** Redeliver a recoverable durable continuation before admitting a fresh session turn. */ + public resumeSessionContinuation( + request: AgentaApi.ResumeSessionContinuationRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__resumeSessionContinuation(request, requestOptions)); + } + + private async __resumeSessionContinuation( + request: AgentaApi.ResumeSessionContinuationRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}/continuations/resume`, + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: {}, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.SessionContinuationResumeResponse, + rawResponse: _response.rawResponse, + }; + } + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/sessions/{session_id}/continuations/resume", + ); + } + /** * @param {AgentaApi.FetchSessionStreamRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts new file mode 100644 index 00000000000..5fc03545fa4 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface ResumeSessionContinuationRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index c6f8ae0fe7c..0dcd28e629b 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -11,6 +11,7 @@ export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; export type { FetchTurnRequest } from "./FetchTurnRequest.js"; export type { GetSessionSnapshotRequest } from "./GetSessionSnapshotRequest.js"; +export type { ResumeSessionContinuationRequest } from "./ResumeSessionContinuationRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; export type { SessionAttachmentReferenceRequest } from "./SessionAttachmentReferenceRequest.js"; export type { SessionDetachRequest } from "./SessionDetachRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts new file mode 100644 index 00000000000..fe53baa8a7d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionContinuationResumeResponse { + resumed: boolean; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index cad4a931cad..af3f0a9ec01 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -392,6 +392,7 @@ export * from "./SessionInteractionQuery.js"; export * from "./SessionInteractionQueryFlags.js"; export * from "./SessionInteractionRequest.js"; export * from "./SessionInteractionResponse.js"; +export * from "./SessionContinuationResumeResponse.js"; export * from "./SessionInteractionStatus.js"; export * from "./SessionInteractionsResponse.js"; export * from "./SessionListItem.js"; diff --git a/web/packages/agenta-chat/src/assets/continuationPreflight.ts b/web/packages/agenta-chat/src/assets/continuationPreflight.ts new file mode 100644 index 00000000000..e4de8a7c40e --- /dev/null +++ b/web/packages/agenta-chat/src/assets/continuationPreflight.ts @@ -0,0 +1,31 @@ +export type ResumeSessionContinuation = (sessionId: string) => Promise + +/** + * Preserve one owner for the next session turn. If the API redelivered a saved approval, + * abort before constructing or sending a competing direct runner invocation. + */ +export async function assertNoResumedSessionContinuation( + resume: ResumeSessionContinuation, + sessionId: string, +): Promise { + if (!(await resume(sessionId))) return + throw new Error( + JSON.stringify({ + status: { + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }, + }), + ) +} + +/** Run request construction only after the durable continuation has declined ownership. */ +export async function prepareAfterContinuationPreflight( + resume: ResumeSessionContinuation, + sessionId: string, + prepare: () => Promise, +): Promise { + await assertNoResumedSessionContinuation(resume, sessionId) + return prepare() +} diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 37e8515415b..45b8fb46cce 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -11,5 +11,6 @@ export * from "./conversationLayout" export * from "./jumpToLatest" export * from "./boundedRequest" export * from "./serverOwnedApproval" +export * from "./continuationPreflight" export {startupLabelFromDataPart} from "./startupPhases" export {getMessageTurnId, latestTurnId} from "./agentTurn" diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index dfa5c42e2da..344e95b8211 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -21,6 +21,7 @@ import { invalidateSessionLivenessQueries, recordInteractionAnswerAtom, respondInteractionAnswerAtom, + resumeSessionContinuationAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, shouldAdoptServerTranscript, @@ -41,6 +42,7 @@ import {useSetAtom, useStore} from "jotai" import {latestTurnId} from "../assets/agentTurn" import {buildRequestWithinDeadline} from "../assets/boundedRequest" +import {prepareAfterContinuationPreflight} from "../assets/continuationPreflight" import {filesToParts} from "../assets/files" import { isSessionTranscript, @@ -276,6 +278,7 @@ export const useAgentConversation = ({ const liveGateInteractionRef = useRef(null) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches // `onData` and never the transcript — this is the only place the answer survives. A stream that @@ -298,26 +301,30 @@ export const useAgentConversation = ({ const hooks: SessionChatHooks = { prepareRequest: async ({messages, id}) => { - clearSessionTurnId(sessionId) - turnAcceptedRef.current = false - acceptedExecutionIdRef.current = null - acceptedRunBySession.delete(sessionId) - setAcceptedRunPending(false) - const sharedResponse = sharedSenderReadyRef.current - const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" - turnDeliverySourceBySession.set(sessionId, deliverySource) - setTurnDeliverySource(deliverySource) - // Bounded, not instant. A null build means the workflow entity has not loaded its - // invocation URL YET — the first send to a freshly created agent races that fetch, and - // failing on the first null made a new user's first message fail (#6042 on the desktop; - // the same race reached /m through this hook). - const req = await buildRequestWithinDeadline(() => - buildAgentRequest(entityIdRef.current, messages, { - sessionId: id ?? sessionId, - sharedResponse, - }), + return prepareAfterContinuationPreflight( + resumeSessionContinuation, + id ?? sessionId, + async () => { + clearSessionTurnId(sessionId) + turnAcceptedRef.current = false + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + const sharedResponse = sharedSenderReadyRef.current + const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" + turnDeliverySourceBySession.set(sessionId, deliverySource) + setTurnDeliverySource(deliverySource) + // Bounded, not instant. A null build means the workflow entity has not loaded + // its invocation URL yet — the first send races that fetch (#6042). + const req = await buildRequestWithinDeadline(() => + buildAgentRequest(entityIdRef.current, messages, { + sessionId: id ?? sessionId, + sharedResponse, + }), + ) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, ) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, // Approve AND deny both resume — a deny-only decision must re-send so the runner // gets the denial round-trip and the model continues (no `approval-responded` limbo). diff --git a/web/packages/agenta-chat/src/model/error.ts b/web/packages/agenta-chat/src/model/error.ts index dd026f6ee86..d0eca58b4c1 100644 --- a/web/packages/agenta-chat/src/model/error.ts +++ b/web/packages/agenta-chat/src/model/error.ts @@ -82,7 +82,16 @@ export const parseAgentRunError = (err: unknown, serverErrorProvenance = false): ? (obj.message as string) : null if (message) { - return {message, code: typeof status?.code === "number" ? status.code : undefined} + const type = typeof status?.type === "string" ? status.type : undefined + const code = type?.endsWith("#continuation-resumed") + ? "continuation_resumed" + : typeof status?.code === "number" || typeof status?.code === "string" + ? status.code + : undefined + return { + message, + code, + } } } catch { // raw isn't JSON — it's already the human message. diff --git a/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts new file mode 100644 index 00000000000..27e6caf2ee5 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts @@ -0,0 +1,43 @@ +import {describe, expect, it, vi} from "vitest" + +import { + assertNoResumedSessionContinuation, + prepareAfterContinuationPreflight, +} from "../../../src/assets/continuationPreflight" +import {parseAgentRunError} from "../../../src/model/error" + +describe("assertNoResumedSessionContinuation", () => { + it("allows the ordinary request when the API did not resume a continuation", async () => { + await expect( + assertNoResumedSessionContinuation(vi.fn().mockResolvedValue(false), "session-1"), + ).resolves.toBeUndefined() + }) + + it("throws the retryable typed error when the saved continuation owns the turn", async () => { + let error: unknown + try { + await assertNoResumedSessionContinuation(vi.fn().mockResolvedValue(true), "session-1") + } catch (caught) { + error = caught + } + + expect(parseAgentRunError(error)).toEqual({ + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }) + }) + + it("never builds a request when the continuation takes ownership", async () => { + const prepare = vi.fn().mockResolvedValue({body: "must not run"}) + + await expect( + prepareAfterContinuationPreflight( + vi.fn().mockResolvedValue(true), + "session-1", + prepare, + ), + ).rejects.toThrow("continuation_resumed") + expect(prepare).not.toHaveBeenCalled() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 0ee98f69788..176d5e3fcc5 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -21,6 +21,8 @@ import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" +const {resumeContinuation} = vi.hoisted(() => ({resumeContinuation: vi.fn()})) + vi.mock("@agenta/playground/agent-chat", async (importOriginal) => { const actual = await importOriginal() return { @@ -49,6 +51,7 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { fetchSessionInteractionStatesAtom: atom(null, () => new Map()), fetchSessionSnapshot: vi.fn(), querySessionTranscript: vi.fn(), + resumeSessionContinuationAtom: atom(null, () => resumeContinuation()), } }) @@ -271,6 +274,8 @@ beforeEach(() => { } as SessionSnapshot) vi.mocked(querySessionTranscript).mockReset() vi.mocked(querySessionTranscript).mockResolvedValue([]) + resumeContinuation.mockReset() + resumeContinuation.mockResolvedValue(false) vi.mocked(buildAgentRequest).mockClear() // Restore the ready-workflow build: one test replaces it with a not-yet-loaded one, and // `mockClear` keeps the implementation. @@ -284,6 +289,28 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { + it("redelivers a durable continuation before request build and suppresses direct invoke", async () => { + resumeContinuation.mockResolvedValueOnce(true) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "do not race"}) + }) + await waitFor(() => expect(result.current.status).toBe("error")) + + expect(resumeContinuation).toHaveBeenCalledOnce() + expect(vi.mocked(buildAgentRequest)).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(result.current.error).toEqual({ + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }) + }) + it("runs a full turn: send → stream → settle → persist + status publish", async () => { fetchMock.mockResolvedValue(streamResponse("Hello back")) const store = createStore() diff --git a/web/packages/agenta-chat/tests/unit/model/error.test.ts b/web/packages/agenta-chat/tests/unit/model/error.test.ts index c66ac1d27af..c4c7ce76f4c 100644 --- a/web/packages/agenta-chat/tests/unit/model/error.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/error.test.ts @@ -22,6 +22,20 @@ describe("parseAgentRunError", () => { expect(parseAgentRunError(raw)).toEqual({message: "Boom", code: 500}) }) + it("preserves the continuation race class when the workflow envelope also uses HTTP 409", () => { + const raw = JSON.stringify({ + status: { + type: "https://agenta.ai/docs/errors#continuation-resumed", + code: 409, + message: "The durable continuation owns this session.", + }, + }) + expect(parseAgentRunError(raw)).toEqual({ + message: "The durable continuation owns this session.", + code: "continuation_resumed", + }) + }) + it("falls back to a top-level message when there's no status wrapper", () => { const raw = JSON.stringify({message: "Top level"}) expect(parseAgentRunError(raw)).toEqual({message: "Top level", code: undefined}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 8a73cbed278..a250484cc3c 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -1144,6 +1144,35 @@ export interface CancelSessionExecutionResult { conflict: boolean } +export interface ResumeSessionContinuationParams extends SessionScopedParams {} + +/** + * Ask the API to redeliver an already-durable approval continuation before a direct invoke. + * + * This mutation deliberately throws on transport or malformed-response failures: if ownership + * is uncertain, allowing the caller to start a fresh turn could race the saved continuation. + */ +export async function resumeSessionContinuation({ + sessionId, + projectId, + appId, + abortSignal, +}: ResumeSessionContinuationParams): Promise { + if (!projectId || !sessionId) { + throw new Error("Continuation preflight has no project or session scope.") + } + + const data = await getSessionsClient().resumeSessionContinuation( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ) + const parsed = z.object({resumed: z.boolean()}).safeParse(data) + if (!parsed.success) { + throw new Error("Continuation preflight returned an invalid response.") + } + return parsed.data.resumed +} + /** Cancel current work through Fern while keeping the session warm. */ export async function cancelSessionExecution({ sessionId, diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index ceaf3d9997d..5dff2cadd8f 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -24,6 +24,7 @@ export { cancelSessionStream, type CancelSessionOutcome, type CancelSessionStreamParams, + resumeSessionContinuation, killSession, deleteSession as deleteSessionRemote, archiveSession as archiveSessionRemote, @@ -47,6 +48,7 @@ export { type CommandSessionStreamParams, type CancelSessionExecutionParams, type CancelSessionExecutionResult, + type ResumeSessionContinuationParams, } from "./api/api" export { getSessionsClient, @@ -141,7 +143,11 @@ export { type SessionInteractionRowState, type SessionInteractionRowStates, } from "./state/interactionStatus" -export {recordInteractionAnswerAtom, respondInteractionAnswerAtom} from "./state/interactionAnswer" +export { + recordInteractionAnswerAtom, + respondInteractionAnswerAtom, + resumeSessionContinuationAtom, +} from "./state/interactionAnswer" export { sessionMountsQueryFamily, mountFilesQueryFamily, diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index 9648e51502a..c6f09488950 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -2,7 +2,7 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import {respondInteraction, transitionInteraction} from "../api/api" +import {respondInteraction, resumeSessionContinuation, transitionInteraction} from "../api/api" import { fetchSessionInteractionStatesAtom, @@ -28,10 +28,23 @@ const rowForToolCall = (states: SessionInteractionRowStates, toolCallId: string) return states.get(toolCallId) ?? null } +/** + * The final admission check before a chat transport invokes the runner directly. `true` means a + * saved approval continuation owns the session and was redelivered, so the caller must abort its + * competing fresh turn. In flag-off mode the API returns false and this is a no-op. + */ +export const resumeSessionContinuationAtom = atom( + null, + async (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + return resumeSessionContinuation({projectId, sessionId}) + }, +) + /** * Submit an approval through the response endpoint and preserve its failure for the card. - * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off legacy path and - * tells the caller to release the local AI SDK gate exactly as before. + * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off server dispatcher + * path. Both are server-owned, so callers never also release the local AI SDK gate. */ export const respondInteractionAnswerAtom = atom( null, diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts new file mode 100644 index 00000000000..93e2640d9e3 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -0,0 +1,40 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {resume} = vi.hoisted(() => ({resume: vi.fn()})) + +vi.mock("@agenta/sdk/resources", () => ({ + getSessionsClient: () => ({resumeSessionContinuation: resume}), + getLowPrioritySessionsClient: vi.fn(), + getMountsClient: vi.fn(), + getLowPriorityMountsClient: vi.fn(), +})) + +import {resumeSessionContinuation} from "../../src/session/api/api" + +beforeEach(() => resume.mockReset()) + +describe("resumeSessionContinuation", () => { + it.each([true, false])("returns resumed=%s from the scoped preflight", async (resumed) => { + resume.mockResolvedValue({resumed}) + + await expect( + resumeSessionContinuation({ + projectId: "project-1", + sessionId: "session/1", + }), + ).resolves.toBe(resumed) + + expect(resume).toHaveBeenCalledWith( + {session_id: "session/1"}, + expect.objectContaining({queryParams: {project_id: "project-1"}}), + ) + }) + + it("fails closed when the API response cannot establish ownership", async () => { + resume.mockResolvedValue({resumed: "maybe"}) + + await expect( + resumeSessionContinuation({projectId: "project-1", sessionId: "session-1"}), + ).rejects.toThrow("invalid response") + }) +}) From de5c81264f14ff53b0926935f9bbdf5c5783b3d0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 15:35:04 +0200 Subject: [PATCH 006/133] fix(sessions): handle parallel durable approvals Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/entrypoints/routers.py | 8 +- api/oss/src/apis/fastapi/sessions/models.py | 22 +- api/oss/src/apis/fastapi/sessions/router.py | 95 +++-- api/oss/src/core/sessions/commands/service.py | 350 ++++++++++++------ .../core/sessions/interactions/interfaces.py | 11 + .../src/core/sessions/interactions/service.py | 17 + api/oss/src/core/sessions/records/service.py | 2 +- api/oss/src/core/workflows/service.py | 2 +- .../dbs/postgres/sessions/interactions/dao.py | 29 ++ .../sessions/interactions_dispatcher.py | 168 ++++++--- api/oss/src/utils/env.py | 3 + ...test_interaction_continuation_admission.py | 206 ++++++++++- .../sessions/test_interactions_dispatcher.py | 37 ++ .../sessions/test_late_record_quarantine.py | 2 + .../sessions/test_record_ingest_endpoint.py | 6 +- .../test_respond_interaction_durable.py | 104 +++++- .../test_session_cancel_feature_flag.py | 24 ++ .../sessions/test_session_commands_dao.py | 136 +++++++ .../unit/workflows/test_invoke_detached.py | 4 +- .../src/features/chat/useApprovalActions.ts | 39 +- .../AgentChatSlice/AgentConversation.tsx | 10 + .../components/AgentComposerDock.tsx | 3 + .../components/ApprovalDock.tsx | 15 +- .../hooks/useAgentChatSession.ts | 15 + .../SessionInteractionRespondRequest.ts | 5 + .../src/components/ApprovalCard.tsx | 4 +- .../src/hooks/useAgentConversation.ts | 26 +- .../agenta-chat/src/hooks/useApprovalDock.ts | 12 +- .../tests/unit/ApprovalCard.test.tsx | 4 +- .../tests/unit/hooks/useApprovalDock.test.ts | 18 + .../agenta-entities/src/session/api/api.ts | 14 +- .../agenta-entities/src/session/index.ts | 1 + .../src/session/state/interactionAnswer.ts | 56 +++ .../session-interaction-response-api.test.ts | 36 ++ 34 files changed, 1231 insertions(+), 253 deletions(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index f565da866af..d0dc96eadb1 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1151,11 +1151,13 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - interactions_service=interactions_service, lock_engine=_lock_engine, delivery=DirectControlDelivery( - continue_interaction=lambda command: _interactions_dispatcher.respond( + continue_interaction=lambda command: _interactions_dispatcher.respond_many( project_id=command.project_id, user_id=command.created_by_id, - interaction_id=UUID(str(command.data["interaction_id"])), - answer=command.data["answer"], + interaction_answers=[ + (UUID(item["interaction_id"]), item["answer"]) + for item in command.data["answers"] + ], control_command_id=command.id, continuation_execution_id=command.target_turn_id, ) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 6b13652b032..9953871dbec 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -253,13 +253,29 @@ class SessionInteractionsResponse(BaseModel): interactions: List[SessionInteraction] = Field(default_factory=list) +class SessionInteractionAnswerRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + interaction_id: UUID + answer: Dict[str, Any] + + class SessionInteractionRespondRequest(BaseModel): # For a user_approval interaction the answer is {approved: bool, tool_call_id?: str, # message?: str} — the dispatcher composes the full resume conversation server-side # (interactions_dispatcher.compose_approval_messages). Other kinds pass through as-is. answer: Optional[Dict[str, Any]] = None + answers: Optional[List[SessionInteractionAnswerRequest]] = Field( + default=None, min_length=1, max_length=100 + ) expected_execution_id: Optional[str] = None + @model_validator(mode="after") + def validate_answer_shape(self) -> "SessionInteractionRespondRequest": + if self.answer is not None and self.answers is not None: + raise ValueError("answer and answers cannot be combined") + return self + # --------------------------------------------------------------------------- # Mounts request/response models (session-scoped view; from SessionMount DTO) @@ -464,12 +480,14 @@ class SessionExecutionRef(BaseModel): class SessionInteractionContinuationExecution(BaseModel): id: str - state: Literal["pending_delivery", "recoverable", "running"] + state: Literal[ + "awaiting_interactions", "pending_delivery", "recoverable", "running" + ] class SessionInteractionContinuationResponse(BaseModel): interaction: SessionInteraction - command: SessionCommandRef + command: Optional[SessionCommandRef] = None execution: SessionInteractionContinuationExecution diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 374e4b39cdc..c6a0798ad2d 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -208,6 +208,19 @@ _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,128}$") +def _idempotency_key_too_long_response() -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + }, + ) + + def _validate_session_id_http(session_id: str) -> None: if not _SESSION_ID_RE.match(session_id): raise HTTPException( @@ -1007,7 +1020,7 @@ async def ingest_record_event( # window where the watchdog could see no `done`, expose recovery, and replay work that # had already finished while the records worker was still settling core state. if ( - env.agenta.sessions.durable_stop + env.agenta.sessions.durable_approvals and self.commands_service is not None and body.record_type == TERMINAL_RECORD_TYPE and body.turn_id @@ -1305,7 +1318,7 @@ async def respond_interaction( if not authorized: raise FORBIDDEN_EXCEPTION - if env.agenta.sessions.durable_stop and self.commands_service is not None: + if env.agenta.sessions.durable_approvals and self.commands_service is not None: idempotency_key = (request.headers.get("Idempotency-Key") or "").strip() if not idempotency_key: return JSONResponse( @@ -1319,35 +1332,50 @@ async def respond_interaction( }, ) if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_CHARACTERS: + return _idempotency_key_too_long_response() + if body.answer is None and body.answers is None: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "code": "validation_error", - "message": "Idempotency-Key is too long.", + "message": "answer is required for a durable response.", "retryable": False, - "details": {"field": "Idempotency-Key", "reason": "too_long"}, - "next_step": "Use an Idempotency-Key of at most 255 characters.", + "details": {"field": "answer", "reason": "required"}, }, ) - if body.answer is None: + interaction_answers = ( + [(item.interaction_id, item.answer) for item in body.answers] + if body.answers is not None + else [(interaction_id, body.answer)] + ) + if interaction_id not in {item[0] for item in interaction_answers}: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "code": "validation_error", - "message": "answer is required for a durable response.", + "message": "The path interaction must be included in answers.", "retryable": False, - "details": {"field": "answer", "reason": "required"}, + "details": {"field": "answers", "reason": "anchor_missing"}, }, ) try: - admission = await self.commands_service.respond_interaction( - project_id=UUID(str(project_id)), - user_id=UUID(str(user_id)), - interaction_id=interaction_id, - answer=body.answer, - expected_execution_id=body.expected_execution_id, - idempotency_key=idempotency_key, - ) + if body.answers is not None: + admission = await self.commands_service.respond_interactions( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_answers=interaction_answers, + expected_execution_id=body.expected_execution_id, + idempotency_key=idempotency_key, + ) + else: + admission = await self.commands_service.respond_interaction( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_id=interaction_id, + answer=body.answer, + expected_execution_id=body.expected_execution_id, + idempotency_key=idempotency_key, + ) except InteractionResponseConflict as error: return JSONResponse( status_code=( @@ -1365,13 +1393,21 @@ async def respond_interaction( response = SessionInteractionContinuationResponse( interaction=admission.interaction, - command=SessionCommandRef( - id=admission.command.id, - state=admission.command.state.value, + command=( + SessionCommandRef( + id=admission.command.id, + state=admission.command.state.value, + ) + if admission.command is not None + else None ), execution=SessionInteractionContinuationExecution( id=admission.execution_id, - state=admission.execution_state.value, + state=( + "awaiting_interactions" + if getattr(admission, "waiting_for_interactions", False) + else admission.execution_state.value + ), ), ) return JSONResponse( @@ -1379,6 +1415,16 @@ async def respond_interaction( content=response.model_dump(mode="json"), ) + if body.answers is not None: + responses = {} + for item in body.answers: + responses[item.interaction_id] = await self.respond_interaction( + request=request, + interaction_id=item.interaction_id, + body=SessionInteractionRespondRequest(answer=item.answer), + ) + return responses[interaction_id] + try: interaction = await self.interactions_service.fetch_interaction( project_id=project_id, @@ -2412,9 +2458,10 @@ async def cancel_session_execution( idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key is not None: - idempotency_key = ( - idempotency_key.strip()[:_MAX_IDEMPOTENCY_KEY_CHARACTERS] or None - ) + idempotency_key = idempotency_key.strip() + if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_CHARACTERS: + return _idempotency_key_too_long_response() + idempotency_key = idempotency_key or None admission = await self._service.request_cancel( project_id=UUID(str(project_id)), @@ -2462,7 +2509,7 @@ async def resume_session_continuation( raise FORBIDDEN_EXCEPTION resumed = False - if env.agenta.sessions.durable_stop: + if env.agenta.sessions.durable_approvals: resumed = await self._service.resume_recoverable_continuation( project_id=UUID(str(project_id)), session_id=session_id, diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 26a17af3f2f..428dde3dd75 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -115,14 +115,18 @@ def __init__( self, *, interaction: SessionInteraction, - command: SessionCommand, + command: Optional[SessionCommand], execution_id: str, execution_state: SessionExecutionState = SessionExecutionState.pending_delivery, + interactions: Optional[List[SessionInteraction]] = None, + waiting_for_interactions: bool = False, ) -> None: self.interaction = interaction + self.interactions = interactions or [interaction] self.command = command self.execution_id = execution_id self.execution_state = execution_state + self.waiting_for_interactions = waiting_for_interactions class CommandOutcomeReport: @@ -382,82 +386,108 @@ async def respond_interaction( answer: dict[str, Any], expected_execution_id: Optional[str], idempotency_key: str, + ) -> InteractionContinuationAdmission: + return await self.respond_interactions( + project_id=project_id, + user_id=user_id, + interaction_answers=[(interaction_id, answer)], + expected_execution_id=expected_execution_id, + idempotency_key=idempotency_key, + ) + + async def respond_interactions( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_answers: List[Tuple[UUID, dict[str, Any]]], + expected_execution_id: Optional[str], + idempotency_key: str, ) -> InteractionContinuationAdmission: if self._executions is None: raise RuntimeError( "durable interaction responses require executions storage" ) + requested = dict(interaction_answers) + if not requested or len(requested) != len(interaction_answers): + raise InteractionResponseConflict( + code="validation_error", + message="Each response must answer at least one distinct interaction.", + ) - async with self._dao.transaction() as transaction: - interaction = await self._interactions.fetch_interaction( - project_id=project_id, - interaction_id=interaction_id, - transaction=transaction, + anchor_id = interaction_answers[0][0] + anchor = await self._interactions.fetch_interaction( + project_id=project_id, + interaction_id=anchor_id, + ) + source_execution_id = anchor.turn_id + if source_execution_id is None: + raise InteractionResponseConflict( + code="validation_error", + message="The interaction is not linked to an execution.", + ) + if ( + expected_execution_id is not None + and expected_execution_id != source_execution_id + ): + raise InteractionResponseConflict( + code="execution_mismatch", + message="The interaction belongs to a different execution.", + details={"current_execution_id": source_execution_id}, ) - source_execution_id = interaction.turn_id - if source_execution_id is None: - raise InteractionResponseConflict( - code="validation_error", - message="The interaction is not linked to an execution.", - ) - if ( - expected_execution_id is not None - and expected_execution_id != source_execution_id - ): - raise InteractionResponseConflict( - code="execution_mismatch", - message="The interaction belongs to a different execution.", - details={"current_execution_id": source_execution_id}, - ) + async with self._dao.transaction() as transaction: source = await self._executions.lock_for_control( project_id=project_id, - session_id=interaction.session_id, + session_id=anchor.session_id, execution_id=source_execution_id, transaction=transaction, ) - interaction = await self._interactions.fetch_interaction( + turn_interactions = await self._interactions.fetch_turn_interactions( project_id=project_id, - interaction_id=interaction_id, + session_id=anchor.session_id, + turn_id=source_execution_id, transaction=transaction, for_update=True, ) + by_id = {interaction.id: interaction for interaction in turn_interactions} + if not requested.keys() <= by_id.keys(): + raise InteractionResponseConflict( + code="execution_mismatch", + message="Every interaction must belong to the same execution.", + details={"current_execution_id": source_execution_id}, + ) existing = await self._dao.fetch_by_idempotency_key( project_id=project_id, - session_id=interaction.session_id, + session_id=anchor.session_id, idempotency_key=idempotency_key, transaction=transaction, ) if existing is not None: + existing_ids = (existing.data or {}).get("interaction_ids") + if not isinstance(existing_ids, list): + existing_id = (existing.data or {}).get("interaction_id") + existing_ids = [existing_id] if isinstance(existing_id, str) else [] same_request = ( existing.kind == SessionCommandKind.continue_interaction and existing.expected_turn_id == source_execution_id - and existing.data is not None - and existing.data.get("interaction_id") == str(interaction_id) - and interaction.data is not None - and interaction.data.resolution == answer + and set(existing_ids) == {str(item) for item in requested} + and all( + by_id[item].data is not None + and by_id[item].data.resolution == answer + for item, answer in requested.items() + ) ) if not same_request: raise IdempotencyKeyReused() execution_id = str(existing.data["continuation_execution_id"]) admission = InteractionContinuationAdmission( - interaction=interaction, + interaction=by_id[anchor_id], command=existing, execution_id=execution_id, + interactions=[by_id[item] for item in requested], ) else: - if interaction.status != SessionInteractionStatus.pending: - raise InteractionResponseConflict( - code="execution_terminal", - message="The interaction is no longer pending.", - details={ - "interaction_status": ( - interaction.status.value - if interaction.status is not None - else None - ) - }, - ) if source.terminal_outcome is not None or source.state in ( SessionExecutionState.stopping, SessionExecutionState.terminal, @@ -468,71 +498,125 @@ async def respond_interaction( details={"execution_state": source.state.value}, ) - transitioned = await self._interactions.transition_interaction( - transition=SessionInteractionTransition( + transitioned: List[SessionInteraction] = [] + for interaction_id, answer in interaction_answers: + interaction = by_id[interaction_id] + if interaction.status == SessionInteractionStatus.responded: + if ( + interaction.data is None + or interaction.data.resolution != answer + ): + raise InteractionResponseConflict( + code="execution_terminal", + message="The interaction was already answered differently.", + details={"interaction_status": "responded"}, + ) + transitioned.append(interaction) + continue + if interaction.status != SessionInteractionStatus.pending: + raise InteractionResponseConflict( + code="execution_terminal", + message="The interaction is no longer pending.", + details={ + "interaction_status": ( + interaction.status.value + if interaction.status is not None + else None + ) + }, + ) + updated = await self._interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=interaction.session_id, + token=interaction.token, + status=SessionInteractionStatus.responded, + resolution=answer, + ), + transaction=transaction, + publish=False, + ) + by_id[interaction_id] = updated + transitioned.append(updated) + + if any( + interaction.status == SessionInteractionStatus.pending + for interaction in by_id.values() + ): + admission = InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=None, + execution_id=source_execution_id, + execution_state=source.state, + interactions=transitioned, + waiting_for_interactions=True, + ) + else: + answered = [ + interaction + for interaction in by_id.values() + if interaction.status == SessionInteractionStatus.responded + and interaction.data is not None + and interaction.data.resolution is not None + ] + result = await self._executions.settle( project_id=project_id, - session_id=interaction.session_id, - token=interaction.token, - status=SessionInteractionStatus.responded, - resolution=answer, - ), - transaction=transaction, - publish=False, - ) - result = await self._executions.settle( - project_id=project_id, - session_id=interaction.session_id, - execution_id=source_execution_id, - terminal_outcome="continued", - settled_by="interaction_response", - transaction=transaction, - ) - if not result.won: - raise InteractionResponseConflict( - code="execution_terminal", - message="The source execution can no longer be continued.", - details={ - "terminal_outcome": result.settlement.terminal_outcome - }, + session_id=anchor.session_id, + execution_id=source_execution_id, + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, ) + if not result.won: + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={ + "terminal_outcome": result.settlement.terminal_outcome + }, + ) - execution_id = str(uuid4()) - await self._executions.create_continuation( - project_id=project_id, - session_id=interaction.session_id, - execution_id=execution_id, - parent_execution_id=source_execution_id, - source_interaction_id=interaction_id, - transaction=transaction, - ) - command = await self._dao.create_command( - user_id=user_id, - command=SessionCommandCreate( + execution_id = str(uuid4()) + await self._executions.create_continuation( project_id=project_id, - session_id=interaction.session_id, - kind=SessionCommandKind.continue_interaction, - target_turn_id=execution_id, - expected_turn_id=source_execution_id, - data={ - "interaction_id": str(interaction_id), - "continuation_execution_id": execution_id, - }, - idempotency_key=idempotency_key, - ), - transaction=transaction, - ) - if ( - command.kind != SessionCommandKind.continue_interaction - or command.target_turn_id != execution_id - or command.data is None - or command.data.get("interaction_id") != str(interaction_id) - ): - raise IdempotencyKeyReused() - admission = InteractionContinuationAdmission( - interaction=transitioned, - command=command, - execution_id=execution_id, - ) + session_id=anchor.session_id, + execution_id=execution_id, + parent_execution_id=source_execution_id, + source_interaction_id=anchor_id, + transaction=transaction, + ) + interaction_ids = [str(interaction.id) for interaction in answered] + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=anchor.session_id, + kind=SessionCommandKind.continue_interaction, + target_turn_id=execution_id, + expected_turn_id=source_execution_id, + data={ + "interaction_id": str(anchor_id), + "interaction_ids": interaction_ids, + "continuation_execution_id": execution_id, + }, + idempotency_key=idempotency_key, + ), + transaction=transaction, + ) + if ( + command.kind != SessionCommandKind.continue_interaction + or command.target_turn_id != execution_id + or command.data is None + or set(command.data.get("interaction_ids") or []) + != set(interaction_ids) + ): + raise IdempotencyKeyReused() + admission = InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=command, + execution_id=execution_id, + interactions=answered, + ) try: await self._interactions.publish_interaction_responded( @@ -542,10 +626,13 @@ async def respond_interaction( except Exception as error: # noqa: BLE001 - the durable transaction already committed log.warning( "interaction response watch publish failed interaction=%s: %s", - interaction_id, + anchor_id, error, ) - if admission.command.state == SessionCommandState.pending: + if ( + admission.command is not None + and admission.command.state == SessionCommandState.pending + ): try: receipt = await self._deliver(admission.command) except Exception as error: # noqa: BLE001 - admission remains accepted @@ -563,7 +650,7 @@ async def respond_interaction( async def _mark_continuation_recoverable( self, admission: InteractionContinuationAdmission ) -> None: - if self._executions is None: + if self._executions is None or admission.command is None: return try: await self._executions.set_state( @@ -588,7 +675,7 @@ async def _mark_continuation_recoverable( async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str ) -> bool: - if not env.agenta.sessions.durable_stop: + if not env.agenta.sessions.durable_approvals: return False command = await self._dao.fetch_resumable_continuation( project_id=project_id, @@ -870,28 +957,54 @@ async def _deliver(self, command: SessionCommand) -> Optional[DeliveryReceipt]: ) return receipt + async def _interactions_for_command( + self, command: SessionCommand + ) -> List[SessionInteraction]: + interaction_ids = (command.data or {}).get("interaction_ids") + if not isinstance(interaction_ids, list): + interaction_id = (command.data or {}).get("interaction_id") + interaction_ids = ( + [interaction_id] if isinstance(interaction_id, str) else [] + ) + if not interaction_ids or not all( + isinstance(interaction_id, str) for interaction_id in interaction_ids + ): + raise ValueError("continuation command has no interaction ids") + return [ + await self._interactions.fetch_interaction( + project_id=command.project_id, + interaction_id=UUID(interaction_id), + ) + for interaction_id in interaction_ids + ] + async def _interaction_for_command( self, command: SessionCommand ) -> SessionInteraction: - interaction_id = (command.data or {}).get("interaction_id") - if not isinstance(interaction_id, str): - raise ValueError("continuation command has no interaction id") - return await self._interactions.fetch_interaction( - project_id=command.project_id, - interaction_id=UUID(interaction_id), - ) + return (await self._interactions_for_command(command))[0] async def _command_for_delivery(self, command: SessionCommand) -> SessionCommand: if command.kind != SessionCommandKind.continue_interaction: return command - interaction = await self._interaction_for_command(command) - if interaction.data is None or interaction.data.resolution is None: + interactions = await self._interactions_for_command(command) + if any( + interaction.data is None or interaction.data.resolution is None + for interaction in interactions + ): raise ValueError("continuation interaction has no durable resolution") + answers = [ + { + "interaction_id": str(interaction.id), + "answer": interaction.data.resolution, + } + for interaction in interactions + ] return command.model_copy( update={ "data": { **(command.data or {}), - "answer": interaction.data.resolution, + "answers": answers, + **({"answer": answers[0]["answer"]} if len(answers) == 1 else {}), } } ) @@ -978,7 +1091,7 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: settled = 0 for command in abandoned: if command.kind == SessionCommandKind.continue_interaction: - if not env.agenta.sessions.durable_stop: + if not env.agenta.sessions.durable_approvals: continue if command.claim_count < max_deliveries: await self._deliver(command) @@ -1060,6 +1173,7 @@ async def settle_execution_lost( ) if ( execution is not None + and env.agenta.sessions.durable_approvals and ( execution.source_interaction_id is not None or execution.parent_execution_id is not None diff --git a/api/oss/src/core/sessions/interactions/interfaces.py b/api/oss/src/core/sessions/interactions/interfaces.py index 9255a3aab0a..b36493561d5 100644 --- a/api/oss/src/core/sessions/interactions/interfaces.py +++ b/api/oss/src/core/sessions/interactions/interfaces.py @@ -33,6 +33,17 @@ async def fetch_interaction( for_update: bool = False, ) -> Optional[SessionInteraction]: ... + @abstractmethod + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: ... + @abstractmethod async def transition_interaction( self, diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 84c30746045..5194a0aea3a 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -88,6 +88,23 @@ async def fetch_interaction( raise InteractionNotFound(f"Interaction {interaction_id} not found") return result + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: + return await self.interactions_dao.fetch_turn_interactions( + project_id=project_id, + session_id=session_id, + turn_id=turn_id, + transaction=transaction, + for_update=for_update, + ) + async def transition_interaction( self, *, diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index a1f65b653d6..3e6cb56981e 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -134,7 +134,7 @@ async def _settle_completed_continuations( best effort here because records and executions use different database engines; the watchdog repeats the reconciliation before it collapses stale ownership. """ - if self.executions_dao is None or not env.agenta.sessions.durable_stop: + if self.executions_dao is None or not env.agenta.sessions.durable_approvals: return candidates = { diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 8d5559baca2..50dae82b783 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -292,7 +292,7 @@ async def _resume_pending_session_continuation( session_id = request.session_id meta = request.meta or {} if ( - not env.agenta.sessions.durable_stop + not env.agenta.sessions.durable_approvals or not session_id or meta.get("control_command_id") or self._session_continuation_resumer is None diff --git a/api/oss/src/dbs/postgres/sessions/interactions/dao.py b/api/oss/src/dbs/postgres/sessions/interactions/dao.py index a599ecce7df..da60407439c 100644 --- a/api/oss/src/dbs/postgres/sessions/interactions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/interactions/dao.py @@ -96,6 +96,35 @@ async def execute(session: Any) -> Optional[SessionInteraction]: async with self.engine.session() as session: return await execute(session) + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: + async def execute(session: Any) -> List[SessionInteraction]: + stmt = ( + select(SessionInteractionDBE) + .where( + SessionInteractionDBE.project_id == project_id, + SessionInteractionDBE.session_id == session_id, + SessionInteractionDBE.turn_id == turn_id, + ) + .order_by(SessionInteractionDBE.id) + ) + if for_update: + stmt = stmt.with_for_update() + rows = (await session.execute(stmt)).scalars().all() + return [map_interaction_dbe_to_dto(row) for row in rows] + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def transition_interaction( self, *, diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index d9ce243d340..1cc81dd75ec 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -223,6 +223,13 @@ def compose_approval_messages( records: List[SessionRecord], interaction: SessionInteraction, answer: Dict[str, Any], +) -> List[Dict[str, Any]]: + return compose_approval_messages_many(records, [(interaction, answer)]) + + +def compose_approval_messages_many( + records: List[SessionRecord], + interaction_answers: List[tuple[SessionInteraction, Dict[str, Any]]], ) -> List[Dict[str, Any]]: """The full resume conversation: replayed history + the approval envelope. @@ -242,58 +249,57 @@ def compose_approval_messages( denial (#5444). The note is still persisted as a user record either way. """ messages = build_wire_messages(records) - gated_id = resolve_gated_tool_call_id(records, interaction, answer) - - gated_call = next( - ( - block - for message in messages - if isinstance(message.get("content"), list) - for block in message["content"] - if block.get("type") == "tool_call" and block.get("toolCallId") == gated_id - ), - None, - ) - has_gated_call = gated_call is not None - shape = _gated_call_shape(records, interaction) - if not has_gated_call: - # No durable tool_call record (e.g. records unavailable): synthesize the anchor the - # runner's call-shape index needs to bind the envelope to name+args. - block = {"type": "tool_call", "toolCallId": gated_id} - if shape.get("name"): - block["toolName"] = shape["name"] - if shape.get("args") is not None: - block["input"] = shape["args"] - messages.append({"role": "assistant", "content": [block]}) - - envelope = { - "type": "tool_result", - "toolCallId": gated_id, - "output": { - "approved": bool(answer.get("approved")), - "interactionToken": interaction.token, - }, - } - # The runner renders the resume nudge as "Call again with the same arguments" and - # matches stale-vs-live approvals by name. An unnamed envelope renders the literal word - # "tool", which names nothing the model can call — it then narrates a fabricated execution - # instead of re-issuing the call. - gated_name = (gated_call or {}).get("toolName") or shape.get("name") - if gated_name: - envelope["toolName"] = gated_name - tail = messages[-1] if messages else None - if ( - tail is not None - and tail.get("role") == "assistant" - and isinstance(tail.get("content"), list) - ): - tail["content"].append(envelope) - else: - messages.append({"role": "assistant", "content": [envelope]}) - - note = answer.get("message") - if isinstance(note, str) and note.strip(): - messages.append({"role": "user", "content": note}) + notes: List[str] = [] + for interaction, answer in interaction_answers: + gated_id = resolve_gated_tool_call_id(records, interaction, answer) + gated_call = next( + ( + block + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + if block.get("type") == "tool_call" + and block.get("toolCallId") == gated_id + ), + None, + ) + shape = _gated_call_shape(records, interaction) + if gated_call is None: + # No durable tool_call record (e.g. records unavailable): synthesize the anchor the + # runner's call-shape index needs to bind the envelope to name+args. + gated_call = {"type": "tool_call", "toolCallId": gated_id} + if shape.get("name"): + gated_call["toolName"] = shape["name"] + if shape.get("args") is not None: + gated_call["input"] = shape["args"] + messages.append({"role": "assistant", "content": [gated_call]}) + + envelope = { + "type": "tool_result", + "toolCallId": gated_id, + "output": { + "approved": bool(answer.get("approved")), + "interactionToken": interaction.token, + }, + } + gated_name = gated_call.get("toolName") or shape.get("name") + if gated_name: + envelope["toolName"] = gated_name + tail = messages[-1] if messages else None + if ( + tail is not None + and tail.get("role") == "assistant" + and isinstance(tail.get("content"), list) + ): + tail["content"].append(envelope) + else: + messages.append({"role": "assistant", "content": [envelope]}) + + note = answer.get("message") + if isinstance(note, str) and note.strip(): + notes.append(note) + + messages.extend({"role": "user", "content": note} for note in notes) return messages @@ -353,11 +359,35 @@ async def respond( control_command_id: Optional[UUID] = None, continuation_execution_id: Optional[str] = None, ) -> None: - interaction = await self.interactions_service.fetch_interaction( + await self.respond_many( project_id=project_id, - interaction_id=interaction_id, + user_id=user_id, + interaction_answers=[(interaction_id, answer)], + control_command_id=control_command_id, + continuation_execution_id=continuation_execution_id, ) + async def respond_many( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_answers: List[tuple[UUID, Any]], + control_command_id: Optional[UUID] = None, + continuation_execution_id: Optional[str] = None, + ) -> None: + resolved = [ + ( + await self.interactions_service.fetch_interaction( + project_id=project_id, + interaction_id=interaction_id, + ), + answer, + ) + for interaction_id, answer in interaction_answers + ] + interaction, first_answer = resolved[0] + data: Optional[SessionInteractionData] = interaction.data references = ( {k: v.model_dump(mode="json") for k, v in data.references.items()} @@ -367,11 +397,31 @@ async def respond( selector = ( data.selector.model_dump(mode="json") if data and data.selector else None ) - inputs = await self._compose_inputs( - project_id=project_id, - interaction=interaction, - answer=answer, - ) + if all( + item.kind == SessionInteractionKind.user_approval + and isinstance(answer, dict) + and isinstance(answer.get("approved"), bool) + for item, answer in resolved + ): + records: List[SessionRecord] = [] + if self.records_service is not None: + try: + records = await self.records_service.get_records( + project_id=project_id, + session_id=interaction.session_id, + ) + except Exception as e: # degrade to synthesized-anchor replay + log.warning( + "[interactions] records replay unavailable for " + f"session={interaction.session_id}: {e}" + ) + inputs = {"messages": compose_approval_messages_many(records, resolved)} + else: + inputs = await self._compose_inputs( + project_id=project_id, + interaction=interaction, + answer=first_answer, + ) # The effective config the gated turn ran under, when the runner stamped one. Sending it # INLINE is what makes the resume correct: the resolver decides hydration purely from # what the caller sent (`_caller_supplied_configuration`), so inline parameters suppress diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index f3127769a81..7ad3bf6d158 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -697,6 +697,9 @@ class SessionsConfig(BaseModel): durable_stop: bool = ( os.getenv("AGENTA_SESSIONS_DURABLE_STOP") or "false" ).lower() in _TRUTHY + durable_approvals: bool = ( + os.getenv("AGENTA_SESSIONS_DURABLE_APPROVALS") or "false" + ).lower() in _TRUTHY late_output: Literal["quarantine", "reject"] = _parse_sessions_late_output() attachments: SessionAttachmentsConfig = SessionAttachmentsConfig() commands: SessionsCommandsConfig = SessionsCommandsConfig() diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index af28f1b7f7a..85b80237cb7 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -115,26 +115,43 @@ async def settle_command(self, *, settle, **kwargs): @pytest.fixture(autouse=True) -def _durable_stop_enabled(monkeypatch): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) +def _durable_approvals_enabled(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) class _Interactions: def __init__(self, interaction): - self.interaction = interaction + self.interactions = [interaction] - async def fetch_interaction(self, **kwargs): - return self.interaction + @property + def interaction(self): + return self.interactions[0] + + @interaction.setter + def interaction(self, value): + self.interactions[0] = value + + async def fetch_interaction(self, *, interaction_id, **kwargs): + return next(item for item in self.interactions if item.id == interaction_id) + + async def fetch_turn_interactions(self, **kwargs): + return self.interactions async def transition_interaction(self, *, transition, **kwargs): - data = self.interaction.data or SessionInteractionData() - self.interaction = self.interaction.model_copy( + index = next( + index + for index, item in enumerate(self.interactions) + if item.token == transition.token + ) + interaction = self.interactions[index] + data = interaction.data or SessionInteractionData() + self.interactions[index] = interaction.model_copy( update={ "status": transition.status, "data": data.model_copy(update={"resolution": transition.resolution}), } ) - return self.interaction + return self.interactions[index] async def publish_interaction_responded(self, **kwargs): return None @@ -274,6 +291,7 @@ async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): assert admission.execution_state == SessionExecutionState.recoverable assert commands.command.data == { "interaction_id": str(interaction_id), + "interaction_ids": [str(interaction_id)], "continuation_execution_id": admission.execution_id, } assert delivery.delivered[0].data["answer"] == {"approved": True} @@ -302,6 +320,143 @@ async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): ) +@pytest.mark.asyncio +async def test_parallel_answers_wait_then_share_one_continuation(): + project_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interactions = _Interactions( + SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + interactions.interactions.append( + SessionInteraction( + id=second_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-2", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + commands = _Commands() + delivery = _Unreachable() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + first = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert first.command is None + assert first.waiting_for_interactions is True + assert interactions.interactions[0].status == SessionInteractionStatus.responded + assert interactions.interactions[1].status == SessionInteractionStatus.pending + assert executions.source.terminal_outcome is None + assert delivery.delivered == [] + + second = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=second_id, + answer={"approved": False}, + expected_execution_id="source-1", + idempotency_key="response-2", + ) + + assert second.command is not None + assert executions.source.terminal_outcome == "continued" + assert len(delivery.delivered) == 1 + assert delivery.delivered[0].data["answers"] == [ + {"interaction_id": str(first_id), "answer": {"approved": True}}, + {"interaction_id": str(second_id), "answer": {"approved": False}}, + ] + + +@pytest.mark.asyncio +async def test_approve_all_commits_one_continuation_for_the_batch(): + project_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interactions = _Interactions( + SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + interactions.interactions.append( + SessionInteraction( + id=second_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-2", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + commands = _Commands() + delivery = _Unreachable() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + admission = await service.respond_interactions( + project_id=project_id, + user_id=uuid4(), + interaction_answers=[ + (first_id, {"approved": True}), + (second_id, {"approved": True}), + ], + expected_execution_id="source-1", + idempotency_key="approve-all", + ) + + assert admission.command is not None + assert len(delivery.delivered) == 1 + assert { + item["interaction_id"] for item in delivery.delivered[0].data["answers"] + } == { + str(first_id), + str(second_id), + } + + @pytest.mark.asyncio async def test_post_commit_failures_do_not_reject_an_accepted_answer(): project_id = uuid4() @@ -757,6 +912,37 @@ async def test_watchdog_keeps_lost_continuation_recoverable(): assert executions.continuation.terminal_outcome is None +@pytest.mark.asyncio +async def test_watchdog_does_not_recover_continuation_when_approvals_are_disabled( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert await service.settle_execution_lost( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + settled_at=datetime.now(timezone.utc), + ) + assert executions.continuation.state == SessionExecutionState.terminal + assert executions.continuation.terminal_outcome == SessionCommandOutcome.lost.value + + @pytest.mark.asyncio async def test_persisted_completion_terminalizes_continuation_before_recovery(): project_id = uuid4() @@ -786,8 +972,8 @@ async def test_persisted_completion_terminalizes_continuation_before_recovery(): @pytest.mark.asyncio -async def test_recovery_hooks_are_disabled_with_durable_stop(monkeypatch): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) +async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) project_id = uuid4() interaction_id = uuid4() commands = _Commands() diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 5cd89e69b41..7625b8a3a89 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -13,6 +13,7 @@ from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( InteractionsDispatcher, build_wire_messages, + compose_approval_messages_many, ) @@ -226,6 +227,42 @@ async def test_respond_detached_calls_dispatch_fn_not_invoke(): # --------------------------------------------------------------------------- +def test_parallel_approval_answers_share_one_resume_conversation(): + project_id = uuid4() + first = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "bash", "tool_call_id": "tc-1"}, + ) + second = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "write_file", "tool_call_id": "tc-2"}, + ) + second = second.model_copy(update={"token": "tok-def"}) + records = [ + *_approval_records(project_id), + *_approval_records(project_id, token="tok-def", tool_call_id="tc-2")[1:], + ] + + messages = compose_approval_messages_many( + records, + [(first, {"approved": True}), (second, {"approved": False})], + ) + + results = [ + block + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + if block.get("type") == "tool_result" + and isinstance(block.get("output"), dict) + and block["output"].get("interactionToken") in {"tok-abc", "tok-def"} + ] + assert [(item["toolCallId"], item["output"]["approved"]) for item in results] == [ + ("tc-1", True), + ("tc-2", False), + ] + + async def test_approval_respond_composes_resume_messages_from_records(): """The dispatched inputs must be a replayable conversation ending in the {approved, interactionToken} tool_result the runner's decision map reads, diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py index 4eea488d5bd..79605c8f440 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -351,6 +351,7 @@ async def test_execution_lookup_failure_appends_the_batch_unguarded(monkeypatch) async def test_runner_done_terminalizes_a_continuation_execution(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) executions = _ExecutionSettlements() executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( project_id=_PROJECT, @@ -370,6 +371,7 @@ async def test_runner_done_terminalizes_a_continuation_execution(monkeypatch): async def test_paused_or_quarantined_done_does_not_complete_a_continuation(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) executions = _ExecutionSettlements() executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( project_id=_PROJECT, diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 59b1eb5c59e..89d923b0160 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -147,7 +147,8 @@ async def test_record_ingest_threads_turn_id_and_span_id(): async def test_terminal_continuation_settles_core_before_stream_acceptance(monkeypatch): monkeypatch.setattr( - "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_stop", True + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, ) records_service = AsyncMock() commands_service = AsyncMock() @@ -197,7 +198,8 @@ async def test_terminal_publish_failure_is_retryable_after_core_settlement(monke from fastapi import HTTPException monkeypatch.setattr( - "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_stop", True + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, ) commands_service = AsyncMock() router = RecordsRouter( diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py index 75cdd863a63..641d7387e43 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -39,7 +39,7 @@ async def test_durable_response_returns_202_and_stable_refs(monkeypatch): execution_state=SessionExecutionState.pending_delivery, ) commands = SimpleNamespace(respond_interaction=AsyncMock(return_value=admission)) - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) monkeypatch.setattr( router_module, "check_action_access", AsyncMock(return_value=True) ) @@ -77,6 +77,66 @@ async def test_durable_response_returns_202_and_stable_refs(monkeypatch): ) +async def test_durable_batch_returns_202_with_one_continuation(monkeypatch): + project_id = uuid4() + user_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interaction = SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="turn-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=SimpleNamespace(id=uuid4(), state=SessionCommandState.pending), + execution_id="turn-2", + execution_state=SessionExecutionState.pending_delivery, + waiting_for_interactions=False, + ) + commands = SimpleNamespace(respond_interactions=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "approve-all"}, + ), + interaction_id=first_id, + body=SessionInteractionRespondRequest( + answers=[ + {"interaction_id": first_id, "answer": {"approved": True}}, + {"interaction_id": second_id, "answer": {"approved": True}}, + ], + expected_execution_id="turn-1", + ), + ) + + assert response.status_code == 202 + commands.respond_interactions.assert_awaited_once_with( + project_id=project_id, + user_id=user_id, + interaction_answers=[ + (first_id, {"approved": True}), + (second_id, {"approved": True}), + ], + expected_execution_id="turn-1", + idempotency_key="approve-all", + ) + + async def test_durable_response_returns_the_conflict_envelope(monkeypatch): project_id = uuid4() user_id = uuid4() @@ -84,7 +144,7 @@ async def test_durable_response_returns_the_conflict_envelope(monkeypatch): commands = SimpleNamespace( respond_interaction=AsyncMock(side_effect=IdempotencyKeyReused()) ) - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) monkeypatch.setattr( router_module, "check_action_access", AsyncMock(return_value=True) ) @@ -123,7 +183,7 @@ async def test_durable_validation_error_returns_422(monkeypatch): ) ) ) - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) monkeypatch.setattr( router_module, "check_action_access", AsyncMock(return_value=True) ) @@ -146,6 +206,40 @@ async def test_durable_validation_error_returns_422(monkeypatch): assert json.loads(response.body)["code"] == "validation_error" +async def test_durable_response_rejects_an_overlength_idempotency_key(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace(respond_interaction=AsyncMock()) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "x" * 256}, + ), + interaction_id=uuid4(), + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + assert response.status_code == 422 + assert json.loads(response.body) == { + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + } + commands.respond_interaction.assert_not_awaited() + + async def test_continuation_resume_endpoint_is_feature_gated(monkeypatch): project_id = uuid4() user_id = uuid4() @@ -160,14 +254,14 @@ async def test_continuation_resume_endpoint_is_feature_gated(monkeypatch): state=SimpleNamespace(project_id=project_id, user_id=user_id) ) - monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) disabled = await router.resume_session_continuation( request=request, session_id="session-1" ) assert disabled.resumed is False commands.resume_recoverable_continuation.assert_not_awaited() - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) enabled = await router.resume_session_continuation( request=request, session_id="session-1" ) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py index d26c9162014..ec191c78f54 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py @@ -115,3 +115,27 @@ def test_runner_token_rejects_non_ascii_credentials_as_unauthorized(monkeypatch) router_module._assert_runner_token(request) assert exc_info.value.status_code == 401 + + +async def test_cancel_rejects_an_overlength_idempotency_key(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace(request_cancel=AsyncMock()) + request = _request() + request.headers = {"Idempotency-Key": "x" * 256} + + response = await SessionControlRouter( + commands_service=service + ).cancel_session_execution(request, "session-1") + + assert response.status_code == 422 + assert json.loads(response.body) == { + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + } + service.request_cancel.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index 94d03d20135..624d0c21c13 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -807,6 +807,142 @@ async def test_full_service_stop_and_answer_have_one_postgres_winner(command_sco ) +async def test_full_service_stop_between_parallel_answers_cancels_the_remainder( + command_scope, +): + first_id = await _insert_pending_interaction(command_scope, token="parallel-first") + second_id = await _insert_pending_interaction( + command_scope, token="parallel-second" + ) + service = _commands_service(command_scope) + + first = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-answer-first", + ) + assert first.command is None + assert first.waiting_for_interactions is True + + stopped = await service.request_cancel( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + session_id=command_scope["session_id"], + expected_execution_id="turn-A", + idempotency_key="parallel-stop", + ) + assert stopped.accepted is True + + with pytest.raises(InteractionResponseConflict): + await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=second_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-answer-second", + ) + + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + first_row = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=first_id + ) + second_row = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=second_id + ) + assert first_row.status == SessionInteractionStatus.responded + assert second_row.status == SessionInteractionStatus.cancelled + async with command_scope["engine"].session() as session: + continuation_count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + assert continuation_count == 0 + + +async def test_full_service_parallel_answers_create_one_terminal_continuation( + command_scope, +): + first_id = await _insert_pending_interaction( + command_scope, token="parallel-continue-first" + ) + second_id = await _insert_pending_interaction( + command_scope, token="parallel-continue-second" + ) + service = _commands_service(command_scope) + + first = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-continue-answer-first", + ) + assert first.command is None + + second = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=second_id, + answer={"approved": False}, + expected_execution_id="turn-A", + idempotency_key="parallel-continue-answer-second", + ) + assert second.command is not None + + async with command_scope["engine"].session() as session: + before = ( + await session.execute( + text( + "SELECT execution_id, terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ).all() + assert before == [(second.execution_id, None)] + + assert await service.settle_execution_completed( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id=second.execution_id, + ) + async with command_scope["engine"].session() as session: + outcomes = ( + ( + await session.execute( + text( + "SELECT terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ) + .scalars() + .all() + ) + assert outcomes == ["completed"] + + async def test_full_service_failure_rolls_back_answer_execution_and_command( command_scope, ): diff --git a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py index 394a3979f2d..61045e9b8ca 100644 --- a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py +++ b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py @@ -232,7 +232,7 @@ async def test_invoke_workflow_batch_still_returns_400_when_no_service_url(): async def test_ordinary_session_invoke_redelivers_recoverable_continuation(monkeypatch): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) svc = _service() resume = AsyncMock(return_value=True) svc.set_session_continuation_resumer(resume) @@ -253,7 +253,7 @@ async def test_ordinary_session_invoke_redelivers_recoverable_continuation(monke async def test_control_continuation_bypasses_ordinary_send_recovery_hook(monkeypatch): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) svc = _service() resume = AsyncMock(return_value=True) svc.set_session_continuation_resumer(resume) diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 727376b3e5d..79afb3dc106 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -105,21 +105,30 @@ export const useApprovalActions = ({ submittedRef.current = targets .map((row) => row.token) .filter((token): token is string => typeof token === "string") - let answered = 0 - for (const row of targets) { - try { - await respondInteraction({ - interactionId: row.id as string, - projectId, - answer: buildApprovalAnswer(approved, message), - expectedExecutionId: row.turn_id ?? undefined, - idempotencyKey: `approval:${row.id}:${approved ? "approve" : "deny"}`, - }) - answered += 1 - } catch (err) { - // Someone (desktop, another tab) already answered this gate — benign. - if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) - } + let answered = targets.length + try { + const ids = targets.map((row) => row.id as string).sort() + await respondInteraction({ + interactionId: ids[0], + projectId, + ...(targets.length === 1 + ? {answer: buildApprovalAnswer(approved, message)} + : { + answers: targets.map((row) => ({ + interactionId: row.id as string, + answer: buildApprovalAnswer(approved, message), + })), + }), + expectedExecutionId: targets[0].turn_id ?? undefined, + idempotencyKey: + targets.length === 1 + ? `approval:${targets[0].id}:${approved ? "approve" : "deny"}` + : `approval-batch:${ids[0]}:${ids.length}:${approved ? "approve" : "deny"}`, + }) + } catch (err) { + // Someone (desktop, another tab) already answered this gate — benign. + if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) + answered = 0 } // Every target was already answered: nothing is resuming, so re-arm now // instead of waiting out the 60s timeout. diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 4b19b9fc665..7de54275bbc 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -153,6 +153,7 @@ const AgentConversation = ({ handleClientToolOutput, markLiveGate, answerApproval, + answerApprovals, resumeOrphaned, isSeen, runningElsewhere: livenessRunningElsewhere, @@ -425,6 +426,14 @@ const AgentConversation = ({ [answerApproval, markLiveGate, submit], ) + const handleApprovalResponses = useCallback( + (ids: string[], approved: boolean) => { + markLiveGate({kind: "approval", id: ids[0]}) + return answerApprovals(ids, approved) + }, + [answerApprovals, markLiveGate], + ) + const interactionAvailability = getInteractionAvailability({stopped, stopping, streaming: busy}) const pendingApprovals = useMemo( () => getLivePendingApprovals(messages, {stopped: !interactionAvailability.approvals}), @@ -920,6 +929,7 @@ const AgentConversation = ({ showTemplateStrip={showTemplateStrip} pendingApprovals={pendingApprovals} onApprovalResponse={handleApprovalResponse} + onApprovalResponses={handleApprovalResponses} connects={connects} elicits={elicits} onClientToolOutput={handleClientToolOutput} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 29a73c358fb..8e8b3ed255f 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -70,6 +70,7 @@ const AgentComposerDock = ({ showTemplateStrip, pendingApprovals, onApprovalResponse, + onApprovalResponses, connects, elicits, onClientToolOutput, @@ -112,6 +113,7 @@ const AgentComposerDock = ({ approved: boolean message?: string }) => void | Promise + onApprovalResponses: (ids: string[], approved: boolean) => void | Promise connects: ConnectionDockState /** Parked question forms the run is blocked on (from `useElicitationDock`). */ elicits: ElicitationDockState @@ -329,6 +331,7 @@ const AgentComposerDock = ({ className={CHAT_COLUMN} approvals={pendingApprovals} onApprovalResponse={onApprovalResponse} + onApprovalResponses={onApprovalResponses} entityId={entityId} /> {/* Parked client-tool interactions (connect): same placement contract as the diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index 1e664e00267..97caef2fc5f 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -14,6 +14,7 @@ interface ApprovalDockProps { approved: boolean message?: string }) => void | Promise + onApprovalResponses?: (ids: string[], approved: boolean) => void | Promise /** Selected agent revision — enables the always-allow grant. */ entityId?: string className?: string @@ -26,7 +27,13 @@ interface ApprovalDockProps { * shape, for every user); this dock is the desktop adapter: it owns the open/close animation, the * multi-gate resolve latch, and how a response actually fires. */ -const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: ApprovalDockProps) => { +const ApprovalDock = ({ + approvals, + onApprovalResponse, + onApprovalResponses, + entityId, + className, +}: ApprovalDockProps) => { const open = approvals.length > 0 // "Approve all" / "Deny all" answer SEVERAL gates at once, and each response settles // asynchronously (the SDK's serial job queue), so the pending set shrinks across renders. @@ -87,7 +94,11 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr setResponding(true) setErrorText(null) setResolvingIds(ids) - void settle(ids.map((id) => onApprovalResponse({id, approved}))) + void settle( + onApprovalResponses + ? [onApprovalResponses(ids, approved)] + : ids.map((id) => onApprovalResponse({id, approved})), + ) } // Always mounted; enter + leave animate via the shared HeightCollapse. `inert` while closed diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index a469ab6ffa9..18568f2e27a 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -45,6 +45,7 @@ import { killSession, recordInteractionAnswerAtom, respondInteractionAnswerAtom, + respondInteractionAnswersAtom, resumeSessionContinuationAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, @@ -149,6 +150,7 @@ export const useAgentChatSession = ({ const setSessionStatus = useSetAtom(setSessionStatusAtom) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. @@ -408,6 +410,18 @@ export const useAgentChatSession = ({ [respondInteractionAnswer, sessionId], ) + const answerApprovals = useCallback( + async (toolCallIds: string[], approved: boolean) => { + await submitServerOwnedApproval({ + submit: () => respondInteractionAnswers({sessionId, toolCallIds, approved}), + retire: () => { + liveGateInteractionRef.current = null + }, + }) + }, + [respondInteractionAnswers, sessionId], + ) + // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a // send is a fact, and never in the predicate, whose `true` the SDK can still refuse. const previousStatusRef = useRef(status) @@ -794,6 +808,7 @@ export const useAgentChatSession = ({ handleClientToolOutput, markLiveGate, answerApproval, + answerApprovals, resumeOrphaned, isSeen, } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts index e3e03ea31aa..40b8abd573c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts @@ -9,4 +9,9 @@ export interface SessionInteractionRespondRequest { interaction_id: string; answer?: Record | null; + answers?: Array<{ + interaction_id: string; + answer: Record; + }> | null; + expected_execution_id?: string | null; } diff --git a/web/packages/agenta-chat/src/components/ApprovalCard.tsx b/web/packages/agenta-chat/src/components/ApprovalCard.tsx index 96f8c89485e..71de97553ad 100644 --- a/web/packages/agenta-chat/src/components/ApprovalCard.tsx +++ b/web/packages/agenta-chat/src/components/ApprovalCard.tsx @@ -201,7 +201,7 @@ export const ApprovalCard = ({
- {answered ? "Answered" : "Needs your approval"} + {answered ? "Answered, waiting for the agent" : "Needs your approval"}
@@ -392,7 +392,7 @@ export const ApprovalCard = ({ {answered ? (

- The agent is continuing. Waiting for the next update… + The answer is saved. Waiting for the agent’s next update…

) : null} {errorText ? ( diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 344e95b8211..675f8cc5f68 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -21,6 +21,7 @@ import { invalidateSessionLivenessQueries, recordInteractionAnswerAtom, respondInteractionAnswerAtom, + respondInteractionAnswersAtom, resumeSessionContinuationAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, @@ -278,6 +279,7 @@ export const useAgentConversation = ({ const liveGateInteractionRef = useRef(null) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches @@ -654,6 +656,24 @@ export const useAgentConversation = ({ [respondInteractionAnswer, sessionId], ) + const handleApprovalResponses = useCallback( + async (args: {ids: string[]; approved: boolean}) => { + liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} + await submitServerOwnedApproval({ + submit: () => + respondInteractionAnswers({ + sessionId, + toolCallIds: args.ids, + approved: args.approved, + }), + retire: () => { + liveGateInteractionRef.current = null + }, + }) + }, + [respondInteractionAnswers, sessionId], + ) + // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a // send is a fact, and never in the predicate, whose `true` the SDK can still refuse. const previousStatusRef = useRef(status) @@ -672,7 +692,11 @@ export const useAgentConversation = ({ [messages], ) - const approvals = useApprovalDock({messages, respond: handleApprovalResponse}) + const approvals = useApprovalDock({ + messages, + respond: handleApprovalResponse, + respondAll: handleApprovalResponses, + }) // Settle a parked client tool (#4920). A widget calls this with the structured reference; // `addToolOutput` matches the part by `toolCallId` on the last turn and the resume predicate diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts index c099350fb83..5260177b3e3 100644 --- a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -16,6 +16,8 @@ export interface UseApprovalDockArgs { messages: UIMessage[] /** Answer one gate — the host's approval-response path (which marks the resume live). */ respond: (args: {id: string; approved: boolean}) => void | Promise + /** Answer one paused turn's shown gates in a single server transaction. */ + respondAll?: (args: {ids: string[]; approved: boolean}) => void | Promise } export interface ApprovalDock { @@ -45,6 +47,7 @@ export interface ApprovalDock { export const useApprovalDock = ({ messages, respond: onRespond, + respondAll: onRespondAll, }: UseApprovalDockArgs): ApprovalDock => { const approvals = useMemo(() => getPendingApprovals(messages), [messages]) const open = approvals.length > 0 @@ -121,8 +124,13 @@ export const useApprovalDock = ({ // Freeze the card so the dock doesn't step through the batch as each response settles — // it holds "1 of N" and closes once all are answered (see `resolvingIds`). setResolvingIds(shown.map((a) => a.approvalId)) - void settle(shown.map((a) => onRespond({id: a.approvalId, approved: true}))) - }, [responding, shown, onRespond, settle]) + const ids = shown.map((approval) => approval.approvalId) + void settle( + onRespondAll + ? [onRespondAll({ids, approved: true})] + : ids.map((id) => onRespond({id, approved: true})), + ) + }, [responding, shown, onRespond, onRespondAll, settle]) return {open, current, count, responding, answered, errorText, respond, approveAll} } diff --git a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx index dfad97bf8fe..042bd14c278 100644 --- a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx @@ -112,8 +112,8 @@ describe("durable response state", () => { />, ) - expect(markup).toContain("Answered") - expect(markup).toContain("The agent is continuing") + expect(markup).toContain("Answered, waiting for the agent") + expect(markup).toContain("The answer is saved") // HeightCollapse keeps its child mounted for the leave animation, but removes it from // layout, accessibility, and interaction while the answered state is visible. expect(markup).toContain('aria-hidden="true" inert=""') diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index a81fde59a7b..5452c8a8add 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -103,6 +103,24 @@ describe("useApprovalDock", () => { expect(result.current.open).toBe(false) }) + it("approveAll uses one batch response when the host supports it", () => { + const respond = vi.fn() + const respondAll = vi.fn() + const {result} = renderHook(() => + useApprovalDock({ + messages: [userTurn, assistantWithGates("g1", "g2")], + respond, + respondAll, + }), + ) + + act(() => result.current.approveAll()) + + expect(respond).not.toHaveBeenCalled() + expect(respondAll).toHaveBeenCalledOnce() + expect(respondAll).toHaveBeenCalledWith({ids: ["g1", "g2"], approved: true}) + }) + it("keeps the last card latched while closed so a leave transition has content", () => { const {result, rerender} = setup([userTurn, assistantWithGates("g1")]) expect(result.current.current?.approvalId).toBe("g1") diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index a250484cc3c..be24a3165e6 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -249,7 +249,9 @@ export async function fetchInteraction({ export interface RespondInteractionParams extends InteractionScopedParams { /** The answer payload (e.g. an approval decision). Shape is interaction-kind specific. */ - answer: Record + answer?: Record + /** Atomic same-turn answers used by Approve all. */ + answers?: {interactionId: string; answer: Record}[] /** The execution the approval belongs to. Durable mode serializes this against Stop. */ expectedExecutionId?: string /** Stable retry identity. Reusing it with a different answer is a conflict. */ @@ -331,6 +333,7 @@ export async function respondInteraction({ appId, abortSignal, answer, + answers, expectedExecutionId, idempotencyKey, }: RespondInteractionParams): Promise { @@ -341,7 +344,14 @@ export async function respondInteraction({ // also release the local AI SDK gate. const request = { interaction_id: interactionId, - answer, + ...(answers + ? { + answers: answers.map((item) => ({ + interaction_id: item.interactionId, + answer: item.answer, + })), + } + : {answer}), ...(expectedExecutionId ? {expected_execution_id: expectedExecutionId} : {}), } const {data, rawResponse} = await getSessionsClient() diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 5dff2cadd8f..c6c80bbc900 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -146,6 +146,7 @@ export { export { recordInteractionAnswerAtom, respondInteractionAnswerAtom, + respondInteractionAnswersAtom, resumeSessionContinuationAtom, } from "./state/interactionAnswer" export { diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index c6f09488950..ac1e8db0620 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -85,6 +85,62 @@ export const respondInteractionAnswerAtom = atom( }, ) +/** Submit every approval currently shown by Approve all as one durable transaction. */ +export const respondInteractionAnswersAtom = atom( + null, + async ( + get, + set, + params: { + sessionId: string + toolCallIds: string[] + approved: boolean + }, + ): Promise<{durable: boolean; recoverable: boolean}> => { + const {sessionId, toolCallIds, approved} = params + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") + if (toolCallIds.length === 0) throw new Error("No pending approvals were selected.") + + const queryClient = get(queryClientAtom) + const rowsQueryKey = sessionInteractionRowsQueryKey(projectId, sessionId) + let states = await set(fetchSessionInteractionStatesAtom, sessionId) + let rows = toolCallIds.map((toolCallId) => rowForToolCall(states, toolCallId)) + if (rows.some((row) => !row?.id)) { + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + states = await set(fetchSessionInteractionStatesAtom, sessionId) + rows = toolCallIds.map((toolCallId) => rowForToolCall(states, toolCallId)) + } + if (rows.some((row) => !row?.id)) { + throw new Error("One or more approvals are no longer pending. Refresh and retry.") + } + + const resolvedRows = rows as NonNullable<(typeof rows)[number]>[] + const executionIds = new Set(resolvedRows.map((row) => row.turnId).filter(Boolean)) + if (executionIds.size !== 1) { + throw new Error("Approve all can only answer approvals from one execution.") + } + const decision = approved ? "approve" : "deny" + const sortedIds = resolvedRows.map((row) => row.id as string).sort() + const result = await respondInteraction({ + interactionId: sortedIds[0], + projectId, + answers: resolvedRows.map((row, index) => ({ + interactionId: row.id as string, + answer: {approved, tool_call_id: toolCallIds[index]}, + })), + expectedExecutionId: resolvedRows[0].turnId, + idempotencyKey: `approval-batch:${sortedIds[0]}:${sortedIds.length}:${decision}`, + }) + if (!result) throw new Error("Approvals could not be submitted.") + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + return { + durable: result.accepted, + recoverable: result.execution?.state === "recoverable", + } + }, +) + /** * Best-effort by design: failures preserve today's in-band resume behavior. * It never blocks or rejects the client-tool resume path. diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts index 2b3bbd4cf61..2e5e49b2e59 100644 --- a/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts @@ -64,6 +64,42 @@ describe("respondInteraction", () => { }) }) + it("sends a same-turn approval batch in one request", async () => { + respond.mockReturnValue( + response(202, { + interaction, + command: {id: "command-1", state: "pending"}, + execution: {id: "turn-2", state: "recoverable"}, + }), + ) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answers: [ + {interactionId: "interaction-1", answer: {approved: true}}, + {interactionId: "interaction-2", answer: {approved: true}}, + ], + expectedExecutionId: "turn-1", + idempotencyKey: "approval-batch:interaction-1:2:approve", + }) + + expect(respond).toHaveBeenCalledWith( + { + interaction_id: "interaction-1", + answers: [ + {interaction_id: "interaction-1", answer: {approved: true}}, + {interaction_id: "interaction-2", answer: {approved: true}}, + ], + expected_execution_id: "turn-1", + }, + expect.objectContaining({ + headers: {"Idempotency-Key": "approval-batch:interaction-1:2:approve"}, + }), + ) + expect(result?.execution?.state).toBe("recoverable") + }) + it("keeps the flag-off server dispatcher response distinguishable without local resume", async () => { respond.mockReturnValue(response(200, {interaction})) From e942d73dd62e3076637dfe363e1809a41138619d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 15:45:58 +0200 Subject: [PATCH 007/133] test(sessions): keep unknown command coverage generic --- .../sessions/test_command_claim_unknown_kind.py | 6 +++--- .../test_command_settle_unknown_kind.py | 17 +++++++---------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py index 849d0237290..f8abed29453 100644 --- a/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py +++ b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py @@ -62,7 +62,7 @@ def test_a_claimable_stop_survives_an_unknown_kind_in_the_batch(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) stop = _row(SessionCommandKind.cancel.value) - unknown = _row("continue_interaction") + unknown = _row("future_command") mapped = commands_dao._map_commands_skipping_unmappable( [stop, unknown], context="claimed" @@ -78,7 +78,7 @@ def test_the_claim_warning_names_the_claimed_context_and_the_kind(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) commands_dao._map_commands_skipping_unmappable( - [_row(SessionCommandKind.cancel.value), _row("continue_interaction")], + [_row(SessionCommandKind.cancel.value), _row("future_command")], context="claimed", ) @@ -86,4 +86,4 @@ def test_the_claim_warning_names_the_claimed_context_and_the_kind(monkeypatch): args = recorder.warnings[0][0] assert args[1] == 1 # one unmappable row assert args[2] == "claimed" # the batch context - assert "continue_interaction=1" in args[3] + assert "future_command=1" in args[3] diff --git a/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py index 3bc171e3134..ac1135098ef 100644 --- a/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py +++ b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py @@ -1,12 +1,9 @@ """The watchdog must settle the commands it understands past a row it cannot map. A newer API replica can write a command `kind` (or state, or outcome) an older replica's -enums do not know. On the integration stack a `continue_interaction` row (increment 6, not on -this head) sat in the claimed table next to an abandoned Stop. The abandoned-command sweep -mapped the whole batch to DTOs before it settled any of it, and `map_command_dbe_to_dto` -raised `ValueError: 'continue_interaction' is not a valid SessionCommandKind` on that one row. -The ValueError escaped the batch, so NO command was settled and the Stop stayed pending pass -after pass. +enums do not know. The abandoned-command sweep used to map the whole batch to DTOs before it +settled any of it, so a `ValueError` on one future command kind escaped the batch. No command +was settled and a known Stop could stay pending pass after pass. `_map_commands_skipping_unmappable` now skips the rows this API cannot map, warns once with the kinds and count, and returns the rest. These tests hold that contract: the known Stop survives as a @@ -66,7 +63,7 @@ def test_a_known_stop_survives_and_an_unknown_kind_is_left_alone(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) stop = _row(SessionCommandKind.cancel.value) - unknown = _row("continue_interaction") + unknown = _row("future_command") mapped = commands_dao._map_commands_skipping_unmappable( [stop, unknown], context="abandoned" @@ -85,8 +82,8 @@ def test_the_unknown_kind_is_warned_once_with_its_kind_and_count(monkeypatch): rows = [ _row(SessionCommandKind.cancel.value), - _row("continue_interaction"), - _row("continue_interaction"), + _row("future_command"), + _row("future_command"), ] commands_dao._map_commands_skipping_unmappable(rows, context="abandoned") @@ -96,7 +93,7 @@ def test_the_unknown_kind_is_warned_once_with_its_kind_and_count(monkeypatch): # The message and its args name the count, the batch context, and the offending kind. assert args[1] == 2 # two unmappable rows assert args[2] == "abandoned" # the batch context - assert "continue_interaction=2" in args[3] + assert "future_command=2" in args[3] def test_an_all_mappable_batch_logs_nothing(monkeypatch): From 63d51f07c3b64b831b9f54097c5b8b3aeb4459f4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:38:43 +0200 Subject: [PATCH 008/133] fix(sessions): gate continuation reconciliation consistently --- api/oss/src/tasks/asyncio/sessions/orphan_sweep.py | 2 +- .../pytest/unit/sessions/test_orphan_sweep_thresholds.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index e30995b41c7..3c39b6310fb 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -540,7 +540,7 @@ async def run_orphan_sweep( if ( records_service is not None and commands_service is not None - and env.agenta.sessions.durable_stop + and env.agenta.sessions.durable_approvals ): runner_completed, completion_failures = await _runner_completed_executions( records_service=records_service, diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index e716dc301fa..30463dccaa5 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -327,7 +327,8 @@ async def test_persisted_done_is_terminalized_before_stale_ownership_is_cleared( anyio_backend, monkeypatch, ): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) row = _FakeRow( session_id="sess-completed-continuation", flags={"is_alive": True, "is_running": True, "is_attached": False}, @@ -352,7 +353,7 @@ async def test_completion_settlement_failure_keeps_ownership_blocking_replay( anyio_backend, monkeypatch, ): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) row = _FakeRow( session_id="sess-completion-race", flags={"is_alive": True, "is_running": True, "is_attached": False}, @@ -375,7 +376,7 @@ async def test_completion_lookup_failure_keeps_ownership_blocking_replay( anyio_backend, monkeypatch, ): - monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) row = _FakeRow( session_id="sess-completion-lookup-race", flags={"is_alive": True, "is_running": True, "is_attached": False}, From ed74085422e14a50554ae5a4e2387d68103038b4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 13:13:30 +0200 Subject: [PATCH 009/133] fix(sessions): keep heartbeat guard failures local Superseded by the milestone 2 generation-safe watchdog fence. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk From e3663e43ffa73a78ccb5bfc19cc9604f1ad6f2a7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 13:13:59 +0200 Subject: [PATCH 010/133] fix(sessions): commit watchdog state before Redis cleanup Superseded by the milestone 2 atomic generation-safe watchdog release, which commits database state before Redis cleanup. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk From f4797ab1dc775daa94bef745688cebabb3afb980 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 13:14:13 +0200 Subject: [PATCH 011/133] test(sessions): keep sweeping after guard timeout Superseded with the removed heartbeat guard by the milestone 2 generation-safe watchdog implementation. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk From 0d21ef839b59e02b9aeb04435d9b2d662a20df1e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:48:03 +0200 Subject: [PATCH 012/133] test(sessions): clear expired-turn affinity after sweep --- .../sessions/test_orphan_sweep_thresholds.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 30463dccaa5..b6df0d94d75 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -322,6 +322,28 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): ) +@pytest.mark.anyio +async def test_durable_sweep_clears_dead_affinity_when_alive_already_expired( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-dead-affinity", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-dead", + ) + redis = _FakeRedis() + owner_key = f"owner:{_PROJECT_ID}:session:{row.session_id}" + await redis.set(owner_key, b"replica-dead") + + await run_orphan_sweep(_FakeTransactionsEngine([row]), redis) + + assert _swept(row) + assert await redis.get(owner_key) is None + + @pytest.mark.anyio async def test_persisted_done_is_terminalized_before_stale_ownership_is_cleared( anyio_backend, From 7f5390ab7b8885894e35348afe5573e91d45f563 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:49:45 +0200 Subject: [PATCH 013/133] fix(sessions): keep parked continuations steerable --- .../src/dbs/postgres/sessions/commands/dao.py | 2 +- ...test_interaction_continuation_admission.py | 38 ++++---- .../sessions/test_session_commands_dao.py | 9 +- .../contracts/commands.md | 89 +++++++++++++++++++ 4 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 docs/design/session-control-and-live-events/contracts/commands.md diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 05e99544fd4..11f78b008f0 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -293,7 +293,7 @@ async def fetch_resumable_continuation( SessionCommandDBE.state == SessionCommandState.applied.value, SessionCommandDBE.outcome == "started", - SessionExecutionDBE.state.in_(("recoverable", "running")), + SessionExecutionDBE.state == "recoverable", ), ), SessionCommandDBE.deleted_at.is_(None), diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index 85b80237cb7..b9e9545a502 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -33,6 +33,7 @@ class _Commands: def __init__(self): self.command = None self.abandoned = [] + self.resumable = True @asynccontextmanager async def transaction(self): @@ -66,20 +67,24 @@ async def claim_for_delivery(self, **kwargs): return self.command async def fetch_resumable_continuation(self, **kwargs): - if self.command and ( - self.command.state - in ( - SessionCommandState.pending, - SessionCommandState.claimed, - ) - or ( - self.command.state == SessionCommandState.obsolete - and self.command.outcome - in (SessionCommandOutcome.lost, SessionCommandOutcome.failed) - ) - or ( - self.command.state == SessionCommandState.applied - and self.command.outcome == SessionCommandOutcome.started + if ( + self.resumable + and self.command + and ( + self.command.state + in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ) + or ( + self.command.state == SessionCommandState.obsolete + and self.command.outcome + in (SessionCommandOutcome.lost, SessionCommandOutcome.failed) + ) + or ( + self.command.state == SessionCommandState.applied + and self.command.outcome == SessionCommandOutcome.started + ) ) ): return self.command @@ -728,7 +733,7 @@ async def test_only_the_winning_started_outcome_is_admitted(): @pytest.mark.asyncio -async def test_running_continuation_blocks_send_while_heartbeat_is_live(): +async def test_parked_running_continuation_does_not_own_send_preflight(): project_id = uuid4() interaction_id = uuid4() commands = _Commands() @@ -745,6 +750,7 @@ async def test_running_continuation_blocks_send_while_heartbeat_is_live(): executions.continuation = executions.continuation.model_copy( update={"state": SessionExecutionState.running} ) + commands.resumable = False delivery = _Unreachable() streams = SimpleNamespace( fetch_header=AsyncMock( @@ -775,7 +781,7 @@ async def test_running_continuation_blocks_send_while_heartbeat_is_live(): executions_dao=executions, ) - assert await service.resume_recoverable_continuation( + assert not await service.resume_recoverable_continuation( project_id=project_id, session_id="session-1" ) assert delivery.delivered == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index 624d0c21c13..2fb79eedc20 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -1075,7 +1075,7 @@ async def test_full_service_concurrent_same_key_conflicting_answer_is_409_domain assert isinstance(conflict, IdempotencyKeyReused) -async def test_running_continuation_blocks_and_only_reopens_after_recovery( +async def test_parked_running_continuation_is_steerable_and_reopens_after_recovery( command_scope, ): commands = SessionCommandsDAO(engine=command_scope["engine"]) @@ -1121,7 +1121,7 @@ async def test_running_continuation_blocks_and_only_reopens_after_recovery( project_id=command_scope["project_id"], session_id=command_scope["session_id"], ) - assert blocker is not None and blocker.id == command.id + assert blocker is None assert ( await commands.reopen_continuation( project_id=command_scope["project_id"], @@ -1139,6 +1139,11 @@ async def test_running_continuation_blocks_and_only_reopens_after_recovery( state=SessionExecutionState.recoverable, expected_states=[SessionExecutionState.running], ) + blocker = await commands.fetch_resumable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + assert blocker is not None and blocker.id == command.id async with commands.transaction() as transaction: await executions.create_continuation( project_id=command_scope["project_id"], diff --git a/docs/design/session-control-and-live-events/contracts/commands.md b/docs/design/session-control-and-live-events/contracts/commands.md new file mode 100644 index 00000000000..bfb9fa1a76c --- /dev/null +++ b/docs/design/session-control-and-live-events/contracts/commands.md @@ -0,0 +1,89 @@ +# Private command contract + +> **AGENT-GENERATED, low weight.** + +## Purpose + +Commands preserve execution-changing intent independently from delivery. Public routes accept +operations, while a private command store and adapter deliver them to the runner. + +## Command shape + +```text +command_id +session_id +type +expected_execution_id +payload: typed by command type +status: pending | claimed | applied | obsolete | lost +claimed_by +claim_expires_at +attempt_count +next_attempt_at +created_at +applied_at +result +``` + +`expected_execution_id` has the same name and meaning at the public and private boundaries. Each +command type defines its payload. A free-form payload is not part of the contract. + +The command ID remains stable across retries. The runner applies one command ID at most once. + +## Delivery port + +The commands domain owns this transport port: + +```text +deliver(command) -> receipt +``` + +The receipt reports whether the runner accepted, duplicated, or refused delivery. It does not +settle the command. The command service owns settlement, retry scheduling, and recovery. + +## State transitions + +```text +pending -> claimed -> applied + -> obsolete + -> lost +``` + +`pending` and `claimed` describe private delivery. Public clients follow execution state and +durable terminal events. + +## Continuation admission + +A continuation command owns the next Send only while its execution is `pending_delivery` or +`recoverable`. An `applied/started` continuation whose execution is `running` may be parked on a +later interaction; it is therefore steerable, just like an initial execution parked for human +input. Send preflight does not claim that state. If the watchdog later moves the execution to +`recoverable`, preflight may reopen and redeliver it before accepting a new message. + +This makes the command query and the public router share one state rule: `running` is live and +steerable; `recoverable` owns continuation recovery. + +## Recovery rules + +- A delivery failure leaves the command `pending`. +- A delivery timeout has an unknown result, so recovery reuses the same command ID. +- A `pending` command whose session still beats is redelivered with bounded attempts. +- A `pending` command whose runner is gone settles `lost`. +- Duplicate delivery returns the existing receipt and applies no second effect. +- Normal shutdown releases claims. Forced shutdown relies on lease expiry and the sweep. +- Each sweep pass has a time bound. A timeout is logged and does not stop later passes. + +## Settlement rules + +The execution row chooses one terminal winner through a compare-and-set. The runner and watchdog +call the same settlement service. Only the winner writes the effective terminal event. + +Where the data shares a database, one transaction settles the command, clears the stopping marker, +updates the session mirror, and cancels pending interactions for the target execution. Redis +liveness changes after commit through an idempotent write. A sweep repairs a missed Redis write. + +## Stop and interaction races + +Both transactions lock the execution row first and the interaction row second. Each update checks +the exact expected state. A winning Stop cancels only interactions owned by its target execution. +A winning response creates one continuation execution and command. From f79228a63e5e9e182b00de9a4e9e81dd5e80a220 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:51:25 +0200 Subject: [PATCH 014/133] fix(sessions): validate batch anchor before feature routing --- api/oss/src/apis/fastapi/sessions/router.py | 23 +++++++------ .../test_respond_interaction_durable.py | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index c6a0798ad2d..9beea760065 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -1318,6 +1318,19 @@ async def respond_interaction( if not authorized: raise FORBIDDEN_EXCEPTION + if body.answers is not None and interaction_id not in { + item.interaction_id for item in body.answers + }: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "The path interaction must be included in answers.", + "retryable": False, + "details": {"field": "answers", "reason": "anchor_missing"}, + }, + ) + if env.agenta.sessions.durable_approvals and self.commands_service is not None: idempotency_key = (request.headers.get("Idempotency-Key") or "").strip() if not idempotency_key: @@ -1348,16 +1361,6 @@ async def respond_interaction( if body.answers is not None else [(interaction_id, body.answer)] ) - if interaction_id not in {item[0] for item in interaction_answers}: - return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={ - "code": "validation_error", - "message": "The path interaction must be included in answers.", - "retryable": False, - "details": {"field": "answers", "reason": "anchor_missing"}, - }, - ) try: if body.answers is not None: admission = await self.commands_service.respond_interactions( diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py index 641d7387e43..4748763a3bc 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -269,3 +269,36 @@ async def test_continuation_resume_endpoint_is_feature_gated(monkeypatch): commands.resume_recoverable_continuation.assert_awaited_once_with( project_id=project_id, session_id="session-1" ) + + +async def test_feature_off_batch_without_path_anchor_returns_422(monkeypatch): + project_id = uuid4() + anchor_id = uuid4() + other_id = uuid4() + interactions = SimpleNamespace(fetch_interaction=AsyncMock()) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=interactions, + workflows_service=AsyncMock(), + commands_service=AsyncMock(), + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=uuid4()), headers={} + ), + interaction_id=anchor_id, + body=SessionInteractionRespondRequest( + answers=[{"interaction_id": other_id, "answer": {"approved": True}}] + ), + ) + + assert response.status_code == 422 + assert json.loads(response.body)["details"] == { + "field": "answers", + "reason": "anchor_missing", + } + interactions.fetch_interaction.assert_not_awaited() From 51355cf34711c9e40781244c5474e8809860b063 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:51:51 +0200 Subject: [PATCH 015/133] fix(sessions): replay the current continuation target --- api/oss/src/core/sessions/commands/service.py | 4 +++- .../unit/sessions/test_interaction_continuation_admission.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 428dde3dd75..89dbba3518b 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -480,7 +480,9 @@ async def respond_interactions( ) if not same_request: raise IdempotencyKeyReused() - execution_id = str(existing.data["continuation_execution_id"]) + execution_id = existing.target_turn_id or str( + existing.data["continuation_execution_id"] + ) admission = InteractionContinuationAdmission( interaction=by_id[anchor_id], command=existing, diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index b9e9545a502..6fda1a61015 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -303,6 +303,9 @@ async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): assert executions.source.terminal_outcome == "continued" assert executions.states[-1][1] == SessionExecutionState.recoverable + commands.command = commands.command.model_copy( + update={"target_turn_id": "continuation-retry"} + ) retry = await service.respond_interaction( project_id=project_id, user_id=user_id, @@ -312,7 +315,7 @@ async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): idempotency_key="response-1", ) assert retry.command.id == admission.command.id - assert retry.execution_id == admission.execution_id + assert retry.execution_id == "continuation-retry" with pytest.raises(IdempotencyKeyReused): await service.respond_interaction( From 10a976baaa53898443f48278b50b37dfe8871109 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:52:59 +0200 Subject: [PATCH 016/133] fix(workflows): scope strict start records to commands --- api/oss/src/core/workflows/service.py | 22 ++++++---- .../unit/workflows/test_invoke_detached.py | 44 ++++++++++++++++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 50dae82b783..75a6490a9f6 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -752,6 +752,7 @@ async def _stream_service_started( credentials: str, payload: dict, run_id: str, + strict_first_record: bool = False, ) -> WorkflowServiceDetachedResponse: """Stream the service ``/invoke`` and return on the FIRST record (the started handshake). @@ -803,15 +804,17 @@ async def _stream_service_started( try: record = json.loads(line) except json.JSONDecodeError as error: - raise WorkflowDetachedStartFailed( - "Workflow service emitted malformed NDJSON before detached start." - ) from error - if not isinstance(record, dict): + if strict_first_record: + raise WorkflowDetachedStartFailed( + "Workflow service emitted malformed NDJSON before detached start." + ) from error + record = None + if strict_first_record and not isinstance(record, dict): raise WorkflowDetachedStartFailed( "Workflow service emitted a non-object record before detached start." ) - kind = record.get("kind") - if kind == "result": + kind = record.get("kind") if isinstance(record, dict) else None + if strict_first_record and kind == "result": result = record.get("result") if not isinstance(result, dict) or result.get("ok") is not True: detail = ( @@ -822,11 +825,13 @@ async def _stream_service_started( raise WorkflowDetachedStartFailed( f"Workflow service rejected detached start: {detail}" ) - elif kind != "event": + elif strict_first_record and kind != "event": raise WorkflowDetachedStartFailed( "Workflow service emitted an unknown record before detached start." ) - record_run_id = record.get("run_id") + record_run_id = ( + record.get("run_id") if isinstance(record, dict) else None + ) return WorkflowServiceDetachedResponse( run_id=record_run_id or run_id, accepted=True, @@ -3007,6 +3012,7 @@ async def invoke_workflow_detached( exclude_none=True, ), run_id=run_id, + strict_first_record=bool(meta.get("control_command_id")), ) async def inspect_workflow( diff --git a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py index 61045e9b8ca..5a4b0a98198 100644 --- a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py +++ b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py @@ -146,9 +146,24 @@ async def test_stream_service_started_rejects_failure_or_malformed_first_record( credentials="Secret tok", payload={}, run_id="run-x", + strict_first_record=True, ) +async def test_stream_service_started_keeps_legacy_best_effort_for_ordinary_trigger(): + response = _FakeStreamResponse(lines=["not-json"]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + ) + + assert result.accepted is True + assert result.run_id == "run-x" + + async def test_stream_service_started_accepts_success_result_record(): response = _FakeStreamResponse( lines=['{"kind": "result", "result": {"ok": true}}'], @@ -173,10 +188,11 @@ async def test_invoke_workflow_detached_returns_run_id_and_threads_meta(): captured = {} - async def _fake_stream(*, url, credentials, payload, run_id): + async def _fake_stream(*, url, credentials, payload, run_id, strict_first_record): captured["url"] = url captured["payload"] = payload captured["run_id"] = run_id + captured["strict_first_record"] = strict_first_record from oss.src.core.workflows.dtos import WorkflowServiceDetachedResponse return WorkflowServiceDetachedResponse(run_id=run_id, accepted=True) @@ -199,6 +215,32 @@ async def _fake_stream(*, url, credentials, payload, run_id): # The coordination ids are threaded onto the request meta (Foundation B handoff). assert captured["payload"]["meta"]["run_id"] == "run-fixed" assert captured["payload"]["meta"]["project_id"] == str(project_id) + assert captured["strict_first_record"] is False + + +async def test_invoke_workflow_detached_enables_strict_handshake_for_control_command(): + svc = _service() + svc._prepare_invoke = AsyncMock(return_value=("Secret tok", "http://svc")) + captured = {} + + async def _fake_stream(*, url, credentials, payload, run_id, strict_first_record): + captured["strict_first_record"] = strict_first_record + from oss.src.core.workflows.dtos import WorkflowServiceDetachedResponse + + return WorkflowServiceDetachedResponse(run_id=run_id, accepted=True) + + svc._stream_service_started = _fake_stream + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + await svc.invoke_workflow_detached( + project_id=uuid4(), + user_id=uuid4(), + request=WorkflowServiceRequest(meta={"control_command_id": str(uuid4())}), + run_id="run-control", + ) + + assert captured["strict_first_record"] is True async def test_invoke_workflow_detached_raises_when_no_service_url(): From 916454b0a168e5fe10301f499df2a6c3dddade53 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:54:25 +0200 Subject: [PATCH 017/133] fix(sessions): replay matching partial answers --- api/oss/src/core/sessions/commands/service.py | 22 +++++++++++++++---- ...test_interaction_continuation_admission.py | 17 +++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 89dbba3518b..ff4ce56f92a 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -494,10 +494,24 @@ async def respond_interactions( SessionExecutionState.stopping, SessionExecutionState.terminal, ): - raise InteractionResponseConflict( - code="execution_terminal", - message="The source execution can no longer be continued.", - details={"execution_state": source.state.value}, + for interaction_id, answer in interaction_answers: + interaction = by_id[interaction_id] + if ( + interaction.status != SessionInteractionStatus.responded + or interaction.data is None + or interaction.data.resolution != answer + ): + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={"execution_state": source.state.value}, + ) + return InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=None, + execution_id=source_execution_id, + execution_state=source.state, + interactions=[by_id[item] for item in requested], ) transitioned: List[SessionInteraction] = [] diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index 6fda1a61015..ac5125e47b0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -40,7 +40,9 @@ async def transaction(self): yield object() async def fetch_by_idempotency_key(self, **kwargs): - return self.command + if self.command and self.command.idempotency_key == kwargs["idempotency_key"]: + return self.command + return None async def fetch_command(self, **kwargs): return self.command @@ -402,6 +404,19 @@ async def test_parallel_answers_wait_then_share_one_continuation(): {"interaction_id": str(second_id), "answer": {"approved": False}}, ] + retry = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1-retry", + ) + + assert retry.interaction.id == first_id + assert retry.command is None + assert len(delivery.delivered) == 1 + @pytest.mark.asyncio async def test_approve_all_commits_one_continuation_for_the_batch(): From b6e6ca66ba1760fadcdc84484e4de2dd793bb43b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 16:58:07 +0200 Subject: [PATCH 018/133] fix(sessions): make continuation preflight additive --- api/oss/src/apis/fastapi/sessions/models.py | 5 ++ api/oss/src/apis/fastapi/sessions/router.py | 8 ++- .../test_respond_interaction_durable.py | 23 ++++++++ .../api/types/SessionStreamResponse.ts | 1 + .../src/assets/continuationPreflight.ts | 9 ++- .../unit/assets/continuationPreflight.test.ts | 13 ++++ .../agenta-entities/src/session/api/api.ts | 54 +++++++++++++---- .../src/session/core/schema.ts | 6 ++ .../agenta-entities/src/session/index.ts | 1 + .../src/session/state/interactionAnswer.ts | 10 +++- .../session-continuation-resume-api.test.ts | 59 +++++++++++++++++-- 11 files changed, 167 insertions(+), 22 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 9953871dbec..27f11eb8d05 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -138,8 +138,13 @@ class SessionStreamQueryRequest(BaseModel): is_running: Optional[bool] = None +class SessionCapabilities(BaseModel): + durable_approvals: bool = False + + class SessionStreamResponse(BaseModel): stream: Optional[SessionStream] = None + capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) class SessionStreamsResponse(BaseModel): diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 9beea760065..8dcb829d9ab 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -155,6 +155,7 @@ SessionDetachRequest, SessionStreamQueryRequest, SessionStreamResponse, + SessionCapabilities, SessionStreamsResponse, # records SessionRecordIngestBody, @@ -516,7 +517,12 @@ async def fetch_session_stream( project_id=UUID(str(project_id)), session_id=session_id, ) - return SessionStreamResponse(stream=sanitize_session_stream(stream)) + return SessionStreamResponse( + stream=sanitize_session_stream(stream), + capabilities=SessionCapabilities( + durable_approvals=env.agenta.sessions.durable_approvals + ), + ) @intercept_exceptions() @_handle_session_exceptions() diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py index 4748763a3bc..451ec4f6f23 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -7,6 +7,7 @@ from oss.src.apis.fastapi.sessions.models import SessionInteractionRespondRequest from oss.src.apis.fastapi.sessions.router import InteractionsRouter from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter from oss.src.core.sessions.commands.dtos import SessionCommandState from oss.src.core.sessions.commands.types import IdempotencyKeyReused from oss.src.core.sessions.executions.dtos import SessionExecutionState @@ -302,3 +303,25 @@ async def test_feature_off_batch_without_path_anchor_returns_422(monkeypatch): "reason": "anchor_missing", } interactions.fetch_interaction.assert_not_awaited() + + +async def test_session_stream_response_advertises_durable_approvals(monkeypatch): + project_id = uuid4() + service = SimpleNamespace(fetch=AsyncMock(return_value=None)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionStreamsRouter( + service=service, + interactions_service=AsyncMock(), + ) + + response = await router.fetch_session_stream( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=uuid4()) + ), + session_id="session-1", + ) + + assert response.capabilities.durable_approvals is True diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts index c94ce7ba5a9..f3ce051a45f 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts @@ -4,4 +4,5 @@ import type * as AgentaApi from "../index.js"; export interface SessionStreamResponse { stream?: (AgentaApi.SessionStream | null) | undefined; + capabilities?: { durable_approvals?: boolean | undefined } | undefined; } diff --git a/web/packages/agenta-chat/src/assets/continuationPreflight.ts b/web/packages/agenta-chat/src/assets/continuationPreflight.ts index e4de8a7c40e..2b6c5101377 100644 --- a/web/packages/agenta-chat/src/assets/continuationPreflight.ts +++ b/web/packages/agenta-chat/src/assets/continuationPreflight.ts @@ -8,7 +8,14 @@ export async function assertNoResumedSessionContinuation( resume: ResumeSessionContinuation, sessionId: string, ): Promise { - if (!(await resume(sessionId))) return + let resumed = false + try { + resumed = await resume(sessionId) + } catch (error) { + console.warn("[continuationPreflight] unavailable; continuing Send", error) + return + } + if (!resumed) return throw new Error( JSON.stringify({ status: { diff --git a/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts index 27e6caf2ee5..a56f27d8920 100644 --- a/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts @@ -40,4 +40,17 @@ describe("assertNoResumedSessionContinuation", () => { ).rejects.toThrow("continuation_resumed") expect(prepare).not.toHaveBeenCalled() }) + + it("still builds a request when the additive preflight transport fails", async () => { + const prepare = vi.fn().mockResolvedValue({body: "ordinary send"}) + + await expect( + prepareAfterContinuationPreflight( + vi.fn().mockRejectedValue(new Error("older API")), + "session-1", + prepare, + ), + ).resolves.toEqual({body: "ordinary send"}) + expect(prepare).toHaveBeenCalledOnce() + }) }) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index be24a3165e6..1cfed2fd461 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -677,6 +677,30 @@ export async function fetchSessionStream({ return validated?.stream ?? null } +/** Server-owned feature capability. Missing/failed responses mean legacy behavior. */ +export async function fetchSessionDurableApprovalsCapability({ + sessionId, + projectId, + appId, + abortSignal, +}: SessionScopedParams): Promise { + if (!projectId || !sessionId) return false + + const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => + getSessionsClient().fetchSessionStream( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + if (!data) return false + const validated = safeParseWithLogging( + sessionStreamResponseSchema, + data, + "[fetchSessionDurableApprovalsCapability]", + ) + return validated?.capabilities.durable_approvals ?? false +} + export interface CommandSessionStreamParams extends SessionScopedParams { /** Steal the run lock from whoever holds it. */ force?: boolean @@ -1159,8 +1183,8 @@ export interface ResumeSessionContinuationParams extends SessionScopedParams {} /** * Ask the API to redeliver an already-durable approval continuation before a direct invoke. * - * This mutation deliberately throws on transport or malformed-response failures: if ownership - * is uncertain, allowing the caller to start a fresh turn could race the saved continuation. + * This mutation fails open: continuation recovery is an additive capability and can never make + * an ordinary Send depend on a new route being available. */ export async function resumeSessionContinuation({ sessionId, @@ -1168,19 +1192,23 @@ export async function resumeSessionContinuation({ appId, abortSignal, }: ResumeSessionContinuationParams): Promise { - if (!projectId || !sessionId) { - throw new Error("Continuation preflight has no project or session scope.") - } + if (!projectId || !sessionId) return false - const data = await getSessionsClient().resumeSessionContinuation( - {session_id: sessionId}, - projectScopedRequest(projectId, appId, abortSignal), - ) - const parsed = z.object({resumed: z.boolean()}).safeParse(data) - if (!parsed.success) { - throw new Error("Continuation preflight returned an invalid response.") + try { + const data = await getSessionsClient().resumeSessionContinuation( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ) + const parsed = z.object({resumed: z.boolean()}).safeParse(data) + if (!parsed.success) { + console.warn("[resumeSessionContinuation] invalid response; continuing Send") + return false + } + return parsed.data.resumed + } catch (error) { + console.warn("[resumeSessionContinuation] preflight failed; continuing Send", error) + return false } - return parsed.data.resumed } /** Cancel current work through Fern while keeping the session warm. */ diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index b7e55b2f34b..ec5ea3f6af2 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -272,6 +272,12 @@ export const sessionsQueryResponseSchema = z.object({ export const sessionStreamResponseSchema = z.object({ stream: sessionStreamSchema.nullish(), + capabilities: z + .object({ + durable_approvals: z.boolean().optional().default(false), + }) + .optional() + .default({durable_approvals: false}), }) /** Control-call result for the prompt × force command matrix. */ diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index c6c80bbc900..c12e60320c9 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -19,6 +19,7 @@ export { querySessions, setSessionHeader, fetchSessionStream, + fetchSessionDurableApprovalsCapability, commandSessionStream, cancelSessionExecution, cancelSessionStream, diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index ac1e8db0620..5aef61f75d8 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -2,7 +2,12 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import {respondInteraction, resumeSessionContinuation, transitionInteraction} from "../api/api" +import { + fetchSessionDurableApprovalsCapability, + respondInteraction, + resumeSessionContinuation, + transitionInteraction, +} from "../api/api" import { fetchSessionInteractionStatesAtom, @@ -37,6 +42,9 @@ export const resumeSessionContinuationAtom = atom( null, async (get, _set, sessionId: string): Promise => { const projectId = get(projectIdAtom) ?? "" + if (!(await fetchSessionDurableApprovalsCapability({projectId, sessionId}))) { + return false + } return resumeSessionContinuation({projectId, sessionId}) }, ) diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 93e2640d9e3..91c39d8fbc3 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -1,17 +1,26 @@ import {beforeEach, describe, expect, it, vi} from "vitest" -const {resume} = vi.hoisted(() => ({resume: vi.fn()})) +const {resume, fetchStream} = vi.hoisted(() => ({resume: vi.fn(), fetchStream: vi.fn()})) vi.mock("@agenta/sdk/resources", () => ({ - getSessionsClient: () => ({resumeSessionContinuation: resume}), + getSessionsClient: () => ({ + resumeSessionContinuation: resume, + fetchSessionStream: fetchStream, + }), getLowPrioritySessionsClient: vi.fn(), getMountsClient: vi.fn(), getLowPriorityMountsClient: vi.fn(), })) -import {resumeSessionContinuation} from "../../src/session/api/api" +import { + fetchSessionDurableApprovalsCapability, + resumeSessionContinuation, +} from "../../src/session/api/api" -beforeEach(() => resume.mockReset()) +beforeEach(() => { + resume.mockReset() + fetchStream.mockReset() +}) describe("resumeSessionContinuation", () => { it.each([true, false])("returns resumed=%s from the scoped preflight", async (resumed) => { @@ -30,11 +39,49 @@ describe("resumeSessionContinuation", () => { ) }) - it("fails closed when the API response cannot establish ownership", async () => { + it("fails open when the API response cannot establish ownership", async () => { resume.mockResolvedValue({resumed: "maybe"}) await expect( resumeSessionContinuation({projectId: "project-1", sessionId: "session-1"}), - ).rejects.toThrow("invalid response") + ).resolves.toBe(false) + }) + + it("fails open on a continuation transport failure", async () => { + resume.mockRejectedValue(new Error("route missing")) + + await expect( + resumeSessionContinuation({projectId: "project-1", sessionId: "session-1"}), + ).resolves.toBe(false) + }) +}) + +describe("fetchSessionDurableApprovalsCapability", () => { + it("uses the authenticated session response as the capability source", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: true}, + }) + + await expect( + fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }), + ).resolves.toBe(true) + }) + + it.each([ + ["older API", {stream: null}], + ["failed request", null], + ])("uses legacy behavior for %s", async (_case, response) => { + fetchStream.mockResolvedValue(response) + + await expect( + fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }), + ).resolves.toBe(false) }) }) From b5f6e0dcfa00d7b359ddb8ed6080aacb87d606a0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:05:38 +0200 Subject: [PATCH 019/133] fix(chat): route approvals by server capability --- web/mobile/src/features/chat/ApprovalDock.tsx | 3 +- .../src/features/chat/LiveConversation.tsx | 16 ++--- .../src/features/chat/useApprovalActions.ts | 10 ++- .../components/AgentComposerDock.tsx | 13 +++- .../components/ApprovalDock.tsx | 20 +++++- .../hooks/useAgentChatSession.ts | 58 +++++++++++++---- .../src/assets/serverOwnedApproval.ts | 28 +++++++++ .../src/components/ApprovalCard.tsx | 13 +++- .../src/hooks/useAgentConversation.ts | 62 +++++++++++++++---- .../agenta-chat/src/hooks/useApprovalDock.ts | 59 ++++++++++++------ .../tests/unit/ApprovalCard.test.tsx | 15 +++++ .../unit/assets/serverOwnedApproval.test.ts | 29 ++++++++- .../agenta-entities/src/session/index.ts | 1 + .../src/session/state/interactionAnswer.ts | 15 ++++- 14 files changed, 278 insertions(+), 64 deletions(-) diff --git a/web/mobile/src/features/chat/ApprovalDock.tsx b/web/mobile/src/features/chat/ApprovalDock.tsx index 7bfaadd967f..8fe4df20261 100644 --- a/web/mobile/src/features/chat/ApprovalDock.tsx +++ b/web/mobile/src/features/chat/ApprovalDock.tsx @@ -30,7 +30,7 @@ export const ApprovalDock = ({ bottomMost?: boolean }) => { const busy = actions.phase === "resuming" - const answered = actions.phase === "answered" + const answered = actions.phase === "answered" || actions.phase === "recoverable" if (approvals.length === 0) return null return ( @@ -46,6 +46,7 @@ export const ApprovalDock = ({ approvals={approvals} responding={busy} answered={answered} + recoverable={actions.phase === "recoverable"} entityId={entityId} steerEnabled={isSteerEnabled()} touch diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 88bfd8806fd..e71f6af1c46 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -383,13 +383,15 @@ export const LiveConversation = ({ }) const approvalActions: ApprovalActions = useMemo( () => ({ - phase: conversation.approvals.answered - ? "answered" - : conversation.approvals.responding - ? "resuming" - : conversation.approvals.errorText - ? "error" - : steerActions.phase, + phase: conversation.approvals.recoverable + ? "recoverable" + : conversation.approvals.answered + ? "answered" + : conversation.approvals.responding + ? "resuming" + : conversation.approvals.errorText + ? "error" + : steerActions.phase, errorText: conversation.approvals.errorText ?? steerActions.errorText, respond: ({approved, message, approvalId}) => { if (message) { diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 79afb3dc106..5c28ed96835 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -9,7 +9,7 @@ import { import {hasSettledResume, selectApprovalTargets, type ApprovalTarget} from "./approvalTargets" import {buildApprovalAnswer} from "./steer" -export type ResumePhase = "idle" | "resuming" | "answered" | "error" +export type ResumePhase = "idle" | "resuming" | "answered" | "recoverable" | "error" /** Fern's `AgentaApiError` message is transport jargon — show the status instead. */ const respondErrorText = (error: unknown): string => { @@ -73,7 +73,7 @@ export const useApprovalActions = ({ // Failure-path re-arm: if the respond was accepted but the run dies before the gate // resolves, the poll never settles us — drop back to idle so the buttons re-arm. useEffect(() => { - if (phase !== "resuming" && phase !== "answered") return + if (phase !== "resuming" && phase !== "answered" && phase !== "recoverable") return const handle = setTimeout(() => setPhase("idle"), 60_000) return () => clearTimeout(handle) }, [phase]) @@ -108,7 +108,7 @@ export const useApprovalActions = ({ let answered = targets.length try { const ids = targets.map((row) => row.id as string).sort() - await respondInteraction({ + const result = await respondInteraction({ interactionId: ids[0], projectId, ...(targets.length === 1 @@ -125,6 +125,10 @@ export const useApprovalActions = ({ ? `approval:${targets[0].id}:${approved ? "approve" : "deny"}` : `approval-batch:${ids[0]}:${ids.length}:${approved ? "approve" : "deny"}`, }) + if (result?.execution?.state === "recoverable") { + setPhase("recoverable") + return + } } catch (err) { // Someone (desktop, another tab) already answered this gate — benign. if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 8e8b3ed255f..21c06101cca 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -1,6 +1,10 @@ import {useCallback, useEffect, useRef, type RefObject} from "react" -import {CHAT_COLUMN, shouldShowStopControl} from "@agenta/chat/assets" +import { + CHAT_COLUMN, + shouldShowStopControl, + type ApprovalSubmissionOutcome, +} from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import { ChatComposer, @@ -112,8 +116,11 @@ const AgentComposerDock = ({ id: string approved: boolean message?: string - }) => void | Promise - onApprovalResponses: (ids: string[], approved: boolean) => void | Promise + }) => void | ApprovalSubmissionOutcome | Promise + onApprovalResponses: ( + ids: string[], + approved: boolean, + ) => void | ApprovalSubmissionOutcome | Promise connects: ConnectionDockState /** Parked question forms the run is blocked on (from `useElicitationDock`). */ elicits: ElicitationDockState diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index 97caef2fc5f..aa6919f31f3 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -1,5 +1,6 @@ import {memo, useEffect, useRef, useState} from "react" +import type {ApprovalSubmissionOutcome} from "@agenta/chat/assets" import {ApprovalCard} from "@agenta/chat/components" import type {PendingApproval} from "@agenta/chat/model" import {HeightCollapse} from "@agenta/ui" @@ -13,8 +14,11 @@ interface ApprovalDockProps { id: string approved: boolean message?: string - }) => void | Promise - onApprovalResponses?: (ids: string[], approved: boolean) => void | Promise + }) => void | ApprovalSubmissionOutcome | Promise + onApprovalResponses?: ( + ids: string[], + approved: boolean, + ) => void | ApprovalSubmissionOutcome | Promise /** Selected agent revision — enables the always-allow grant. */ entityId?: string className?: string @@ -50,6 +54,7 @@ const ApprovalDock = ({ const [responding, setResponding] = useState(false) const [answered, setAnswered] = useState(false) + const [recoverable, setRecoverable] = useState(false) const [errorText, setErrorText] = useState(null) // Feature flag: the "Redirect" (steer) control is OFF by default. The UI is complete, but the // redirect runs as a follow-up turn — the model reasons about the bare denial before it lands — @@ -60,6 +65,7 @@ const ApprovalDock = ({ useEffect(() => { setResponding(false) setAnswered(false) + setRecoverable(false) setErrorText(null) }, [current?.approvalId]) @@ -71,12 +77,19 @@ const ApprovalDock = ({ } }, [approvals, resolvingIds]) - const settle = async (responses: (Promise | void)[]) => { + const settle = async ( + responses: (void | ApprovalSubmissionOutcome | Promise)[], + ) => { const results = await Promise.allSettled(responses) const failed = results.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) if (!failed) { + setRecoverable( + results.some( + (result) => result.status === "fulfilled" && result.value?.recoverable === true, + ), + ) setAnswered(true) return } @@ -111,6 +124,7 @@ const ApprovalDock = ({ approvals={shown} responding={responding} answered={answered} + recoverable={recoverable} errorText={errorText} entityId={entityId} steerEnabled={steerEnabled} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 18568f2e27a..a9a7f517a00 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -6,7 +6,7 @@ import { latestTurnId, prepareAfterContinuationPreflight, startupLabelFromDataPart, - submitServerOwnedApproval, + submitApprovalForCapability, } from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import {useSessionChat} from "@agenta/chat/hooks" @@ -47,6 +47,7 @@ import { respondInteractionAnswerAtom, respondInteractionAnswersAtom, resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, } from "@agenta/entities/session" @@ -54,6 +55,7 @@ import {markTraceAsFresh} from "@agenta/entities/trace" import {invalidateAgentCommittedRevisionCache, workflowMolecule} from "@agenta/entities/workflow" import { agentShouldResumeAfterApproval, + approvalResolution, buildAgentRequest, buildTurnCapture, isHitlPending, @@ -152,6 +154,7 @@ export const useAgentChatSession = ({ const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) + const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. // `null` means "no live gate" — voided by a stop, or spent once a resume really went out; @@ -390,36 +393,69 @@ export const useAgentChatSession = ({ liveGateInteractionRef.current = interaction }, []) - /** Submit an approval to the server-owned dispatcher. Durable mode returns 202 after recording - * the command; flag-off mode returns 200 after enqueueing the existing detached resume. */ + /** Choose the durable dispatcher only when the server advertises it. */ const answerApproval = useCallback( async (approvalId: string, approved: boolean) => { - await submitServerOwnedApproval({ - submit: () => + return submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({ sessionId, toolCallId: approvalId, approved, }), - retire: () => { + retireDurable: () => { // A lost HTTP response may still follow a committed continuation. liveGateInteractionRef.current = null }, + recordLegacy: () => + recordInteractionAnswer({ + sessionId, + toolCallId: approvalId, + resolution: approvalResolution(approvalId, approved), + }), + releaseLegacy: () => addToolApprovalResponse({id: approvalId, approved}), }) }, - [respondInteractionAnswer, sessionId], + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) const answerApprovals = useCallback( async (toolCallIds: string[], approved: boolean) => { - await submitServerOwnedApproval({ - submit: () => respondInteractionAnswers({sessionId, toolCallIds, approved}), - retire: () => { + return submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswers({sessionId, toolCallIds, approved}), + retireDurable: () => { liveGateInteractionRef.current = null }, + recordLegacy: () => + Promise.all( + toolCallIds.map((approvalId) => + recordInteractionAnswer({ + sessionId, + toolCallId: approvalId, + resolution: approvalResolution(approvalId, approved), + }), + ), + ).then(() => undefined), + releaseLegacy: () => { + for (const id of toolCallIds) addToolApprovalResponse({id, approved}) + }, }) }, - [respondInteractionAnswers, sessionId], + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswers, + sessionId, + supportsDurableApprovals, + ], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a diff --git a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts index d9c5cfb94e3..345d2af16d6 100644 --- a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts +++ b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts @@ -1,3 +1,5 @@ +import {recordAnswerThenRelease} from "@agenta/playground/agent-chat" + /** * Keep the server as the sole continuation owner even when its HTTP response is ambiguous. * A rejected request may have committed before the connection failed, so the browser must retire @@ -16,3 +18,29 @@ export async function submitServerOwnedApproval({ retire() } } + +export interface ApprovalSubmissionOutcome { + durable: boolean + recoverable: boolean +} + +/** Choose the approval owner from the server capability, preserving the original local path. */ +export async function submitApprovalForCapability({ + durableApprovals, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, +}: { + durableApprovals: boolean + submitDurable: () => Promise + retireDurable: () => void + recordLegacy: () => Promise + releaseLegacy: () => void +}): Promise { + if (durableApprovals) { + return submitServerOwnedApproval({submit: submitDurable, retire: retireDurable}) + } + await recordAnswerThenRelease({record: recordLegacy, release: releaseLegacy}) + return {durable: false, recoverable: false} +} diff --git a/web/packages/agenta-chat/src/components/ApprovalCard.tsx b/web/packages/agenta-chat/src/components/ApprovalCard.tsx index 71de97553ad..0447c4bf02d 100644 --- a/web/packages/agenta-chat/src/components/ApprovalCard.tsx +++ b/web/packages/agenta-chat/src/components/ApprovalCard.tsx @@ -28,6 +28,8 @@ export interface ApprovalCardProps { responding?: boolean /** The durable response was accepted; the card stays put while records catch up. */ answered?: boolean + /** The durable continuation could not be delivered and will retry on the next Send. */ + recoverable?: boolean /** The agent revision — enables the always-allow row (a draft-config grant). */ entityId?: string /** Show the Redirect (deny + note) entry point — hosts gate it by their own flag. */ @@ -50,6 +52,7 @@ export const ApprovalCard = ({ approvals, responding = false, answered = false, + recoverable = false, entityId, steerEnabled = false, touch = false, @@ -201,7 +204,11 @@ export const ApprovalCard = ({
- {answered ? "Answered, waiting for the agent" : "Needs your approval"} + {answered + ? recoverable + ? "Answer saved, retry needed" + : "Answered, waiting for the agent" + : "Needs your approval"}
@@ -392,7 +399,9 @@ export const ApprovalCard = ({ {answered ? (

- The answer is saved. Waiting for the agent’s next update… + {recoverable + ? "The answer is saved. Send your next message to retry the continuation." + : "The answer is saved. Waiting for the agent’s next update…"}

) : null} {errorText ? ( diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 675f8cc5f68..a0c1a8a7bb6 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -23,6 +23,7 @@ import { respondInteractionAnswerAtom, respondInteractionAnswersAtom, resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, shouldAdoptServerTranscript, @@ -31,6 +32,7 @@ import {markTraceAsFresh} from "@agenta/entities/trace" import {buildRenderMap} from "@agenta/playground" import { agentShouldResumeAfterApproval, + approvalResolution, buildAgentRequest, isResumeSend, recordAnswerThenRelease, @@ -51,7 +53,7 @@ import { type SessionTranscript, } from "../assets/loadSession" import {messageText, sideEffectingToolsInRange} from "../assets/rewind" -import {submitServerOwnedApproval} from "../assets/serverOwnedApproval" +import {submitApprovalForCapability} from "../assets/serverOwnedApproval" import {startupLabelFromDataPart} from "../assets/startupPhases" import {getMessageTraceId} from "../assets/trace" import {isClientToolPart as defaultIsClientToolPart} from "../clientTools" @@ -281,6 +283,7 @@ export const useAgentConversation = ({ const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) + const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches // `onData` and never the transcript — this is the only place the answer survives. A stream that @@ -411,6 +414,7 @@ export const useAgentConversation = ({ stop, regenerate, setMessages, + addToolApprovalResponse, addToolOutput, error, clearError, @@ -636,42 +640,78 @@ export const useAgentConversation = ({ sessionId, }) - // Approval responses flow through the server-owned dispatcher. Retire the local marker even - // on an ambiguous transport error: the server may already have committed the continuation. + // The server capability chooses one owner. Feature-off servers keep the original ordered row + // transition + AI SDK gate release; durable servers own continuation after their 202. const handleApprovalResponse = useCallback( async (args: {id: string; approved: boolean}) => { liveGateInteractionRef.current = {kind: "approval", id: args.id} - await submitServerOwnedApproval({ - submit: () => + return submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({ sessionId, toolCallId: args.id, approved: args.approved, }), - retire: () => { + retireDurable: () => { liveGateInteractionRef.current = null }, + recordLegacy: () => + recordInteractionAnswer({ + sessionId, + toolCallId: args.id, + resolution: approvalResolution(args.id, args.approved), + }), + releaseLegacy: () => addToolApprovalResponse(args), }) }, - [respondInteractionAnswer, sessionId], + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) const handleApprovalResponses = useCallback( async (args: {ids: string[]; approved: boolean}) => { liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} - await submitServerOwnedApproval({ - submit: () => + return submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswers({ sessionId, toolCallIds: args.ids, approved: args.approved, }), - retire: () => { + retireDurable: () => { liveGateInteractionRef.current = null }, + recordLegacy: () => + Promise.all( + args.ids.map((id) => + recordInteractionAnswer({ + sessionId, + toolCallId: id, + resolution: approvalResolution(id, args.approved), + }), + ), + ).then(() => undefined), + releaseLegacy: () => { + for (const id of args.ids) { + addToolApprovalResponse({id, approved: args.approved}) + } + }, }) }, - [respondInteractionAnswers, sessionId], + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswers, + sessionId, + supportsDurableApprovals, + ], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts index 5260177b3e3..1e4e28ba445 100644 --- a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -10,14 +10,20 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" import type {UIMessage} from "ai" +import type {ApprovalSubmissionOutcome} from "../assets/serverOwnedApproval" import {getPendingApprovals, type PendingApproval} from "../model/approvals" +type ApprovalResponse = void | ApprovalSubmissionOutcome + export interface UseApprovalDockArgs { messages: UIMessage[] /** Answer one gate — the host's approval-response path (which marks the resume live). */ - respond: (args: {id: string; approved: boolean}) => void | Promise + respond: (args: {id: string; approved: boolean}) => ApprovalResponse | Promise /** Answer one paused turn's shown gates in a single server transaction. */ - respondAll?: (args: {ids: string[]; approved: boolean}) => void | Promise + respondAll?: (args: { + ids: string[] + approved: boolean + }) => ApprovalResponse | Promise } export interface ApprovalDock { @@ -31,6 +37,8 @@ export interface ApprovalDock { responding: boolean /** The server accepted the durable response; wait for records to replace the parked gate. */ answered: boolean + /** The answer is durable, but delivery needs the user's next Send to retry. */ + recoverable: boolean errorText: string | null /** Answer the current gate. */ respond: (approved: boolean) => void @@ -71,6 +79,7 @@ export const useApprovalDock = ({ const [responding, setResponding] = useState(false) const [answered, setAnswered] = useState(false) + const [recoverable, setRecoverable] = useState(false) const [errorText, setErrorText] = useState(null) // The current gate changed (we answered one, the next slid in) — re-enable. Held during a @@ -78,26 +87,36 @@ export const useApprovalDock = ({ useEffect(() => { setResponding(false) setAnswered(false) + setRecoverable(false) setErrorText(null) }, [current?.approvalId]) - const settle = useCallback(async (responses: (void | Promise)[]) => { - const results = await Promise.allSettled(responses) - const failed = results.find( - (result): result is PromiseRejectedResult => result.status === "rejected", - ) - if (!failed) { - setAnswered(true) - return - } - setResponding(false) - setResolvingIds(null) - setErrorText( - failed.reason instanceof Error - ? failed.reason.message - : "Approval failed. Please try again.", - ) - }, []) + const settle = useCallback( + async (responses: (ApprovalResponse | Promise)[]) => { + const results = await Promise.allSettled(responses) + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + if (!failed) { + setRecoverable( + results.some( + (result) => + result.status === "fulfilled" && result.value?.recoverable === true, + ), + ) + setAnswered(true) + return + } + setResponding(false) + setResolvingIds(null) + setErrorText( + failed.reason instanceof Error + ? failed.reason.message + : "Approval failed. Please try again.", + ) + }, + [], + ) // Once every gate we fired has settled (left the pending set), drop the latch — the dock then // closes if nothing remains, or re-latches onto the uncovered gates (a mixed batch). @@ -132,5 +151,5 @@ export const useApprovalDock = ({ ) }, [responding, shown, onRespond, onRespondAll, settle]) - return {open, current, count, responding, answered, errorText, respond, approveAll} + return {open, current, count, responding, answered, recoverable, errorText, respond, approveAll} } diff --git a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx index 042bd14c278..1b4b411c9e7 100644 --- a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx @@ -134,6 +134,21 @@ describe("durable response state", () => { expect(markup).toContain("Approval failed. Please try again.") expect(markup).toContain(">Approve<") }) + + it("explains a recoverable 202 on the shared desktop and mobile card", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Answer saved, retry needed") + expect(markup).toContain("Send your next message to retry the continuation") + }) }) describe("granting a batch", () => { diff --git a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts index dbd8c8bc7db..66fe373035a 100644 --- a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts @@ -1,6 +1,9 @@ import {describe, expect, it, vi} from "vitest" -import {submitServerOwnedApproval} from "../../../src/assets/serverOwnedApproval" +import { + submitApprovalForCapability, + submitServerOwnedApproval, +} from "../../../src/assets/serverOwnedApproval" describe("submitServerOwnedApproval", () => { it("retires local resume ownership after a successful response", async () => { @@ -22,3 +25,27 @@ describe("submitServerOwnedApproval", () => { expect(retire).toHaveBeenCalledOnce() }) }) + +describe("submitApprovalForCapability", () => { + it("uses the legacy row transition and local gate release when capability is off", async () => { + const submitDurable = vi.fn() + const retireDurable = vi.fn() + const recordLegacy = vi.fn().mockResolvedValue(undefined) + const releaseLegacy = vi.fn() + + await expect( + submitApprovalForCapability({ + durableApprovals: false, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }), + ).resolves.toEqual({durable: false, recoverable: false}) + + expect(submitDurable).not.toHaveBeenCalled() + expect(retireDurable).not.toHaveBeenCalled() + expect(recordLegacy).toHaveBeenCalledOnce() + expect(releaseLegacy).toHaveBeenCalledOnce() + }) +}) diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index c12e60320c9..f6e7ff09580 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -149,6 +149,7 @@ export { respondInteractionAnswerAtom, respondInteractionAnswersAtom, resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, } from "./state/interactionAnswer" export { sessionMountsQueryFamily, diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index 5aef61f75d8..e119d347eec 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -49,6 +49,14 @@ export const resumeSessionContinuationAtom = atom( }, ) +export const sessionDurableApprovalsCapabilityAtom = atom( + null, + async (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + return fetchSessionDurableApprovalsCapability({projectId, sessionId}) + }, +) + /** * Submit an approval through the response endpoint and preserve its failure for the card. * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off server dispatcher @@ -64,7 +72,7 @@ export const respondInteractionAnswerAtom = atom( toolCallId: string approved: boolean }, - ): Promise<{durable: boolean}> => { + ): Promise<{durable: boolean; recoverable: boolean}> => { const {sessionId, toolCallId, approved} = params const projectId = get(projectIdAtom) ?? "" if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") @@ -89,7 +97,10 @@ export const respondInteractionAnswerAtom = atom( }) if (!result) throw new Error("Approval could not be submitted.") await queryClient.invalidateQueries({queryKey: rowsQueryKey}) - return {durable: result.accepted} + return { + durable: result.accepted, + recoverable: result.execution?.state === "recoverable", + } }, ) From fea531c64075c1149313d5107c77c9c235c3d259 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:06:45 +0200 Subject: [PATCH 020/133] fix(mobile): reject cross-execution approval batches --- web/mobile/src/features/chat/approvalTargets.ts | 8 +++++++- web/mobile/tests/unit/approvalTargets.test.ts | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/web/mobile/src/features/chat/approvalTargets.ts b/web/mobile/src/features/chat/approvalTargets.ts index 0d438035308..77d15d2cf4b 100644 --- a/web/mobile/src/features/chat/approvalTargets.ts +++ b/web/mobile/src/features/chat/approvalTargets.ts @@ -32,6 +32,12 @@ export const selectApprovalTargets = ( target: ApprovalTarget, ): SessionInteraction[] => { const pending = (rows ?? []).filter((row) => row.kind === "user_approval" && !!row.id) - if (target.all) return pending + if (target.all) { + const executionIds = new Set(pending.map((row) => row.turn_id ?? null)) + if (executionIds.size > 1) { + throw new Error("Approve all can only answer approvals from one execution.") + } + return pending + } return pending.filter((row) => row.token === target.approvalId) } diff --git a/web/mobile/tests/unit/approvalTargets.test.ts b/web/mobile/tests/unit/approvalTargets.test.ts index ae386fa20f0..c2fe461c104 100644 --- a/web/mobile/tests/unit/approvalTargets.test.ts +++ b/web/mobile/tests/unit/approvalTargets.test.ts @@ -20,13 +20,27 @@ describe("selectApprovalTargets", () => { }) it("returns every pending approval for approve-all", () => { - const rows = [row(), row({id: "int-2", token: "appr-2"})] + const rows = [ + row({turn_id: "turn-1"}), + row({id: "int-2", token: "appr-2", turn_id: "turn-1"}), + ] expect(selectApprovalTargets(rows, {all: true}).map((r) => r.id)).toEqual([ "int-1", "int-2", ]) }) + it("rejects approve-all across executions before posting", () => { + const rows = [ + row({turn_id: "turn-1"}), + row({id: "int-2", token: "appr-2", turn_id: "turn-2"}), + ] + + expect(() => selectApprovalTargets(rows, {all: true})).toThrow( + "Approve all can only answer approvals from one execution.", + ) + }) + it("drops non-approval kinds", () => { const rows = [row({id: "int-3", token: "appr-3", kind: "client_tool"})] expect(selectApprovalTargets(rows, {all: true})).toEqual([]) From a03d5a1a8dc413878c015f312cd9f0385856d17c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:08:17 +0200 Subject: [PATCH 021/133] test(sessions): model conditional watchdog updates --- .../tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py index 7e7d62db863..e5970c42f9b 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py @@ -85,6 +85,7 @@ async def execute(self, stmt): for row in self._rows: if row.id in ids: row.flags = dict(flags_val) + row.updated_at = datetime.now(timezone.utc) matched += 1 return _FakeResult([], rowcount=matched) return _FakeResult([]) From 23038a2ecf6977f21f177913fb5810c875fd172d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:14:37 +0200 Subject: [PATCH 022/133] fix(sessions): disable ORM sync for guarded collapse From 95195f9ebad9a3e773db046043f399477f84955e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:15:17 +0200 Subject: [PATCH 023/133] test(chat): preserve recoverable approval state --- .../tests/unit/hooks/useApprovalDock.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index 5452c8a8add..ae0ec49a9b6 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -165,4 +165,16 @@ describe("useApprovalDock", () => { act(() => result.current.respond(false)) expect(respond).toHaveBeenCalledTimes(2) }) + + it("preserves a recoverable durable response for the shared card", async () => { + const respond = vi.fn(() => Promise.resolve({durable: true, recoverable: true})) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + await act(async () => result.current.respond(true)) + + expect(result.current.answered).toBe(true) + expect(result.current.recoverable).toBe(true) + }) }) From 24362fbb0217b0dbe54920cbe73443ef0d00fa86 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:16:07 +0200 Subject: [PATCH 024/133] test(sessions): enforce commit before Redis release --- .../sessions/test_orphan_sweep_thresholds.py | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index b6df0d94d75..765b511bd01 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -162,9 +162,10 @@ def scalars(self): class _FakePgSession: - def __init__(self, rows, before_update=None): + def __init__(self, rows, before_update=None, on_commit=None): self._rows = rows self._before_update = before_update + self._on_commit = on_commit async def execute(self, stmt): if isinstance(stmt, Update): @@ -185,17 +186,22 @@ async def execute(self, stmt): return _FakeResult(matched) async def commit(self): - pass + if self._on_commit is not None: + self._on_commit() class _FakeTransactionsEngine: def __init__(self, rows, before_update=None): self._rows = rows self._before_update = before_update + self.committed = False + + def _mark_committed(self): + self.committed = True @asynccontextmanager async def session(self): - yield _FakePgSession(self._rows, self._before_update) + yield _FakePgSession(self._rows, self._before_update, self._mark_committed) class _FakeRedis: @@ -249,6 +255,20 @@ def decode(value): return [released_alive, released_running, released_owner] +class _CommitObservingRedis(_FakeRedis): + def __init__(self, engine: _FakeTransactionsEngine): + super().__init__() + self.engine = engine + + async def eval(self, script, numkeys, key, expected, *args): + normalized = key.decode() if isinstance(key, bytes) else key + if normalized.startswith(("alive:", "running:", "owner:")): + assert self.engine.committed, ( + "watchdog released Redis before the row commit" + ) + return await super().eval(script, numkeys, key, expected, *args) + + def _swept(row: _FakeRow) -> bool: return row.flags == {"is_alive": False, "is_running": False, "is_attached": False} @@ -322,6 +342,33 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): ) +@pytest.mark.anyio +async def test_redis_release_happens_only_after_stream_collapse_commits( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-commit-before-redis", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-old", + ) + engine = _FakeTransactionsEngine([row]) + redis = _CommitObservingRedis(engine) + for prefix, value in ( + ("alive", b"turn-old"), + ("running", b"turn-old"), + ("owner", b"replica-old"), + ): + await redis.set(f"{prefix}:{_PROJECT_ID}:session:{row.session_id}", value) + + await run_orphan_sweep(engine, redis) + + assert engine.committed is True + assert _swept(row) + + @pytest.mark.anyio async def test_durable_sweep_clears_dead_affinity_when_alive_already_expired( anyio_backend, From d58265be7251fb1e8e5166aeea004385584acd89 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:39:17 +0200 Subject: [PATCH 025/133] fix(sessions): collapse turns settled lost during sweep --- .../tasks/asyncio/sessions/orphan_sweep.py | 19 +++++++-- .../sessions/test_orphan_sweep_thresholds.py | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 3c39b6310fb..020c9dc1103 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -595,6 +595,18 @@ async def run_orphan_sweep( # Win the stale stream generation before settling its execution or publishing records. # The update and execution settlement share this transaction; an exception rolls both # back, while record ids make a publish-before-commit retry idempotent. + # A turn this pass is settling as LOST must reach rest even when the row's + # `updated_at` moved inside this same pass. The command settlement and the record + # publish above both write through this transaction, so the advance can be our own + # write rather than a sign of life. The `turn_id` guard still protects a row that + # advanced to a NEWER turn, which is the case the timestamp guard exists for. + settled_lost = { + key + for key in unsettled + if env.agenta.sessions.durable_approvals + and terminal_outcomes.get(key) != "stopped" + } + collapsed_flags = SessionStreamFlags( is_alive=False, is_running=False, is_attached=False ).model_dump(mode="json") @@ -619,12 +631,13 @@ async def run_orphan_sweep( if turn_id is not None else SessionStreamDBE.turn_id.is_(None) ), - ( + ] + if (project_uuid, session_id, turn_id) not in settled_lost: + conditions.append( SessionStreamDBE.updated_at == observed_updated_at if observed_updated_at is not None else SessionStreamDBE.updated_at.is_(None) - ), - ] + ) result = await session.execute( sa_update(SessionStreamDBE) .where(*conditions) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 765b511bd01..16a321cea49 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -16,6 +16,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone, timedelta from typing import Optional +from uuid import UUID import pytest from sqlalchemy.sql import operators @@ -299,6 +300,14 @@ async def runner_completed_turns(self, *, project_id, keys): return set(keys) +class _NoCompletedRecords(_CompletedRecords): + async def settled_turns(self, *, project_id, keys): + return set() + + async def runner_completed_turns(self, *, project_id, keys): + return set() + + class _CompletionLookupFailure(_CompletedRecords): async def runner_completed_turns(self, *, project_id, keys): raise RuntimeError("records database unavailable") @@ -342,6 +351,36 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): ) +@pytest.mark.anyio +async def test_turn_settled_lost_by_this_pass_collapses_after_timestamp_advance( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-settled-during-sweep", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-lost", + ) + row.project_id = UUID("00000000-0000-4000-8000-000000000001") + + def advance_timestamp_only(): + row.updated_at = datetime.now(timezone.utc) + + async def publish(**_kwargs): + return False + + await run_orphan_sweep( + _FakeTransactionsEngine([row], before_update=advance_timestamp_only), + _FakeRedis(), + records_service=_NoCompletedRecords(), + publish=publish, + ) + + assert _swept(row) + + @pytest.mark.anyio async def test_redis_release_happens_only_after_stream_collapse_commits( anyio_backend, From abe27ddf94cbd5af9fd7c2e049571dc94931746e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:40:22 +0200 Subject: [PATCH 026/133] fix(web): use generated session capabilities type --- .../src/generated/api/types/SessionCapabilities.ts | 5 +++++ .../src/generated/api/types/SessionStreamResponse.ts | 2 +- .../agenta-api-client/src/generated/api/types/index.ts | 1 + .../tests/unit/session-continuation-resume-api.test.ts | 9 ++++++++- 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts new file mode 100644 index 00000000000..879fde8d3bd --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionCapabilities { + durable_approvals?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts index f3ce051a45f..8ffc7f3e09d 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts @@ -4,5 +4,5 @@ import type * as AgentaApi from "../index.js"; export interface SessionStreamResponse { stream?: (AgentaApi.SessionStream | null) | undefined; - capabilities?: { durable_approvals?: boolean | undefined } | undefined; + capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index af3f0a9ec01..7edb941ab86 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -392,6 +392,7 @@ export * from "./SessionInteractionQuery.js"; export * from "./SessionInteractionQueryFlags.js"; export * from "./SessionInteractionRequest.js"; export * from "./SessionInteractionResponse.js"; +export * from "./SessionCapabilities.js"; export * from "./SessionContinuationResumeResponse.js"; export * from "./SessionInteractionStatus.js"; export * from "./SessionInteractionsResponse.js"; diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 91c39d8fbc3..5855c2adec8 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -1,4 +1,5 @@ -import {beforeEach, describe, expect, it, vi} from "vitest" +import type {SessionCapabilities, SessionStreamResponse} from "@agentaai/api-client" +import {beforeEach, describe, expect, expectTypeOf, it, vi} from "vitest" const {resume, fetchStream} = vi.hoisted(() => ({resume: vi.fn(), fetchStream: vi.fn()})) @@ -57,6 +58,12 @@ describe("resumeSessionContinuation", () => { }) describe("fetchSessionDurableApprovalsCapability", () => { + it("uses the generated named capability model", () => { + expectTypeOf< + NonNullable + >().toEqualTypeOf() + }) + it("uses the authenticated session response as the capability source", async () => { fetchStream.mockResolvedValue({ stream: null, From 3291c10727dcf7184f13ad14aac36aab449bf53e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:43:50 +0200 Subject: [PATCH 027/133] fix(web): cache durable approval capability --- .../src/features/chat/useSessionWatch.ts | 2 + .../hooks/useSessionRecordsWatch.ts | 8 ++- .../agenta-entities/src/session/api/api.ts | 55 ++++++++++++++----- .../agenta-entities/src/session/index.ts | 1 + .../session-continuation-resume-api.test.ts | 23 ++++++++ 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/web/mobile/src/features/chat/useSessionWatch.ts b/web/mobile/src/features/chat/useSessionWatch.ts index d95cdde7e48..e1b1f76a267 100644 --- a/web/mobile/src/features/chat/useSessionWatch.ts +++ b/web/mobile/src/features/chat/useSessionWatch.ts @@ -1,6 +1,7 @@ import {useEffect, useRef, useState} from "react" import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" +import {invalidateSessionDurableApprovalsCapability} from "@agenta/entities/session" import {useQueryClient} from "@tanstack/react-query" import {tryRefreshSession} from "@/lib/auth" @@ -120,6 +121,7 @@ export const useSessionWatch = ({ // headers reach us before the server's Redis subscription is live, so a // change landing in that window would miss both this refetch and the stream. es.addEventListener("ready", () => { + invalidateSessionDurableApprovalsCapability({projectId, sessionId}) notifyOnConnect() invalidateBadges() }) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts index bf1db11cbce..b3e89d7decf 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts @@ -1,6 +1,7 @@ import {useRef} from "react" import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" +import {invalidateSessionDurableApprovalsCapability} from "@agenta/entities/session" import {useWatchEventSource} from "@agenta/sessions/watch" import {useQueryClient} from "@tanstack/react-query" @@ -62,7 +63,12 @@ export const useSessionRecordsWatch = ({ enabled, refreshSession, on: { - ready: onReady, + ready: () => { + if (projectId) { + invalidateSessionDurableApprovalsCapability({projectId, sessionId}) + } + onReady() + }, "records-changed": () => { onRecordsChanged() refreshLegacyObserverLiveness() diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 1cfed2fd461..88e23326764 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -172,6 +172,21 @@ export async function fetchSessionSnapshot({ return safeParseWithLogging(sessionSnapshotSchema, data, "[fetchSessionSnapshot]") } +const durableApprovalsCapabilityCache = new Map>() + +const durableApprovalsCapabilityKey = ({projectId, sessionId}: SessionScopedParams): string => + JSON.stringify([projectId, sessionId]) + +export function invalidateSessionDurableApprovalsCapability( + params?: Pick, +): void { + if (!params) { + durableApprovalsCapabilityCache.clear() + return + } + durableApprovalsCapabilityCache.delete(durableApprovalsCapabilityKey(params)) +} + export interface QueryInteractionsParams extends Omit { /** Omit for a PROJECT-WIDE query — the backend treats `session_id` as optional, so one call * returns every matching interaction across the project (the pending-approvals badge @@ -686,19 +701,33 @@ export async function fetchSessionDurableApprovalsCapability({ }: SessionScopedParams): Promise { if (!projectId || !sessionId) return false - const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => - getSessionsClient().fetchSessionStream( - {session_id: sessionId}, - projectScopedRequest(projectId, appId, abortSignal), - ), - ) - if (!data) return false - const validated = safeParseWithLogging( - sessionStreamResponseSchema, - data, - "[fetchSessionDurableApprovalsCapability]", - ) - return validated?.capabilities.durable_approvals ?? false + const key = durableApprovalsCapabilityKey({projectId, sessionId}) + const cached = durableApprovalsCapabilityCache.get(key) + if (cached) return cached + + let request: Promise | undefined + request = (async () => { + const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => + getSessionsClient().fetchSessionStream( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + if (!data) { + if (durableApprovalsCapabilityCache.get(key) === request) { + durableApprovalsCapabilityCache.delete(key) + } + return false + } + const validated = safeParseWithLogging( + sessionStreamResponseSchema, + data, + "[fetchSessionDurableApprovalsCapability]", + ) + return validated?.capabilities.durable_approvals ?? false + })() + durableApprovalsCapabilityCache.set(key, request) + return request } export interface CommandSessionStreamParams extends SessionScopedParams { diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index f6e7ff09580..5bfd69701df 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -20,6 +20,7 @@ export { setSessionHeader, fetchSessionStream, fetchSessionDurableApprovalsCapability, + invalidateSessionDurableApprovalsCapability, commandSessionStream, cancelSessionExecution, cancelSessionStream, diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 5855c2adec8..1d5f5bc6626 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -15,12 +15,14 @@ vi.mock("@agenta/sdk/resources", () => ({ import { fetchSessionDurableApprovalsCapability, + invalidateSessionDurableApprovalsCapability, resumeSessionContinuation, } from "../../src/session/api/api" beforeEach(() => { resume.mockReset() fetchStream.mockReset() + invalidateSessionDurableApprovalsCapability() }) describe("resumeSessionContinuation", () => { @@ -78,6 +80,27 @@ describe("fetchSessionDurableApprovalsCapability", () => { ).resolves.toBe(true) }) + it("shares one request per session until the session reconnects", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: true}, + }) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await Promise.all([ + fetchSessionDurableApprovalsCapability(scope), + fetchSessionDurableApprovalsCapability(scope), + ]) + await fetchSessionDurableApprovalsCapability(scope) + + expect(fetchStream).toHaveBeenCalledTimes(1) + + invalidateSessionDurableApprovalsCapability(scope) + await fetchSessionDurableApprovalsCapability(scope) + + expect(fetchStream).toHaveBeenCalledTimes(2) + }) + it.each([ ["older API", {stream: null}], ["failed request", null], From 3bc5b970e7f1491b78bf3d162ba0b0228785febe Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 17:44:43 +0200 Subject: [PATCH 028/133] fix(sessions): return heartbeat after guard lease loss --- api/oss/src/core/sessions/streams/service.py | 26 ++++++ api/oss/src/dbs/redis/sessions/locks.py | 91 ++++++++++++++++++- .../sessions/test_heartbeat_lock_races.py | 33 +++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/sessions/streams/service.py b/api/oss/src/core/sessions/streams/service.py index 9d0df4d577e..6eff4a7b0b2 100644 --- a/api/oss/src/core/sessions/streams/service.py +++ b/api/oss/src/core/sessions/streams/service.py @@ -28,6 +28,7 @@ ) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface from oss.src.dbs.redis.sessions.locks import ( + SessionHeartbeatGuardLost, acquire_alive_with_start, acquire_running, claim_owner, @@ -35,6 +36,7 @@ clear_owner, displace_turns, release_running, + session_heartbeat_guard, force_clear_owner, get_alive_owner, get_owner, @@ -551,6 +553,30 @@ async def heartbeat( *, project_id: UUID, request: SessionHeartbeatRequest, + ) -> SessionHeartbeatResult: + _validate_session_id(request.session_id) + async with session_heartbeat_guard( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + ) as guard: + result = await self._heartbeat_locked( + project_id=project_id, request=request + ) + try: + guard.ensure_held() + except SessionHeartbeatGuardLost: + log.warning( + "sessions: heartbeat guard lease lost after heartbeat committed", + session_id=request.session_id, + ) + return result + + async def _heartbeat_locked( + self, + *, + project_id: UUID, + request: SessionHeartbeatRequest, ) -> SessionHeartbeatResult: """Refresh the nest, mirror it onto the row, and fill what the row still lacks. diff --git a/api/oss/src/dbs/redis/sessions/locks.py b/api/oss/src/dbs/redis/sessions/locks.py index a3bc900d26b..fbe3c9b59b9 100644 --- a/api/oss/src/dbs/redis/sessions/locks.py +++ b/api/oss/src/dbs/redis/sessions/locks.py @@ -5,9 +5,14 @@ every key name, TTL, and wire shape. """ +import asyncio import json -from typing import List, Optional, Tuple +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import AsyncIterator, List, Optional, Tuple +from uuid import uuid4 +from oss.src.utils.logging import get_module_logger from oss.src.dbs.redis.shared.engine import LockEngine from oss.src.dbs.redis.sessions.contract import ( ALIVE_TTL_SECONDS, @@ -35,12 +40,96 @@ validate_session_id, # noqa: F401 — re-exported for callers that import from locks ) +log = get_module_logger(__name__) + +_RENEW_IF_OWNER_LUA = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('expire', KEYS[1], ARGV[2]) +end +return 0 +""" + + +class SessionHeartbeatGuardLost(RuntimeError): + pass + + +@dataclass +class SessionHeartbeatGuardLease: + session_id: str + lost: bool = False + + def ensure_held(self) -> None: + if self.lost: + raise SessionHeartbeatGuardLost( + f"heartbeat guard lease was lost for session {self.session_id}" + ) + # --------------------------------------------------------------------------- # Alive lock — global run lock (at most one in-flight run per session) # --------------------------------------------------------------------------- +@asynccontextmanager +async def session_heartbeat_guard( + engine: LockEngine, + *, + project_id: str, + session_id: str, + lease_seconds: int = 30, + renewal_seconds: float = 10.0, + wait_seconds: float = 5.0, +) -> AsyncIterator[SessionHeartbeatGuardLease]: + """Serialize heartbeat ownership changes with watchdog fencing for one session.""" + key = f"heartbeat-guard:{project_id}:session:{session_id}" + token = str(uuid4()).encode() + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_seconds + while await engine.set(key, token, nx=True, ex=lease_seconds) is None: + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(f"heartbeat guard timed out for session {session_id}") + await asyncio.sleep(0.01) + + lease = SessionHeartbeatGuardLease(session_id=session_id) + renewed_at = loop.time() + + async def renew() -> None: + nonlocal renewed_at + while True: + await asyncio.sleep(renewal_seconds) + try: + renewed = await engine.eval( + _RENEW_IF_OWNER_LUA, + 1, + key.encode(), + token, + str(lease_seconds).encode(), + ) + except Exception: + log.warning( + "heartbeat guard renewal failed; retrying before lease expiry", + session_id=session_id, + exc_info=True, + ) + if loop.time() - renewed_at >= lease_seconds: + lease.lost = True + return + continue + if renewed != 1: + lease.lost = True + return + renewed_at = loop.time() + + renewal = asyncio.create_task(renew()) + try: + yield lease + finally: + renewal.cancel() + await asyncio.gather(renewal, return_exceptions=True) + await engine.eval(RELEASE_IF_OWNER_LUA, 1, key.encode(), token) + + async def acquire_alive( engine: LockEngine, *, diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py index 7fd1bca0521..591f48c3f4f 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py @@ -7,6 +7,7 @@ than by a comment. """ +from contextlib import asynccontextmanager from typing import Optional from unittest.mock import AsyncMock, patch from uuid import uuid4 @@ -20,6 +21,7 @@ ) from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.dbs.redis.sessions.locks import ( + SessionHeartbeatGuardLost, force_clear_owner, get_alive_owner, get_owner, @@ -90,6 +92,37 @@ async def _superseded(lock_engine, turn: str) -> bool: # --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_heartbeat_returns_committed_result_when_guard_lease_is_lost(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + class _LostGuard: + def ensure_held(self): + raise SessionHeartbeatGuardLost("lease expired") + + @asynccontextmanager + async def _lost_guard(*_args, **_kwargs): + yield _LostGuard() + + with ( + patch( + "oss.src.core.sessions.streams.service.session_heartbeat_guard", + new=_lost_guard, + ), + patch("oss.src.core.sessions.streams.service.log.warning") as warning, + ): + result = await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-a")) + + assert result.is_current_turn is True + assert dao.row is result.stream + assert dao.row is not None and dao.row.turn_id == "turn-a" + warning.assert_called_once_with( + "sessions: heartbeat guard lease lost after heartbeat committed", + session_id=_SESSION, + ) + + @pytest.mark.asyncio async def test_a_dead_turns_beat_does_not_reclaim_replica_affinity(lock_engine): """`claim_owner` never steals, so an owner key that keeps getting renewed locks the From d78ad56f67d7c6f3704d73940cdb0439f3f80bc7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 19:11:43 +0200 Subject: [PATCH 029/133] fix(sessions): resolve a resume's references from the session Symptom: a user approves a tool call, POST /sessions/interactions//respond returns 202, and the turn never continues. The API log repeats "control delivery unreachable ... Workflow revision has no runnable service URL." three times over six minutes, then the command settles obsolete and the card sits on "Answered, waiting for the agent". Cause: a durable continuation is a server-side invoke, and an invoke finds its service URL only through the request's references. _ensure_request_revision returns at once when the request carries neither data.revision nor references, so _get_service_url gets no revision and returns None. The dispatcher took the references from the gate row alone. A session whose first Send carried no references stamps none on the gate row, so the invoke had no URL and every redelivery failed. Fix: InteractionsDispatcher._session_references resolves the identity from the gate row first, then from the session's own session_turns.references, then from session_streams.references. The new keyed_references helper folds the stored flat list back into the keyed map an invoke carries, and drops any family _validate_execution_reference_families would reject. Both reads are best effort and read-only: a read that fails logs and falls through, because the continuation is already durable. routers.py passes the existing turns and streams services in. Five tests cover the turn fallback, the stream fallback, the gate row winning over both, a session with no identity anywhere, and the helper's family filter. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/entrypoints/routers.py | 4 + .../sessions/interactions_dispatcher.py | 118 +++++++++- .../sessions/test_interactions_dispatcher.py | 219 ++++++++++++++++++ 3 files changed, 340 insertions(+), 1 deletion(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index d0dc96eadb1..d4eba636985 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -893,6 +893,10 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - workflows_service=workflows_service, interactions_service=interactions_service, records_service=records_service, + # Read-only: the resume's reference fallback, for a gate row whose own `data.references` is + # empty. Without it the invoke has nothing to resolve a service URL from. + turns_service=session_turns_service, + streams_service=session_streams_service, dispatch_fn=_dispatch_detached_run, ) diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 1cc81dd75ec..06bd9446c39 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -17,6 +17,14 @@ so the run continues under the config the gate was raised against rather than the referenced variant's HEAD revision. A row written before that field existed has none, and the body is byte-identical to the references-only one this dispatcher has always sent. + +``references`` are not decoration on this request: they are how the invoke finds a service to +call at all (``WorkflowsService._ensure_request_revision`` resolves them into +``data.revision``, and ``_get_service_url`` reads the URL off it). A gate row whose +``data.references`` is empty therefore produces an invoke with no service URL, which fails +``Workflow revision has no runnable service URL.`` on every redelivery. The same identity is +also recorded on the session's turn and stream rows, so this dispatcher falls back to those +before giving up. """ from typing import Any, Callable, Dict, List, Optional @@ -30,6 +38,10 @@ from oss.src.core.sessions.records.dtos import SessionRecord from oss.src.core.sessions.records.service import RecordsService from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.turns.dtos import SessionTurnQuery +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.types import SessionReference from oss.src.core.workflows.dtos import ( WorkflowServiceRequest, WorkflowServiceRequestData, @@ -41,6 +53,48 @@ log = get_module_logger(__name__) +# The reference keys `WorkflowsService._validate_execution_reference_families` accepts. A stored +# session reference list is untyped on purpose (a turn append is fire-and-forget, so rejecting an +# unknown family would drop the whole turn), which is why anything else is dropped here instead +# of being sent into a 400. +_EXECUTION_REFERENCE_KEYS = frozenset( + { + "workflow", + "workflow_variant", + "workflow_revision", + "application", + "application_variant", + "application_revision", + "evaluator", + "evaluator_variant", + "evaluator_revision", + } +) + + +def keyed_references( + elements: Optional[List[SessionReference]], +) -> Optional[Dict[str, Any]]: + """Fold a stored flat reference list back into the keyed map an invoke carries. + + Sessions persist references as a flat list whose family lives in each element's ``key`` + (``session_turns.references``, ``session_streams.references``). An invoke carries the same + identity as a map keyed by family, so the fold is the whole conversion. + """ + if not elements: + return None + keyed: Dict[str, Any] = {} + for element in elements: + key = getattr(element, "key", None) + if key not in _EXECUTION_REFERENCE_KEYS or key in keyed: + continue + reference = element.model_dump(mode="json", exclude_none=True) + reference.pop("key", None) + if reference: + keyed[key] = reference + return keyed or None + + def _user_attachment_blocks(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: """Attachment blocks for one user record, in the runner's wire shape. @@ -313,13 +367,72 @@ def __init__( workflows_service: WorkflowsService, interactions_service: SessionInteractionsService, records_service: Optional[RecordsService] = None, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, dispatch_fn: Optional[Callable] = None, ) -> None: self.workflows_service = workflows_service self.interactions_service = interactions_service self.records_service = records_service + # Read-only, for the resume's reference fallback: the identity a session recorded on its + # turn and stream rows when the gate row carries none. + self.turns_service = turns_service + self.streams_service = streams_service self._dispatch_fn = dispatch_fn + async def _session_references( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[Dict[str, Any]]: + """The session's own workflow identity, for a gate row that carries none. + + WHY THIS EXISTS. A resume is a server-side invoke, and the invoke resolves its service + URL from the request's references (`WorkflowsService._ensure_request_revision` -> + `_get_service_url`). A gate row written before `data.references` existed, or by a turn + whose run context had no workflow identity yet, leaves the resume with nothing to + resolve and the continuation fails `Workflow revision has no runnable service URL.` + forever. The turn and stream rows of the SAME session carry the identity the platform + resolved for that run, so read it from there rather than failing. + + Best effort by design: the resume is already durable, and a read that fails here must + not turn into a failed continuation. A session that recorded no identity anywhere still + cannot be resumed server-side; that case is the caller's to report. + """ + if self.turns_service is not None: + try: + turns = await self.turns_service.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=session_id), + ) + except Exception as e: # noqa: BLE001 - fallback read is best effort + log.warning( + f"[interactions] turn references unavailable for session={session_id}: {e}" + ) + turns = [] + for turn in turns or []: + references = keyed_references(turn.references) + if references: + return references + + if self.streams_service is not None: + try: + stream = await self.streams_service.fetch_header( + project_id=project_id, + session_id=session_id, + ) + except Exception as e: # noqa: BLE001 - fallback read is best effort + log.warning( + f"[interactions] stream references unavailable for " + f"session={session_id}: {e}" + ) + stream = None + if stream is not None: + return keyed_references(stream.references) + + return None + async def _compose_inputs( self, *, @@ -392,7 +505,10 @@ async def respond_many( references = ( {k: v.model_dump(mode="json") for k, v in data.references.items()} if data and data.references - else None + else await self._session_references( + project_id=project_id, + session_id=interaction.session_id, + ) ) selector = ( data.selector.model_dump(mode="json") if data and data.selector else None diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 7625b8a3a89..47c3b6f2b63 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -763,3 +763,222 @@ def test_a_user_record_with_neither_text_nor_attachments_is_skipped(): ] assert build_wire_messages(records) == [] + + +# --------------------------------------------------------------------------- +# The resume's reference fallback. +# +# A resume is a server-side invoke, and the invoke resolves its service URL from the request's +# references. A gate row with no `data.references` produced a request with none, so +# `WorkflowsService._prepare_invoke` returned no URL and every redelivery failed with +# "Workflow revision has no runnable service URL." The same identity is recorded on the +# session's turn and stream rows, so the resume reads it from there. +# --------------------------------------------------------------------------- + + +def _session_turn(project_id, *, references, session_id="sess-test-1"): + from agenta.sdk.agents.dtos import HarnessKind + from oss.src.core.sessions.turns.dtos import SessionTurn + + return SessionTurn( + id=uuid4(), + project_id=project_id, + session_id=session_id, + turn_id=uuid4(), + stream_id=uuid4(), + turn_index=0, + harness_kind=HarnessKind.PI, + references=references, + ) + + +def _session_stream(project_id, *, references, session_id="sess-test-1"): + from oss.src.core.sessions.streams.dtos import SessionStream + + return SessionStream( + id=uuid4(), + project_id=project_id, + session_id=session_id, + references=references, + ) + + +def _turns_service(turns): + service = MagicMock() + service.query_turns = AsyncMock(return_value=turns) + return service + + +def _streams_service(stream): + service = MagicMock() + service.fetch_header = AsyncMock(return_value=stream) + return service + + +async def test_resume_falls_back_to_the_turn_references_when_the_gate_row_has_none(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + turns_service = _turns_service( + [ + _session_turn(project_id, references=None), + _session_turn( + project_id, + references=[ + SessionReference(key="workflow", id=uuid4(), slug="wf-1"), + SessionReference( + key="workflow_variant", id=uuid4(), slug="wf-1.default" + ), + ], + ), + ] + ) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references or {}) == {"workflow", "workflow_variant"} + assert invoke_request.references["workflow"].slug == "wf-1" + # `key` names the family in the stored list; it is not a field of a wire Reference. + assert not hasattr(invoke_request.references["workflow"], "key") + + +async def test_resume_falls_back_to_the_stream_references_when_no_turn_carries_any(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=_turns_service([_session_turn(project_id, references=None)]), + streams_service=_streams_service( + _session_stream( + project_id, + references=[SessionReference(key="workflow", id=uuid4(), slug="wf-2")], + ) + ), + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references) == {"workflow"} + assert invoke_request.references["workflow"].slug == "wf-2" + + +async def test_the_gate_rows_own_references_win_over_the_session_fallback(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=True) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + turns_service = _turns_service( + [ + _session_turn( + project_id, + references=[SessionReference(key="workflow", id=uuid4(), slug="other")], + ) + ] + ) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references) == {"workflow"} + assert invoke_request.references["workflow"].slug == "wf-1" + turns_service.query_turns.assert_not_awaited() + + +async def test_a_session_with_no_recorded_identity_still_sends_a_reference_less_request(): + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=_turns_service([]), + streams_service=_streams_service(None), + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert invoke_request.references is None + + +def test_keyed_references_drops_families_the_invoke_does_not_accept(): + from oss.src.core.sessions.types import SessionReference + from oss.src.tasks.asyncio.sessions.interactions_dispatcher import keyed_references + + assert ( + keyed_references( + [ + SessionReference(key="testset", slug="ts-1"), + SessionReference(key=None, slug="untyped"), + ] + ) + is None + ) + assert keyed_references([SessionReference(key="application", slug="app-1")]) == { + "application": {"slug": "app-1"} + } From e29364f20bd3911f688ab75216cf2cbf7ec23451 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 19:11:52 +0200 Subject: [PATCH 030/133] fix(web): keep the approval outcome the card reads Symptom: after an answer the approval card always showed "Answered, waiting for the agent", even when the server had marked the continuation recoverable. The user never saw "Answer saved, retry needed", so the one state that asks for an action stayed invisible. Cause: handleApprovalResponse returned answerApproval(...).then(() => { ... }). The arrow body returned nothing, so the promise resolved to undefined and the dock's result.value?.recoverable was always false. Fix: the ordered click is now the extracted answerThenSteer helper. It answers the gate, sends a denial's steer note after the answer as before, and returns the submission outcome to the caller. Four tests pin the return value, the steer ordering, the approve-side suppression, and the blank-note case. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 12 ++- .../assets/answerThenSteer.test.ts | 74 +++++++++++++++++++ .../AgentChatSlice/assets/answerThenSteer.ts | 33 +++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts create mode 100644 web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 7de54275bbc..0c0d693eb54 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -55,6 +55,7 @@ import {DriveFileLinkProvider} from "@/oss/components/Drives/DriveFileLinkProvid import {useSessionFilesPane} from "@/oss/components/Drives/SessionFilesPane" import {TEMPLATE_STRIP_MODE} from "@/oss/components/pages/agent-home/assets/constants" +import {answerThenSteer} from "./assets/answerThenSteer" import {isAgentFileUploadsEnabled} from "./assets/constants" import {CONTENT_VISIBILITY_ENABLED} from "./assets/conversationLayout" import {runWithInFlightSubmit} from "./assets/inFlightSubmit" @@ -417,10 +418,13 @@ const AgentConversation = ({ // (The model still reasons about the bare denial first — the "flail" — because the // harness owns the reject continuation and exposes no reject-with-feedback seam; killing // that flail needs an upstream ACP change, not an FE one.) - const steer = args.message?.trim() - return answerApproval(args.id, args.approved).then(() => { - // After the answer for the same reason the flip is: a steer starts its own turn. - if (!args.approved && steer) submit({text: steer}) + // The outcome is RETURNED, not swallowed: the dock reads `recoverable` off it to show + // "Answer saved, retry needed" instead of "Answered, waiting for the agent". + return answerThenSteer({ + approved: args.approved, + message: args.message, + answer: () => answerApproval(args.id, args.approved), + steer: (text) => submit({text}), }) }, [answerApproval, markLiveGate, submit], diff --git a/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts new file mode 100644 index 00000000000..9ff0b23a2b7 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts @@ -0,0 +1,74 @@ +/** + * The approval click keeps the submission outcome. + * + * The dock decides between "Answer saved, retry needed" and "Answered, waiting for the agent" from + * the `recoverable` flag on the value this wrapper resolves to. A wrapper that answered the gate + * and resolved to `undefined` showed a healthy card over a continuation the server could not + * deliver, which is the state the user has to act on. + */ +import {describe, expect, it, vi} from "vitest" + +import {answerThenSteer} from "./answerThenSteer" + +describe("answerThenSteer", () => { + it("resolves to the submission outcome, so a recoverable answer reaches the card", async () => { + const outcome = await answerThenSteer({ + approved: true, + answer: async () => ({durable: true, recoverable: true}), + steer: () => undefined, + }) + + expect(outcome).toEqual({durable: true, recoverable: true}) + }) + + it("still resolves to the outcome when a denial also sends a steer note", async () => { + const steer = vi.fn() + + const outcome = await answerThenSteer({ + approved: false, + message: " use the staging bucket ", + answer: async () => ({durable: true, recoverable: false}), + steer, + }) + + expect(outcome).toEqual({durable: true, recoverable: false}) + expect(steer).toHaveBeenCalledWith("use the staging bucket") + }) + + it("sends the steer note only after the answer, and only on a denial", async () => { + const order: string[] = [] + const steer = vi.fn(() => order.push("steer")) + + await answerThenSteer({ + approved: false, + message: "stop", + answer: async () => { + order.push("answer") + }, + steer, + }) + expect(order).toEqual(["answer", "steer"]) + + steer.mockClear() + await answerThenSteer({ + approved: true, + message: "stop", + answer: async () => undefined, + steer, + }) + expect(steer).not.toHaveBeenCalled() + }) + + it("ignores a blank note", async () => { + const steer = vi.fn() + + await answerThenSteer({ + approved: false, + message: " ", + answer: async () => undefined, + steer, + }) + + expect(steer).not.toHaveBeenCalled() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts new file mode 100644 index 00000000000..ba1c3b9b002 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts @@ -0,0 +1,33 @@ +/** + * Answer one approval gate, then start the steer turn a denial carries — and hand the submission + * outcome back to the caller. + * + * The outcome is the whole point of the return value. The dock reads `recoverable` off it to tell + * "Answer saved, retry needed" from "Answered, waiting for the agent", so a wrapper that answers + * the gate and returns nothing makes an undeliverable continuation look like a healthy one for as + * long as the card stays open. Extracted so that contract has a test of its own. + * + * A steer note is sent only with a DENIAL, and only after the answer, because resuming a parked + * gate makes the harness continue the original prompt: a note fused into that resume is + * subordinated to the original intent. As its own turn it drives the redirect. + */ +import type {ApprovalSubmissionOutcome} from "@agenta/chat/assets" + +export type ApprovalAnswerResult = void | ApprovalSubmissionOutcome + +export async function answerThenSteer({ + approved, + message, + answer, + steer, +}: { + approved: boolean + message?: string + answer: () => Promise + steer: (text: string) => void +}): Promise { + const note = message?.trim() + const outcome = await answer() + if (!approved && note) steer(note) + return outcome +} From b04d8161a8ff77d0578db16d0b6cbcebc8efaff2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 20:02:16 +0200 Subject: [PATCH 031/133] fix(workflows): read the service wire on a detached start Every durable approval continuation reported "control delivery unreachable" with "Workflow service emitted an unknown record before detached start", while the runner admitted the continuation and ran the turn to completion. Two executions reached terminal/completed and still carried continuation_delivery_failed. The strict start parser required the first NDJSON record to carry kind "event" or a successful kind "result". That is the RUNNER's vocabulary. The API does not call the runner. It calls the deployed workflow service, which re-frames every runner record as an agenta event, {"type", "data"}, with no kind field anywhere. So the strict branch rejected the first record of every continuation, always. _stream_service_started now accepts the first JSON object as the started handshake. A new _detached_start_failure rejects only an explicit failure frame, and reads one in either vocabulary: the service's {"type": "error"}, and the runner's {"kind": "result", "result": {"ok": false}} where a deployment forwards runner records verbatim. The tests replay the record sequences from the browser pass of 2026-09-04. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/workflows/service.py | 77 +++++++++++++----- .../unit/workflows/test_invoke_detached.py | 78 ++++++++++++++++++- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 75a6490a9f6..9e570512409 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -756,12 +756,17 @@ async def _stream_service_started( ) -> WorkflowServiceDetachedResponse: """Stream the service ``/invoke`` and return on the FIRST record (the started handshake). - The runner emits NDJSON ``{"kind": "event"|"result", ...}`` records the moment each is - built; the first one means the run is accepted and owned (the alive-held handshake). We - return then and close the connection — the runner owns the run (alive watchdog) and - persists independently (producer-driven ingest), so draining to completion is unnecessary. - The read timeout is generous (sandbox cold-start can take seconds); we are NOT awaiting - the whole run, so it is not the batch 60s-whole-run budget. + The deployed workflow service emits one NDJSON record the moment each is built; the first + one means the run is accepted and owned (the alive-held handshake). We return then and + close the connection — the runner owns the run (alive watchdog) and persists independently + (producer-driven ingest), so draining to completion is unnecessary. The read timeout is + generous (sandbox cold-start can take seconds); we are NOT awaiting the whole run, so it + is not the batch 60s-whole-run budget. + + ``strict_first_record`` (a durable control command) surfaces an explicit failure frame + instead of reporting it as a start. It does NOT require a particular record shape: see + ``_detached_start_failure`` for why the two producers on this stream disagree about the + vocabulary, and why anything unrecognised is a start. """ headers = inject( { @@ -813,22 +818,12 @@ async def _stream_service_started( raise WorkflowDetachedStartFailed( "Workflow service emitted a non-object record before detached start." ) - kind = record.get("kind") if isinstance(record, dict) else None - if strict_first_record and kind == "result": - result = record.get("result") - if not isinstance(result, dict) or result.get("ok") is not True: - detail = ( - result.get("error") - if isinstance(result, dict) - else "malformed result record" - ) + if strict_first_record and isinstance(record, dict): + failure = WorkflowsService._detached_start_failure(record) + if failure is not None: raise WorkflowDetachedStartFailed( - f"Workflow service rejected detached start: {detail}" + f"Workflow service rejected detached start: {failure}" ) - elif strict_first_record and kind != "event": - raise WorkflowDetachedStartFailed( - "Workflow service emitted an unknown record before detached start." - ) record_run_id = ( record.get("run_id") if isinstance(record, dict) else None ) @@ -844,6 +839,48 @@ async def _stream_service_started( "Workflow service closed the stream before emitting a started record." ) + @staticmethod + def _detached_start_failure(record: dict) -> Optional[str]: + """Read an explicit failure out of the FIRST record, in either wire vocabulary. + + Two producers can answer this stream and they do not share a vocabulary. + + * The deployed workflow SERVICE is the ordinary case. It streams agenta event frames, + ``{"type": ..., "data": {...}}``, and its failure frame is ``{"type": "error"}``. There + is no ``kind`` anywhere on that wire. + * The agent RUNNER's own NDJSON, ``{"kind": "event"|"result"}``, reaches this parser only + where a deployment forwards the runner stream verbatim. Its failure is a terminal + ``{"kind": "result", "result": {"ok": false}}``. + + Everything else is the started handshake. Rejecting an unrecognised record instead is what + made EVERY durable continuation report `unreachable` while the runner was in fact already + running the turn: the service's first frame carries ``type``, never ``kind``. + + A runner that refuses a continuation outright never reaches here at all. The SDK turns its + ``ok: false`` result into an exception inside the already-committed ASGI response, so the + service closes the stream having written nothing, and the caller raises the + "closed the stream" failure above. + """ + if record.get("kind") == "result": + result = record.get("result") + if isinstance(result, dict) and result.get("ok") is True: + return None + detail = ( + result.get("error") + if isinstance(result, dict) + else "malformed result record" + ) + return str(detail or "the runner rejected the run") + + if record.get("type") == "error": + data = record.get("data") + message = data.get("message") if isinstance(data, dict) else None + code = data.get("code") if isinstance(data, dict) else None + detail = str(message or "the service reported an error") + return f"{detail} ({code})" if code else detail + + return None + @staticmethod def _coerce_invoke_response( *, diff --git a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py index 5a4b0a98198..e277e01438e 100644 --- a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py +++ b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py @@ -134,7 +134,8 @@ async def test_stream_service_started_raises_on_http_error(): "[]", '{"kind": "result", "result": {"ok": false, "error": "rejected"}}', '{"kind": "result"}', - '{"kind": "unknown"}', + # The service wire's own failure frame (an agenta `error` event). + '{"type": "error", "data": {"type": "error", "message": "no key", "code": "auth"}}', ], ) async def test_stream_service_started_rejects_failure_or_malformed_first_record(line): @@ -164,6 +165,81 @@ async def test_stream_service_started_keeps_legacy_best_effort_for_ordinary_trig assert result.run_id == "run-x" +@pytest.mark.parametrize( + "line", + [ + # The record sequence a durable continuation really produced (browser pass, + # 2026-09-04 17:35Z, session d99f32ae / command 01a06d7d): the runner admitted the + # continuation and its first event was a `tool_call`. The DEPLOYED SERVICE re-frames + # every runner record as an agenta event, `{"type", "data"}` — there is no `kind` on + # that wire, and reading the first frame as a runner record called every one of those + # deliveries unreachable while the turn ran to completion underneath the card. + '{"type": "tool_call", "data": {"type": "tool_call", "id": "t1", "name": "Bash"}}', + '{"type": "interaction_response", "data": {"type": "interaction_response"}}', + '{"type": "message", "data": {"type": "message", "text": "ok"}}', + # An unrecognised record is a start, not a failure: only an explicit failure frame is. + '{"kind": "unknown"}', + '{"type": "error_recovered", "data": {}}', + ], +) +async def test_stream_service_started_accepts_a_service_event_frame_as_the_start(line): + response = _FakeStreamResponse(lines=[line, '{"type": "done", "data": {}}']) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert result.accepted is True + assert result.run_id == "run-x" + assert response.consumed == 1 + + +async def test_stream_service_started_reports_a_runner_refusal_verbatim(): + """Case (b) of the same browser pass, command 01a06d7a. + + The runner refuses a continuation it cannot prove it owns and writes + ``{"kind": "result", ok: false}``. Where a deployment forwards that record verbatim the + caller must surface the reason, not report a start. + """ + refusal = ( + '{"kind": "result", "result": {"ok": false, "error": ' + '"Continuation could not establish alive ownership; retry delivery."}}' + ) + response = _FakeStreamResponse(lines=[refusal]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed) as failure: + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert "alive ownership" in str(failure.value) + + +async def test_stream_service_started_reports_an_empty_stream_as_a_failed_start(): + """The same refusal as it actually reaches the API through the SDK service. + + The SDK turns the runner's ``ok: false`` result into an exception inside an ASGI response + whose 200 is already committed, so the service closes the stream having written nothing. + """ + response = _FakeStreamResponse(lines=[]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed) as failure: + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert "closed the stream" in str(failure.value) + + async def test_stream_service_started_accepts_success_result_record(): response = _FakeStreamResponse( lines=['{"kind": "result", "result": {"ok": true}}'], From a8e60e077df785127fa603d1ece8311a515ee546 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 20:02:25 +0200 Subject: [PATCH 032/133] fix(sessions): keep a running continuation out of the recoverable state A transport failure that arrived after the runner had reported its outcome demoted a running execution back to recoverable. The card then rendered "Answer saved, retry needed" over a turn that was under way or already finished. Two executions of the browser pass reached terminal/completed and still carried continuation_delivery_failed. _mark_continuation_recoverable now passes expected_states, so only an execution that still waits for a runner can become recoverable. The caller reports recoverable only when the write applied. A projection that raises still counts, because the transport failure that brought us there is real. Two smaller corrections travel with it. _deliver answers with an "exhausted" receipt when the bounded delivery budget is spent, so the card says "Send your next message to retry it" instead of a promise of a redelivery that no longer happens. And resume_recoverable_continuation settles an exhausted command through the existing exhaustion path before the reopen, so a Send that arrives before the sweep retargets a fresh execution instead of doing nothing. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/commands/interfaces.py | 5 +- api/oss/src/core/sessions/commands/service.py | 106 +++++++++++++-- ...test_interaction_continuation_admission.py | 126 ++++++++++++++++++ 3 files changed, 224 insertions(+), 13 deletions(-) diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index 8daa58c6794..a4fb8ecb8bf 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -42,9 +42,12 @@ class DeliveryReceipt(BaseModel): settlement sweep recovers it. * `not_held` — a reachable runner said it does not hold that session, which lets the service settle at once instead of waiting for the deadline. + * `exhausted` — the bounded delivery budget is spent, so the transport was never called. + Nothing retries the command on its own after this, which is why it is not `unreachable`: + the caller must tell the user the truth rather than promise a redelivery. """ - status: str # "accepted" | "unreachable" | "not_held" + status: str # "accepted" | "unreachable" | "not_held" | "exhausted" detail: Optional[str] = None # Which runner process took it, when the transport learned that. The service uses it as the # claim owner, so the outcome route's guard reads the same way on every transport. diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index ff4ce56f92a..fe27b0d3ea7 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -659,26 +659,62 @@ async def respond_interactions( ) receipt = None if receipt is None or receipt.status != "accepted": - await self._mark_continuation_recoverable(admission) - admission.execution_state = SessionExecutionState.recoverable + if await self._mark_continuation_recoverable(admission, receipt): + admission.execution_state = SessionExecutionState.recoverable return admission async def _mark_continuation_recoverable( - self, admission: InteractionContinuationAdmission - ) -> None: + self, + admission: InteractionContinuationAdmission, + receipt: Optional[DeliveryReceipt] = None, + ) -> bool: + """Project a failed delivery onto the execution the card reads. + + Two guards, both learned from a browser pass where a delivered continuation was reported + `unreachable` anyway. + + `expected_states` is the important one. A transport that fails AFTER the runner reported + its outcome would otherwise demote a `running` execution back to `recoverable`, telling + the user to retry a turn that is running underneath the card. Only an execution still + waiting for a runner may be turned recoverable. + + The message is the other. It is what the card renders, so it must not promise a + redelivery that cannot happen: once the delivery budget is spent, nothing redelivers this + command on its own and only the user's next Send does (`resume_recoverable_continuation` + reopens the budget). + + False means the DAO REFUSED, which happens only when the execution has moved on, so the + caller must not report `recoverable`. A projection that raises returns True: the write is + best effort, but the transport failure that brought us here is real and the user still + owns the retry. + """ if self._executions is None or admission.command is None: - return + return False + exhausted = receipt is not None and receipt.status == "exhausted" try: - await self._executions.set_state( + applied = await self._executions.set_state( project_id=admission.command.project_id, session_id=admission.command.session_id, execution_id=admission.execution_id, state=SessionExecutionState.recoverable, error={ - "code": "continuation_delivery_failed", + "code": ( + "continuation_delivery_exhausted" + if exhausted + else "continuation_delivery_failed" + ), "retryable": True, - "message": "The continuation is durable and awaiting redelivery.", + "message": ( + "The continuation could not be delivered. Send your next message to " + "retry it." + if exhausted + else "The continuation is durable and awaiting redelivery." + ), }, + expected_states=[ + SessionExecutionState.pending_delivery, + SessionExecutionState.recoverable, + ], ) except Exception as error: # noqa: BLE001 - recovery projection is best effort log.error( @@ -687,6 +723,8 @@ async def _mark_continuation_recoverable( admission.execution_id, error, ) + return True + return applied is not None async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str @@ -715,6 +753,32 @@ async def resume_recoverable_continuation( # `recoverable`, after it has collapsed and tombstoned the old ownership. Until # then this durable continuation still owns Send, but it is never redelivered. return True + if ( + command.state + in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ) + and command.claim_count >= env.agenta.sessions.commands.max_deliveries + ): + # The budget bounds the AUTOMATIC retry loop, not the user. A command that spent it + # is undeliverable until the sweep settles it exhausted, so a Send arriving inside + # that window would deliver nothing and re-render a card asking for another Send. + # Settle it here instead. Redelivering it as it stands is not an option: the budget + # is spent precisely because this execution id keeps being refused, so the ending has + # to be recorded before the reopen below can retarget a fresh one. + if await self._settle_exhausted_continuation(command): + refreshed = await self._dao.fetch_command(command_id=command.id) + if refreshed is not None: + command = refreshed + execution = ( + await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + or execution + ) if command.state not in ( SessionCommandState.pending, SessionCommandState.claimed, @@ -744,7 +808,7 @@ async def resume_recoverable_continuation( ) receipt = None if receipt is None or receipt.status != "accepted": - await self._mark_continuation_recoverable(admission) + await self._mark_continuation_recoverable(admission, receipt) return True async def _reopen_continuation_attempt( @@ -908,18 +972,36 @@ async def _deliver(self, command: SessionCommand) -> Optional[DeliveryReceipt]: """Hand the command to the transport, then record what the transport learned. Never raises. The user's request has already succeeded by the time this runs. + + An `exhausted` receipt means the bounded delivery budget is spent. Nothing redelivers the + command after that, so the caller must say the true thing on the card rather than promise + a redelivery: only the user's next Send reopens the budget. """ + maximum = env.agenta.sessions.commands.max_deliveries + requested = command try: command = await self._dao.record_delivery_attempt( - project_id=command.project_id, - command_id=command.id, + project_id=requested.project_id, + command_id=requested.id, now=datetime.now(timezone.utc), - max_deliveries=env.agenta.sessions.commands.max_deliveries, + max_deliveries=maximum, ) except Exception as error: # noqa: BLE001 - delivery bookkeeping is post-commit log.warning("control delivery reservation failed: %s", error) return None if command is None: + if requested.claim_count >= maximum: + log.warning( + "control delivery budget exhausted for command=%s session=%s after %s " + "attempts", + requested.id, + requested.session_id, + requested.claim_count, + ) + return DeliveryReceipt( + status="exhausted", + detail=f"delivery budget of {maximum} attempts is spent", + ) return None try: diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index ac5125e47b0..905e8b319af 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -1034,3 +1034,129 @@ async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): ) assert await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) == 0 assert delivery.delivered == [] + + +class _StartedThenUnreachable: + """The runner admits the continuation and reports `started`, then the transport fails. + + The real shape of it (browser pass, 2026-09-04 17:28Z-17:35Z): the runner posted its + outcome, the API turned the execution `running`, and only afterwards did the detached-start + parser reject the stream. Both executions ran to completion carrying + `error.code = continuation_delivery_failed`, so the card offered a retry for work that was + already done. + """ + + def __init__(self, executions, execution_id): + self._executions = executions + self._execution_id = execution_id + self.delivered = [] + + async def deliver(self, **kwargs): + command = kwargs["command"] + self.delivered.append(command) + await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=self._execution_id, + state=SessionExecutionState.running, + error=None, + ) + return DeliveryReceipt( + status="unreachable", detail="parser rejected the stream" + ) + + async def acknowledge(self, **kwargs): + return None + + +@pytest.mark.asyncio +async def test_a_late_delivery_failure_does_not_demote_a_running_continuation(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _StartedThenUnreachable(executions, "continuation-1") + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed is True + assert delivery.delivered + # The turn the runner is already running keeps `running`. The recoverable projection is + # refused, so the card never asks the user to retry work that is under way. + assert executions.continuation.state == SessionExecutionState.running + assert executions.continuation.error is None + + +@pytest.mark.asyncio +async def test_a_send_after_the_budget_is_spent_reopens_the_continuation(monkeypatch): + """Command 01a06d7a of the same pass: three refusals, then a command nothing redelivers. + + The budget bounds the automatic loop only. A Send arriving before the sweep settles the + command must not be swallowed: settle it exhausted, retarget a fresh execution, deliver. + """ + maximum = 3 + monkeypatch.setattr(env.agenta.sessions.commands, "max_deliveries", maximum) + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command( + project_id, interaction_id, claim_count=maximum + ) + spent = commands.command + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed is True + # The exhausted attempt is recorded as ended and the command now targets a NEW execution: + # redelivering the old id is what spent the budget in the first place. + assert commands.command.target_turn_id != spent.target_turn_id + assert commands.command.claim_count == 0 + assert delivery.delivered + assert delivery.delivered[0].target_turn_id == commands.command.target_turn_id From 9d90b92e81b2889b16f82c26ea93bd78761cf2be Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 20:58:18 +0200 Subject: [PATCH 033/133] fix(web): sync approval answers across readers Symptom: A tab that did not answer an approval kept the pending card and live buttons after another reader answered it. Cause: The interaction watch event only invalidated records, but an interaction row can settle without appending a record, so the mounted transcript never reduced the gate. Fix: Refetch interaction rows on live interaction events and reconnects, then reconcile terminal row state into the rendered transcript immediately. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../hooks/useSessionHydration.ts | 70 ++++++++++++++++--- .../src/assets/transcriptToMessages.ts | 59 ++++++++++++---- .../unit/assets/transcriptToMessages.test.ts | 20 ++++++ .../src/session/state/interactionStatus.ts | 21 +++--- .../src/watch/watchEventSource.ts | 12 +++- .../tests/unit/watchEventSource.test.ts | 10 +++ 6 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 4d8de30efda..89c7ed6d6ac 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -1,11 +1,18 @@ import {type MutableRefObject, useCallback, useEffect, useRef, useState} from "react" -import {isSessionTranscript, loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" +import { + isSessionTranscript, + loadSessionMessages, + reconcileInteractionRowStates, + type SessionTranscript, +} from "@agenta/chat/assets" import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" import {hasSessionChat, isSessionFresh} from "@agenta/chat/state" import { + fetchSessionInteractionStatesAtom, fetchSessionRecordsAtom, hasWaitingInteraction, + revalidateSessionInteractionsAtom, revalidateSessionRecordsAtom, type SessionInteractionRowStates, shouldAdoptServerTranscript, @@ -466,6 +473,8 @@ export const useSessionHydration = ({ const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) const projectId = useAtomValue(projectIdAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const revalidateSessionInteractions = useSetAtom(revalidateSessionInteractionsAtom) + const fetchSessionInteractionStates = useSetAtom(fetchSessionInteractionStatesAtom) const refreshFromRecords = useCallback( async (transcript?: SessionTranscript): Promise => { const adoptOrConfirm = (candidate: unknown): boolean => { @@ -526,26 +535,69 @@ export const useSessionHydration = ({ readLog, ], ) - // `ready` fires on every connect — each tab activation, each return to the foreground — so it - // must not repeat a read the mount is already doing. A change that lands after the subscribe - // arrives as `records-changed`, which is never skipped (#6296). + const refreshFromInteractions = useCallback(() => { + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return + void revalidateSessionInteractions(sessionId) + .then(async () => { + const rows = await fetchSessionInteractionStates(sessionId) + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + persistMessages({ + id: sessionId, + messages: reconciled, + recordCount: recordWatermarkRef.current, + }) + }) + .catch(() => undefined) + }, [ + sessionId, + busyRef, + pendingResumeRef, + messagesRef, + recordWatermarkRef, + revalidateSessionInteractions, + fetchSessionInteractionStates, + setMessages, + persistMessages, + ]) + // `ready` fires on every connect — each tab activation, each return to the foreground. Records + // can skip a duplicate mount read, but rows must always catch up because a response changes the + // interaction row without necessarily appending a record (#6296). const refreshOnReady = useCallback(() => { if ( - !shouldRefreshOnReady({ + shouldRefreshOnReady({ inFlight: logReadsInFlightRef.current > 0, lastLoadedAt: logReadCompletedAtRef.current, now: Date.now(), }) ) - return - refreshFromRecords() - }, [refreshFromRecords]) + refreshFromRecords() + refreshFromInteractions() + }, [refreshFromRecords, refreshFromInteractions]) useSessionRecordsWatch({ sessionId, projectId, - // #5919 relay; this surface re-reads records on any interaction change. + // #5919 relay; this surface re-reads records on any interaction change, and the + // interaction rows themselves, because a response changes a row without appending a record. onInteractionChanged: () => { revalidateSessionRecords(sessionId) + refreshFromInteractions() }, enabled: activeSessionId === sessionId, onReady: refreshOnReady, diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index d834ac38669..923636b8898 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -196,8 +196,8 @@ const isRunnerSentinelError = (part: Part): boolean => { ) } -function settleClientToolPart(part: Part, row: SessionInteractionRowState): void { - if (part.state !== "input-available") return +function settleClientToolPart(part: Part, row: SessionInteractionRowState): boolean { + if (part.state !== "input-available") return false if (row.resolution) { if (row.resolution.outcome === "error") { @@ -213,13 +213,15 @@ function settleClientToolPart(part: Part, row: SessionInteractionRowState): void ? output : {...CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} } - return + return true } if (row.status === "cancelled" || row.status === "responded" || row.status === "resolved") { part.state = "output-available" part.output = {...CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} + return true } + return false } /** @@ -230,27 +232,32 @@ function settleClientToolPart(part: Part, row: SessionInteractionRowState): void * a dead gate left `approval-requested` holds the message queue forever once the scans that read * it cover the whole transcript. */ -function settleApprovalPart(part: Part, row: SessionInteractionRowState): void { - if (part.state !== "approval-requested") return +function settleApprovalPart(part: Part, row: SessionInteractionRowState): boolean { + if (part.state !== "approval-requested") return false const verdict = row.resolution?.verdict if (verdict === "approved" || verdict === "denied") { part.state = "approval-responded" part.approval = {id: row.token, approved: verdict === "approved"} - return + return true } if (row.status === "cancelled") { part.state = "output-denied" - return + return true } - if (row.status === "responded" || row.status === "resolved") part.state = "approval-responded" + if (row.status === "responded" || row.status === "resolved") { + part.state = "approval-responded" + return true + } + return false } function applyInteractionRowStates( index: TranscriptIndex, interactionRowStates: SessionInteractionRowStates | undefined, -): void { - if (!interactionRowStates || interactionRowStates.size === 0) return +): boolean { + if (!interactionRowStates || interactionRowStates.size === 0) return false + let changed = false for (const row of interactionRowStates.values()) { // Token equality supports rows written before the runner stamped the tool-call id; an // approval gate is also indexed under its interaction id, which IS the row token. @@ -258,10 +265,38 @@ function applyInteractionRowStates( const part = index.tools.get(toolCallId) ?? index.approvals.get(row.token) if (!part) continue - if (row.kind === "user_approval") settleApprovalPart(part, row) + if (row.kind === "user_approval") changed = settleApprovalPart(part, row) || changed else if (row.kind === "client_tool" || row.kind === "user_input") - settleClientToolPart(part, row) + changed = settleClientToolPart(part, row) || changed + } + return changed +} + +/** Apply row lifecycle changes to an already-rendered transcript without waiting for a record. */ +export function reconcileInteractionRowStates( + messages: UIMessage[], + interactionRowStates: SessionInteractionRowStates | undefined, +): UIMessage[] { + if (!interactionRowStates || interactionRowStates.size === 0) return messages + + const cloned = messages.map((message) => ({ + ...message, + parts: message.parts.map((part) => ({...part})) as UIMessage["parts"], + })) + const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} + for (const message of cloned) { + for (const rawPart of message.parts) { + const part = rawPart as Part + const toolCallId = part.toolCallId + if (typeof toolCallId === "string" && toolCallId) index.tools.set(toolCallId, part) + const approval = part.approval as {id?: unknown} | undefined + if (typeof approval?.id === "string" && approval.id) { + index.approvals.set(approval.id, part) + } + } } + + return applyInteractionRowStates(index, interactionRowStates) ? cloned : messages } /** diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 28e76ebbc56..85b1e7e50a4 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -8,6 +8,7 @@ import {describe, expect, it} from "vitest" import { APPROVED_EXECUTION_RESULT_UNKNOWN, + reconcileInteractionRowStates, transcriptToMessages, } from "../../../src/assets/transcriptToMessages" @@ -906,6 +907,25 @@ describe("transcriptToMessages interaction-row precedence", () => { }) }) + it("settles an already-rendered approval when another reader answers the row", () => { + const live = transcriptToMessages(abandonedApprovalRecords()) ?? [] + const reconciled = reconcileInteractionRowStates( + live, + rowStates(rowState("approval-1", {kind: "user_approval", status: "responded"})), + ) + + expect( + reconciled.flatMap((message) => message.parts).find((part) => + "toolCallId" in part ? part.toolCallId === "tool-1" : false, + ), + ).toMatchObject({state: "approval-responded"}) + expect( + live.flatMap((message) => message.parts).find((part) => + "toolCallId" in part ? part.toolCallId === "tool-1" : false, + ), + ).toMatchObject({state: "approval-requested"}) + }) + it("preserves record-only replay when row states are omitted", () => { expect(toolParts([elicitationRequest()])[0]).toMatchObject({state: "input-available"}) }) diff --git a/web/packages/agenta-entities/src/session/state/interactionStatus.ts b/web/packages/agenta-entities/src/session/state/interactionStatus.ts index 1ed74d5e122..64bedd6d0bc 100644 --- a/web/packages/agenta-entities/src/session/state/interactionStatus.ts +++ b/web/packages/agenta-entities/src/session/state/interactionStatus.ts @@ -79,15 +79,18 @@ export const fetchSessionInteractionStatesAtom = atom( }, ) -export const revalidateSessionInteractionsAtom = atom(null, (get, _set, sessionId: string) => { - const projectId = get(projectIdAtom) ?? "" - if (!projectId || !sessionId) return - // Keep an initial rows fetch in flight while marking its cache entry stale. - void get(queryClientAtom).invalidateQueries( - {queryKey: sessionInteractionRowsQueryKey(projectId, sessionId)}, - {cancelRefetch: false}, - ) -}) +export const revalidateSessionInteractionsAtom = atom( + null, + async (get, _set, sessionId: string) => { + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) return + // Keep an initial rows fetch in flight while marking its cache entry stale. + await get(queryClientAtom).invalidateQueries( + {queryKey: sessionInteractionRowsQueryKey(projectId, sessionId)}, + {cancelRefetch: false}, + ) + }, +) /** A row whose lifecycle has ended; `pending` is the only other value the API returns. */ const isTerminalRow = (row: SessionInteractionRowState): boolean => diff --git a/web/packages/agenta-sessions/src/watch/watchEventSource.ts b/web/packages/agenta-sessions/src/watch/watchEventSource.ts index e3b9c7f77ea..3fa13b7a149 100644 --- a/web/packages/agenta-sessions/src/watch/watchEventSource.ts +++ b/web/packages/agenta-sessions/src/watch/watchEventSource.ts @@ -4,6 +4,8 @@ const RETRY_BASE_MS = 1_000 const RETRY_MAX_MS = 30_000 const MIN_INTERVAL_MS = 3_000 +export const shouldCoalesceWatchEvent = (eventName: string): boolean => eventName !== "interaction" + const retryDelayMs = (attempt: number): number => Math.round(Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS) * (0.5 + Math.random() / 2)) @@ -26,8 +28,10 @@ export type RefreshSession = () => Promise * refreshes the session first, because the usual fatal cause is a 401 at the access-token * refresh boundary and a stream carries no interceptor to refresh-and-retry the way the * Fern/axios calls do. - * - Handlers are coalesced to one call per event name per `MIN_INTERVAL_MS`, so a burst of server - * events (or a reconnect loop) cannot fan out into a refetch storm. + * - Most handlers are coalesced to one call per event name per `MIN_INTERVAL_MS`, so a burst of + * server events (or a reconnect loop) cannot fan out into a refetch storm. Interaction events + * bypass that window because a reader must see an approval answer within one second even when + * it follows the pending event immediately. */ export const useWatchEventSource = ({ url, @@ -68,6 +72,10 @@ export const useWatchEventSource = ({ } const notify = (eventName: string, event: MessageEvent) => { + if (!shouldCoalesceWatchEvent(eventName)) { + onRef.current[eventName]?.(event) + return + } pendingEvents.set(eventName, event) const now = Date.now() const elapsed = lastNotifiedAt === null ? MIN_INTERVAL_MS : now - lastNotifiedAt diff --git a/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts b/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts new file mode 100644 index 00000000000..13d31705fb0 --- /dev/null +++ b/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts @@ -0,0 +1,10 @@ +import {describe, expect, it} from "vitest" + +import {shouldCoalesceWatchEvent} from "../../src/watch/watchEventSource" + +describe("watch event coalescing", () => { + it("does not delay interaction resolution behind the shared refetch window", () => { + expect(shouldCoalesceWatchEvent("interaction")).toBe(false) + expect(shouldCoalesceWatchEvent("record")).toBe(true) + }) +}) From 70b01dbf12eef406c13293d89cc799561fdc431b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:03:36 +0200 Subject: [PATCH 034/133] fix(web): keep held messages visible during approval recovery Symptom: A message sent while an approval gate was open disappeared, and a recoverable continuation Send produced no visible held-message feedback. Cause: Desktop and mobile hid the queue whenever a gate dock was open, while the queue had no recovery-aware path for the next Send. Fix: Always render held messages with explicit answer-waiting copy and keep a recoverable Send queued while it retries the durable continuation. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/features/chat/LiveConversation.tsx | 10 ++----- .../AgentChatSlice/AgentConversation.tsx | 21 ++++++++++--- .../components/AgentComposerDock.tsx | 11 ++----- .../hooks/useAgentChatSession.ts | 5 ++++ .../src/components/QueuedMessagesDock.tsx | 2 +- .../src/hooks/useAgentChatQueue.ts | 21 ++++++++++++- .../src/hooks/useAgentConversation.ts | 16 ++++++++-- .../tests/unit/QueuedMessagesDock.test.tsx | 19 ++++++++++++ .../unit/hooks/useAgentChatQueue.test.ts | 30 ++++++++++++++++--- 9 files changed, 108 insertions(+), 27 deletions(-) create mode 100644 web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index e71f6af1c46..d9098d6a5e5 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -430,9 +430,6 @@ export const LiveConversation = ({ approvalsPending: pendingApprovals.length > 0, elicitationPending: elicits.open, }) - // Any blocking dock on screen. The queue card yields to all of them rather than stacking, - // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. - const gateDockOpen = pendingApprovals.length > 0 || elicits.open || connects.open // A docked gate holds the jump pill back — same rule, same reasons, as the desktop. This // surface has no question-form dock yet, so only approvals and connect cards can gate it. const gateOpen = jumpGateOpen({ @@ -566,10 +563,9 @@ export const LiveConversation = ({ className={`pointer-events-none absolute inset-x-0 bottom-full ${BOTTOM_FADE_HOVER_HIDE}`} style={BOTTOM_FADE_OVERLAY_STYLE} /> - {/* What you have lined up. Yields to the gate docks entirely: those are - blocked runs wanting an answer, and stacking a second card above one - buries the composer. It comes back when the gate clears. */} - {conversation.queued.length > 0 && !gateDockOpen ? ( + {/* What you have lined up stays visible while a gate is open: the queued + message is the acknowledgement that the user's Send was not lost. */} + {conversation.queued.length > 0 ? (
{ + const resumed = await retryContinuation() + if (resumed) setRecoverableContinuation(false) + return resumed + }, [retryContinuation]) // Context-window denominator for the token-budget indicator: the SDK model catalog's own // `context_window`, delivered on the (global) harness-capabilities document — never hardcoded. @@ -397,6 +404,8 @@ const AgentConversation = ({ acceptedRunPending, stopped, resumeOrphaned, + recoverable: recoverableContinuation, + retryContinuation: retryRecoverableContinuation, sendQueued, sessionId, }) @@ -405,7 +414,7 @@ const AgentConversation = ({ // in THIS mount marks the resume as live — a restored approval-requested tail the user answers // after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it. const handleApprovalResponse = useCallback( - (args: {id: string; approved: boolean; message?: string}) => { + async (args: {id: string; approved: boolean; message?: string}) => { markLiveGate({kind: "approval", id: args.id}) // `answerApproval` owns the whole ordered click: the row first, then the part flip that // lets the SDK resume. Never flip here — an early flip lets the resume's stale sweep @@ -420,20 +429,24 @@ const AgentConversation = ({ // that flail needs an upstream ACP change, not an FE one.) // The outcome is RETURNED, not swallowed: the dock reads `recoverable` off it to show // "Answer saved, retry needed" instead of "Answered, waiting for the agent". - return answerThenSteer({ + const outcome = await answerThenSteer({ approved: args.approved, message: args.message, answer: () => answerApproval(args.id, args.approved), steer: (text) => submit({text}), }) + setRecoverableContinuation(outcome?.recoverable === true) + return outcome }, [answerApproval, markLiveGate, submit], ) const handleApprovalResponses = useCallback( - (ids: string[], approved: boolean) => { + async (ids: string[], approved: boolean) => { markLiveGate({kind: "approval", id: ids[0]}) - return answerApprovals(ids, approved) + const outcome = await answerApprovals(ids, approved) + setRecoverableContinuation(outcome?.recoverable === true) + return outcome }, [answerApprovals, markLiveGate], ) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 21c06101cca..c98522fa541 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -260,10 +260,6 @@ const AgentComposerDock = ({ // Permission rules live in the Advanced accordion's Permissions group. const openPermissionsConfig = useCallback(() => openConfigFor("advanced"), [openConfigFor]) - // Any blocking dock on screen. The queue card yields to all of them rather than stacking, - // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. - const gateDockOpen = pendingApprovals.length > 0 || elicits.open || connects.open - // Editing borrows the composer: the row's text goes in, the draft it displaces is stashed. const {beginEdit, cancelEdit} = queue const editQueued = useCallback( @@ -308,12 +304,11 @@ const AgentComposerDock = ({ />
) : null} - {/* Above the gate docks, and hidden entirely while one is up: those are blocked - runs wanting an answer, and a second card stacked above one buries the composer. - Inside the `Reveal` so it shares the composer's `px-3` gutter and column. */} + {/* Above the gate docks so a held message remains visible while the run waits for + an answer. Inside the `Reveal` so it shares the composer's gutter and column. */} { sharedSenderReadyRef.current = ready }, []) + const retryContinuation = useCallback( + () => resumeSessionContinuation(sessionId), + [resumeSessionContinuation, sessionId], + ) // Rebuilt every render and bound to the chat on every commit (below), so they always see the live // values — `entityId` included, which is why a run follows a revision switch or a self-commit @@ -845,6 +849,7 @@ export const useAgentChatSession = ({ markLiveGate, answerApproval, answerApprovals, + retryContinuation, resumeOrphaned, isSeen, } diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index 370553cbef5..6747967c1ac 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -217,7 +217,7 @@ const QueuedMessagesDock = ({ {queued.length} queued message{queued.length === 1 ? "" : "s"} - {held ? " · waiting on you" : ""} + {held ? " · waits for your answer" : ""} {/* Not `CollapseToggleButton`: it carries a tooltip, and a caret in a two-item diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 0321c86d72b..5d961118348 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -31,6 +31,10 @@ interface UseAgentChatQueueArgs { * mount can fire the auto-resume. Holding for it would freeze the queue forever with no * dock and no stop (AGE-3937), so it voids the hold exactly like a user stop. */ resumeOrphaned?: boolean + /** The approval answer is durable but its continuation was not delivered. A composer Send + * keeps the message held and uses the click to retry that continuation first. */ + recoverable?: boolean + retryContinuation?: () => Promise /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be * referentially stable so the release effect doesn't churn on every streamed token. */ sendQueued: (item: QueuedMessage) => void @@ -59,6 +63,8 @@ export const useAgentChatQueue = ({ acceptedRunPending = false, stopped, resumeOrphaned = false, + recoverable = false, + retryContinuation, sendQueued, sessionId, }: UseAgentChatQueueArgs) => { @@ -88,6 +94,7 @@ export const useAgentChatQueue = ({ // One latch shared by both send paths caps releases to one per settle and preserves FIFO. const releasingRef = useRef(false) + const retryingContinuationRef = useRef(false) const queuedRef = useRef(queued) useEffect(() => { queuedRef.current = queued @@ -113,6 +120,18 @@ export const useAgentChatQueue = ({ const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const message: QueuedMessage = {...item, id: generateId()} + if (recoverable && retryContinuation) { + setQueued((q) => [...q, message]) + if (!retryingContinuationRef.current) { + retryingContinuationRef.current = true + void retryContinuation() + .catch(() => false) + .finally(() => { + retryingContinuationRef.current = false + }) + } + return + } if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { releasingRef.current = true lastSentRef.current = message @@ -121,7 +140,7 @@ export const useAgentChatQueue = ({ setQueued((q) => [...q, message]) } }, - [canReleaseNow, sendQueued], + [canReleaseNow, recoverable, retryContinuation, sendQueued], ) const removeQueued = useCallback((id: string) => { diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index a0c1a8a7bb6..ada7765a541 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -284,6 +284,12 @@ export const useAgentConversation = ({ const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) + const [recoverableContinuation, setRecoverableContinuation] = useState(false) + const retryRecoverableContinuation = useCallback(async () => { + const resumed = await resumeSessionContinuation(sessionId) + if (resumed) setRecoverableContinuation(false) + return resumed + }, [resumeSessionContinuation, sessionId]) // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches // `onData` and never the transcript — this is the only place the answer survives. A stream that @@ -636,6 +642,8 @@ export const useAgentConversation = ({ acceptedRunPending, stopped, resumeOrphaned, + recoverable: recoverableContinuation, + retryContinuation: retryRecoverableContinuation, sendQueued, sessionId, }) @@ -645,7 +653,7 @@ export const useAgentConversation = ({ const handleApprovalResponse = useCallback( async (args: {id: string; approved: boolean}) => { liveGateInteractionRef.current = {kind: "approval", id: args.id} - return submitApprovalForCapability({ + const outcome = await submitApprovalForCapability({ durableApprovals: await supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswer({ @@ -664,6 +672,8 @@ export const useAgentConversation = ({ }), releaseLegacy: () => addToolApprovalResponse(args), }) + setRecoverableContinuation(outcome.recoverable) + return outcome }, [ addToolApprovalResponse, @@ -677,7 +687,7 @@ export const useAgentConversation = ({ const handleApprovalResponses = useCallback( async (args: {ids: string[]; approved: boolean}) => { liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} - return submitApprovalForCapability({ + const outcome = await submitApprovalForCapability({ durableApprovals: await supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswers({ @@ -704,6 +714,8 @@ export const useAgentConversation = ({ } }, }) + setRecoverableContinuation(outcome.recoverable) + return outcome }, [ addToolApprovalResponse, diff --git a/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx new file mode 100644 index 00000000000..3c8e463b9f7 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx @@ -0,0 +1,19 @@ +import {renderToStaticMarkup} from "react-dom/server" +import {describe, expect, it} from "vitest" + +import QueuedMessagesDock from "../../src/components/QueuedMessagesDock" + +describe("QueuedMessagesDock", () => { + it("explains that a held message waits for the open answer", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ) + + expect(markup).toContain("1 queued message · waits for your answer") + expect(markup).toContain("continue afterward") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 6557396d56e..3583ce134b8 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -38,15 +38,19 @@ interface HarnessProps { stopped: boolean acceptedRunPending?: boolean resumeOrphaned?: boolean + recoverable?: boolean sessionId?: string } const setup = (initial: HarnessProps) => { const sendQueued = vi.fn() - const view = renderHook((props: HarnessProps) => useAgentChatQueue({...props, sendQueued}), { - initialProps: initial, - }) - return {sendQueued, ...view} + const retryContinuation = vi.fn(() => Promise.resolve(true)) + const view = renderHook( + (props: HarnessProps) => + useAgentChatQueue({...props, sendQueued, retryContinuation}), + {initialProps: initial}, + ) + return {sendQueued, retryContinuation, ...view} } const settledEmpty: HarnessProps = {status: "ready", messages: [], stopped: false} @@ -140,6 +144,24 @@ describe("useAgentChatQueue", () => { expect(result.current.queued.map((m) => m.text)).toEqual(["while paused"]) }) + it("keeps a recoverable Send visible and retries the saved continuation", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + recoverable: true, + } + const {result, sendQueued, retryContinuation} = setup(paused) + + act(() => result.current.submit({text: "send after the approval"})) + + expect(sendQueued).not.toHaveBeenCalled() + expect(retryContinuation).toHaveBeenCalledOnce() + expect(result.current.queued.map((message) => message.text)).toEqual([ + "send after the approval", + ]) + }) + it("releases a held message once the approval gate resolves", () => { const paused: HarnessProps = { status: "ready", From 7771a623990f06df14129a455d5d5d36bb9f4a9d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:06:14 +0200 Subject: [PATCH 035/133] fix(web): retire desktop approval dock from terminal records Symptom: Desktop could keep showing an answered approval card after the continuation had written its terminal record and finished. Cause: The hydration guard protected the stale local card whenever the separately cached interaction row still appeared pending, even when the fetched server transcript had already retired the gate. Fix: Protect local interaction state only while both the rendered and fetched transcripts still contain a pending gate, allowing terminal records to clear the dock. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/hooks/useSessionHydration.test.ts | 10 ++++++++++ .../AgentChatSlice/hooks/useSessionHydration.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts index 87c0dd154e8..6e0075be3ce 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts @@ -108,4 +108,14 @@ describe("shouldProtectRenderedInteraction", () => { ]) as SessionInteractionRowStates expect(shouldProtectRenderedInteraction([approval], settledRows)).toBe(false) }) + + it("does not protect a stale desktop card from a terminal server transcript", () => { + const finished = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "finished"}], + } as unknown as UIMessage + + expect(shouldProtectRenderedInteraction([approval], pendingRows, [finished])).toBe(false) + }) }) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 89c7ed6d6ac..c310043c7f1 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -77,12 +77,18 @@ export const hasStrandedTail = (messages: UIMessage[]): boolean => * Protect local interaction state only when the pending server row already has an actionable card * on screen. A pending row by itself is not enough: the browser may have cached the transcript * before the interaction_request record arrived. Treating that stale copy as user-owned state - * prevents hydration from ever delivering the missing approval or form. + * prevents hydration from ever delivering the missing approval or form. The server transcript + * participates too: once its terminal records have retired the gate, that durable completion must + * replace an answered desktop card even if the separately cached row query still says pending. */ export const shouldProtectRenderedInteraction = ( messages: UIMessage[], interactionRows: SessionInteractionRowStates | undefined, -): boolean => hasWaitingInteraction(interactionRows) && isHitlPending(messages) + serverMessages: UIMessage[] = messages, +): boolean => + hasWaitingInteraction(interactionRows) && + isHitlPending(messages) && + isHitlPending(serverMessages) /** Same carrier shape `useAgentChatSession`'s error effect uses, so the stamp renders through the * existing red error bubble. */ @@ -193,6 +199,7 @@ export const useSessionHydration = ({ awaitingUser: shouldProtectRenderedInteraction( messagesRef.current, interactionRows, + serverMsgs, ), }) if (!adopt) return false From ec0d22249e7a2a6c8c8b92d16c7a009ea0446a4f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:10:21 +0200 Subject: [PATCH 036/133] fix(web): scope recovery state to its approval Symptom: Once a tab showed the recoverable approval copy, later approvals could inherit that label even after their continuations succeeded. Cause: An asynchronous response from an earlier gate could update unkeyed dock and conversation recovery state after the UI had advanced to another interaction. Fix: Key response settlement and retry state to the initiating approval, ignore late results, and reset recovery when a new pending approval becomes current. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 18 ++++++++- .../components/ApprovalDock.test.tsx | 40 +++++++++++++++++++ .../components/ApprovalDock.tsx | 22 ++++++---- .../src/hooks/useAgentConversation.ts | 18 ++++++++- .../agenta-chat/src/hooks/useApprovalDock.ts | 11 ++++- .../tests/unit/hooks/useApprovalDock.test.ts | 23 +++++++++++ 6 files changed, 119 insertions(+), 13 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 8ad36c916d3..91a2d08f5c8 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -253,6 +253,7 @@ const AgentConversation = ({ const modelKey = useAgentModelKeyStatus(entityId) const modelBlocked = modelKey.gateActive const [recoverableContinuation, setRecoverableContinuation] = useState(false) + const approvalResponseOwnerRef = useRef(null) const retryRecoverableContinuation = useCallback(async () => { const resumed = await retryContinuation() if (resumed) setRecoverableContinuation(false) @@ -415,6 +416,7 @@ const AgentConversation = ({ // after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it. const handleApprovalResponse = useCallback( async (args: {id: string; approved: boolean; message?: string}) => { + approvalResponseOwnerRef.current = args.id markLiveGate({kind: "approval", id: args.id}) // `answerApproval` owns the whole ordered click: the row first, then the part flip that // lets the SDK resume. Never flip here — an early flip lets the resume's stale sweep @@ -435,7 +437,9 @@ const AgentConversation = ({ answer: () => answerApproval(args.id, args.approved), steer: (text) => submit({text}), }) - setRecoverableContinuation(outcome?.recoverable === true) + if (approvalResponseOwnerRef.current === args.id) { + setRecoverableContinuation(outcome?.recoverable === true) + } return outcome }, [answerApproval, markLiveGate, submit], @@ -443,9 +447,12 @@ const AgentConversation = ({ const handleApprovalResponses = useCallback( async (ids: string[], approved: boolean) => { + approvalResponseOwnerRef.current = ids[0] markLiveGate({kind: "approval", id: ids[0]}) const outcome = await answerApprovals(ids, approved) - setRecoverableContinuation(outcome?.recoverable === true) + if (approvalResponseOwnerRef.current === ids[0]) { + setRecoverableContinuation(outcome?.recoverable === true) + } return outcome }, [answerApprovals, markLiveGate], @@ -456,6 +463,13 @@ const AgentConversation = ({ () => getLivePendingApprovals(messages, {stopped: !interactionAvailability.approvals}), [messages, interactionAvailability.approvals], ) + const pendingApprovalId = pendingApprovals[0]?.approvalId + if (pendingApprovalId && approvalResponseOwnerRef.current !== pendingApprovalId) { + approvalResponseOwnerRef.current = pendingApprovalId + } + useEffect(() => { + if (pendingApprovalId) setRecoverableContinuation(false) + }, [pendingApprovalId]) // Parked connect interactions on the paused turn → the connect dock owns their actions (the // inline rows are passive markers). Gated off while busy (`input-streaming` isn't parked yet) // and after a user stop (the run is dead, nothing to settle — matches the queue's stop void). diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx index e32d76e8729..1b36f5f9447 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx @@ -5,8 +5,12 @@ * REPLACES Approve as the single primary, "Deny all" mirrors it, and the detail rows list the * pending actions — the informed-click job the popover used to do. */ +import {act} from "react" + +import {createRoot} from "react-dom/client" import {renderToStaticMarkup} from "react-dom/server" import {describe, expect, it} from "vitest" +;(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true // No hook mock: the card imports `useAlwaysAllowTool` by its relative path inside the package, so // a `@agenta/chat/hooks` mock resolves elsewhere and does nothing. The always-allow row is covered @@ -67,3 +71,39 @@ describe("no pending gate", () => { expect(render([])).not.toContain("Needs your approval") }) }) + +describe("interaction-scoped response state", () => { + it("does not leak a late recoverable result onto the next desktop gate", async () => { + let resolveFirst: ((value: {durable: boolean; recoverable: boolean}) => void) | undefined + const onApprovalResponse = () => + new Promise<{durable: boolean; recoverable: boolean}>((resolve) => { + resolveFirst = resolve + }) + const host = document.createElement("div") + document.body.appendChild(host) + const root = createRoot(host) + const renderGate = (id: string) => ( + + ) + + await act(async () => root.render(renderGate("g1"))) + const approveButton = [...host.querySelectorAll("button")].find((button) => + button.textContent?.includes("Approve"), + ) as HTMLButtonElement + await act(async () => { + approveButton.click() + }) + await act(async () => root.render(renderGate("g2"))) + await act(async () => resolveFirst?.({durable: true, recoverable: true})) + + expect(host.textContent).toContain("Needs your approval") + expect(host.textContent).not.toContain("Answer saved, retry needed") + + await act(async () => root.unmount()) + host.remove() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index aa6919f31f3..30b498bb2fa 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -51,6 +51,8 @@ const ApprovalDock = ({ if (open && !resolving) shownRef.current = approvals const shown = shownRef.current const current = shown[0] + const currentIdRef = useRef(current?.approvalId) + currentIdRef.current = current?.approvalId const [responding, setResponding] = useState(false) const [answered, setAnswered] = useState(false) @@ -79,8 +81,10 @@ const ApprovalDock = ({ const settle = async ( responses: (void | ApprovalSubmissionOutcome | Promise)[], + ownerId: string | undefined, ) => { const results = await Promise.allSettled(responses) + if (currentIdRef.current !== ownerId) return const failed = results.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) @@ -111,6 +115,7 @@ const ApprovalDock = ({ onApprovalResponses ? [onApprovalResponses(ids, approved)] : ids.map((id) => onApprovalResponse({id, approved})), + ids[0], ) } @@ -133,13 +138,16 @@ const ApprovalDock = ({ if (responding) return setResponding(true) setErrorText(null) - void settle([ - onApprovalResponse({ - id: approvalId, - approved, - ...(message?.trim() ? {message: message.trim()} : {}), - }), - ]) + void settle( + [ + onApprovalResponse({ + id: approvalId, + approved, + ...(message?.trim() ? {message: message.trim()} : {}), + }), + ], + approvalId, + ) }} onApproveAll={(ids) => respondMany(ids, true)} onDenyAll={(ids) => respondMany(ids, false)} diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index ada7765a541..e33cd3d80af 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -285,6 +285,7 @@ export const useAgentConversation = ({ const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) const [recoverableContinuation, setRecoverableContinuation] = useState(false) + const approvalResponseOwnerRef = useRef(null) const retryRecoverableContinuation = useCallback(async () => { const resumed = await resumeSessionContinuation(sessionId) if (resumed) setRecoverableContinuation(false) @@ -652,6 +653,7 @@ export const useAgentConversation = ({ // transition + AI SDK gate release; durable servers own continuation after their 202. const handleApprovalResponse = useCallback( async (args: {id: string; approved: boolean}) => { + approvalResponseOwnerRef.current = args.id liveGateInteractionRef.current = {kind: "approval", id: args.id} const outcome = await submitApprovalForCapability({ durableApprovals: await supportsDurableApprovals(sessionId), @@ -672,7 +674,9 @@ export const useAgentConversation = ({ }), releaseLegacy: () => addToolApprovalResponse(args), }) - setRecoverableContinuation(outcome.recoverable) + if (approvalResponseOwnerRef.current === args.id) { + setRecoverableContinuation(outcome.recoverable) + } return outcome }, [ @@ -686,6 +690,7 @@ export const useAgentConversation = ({ const handleApprovalResponses = useCallback( async (args: {ids: string[]; approved: boolean}) => { + approvalResponseOwnerRef.current = args.ids[0] liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} const outcome = await submitApprovalForCapability({ durableApprovals: await supportsDurableApprovals(sessionId), @@ -714,7 +719,9 @@ export const useAgentConversation = ({ } }, }) - setRecoverableContinuation(outcome.recoverable) + if (approvalResponseOwnerRef.current === args.ids[0]) { + setRecoverableContinuation(outcome.recoverable) + } return outcome }, [ @@ -749,6 +756,13 @@ export const useAgentConversation = ({ respond: handleApprovalResponse, respondAll: handleApprovalResponses, }) + const pendingApprovalId = approvals.current?.approvalId + if (pendingApprovalId && approvalResponseOwnerRef.current !== pendingApprovalId) { + approvalResponseOwnerRef.current = pendingApprovalId + } + useEffect(() => { + if (pendingApprovalId) setRecoverableContinuation(false) + }, [pendingApprovalId]) // Settle a parked client tool (#4920). A widget calls this with the structured reference; // `addToolOutput` matches the part by `toolCallId` on the last turn and the resume predicate diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts index 1e4e28ba445..772dd4ce667 100644 --- a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -76,6 +76,8 @@ export const useApprovalDock = ({ const shown = shownRef.current const current = shown[0] ?? null const count = shown.length + const currentIdRef = useRef(current?.approvalId) + currentIdRef.current = current?.approvalId const [responding, setResponding] = useState(false) const [answered, setAnswered] = useState(false) @@ -92,8 +94,12 @@ export const useApprovalDock = ({ }, [current?.approvalId]) const settle = useCallback( - async (responses: (ApprovalResponse | Promise)[]) => { + async ( + responses: (ApprovalResponse | Promise)[], + ownerId: string | undefined, + ) => { const results = await Promise.allSettled(responses) + if (currentIdRef.current !== ownerId) return const failed = results.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) @@ -131,7 +137,7 @@ export const useApprovalDock = ({ if (responding || !current) return setResponding(true) setErrorText(null) - void settle([onRespond({id: current.approvalId, approved})]) + void settle([onRespond({id: current.approvalId, approved})], current.approvalId) }, [responding, current, onRespond, settle], ) @@ -148,6 +154,7 @@ export const useApprovalDock = ({ onRespondAll ? [onRespondAll({ids, approved: true})] : ids.map((id) => onRespond({id, approved: true})), + ids[0], ) }, [responding, shown, onRespond, onRespondAll, settle]) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index ae0ec49a9b6..7389383606f 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -177,4 +177,27 @@ describe("useApprovalDock", () => { expect(result.current.answered).toBe(true) expect(result.current.recoverable).toBe(true) }) + + it("does not apply a late recoverable result to the next interaction", async () => { + let resolveFirst: ((value: {durable: boolean; recoverable: boolean}) => void) | undefined + const respond = vi.fn( + () => + new Promise<{durable: boolean; recoverable: boolean}>((resolve) => { + resolveFirst = resolve + }), + ) + const {result, rerender} = renderHook( + (props: {messages: UIMessage[]}) => + useApprovalDock({messages: props.messages, respond}), + {initialProps: {messages: [assistantWithGates("g1")] }}, + ) + + act(() => result.current.respond(true)) + rerender({messages: [assistantWithGates("g2")]}) + await act(async () => resolveFirst?.({durable: true, recoverable: true})) + + expect(result.current.current?.approvalId).toBe("g2") + expect(result.current.answered).toBe(false) + expect(result.current.recoverable).toBe(false) + }) }) From 0f6a3222d5bac564d5bb465c7af9ed38ecdc7923 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:13:00 +0200 Subject: [PATCH 037/133] fix(web): refresh interaction rows during transcript hydration Symptom: Reopening or background-refreshing a session could replay an approval as pending even though its interaction row was already responded or resolved. Cause: Hydration reused a cached interaction-row snapshot, including for a later refreshed record log, so row lifecycle and records could come from different moments. Fix: Invalidate and refetch interaction rows for initial hydration and again when refreshed records arrive, then replay each log against the matching fresh row state. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../agenta-chat/src/assets/loadSession.ts | 19 ++++- .../tests/unit/assets/loadSession.test.ts | 72 +++++++++++++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index d445f05f4b7..ec318eded5b 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -6,6 +6,7 @@ import { fetchSessionInteractionStatesAtom, fetchSessionRecordsAtom, + revalidateSessionInteractionsAtom, type SessionInteractionRowStates, } from "@agenta/entities/session" import type {UIMessage} from "ai" @@ -82,6 +83,9 @@ export const loadSessionMessages = async ( // notice instead of leaking an unhandled rejection. try { const store = getDefaultStore() + await Promise.resolve(store.set(revalidateSessionInteractionsAtom, sessionId)).catch( + () => undefined, + ) // The best-effort lifecycle join must never gate transcript loading. const [{records, refreshed}, interactionRowStates] = await Promise.all([ store.set(fetchSessionRecordsAtom, sessionId), @@ -89,15 +93,24 @@ export const loadSessionMessages = async ( ]) if (refreshed && onRefreshed) { void refreshed - .then((fresh) => { + .then(async (fresh) => { if (!fresh || fresh.length === 0) return - const freshMsgs = transcriptToMessages(fresh, {interactionRowStates}) + await Promise.resolve( + store.set(revalidateSessionInteractionsAtom, sessionId), + ).catch(() => undefined) + const freshInteractionRowStates = await store.set( + fetchSessionInteractionStatesAtom, + sessionId, + ) + const freshMsgs = transcriptToMessages(fresh, { + interactionRowStates: freshInteractionRowStates, + }) if (freshMsgs && freshMsgs.length > 0) { onRefreshed({ messages: freshMsgs, recordCount: fresh.length, sequenceCursor: sequenceCursorForRecords(fresh), - interactionRows: interactionRowStates, + interactionRows: freshInteractionRowStates, }) } }) diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts index 4901a18798a..c9d4c0c1aa8 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -1,4 +1,5 @@ import type {SessionInteractionRowState, SessionRecord} from "@agenta/entities/session" +import type {UIMessage} from "ai" import {atom} from "jotai" import {beforeEach, describe, expect, it, vi} from "vitest" @@ -12,6 +13,7 @@ let interactionRowStates = new Map() vi.mock("@agenta/entities/session", () => ({ fetchSessionRecordsAtom: atom(null, async () => fetchResult), fetchSessionInteractionStatesAtom: atom(null, async () => interactionRowStates), + revalidateSessionInteractionsAtom: atom(null, async () => undefined), })) const {loadSessionMessages} = await import("../../../src/assets/loadSession") @@ -27,6 +29,30 @@ const record = (id: string, payload: Record, sender = "agent"): created_at: null, }) +const approvalRecords = (): SessionRecord[] => [ + record("r-call", {type: "tool_call", id: "call-1", name: "bash", input: {command: "ls"}}), + record("r-gate", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "call-1"}, + }), + record("r-paused", {type: "done", stopReason: "paused"}), +] + +const approvalRow = (status: "pending" | "responded" | "resolved") => + new Map([ + [ + "approval-1", + { + token: "approval-1", + status, + kind: "user_approval" as const, + toolCallId: "call-1", + }, + ], + ]) + describe("loadSessionMessages", () => { beforeEach(() => { fetchResult = {records: null} @@ -52,6 +78,20 @@ describe("loadSessionMessages", () => { expect(transcript?.messages[0]).toMatchObject({parts: [{type: "text", text: "hi"}]}) }) + it.each(["responded", "resolved"] as const)( + "retires a replayed gate whose interaction row is %s", + async (status) => { + fetchResult = {records: approvalRecords()} + interactionRowStates = approvalRow(status) + + const transcript = await loadSessionMessages("session-1") + + expect(transcript?.messages.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + }, + ) + // The adoption watermark: records, not messages — a turn that grows in place keeps its // message count (issue #5530), so only this number sees the log move. it("reports how many records the transcript was built from", async () => { @@ -90,10 +130,9 @@ describe("loadSessionMessages", () => { } const onRefreshed = vi.fn() await loadSessionMessages("session-1", onRefreshed) - // `refreshed` resolves asynchronously after the function returns — flush microtasks. - await Promise.resolve() - await Promise.resolve() - expect(onRefreshed).toHaveBeenCalledTimes(1) + // `refreshed` resolves asynchronously after the function returns and refreshes the row + // join before delivery. + await vi.waitFor(() => expect(onRefreshed).toHaveBeenCalledTimes(1)) const delivered = onRefreshed.mock.calls[0][0] as { messages: {parts: unknown}[] recordCount: number @@ -103,6 +142,31 @@ describe("loadSessionMessages", () => { expect(delivered.recordCount).toBe(2) }) + it("joins refreshed records with refreshed interaction rows", async () => { + let resolveRecords: ((records: SessionRecord[]) => void) | undefined + fetchResult = { + records: approvalRecords(), + refreshed: new Promise((resolve) => { + resolveRecords = resolve + }), + } + interactionRowStates = approvalRow("pending") + const onRefreshed = vi.fn() + const initial = await loadSessionMessages("session-1", onRefreshed) + expect(initial?.messages.flatMap((message) => message.parts)).toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + + interactionRowStates = approvalRow("responded") + resolveRecords?.(approvalRecords()) + await vi.waitFor(() => expect(onRefreshed).toHaveBeenCalledOnce()) + + const refreshed = onRefreshed.mock.calls[0][0] + expect(refreshed.messages.flatMap((message: UIMessage) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + }) + // The chain outlives the call, so the function's own try/catch never sees a rejection here. it("survives a rejected background revalidation without an unhandled rejection", async () => { const unhandled = vi.fn() From 89b12294bccf1bad9ce8ebf84c20ec26469dce4a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:17:07 +0200 Subject: [PATCH 038/133] fix(api): share bounded interaction reference fallback Symptom: Reference fallback loaded every session turn, while the router's inline last-resort skipped the dispatcher fallback and could invoke without a runnable workflow reference. Cause: Session identity resolution lived privately in the dispatcher and queried an unbounded turn list, leaving the router with a separate gate-only implementation. Fix: Move reference resolution into the core interactions layer, request only the newest turn before the stream fallback, and use that resolver from both dispatcher and inline router paths. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/router.py | 19 +-- .../core/sessions/interactions/references.py | 95 +++++++++++++++ .../sessions/interactions_dispatcher.py | 113 ++---------------- .../sessions/test_interactions_dispatcher.py | 4 +- .../test_respond_interaction_enqueue.py | 47 ++++++++ 5 files changed, 166 insertions(+), 112 deletions(-) create mode 100644 api/oss/src/core/sessions/interactions/references.py diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 8dcb829d9ab..b034ecab07b 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -102,6 +102,7 @@ SessionInteractionTransition, ) from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.interactions.references import resolve_interaction_references from oss.src.core.sessions.interactions.types import InteractionNotFound from oss.src.core.sessions.attachments.dtos import Attachment from oss.src.core.sessions.attachments.service import SessionAttachmentsService @@ -1076,12 +1077,16 @@ def __init__( # it so both paths share ONE answer-composition implementation. interactions_dispatcher: Optional[Any] = None, commands_service: Optional[SessionCommandsService] = None, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, ) -> None: self.interactions_service = interactions_service self.workflows_service = workflows_service self.respond_task = respond_task self.interactions_dispatcher = interactions_dispatcher self.commands_service = commands_service + self.turns_service = turns_service + self.streams_service = streams_service self.router = APIRouter() @@ -1491,13 +1496,11 @@ async def respond_interaction( answer=answer, ) else: - references = ( - { - k: v.model_dump(mode="json") - for k, v in interaction.data.references.items() - } - if interaction.data and interaction.data.references - else None + references = await resolve_interaction_references( + project_id=UUID(str(project_id)), + interaction=interaction, + turns_service=self.turns_service, + streams_service=self.streams_service, ) selector = ( interaction.data.selector.model_dump(mode="json") @@ -2634,6 +2637,8 @@ def __init__( respond_task=respond_task, interactions_dispatcher=interactions_dispatcher, commands_service=commands_service, + turns_service=turns_service, + streams_service=streams_service, ) self.attachments = SessionAttachmentsRouter( attachments_service=attachments_service, diff --git a/api/oss/src/core/sessions/interactions/references.py b/api/oss/src/core/sessions/interactions/references.py new file mode 100644 index 00000000000..b08fcd2bc86 --- /dev/null +++ b/api/oss/src/core/sessions/interactions/references.py @@ -0,0 +1,95 @@ +from typing import Any, Dict, List, Optional +from uuid import UUID + +from oss.src.core.sessions.interactions.dtos import SessionInteraction +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.turns.dtos import SessionTurnQuery +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.types import SessionReference +from oss.src.core.shared.dtos import Windowing +from oss.src.utils.logging import get_module_logger + + +log = get_module_logger(__name__) + + +_EXECUTION_REFERENCE_KEYS = frozenset( + { + "workflow", + "workflow_variant", + "workflow_revision", + "application", + "application_variant", + "application_revision", + "evaluator", + "evaluator_variant", + "evaluator_revision", + } +) + + +def keyed_references( + elements: Optional[List[SessionReference]], +) -> Optional[Dict[str, Any]]: + if not elements: + return None + keyed: Dict[str, Any] = {} + for element in elements: + key = getattr(element, "key", None) + if key not in _EXECUTION_REFERENCE_KEYS or key in keyed: + continue + reference = element.model_dump(mode="json", exclude_none=True) + reference.pop("key", None) + if reference: + keyed[key] = reference + return keyed or None + + +async def resolve_interaction_references( + *, + project_id: UUID, + interaction: SessionInteraction, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, +) -> Optional[Dict[str, Any]]: + data = interaction.data + if data and data.references: + return { + key: reference.model_dump(mode="json") + for key, reference in data.references.items() + } + + if turns_service is not None: + try: + turns = await turns_service.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=interaction.session_id), + windowing=Windowing(limit=1), + ) + except Exception as error: # noqa: BLE001 - fallback reads are best effort + log.warning( + f"[interactions] turn references unavailable for " + f"session={interaction.session_id}: {error}" + ) + turns = [] + if turns: + references = keyed_references(turns[0].references) + if references: + return references + + if streams_service is not None: + try: + stream = await streams_service.fetch_header( + project_id=project_id, + session_id=interaction.session_id, + ) + except Exception as error: # noqa: BLE001 - fallback reads are best effort + log.warning( + f"[interactions] stream references unavailable for " + f"session={interaction.session_id}: {error}" + ) + stream = None + if stream is not None: + return keyed_references(stream.references) + + return None diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 06bd9446c39..c71d14747c9 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -35,13 +35,15 @@ SessionInteractionData, SessionInteractionKind, ) +from oss.src.core.sessions.interactions.references import ( + keyed_references as keyed_references, + resolve_interaction_references, +) from oss.src.core.sessions.records.dtos import SessionRecord from oss.src.core.sessions.records.service import RecordsService from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.streams.service import SessionStreamsService -from oss.src.core.sessions.turns.dtos import SessionTurnQuery from oss.src.core.sessions.turns.service import SessionTurnsService -from oss.src.core.sessions.types import SessionReference from oss.src.core.workflows.dtos import ( WorkflowServiceRequest, WorkflowServiceRequestData, @@ -53,48 +55,6 @@ log = get_module_logger(__name__) -# The reference keys `WorkflowsService._validate_execution_reference_families` accepts. A stored -# session reference list is untyped on purpose (a turn append is fire-and-forget, so rejecting an -# unknown family would drop the whole turn), which is why anything else is dropped here instead -# of being sent into a 400. -_EXECUTION_REFERENCE_KEYS = frozenset( - { - "workflow", - "workflow_variant", - "workflow_revision", - "application", - "application_variant", - "application_revision", - "evaluator", - "evaluator_variant", - "evaluator_revision", - } -) - - -def keyed_references( - elements: Optional[List[SessionReference]], -) -> Optional[Dict[str, Any]]: - """Fold a stored flat reference list back into the keyed map an invoke carries. - - Sessions persist references as a flat list whose family lives in each element's ``key`` - (``session_turns.references``, ``session_streams.references``). An invoke carries the same - identity as a map keyed by family, so the fold is the whole conversion. - """ - if not elements: - return None - keyed: Dict[str, Any] = {} - for element in elements: - key = getattr(element, "key", None) - if key not in _EXECUTION_REFERENCE_KEYS or key in keyed: - continue - reference = element.model_dump(mode="json", exclude_none=True) - reference.pop("key", None) - if reference: - keyed[key] = reference - return keyed or None - - def _user_attachment_blocks(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: """Attachment blocks for one user record, in the runner's wire shape. @@ -380,59 +340,6 @@ def __init__( self.streams_service = streams_service self._dispatch_fn = dispatch_fn - async def _session_references( - self, - *, - project_id: UUID, - session_id: str, - ) -> Optional[Dict[str, Any]]: - """The session's own workflow identity, for a gate row that carries none. - - WHY THIS EXISTS. A resume is a server-side invoke, and the invoke resolves its service - URL from the request's references (`WorkflowsService._ensure_request_revision` -> - `_get_service_url`). A gate row written before `data.references` existed, or by a turn - whose run context had no workflow identity yet, leaves the resume with nothing to - resolve and the continuation fails `Workflow revision has no runnable service URL.` - forever. The turn and stream rows of the SAME session carry the identity the platform - resolved for that run, so read it from there rather than failing. - - Best effort by design: the resume is already durable, and a read that fails here must - not turn into a failed continuation. A session that recorded no identity anywhere still - cannot be resumed server-side; that case is the caller's to report. - """ - if self.turns_service is not None: - try: - turns = await self.turns_service.query_turns( - project_id=project_id, - query=SessionTurnQuery(session_id=session_id), - ) - except Exception as e: # noqa: BLE001 - fallback read is best effort - log.warning( - f"[interactions] turn references unavailable for session={session_id}: {e}" - ) - turns = [] - for turn in turns or []: - references = keyed_references(turn.references) - if references: - return references - - if self.streams_service is not None: - try: - stream = await self.streams_service.fetch_header( - project_id=project_id, - session_id=session_id, - ) - except Exception as e: # noqa: BLE001 - fallback read is best effort - log.warning( - f"[interactions] stream references unavailable for " - f"session={session_id}: {e}" - ) - stream = None - if stream is not None: - return keyed_references(stream.references) - - return None - async def _compose_inputs( self, *, @@ -502,13 +409,11 @@ async def respond_many( interaction, first_answer = resolved[0] data: Optional[SessionInteractionData] = interaction.data - references = ( - {k: v.model_dump(mode="json") for k, v in data.references.items()} - if data and data.references - else await self._session_references( - project_id=project_id, - session_id=interaction.session_id, - ) + references = await resolve_interaction_references( + project_id=project_id, + interaction=interaction, + turns_service=self.turns_service, + streams_service=self.streams_service, ) selector = ( data.selector.model_dump(mode="json") if data and data.selector else None diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 47c3b6f2b63..fc4939624ae 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -829,7 +829,6 @@ async def test_resume_falls_back_to_the_turn_references_when_the_gate_row_has_no turns_service = _turns_service( [ - _session_turn(project_id, references=None), _session_turn( project_id, references=[ @@ -860,6 +859,9 @@ async def test_resume_falls_back_to_the_turn_references_when_the_gate_row_has_no assert invoke_request.references["workflow"].slug == "wf-1" # `key` names the family in the stored list; it is not a field of a wire Reference. assert not hasattr(invoke_request.references["workflow"], "key") + turn_query = turns_service.query_turns.await_args.kwargs + assert turn_query["query"].session_id == interaction.session_id + assert turn_query["windowing"].limit == 1 async def test_resume_falls_back_to_the_stream_references_when_no_turn_carries_any(): diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py index a063615ffcc..95d8c6a8962 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py @@ -9,6 +9,7 @@ import asyncio from uuid import uuid4 +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -214,3 +215,49 @@ async def test_no_worker_fallback_routes_through_the_dispatcher(): answer={"approved": True}, ) workflows_service.invoke_workflow.assert_not_awaited() + + +async def test_inline_last_resort_uses_the_shared_session_reference_resolver(): + from oss.src.core.sessions.types import SessionReference + + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="sess-1", + token="tok-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + service = _RacyInteractionsService(interaction=interaction) + workflows_service = AsyncMock() + turns_service = AsyncMock() + turns_service.query_turns.return_value = [ + SimpleNamespace( + references=[SessionReference(key="workflow", slug="agent-from-turn")] + ) + ] + router = InteractionsRouter( + interactions_service=service, + workflows_service=workflows_service, + turns_service=turns_service, + ) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert invoke_request.references["workflow"].slug == "agent-from-turn" + assert turns_service.query_turns.await_args.kwargs["windowing"].limit == 1 From 930de8b0789d690cfe10b6df28f4e3778bb781fc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 21:19:47 +0200 Subject: [PATCH 039/133] fix(web): drain held queue after continuation terminal Symptom: A message held during an approval remained as '1 queued message' after the continuation finished and never sent. Cause: Record replay preserved the approval-responded part but discarded the terminal done marker, so queue release kept classifying the settled transcript as a pre-resume window. Fix: Preserve non-paused terminal records in message metadata and let a ready queue release once that durable terminal marker is present and no actionable gate remains. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- STATUS-round7.md | 48 +++++++++++++++++++ .../src/assets/transcriptToMessages.ts | 8 +++- .../unit/assets/transcriptToMessages.test.ts | 6 ++- .../unit/hooks/useAgentChatQueue.test.ts | 34 +++++++++++++ .../src/state/execution/agentMessageQueue.ts | 7 +++ .../tests/unit/agentMessageQueue.test.ts | 12 +++++ 6 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 STATUS-round7.md diff --git a/STATUS-round7.md b/STATUS-round7.md new file mode 100644 index 00000000000..9295311f0b5 --- /dev/null +++ b/STATUS-round7.md @@ -0,0 +1,48 @@ +# Round 7 status + +Branch: `feat/session-durable-approvals` + +Implemented as seven buildable commits after `5700408966`: + +1. Cross-reader interaction events bypass the general refetch throttle, then refetch and reconcile + settled rows into mounted transcripts. +2. Held messages remain visible during gates; recoverable Sends retry the durable continuation. +3. A terminal server transcript clears the desktop approval dock even if the row cache is stale. +4. Approval response and recovery state is scoped to the interaction that produced it. +5. Initial and background transcript hydration replay against fresh interaction rows. +6. Dispatcher and inline router fallback share bounded reference resolution (latest turn, then stream). +7. Replayed terminal records release the held queue after a completed continuation. + +## Browser re-check + +- Open one pending approval in two desktop tabs. Answer in tab B. Tab A must leave the actionable + "Needs your approval" state within one second without a second click, then clear after the + continuation's terminal record. +- Repeat with mobile answering and desktop observing. The desktop result must match the two-desktop + case. +- While a gate is open, send a message. A visible `1 queued message · waits for your answer` card + must appear immediately on desktop and mobile. +- Force a recoverable approval response, then Send. The Send must redeliver the saved continuation, + keep the typed message visible in the held queue, and must not start a competing fresh turn or + create a `continuation_resumed` failure bubble. +- After that continuation writes its terminal record, the approval dock must close and the held + message must leave the queue and run exactly once as the next turn. +- After any recoverable interaction, start a new approval whose continuation succeeds. The new card + must show ordinary pending/answered copy, never inherited "retry needed" copy. +- Reload a session whose interaction row is `responded` or `resolved`. No actionable approval card + may reappear. +- Exercise a legacy/reference-less gate through the inline fallback composition. The continuation + must resolve the newest turn's workflow reference (or the stream fallback) and invoke normally. + +## Automated verification + +- `@agenta/chat`: 648 passed. +- `@agenta/oss`: 429 passed, 1 skipped. +- `@agenta/entities`: 1,480 unit tests passed; 31 integration tests skipped because the required + API/auth environment was not configured. +- `@agenta/mobile`: 147 passed. +- `@agenta/sessions`: 71 passed. +- Chat, OSS, entities, and mobile typechecks passed. +- Monorepo frontend lint passed (four pre-existing mobile hook warnings remain). +- API sessions: 706 passed. +- Ruff 0.15.12 format check: 1,491 files formatted; Ruff check passed. diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index 923636b8898..520a3c0a462 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -66,6 +66,8 @@ interface DraftMessage { paused?: boolean /** The turn paused for approval and then RESUMED to completion (a second, non-paused `done`). */ resumed?: boolean + /** A non-paused durable `done` closed this turn. */ + recordTerminal?: boolean /** The turn's persisted `error` event — replayed through the same `metadata.runError` channel * the live stream stamps, so a failure renders as the error bubble, not as body text. */ runError?: string @@ -632,7 +634,10 @@ export function transcriptToMessages( } // A resumed-then-completed turn is no longer paused. if (current?.paused) current.resumed = true - if (current) current.paused = false + if (current) { + current.paused = false + current.recordTerminal = true + } current = null continue } @@ -668,6 +673,7 @@ export function transcriptToMessages( if (d.usage) metadata.usage = d.usage if (d.paused) metadata.paused = true if (d.runStopped) metadata.runStopped = true + if (d.recordTerminal) metadata.recordTerminal = true if (d.runError && !d.runStopped) metadata.runError = { message: d.runError, diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 85b1e7e50a4..89560c2d2d2 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -881,12 +881,16 @@ describe("transcriptToMessages interaction-row precedence", () => { it("still settles a resumed turn's gate when no row carries a verdict", () => { // The sweep's own job, unchanged: a resumed gate must not replay as still awaiting the user. - const parts = allParts(resumedApprovalRecords()) + const messages = transcriptToMessages(resumedApprovalRecords()) ?? [] + const parts = messages.flatMap( + (message) => message.parts as unknown as Record[], + ) expect(parts.some((part) => part.state === "approval-requested")).toBe(false) expect(parts.find((part) => part.toolCallId === "tool-1")).toMatchObject({ state: "approval-responded", }) + expect(messages.at(-1)?.metadata).toMatchObject({recordTerminal: true}) }) it("keeps an answered approval row's approved verdict", () => { diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 3583ce134b8..87494eba767 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -32,6 +32,21 @@ const assistantAwaitingApproval = (id: string): UIMessage => ], }) as unknown as UIMessage +const assistantTerminalAfterApproval = (id: string): UIMessage => + ({ + ...assistantAwaitingApproval(id), + metadata: {recordTerminal: true}, + parts: [ + { + type: "tool-send_email", + state: "approval-responded", + toolCallId: `${id}-call`, + input: {to: "a@b.c"}, + approval: {id: `${id}-approval`, approved: true}, + }, + ], + }) as unknown as UIMessage + interface HarnessProps { status: string messages: UIMessage[] @@ -180,6 +195,25 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("drains a held message after the continuation terminal record", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, sendQueued} = setup(paused) + act(() => result.current.submit({text: "after continuation"})) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantTerminalAfterApproval("a1")], + }) + + expect(sendQueued).toHaveBeenCalledOnce() + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "after continuation"}) + expect(result.current.queued).toHaveLength(0) + }) + it("a user stop voids the HITL hold: settled sends go immediately and hitlPending clears", () => { const stoppedPaused: HarnessProps = { status: "ready", diff --git a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts index 05ea701ecf5..bb04bcc92fc 100644 --- a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts +++ b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts @@ -25,6 +25,7 @@ interface ToolPartLike { interface MessageLike { role?: string parts?: ToolPartLike[] + metadata?: unknown } const isToolPart = (part: ToolPartLike): boolean => { @@ -77,6 +78,12 @@ export function messageHasPendingHitl(message: MessageLike): boolean { */ export function canReleaseQueuedMessage(status: string, messages: MessageLike[]): boolean { if (status === "error") return !isHitlPending(messages) + const lastAssistant = messages.findLast((message) => message.role === "assistant") + const recordTerminal = (lastAssistant?.metadata as {recordTerminal?: unknown} | undefined) + ?.recordTerminal + if (status === "ready" && recordTerminal === true) { + return !isHitlPending(messages) + } return ( status === "ready" && !isHitlPending(messages) && diff --git a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts index b8544ec9dde..085787e85e2 100644 --- a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts @@ -221,4 +221,16 @@ describe("canReleaseQueuedMessage", () => { ]), ).toBe(true) }) + + it("releases an answered approval after its durable terminal record", () => { + expect( + canReleaseQueuedMessage("ready", [ + user("do it"), + { + ...assistantWithTool("approval-responded", true), + metadata: {recordTerminal: true}, + }, + ]), + ).toBe(true) + }) }) From cf0bc4a735b94285c669141a8b01705f31ce1b34 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 22:11:22 +0200 Subject: [PATCH 040/133] fix(web): hold queued messages through continuation Track durable continuation executions separately from their paused source turns, and release held messages only after the continuation terminal record. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/assets/transcriptToMessages.ts | 78 +++++++++++++++-- .../tests/unit/assets/loadSession.test.ts | 1 + .../unit/assets/transcriptToMessages.test.ts | 86 ++++++++++++++++++- .../unit/hooks/useAgentChatQueue.test.ts | 31 ++++++- .../src/session/core/schema.ts | 2 + .../tests/unit/session-record-schema.test.ts | 3 + .../src/state/execution/agentMessageQueue.ts | 21 +++++ .../tests/unit/agentMessageQueue.test.ts | 18 ++++ 8 files changed, 226 insertions(+), 14 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index 520a3c0a462..426350255b1 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -68,6 +68,14 @@ interface DraftMessage { resumed?: boolean /** A non-paused durable `done` closed this turn. */ recordTerminal?: boolean + /** The durable approval resumed under a separate execution; queue release follows this one. */ + approvalContinuation?: { + sourceExecutionId: string + executionId: string + state: "running" | "done" | "error" + } + /** Execution id of the paused approval turn, kept internal while replay associates its resume. */ + pausedExecutionId?: string /** The turn's persisted `error` event — replayed through the same `metadata.runError` channel * the live stream stamps, so a failure renders as the error bubble, not as body text. */ runError?: string @@ -590,6 +598,11 @@ export function transcriptToMessages( ): UIMessage[] | null { const drafts: DraftMessage[] = [] let current: DraftMessage | null = null + let latestPaused: DraftMessage | null = null + const draftsByExecution = new Map< + string, + {user?: DraftMessage; assistant?: DraftMessage; hasUser: boolean} + >() // Paused resumes close the draft, but later answers and results still target its tool part. const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} @@ -597,6 +610,7 @@ export function transcriptToMessages( const payload = row.payload if (!payload || typeof payload !== "object") continue const p = payload as Record + const executionId = row.turn_id ?? undefined // Speculative trace link (no-op until the backend stamps one) — the id can ride the `done` // row too, so read it before the turn closes. const traceId = extractTraceId(row, p) @@ -604,18 +618,22 @@ export function transcriptToMessages( // this every turn folds into one assistant bubble; closing the draft here starts a // fresh message per turn. if (row.session_update === "done" || p.type === "done") { + const target: DraftMessage | null = + (executionId ? draftsByExecution.get(executionId)?.assistant : undefined) ?? current // Last-wins: a paused turn folds into its resume (below), and that turn has two `done`s // with two traceIds — prefer the RESUME trace, where the approved tool actually executed. // A normal turn has a single `done`, so this is unchanged for it. - if (current && traceId) current.traceId = traceId - if (current && p.stopReason === "paused") { + if (target && traceId) target.traceId = traceId + if (target && p.stopReason === "paused") { // Paused mid-approval: the resume turn's records (the re-emitted call, its result, // the follow-up text) belong to the SAME assistant turn the user saw live, so keep // the draft OPEN and let them fold into it instead of splitting into a dangling // "awaiting approval" bubble + a resumed bubble. A paused turn blocks the session, // so it's always followed by its own resume or is the last (abandoned) turn. Mark it // paused for the adoption heuristic; the normal `done` below clears it on resume. - current.paused = true + target.paused = true + target.pausedExecutionId = executionId + latestPaused = target continue } if (p.stopReason === "cancelled") { @@ -633,21 +651,62 @@ export function transcriptToMessages( continue } // A resumed-then-completed turn is no longer paused. - if (current?.paused) current.resumed = true - if (current) { - current.paused = false - current.recordTerminal = true + if ( + target?.approvalContinuation && + target.approvalContinuation.executionId === executionId + ) { + target.approvalContinuation.state = "done" } - current = null + if (target?.paused) target.resumed = true + if (target) { + target.paused = false + target.recordTerminal = true + } + if (latestPaused === target) latestPaused = null + if (current === target) current = null continue } const role = roleOf(row.sender) - if (!current || current.role !== role) { + if (executionId) { + let execution = draftsByExecution.get(executionId) + if (!execution) { + execution = {hasUser: false} + draftsByExecution.set(executionId, execution) + } + if (role === "user") execution.hasUser = true + current = execution[role] ?? null + if (!current && role === "assistant" && latestPaused && !execution.hasUser) { + current = latestPaused + execution.assistant = current + if ( + latestPaused.pausedExecutionId && + latestPaused.pausedExecutionId !== executionId + ) { + latestPaused.approvalContinuation = { + sourceExecutionId: latestPaused.pausedExecutionId, + executionId, + state: "running", + } + } + } + if (!current) { + current = newDraft(row.id, role) + execution[role] = current + drafts.push(current) + } + } else if (!current || current.role !== role) { current = newDraft(row.id, role) drafts.push(current) } if (traceId && !current.traceId) current.traceId = traceId applyEvent(current, p, index, row.session_id) + if ( + p.type === "error" && + current.approvalContinuation && + current.approvalContinuation.executionId === executionId + ) { + current.approvalContinuation.state = "error" + } } // Recorded results win; otherwise saved answers, neutral terminal state, then pending. @@ -674,6 +733,7 @@ export function transcriptToMessages( if (d.paused) metadata.paused = true if (d.runStopped) metadata.runStopped = true if (d.recordTerminal) metadata.recordTerminal = true + if (d.approvalContinuation) metadata.approvalContinuation = d.approvalContinuation if (d.runError && !d.runStopped) metadata.runError = { message: d.runError, diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts index c9d4c0c1aa8..f7e635d6d7d 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -26,6 +26,7 @@ const record = (id: string, payload: Record, sender = "agent"): sender, session_update: String(payload.type), payload, + turn_id: null, created_at: null, }) diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 89560c2d2d2..3cc53532ed9 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -4,6 +4,7 @@ import type { SessionRecord, } from "@agenta/entities/session" import {CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} from "@agenta/shared/clientTools" +import type {UIMessage} from "ai" import {describe, expect, it} from "vitest" import { @@ -14,7 +15,12 @@ import { import abandonedFormSession from "./__fixtures__/abandonedFormSession.json" -const record = (id: string, payload: Record, sender = "agent"): SessionRecord => ({ +const record = ( + id: string, + payload: Record, + sender = "agent", + turnId: string | null = null, +): SessionRecord => ({ id, session_id: "session-1", project_id: "project-1", @@ -22,9 +28,15 @@ const record = (id: string, payload: Record, sender = "agent"): sender, session_update: String(payload.type), payload, + turn_id: turnId, created_at: null, }) +const firstAssistantMetadata = (messages: UIMessage[] | null): Record | undefined => + messages?.find((message) => message.role === "assistant")?.metadata as + | Record + | undefined + describe("transcriptToMessages", () => { it("replays the approved-content manifest as the egress's sibling data part", () => { // `tool-approval-request` is a strict object, so the manifest cannot ride the approval @@ -243,6 +255,78 @@ const approvalRecords = (): SessionRecord[] => [ * turn the user already answered. */ describe("transcriptToMessages approval resume", () => { + it("tracks a durable continuation by its own execution through running and terminal records", () => { + const source = [ + record("r-user", {type: "message", text: "run it"}, "user", "source-turn"), + record( + "r-call", + {type: "tool_call", id: "tool-1", name: "bash", input: {}}, + "agent", + "source-turn", + ), + record( + "r-req", + { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }, + "agent", + "source-turn", + ), + record( + "r-source-done", + {type: "done", stopReason: "paused"}, + "agent", + "source-turn", + ), + ] + const running = [ + ...source, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + record( + "r-response", + { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-2", approved: true}, + }, + "agent", + "continuation-turn", + ), + ] + + expect(firstAssistantMetadata(transcriptToMessages(running))).toMatchObject({ + paused: true, + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "running", + }, + }) + + const finished = transcriptToMessages([ + ...running, + record("r-result", {type: "tool_result", id: "tool-2", output: "ok"}, "agent", "continuation-turn"), + record("r-continuation-done", {type: "done"}, "agent", "continuation-turn"), + ]) + expect(firstAssistantMetadata(finished)).toMatchObject({ + recordTerminal: true, + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + }, + }) + }) + it("merges a paused turn with its resume into one message and settles the re-emitted call once", () => { // Real cold-replay shape (verified against records): a Write call pauses for approval, the // turn ends stopReason:"paused", then the resume turn RE-EMITS the same call id, settles it, diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 87494eba767..cf4a0b52e94 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -32,10 +32,20 @@ const assistantAwaitingApproval = (id: string): UIMessage => ], }) as unknown as UIMessage -const assistantTerminalAfterApproval = (id: string): UIMessage => +const assistantContinuation = ( + id: string, + state: "running" | "done" | "error", +): UIMessage => ({ ...assistantAwaitingApproval(id), - metadata: {recordTerminal: true}, + metadata: { + ...(state === "done" ? {recordTerminal: true} : {}), + approvalContinuation: { + sourceExecutionId: `${id}-source-execution`, + executionId: `${id}-continuation-execution`, + state, + }, + }, parts: [ { type: "tool-send_email", @@ -195,7 +205,7 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) - it("drains a held message after the continuation terminal record", () => { + it("holds through a different continuation execution and drains once after its terminal", () => { const paused: HarnessProps = { status: "ready", messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], @@ -206,12 +216,25 @@ describe("useAgentChatQueue", () => { rerender({ ...paused, - messages: [userTurn("u1", "go"), assistantTerminalAfterApproval("a1")], + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], }) expect(sendQueued).toHaveBeenCalledOnce() expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "after continuation"}) expect(result.current.queued).toHaveLength(0) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], + }) + expect(sendQueued).toHaveBeenCalledOnce() }) it("a user stop voids the HITL hold: settled sends go immediately and hitlPending clears", () => { diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index ec5ea3f6af2..6316c24a329 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -26,6 +26,7 @@ export const sessionRecordSchema = z record_source: z.string().nullish(), record_type: z.string().nullish(), attributes: z.record(z.string(), z.unknown()).nullish(), + turn_id: z.string().nullish(), timestamp: z.string().nullish(), created_at: z.string().nullish(), }) @@ -38,6 +39,7 @@ export const sessionRecordSchema = z sender: r.record_source ?? null, session_update: r.record_type ?? null, payload: r.attributes ?? null, + turn_id: r.turn_id ?? null, created_at: r.created_at ?? r.timestamp ?? null, })) diff --git a/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts b/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts index 4b65ab4b26e..5c85229ab87 100644 --- a/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts @@ -21,6 +21,7 @@ const wireRecord = { record_source: "runner", record_type: "message", attributes: {type: "message", text: "hello"}, + turn_id: "turn-1", timestamp: "2026-07-07T00:00:00Z", created_at: "2026-07-07T00:00:01Z", } @@ -34,6 +35,7 @@ describe("sessionRecordSchema", () => { expect(out.payload).toEqual({type: "message", text: "hello"}) expect(out.event_index).toBe(3) expect(out.session_update).toBe("message") + expect(out.turn_id).toBe("turn-1") expect(out.created_at).toBe("2026-07-07T00:00:01Z") }) @@ -63,6 +65,7 @@ describe("sessionRecordSchema", () => { expect(out.id).toBe("rec-2") expect(out.payload).toEqual({type: "thought", text: "…"}) expect(out.sender).toBeNull() + expect(out.turn_id).toBeNull() }) it("validates the query response envelope and remaps each record", () => { diff --git a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts index bb04bcc92fc..2fcbaa60c72 100644 --- a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts +++ b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts @@ -28,6 +28,22 @@ interface MessageLike { metadata?: unknown } +type ApprovalContinuationState = "running" | "done" | "error" + +const latestApprovalContinuationState = ( + messages: MessageLike[], +): ApprovalContinuationState | undefined => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const state = ( + messages[i]?.metadata as + | {approvalContinuation?: {state?: ApprovalContinuationState}} + | undefined + )?.approvalContinuation?.state + if (state) return state + } + return undefined +} + const isToolPart = (part: ToolPartLike): boolean => { const type = part?.type return typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool") @@ -77,7 +93,12 @@ export function messageHasPendingHitl(message: MessageLike): boolean { * freeze the queue permanently. `isHitlPending` still holds — its dock IS the unblock UI. */ export function canReleaseQueuedMessage(status: string, messages: MessageLike[]): boolean { + const continuationState = latestApprovalContinuationState(messages) + if (continuationState === "running") return false if (status === "error") return !isHitlPending(messages) + if (status === "ready" && (continuationState === "done" || continuationState === "error")) { + return !isHitlPending(messages) + } const lastAssistant = messages.findLast((message) => message.role === "assistant") const recordTerminal = (lastAssistant?.metadata as {recordTerminal?: unknown} | undefined) ?.recordTerminal diff --git a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts index 085787e85e2..4cd35896682 100644 --- a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts @@ -233,4 +233,22 @@ describe("canReleaseQueuedMessage", () => { ]), ).toBe(true) }) + + it("holds an answered approval while its continuation execution is running", () => { + expect( + canReleaseQueuedMessage("ready", [ + user("do it"), + { + ...assistantWithTool("approval-responded", true), + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "running", + }, + }, + }, + ]), + ).toBe(false) + }) }) From 3fc86c5c5877b1794905a7d250191cc026dbc568 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 22:12:14 +0200 Subject: [PATCH 041/133] fix(web): retire observer approvals on continuation Treat the first record from a continuation execution as proof that its source approval was answered, while preserving interaction-response settlement when present. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/assets/transcriptToMessages.ts | 5 +- .../unit/assets/transcriptToMessages.test.ts | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index 426350255b1..ccbf7c09030 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -713,8 +713,11 @@ export function transcriptToMessages( applyInteractionRowStates(index, options?.interactionRowStates) // A resumed turn's remaining approval gate was answered even when its response row is absent. + // A continuation turn proves the same thing: the runner emits no records under its new + // execution before the durable answer owns it, so an observer retires the card on the first + // continuation frame instead of waiting for that optional event or `done`. for (const d of drafts) { - if (!d.resumed) continue + if (!d.resumed && !d.approvalContinuation) continue for (const part of d.parts) { if (part.state === "approval-requested") part.state = "approval-responded" } diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 3cc53532ed9..2fb761ee7b5 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -255,6 +255,73 @@ const approvalRecords = (): SessionRecord[] => [ * turn the user already answered. */ describe("transcriptToMessages approval resume", () => { + it("retires tab A's pending card on the first continuation frame without a response event", () => { + const pendingRecords = [ + record("r-user", {type: "message", text: "run it"}, "user", "source-turn"), + record( + "r-call", + {type: "tool_call", id: "tool-1", name: "bash", input: {}}, + "agent", + "source-turn", + ), + record( + "r-req", + { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }, + "agent", + "source-turn", + ), + record( + "r-source-done", + {type: "done", stopReason: "paused"}, + "agent", + "source-turn", + ), + ] + const pendingParts = transcriptToMessages(pendingRecords)![1] + .parts as unknown as Record[] + expect(pendingParts).toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + + const continuationRunning = transcriptToMessages([ + ...pendingRecords, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + ])! + expect(continuationRunning.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + expect(firstAssistantMetadata(continuationRunning)).toMatchObject({ + approvalContinuation: {executionId: "continuation-turn", state: "running"}, + }) + + const continuationDone = transcriptToMessages([ + ...pendingRecords, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + record("r-continuation-done", {type: "done"}, "agent", "continuation-turn"), + ])! + expect(continuationDone.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + expect(firstAssistantMetadata(continuationDone)).toMatchObject({ + approvalContinuation: {executionId: "continuation-turn", state: "done"}, + }) + }) + it("tracks a durable continuation by its own execution through running and terminal records", () => { const source = [ record("r-user", {type: "message", text: "run it"}, "user", "source-turn"), From 0b6b3b465635bae279cbf3cef75e4b63333c07e6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 22:16:36 +0200 Subject: [PATCH 042/133] fix(web): retire approval dock on continuation end Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/assets/transcriptToMessages.ts | 22 ++++++++- .../agenta-chat/src/model/approvals.ts | 26 +++++++++- .../unit/assets/transcriptToMessages.test.ts | 14 +++++- .../unit/hooks/useAgentChatQueue.test.ts | 1 + .../tests/unit/hooks/useApprovalDock.test.ts | 39 +++++++++++++++ .../tests/unit/model/approvals.test.ts | 49 +++++++++++++++++++ .../tests/unit/agentMessageQueue.test.ts | 1 + 7 files changed, 148 insertions(+), 4 deletions(-) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index ccbf7c09030..ea2d7dcc2a8 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -73,6 +73,7 @@ interface DraftMessage { sourceExecutionId: string executionId: string state: "running" | "done" | "error" + approvalIds: string[] } /** Execution id of the paused approval turn, kept internal while replay associates its resume. */ pausedExecutionId?: string @@ -133,6 +134,14 @@ const newDraft = (id: string, role: "user" | "assistant"): DraftMessage => ({ reasoning: new Map(), }) +const pendingApprovalIds = (draft: DraftMessage): string[] => + draft.parts.flatMap((part) => { + const approval = part.approval as {id?: unknown} | undefined + return part.state === "approval-requested" && typeof approval?.id === "string" + ? [approval.id] + : [] + }) + const toolPartType = (name?: string | null): string => (name ? `tool-${name}` : "dynamic-tool") /** Envelope keys an MCP-style `tool_call` record wraps its real arguments in. */ @@ -686,6 +695,7 @@ export function transcriptToMessages( sourceExecutionId: latestPaused.pausedExecutionId, executionId, state: "running", + approvalIds: pendingApprovalIds(latestPaused), } } } @@ -718,8 +728,18 @@ export function transcriptToMessages( // continuation frame instead of waiting for that optional event or `done`. for (const d of drafts) { if (!d.resumed && !d.approvalContinuation) continue + const continuationApprovalIds = d.approvalContinuation + ? new Set(d.approvalContinuation.approvalIds) + : null for (const part of d.parts) { - if (part.state === "approval-requested") part.state = "approval-responded" + const approval = part.approval as {id?: unknown} | undefined + if ( + part.state === "approval-requested" && + (!continuationApprovalIds || + (typeof approval?.id === "string" && continuationApprovalIds.has(approval.id))) + ) { + part.state = "approval-responded" + } } } diff --git a/web/packages/agenta-chat/src/model/approvals.ts b/web/packages/agenta-chat/src/model/approvals.ts index bf308586c5b..102befbfe2a 100644 --- a/web/packages/agenta-chat/src/model/approvals.ts +++ b/web/packages/agenta-chat/src/model/approvals.ts @@ -48,11 +48,35 @@ export const getPendingApprovals = (messages: UIMessage[]): PendingApproval[] => const out: PendingApproval[] = [] for (const message of messages) { if (message.role !== "assistant") continue + const continuation = ( + message.metadata as + | { + approvalContinuation?: { + state?: string + approvalIds?: unknown + } + } + | undefined + )?.approvalContinuation + const terminalApprovalIds = + (continuation?.state === "done" || continuation?.state === "error") && + Array.isArray(continuation.approvalIds) + ? new Set( + continuation.approvalIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ), + ) + : null const manifests = manifestsByToolCallId(message.parts) for (const part of message.parts ?? []) { const p = part as ToolUIPart const approval = (p as {approval?: ApprovalRef}).approval - if (isToolPart(p.type as string) && p.state === "approval-requested" && approval?.id) { + if ( + isToolPart(p.type as string) && + p.state === "approval-requested" && + approval?.id && + !terminalApprovalIds?.has(approval.id) + ) { out.push({ approvalId: approval.id, toolName: partToolName(p), diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 2fb761ee7b5..49ee1e3ab17 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -301,7 +301,11 @@ describe("transcriptToMessages approval resume", () => { expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), ) expect(firstAssistantMetadata(continuationRunning)).toMatchObject({ - approvalContinuation: {executionId: "continuation-turn", state: "running"}, + approvalContinuation: { + executionId: "continuation-turn", + state: "running", + approvalIds: ["approval-1"], + }, }) const continuationDone = transcriptToMessages([ @@ -318,7 +322,11 @@ describe("transcriptToMessages approval resume", () => { expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), ) expect(firstAssistantMetadata(continuationDone)).toMatchObject({ - approvalContinuation: {executionId: "continuation-turn", state: "done"}, + approvalContinuation: { + executionId: "continuation-turn", + state: "done", + approvalIds: ["approval-1"], + }, }) }) @@ -376,6 +384,7 @@ describe("transcriptToMessages approval resume", () => { sourceExecutionId: "source-turn", executionId: "continuation-turn", state: "running", + approvalIds: ["approval-1"], }, }) @@ -390,6 +399,7 @@ describe("transcriptToMessages approval resume", () => { sourceExecutionId: "source-turn", executionId: "continuation-turn", state: "done", + approvalIds: ["approval-1"], }, }) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index cf4a0b52e94..a4c6ef3f54e 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -44,6 +44,7 @@ const assistantContinuation = ( sourceExecutionId: `${id}-source-execution`, executionId: `${id}-continuation-execution`, state, + approvalIds: [`${id}-approval`], }, }, parts: [ diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index 7389383606f..d89df5f4825 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom import {act, renderHook} from "@testing-library/react" +import type {SessionInteractionRowStates} from "@agenta/entities/session" import type {UIMessage} from "ai" import {describe, expect, it, vi} from "vitest" +import {reconcileInteractionRowStates} from "../../../src/assets/transcriptToMessages" import {useApprovalDock} from "../../../src/hooks/useApprovalDock" const gatePart = (approvalId: string, toolName = "send_email") => ({ @@ -130,6 +132,43 @@ describe("useApprovalDock", () => { expect(result.current.current?.approvalId).toBe("g1") }) + it("closes when the continuation reaches a terminal record", () => { + const terminal = { + ...assistantWithGates("g1"), + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + approvalIds: ["g1"], + }, + }, + } as UIMessage + + const {result} = setup([userTurn, terminal]) + + expect(result.current.open).toBe(false) + }) + + it("closes when another reader resolves the interaction row", () => { + const pending = assistantWithGates("g1") + const rows: SessionInteractionRowStates = new Map([ + [ + "g1", + { + token: "g1", + kind: "user_approval", + status: "resolved", + resolution: {verdict: "approved"}, + }, + ], + ]) + const reconciled = reconcileInteractionRowStates([pending], rows) + const {result} = setup(reconciled) + + expect(result.current.open).toBe(false) + }) + it("moves from sending to answered only after the response promise resolves", async () => { let accept: (() => void) | undefined const respond = vi.fn( diff --git a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts index ef15add4998..fbe9835fe6f 100644 --- a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts @@ -31,4 +31,53 @@ describe("getPendingApprovals", () => { it("is empty for an empty message list", () => { expect(getPendingApprovals([])).toEqual([]) }) + + it.each(["done", "error"])( + "retires a stale approval after its continuation is %s", + (state) => { + const [, message] = approvalTurnFixture as UIMessage[] + const stale = { + ...message, + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state, + approvalIds: ["appr_1", "appr_2"], + }, + }, + } as UIMessage + + expect(getPendingApprovals([stale])).toEqual([]) + }, + ) + + it("keeps a later interaction out of an earlier continuation's terminal sweep", () => { + const [, message] = approvalTurnFixture as UIMessage[] + const withLaterGate = { + ...message, + parts: [ + ...message.parts, + { + type: "tool-create_issue", + toolCallId: "call_4", + state: "approval-requested", + input: {title: "Follow-up"}, + approval: {id: "appr_3"}, + }, + ], + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + approvalIds: ["appr_1", "appr_2"], + }, + }, + } as UIMessage + + expect(getPendingApprovals([withLaterGate])).toEqual([ + {approvalId: "appr_3", toolName: "create_issue", input: {title: "Follow-up"}}, + ]) + }) }) diff --git a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts index 4cd35896682..6c6c784f48b 100644 --- a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts @@ -245,6 +245,7 @@ describe("canReleaseQueuedMessage", () => { sourceExecutionId: "source-turn", executionId: "continuation-turn", state: "running", + approvalIds: ["perm_1"], }, }, }, From 73235aee064a8e7f96d6e8792f9aff51bf6a1f58 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 22:23:25 +0200 Subject: [PATCH 043/133] test(web): isolate recoverable approval state Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- STATUS-round8.md | 73 +++++++++++++++++++ .../components/ApprovalDock.test.tsx | 29 ++++++++ 2 files changed, 102 insertions(+) create mode 100644 STATUS-round8.md diff --git a/STATUS-round8.md b/STATUS-round8.md new file mode 100644 index 00000000000..6b5d8860cdd --- /dev/null +++ b/STATUS-round8.md @@ -0,0 +1,73 @@ +# Round 8 status + +Branch: `feat/session-durable-approvals` + +Round 8 fixes the browser-visible source/continuation identity mismatch. Session records now retain +their `turn_id`; replay associates a paused source execution with the new execution that continues +it, and the source message carries that continuation's running or terminal lifecycle. Approval +retirement is scoped to the interaction IDs that the continuation inherited. + +## Browser proof + +Use the same desktop and mobile routes, agent, and durable-approval setup as the Round 7 re-check. +Reload each page before starting so the browser loads this head. + +### 1. Held message waits for the continuation terminal record + +1. In one desktop tab, send a prompt that asks the agent to run a Bash command that waits briefly + before printing a unique marker, so the approval continuation remains visibly in flight. +2. While the approval card is pending, type and send a second, uniquely identifiable message. +3. Confirm the second message appears in `1 queued message · waits for your answer` and does not + start a user turn. +4. Click **Approve**. While the Bash activity is running, confirm the queued card and its text remain + visible and no Stop/steer request is sent for the queued text. +5. Wait for the Bash output and the continuation's terminal `done` record. Only then confirm the + queued text starts one normal user turn, the queued chip clears, and the Bash result is not + `INTERRUPTED_BY_USER`. +6. Keep the page open for another 30 seconds and confirm the queued text is not sent a second time. + +### 2. A non-answering tab retires the approval card + +1. Open the same pending-approval session in desktop tabs A and B. +2. In tab B, click **Approve** and note the interaction ID, source turn ID, and continuation turn ID + in the Network response/record stream. +3. Do not click anything in tab A. Confirm tab A receives continuation records whose `turn_id` + differs from the source turn, including the `interaction_response` when present. +4. On the first continuation record, confirm tab A removes the live Approve/Deny controls rather + than leaving `Needs your approval` actionable. +5. Wait for the continuation output and terminal record. Confirm tab A renders the output and the + approval card remains retired. Repeat once with mobile answering and desktop observing. + +### 3. The desktop dock closes on continuation completion + +1. In one desktop tab, create a pending Bash approval and click **Approve**. +2. While the continuation runs, confirm the card may show `Answered, waiting for the agent`. +3. Inspect the session records and identify the terminal `done` or `error` record on the new + continuation `turn_id`, not the paused source `turn_id`. +4. Confirm the dock closes when that terminal record arrives. It must not remain visible for the + extra 60 seconds seen in Round 7. +5. Repeat with a second tab answering the gate. Confirm the observing tab also closes the dock when + its interaction-row snapshot reaches `responded`/`resolved`, even before a terminal record. + +### 4. Recoverable copy is scoped to one interaction + +1. Create approval interaction X and force its response endpoint onto the recoverable path used by + the Round 7 card test. +2. Answer X and confirm its card says `Answer saved, retry needed`. +3. In the same tab, advance to a later approval interaction Y with a different interaction ID. +4. Confirm Y opens with ordinary `Needs your approval` copy and live buttons. It must not inherit + X's recoverable text, answered state, disabled buttons, or error text. +5. Answer Y on a normal continuation and confirm it uses `Answered, waiting for the agent` only + while its own continuation is active, then closes on Y's terminal record. + +## Automated verification + +- `@agenta/chat`: 655 passed. +- `@agenta/oss`: 430 passed, 1 skipped. +- `@agenta/entities`: 1,480 unit tests passed; 31 integration tests skipped because the required + API/auth integration environment was not configured. +- `@agenta/mobile`: 147 passed. +- `@agenta/playground`: 267 passed. +- Chat, OSS, entities, mobile, and playground typechecks passed. +- Monorepo frontend lint passed (four pre-existing mobile hook warnings remain). +- No API files changed, so the dedicated-DB API sessions suite and Ruff were not applicable. diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx index 1b36f5f9447..b47dccbf02a 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx @@ -73,6 +73,35 @@ describe("no pending gate", () => { }) describe("interaction-scoped response state", () => { + it("does not carry a settled recoverable card from interaction X to later interaction Y", async () => { + const onApprovalResponse = () => Promise.resolve({durable: true, recoverable: true}) + const host = document.createElement("div") + document.body.appendChild(host) + const root = createRoot(host) + const renderGate = (id: string) => ( + + ) + + await act(async () => root.render(renderGate("interaction-x"))) + const approveButton = [...host.querySelectorAll("button")].find((button) => + button.textContent?.includes("Approve"), + ) as HTMLButtonElement + await act(async () => approveButton.click()) + expect(host.textContent).toContain("Answer saved, retry needed") + + await act(async () => root.render(renderGate("interaction-y"))) + + expect(host.textContent).toContain("Needs your approval") + expect(host.textContent).not.toContain("Answer saved, retry needed") + + await act(async () => root.unmount()) + host.remove() + }) + it("does not leak a late recoverable result onto the next desktop gate", async () => { let resolveFirst: ((value: {durable: boolean; recoverable: boolean}) => void) | undefined const onApprovalResponse = () => From 7de9592e144b55372831af136c407c667edf9603 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 23:34:10 +0200 Subject: [PATCH 044/133] fix(web): hold a queued message until its continuation ends A message typed while an approval card was open was released into the running continuation about sixteen seconds after the answer. The runner resolved the second turn for the session by superseding: it destroyed the warm sandbox mid-call, the approved "sleep 25 && echo ..." returned "Command aborted", and the released message's own turn was declared lost. The user lost both. The hold added in round 8 was correct and was bypassed one level up. `useAgentChatQueue` ORs `canReleaseQueuedMessage` with the orphan escape hatch, and a durable answer makes that hatch true every time: the answer nulls the live gate marker via `retireDurable`, and the first adopted server transcript puts every message id in `restoredIdsRef`, so the paused tail reads as a restored "resume imminent" turn that nothing can fire. The sixteen seconds were not the hold working, they were the wait for the next record-log read. A second hole sat behind it. `approvalContinuation` is stamped from the continuation's FIRST record, which lands eight to eleven seconds after the answer, and a transcript adopted inside that window shows a paused turn whose gate is answered, which every predicate reads as settled. Put one hold in front of every release path, and give it a signal that exists from the moment of the answer: the respond body's `execution.id`, carried through `ApprovalSubmissionOutcome` to the queue, released only on that execution's own terminal record. A user stop still outranks it, and `CONTINUATION_HOLD_MAX_MS` bounds it so a continuation that is never delivered cannot strand the queue with no dock to unblock it. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 13 +++- .../src/assets/serverOwnedApproval.ts | 3 + .../src/hooks/useAgentChatQueue.ts | 67 ++++++++++++++++++- .../src/hooks/useAgentConversation.ts | 13 +++- .../src/session/state/interactionAnswer.ts | 6 +- .../agenta-playground/src/agentChat.ts | 7 +- web/packages/agenta-playground/src/index.ts | 8 ++- .../src/state/execution/agentMessageQueue.ts | 49 +++++++++++--- .../src/state/execution/index.ts | 8 ++- .../agenta-playground/src/state/index.ts | 8 ++- 10 files changed, 165 insertions(+), 17 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 91a2d08f5c8..76ba56691b7 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -253,6 +253,11 @@ const AgentConversation = ({ const modelKey = useAgentModelKeyStatus(entityId) const modelBlocked = modelKey.gateActive const [recoverableContinuation, setRecoverableContinuation] = useState(false) + // Execution id of the continuation the last durable answer started (respond body, + // `execution.id`). The queue holds every send until that execution writes its terminal record: + // the transcript-derived hold cannot cover the seconds between the answer and the + // continuation's first record, and a transcript adopted inside that gap reads as settled. + const [continuationExecutionId, setContinuationExecutionId] = useState(null) const approvalResponseOwnerRef = useRef(null) const retryRecoverableContinuation = useCallback(async () => { const resumed = await retryContinuation() @@ -407,6 +412,7 @@ const AgentConversation = ({ resumeOrphaned, recoverable: recoverableContinuation, retryContinuation: retryRecoverableContinuation, + continuationExecutionId, sendQueued, sessionId, }) @@ -439,6 +445,7 @@ const AgentConversation = ({ }) if (approvalResponseOwnerRef.current === args.id) { setRecoverableContinuation(outcome?.recoverable === true) + setContinuationExecutionId(outcome?.executionId ?? null) } return outcome }, @@ -452,6 +459,7 @@ const AgentConversation = ({ const outcome = await answerApprovals(ids, approved) if (approvalResponseOwnerRef.current === ids[0]) { setRecoverableContinuation(outcome?.recoverable === true) + setContinuationExecutionId(outcome?.executionId ?? null) } return outcome }, @@ -468,7 +476,10 @@ const AgentConversation = ({ approvalResponseOwnerRef.current = pendingApprovalId } useEffect(() => { - if (pendingApprovalId) setRecoverableContinuation(false) + if (pendingApprovalId) { + setRecoverableContinuation(false) + setContinuationExecutionId(null) + } }, [pendingApprovalId]) // Parked connect interactions on the paused turn → the connect dock owns their actions (the // inline rows are passive markers). Gated off while busy (`input-streaming` isn't parked yet) diff --git a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts index 345d2af16d6..54c97b4ce05 100644 --- a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts +++ b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts @@ -22,6 +22,9 @@ export async function submitServerOwnedApproval({ export interface ApprovalSubmissionOutcome { durable: boolean recoverable: boolean + /** `execution.id` from the respond body — the continuation turn the server just started. + * The queue holds every send until this execution writes its own terminal record. */ + executionId?: string } /** Choose the approval owner from the server capability, preserving the original local path. */ diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 5d961118348..d056c194075 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -1,7 +1,12 @@ // Canonical since the desktop re-plumb: the OSS copy is deleted and both apps import this. import {useCallback, useEffect, useRef, useState} from "react" -import {canReleaseQueuedMessage, isHitlPending} from "@agenta/playground/agent-chat" +import { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, +} from "@agenta/playground/agent-chat" import {generateId} from "@agenta/shared/utils" import type {FileUIPart, UIMessage} from "ai" @@ -35,6 +40,18 @@ interface UseAgentChatQueueArgs { * keeps the message held and uses the click to retry that continuation first. */ recoverable?: boolean retryContinuation?: () => Promise + /** + * Execution id of the durable approval continuation this mount just started, read from the + * respond body (`execution.id`). Non-null means the server owns the next turn: nothing may + * release until that execution's own terminal record lands in the transcript. + * + * It exists because the transcript-derived hold cannot cover the whole window. The + * `approvalContinuation` metadata only appears once the continuation's FIRST record is + * persisted — measured at 8 seconds after the answer on a local sandbox — and a transcript + * adopted inside that gap shows a paused turn with an answered gate, which every release path + * reads as settled. + */ + continuationExecutionId?: string | null /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be * referentially stable so the release effect doesn't churn on every streamed token. */ sendQueued: (item: QueuedMessage) => void @@ -46,6 +63,18 @@ interface UseAgentChatQueueArgs { // In-memory, page-session lifetime — same as the composer drafts it accompanies. const queuedBySession = new Map() +/** + * Ceiling on the id-keyed continuation hold. + * + * A continuation that is never delivered writes no records at all (observed twice in nine + * approvals), so its terminal record never arrives and an unbounded hold would freeze the queue + * with no dock to unblock it — the AGE-3937 trap this file already carries scars from. After the + * ceiling the hold falls back to the transcript-derived one, which is self-clearing: a + * continuation that produced records always produces a terminal record too. Well past the + * 8-to-11 seconds a local sandbox needs to write the continuation's first record. + */ +export const CONTINUATION_HOLD_MAX_MS = 45_000 + /** * Holds user messages typed while a turn is in flight and releases them ONE AT A TIME once the * stream truly settles. It never releases mid human-in-the-loop (a tool-approval gate) — that @@ -65,6 +94,7 @@ export const useAgentChatQueue = ({ resumeOrphaned = false, recoverable = false, retryContinuation, + continuationExecutionId = null, sendQueued, sessionId, }: UseAgentChatQueueArgs) => { @@ -81,10 +111,45 @@ export const useAgentChatQueue = ({ // Settled = the stream is over (done or failed). A stop lands here (abort → "ready"). const settled = status === "ready" || status === "error" + + // ── The durable-continuation hold ───────────────────────────────────────────────────────── + // A server-owned continuation is a TURN. Sending into it starts a second turn for the same + // session, and the runner resolves that collision by superseding: it tears down the warm + // sandbox mid-call, so the tool the user just approved comes back "Command aborted" and the + // sent message dies with it. Nothing below may release while one is in flight. + const [, forceHoldRecheck] = useState(0) + const holdStartedAtRef = useRef<{id: string; at: number} | null>(null) + if (continuationExecutionId) { + if (holdStartedAtRef.current?.id !== continuationExecutionId) { + holdStartedAtRef.current = {id: continuationExecutionId, at: Date.now()} + } + } else { + holdStartedAtRef.current = null + } + const holdStartedAt = holdStartedAtRef.current + const idHoldExpired = + !!holdStartedAt && Date.now() - holdStartedAt.at >= CONTINUATION_HOLD_MAX_MS + const idHold = + !!continuationExecutionId && + !idHoldExpired && + !approvalContinuationSettled(messages, continuationExecutionId) + // The ceiling needs a render to take effect; nothing else re-renders a queue that is holding. + useEffect(() => { + if (!holdStartedAt || idHoldExpired) return + const remaining = holdStartedAt.at + CONTINUATION_HOLD_MAX_MS - Date.now() + const timer = setTimeout(() => forceHoldRecheck((n) => n + 1), Math.max(remaining, 0)) + return () => clearTimeout(timer) + }, [holdStartedAt, idHoldExpired]) + + // A user stop cancels the continuation too, so it outranks the hold exactly as it outranks + // every other gate here. + const continuationHold = !stopped && (idHold || hasRunningApprovalContinuation(messages)) + // Releasable now: the normal gate, OR a settled turn whose hold was voided — by a user stop, // or by an orphaned restored resume shape that nothing in this mount can ever fire. const canReleaseNow = !acceptedRunPending && + !continuationHold && (canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled)) // A stop voids the gate for release (above), so it must void it for reporting too — else the diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index e33cd3d80af..2646adfb8f2 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -285,6 +285,11 @@ export const useAgentConversation = ({ const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) const [recoverableContinuation, setRecoverableContinuation] = useState(false) + // Execution id of the continuation the last durable answer started (respond body, + // `execution.id`). The queue holds every send until that execution writes its terminal record: + // the transcript-derived hold cannot cover the seconds between the answer and the + // continuation's first record, and a transcript adopted inside that gap reads as settled. + const [continuationExecutionId, setContinuationExecutionId] = useState(null) const approvalResponseOwnerRef = useRef(null) const retryRecoverableContinuation = useCallback(async () => { const resumed = await resumeSessionContinuation(sessionId) @@ -645,6 +650,7 @@ export const useAgentConversation = ({ resumeOrphaned, recoverable: recoverableContinuation, retryContinuation: retryRecoverableContinuation, + continuationExecutionId, sendQueued, sessionId, }) @@ -676,6 +682,7 @@ export const useAgentConversation = ({ }) if (approvalResponseOwnerRef.current === args.id) { setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) } return outcome }, @@ -721,6 +728,7 @@ export const useAgentConversation = ({ }) if (approvalResponseOwnerRef.current === args.ids[0]) { setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) } return outcome }, @@ -761,7 +769,10 @@ export const useAgentConversation = ({ approvalResponseOwnerRef.current = pendingApprovalId } useEffect(() => { - if (pendingApprovalId) setRecoverableContinuation(false) + if (pendingApprovalId) { + setRecoverableContinuation(false) + setContinuationExecutionId(null) + } }, [pendingApprovalId]) // Settle a parked client tool (#4920). A widget calls this with the structured reference; diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index e119d347eec..b6c2cc5b639 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -72,7 +72,7 @@ export const respondInteractionAnswerAtom = atom( toolCallId: string approved: boolean }, - ): Promise<{durable: boolean; recoverable: boolean}> => { + ): Promise<{durable: boolean; recoverable: boolean; executionId?: string}> => { const {sessionId, toolCallId, approved} = params const projectId = get(projectIdAtom) ?? "" if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") @@ -100,6 +100,7 @@ export const respondInteractionAnswerAtom = atom( return { durable: result.accepted, recoverable: result.execution?.state === "recoverable", + ...(result.execution?.id ? {executionId: result.execution.id} : {}), } }, ) @@ -115,7 +116,7 @@ export const respondInteractionAnswersAtom = atom( toolCallIds: string[] approved: boolean }, - ): Promise<{durable: boolean; recoverable: boolean}> => { + ): Promise<{durable: boolean; recoverable: boolean; executionId?: string}> => { const {sessionId, toolCallIds, approved} = params const projectId = get(projectIdAtom) ?? "" if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") @@ -156,6 +157,7 @@ export const respondInteractionAnswersAtom = atom( return { durable: result.accepted, recoverable: result.execution?.state === "recoverable", + ...(result.execution?.id ? {executionId: result.execution.id} : {}), } }, ) diff --git a/web/packages/agenta-playground/src/agentChat.ts b/web/packages/agenta-playground/src/agentChat.ts index d7de7e781f9..c56b4fffcec 100644 --- a/web/packages/agenta-playground/src/agentChat.ts +++ b/web/packages/agenta-playground/src/agentChat.ts @@ -21,5 +21,10 @@ export { type ChatStatusLike, } from "./state/execution/approvalAnswer" export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./state/execution/answerOrdering" -export {canReleaseQueuedMessage, isHitlPending} from "./state/execution/agentMessageQueue" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, +} from "./state/execution/agentMessageQueue" export {createNegotiatingFetch, type NegotiatingFetch} from "./state/execution/agentNegotiation" diff --git a/web/packages/agenta-playground/src/index.ts b/web/packages/agenta-playground/src/index.ts index ebdac541c57..7127516f028 100644 --- a/web/packages/agenta-playground/src/index.ts +++ b/web/packages/agenta-playground/src/index.ts @@ -90,7 +90,13 @@ export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./state" // Render-hint map for interaction kinds (sibling `data-render` parts → toolCallId lookup). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./state" // Queued-message release gate for the agent chat composer (HITL-safe, one-by-one). -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./state" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./state" // Per-turn request capture + correlation helpers (Turn Inspector Context/Raw tabs). export { appendCapped, diff --git a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts index 2fcbaa60c72..18390795daf 100644 --- a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts +++ b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts @@ -30,20 +30,53 @@ interface MessageLike { type ApprovalContinuationState = "running" | "done" | "error" -const latestApprovalContinuationState = ( +interface ApprovalContinuationMeta { + executionId?: string + state?: ApprovalContinuationState +} + +const latestApprovalContinuation = ( messages: MessageLike[], -): ApprovalContinuationState | undefined => { +): ApprovalContinuationMeta | undefined => { for (let i = messages.length - 1; i >= 0; i -= 1) { - const state = ( - messages[i]?.metadata as - | {approvalContinuation?: {state?: ApprovalContinuationState}} - | undefined - )?.approvalContinuation?.state - if (state) return state + const continuation = ( + messages[i]?.metadata as {approvalContinuation?: ApprovalContinuationMeta} | undefined + )?.approvalContinuation + if (continuation?.state) return continuation } return undefined } +const latestApprovalContinuationState = ( + messages: MessageLike[], +): ApprovalContinuationState | undefined => latestApprovalContinuation(messages)?.state + +/** + * A durable approval continuation is still in flight for this conversation. + * + * `canReleaseQueuedMessage` already holds on this, but the gate is not the only release path: + * `useAgentChatQueue` also releases on a user stop and on an ORPHANED restored resume shape. The + * orphan hatch is true for EVERY durable answer — the answer retires the local gate marker, and + * the first adopted server transcript makes the tail a restored "resume imminent" message — so + * without this predicate it walks around the hold and sends into the running continuation, which + * supersedes it on the runner and aborts the tool call the user just approved. + */ +export function hasRunningApprovalContinuation(messages: MessageLike[]): boolean { + return latestApprovalContinuationState(messages) === "running" +} + +/** + * The transcript carries a terminal record for `executionId` — the only proof a client that never + * streamed the continuation has that the continuation is over. A DIFFERENT execution id counts as + * settled: a later continuation replaced this one, so this id can never terminate. + */ +export function approvalContinuationSettled(messages: MessageLike[], executionId: string): boolean { + const continuation = latestApprovalContinuation(messages) + if (!continuation) return false + if (continuation.executionId !== executionId) return true + return continuation.state === "done" || continuation.state === "error" +} + const isToolPart = (part: ToolPartLike): boolean => { const type = part?.type return typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool") diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index ed9c1a48d44..fd8eeefd487 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -376,7 +376,13 @@ export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./answerOrderin // Render-hint map: sibling `data-render` parts → toolCallId lookup (interaction kinds). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./renderMap" // Agent-lane queued-message release gate (never releases mid-HITL or pre-resume). -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./agentMessageQueue" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./agentMessageQueue" // Per-turn request capture + correlation helpers (Turn Inspector Context/Raw tabs). export { appendCapped, diff --git a/web/packages/agenta-playground/src/state/index.ts b/web/packages/agenta-playground/src/state/index.ts index 72bc67efa2e..2ad9982a4d7 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -191,7 +191,13 @@ export { export {approvalResolution, isResumeSend, type ChatStatusLike} from "./execution" export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./execution" export {buildRenderMap, renderKindFor, type RenderHintLike} from "./execution" -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./execution" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./execution" export { appendCapped, buildTurnCapture, From ee19614d81140f812a1cc2aa7753b3ec4d49f3bc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 23:34:22 +0200 Subject: [PATCH 045/133] fix(api): refuse a Send that would supersede an executing continuation Every chat send runs a preflight against POST /sessions/{id}/continuations/resume, which aborts the send when a durable continuation owns the session. It answered "nobody owns this" while a continuation was executing, so the browser invoked the runner directly and the approved tool call was destroyed with the sandbox. The refusal was already written. `resume_recoverable_continuation` has an explicit `state == running` branch that returns True without redelivering the command. It was unreachable: `fetch_resumable_continuation` admitted only `pending_delivery` and `recoverable`, so a delivered-and-running continuation matched no branch and the DAO returned None. Let `running` through the filter, and split the two live shapes it covers in the service, where the discriminator belongs. A continuation PARKED on its own approval has a pending interaction row against its execution; nothing is in flight to destroy, so a Send is a steer and stays allowed, which is review finding N2. A continuation EXECUTING inside a tool call has no such row, and its Send is refused. The signal is the interaction row rather than the Redis `running` lock because the lock is absent both when a turn parks and when its runner goes quiet mid-call, and those two must not answer the same way. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/sessions/commands/service.py | 36 +++++- .../src/dbs/postgres/sessions/commands/dao.py | 11 +- .../sessions/test_session_commands_dao.py | 119 +++++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index fe27b0d3ea7..1aeff71c873 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -726,6 +726,26 @@ async def _mark_continuation_recoverable( return True return applied is not None + async def _execution_is_parked_on_a_gate( + self, *, project_id: UUID, session_id: str, execution_id: str + ) -> bool: + """Has this execution raised its own gate and stopped to wait on the user? + + Read from the interaction rows, not from the Redis `running` lock. The lock says the + right thing about a healthy runner and the wrong thing about a partitioned one: it is + absent both when a turn parks AND when the runner goes quiet mid-tool-call, and those + two must not be answered the same way (see + `test_stale_heartbeat_never_replays_an_admitted_continuation`). A pending row is a + durable fact that only the park writes, so the unknown case falls to "executing", + which is the safe side. + """ + rows = await self._interactions.fetch_turn_interactions( + project_id=project_id, + session_id=session_id, + turn_id=execution_id, + ) + return any(row.status == SessionInteractionStatus.pending for row in rows) + async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str ) -> bool: @@ -752,7 +772,21 @@ async def resume_recoverable_continuation( # executing the approved side effect. Only the watchdog may turn `running` into # `recoverable`, after it has collapsed and tombstoned the old ownership. Until # then this durable continuation still owns Send, but it is never redelivered. - return True + # + # `running` covers two live shapes, and only one of them owns Send: + # + # * EXECUTING — the continuation is inside a tool call. A Send here starts a + # second turn for the session, and the runner resolves that by superseding: + # it destroys the warm sandbox mid-call and the tool the user had just + # approved returns aborted. Both turns are lost. Refuse. + # * PARKED on its own approval — the continuation raised a new gate and stopped + # to wait on the user. Nothing is in flight to destroy, so a Send is a steer + # and stays allowed. Allow. + return not await self._execution_is_parked_on_a_gate( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) if ( command.state in ( diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 11f78b008f0..275a030d889 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -293,7 +293,16 @@ async def fetch_resumable_continuation( SessionCommandDBE.state == SessionCommandState.applied.value, SessionCommandDBE.outcome == "started", - SessionExecutionDBE.state == "recoverable", + # `running` belongs here beside `recoverable`. A delivered + # continuation that is still executing OWNS the session's next turn, + # and `resume_recoverable_continuation` already says exactly that: + # its `state == running` branch returns True without redelivering. + # That branch was unreachable while this filter dropped `running`, so + # the Send preflight answered "nobody owns this" and the browser + # invoked the runner directly — which supersedes the continuation, + # tears down its warm sandbox mid-call and returns the tool call the + # user had just approved as aborted. + SessionExecutionDBE.state.in_(("recoverable", "running")), ), ), SessionCommandDBE.deleted_at.is_(None), diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index 2fb79eedc20..4dfc50e8a31 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -45,6 +45,7 @@ import oss.src.dbs.postgres.shared.engine as engine_module from oss.src.dbs.postgres.shared.engine import get_transactions_engine import oss.src.models.db_models # noqa: F401 +from oss.src.utils.env import env pytestmark = pytest.mark.integration @@ -1075,7 +1076,7 @@ async def test_full_service_concurrent_same_key_conflicting_answer_is_409_domain assert isinstance(conflict, IdempotencyKeyReused) -async def test_parked_running_continuation_is_steerable_and_reopens_after_recovery( +async def test_live_continuation_is_a_send_candidate_and_reopens_after_recovery( command_scope, ): commands = SessionCommandsDAO(engine=command_scope["engine"]) @@ -1117,11 +1118,25 @@ async def test_parked_running_continuation_is_steerable_and_reopens_after_recove transaction=transaction, ) + # A `running` continuation row now REACHES the service, which decides between the two live + # shapes `running` covers. The DAO deliberately does not: the discriminator is the Redis + # `running` lock, which only the service reads. + # + # * PARKED on its own approval — no Redis `running` lock. A Send is a steer and is + # allowed. This is review finding N2 and it stays. + # * EXECUTING inside a tool call — the lock names this execution. A Send starts a second + # turn, the runner supersedes, the warm sandbox is destroyed mid-call and the tool the + # user had just approved returns "Command aborted" (increment-6 browser pass, round 8, + # session 9d40cfcc-6485-4250-8d2e-17f1f12f55f4). It is refused. + # + # Both are covered by the two `resume_recoverable_continuation` tests below. blocker = await commands.fetch_resumable_continuation( project_id=command_scope["project_id"], session_id=command_scope["session_id"], ) - assert blocker is None + assert blocker is not None and blocker.id == command.id + # Being a Send candidate is not the same as being retargetable: only a recovered execution + # reopens. assert ( await commands.reopen_continuation( project_id=command_scope["project_id"], @@ -1165,6 +1180,106 @@ async def test_parked_running_continuation_is_steerable_and_reopens_after_recove assert reopened.target_turn_id == "continuation-retry" +async def _park_continuation_on_its_own_gate(command_scope, *, token: str) -> None: + """The shape a continuation leaves when it raises its OWN approval and stops on the user.""" + async with command_scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_interactions " + "(project_id, id, session_id, turn_id, token, kind, status) " + "VALUES (:project_id, :id, :session_id, 'continuation-live', :token, " + "'user_approval', 'pending')" + ), + { + "project_id": command_scope["project_id"], + "id": uuid.uuid4(), + "session_id": command_scope["session_id"], + "token": token, + }, + ) + + +async def _seed_live_continuation(command_scope, *, token: str) -> None: + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token=token) + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-live", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + await commands.create_command( + user_id=command_scope["user_id"], + command=_create( + command_scope, + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-live", + expected_turn_id="turn-A", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-live", + }, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.started, + settled_at=datetime.now(timezone.utc), + ), + transaction=transaction, + ) + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-live", + state=SessionExecutionState.running, + transaction=transaction, + ) + + +async def test_executing_continuation_refuses_a_competing_send( + command_scope, monkeypatch +): + """The continuation is inside a tool call: Send must be refused, not superseded. + + Its execution holds no pending gate of its own, which is exactly the state the runner was + in when a released message tore down the warm sandbox and turned the approved call into + "Command aborted". + """ + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + await _seed_live_continuation(command_scope, token="live-executing") + service = _commands_service(command_scope) + + assert ( + await service.resume_recoverable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + is True + ) + + +async def test_parked_continuation_still_accepts_a_send(command_scope, monkeypatch): + """The continuation raised its own approval gate: a Send is a steer and stays allowed. + + The park writes a pending interaction row against the continuation's own execution, so the + same `running` row in Postgres must not be read as ownership. This is review finding N2. + """ + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + await _seed_live_continuation(command_scope, token="live-parked") + await _park_continuation_on_its_own_gate(command_scope, token="live-parked-gate") + service = _commands_service(command_scope) + + assert ( + await service.resume_recoverable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + is False + ) + + async def test_stop_and_answer_have_one_postgres_serialized_winner(command_scope): commands = SessionCommandsDAO(engine=command_scope["engine"]) executions = SessionExecutionsDAO(engine=command_scope["engine"]) From 186494641c52ea148107e9c51da0e30e5a1cf8a8 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 23:34:32 +0200 Subject: [PATCH 046/133] test(web): replay the round-8 record logs for the hold and the dock Both fixtures are the real durable record logs from the increment-6 stack, ordered exactly as GET /sessions/records returns them, so the regression is pinned against what the server actually wrote rather than against a hand-built shape. The queue test walks session 9d40cfcc prefix by prefix and asserts the held message survives the continuation's re-raised tool call and its interaction response, which is where the round-8 build sent, and releases only on the continuation's own terminal record. It also covers the window before the first continuation record, the bounded ceiling, and an answer that started no continuation. The dock test replays session 973bfdbd and pins the retirement contract. Round 8 reported the dock as still open reading "Answered, waiting for the agent", but screenshots 41 and 47 show no dock at either round: a closed HeightCollapse keeps its latched card mounted at zero height with aria-hidden and inert, and ApprovalDock only resets `answered` when the current approval id changes, so a DOM read finds the retired text forever. Pinning `getPendingApprovals`, which is what `open` is built from, gives the next round the state that drives the pixels. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../approvalDockRetirement.records.json | 231 ++++++++++++++++ ...heldMessageDuringContinuation.records.json | 252 ++++++++++++++++++ .../hooks/durableContinuationHold.test.ts | 168 ++++++++++++ .../unit/model/approvalDockRetirement.test.ts | 45 ++++ 4 files changed, 696 insertions(+) create mode 100644 web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json create mode 100644 web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json create mode 100644 web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts create mode 100644 web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts diff --git a/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json new file mode 100644 index 00000000000..a1b77b1d01e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json @@ -0,0 +1,231 @@ +[ + { + "id": "df76c981-7098-49b9-92ad-d5f2fc759580", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Run the shell command: echo inc6-r8-dock. Report its exact output.", + "type": "message", + "attachments": [] + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:43.438+00" + }, + { + "id": "09dd3c78-7e32-4ee9-b084-4d30d3117505", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user wants me to run a simple shell command and report its output.", + "type": "thought" + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.665+00" + }, + { + "id": "a5d965ef-3703-5097-bdaf-49a68f733a7d", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_616b5f199712455793b6d76e", + "name": "bash", + "type": "tool_call", + "input": { + "command": "echo inc6-r8-dock" + } + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.846+00" + }, + { + "id": "ea6333e4-cab3-542f-8842-d61996291ada", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "interaction_request", + "payload": { + "id": "995951ee-bfec-4ef3-bd82-ea8bd0bbe313", + "kind": "user_approval", + "type": "interaction_request", + "payload": { + "options": [ + { + "kind": "allow_once", + "name": "Yes", + "optionId": "yes" + }, + { + "kind": "reject_once", + "name": "No", + "optionId": "no" + } + ], + "toolCall": { + "kind": "other", + "title": "agenta-approval", + "status": "pending", + "rawInput": { + "command": "echo inc6-r8-dock" + }, + "toolCallId": "call_616b5f199712455793b6d76e", + "resolvedName": "Bash" + }, + "toolCallId": "call_616b5f199712455793b6d76e", + "availableReplies": ["once", "reject"] + } + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.86+00" + }, + { + "id": "663d522e-c1c6-40e8-8363-6bd3dcab9cc7", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "27ec280560f31d2d40fc235c58c9f33e", + "stopReason": "paused" + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.87+00" + }, + { + "id": "43b9b722-287b-43d1-98d3-176358c34397", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user approved the bash command and wants me to execute it now.", + "type": "thought" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:31:59.872+00" + }, + { + "id": "38dc3726-352c-5298-be69-dee73acc5be4", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_730a4e6cf1ba4acfa98d3fbc", + "name": "bash", + "type": "tool_call", + "input": { + "command": "echo inc6-r8-dock" + } + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.899+00" + }, + { + "id": "eeb0ad94-215f-58a6-b2d3-840f47e9c751", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "interaction_response", + "payload": { + "id": "995951ee-bfec-4ef3-bd82-ea8bd0bbe313", + "kind": "user_approval", + "type": "interaction_response", + "payload": { + "approved": true, + "toolCallId": "call_730a4e6cf1ba4acfa98d3fbc" + } + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.908+00" + }, + { + "id": "73df09a2-7a8f-5075-80a4-09e40955deea", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "tool_result", + "payload": { + "id": "call_730a4e6cf1ba4acfa98d3fbc", + "type": "tool_result", + "output": "inc6-r8-dock\n", + "isError": false + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.916+00" + }, + { + "id": "393b1fdb-131d-48aa-9149-73628a51c063", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The command executed successfully. The exact output is \"inc6-r8-dock\".", + "type": "thought" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:04.304+00" + }, + { + "id": "8ab873ee-67ba-44ad-b1d7-e37b33c2856e", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 5, + "sender": "agent", + "session_update": "usage", + "payload": { + "cost": 0.000369396, + "type": "usage", + "input": 3296, + "total": 6465, + "output": 97 + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.333+00" + }, + { + "id": "5c35ae31-eae1-48cd-986a-e540be258889", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 6, + "sender": "agent", + "session_update": "message", + "payload": { + "text": "The exact output is:\n\n```\ninc6-r8-dock\n```", + "type": "message" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.344+00" + }, + { + "id": "2036b6a3-f853-42f4-9beb-e2c7b31673e1", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 7, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "114dca2ee59c63cf28c8a8dd83bb31ad" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.349+00" + } +] diff --git a/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json new file mode 100644 index 00000000000..2b7d8fe81cc --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json @@ -0,0 +1,252 @@ +[ + { + "id": "3e5b0402-9c55-4aee-b01c-258a7cd5c53c", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Run this shell command exactly once: sleep 25 && echo inc6-r8-slow. Report its exact output when it finishes.", + "type": "message", + "attachments": [] + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:14.208+00" + }, + { + "id": "098ce6b7-aeb4-45ad-b841-b25a55771f1a", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user wants me to run a shell command that sleeps for 25 seconds and then echoes a string. Let me execute it with a timeout long enough to cover the 25-second sleep.", + "type": "thought" + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:22.441+00" + }, + { + "id": "7ad1f0f9-333a-5133-9a09-d532bac1b880", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_d5db7a06b78e45078475f501", + "name": "bash", + "type": "tool_call", + "input": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + } + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.872+00" + }, + { + "id": "6e072490-fc95-54b6-a3d7-d74f3d764f42", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "interaction_request", + "payload": { + "id": "bd87965d-f19c-4a4e-9bff-cd36a3eb2830", + "kind": "user_approval", + "type": "interaction_request", + "payload": { + "options": [ + { + "kind": "allow_once", + "name": "Yes", + "optionId": "yes" + }, + { + "kind": "reject_once", + "name": "No", + "optionId": "no" + } + ], + "toolCall": { + "kind": "other", + "title": "agenta-approval", + "status": "pending", + "rawInput": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + }, + "toolCallId": "call_d5db7a06b78e45078475f501", + "resolvedName": "Bash" + }, + "toolCallId": "call_d5db7a06b78e45078475f501", + "availableReplies": ["once", "reject"] + } + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.888+00" + }, + { + "id": "fe72acb2-29fe-4529-b146-6cf4334d5e89", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "97e22b7f8e005905dc4234527a59bf12", + "stopReason": "paused" + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.899+00" + }, + { + "id": "beb812b6-b22c-4a5f-8609-bf3dd07f4c61", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user approved the bash call. I need to execute the exact same command again.", + "type": "thought" + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:07.439+00" + }, + { + "id": "b1d41b96-eedb-50b9-8edb-f69283a54829", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_2f8bcacc4e65494ba5141733", + "name": "bash", + "type": "tool_call", + "input": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + } + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.223+00" + }, + { + "id": "f9fefda8-a2c4-563a-819e-680d50d95d58", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "interaction_response", + "payload": { + "id": "bd87965d-f19c-4a4e-9bff-cd36a3eb2830", + "kind": "user_approval", + "type": "interaction_response", + "payload": { + "approved": true, + "toolCallId": "call_2f8bcacc4e65494ba5141733" + } + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.24+00" + }, + { + "id": "e5d8373d-aba6-4e2c-9297-1f0ddf0c83e7", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Then reply with the marker inc6-r8-held.", + "type": "message", + "attachments": [] + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:28:08.298+00" + }, + { + "id": "6279bf85-7443-5daf-8fe4-8c1fa58b89a0", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "tool_result", + "payload": { + "id": "call_2f8bcacc4e65494ba5141733", + "type": "tool_result", + "output": "Command aborted", + "isError": true + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.331+00" + }, + { + "id": "73e9a0e5-24f8-47f4-aa36-c5d5bd1f6d44", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "usage", + "payload": { + "cost": 0.00029988, + "type": "usage", + "input": 3156, + "total": 3244, + "output": 88 + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:09.086+00" + }, + { + "id": "401f61ae-ce9c-43bc-bf7d-479c4d16ee81", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 5, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "4de387f5cdaceedc4c8ba68a88d682a5", + "stopReason": "cancelled" + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:09.098+00" + }, + { + "id": "00159e20-1069-5b01-a977-2025f4da53dc", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "error", + "payload": { + "code": "execution_lost", + "type": "error", + "message": "The agent stopped responding and the run was closed. Send the message again to retry.", + "settled_by": "watchdog" + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:30:23.941613+00" + }, + { + "id": "8aebaa8d-e653-52dc-abd7-90abbf4eeca1", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "settled_by": "watchdog" + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:30:23.942613+00" + } +] diff --git a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts new file mode 100644 index 00000000000..a5a2c844770 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +/** + * Regression for the increment-6 browser pass, round 8, item 2. + * + * A user typed while an approval card was open, approved, and the client held the message for + * about sixteen seconds. Then it sent it into the running continuation: the runner superseded the + * continuation's warm sandbox, the approved `sleep 25 && echo …` came back "Command aborted", and + * the released message's own turn was declared lost. The user lost both. + * + * The records below are the REAL durable record log of that session + * (9d40cfcc-6485-4250-8d2e-17f1f12f55f4), exported from the increment-6 stack and ordered exactly + * as `GET /sessions/records` returns them (timestamp, then record index). Replaying them prefix by + * prefix is what pins the two holes the round-8 fix left open: + * + * 1. `resumeOrphaned` walked around the gate. `canReleaseQueuedMessage` holds correctly on + * `approvalContinuation.state === "running"`, but the hook ORs that gate with the orphan + * escape hatch, and a durable answer makes the hatch true every time: the answer retires the + * local gate marker, and the first adopted server transcript makes the tail a restored + * "resume imminent" message. + * 2. The transcript-derived hold starts too late. `approvalContinuation` is stamped from the + * continuation's FIRST record, which landed 8.1 s after the answer here (20:27:59 → 20:28:07). + * A transcript adopted inside that gap shows a paused turn whose gate is answered — settled, + * by every predicate. The respond body's `execution.id` covers that window. + */ +import {act, renderHook} from "@testing-library/react" +import type {UIMessage} from "ai" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {transcriptToMessages} from "../../../src/assets/transcriptToMessages" +import {CONTINUATION_HOLD_MAX_MS, useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" + +import records from "../assets/__fixtures__/heldMessageDuringContinuation.records.json" + +/** The continuation execution the respond body named (`execution.id`). */ +const CONTINUATION_EXECUTION_ID = "943f3c99-5816-4a46-b6e3-7a10fe587575" + +/** Record indices in the fixture, by the event that closes each prefix. */ +const AFTER_SOURCE_PAUSED_DONE = 5 +const AFTER_CONTINUATION_FIRST_THOUGHT = 6 +const AFTER_CONTINUATION_TOOL_CALL = 7 +const AFTER_CONTINUATION_INTERACTION_RESPONSE = 8 +const AFTER_CONTINUATION_DONE = 12 + +const messagesAfter = (count: number): UIMessage[] => + transcriptToMessages(records.slice(0, count) as never) ?? [] + +/** + * The hook exactly as the desktop mounts it after a durable approve: the answer left no live gate + * marker, so the conversation's `resumeOrphaned` is true, and the stream itself has been "ready" + * since the turn paused. Only the continuation hold can stop a release here. + */ +const renderQueue = (initial: {messages: UIMessage[]; continuationExecutionId?: string | null}) => { + const sendQueued = vi.fn() + const view = renderHook( + (props: {messages: UIMessage[]; continuationExecutionId?: string | null}) => + useAgentChatQueue({ + status: "ready", + messages: props.messages, + stopped: false, + resumeOrphaned: true, + sendQueued, + ...(props.continuationExecutionId !== undefined + ? {continuationExecutionId: props.continuationExecutionId} + : {}), + }), + {initialProps: initial}, + ) + act(() => { + view.result.current.submit({text: "Then reply with the marker inc6-r8-held."}) + }) + return {...view, sendQueued} +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe("a held message must outlive the durable continuation", () => { + it("holds through every continuation record and releases on its terminal one", () => { + const {rerender, result, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + + // The prefixes the browser really walked through, in order. The last one is where the + // round-8 build sent: the continuation's re-raised tool call and its interaction response + // together make the tail read as settled to every predicate that ignores the execution. + for (const count of [ + AFTER_CONTINUATION_FIRST_THOUGHT, + AFTER_CONTINUATION_TOOL_CALL, + AFTER_CONTINUATION_INTERACTION_RESPONSE, + ]) { + rerender({ + messages: messagesAfter(count), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued, `released after record ${count}`).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + } + + rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).toHaveBeenCalledOnce() + expect(sendQueued.mock.calls[0][0]).toMatchObject({ + text: "Then reply with the marker inc6-r8-held.", + }) + expect(result.current.queued).toHaveLength(0) + }) + + it("holds on the execution id alone, before the continuation writes its first record", () => { + // The 8.1-second window between the answer and the continuation's first record. Nothing + // in the transcript says a continuation exists; only the respond body does. + const paused = messagesAfter(AFTER_SOURCE_PAUSED_DONE) + const {result, sendQueued} = renderQueue({ + messages: paused, + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + }) + + it("releases in that same window when no continuation was started", () => { + // The guard must be the execution id, not the paused shape: an approval whose respond + // returned no execution has nothing to wait for, and holding it would strand the queue. + const {sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: null, + }) + expect(sendQueued).toHaveBeenCalledOnce() + }) + + it("gives up the id-keyed hold at the ceiling, so an undelivered continuation cannot strand the queue", () => { + vi.useFakeTimers() + const {result, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(CONTINUATION_HOLD_MAX_MS + 1) + }) + expect(sendQueued).toHaveBeenCalledOnce() + expect(result.current.queued).toHaveLength(0) + }) + + it("keeps holding past the ceiling while the transcript still shows the continuation running", () => { + vi.useFakeTimers() + const {rerender, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_CONTINUATION_FIRST_THOUGHT), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + act(() => { + vi.advanceTimersByTime(CONTINUATION_HOLD_MAX_MS + 1) + }) + expect(sendQueued).not.toHaveBeenCalled() + + rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).toHaveBeenCalledOnce() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts b/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts new file mode 100644 index 00000000000..420456b637a --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts @@ -0,0 +1,45 @@ +/** + * Increment-6 browser pass, round 8, item 3 — the desktop approval dock after a clean approve. + * + * The records below are the REAL durable record log of session + * 973bfdbd-0226-477d-865a-479f4c3e3db1, ordered as `GET /sessions/records` returns them. The dock + * is `open = getPendingApprovals(messages).length > 0`, so this replay is the whole retirement + * contract: from the continuation's first record onward the gate must be gone. + * + * The round-8 report said the dock stayed open reading "Answered, waiting for the agent". The + * screenshots taken at the same moment (evidence 41 and 47) show no dock at all. A closed + * `HeightCollapse` keeps its latched card mounted at height 0 with `aria-hidden` and `inert` + * (web/packages/agenta-ui/src/components/HeightCollapse.tsx), and `ApprovalDock` only resets its + * `answered` flag when the current approval id changes — so a DOM or text read still finds the + * stale eyebrow long after the dock has closed. This test pins the state that actually drives the + * pixels, so the next round measures the same thing the user sees. + */ +import {describe, expect, it} from "vitest" + +import {transcriptToMessages} from "../../../src/assets/transcriptToMessages" +import {getPendingApprovals} from "../../../src/model/approvals" + +import records from "../assets/__fixtures__/approvalDockRetirement.records.json" + +const APPROVAL_ID = "995951ee-bfec-4ef3-bd82-ea8bd0bbe313" +const AFTER_INTERACTION_REQUEST = 4 +const AFTER_SOURCE_PAUSED_DONE = 5 +const AFTER_CONTINUATION_FIRST_THOUGHT = 6 + +const pendingAfter = (count: number): string[] => + getPendingApprovals( + (transcriptToMessages(records.slice(0, count) as never) ?? []) as never, + ).map((approval) => approval.approvalId) + +describe("the approval dock over a real durable continuation", () => { + it("holds the gate while the source turn is parked", () => { + expect(pendingAfter(AFTER_INTERACTION_REQUEST)).toEqual([APPROVAL_ID]) + expect(pendingAfter(AFTER_SOURCE_PAUSED_DONE)).toEqual([APPROVAL_ID]) + }) + + it("retires the gate on the continuation's first record and never re-opens it", () => { + for (let count = AFTER_CONTINUATION_FIRST_THOUGHT; count <= records.length; count += 1) { + expect(pendingAfter(count), `record ${count}`).toEqual([]) + } + }) +}) From c9729292ac144b316bec3dbbce67969b27832811 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 23:34:38 +0200 Subject: [PATCH 047/133] chore: drop STATUS-round8.md from the branch The round-7 status note was committed into the worktree by mistake. It is a working artifact, not source, and it has been moved to the night folder. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- STATUS-round8.md | 73 ------------------------------------------------ 1 file changed, 73 deletions(-) delete mode 100644 STATUS-round8.md diff --git a/STATUS-round8.md b/STATUS-round8.md deleted file mode 100644 index 6b5d8860cdd..00000000000 --- a/STATUS-round8.md +++ /dev/null @@ -1,73 +0,0 @@ -# Round 8 status - -Branch: `feat/session-durable-approvals` - -Round 8 fixes the browser-visible source/continuation identity mismatch. Session records now retain -their `turn_id`; replay associates a paused source execution with the new execution that continues -it, and the source message carries that continuation's running or terminal lifecycle. Approval -retirement is scoped to the interaction IDs that the continuation inherited. - -## Browser proof - -Use the same desktop and mobile routes, agent, and durable-approval setup as the Round 7 re-check. -Reload each page before starting so the browser loads this head. - -### 1. Held message waits for the continuation terminal record - -1. In one desktop tab, send a prompt that asks the agent to run a Bash command that waits briefly - before printing a unique marker, so the approval continuation remains visibly in flight. -2. While the approval card is pending, type and send a second, uniquely identifiable message. -3. Confirm the second message appears in `1 queued message · waits for your answer` and does not - start a user turn. -4. Click **Approve**. While the Bash activity is running, confirm the queued card and its text remain - visible and no Stop/steer request is sent for the queued text. -5. Wait for the Bash output and the continuation's terminal `done` record. Only then confirm the - queued text starts one normal user turn, the queued chip clears, and the Bash result is not - `INTERRUPTED_BY_USER`. -6. Keep the page open for another 30 seconds and confirm the queued text is not sent a second time. - -### 2. A non-answering tab retires the approval card - -1. Open the same pending-approval session in desktop tabs A and B. -2. In tab B, click **Approve** and note the interaction ID, source turn ID, and continuation turn ID - in the Network response/record stream. -3. Do not click anything in tab A. Confirm tab A receives continuation records whose `turn_id` - differs from the source turn, including the `interaction_response` when present. -4. On the first continuation record, confirm tab A removes the live Approve/Deny controls rather - than leaving `Needs your approval` actionable. -5. Wait for the continuation output and terminal record. Confirm tab A renders the output and the - approval card remains retired. Repeat once with mobile answering and desktop observing. - -### 3. The desktop dock closes on continuation completion - -1. In one desktop tab, create a pending Bash approval and click **Approve**. -2. While the continuation runs, confirm the card may show `Answered, waiting for the agent`. -3. Inspect the session records and identify the terminal `done` or `error` record on the new - continuation `turn_id`, not the paused source `turn_id`. -4. Confirm the dock closes when that terminal record arrives. It must not remain visible for the - extra 60 seconds seen in Round 7. -5. Repeat with a second tab answering the gate. Confirm the observing tab also closes the dock when - its interaction-row snapshot reaches `responded`/`resolved`, even before a terminal record. - -### 4. Recoverable copy is scoped to one interaction - -1. Create approval interaction X and force its response endpoint onto the recoverable path used by - the Round 7 card test. -2. Answer X and confirm its card says `Answer saved, retry needed`. -3. In the same tab, advance to a later approval interaction Y with a different interaction ID. -4. Confirm Y opens with ordinary `Needs your approval` copy and live buttons. It must not inherit - X's recoverable text, answered state, disabled buttons, or error text. -5. Answer Y on a normal continuation and confirm it uses `Answered, waiting for the agent` only - while its own continuation is active, then closes on Y's terminal record. - -## Automated verification - -- `@agenta/chat`: 655 passed. -- `@agenta/oss`: 430 passed, 1 skipped. -- `@agenta/entities`: 1,480 unit tests passed; 31 integration tests skipped because the required - API/auth integration environment was not configured. -- `@agenta/mobile`: 147 passed. -- `@agenta/playground`: 267 passed. -- Chat, OSS, entities, mobile, and playground typechecks passed. -- Monorepo frontend lint passed (four pre-existing mobile hook warnings remain). -- No API files changed, so the dedicated-DB API sessions suite and Ruff were not applicable. From 47f3cbf34e74c0d69df6fb3358d16b98527a7b55 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 00:12:44 +0200 Subject: [PATCH 048/133] fix(sessions): push interaction resolutions to readers Publish committed interaction projections on the session watch relay and reconcile them immediately in every reader, with a one-second gate-only query fallback. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/watch.py | 8 +- api/oss/src/core/sessions/commands/service.py | 1 + .../src/core/sessions/interactions/service.py | 47 +++++++- api/oss/src/core/sessions/watch/interfaces.py | 11 +- api/oss/src/dbs/redis/sessions/contract.py | 18 ++- api/oss/src/dbs/redis/sessions/watch.py | 7 +- .../test_interaction_cancel_records.py | 3 +- ...test_interaction_continuation_admission.py | 6 +- .../test_watch_interactions_publish.py | 53 +++++++-- .../unit/sessions/test_watch_publish.py | 38 +++++++ web/mobile/src/features/chat/ChatScreen.tsx | 9 +- .../src/features/chat/LiveConversation.tsx | 2 + .../src/features/chat/useSessionTranscript.ts | 63 ++++++++++- .../src/features/chat/useSessionWatch.ts | 14 +-- .../hooks/useSessionHydration.ts | 105 ++++++++++++------ .../hooks/useSessionRecordsWatch.ts | 2 +- .../src/hooks/useAgentConversation.ts | 63 +++++++++++ .../unit/assets/transcriptToMessages.test.ts | 73 +++++++----- .../src/session/core/schema.ts | 7 ++ .../agenta-entities/src/session/index.ts | 2 + .../src/session/state/interactionStatus.ts | 18 ++- .../unit/interaction-watch-event.test.ts | 48 ++++++++ 22 files changed, 497 insertions(+), 101 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts diff --git a/api/oss/src/apis/fastapi/sessions/watch.py b/api/oss/src/apis/fastapi/sessions/watch.py index 5c6c0613339..9684cafb2f7 100644 --- a/api/oss/src/apis/fastapi/sessions/watch.py +++ b/api/oss/src/apis/fastapi/sessions/watch.py @@ -1,10 +1,10 @@ """SSE frame generator for ``GET /sessions/streams/watch`` (M3 live relay). Bridges one Redis pub/sub subscription (durable plane, one per SSE connection) -into `text/event-stream` frames. Events carry TYPE + minimal metadata only — -clients revalidate through their existing query paths; no record payloads ride -the wire. Idle periods emit ``: heartbeat`` comment frames so proxies and -clients never see a silent connection. +into `text/event-stream` frames. Most events carry type plus minimal metadata; +interaction events may carry committed row state so readers can retire a gate +without a query round trip. Idle periods emit ``: heartbeat`` comment frames so +proxies and clients never see a silent connection. """ import json diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 1aeff71c873..1bcbdeb238b 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -638,6 +638,7 @@ async def respond_interactions( await self._interactions.publish_interaction_responded( project_id=project_id, session_id=admission.interaction.session_id, + interactions=admission.interactions, ) except Exception as error: # noqa: BLE001 - the durable transaction already committed log.warning( diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 5194a0aea3a..df187cf4ba5 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from uuid import NAMESPACE_DNS, UUID, uuid5 from oss.src.core.sessions.interactions.dtos import ( @@ -26,6 +26,27 @@ log = get_module_logger(__name__) +def _watch_interaction_state(interaction: SessionInteraction) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if interaction.data is not None: + if ( + interaction.data.request is not None + and interaction.data.request.tool_call_id is not None + ): + data["request"] = {"tool_call_id": interaction.data.request.tool_call_id} + if interaction.data.resolution is not None: + data["resolution"] = interaction.data.resolution + return { + "id": str(interaction.id) if interaction.id is not None else None, + "session_id": interaction.session_id, + "turn_id": interaction.turn_id, + "token": interaction.token, + "kind": interaction.kind.value, + "status": interaction.status.value if interaction.status is not None else None, + "data": data or None, + } + + class SessionInteractionsService: def __init__( self, @@ -39,7 +60,12 @@ def __init__( self._records = records_service async def _publish_interaction( - self, *, project_id: UUID, session_id: str, status: str + self, + *, + project_id: UUID, + session_id: str, + status: str, + interactions: Optional[List[SessionInteraction]] = None, ) -> None: # Fire-and-forget relay notification; the publisher never raises. if self._watch is not None: @@ -47,6 +73,14 @@ async def _publish_interaction( project_id=str(project_id), session_id=session_id, status=status, + interactions=( + [ + _watch_interaction_state(interaction) + for interaction in interactions + ] + if interactions is not None + else None + ), ) async def create_interaction( @@ -66,6 +100,7 @@ async def create_interaction( project_id=project_id, session_id=interaction.session_id, status=WATCH_INTERACTION_PENDING, + interactions=[created], ) return created @@ -125,6 +160,7 @@ async def transition_interaction( project_id=transition.project_id, session_id=transition.session_id, status=WATCH_INTERACTION_RESOLVED, + interactions=[result], ) return result @@ -200,12 +236,17 @@ async def publish_session_pending_cancelled( ) async def publish_interaction_responded( - self, *, project_id: UUID, session_id: str + self, + *, + project_id: UUID, + session_id: str, + interactions: List[SessionInteraction], ) -> None: await self._publish_interaction( project_id=project_id, session_id=session_id, status=WATCH_INTERACTION_RESOLVED, + interactions=interactions, ) async def query_interactions( diff --git a/api/oss/src/core/sessions/watch/interfaces.py b/api/oss/src/core/sessions/watch/interfaces.py index dd5b77b7c1a..b86773dd462 100644 --- a/api/oss/src/core/sessions/watch/interfaces.py +++ b/api/oss/src/core/sessions/watch/interfaces.py @@ -1,4 +1,4 @@ -from typing import Protocol, runtime_checkable +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable @runtime_checkable @@ -22,9 +22,14 @@ async def lifecycle(self, *, project_id: str, session_id: str, state: str) -> No ... async def interaction( - self, *, project_id: str, session_id: str, status: str + self, + *, + project_id: str, + session_id: str, + status: str, + interactions: Optional[List[Dict[str, Any]]] = None, ) -> None: - """A gate became actionable or was answered (`pending` | `resolved`).""" + """A gate changed, optionally carrying the committed row state.""" ... async def changed(self, *, project_id: str, entity: str, id: str) -> None: diff --git a/api/oss/src/dbs/redis/sessions/contract.py b/api/oss/src/dbs/redis/sessions/contract.py index ce308a292ca..ac25129fe59 100644 --- a/api/oss/src/dbs/redis/sessions/contract.py +++ b/api/oss/src/dbs/redis/sessions/contract.py @@ -29,6 +29,8 @@ The nest: alive ⊇ running ⊇ attached. attached ⟹ running ⟹ alive. """ +from typing import Any, Dict, List, Optional + from oss.src.utils.env import env # --------------------------------------------------------------------------- @@ -130,7 +132,8 @@ def make_displacement_payload(*, by: str) -> dict: # Payload shapes: # {"type": "records-changed", "session_id": s} # {"type": "lifecycle", "session_id": s, "state": "running"|"ended"} -# {"type": "interaction", "session_id": s, "status": "pending"|"resolved"} +# {"type": "interaction", "session_id": s, "status": "pending"|"resolved", +# "interactions": [...]?} # {"type": "-changed", "entity": entity, "id": id} # --------------------------------------------------------------------------- @@ -169,8 +172,17 @@ def make_watch_lifecycle_payload(*, session_id: str, state: str) -> dict: return {"type": WATCH_EVENT_LIFECYCLE, "session_id": session_id, "state": state} -def make_watch_interaction_payload(*, session_id: str, status: str) -> dict: - return {"type": WATCH_EVENT_INTERACTION, "session_id": session_id, "status": status} +def make_watch_interaction_payload( + *, session_id: str, status: str, interactions: Optional[List[Dict[str, Any]]] = None +) -> dict: + payload = { + "type": WATCH_EVENT_INTERACTION, + "session_id": session_id, + "status": status, + } + if interactions is not None: + payload["interactions"] = interactions + return payload def make_watch_entity_changed_payload(*, entity: str, id: str) -> dict: diff --git a/api/oss/src/dbs/redis/sessions/watch.py b/api/oss/src/dbs/redis/sessions/watch.py index c978e57f312..bf695fc589b 100644 --- a/api/oss/src/dbs/redis/sessions/watch.py +++ b/api/oss/src/dbs/redis/sessions/watch.py @@ -11,7 +11,7 @@ import asyncio import json -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from oss.src.dbs.redis.sessions.contract import ( make_watch_entity_changed_payload, @@ -96,12 +96,15 @@ async def interaction( project_id: str, session_id: str, status: str, + interactions: Optional[List[Dict[str, Any]]] = None, ) -> None: await self._publish( channel=watch_channel(project_id, session_id), project_id=project_id, payload=make_watch_interaction_payload( - session_id=session_id, status=status + session_id=session_id, + status=status, + interactions=interactions, ), ) diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py index 743d734d4b5..c26bc866aba 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py @@ -19,9 +19,10 @@ def __init__(self, journal): self.journal = journal self.calls = [] - async def interaction(self, *, project_id, session_id, status): + async def interaction(self, *, project_id, session_id, status, interactions=None): self.journal.append("publish") self.calls.append((project_id, session_id, status)) + self.pushed = interactions class _RecordingRecordsService: diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index 905e8b319af..7c6a3cc6d85 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -129,6 +129,7 @@ def _durable_approvals_enabled(monkeypatch): class _Interactions: def __init__(self, interaction): self.interactions = [interaction] + self.published = [] @property def interaction(self): @@ -161,7 +162,7 @@ async def transition_interaction(self, *, transition, **kwargs): return self.interactions[index] async def publish_interaction_responded(self, **kwargs): - return None + self.published.append(kwargs) class _Executions: @@ -304,6 +305,9 @@ async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): assert delivery.delivered[0].data["answer"] == {"approved": True} assert executions.source.terminal_outcome == "continued" assert executions.states[-1][1] == SessionExecutionState.recoverable + assert interactions.published[0]["interactions"][0].data.resolution == { + "approved": True + } commands.command = commands.command.model_copy( update={"target_turn_id": "continuation-retry"} diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py index 790190c38a1..4c1174c57a4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py @@ -15,11 +15,15 @@ from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, SessionInteractionCreate, + SessionInteractionData, SessionInteractionKind, SessionInteractionStatus, SessionInteractionTransition, ) -from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.interactions.service import ( + SessionInteractionsService, + _watch_interaction_state, +) _PROJECT = uuid4() @@ -27,12 +31,17 @@ class _RecordingPublisher: def __init__(self): - self.interaction_calls: list[tuple[str, str, str]] = [] - - async def interaction( - self, *, project_id: str, session_id: str, status: str - ) -> None: - self.interaction_calls.append((project_id, session_id, status)) + self.interaction_calls: list[tuple[str, str, str, list[dict] | None]] = [] + + async def interaction(self, **kwargs) -> None: + self.interaction_calls.append( + ( + kwargs["project_id"], + kwargs["session_id"], + kwargs["status"], + kwargs.get("interactions"), + ) + ) def _interaction(session_id: str) -> SessionInteraction: @@ -70,13 +79,28 @@ async def test_create_publishes_pending(): ), ) - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "pending")] + assert publisher.interaction_calls == [ + ( + str(_PROJECT), + "sess-1", + "pending", + [_watch_interaction_state(dao.create_interaction.return_value)], + ) + ] @pytest.mark.asyncio async def test_transition_publishes_resolved(): dao = AsyncMock() - dao.transition_interaction = AsyncMock(return_value=_interaction("sess-1")) + resolved = _interaction("sess-1").model_copy( + update={ + "status": SessionInteractionStatus.responded, + "data": SessionInteractionData( + resolution={"verdict": "approved", "tool_call_id": "tool-1"} + ), + } + ) + dao.transition_interaction = AsyncMock(return_value=resolved) svc, publisher = _service(dao) await svc.transition_interaction( @@ -88,7 +112,14 @@ async def test_transition_publishes_resolved(): ), ) - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved")] + assert publisher.interaction_calls == [ + ( + str(_PROJECT), + "sess-1", + "resolved", + [_watch_interaction_state(resolved)], + ) + ] @pytest.mark.asyncio @@ -127,7 +158,7 @@ async def test_cancel_sweep_publishes_resolved_only_when_it_cancelled(): project_id=_PROJECT, session_id="sess-1" ) assert cancelled == 2 - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved")] + assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved", None)] # No-op sweep: nothing was pending, nothing changed, nothing to notify. dao.cancel_session_pending = AsyncMock(return_value=[]) diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py index 31717408ae0..0c5c52b4ba0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py @@ -182,6 +182,44 @@ async def test_publisher_publishes_on_watch_channel(): } +@pytest.mark.asyncio +async def test_publisher_carries_committed_interaction_resolution(): + import fakeredis + + redis = fakeredis.FakeAsyncRedis() + pubsub = redis.pubsub() + project_id = str(uuid4()) + channel = watch_channel(project_id, "sess-1") + await pubsub.subscribe(channel) + await pubsub.get_message(timeout=1) + + publisher = SessionsWatchPublisher(redis_client=redis) + interaction = { + "id": str(uuid4()), + "session_id": "sess-1", + "turn_id": "turn-1", + "token": "approval-1", + "kind": "user_approval", + "status": "responded", + "data": {"resolution": {"verdict": "approved"}}, + } + await publisher.interaction( + project_id=project_id, + session_id="sess-1", + status="resolved", + interactions=[interaction], + ) + + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1) + assert message is not None + assert json.loads(message["data"]) == { + "type": "interaction", + "session_id": "sess-1", + "status": "resolved", + "interactions": [interaction], + } + + @pytest.mark.asyncio async def test_publisher_swallows_redis_failure(): broken = AsyncMock() diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 39d1a661c01..ab2344824bf 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -152,10 +152,15 @@ const ReplayScreen = ({ // Tightened records cadence only while this foregrounded screen shows a running or pending // turn; derived from the previous render's messages, so it settles one render behind. const [pollMs, setPollMs] = useState(0) - const {messages, state, refresh} = useSessionTranscript(sessionId, pollMs) + const {messages, state, refresh, interactionChanged} = useSessionTranscript(sessionId, pollMs) // Live relay (M3): push-invalidate through the same tick body; while it is open the // poll below is only a safety net. - const watch = useSessionWatch({sessionId, projectId, onRecordsChanged: refresh}) + const watch = useSessionWatch({ + sessionId, + projectId, + onRecordsChanged: refresh, + onInteractionChanged: interactionChanged, + }) const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) const pendingCount = pendingApprovals.length const pendingApprovalIds = useMemo( diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index d9098d6a5e5..b36aab81abc 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -263,10 +263,12 @@ export const LiveConversation = ({ }, [sessionId]) // Push invalidation folds cross-device changes into the guarded transcript. + const {interactionChanged} = conversation const watch = useSessionWatch({ sessionId, projectId, onRecordsChanged: revalidate, + onInteractionChanged: interactionChanged, sharedReaderAdvertised: sharedReader, }) // Poll slowly while a cross-device run cannot be watched live. diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts index 48b8d3a334b..dad5f13e011 100644 --- a/web/mobile/src/features/chat/useSessionTranscript.ts +++ b/web/mobile/src/features/chat/useSessionTranscript.ts @@ -1,12 +1,25 @@ import {useCallback, useEffect, useRef, useState} from "react" -import {loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" -import {revalidateSessionRecordsAtom} from "@agenta/entities/session" +import { + loadSessionMessages, + reconcileInteractionRowStates, + type SessionTranscript, +} from "@agenta/chat/assets" +import { + fetchSessionInteractionStatesAtom, + interactionStatesFromWatchEvent, + revalidateSessionInteractionsAtom, + revalidateSessionRecordsAtom, + type SessionInteractionRowStates, +} from "@agenta/entities/session" +import {isHitlPending} from "@agenta/playground" import type {UIMessage} from "ai" import {getDefaultStore} from "jotai" import {adoptTranscriptRead, shouldAdoptTranscript} from "./transcriptAdoption" +const INTERACTION_GATE_POLL_MS = 1_000 + /** * Read-only transcript for one session: server record replay via `loadSessionMessages` * (IndexedDB-restored, revalidation re-delivered through `onRefreshed`). `null` history @@ -129,5 +142,49 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { } }, [refresh, pollMs]) - return {messages, state, refresh} + const applyInteractionStates = useCallback( + (rows: SessionInteractionRowStates) => { + if (sessionRef.current !== sessionId) return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + }, + [sessionId], + ) + const refreshInteractions = useCallback(async () => { + const store = getDefaultStore() + await store.set(revalidateSessionInteractionsAtom, sessionId) + applyInteractionStates(await store.set(fetchSessionInteractionStatesAtom, sessionId)) + }, [applyInteractionStates, sessionId]) + const interactionChanged = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshInteractions() + return + } + applyInteractionStates(pushed) + void getDefaultStore().set(revalidateSessionInteractionsAtom, sessionId) + }, + [applyInteractionStates, refreshInteractions, sessionId], + ) + const interactionGateOpen = isHitlPending(messages) + useEffect(() => { + if (!interactionGateOpen) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async () => { + await refreshInteractions().catch(() => undefined) + if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + } + timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [interactionGateOpen, refreshInteractions]) + + return {messages, state, refresh, interactionChanged} } diff --git a/web/mobile/src/features/chat/useSessionWatch.ts b/web/mobile/src/features/chat/useSessionWatch.ts index e1b1f76a267..443a6a0daa0 100644 --- a/web/mobile/src/features/chat/useSessionWatch.ts +++ b/web/mobile/src/features/chat/useSessionWatch.ts @@ -16,13 +16,13 @@ import {sessionWatchUrl, watchRetryDelayMs} from "./watchRelay" const MIN_INTERVAL_MS = 3_000 /** - * One EventSource per foregrounded chat screen (M3 live relay). Events carry no payloads — - * every handler funnels into the existing revalidate paths: + * One EventSource per foregrounded chat screen (M3 live relay). Most events invalidate existing + * queries; interaction events also carry committed row state for immediate gate retirement: * * - `records-changed` (and every `open`, for missed-event coverage) → `onRecordsChanged`, * i.e. the transcript tick's body (`revalidateSessionRecordsAtom` + re-read). - * - `lifecycle` / `interaction` → invalidate the shared liveness + actionable-interactions - * queries, and the nav rail's own session queries (no duplicated state; each refetches). + * - `interaction` → reduce its committed row state and invalidate the shared badge queries. + * - `lifecycle` → invalidate liveness and the nav rail's session queries. * * Foreground-only: the source closes on `visibilitychange → hidden` and reopens on visible. * Transient errors ride EventSource's built-in reconnect (the server pins its delay with an @@ -41,7 +41,7 @@ export const useSessionWatch = ({ sessionId: string projectId: string onRecordsChanged: () => void - onInteractionChanged?: () => void + onInteractionChanged?: (event: MessageEvent) => void sharedReaderAdvertised?: boolean }): {connected: boolean} => { const [connected, setConnected] = useState(false) @@ -139,9 +139,9 @@ export const useSessionWatch = ({ } }) es.addEventListener("lifecycle", () => invalidateBadges(true)) - es.addEventListener("interaction", () => { + es.addEventListener("interaction", (event) => { + onInteractionChangedRef.current?.(event as MessageEvent) invalidateBadges() - onInteractionChangedRef.current?.() }) es.onerror = () => { setConnected(false) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index c310043c7f1..4440f20087c 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -12,6 +12,7 @@ import { fetchSessionInteractionStatesAtom, fetchSessionRecordsAtom, hasWaitingInteraction, + interactionStatesFromWatchEvent, revalidateSessionInteractionsAtom, revalidateSessionRecordsAtom, type SessionInteractionRowStates, @@ -42,6 +43,7 @@ const REMOTE_RUN_POLL_MS = 15_000 * resets to the fast cadence, so a long turn that is simply quiet (a slow tool call emits no * records until it returns) is still followed. */ const REMOTE_RUN_POLL_MAX_MS = 60_000 +const INTERACTION_GATE_POLL_MS = 1_000 /** Retry budget for the stranded-first-send record check when the fetch itself fails * (`records: null`). Bounded so a down endpoint gets a short burst, not a hammer; when the budget @@ -542,7 +544,37 @@ export const useSessionHydration = ({ readLog, ], ) - const refreshFromInteractions = useCallback(() => { + const applyInteractionStates = useCallback( + (rows: SessionInteractionRowStates) => { + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + persistMessages({ + id: sessionId, + messages: reconciled, + recordCount: recordWatermarkRef.current, + }) + }, + [ + sessionId, + busyRef, + pendingResumeRef, + messagesRef, + recordWatermarkRef, + setMessages, + persistMessages, + ], + ) + const refreshFromInteractions = useCallback(async () => { if ( shouldSkipRecordsRefresh({ busy: busyRef.current, @@ -550,39 +582,33 @@ export const useSessionHydration = ({ }) ) return - void revalidateSessionInteractions(sessionId) - .then(async () => { - const rows = await fetchSessionInteractionStates(sessionId) - if ( - shouldSkipRecordsRefresh({ - busy: busyRef.current, - pendingResume: !!pendingResumeRef.current, - }) - ) - return - const current = messagesRef.current - const reconciled = reconcileInteractionRowStates(current, rows) - if (reconciled === current) return - messagesRef.current = reconciled - setMessages(reconciled) - persistMessages({ - id: sessionId, - messages: reconciled, - recordCount: recordWatermarkRef.current, - }) - }) - .catch(() => undefined) + try { + await revalidateSessionInteractions(sessionId) + const rows = await fetchSessionInteractionStates(sessionId) + applyInteractionStates(rows) + } catch { + // Best-effort fallback; the live relay or next interval can still converge. + } }, [ sessionId, busyRef, pendingResumeRef, - messagesRef, - recordWatermarkRef, revalidateSessionInteractions, fetchSessionInteractionStates, - setMessages, - persistMessages, + applyInteractionStates, ]) + const refreshFromInteractionEvent = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshFromInteractions() + return + } + applyInteractionStates(pushed) + void revalidateSessionInteractions(sessionId) + }, + [sessionId, applyInteractionStates, refreshFromInteractions, revalidateSessionInteractions], + ) // `ready` fires on every connect — each tab activation, each return to the foreground. Records // can skip a duplicate mount read, but rows must always catch up because a response changes the // interaction row without necessarily appending a record (#6296). @@ -595,16 +621,31 @@ export const useSessionHydration = ({ }) ) refreshFromRecords() - refreshFromInteractions() + void refreshFromInteractions() }, [refreshFromRecords, refreshFromInteractions]) + const interactionGateOpen = isHitlPending(messagesRef.current) + useEffect(() => { + if (activeSessionId !== sessionId || !interactionGateOpen) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async () => { + await refreshFromInteractions() + if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + } + timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [activeSessionId, sessionId, interactionGateOpen, refreshFromInteractions]) useSessionRecordsWatch({ sessionId, projectId, - // #5919 relay; this surface re-reads records on any interaction change, and the - // interaction rows themselves, because a response changes a row without appending a record. - onInteractionChanged: () => { + // #5919 relay; this surface re-reads records on any interaction change, and applies the + // pushed interaction row, because a response changes a row without appending a record. + onInteractionChanged: (event) => { revalidateSessionRecords(sessionId) - refreshFromInteractions() + refreshFromInteractionEvent(event) }, enabled: activeSessionId === sessionId, onReady: refreshOnReady, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts index b3e89d7decf..80af395ae69 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts @@ -36,7 +36,7 @@ export const useSessionRecordsWatch = ({ * `onRecordsChanged` so it can skip a log the caller has just read (#6296). */ onReady: () => void onRecordsChanged: () => void - onInteractionChanged: () => void + onInteractionChanged: (event: MessageEvent) => void sharedReaderAdvertised: boolean }): void => { const queryClient = useQueryClient() diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 2646adfb8f2..4f790f25ab9 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -19,12 +19,15 @@ import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from "rea import { invalidateSessionListQueries, invalidateSessionLivenessQueries, + fetchSessionInteractionStatesAtom, + interactionStatesFromWatchEvent, recordInteractionAnswerAtom, respondInteractionAnswerAtom, respondInteractionAnswersAtom, resumeSessionContinuationAtom, sessionDurableApprovalsCapabilityAtom, revalidateSessionMountsAtom, + revalidateSessionInteractionsAtom, revalidateSessionRecordsAtom, shouldAdoptServerTranscript, } from "@agenta/entities/session" @@ -56,6 +59,7 @@ import {messageText, sideEffectingToolsInRange} from "../assets/rewind" import {submitApprovalForCapability} from "../assets/serverOwnedApproval" import {startupLabelFromDataPart} from "../assets/startupPhases" import {getMessageTraceId} from "../assets/trace" +import {reconcileInteractionRowStates} from "../assets/transcriptToMessages" import {isClientToolPart as defaultIsClientToolPart} from "../clientTools" import {classifyAgentRunError, type ParsedRunError, type RunErrorMetadata} from "../model/error" import {withoutSharedSenderAcceptanceMessages} from "../model/livePreview" @@ -102,6 +106,7 @@ import {useSessionLivePreview} from "./useSessionLivePreview" * error; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to a * dev runtime-error overlay (F-033). */ const ignoreStreamRejection = () => {} +const INTERACTION_GATE_POLL_MS = 1_000 export interface SendInput { text: string @@ -206,6 +211,8 @@ export interface AgentConversation { readerReady: boolean /** This browser's accepted turn is still owned by the shared session path. */ acceptedRunPending: boolean + /** Apply a pushed interaction row immediately, falling back to the row query for old events. */ + interactionChanged: (event: MessageEvent) => void } /** @@ -229,6 +236,8 @@ export const useAgentConversation = ({ const setSessionStatus = useSetAtom(setSessionStatusAtom) const revalidateSessionMounts = useSetAtom(revalidateSessionMountsAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const revalidateSessionInteractions = useSetAtom(revalidateSessionInteractionsAtom) + const fetchSessionInteractionStates = useSetAtom(fetchSessionInteractionStatesAtom) const pruneExpanded = useSetAtom(pruneExpandedAtom) const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) const setTurnStartupLabel = useSetAtom(startTurnClockAtom) @@ -987,6 +996,59 @@ export const useAgentConversation = ({ : transcriptMessages }, [includePreview, messages, previewMessages]) + const applyInteractionStates = useCallback( + (rows: ReturnType) => { + if (!rows || busyRef.current || liveGateInteractionRef.current) return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + persistMessages({ + id: sessionId, + messages: reconciled, + recordCount: recordWatermarkRef.current, + }) + }, + [persistMessages, sessionId, setMessages], + ) + const refreshInteractions = useCallback(async () => { + if (busyRef.current || liveGateInteractionRef.current) return + await revalidateSessionInteractions(sessionId) + applyInteractionStates(await fetchSessionInteractionStates(sessionId)) + }, [ + applyInteractionStates, + fetchSessionInteractionStates, + revalidateSessionInteractions, + sessionId, + ]) + const interactionChanged = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshInteractions() + return + } + applyInteractionStates(pushed) + void revalidateSessionInteractions(sessionId) + }, + [applyInteractionStates, refreshInteractions, revalidateSessionInteractions, sessionId], + ) + useEffect(() => { + if (!hitlPending) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async () => { + await refreshInteractions().catch(() => undefined) + if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + } + timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [hitlPending, refreshInteractions]) + // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── const handleStop = useCallback(() => { const last = messagesRef.current[messagesRef.current.length - 1] @@ -1124,5 +1186,6 @@ export const useAgentConversation = ({ runningFromSnapshot, readerReady, acceptedRunPending, + interactionChanged, } } diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 49ee1e3ab17..746bb186c06 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -3,6 +3,7 @@ import type { SessionInteractionRowStates, SessionRecord, } from "@agenta/entities/session" +import {interactionStatesFromWatchEvent} from "@agenta/entities/session" import {CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} from "@agenta/shared/clientTools" import type {UIMessage} from "ai" import {describe, expect, it} from "vitest" @@ -32,7 +33,9 @@ const record = ( created_at: null, }) -const firstAssistantMetadata = (messages: UIMessage[] | null): Record | undefined => +const firstAssistantMetadata = ( + messages: UIMessage[] | null, +): Record | undefined => messages?.find((message) => message.role === "assistant")?.metadata as | Record | undefined @@ -275,15 +278,12 @@ describe("transcriptToMessages approval resume", () => { "agent", "source-turn", ), - record( - "r-source-done", - {type: "done", stopReason: "paused"}, - "agent", - "source-turn", - ), + record("r-source-done", {type: "done", stopReason: "paused"}, "agent", "source-turn"), ] - const pendingParts = transcriptToMessages(pendingRecords)![1] - .parts as unknown as Record[] + const pendingParts = transcriptToMessages(pendingRecords)![1].parts as unknown as Record< + string, + unknown + >[] expect(pendingParts).toEqual( expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), ) @@ -350,12 +350,7 @@ describe("transcriptToMessages approval resume", () => { "agent", "source-turn", ), - record( - "r-source-done", - {type: "done", stopReason: "paused"}, - "agent", - "source-turn", - ), + record("r-source-done", {type: "done", stopReason: "paused"}, "agent", "source-turn"), ] const running = [ ...source, @@ -390,7 +385,12 @@ describe("transcriptToMessages approval resume", () => { const finished = transcriptToMessages([ ...running, - record("r-result", {type: "tool_result", id: "tool-2", output: "ok"}, "agent", "continuation-turn"), + record( + "r-result", + {type: "tool_result", id: "tool-2", output: "ok"}, + "agent", + "continuation-turn", + ), record("r-continuation-done", {type: "done"}, "agent", "continuation-turn"), ]) expect(firstAssistantMetadata(finished)).toMatchObject({ @@ -1072,22 +1072,41 @@ describe("transcriptToMessages interaction-row precedence", () => { }) }) - it("settles an already-rendered approval when another reader answers the row", () => { + it("replays the observer tab sequence from pending record to pushed resolution", () => { const live = transcriptToMessages(abandonedApprovalRecords()) ?? [] - const reconciled = reconcileInteractionRowStates( - live, - rowStates(rowState("approval-1", {kind: "user_approval", status: "responded"})), + const pushed = interactionStatesFromWatchEvent( + JSON.stringify({ + type: "interaction", + session_id: "session-1", + status: "resolved", + interactions: [ + { + id: "interaction-row-1", + session_id: "session-1", + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: { + request: {tool_call_id: "tool-1"}, + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }, + }, + ], + }), + "session-1", ) + const reconciled = reconcileInteractionRowStates(live, pushed) expect( - reconciled.flatMap((message) => message.parts).find((part) => - "toolCallId" in part ? part.toolCallId === "tool-1" : false, - ), - ).toMatchObject({state: "approval-responded"}) + reconciled + .flatMap((message) => message.parts) + .find((part) => ("toolCallId" in part ? part.toolCallId === "tool-1" : false)), + ).toMatchObject({state: "approval-responded", approval: {approved: true}}) expect( - live.flatMap((message) => message.parts).find((part) => - "toolCallId" in part ? part.toolCallId === "tool-1" : false, - ), + live + .flatMap((message) => message.parts) + .find((part) => ("toolCallId" in part ? part.toolCallId === "tool-1" : false)), ).toMatchObject({state: "approval-requested"}) }) diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 6316c24a329..25f6d7adf5d 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -92,6 +92,13 @@ export const sessionInteractionResponseSchema = z.object({ interaction: sessionInteractionSchema.nullish(), }) +export const sessionInteractionWatchEventSchema = z.object({ + type: z.literal("interaction"), + session_id: z.string(), + status: z.string(), + interactions: z.array(sessionInteractionSchema).nullish(), +}) + export type SessionInteraction = z.infer /** HITL lifecycle codes. `pending` is the only actionable state. */ diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 5bfd69701df..fd831cdb653 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -141,6 +141,8 @@ export { export { fetchSessionInteractionStatesAtom, hasWaitingInteraction, + interactionStatesFromRows, + interactionStatesFromWatchEvent, revalidateSessionInteractionsAtom, type SessionInteractionRowState, type SessionInteractionRowStates, diff --git a/web/packages/agenta-entities/src/session/state/interactionStatus.ts b/web/packages/agenta-entities/src/session/state/interactionStatus.ts index 64bedd6d0bc..a86e962258f 100644 --- a/web/packages/agenta-entities/src/session/state/interactionStatus.ts +++ b/web/packages/agenta-entities/src/session/state/interactionStatus.ts @@ -12,6 +12,7 @@ import type { SessionInteractionKind, SessionInteractionStatusCode, } from "../core/schema" +import {sessionInteractionWatchEventSchema} from "../core/schema" const SESSION_INTERACTION_ROWS_STALE_MS = 15_000 @@ -37,7 +38,7 @@ export interface SessionInteractionRowState { export type SessionInteractionRowStates = ReadonlyMap -function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteractionRowStates { +export function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteractionRowStates { const states = new Map() for (const row of rows) { if (typeof row.token !== "string" || !row.token) continue @@ -56,6 +57,21 @@ function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteracti return states } +/** Row states delivered by the session watch relay; undefined means the caller must refetch. */ +export function interactionStatesFromWatchEvent( + data: string, + sessionId: string, +): SessionInteractionRowStates | undefined { + try { + const parsed = sessionInteractionWatchEventSchema.safeParse(JSON.parse(data)) + if (!parsed.success || parsed.data.session_id !== sessionId || !parsed.data.interactions) + return undefined + return interactionStatesFromRows(parsed.data.interactions) + } catch { + return undefined + } +} + /** * Imperative, best-effort fetch through the shared query cache. Never throws — a failure (network, * missing project scope) resolves to an empty map, so a replay-join miss degrades to today's diff --git a/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts b/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts new file mode 100644 index 00000000000..18ecd593477 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from "vitest" + +import {interactionStatesFromWatchEvent} from "../../src/session/state/interactionStatus" + +const event = (sessionId = "session-1") => + JSON.stringify({ + type: "interaction", + session_id: sessionId, + status: "resolved", + interactions: [ + { + id: "interaction-1", + session_id: sessionId, + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: { + request: {tool_call_id: "tool-1"}, + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }, + }, + ], + }) + +describe("interactionStatesFromWatchEvent", () => { + it("decodes the committed resolution carried by the session relay", () => { + expect( + interactionStatesFromWatchEvent(event(), "session-1")?.get("approval-1"), + ).toMatchObject({ + id: "interaction-1", + turnId: "turn-1", + toolCallId: "tool-1", + status: "responded", + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }) + }) + + it("falls back to a query for metadata-only and foreign-session events", () => { + expect( + interactionStatesFromWatchEvent( + JSON.stringify({type: "interaction", session_id: "session-1", status: "resolved"}), + "session-1", + ), + ).toBeUndefined() + expect(interactionStatesFromWatchEvent(event("session-2"), "session-1")).toBeUndefined() + }) +}) From 868e46b9bfe813568b7907ddbe36c3fa4932679d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 00:13:07 +0200 Subject: [PATCH 049/133] fix(chat): preserve ownership for released sends Mark a queued send as locally owned before transport dispatch so liveness cannot misclassify its run as foreign. Document executing and parked continuation admission. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../contracts/commands.md | 19 +++++++------ .../AgentChatSlice/AgentConversation.tsx | 5 ++++ .../src/hooks/useAgentChatQueue.ts | 9 ++++-- .../src/hooks/useAgentConversation.ts | 5 ++++ .../hooks/durableContinuationHold.test.ts | 1 + .../unit/hooks/useAgentChatQueue.test.ts | 28 +++++++++++++++---- 6 files changed, 51 insertions(+), 16 deletions(-) diff --git a/docs/design/session-control-and-live-events/contracts/commands.md b/docs/design/session-control-and-live-events/contracts/commands.md index bfb9fa1a76c..7d31f529cc2 100644 --- a/docs/design/session-control-and-live-events/contracts/commands.md +++ b/docs/design/session-control-and-live-events/contracts/commands.md @@ -54,14 +54,17 @@ durable terminal events. ## Continuation admission -A continuation command owns the next Send only while its execution is `pending_delivery` or -`recoverable`. An `applied/started` continuation whose execution is `running` may be parked on a -later interaction; it is therefore steerable, just like an initial execution parked for human -input. Send preflight does not claim that state. If the watchdog later moves the execution to -`recoverable`, preflight may reopen and redeliver it before accepting a new message. - -This makes the command query and the public router share one state rule: `running` is live and -steerable; `recoverable` owns continuation recovery. +A continuation command owns the next Send while its execution is `pending_delivery` or +`recoverable`. Once it is `applied/started`, Send admission depends on the continuation phase: + +- **EXECUTING:** the client holds a Send and delivers it after the continuation ends. If a client + sends it anyway, the server refuses it so it cannot supersede the executing continuation. +- **PARKED:** the server accepts a Send as a steer, just as it does for an initial execution parked + for human input. + +If the watchdog moves the execution to `recoverable`, preflight may reopen and redeliver it before +accepting a new message. The client hold is an ordering guarantee; the server refusal is the race +backstop for stale or non-conforming clients. ## Recovery rules diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 76ba56691b7..38307cc233b 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -389,6 +389,10 @@ const AgentConversation = ({ }, [sendMessage, sessionId], ) + const markRunOwned = useCallback( + () => setSessionStatus({id: sessionId, status: "running"}), + [sessionId, setSessionStatus], + ) // Queue messages typed while a turn is streaming or paused on a HITL approval; released // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — @@ -413,6 +417,7 @@ const AgentConversation = ({ recoverable: recoverableContinuation, retryContinuation: retryRecoverableContinuation, continuationExecutionId, + markRunOwned, sendQueued, sessionId, }) diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index d056c194075..12965e29ea8 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -52,6 +52,8 @@ interface UseAgentChatQueueArgs { * reads as settled. */ continuationExecutionId?: string | null + /** Mark this tab as the next run's owner before a released send reaches the transport. */ + markRunOwned: () => void /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be * referentially stable so the release effect doesn't churn on every streamed token. */ sendQueued: (item: QueuedMessage) => void @@ -95,6 +97,7 @@ export const useAgentChatQueue = ({ recoverable = false, retryContinuation, continuationExecutionId = null, + markRunOwned, sendQueued, sessionId, }: UseAgentChatQueueArgs) => { @@ -200,12 +203,13 @@ export const useAgentChatQueue = ({ if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { releasingRef.current = true lastSentRef.current = message + markRunOwned() sendQueued(message) } else { setQueued((q) => [...q, message]) } }, - [canReleaseNow, recoverable, retryContinuation, sendQueued], + [canReleaseNow, recoverable, retryContinuation, markRunOwned, sendQueued], ) const removeQueued = useCallback((id: string) => { @@ -302,8 +306,9 @@ export const useAgentChatQueue = ({ setQueued(rest) // A released head also needs refusal recovery because it has left the queue. lastSentRef.current = head + markRunOwned() sendQueued(head) - }, [settled, canReleaseNow, queued, sendQueued]) + }, [settled, canReleaseNow, queued, markRunOwned, sendQueued]) return { queued, diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 4f790f25ab9..f773861c081 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -629,6 +629,10 @@ export const useAgentConversation = ({ }, [sendMessage, sessionId], ) + const markRunOwned = useCallback( + () => setSessionStatus({id: sessionId, status: "running"}), + [sessionId, setSessionStatus], + ) // Orphan detection for the queue's pre-resume hold: the tail is a RESTORED message (this // mount never streamed it) shaped like "auto-resume imminent", and no gate was settled live @@ -660,6 +664,7 @@ export const useAgentConversation = ({ recoverable: recoverableContinuation, retryContinuation: retryRecoverableContinuation, continuationExecutionId, + markRunOwned, sendQueued, sessionId, }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts index a5a2c844770..e991e36133c 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts @@ -58,6 +58,7 @@ const renderQueue = (initial: {messages: UIMessage[]; continuationExecutionId?: messages: props.messages, stopped: false, resumeOrphaned: true, + markRunOwned: vi.fn(), sendQueued, ...(props.continuationExecutionId !== undefined ? {continuationExecutionId: props.continuationExecutionId} diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index a4c6ef3f54e..83362e4d40e 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -32,10 +32,7 @@ const assistantAwaitingApproval = (id: string): UIMessage => ], }) as unknown as UIMessage -const assistantContinuation = ( - id: string, - state: "running" | "done" | "error", -): UIMessage => +const assistantContinuation = (id: string, state: "running" | "done" | "error"): UIMessage => ({ ...assistantAwaitingApproval(id), metadata: { @@ -70,13 +67,14 @@ interface HarnessProps { const setup = (initial: HarnessProps) => { const sendQueued = vi.fn() + const markRunOwned = vi.fn() const retryContinuation = vi.fn(() => Promise.resolve(true)) const view = renderHook( (props: HarnessProps) => - useAgentChatQueue({...props, sendQueued, retryContinuation}), + useAgentChatQueue({...props, markRunOwned, sendQueued, retryContinuation}), {initialProps: initial}, ) - return {sendQueued, retryContinuation, ...view} + return {markRunOwned, sendQueued, retryContinuation, ...view} } const settledEmpty: HarnessProps = {status: "ready", messages: [], stopped: false} @@ -206,6 +204,24 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("marks a released send as locally owned before dispatching it", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, markRunOwned, sendQueued} = setup(paused) + act(() => result.current.submit({text: "held during the continuation"})) + + rerender({...paused, messages: [userTurn("u1", "go"), assistantText("a2", "done")]}) + + expect(markRunOwned).toHaveBeenCalledOnce() + expect(sendQueued).toHaveBeenCalledOnce() + expect(markRunOwned.mock.invocationCallOrder[0]).toBeLessThan( + sendQueued.mock.invocationCallOrder[0], + ) + }) + it("holds through a different continuation execution and drains once after its terminal", () => { const paused: HarnessProps = { status: "ready", From 8d754846627945f7d93206191bdf7e24a1c5cf12 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 00:34:03 +0200 Subject: [PATCH 050/133] fix(chat): own durable approval continuations Keep the answering tab locally running for the continuation execution returned by the durable approval response, without claiming ownership in observer tabs. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 13 +++++- .../AgentChatSlice/state/liveness.test.ts | 19 ++++++++ .../src/hooks/useAgentChatQueue.ts | 10 ++++ .../src/hooks/useAgentConversation.ts | 3 +- .../hooks/durableContinuationHold.test.ts | 46 ++++++++++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 38307cc233b..ccb4e5c39bb 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -402,6 +402,7 @@ const AgentConversation = ({ queued, submit, removeQueued, + ownsContinuation, hitlPending, editingId, beginEdit, @@ -557,11 +558,19 @@ const AgentConversation = ({ ? "error" : hitlPending || anyPendingInteraction ? "awaiting" - : busy + : busy || ownsContinuation ? "running" : "idle" setSessionStatus({id: sessionId, status}) - }, [error, hitlPending, anyPendingInteraction, busy, sessionId, setSessionStatus]) + }, [ + error, + hitlPending, + anyPendingInteraction, + busy, + ownsContinuation, + sessionId, + setSessionStatus, + ]) // On unmount, retire the dot ONLY if the run went with us. A chat preserved past this mount // (route change with the tab still open) is still this browser's run to report, so it keeps its // status until it settles — `useAgentChatSession`'s `onFinish` retires it then. The session hook diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index a10fe89f260..eee1e713ccb 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -39,6 +39,25 @@ describe("isRunningElsewhere", () => { } }) + it("hides an owned continuation in the answering tab but shows it in an observer", () => { + const continuationPoll = {isRunning: true, livenessUpdatedAt: 16_000} as const + + expect( + isRunningElsewhere({ + ...continuationPoll, + localStatus: "running", + localSettledAt: undefined, + }), + ).toBe(false) + expect( + isRunningElsewhere({ + ...continuationPoll, + localStatus: "idle", + localSettledAt: undefined, + }), + ).toBe(true) + }) + it("distrusts stale liveness after a local error", () => { expect( isRunningElsewhere({ diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 12965e29ea8..2436e87e11e 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -147,6 +147,14 @@ export const useAgentChatQueue = ({ // A user stop cancels the continuation too, so it outranks the hold exactly as it outranks // every other gate here. const continuationHold = !stopped && (idHold || hasRunningApprovalContinuation(messages)) + // Ownership is scoped by the respond body's execution id, so an observer rendering the same + // continuation records never claims it. Keep ownership past the gap ceiling once that exact + // execution is visibly running; the ceiling only protects a continuation that wrote nothing. + const ownsContinuation = + idHold || + (!!continuationExecutionId && + hasRunningApprovalContinuation(messages) && + !approvalContinuationSettled(messages, continuationExecutionId)) // Releasable now: the normal gate, OR a settled turn whose hold was voided — by a user stop, // or by an orphaned restored resume shape that nothing in this mount can ever fire. @@ -314,6 +322,8 @@ export const useAgentChatQueue = ({ queued, submit, removeQueued, + /** This tab received the durable respond body for this still-running execution. */ + ownsContinuation, /** The conversation is paused on a HITL approval — typed messages should queue, not send. */ hitlPending, /** Id of the held message the composer is currently editing, or null. */ diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index f773861c081..7ac4bd62216 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -650,6 +650,7 @@ export const useAgentConversation = ({ queued, submit, removeQueued, + ownsContinuation, hitlPending, editingId, beginEdit, @@ -837,7 +838,7 @@ export const useAgentConversation = ({ const runStatus = deriveSessionRunStatus({ error: !!errorBoundary.runError, hitlPending, - busy: busy || acceptedRunPending, + busy: busy || acceptedRunPending || ownsContinuation, }) useEffect(() => { setSessionStatus({id: sessionId, status: runStatus}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts index e991e36133c..356f41be586 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts @@ -77,6 +77,48 @@ afterEach(() => { }) describe("a held message must outlive the durable continuation", () => { + it("keeps continuation ownership only in the tab that received the respond execution id", () => { + const answering = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + const observer = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: null, + }) + + expect(answering.result.current.ownsContinuation).toBe(true) + expect(observer.result.current.ownsContinuation).toBe(false) + + for (const count of [ + AFTER_CONTINUATION_FIRST_THOUGHT, + AFTER_CONTINUATION_TOOL_CALL, + AFTER_CONTINUATION_INTERACTION_RESPONSE, + ]) { + const messages = messagesAfter(count) + answering.rerender({ + messages, + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + observer.rerender({messages, continuationExecutionId: null}) + + expect( + answering.result.current.ownsContinuation, + `answering tab lost ownership after record ${count}`, + ).toBe(true) + expect( + observer.result.current.ownsContinuation, + `observer claimed ownership after record ${count}`, + ).toBe(false) + } + + answering.rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(answering.result.current.ownsContinuation).toBe(false) + }) + it("holds through every continuation record and releases on its terminal one", () => { const {rerender, result, sendQueued} = renderQueue({ messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), @@ -151,7 +193,7 @@ describe("a held message must outlive the durable continuation", () => { it("keeps holding past the ceiling while the transcript still shows the continuation running", () => { vi.useFakeTimers() - const {rerender, sendQueued} = renderQueue({ + const {rerender, result, sendQueued} = renderQueue({ messages: messagesAfter(AFTER_CONTINUATION_FIRST_THOUGHT), continuationExecutionId: CONTINUATION_EXECUTION_ID, }) @@ -159,11 +201,13 @@ describe("a held message must outlive the durable continuation", () => { vi.advanceTimersByTime(CONTINUATION_HOLD_MAX_MS + 1) }) expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.ownsContinuation).toBe(true) rerender({ messages: messagesAfter(AFTER_CONTINUATION_DONE), continuationExecutionId: CONTINUATION_EXECUTION_ID, }) expect(sendQueued).toHaveBeenCalledOnce() + expect(result.current.ownsContinuation).toBe(false) }) }) From 2ebd8483375f3bbdc5915f4a241851e870571f9f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 01:08:37 +0200 Subject: [PATCH 051/133] fix(api): preserve cancellation settlement ordering Exclude cancelled terminal records from completion reconciliation so the runner's Stop outcome remains the terminal compare-and-set winner. Cover both the synchronous ingest guard and persisted-record-first ordering. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/router.py | 2 +- api/oss/src/core/sessions/records/service.py | 3 +- .../sessions/test_late_record_quarantine.py | 30 ++++++++++++++ .../sessions/test_record_ingest_endpoint.py | 39 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index b034ecab07b..cac4e3997d5 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -1031,7 +1031,7 @@ async def ingest_record_event( and self.commands_service is not None and body.record_type == TERMINAL_RECORD_TYPE and body.turn_id - and (body.attributes or {}).get("stopReason") != "paused" + and (body.attributes or {}).get("stopReason") not in ("paused", "cancelled") ): await self.commands_service.settle_execution_completed( project_id=UUID(project_id), diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index 3e6cb56981e..1202b33cb60 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -145,7 +145,8 @@ async def _settle_completed_continuations( and record.quarantined_at is None and (record.attributes or {}).get(RECORD_SETTLED_BY_ATTRIBUTE) != SETTLED_BY_WATCHDOG - and (record.attributes or {}).get("stopReason") != "paused" + and (record.attributes or {}).get("stopReason") + not in ("paused", "cancelled") } for project_id, session_id, execution_id in candidates: try: diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py index 79605c8f440..3733c3982f0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -403,6 +403,36 @@ async def test_paused_or_quarantined_done_does_not_complete_a_continuation(monke assert _quarantined(service.records_dao)[-1].record_type == "done" +async def test_cancelled_done_arriving_first_leaves_stop_settlement_to_win(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) + service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + + await service.append_many( + events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + ) + assert executions.rows[(_SESSION, _TURN)].terminal_outcome is None + + result = await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="stopped", + settled_by="runner", + ) + + assert result.won is True + assert executions.rows[(_SESSION, _TURN)].terminal_outcome == "stopped" + + async def test_ingest_marks_the_runners_terminal_record_written(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) executions = _ExecutionSettlements() diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 89d923b0160..77bd45e89c1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -194,6 +194,45 @@ async def publish(**kwargs): ) +async def test_cancelled_terminal_does_not_settle_continuation_as_completed( + monkeypatch, +): + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, + ) + commands_service = AsyncMock() + router = RecordsRouter( + records_service=AsyncMock(), + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + attributes={"stopReason": "cancelled"}, + ) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=True, + ), + ): + await router.ingest_record_event(request=request, body=body) + + commands_service.settle_execution_completed.assert_not_awaited() + + async def test_terminal_publish_failure_is_retryable_after_core_settlement(monkeypatch): from fastapi import HTTPException From f3b98bb9c994357756843f0883e1ca7a55f4d356 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 01:09:43 +0200 Subject: [PATCH 052/133] fix(api): serialize terminal interaction retries Allow a valid matching retry to return the terminal source execution after a sibling answer has already continued the turn. Cover the service branch and FastAPI response construction. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/models.py | 6 ++- ...test_interaction_continuation_admission.py | 1 + .../test_respond_interaction_durable.py | 50 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 27f11eb8d05..a266960cd44 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -486,7 +486,11 @@ class SessionExecutionRef(BaseModel): class SessionInteractionContinuationExecution(BaseModel): id: str state: Literal[ - "awaiting_interactions", "pending_delivery", "recoverable", "running" + "awaiting_interactions", + "pending_delivery", + "recoverable", + "running", + "terminal", ] diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index 7c6a3cc6d85..ad4ce14236c 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -419,6 +419,7 @@ async def test_parallel_answers_wait_then_share_one_continuation(): assert retry.interaction.id == first_id assert retry.command is None + assert retry.execution_state == SessionExecutionState.terminal assert len(delivery.delivered) == 1 diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py index 451ec4f6f23..25bd3f81033 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -78,6 +78,56 @@ async def test_durable_response_returns_202_and_stable_refs(monkeypatch): ) +async def test_matching_partial_answer_retry_returns_terminal_source(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=None, + execution_id="source-1", + execution_state=SessionExecutionState.terminal, + waiting_for_interactions=False, + ) + commands = SimpleNamespace(respond_interaction=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "partial-answer-retry"}, + ), + interaction_id=interaction_id, + body=SessionInteractionRespondRequest( + answer={"approved": True}, expected_execution_id="source-1" + ), + ) + + assert response.status_code == 202 + assert json.loads(response.body) == { + "interaction": interaction.model_dump(mode="json"), + "command": None, + "execution": {"id": "source-1", "state": "terminal"}, + } + + async def test_durable_batch_returns_202_with_one_continuation(monkeypatch): project_id = uuid4() user_id = uuid4() From ebb5c0fe6057bdc35b2ff1535ce9a91cee57d8bd Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 01:11:34 +0200 Subject: [PATCH 053/133] fix(mobile): distinguish local continuation ownership Derive the running-elsewhere strip from the shared local run status so a detached continuation is not labeled remote in its owner tab. Cover owner, observer, and locally parked states. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/mobile/src/features/chat/LiveConversation.tsx | 11 +++++++++-- web/mobile/src/features/chat/turnStatus.ts | 10 +++++++++- web/mobile/tests/unit/turnStatus.test.ts | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index b36aab81abc..679024257b5 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -61,7 +61,11 @@ import {ChatLoading} from "./states/ChatStates" import {StopButton} from "./StopButton" import {cancelledStopAction} from "./stopHereState" import {TurnRow} from "./TurnRow" -import {deriveMobileRemoteTurnPresentation, showTrailingWorkingPulse} from "./turnStatus" +import { + deriveMobileRemoteTurnPresentation, + showRunningElsewhere, + showTrailingWorkingPulse, +} from "./turnStatus" import {TurnStatusLine} from "./TurnStatusLine" import {useApprovalActions, type ApprovalActions} from "./useApprovalActions" import {useSessionWatch} from "./useSessionWatch" @@ -586,7 +590,10 @@ export const LiveConversation = ({ composer, as on the desktop — it used to be a top bar that also appeared for THIS device's own turns, duplicating the composer's Stop and shifting the transcript twice per run. */} - {remoteTurn.showStrip && !streamingHere ? ( + {showRunningElsewhere({ + running: remoteTurn.showStrip, + localStatus: conversation.runStatus, + }) && !streamingHere ? ( streaming && !turns.some((turn) => !turn.isUser && turn.isStreamingTurn) + +export const showRunningElsewhere = ({ + running, + localStatus, +}: { + running: boolean + localStatus: SessionRunStatus +}): boolean => running && localStatus !== "running" && localStatus !== "awaiting" diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index 6b8ab043281..5ece0284506 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -2,6 +2,7 @@ import {describe, expect, it} from "vitest" import { deriveMobileRemoteTurnPresentation, + showRunningElsewhere, showTrailingWorkingPulse, } from "@/features/chat/turnStatus" @@ -85,3 +86,17 @@ describe("deriveMobileRemoteTurnPresentation", () => { ).toBe(false) }) }) + +describe("showRunningElsewhere", () => { + it("hides the strip for the tab that owns a detached continuation", () => { + expect(showRunningElsewhere({running: true, localStatus: "running"})).toBe(false) + }) + + it("shows the strip for an idle observer of the same backend run", () => { + expect(showRunningElsewhere({running: true, localStatus: "idle"})).toBe(true) + }) + + it("keeps a locally parked gate from being labeled remote", () => { + expect(showRunningElsewhere({running: true, localStatus: "awaiting"})).toBe(false) + }) +}) From c5659f28de8e322c7e990b48aa586b7ea188fa8f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 13:36:13 +0200 Subject: [PATCH 054/133] style(sessions): reformat the completion-failure filter Ruff 0.15.12 collapses the comprehension guard onto one line. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/tasks/asyncio/sessions/orphan_sweep.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 020c9dc1103..256db4c3719 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -556,8 +556,7 @@ async def run_orphan_sweep( orphan_rows = [ row for row in orphan_rows - if (row[1], row[2], str(row[3])) - not in completion_failures + if (row[1], row[2], str(row[3])) not in completion_failures ] if not orphan_rows and not unsettled: From 1978bf1b3cfe7e7cddf581945a0c1a157d090419 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 13:59:00 +0200 Subject: [PATCH 055/133] fix(runner): register the Stop handle only after admission Increment 6 registered the session execution as soon as the abort controller existed. On the milestone 2 base that reintroduces the bug d91ed34f94 fixed: a contender the coordination plane refuses replaces the admitted turn's Stop handle, so a Stop for the live turn reaches the refused one and the live turn keeps running. The registration below, after admission, is the only one. A durable continuation reaches it on the same path, and the window this removes is one heartbeat round trip, not the environment acquisition the original comment described. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- services/runner/src/server.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 0aee969202b..1f3d97f3181 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -654,28 +654,12 @@ async function runAndStreamWithApiBaseResolved( }); } - // Make this execution reachable by a control command. Registered as early as the abort - // controller exists, so a Stop that arrives while the environment is still being acquired - // still aborts the run rather than waiting for the heartbeat to notice. + // The Stop handle is registered only AFTER admission succeeds, further down. A contender that + // the coordination plane refuses must never replace the admitted turn's handle, or a Stop for + // the live turn reaches the refused one and the live turn keeps running. // // A run with no project scope is not registered. `poolKeyFor` forms no key for it either, so // it can never park, and Stop falls back to the heartbeat path exactly as it did before. - if (sessionOwned) { - registerExecution({ - // Usually undefined here: `runContext.project.id` is empty on the live invoke path, and - // the real scope comes from the signed mount. The coordinator fills it in through - // `onScopeResolved` a moment later. - projectId: projectScopeFor(request, undefined)?.id, - sessionId, - turnId, - startedAt: Date.now(), - // Labelled, because a command from the control plane IS a cooperative user Stop and - // `shouldPark` parks only an abort the runner can prove was one. An unlabelled abort here - // would end the turn `cancelled` and then DESTROY the sandbox, which is the exact failure - // Stop exists to avoid. See `sessions/stop-signal.ts`. - abort: () => controller.abort(USER_STOP_ABORT_REASON), - }); - } if (sessionOwned) { try { From ac4c48e4546dfc094a81276e8b7a69a2015a4202 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:04:23 +0200 Subject: [PATCH 056/133] fix(chat): settle a cancelled durable continuation Milestone 2 added a terminal branch for a user Stop that returns before the continuation bookkeeping below it. A continuation the runner cancels then keeps the state "running" forever, so the durable-continuation hold never releases the queued message. Settle the continuation in that branch as well. Nothing else in the Stop branch changes, so a stopped turn still renders Stopped and still carries no recordTerminal marker. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../agenta-chat/src/assets/transcriptToMessages.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index ea2d7dcc2a8..556a1369097 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -646,6 +646,14 @@ export function transcriptToMessages( continue } if (p.stopReason === "cancelled") { + // A cancelled continuation is still a TERMINAL record for that execution. Settle it + // here too, or the durable-continuation hold waits for a `done` that never comes. + if ( + target?.approvalContinuation && + target.approvalContinuation.executionId === executionId + ) { + target.approvalContinuation.state = "done" + } // Keep a carrier so a content-free cancellation can still render Stopped. if (!current || current.role !== "assistant") { current = newDraft(row.id, "assistant") From 6ddc2bf0c7b438f9d984928ba8e5a63f6215a1fb Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:08:01 +0200 Subject: [PATCH 057/133] test(web): mock the durable approval seams the session hook now reads The execution-guard suite predates increment 6. Its module mocks list the atoms and asset helpers the hook imported at the time, so the added durable approval atoms and the continuation preflight came back undefined and the hook threw on mount. The preflight mock is a pass-through: this suite drives the execution guard, not the durable retry. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/hooks/useAgentChatSession.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index 5788c340256..5520e65a7a9 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -28,7 +28,15 @@ vi.mock("@agenta/chat/assets", () => ({ buildRequestWithinDeadline: (build: () => Promise) => build(), getMessageTraceId: () => undefined, latestTurnId: () => state.latestTurnId, + // The continuation preflight is a pass-through here: this suite drives the execution guard, + // not the durable retry, so the request builder must simply run. + prepareAfterContinuationPreflight: ( + _resume: unknown, + _sessionId: string, + build: () => Promise, + ) => build(), startupLabelFromDataPart: () => undefined, + submitApprovalForCapability: vi.fn(), })) vi.mock("@agenta/chat/hooks", () => ({ @@ -83,6 +91,10 @@ vi.mock("@agenta/entities/session", () => ({ invalidateSessionListQueries: vi.fn(), killSession: vi.fn(), recordInteractionAnswerAtom: "record-interaction-answer", + respondInteractionAnswerAtom: "respond-interaction-answer", + respondInteractionAnswersAtom: "respond-interaction-answers", + resumeSessionContinuationAtom: "resume-session-continuation", + sessionDurableApprovalsCapabilityAtom: "session-durable-approvals-capability", revalidateSessionMountsAtom: "revalidate-mounts", revalidateSessionRecordsAtom: "revalidate-records", })) From 69542622241cf31c06219f694b4b9991b3ea8ea5 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:09:40 +0200 Subject: [PATCH 058/133] style(chat): format the durable approval test files Prettier's CI job checks the package tests directories that lint-fix does not reach. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../tests/unit/hooks/useApprovalDock.test.ts | 2 +- .../tests/unit/model/approvals.test.ts | 31 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index d89df5f4825..182b3f46ede 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -228,7 +228,7 @@ describe("useApprovalDock", () => { const {result, rerender} = renderHook( (props: {messages: UIMessage[]}) => useApprovalDock({messages: props.messages, respond}), - {initialProps: {messages: [assistantWithGates("g1")] }}, + {initialProps: {messages: [assistantWithGates("g1")]}}, ) act(() => result.current.respond(true)) diff --git a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts index fbe9835fe6f..9ea749ba53b 100644 --- a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts @@ -32,25 +32,22 @@ describe("getPendingApprovals", () => { expect(getPendingApprovals([])).toEqual([]) }) - it.each(["done", "error"])( - "retires a stale approval after its continuation is %s", - (state) => { - const [, message] = approvalTurnFixture as UIMessage[] - const stale = { - ...message, - metadata: { - approvalContinuation: { - sourceExecutionId: "source-turn", - executionId: "continuation-turn", - state, - approvalIds: ["appr_1", "appr_2"], - }, + it.each(["done", "error"])("retires a stale approval after its continuation is %s", (state) => { + const [, message] = approvalTurnFixture as UIMessage[] + const stale = { + ...message, + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state, + approvalIds: ["appr_1", "appr_2"], }, - } as UIMessage + }, + } as UIMessage - expect(getPendingApprovals([stale])).toEqual([]) - }, - ) + expect(getPendingApprovals([stale])).toEqual([]) + }) it("keeps a later interaction out of an earlier continuation's terminal sweep", () => { const [, message] = approvalTurnFixture as UIMessage[] From 892e2ba9889d30fa20fc1b8c68e62105a475571c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 13:31:38 +0200 Subject: [PATCH 059/133] feat(api): persist queued session input Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/entrypoints/routers.py | 10 + ...oss000000028_add_session_pending_inputs.py | 81 +++++++ api/oss/src/apis/fastapi/sessions/models.py | 54 ++++- api/oss/src/apis/fastapi/sessions/router.py | 216 +++++++++++++++++- api/oss/src/core/sessions/inputs/__init__.py | 15 ++ api/oss/src/core/sessions/inputs/dtos.py | 47 ++++ .../src/core/sessions/inputs/interfaces.py | 67 ++++++ api/oss/src/core/sessions/inputs/service.py | 126 ++++++++++ api/oss/src/core/sessions/inputs/types.py | 28 +++ .../dbs/postgres/sessions/inputs/__init__.py | 3 + .../src/dbs/postgres/sessions/inputs/dao.py | 209 +++++++++++++++++ .../src/dbs/postgres/sessions/inputs/dbes.py | 63 +++++ .../dbs/postgres/sessions/inputs/mappings.py | 36 +++ api/oss/src/utils/env.py | 2 + .../sessions/test_pending_inputs_service.py | 156 +++++++++++++ sdks/python/agenta/sdk/decorators/routing.py | 80 +++++++ sdks/python/agenta/sdk/models/workflows.py | 1 + services/entrypoints/main.py | 4 + 18 files changed, 1193 insertions(+), 5 deletions(-) create mode 100644 api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py create mode 100644 api/oss/src/core/sessions/inputs/__init__.py create mode 100644 api/oss/src/core/sessions/inputs/dtos.py create mode 100644 api/oss/src/core/sessions/inputs/interfaces.py create mode 100644 api/oss/src/core/sessions/inputs/service.py create mode 100644 api/oss/src/core/sessions/inputs/types.py create mode 100644 api/oss/src/dbs/postgres/sessions/inputs/__init__.py create mode 100644 api/oss/src/dbs/postgres/sessions/inputs/dao.py create mode 100644 api/oss/src/dbs/postgres/sessions/inputs/dbes.py create mode 100644 api/oss/src/dbs/postgres/sessions/inputs/mappings.py create mode 100644 api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index d4eba636985..8d9f5e08d84 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -186,6 +186,9 @@ from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE # noqa: F401 from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO +from oss.src.core.sessions.inputs.service import SessionInputsService from oss.src.core.sessions.commands.service import SessionCommandsService from oss.src.dbs.http.sessions.control_delivery_direct import DirectControlDelivery from oss.src.tasks.asyncio.sessions.orphan_sweep import orphan_sweep_loop @@ -603,6 +606,7 @@ async def lifespan(*args, **kwargs): session_turns_dao = SessionTurnsDAO(engine=_transactions_engine) session_commands_dao = SessionCommandsDAO(engine=_transactions_engine) session_executions_dao = SessionExecutionsDAO(engine=_transactions_engine) +session_inputs_dao = SessionInputsDAO(engine=_transactions_engine) connections_dao = ConnectionsDAO(engine=_transactions_engine) mounts_dao = MountsDAO(engine=_transactions_engine) @@ -1138,6 +1142,11 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - records_service=records_service, ) +session_inputs_service = SessionInputsService( + inputs_dao=session_inputs_dao, + streams_service=session_streams_service, +) + # Durable session commands (Stop). The control-delivery adapter is chosen by one setting. # `direct` posts the command to the runner's own /cancel over the hop that already carries hard # kill; `long_poll` is not built yet, and naming it fails at boot rather than silently falling @@ -1183,6 +1192,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - turns_service=session_turns_service, sessions_service=sessions_service, commands_service=session_commands_service, + inputs_service=session_inputs_service, respond_task=_interactions_worker.respond_interaction, interactions_dispatcher=_interactions_dispatcher, ) diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py new file mode 100644 index 00000000000..73bfc2ee158 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py @@ -0,0 +1,81 @@ +"""add durable session pending inputs + +Revision ID: oss000000028 +Revises: oss000000027 +Create Date: 2026-09-04 15:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000028" +down_revision: Union[str, None] = "oss000000027" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_inputs", + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("position", sa.BigInteger(), nullable=False), + sa.Column("state", sa.String(), server_default="pending", nullable=False), + sa.Column("policy", sa.String(), nullable=False), + sa.Column("idempotency_key", sa.String(), nullable=False), + sa.Column("request_fingerprint", sa.String(length=64), nullable=False), + sa.Column("promoted_execution_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.CheckConstraint( + "state IN ('pending', 'promoted', 'removed')", + name="ck_session_inputs_state", + ), + sa.CheckConstraint( + "policy IN ('queue', 'steer')", name="ck_session_inputs_policy" + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("project_id", "id"), + ) + op.create_index("uq_session_inputs_id", "session_inputs", ["id"], unique=True) + op.create_index( + "uq_session_inputs_idempotency", + "session_inputs", + ["project_id", "session_id", "idempotency_key"], + unique=True, + ) + op.create_index( + "uq_session_inputs_position", + "session_inputs", + ["project_id", "session_id", "position"], + unique=True, + ) + op.create_index( + "ix_session_inputs_pending", + "session_inputs", + ["project_id", "session_id", "position"], + postgresql_where=sa.text("state = 'pending'"), + ) + + +def downgrade() -> None: + op.drop_index("ix_session_inputs_pending", table_name="session_inputs") + op.drop_index("uq_session_inputs_position", table_name="session_inputs") + op.drop_index("uq_session_inputs_idempotency", table_name="session_inputs") + op.drop_index("uq_session_inputs_id", table_name="session_inputs") + op.drop_table("session_inputs") diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index a266960cd44..c430ecd07c3 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -29,6 +29,7 @@ from oss.src.core.sessions.mounts.dtos import SessionMount, SessionMountQuery from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn, SessionTurnQuery from oss.src.core.sessions.types import SessionReference +from oss.src.core.sessions.inputs.dtos import PendingInput from oss.src.core.shared.dtos import OTelSpanId, Windowing from oss.src.dbs.postgres.sessions.streams.dao import MAX_SESSION_QUERY_LIMIT @@ -122,6 +123,55 @@ class SessionResponse(BaseModel): session: Optional[SessionStream] = None +class SessionCapabilities(BaseModel): + durable_approvals: bool = False + queue: bool = False + steer: bool = False + + +class SessionExecutionSnapshot(BaseModel): + id: Optional[str] = None + state: Literal["idle", "running", "stopping"] = "idle" + + +class SessionPendingSnapshot(BaseModel): + inputs: List[PendingInput] = Field(default_factory=list) + interactions: List[SessionInteraction] = Field(default_factory=list) + + +class SessionReadSnapshot(BaseModel): + latest_sequence: int = 0 + history_complete: bool = True + + +class SessionSnapshotResponse(BaseModel): + session: Optional[SessionStream] = None + execution: SessionExecutionSnapshot = Field( + default_factory=SessionExecutionSnapshot + ) + pending: SessionPendingSnapshot = Field(default_factory=SessionPendingSnapshot) + read: SessionReadSnapshot = Field(default_factory=SessionReadSnapshot) + capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) + + +class PendingInputResponse(BaseModel): + input: PendingInput + + +class PendingInputAdmissionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: SessionId + content: Dict[str, Any] + on_busy: Literal["reject", "queue", "steer"] = "reject" + + +class PendingInputAdmissionResponse(BaseModel): + action: Literal["execute", "pending"] + input: Optional[PendingInput] = None + execution_id: Optional[str] = None + + # --------------------------------------------------------------------------- # Streams request/response models # --------------------------------------------------------------------------- @@ -138,10 +188,6 @@ class SessionStreamQueryRequest(BaseModel): is_running: Optional[bool] = None -class SessionCapabilities(BaseModel): - durable_approvals: bool = False - - class SessionStreamResponse(BaseModel): stream: Optional[SessionStream] = None capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index cac4e3997d5..3ed49e7c202 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -104,6 +104,13 @@ from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.interactions.references import resolve_interaction_references from oss.src.core.sessions.interactions.types import InteractionNotFound +from oss.src.core.sessions.inputs.service import SessionInputsService +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, + SessionInputNotFound, + SessionInputNotRemovable, +) from oss.src.core.sessions.attachments.dtos import Attachment from oss.src.core.sessions.attachments.service import SessionAttachmentsService from oss.src.core.sessions.attachments.types import ( @@ -195,6 +202,14 @@ SessionQueryRequest, SessionResponse, SessionsResponse, + PendingInputResponse, + PendingInputAdmissionRequest, + PendingInputAdmissionResponse, + SessionCapabilities, + SessionExecutionSnapshot, + SessionPendingSnapshot, + SessionReadSnapshot, + SessionSnapshotResponse, ) from oss.src.apis.fastapi.sessions.utils import ( compute_session_response_windowing, @@ -2079,12 +2094,14 @@ def __init__( records_service: Optional[RecordsService] = None, interactions_service: Optional[SessionInteractionsService] = None, turns_service: Optional[SessionTurnsService] = None, + inputs_service: Optional[SessionInputsService] = None, ) -> None: self.sessions_service = sessions_service self.streams_service = streams_service self.records_service = records_service self.interactions_service = interactions_service self.turns_service = turns_service + self.inputs_service = inputs_service self.router = APIRouter() self.router.add_api_route( @@ -2105,6 +2122,24 @@ def __init__( status_code=status.HTTP_200_OK, tags=["Sessions"], ) + if inputs_service is not None: + self.router.add_api_route( + "/sessions/{session_id}", + self.fetch_session_snapshot, + methods=["GET"], + operation_id="fetch_session_snapshot", + response_model=SessionSnapshotResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}", + self.remove_pending_input, + methods=["DELETE"], + operation_id="remove_pending_session_input", + response_model=PendingInputResponse, + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/archive", self.archive_session, @@ -2249,6 +2284,92 @@ async def query_sessions( windowing=response_windowing, ) + @intercept_exceptions() + async def fetch_session_snapshot( + self, request: Request, session_id: str + ) -> SessionSnapshotResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + stream = await self.streams_service.fetch_header( + project_id=project_id, session_id=session_id + ) + interactions = await self.interactions_service.query_interactions( + project_id=project_id, + query=SessionInteractionQuery( + session_id=session_id, status=SessionInteractionStatus.pending + ), + ) + inputs = await self.inputs_service.list_pending( + project_id=project_id, session_id=session_id + ) + state = "idle" + execution_id = stream.turn_id if stream else None + if stream and stream.stopping_turn_id: + state = "stopping" + execution_id = stream.stopping_turn_id + elif stream and stream.flags.is_running: + state = "running" + return SessionSnapshotResponse( + session=stream, + execution=SessionExecutionSnapshot(id=execution_id, state=state), + pending=SessionPendingSnapshot(inputs=inputs, interactions=interactions), + read=SessionReadSnapshot(), + capabilities=SessionCapabilities( + durable_approvals=env.agenta.sessions.durable_approvals, + queue=env.agenta.sessions.queue, + steer=env.agenta.sessions.queue and env.agenta.sessions.steer, + ), + ) + + @intercept_exceptions() + async def remove_pending_input( + self, request: Request, session_id: str, input_id: UUID + ) -> PendingInputResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + item = await self.inputs_service.remove( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + ) + except SessionInputNotFound as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "pending_input_not_found", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + except SessionInputNotRemovable as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "pending_input_promoted", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + return PendingInputResponse(input=item) + @intercept_exceptions() async def delete_session( self, @@ -2390,6 +2511,48 @@ async def wrapper(*args, **kwargs): return decorator +def _handle_input_exceptions(): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except SessionInputBusy as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "session_busy", + "message": str(error), + "retryable": True, + "next_step": "Retry after the current execution settles.", + "details": {"current_execution_id": error.current_execution_id}, + }, + ) from error + except SessionInputIdempotencyConflict as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "idempotency_key_reused", + "message": str(error), + "retryable": False, + "next_step": "Reuse the original body or send a new key.", + }, + ) from error + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "validation_error", + "message": str(error), + "retryable": False, + }, + ) from error + + return wrapper + + return decorator + + class SessionControlRouter: """The Stop plane: one public route and one internal one. @@ -2408,8 +2571,10 @@ def __init__( self, *, commands_service: SessionCommandsService, + inputs_service: Optional[SessionInputsService] = None, ) -> None: self._service = commands_service + self._inputs_service = inputs_service self.router = APIRouter() self.router.add_api_route( @@ -2434,6 +2599,51 @@ def __init__( tags=["Sessions"], include_in_schema=False, ) + if inputs_service is not None: + self.router.add_api_route( + "/sessions/control/inputs/admit", + self.admit_session_input, + methods=["POST"], + operation_id="admit_session_input", + include_in_schema=False, + tags=["Sessions"], + ) + + @intercept_exceptions() + @_handle_input_exceptions() + async def admit_session_input( + self, request: Request, payload: PendingInputAdmissionRequest + ) -> JSONResponse: + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key is not None: + idempotency_key = ( + idempotency_key.strip()[:_MAX_IDEMPOTENCY_KEY_CHARACTERS] or None + ) + admission = await self._inputs_service.admit( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=payload.session_id, + content=payload.content, + policy=payload.on_busy, + idempotency_key=idempotency_key, + ) + response = PendingInputAdmissionResponse(**admission.model_dump()) + return JSONResponse( + status_code=( + status.HTTP_202_ACCEPTED + if admission.action == "pending" + else status.HTTP_200_OK + ), + content=response.model_dump(mode="json", exclude_none=True), + ) @intercept_exceptions() @_handle_command_exceptions() @@ -2619,6 +2829,7 @@ def __init__( turns_service: SessionTurnsService, sessions_service: SessionsService, commands_service: SessionCommandsService, + inputs_service: Optional[SessionInputsService] = None, respond_task: Optional[Any] = None, interactions_dispatcher: Optional[Any] = None, ) -> None: @@ -2654,5 +2865,8 @@ def __init__( records_service=records_service, interactions_service=interactions_service, turns_service=turns_service, + inputs_service=inputs_service, + ) + self.control = SessionControlRouter( + commands_service=commands_service, inputs_service=inputs_service ) - self.control = SessionControlRouter(commands_service=commands_service) diff --git a/api/oss/src/core/sessions/inputs/__init__.py b/api/oss/src/core/sessions/inputs/__init__.py new file mode 100644 index 00000000000..f12ad9fd8b2 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/__init__.py @@ -0,0 +1,15 @@ +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputCreate, + PendingInputState, +) +from oss.src.core.sessions.inputs.service import SessionInputsService + +__all__ = [ + "PendingInput", + "PendingInputAdmission", + "PendingInputCreate", + "PendingInputState", + "SessionInputsService", +] diff --git a/api/oss/src/core/sessions/inputs/dtos.py b/api/oss/src/core/sessions/inputs/dtos.py new file mode 100644 index 00000000000..bb3764a0105 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/dtos.py @@ -0,0 +1,47 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel + +from oss.src.core.shared.dtos import Identifier, Lifecycle + + +class PendingInputState(str, Enum): + pending = "pending" + promoted = "promoted" + removed = "removed" + + +class PendingInput(Identifier, Lifecycle): + project_id: UUID + session_id: str + content: Dict[str, Any] + position: int + state: PendingInputState + policy: Literal["queue", "steer"] + idempotency_key: str + request_fingerprint: str + promoted_execution_id: Optional[str] = None + + +class PendingInputCreate(BaseModel): + project_id: UUID + session_id: str + content: Dict[str, Any] + policy: Literal["queue", "steer"] + idempotency_key: str + request_fingerprint: str + + +class PendingInputAdmission(BaseModel): + action: Literal["execute", "pending"] + input: Optional[PendingInput] = None + execution_id: Optional[str] = None + + +class PendingInputPromotion(BaseModel): + input: PendingInput + execution_id: str + created_at: datetime diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py new file mode 100644 index 00000000000..2022331f353 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -0,0 +1,67 @@ +from abc import ABC, abstractmethod +from typing import Any, AsyncContextManager, List, Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate + + +class SessionInputsDAOInterface(ABC): + @abstractmethod + def transaction(self) -> AsyncContextManager[Any]: + pass + + @abstractmethod + async def create_input( + self, + *, + user_id: Optional[UUID], + pending_input: PendingInputCreate, + prioritize: bool = False, + transaction: Optional[Any] = None, + ) -> PendingInput: + pass + + @abstractmethod + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def list_pending( + self, *, project_id: UUID, session_id: str + ) -> List[PendingInput]: + pass + + @abstractmethod + async def fetch_input( + self, *, project_id: UUID, session_id: str, input_id: UUID + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def remove_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def promote_next( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + pass diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py new file mode 100644 index 00000000000..262ff953d53 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/service.py @@ -0,0 +1,126 @@ +import hashlib +import json +from typing import Any, Dict, List, Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputCreate, + PendingInputState, +) +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, + SessionInputNotFound, + SessionInputNotRemovable, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.utils.env import env + + +def input_fingerprint(*, content: Dict[str, Any], policy: str) -> str: + canonical = json.dumps( + {"content": content, "on_busy": policy}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return hashlib.sha256(canonical).hexdigest() + + +class SessionInputsService: + def __init__( + self, + *, + inputs_dao: SessionInputsDAOInterface, + streams_service: SessionStreamsService, + ) -> None: + self._dao = inputs_dao + self._streams = streams_service + + async def admit( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + content: Dict[str, Any], + policy: str, + idempotency_key: Optional[str], + ) -> PendingInputAdmission: + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + busy = bool(stream and stream.flags and stream.flags.is_running) + if not busy: + return PendingInputAdmission(action="execute") + + current_execution_id = stream.turn_id if stream else None + if policy != "queue" or not env.agenta.sessions.queue: + raise SessionInputBusy(current_execution_id=current_execution_id) + if not idempotency_key: + raise ValueError("Idempotency-Key is required when queueing input.") + + fingerprint = input_fingerprint(content=content, policy=policy) + async with self._dao.transaction() as transaction: + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=session_id, + idempotency_key=idempotency_key, + transaction=transaction, + ) + if existing is not None: + if existing.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + return PendingInputAdmission(action="pending", input=existing) + item = await self._dao.create_input( + user_id=user_id, + pending_input=PendingInputCreate( + project_id=project_id, + session_id=session_id, + content=content, + policy="queue", + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + ), + transaction=transaction, + ) + # `create_input` rechecks under the session transaction lock, so a concurrent + # admission can return the row that won after our optimistic read above. + if item.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + return PendingInputAdmission(action="pending", input=item) + + async def list_pending( + self, *, project_id: UUID, session_id: str + ) -> List[PendingInput]: + if not env.agenta.sessions.queue: + return [] + return await self._dao.list_pending( + project_id=project_id, session_id=session_id + ) + + async def remove( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + input_id: UUID, + ) -> PendingInput: + item = await self._dao.remove_pending( + project_id=project_id, + session_id=session_id, + input_id=input_id, + user_id=user_id, + ) + if item is not None: + return item + existing = await self._dao.fetch_input( + project_id=project_id, session_id=session_id, input_id=input_id + ) + if existing is not None and existing.state != PendingInputState.pending: + raise SessionInputNotRemovable(str(input_id)) + raise SessionInputNotFound(str(input_id)) diff --git a/api/oss/src/core/sessions/inputs/types.py b/api/oss/src/core/sessions/inputs/types.py new file mode 100644 index 00000000000..2f54957fa27 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/types.py @@ -0,0 +1,28 @@ +from typing import Optional + + +class SessionInputError(Exception): + pass + + +class SessionInputBusy(SessionInputError): + def __init__(self, current_execution_id: Optional[str] = None): + self.current_execution_id = current_execution_id + super().__init__("The session is already running an execution.") + + +class SessionInputNotFound(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The pending input was not found.") + + +class SessionInputNotRemovable(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The input can no longer be removed because it was promoted.") + + +class SessionInputIdempotencyConflict(SessionInputError): + def __init__(self): + super().__init__("This idempotency key was already used for a different input.") diff --git a/api/oss/src/dbs/postgres/sessions/inputs/__init__.py b/api/oss/src/dbs/postgres/sessions/inputs/__init__.py new file mode 100644 index 00000000000..575f08594ad --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/__init__.py @@ -0,0 +1,3 @@ +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO + +__all__ = ["SessionInputsDAO"] diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py new file mode 100644 index 00000000000..0a295fab3d8 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -0,0 +1,209 @@ +from datetime import datetime, timezone +from typing import Any, List, Optional +from uuid import UUID + +from sqlalchemy import func, select, text, update as sa_update + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE +from oss.src.dbs.postgres.sessions.inputs.mappings import ( + new_input_row, + to_pending_input, +) +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) + + +class SessionInputsDAO(SessionInputsDAOInterface): + def __init__(self, engine: Optional[TransactionsEngine] = None): + self.engine = engine or get_transactions_engine() + + def transaction(self): + return self.engine.session() + + async def _lock_session( + self, session: Any, project_id: UUID, session_id: str + ) -> None: + await session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:scope))"), + {"scope": f"{project_id}:{session_id}:inputs"}, + ) + + async def create_input( + self, + *, + user_id: Optional[UUID], + pending_input: PendingInputCreate, + prioritize: bool = False, + transaction: Optional[Any] = None, + ) -> PendingInput: + async def execute(session: Any) -> PendingInput: + await self._lock_session( + session, pending_input.project_id, pending_input.session_id + ) + # Admission reads before it writes. Two requests carrying the same key can both + # observe "missing" before either commits, so repeat that lookup after taking the + # session-scoped transaction lock. Returning the winner lets the service apply the + # fingerprint rule without leaking an IntegrityError to either caller. + existing = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == pending_input.project_id, + SessionInputDBE.session_id == pending_input.session_id, + SessionInputDBE.idempotency_key + == pending_input.idempotency_key, + ) + ) + ).scalar_one_or_none() + if existing is not None: + return to_pending_input(existing) + aggregate = func.min if prioritize else func.max + current = ( + await session.execute( + select(aggregate(SessionInputDBE.position)).where( + SessionInputDBE.project_id == pending_input.project_id, + SessionInputDBE.session_id == pending_input.session_id, + ) + ) + ).scalar_one() + position = ( + (current - 1) + if prioritize and current is not None + else (current or 0) + 1 + ) + row = new_input_row( + user_id=user_id, + position=position, + values=pending_input.model_dump(mode="python"), + ) + session.add(row) + await session.flush() + return to_pending_input(row) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + async def execute(session: Any) -> Optional[PendingInput]: + row = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.idempotency_key == idempotency_key, + ) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def list_pending( + self, *, project_id: UUID, session_id: str + ) -> List[PendingInput]: + async with self.engine.session() as session: + rows = ( + await session.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "pending", + ) + .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + ) + ).scalars() + return [to_pending_input(row) for row in rows] + + async def fetch_input( + self, *, project_id: UUID, session_id: str, input_id: UUID + ) -> Optional[PendingInput]: + async with self.engine.session() as session: + row = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + async def remove_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + ) -> Optional[PendingInput]: + async with self.engine.session() as session: + row = ( + await session.execute( + sa_update(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + SessionInputDBE.state == "pending", + ) + .values( + state="removed", + updated_at=datetime.now(timezone.utc), + updated_by_id=user_id, + ) + .returning(SessionInputDBE) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + async def promote_next( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + async def execute(session: Any) -> Optional[PendingInput]: + row = ( + await session.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "pending", + ) + .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + .limit(1) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + if row is None: + return None + row.state = "promoted" + row.promoted_execution_id = execution_id + row.updated_at = datetime.now(timezone.utc) + await session.flush() + return to_pending_input(row) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dbes.py b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py new file mode 100644 index 00000000000..05af2d6570f --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py @@ -0,0 +1,63 @@ +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + ForeignKeyConstraint, + Index, + String, + text, +) +from sqlalchemy.dialects.postgresql import JSONB + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.shared.dbas import ( + IdentifierDBA, + LifecycleDBA, + ProjectScopeDBA, +) + + +class SessionInputDBE(Base, ProjectScopeDBA, LifecycleDBA, IdentifierDBA): + __tablename__ = "session_inputs" + + session_id = Column(String, nullable=False) + content = Column(JSONB(none_as_null=True), nullable=False) + position = Column(BigInteger, nullable=False) + state = Column(String, nullable=False, default="pending", server_default="pending") + policy = Column(String, nullable=False) + idempotency_key = Column(String, nullable=False) + request_fingerprint = Column(String(64), nullable=False) + promoted_execution_id = Column(String, nullable=True) + + __table_args__ = ( + ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + CheckConstraint( + "state IN ('pending', 'promoted', 'removed')", + name="ck_session_inputs_state", + ), + CheckConstraint( + "policy IN ('queue', 'steer')", name="ck_session_inputs_policy" + ), + Index("uq_session_inputs_id", "id", unique=True), + Index( + "uq_session_inputs_idempotency", + "project_id", + "session_id", + "idempotency_key", + unique=True, + ), + Index( + "uq_session_inputs_position", + "project_id", + "session_id", + "position", + unique=True, + ), + Index( + "ix_session_inputs_pending", + "project_id", + "session_id", + "position", + postgresql_where=text("state = 'pending'"), + ), + ) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/mappings.py b/api/oss/src/dbs/postgres/sessions/inputs/mappings.py new file mode 100644 index 00000000000..19add41b349 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/mappings.py @@ -0,0 +1,36 @@ +from typing import Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE + + +def to_pending_input(row: SessionInputDBE) -> PendingInput: + return PendingInput( + id=row.id, + created_at=row.created_at, + updated_at=row.updated_at, + deleted_at=row.deleted_at, + created_by_id=row.created_by_id, + updated_by_id=row.updated_by_id, + deleted_by_id=row.deleted_by_id, + project_id=row.project_id, + session_id=row.session_id, + content=row.content, + position=row.position, + state=PendingInputState(row.state), + policy=row.policy, + idempotency_key=row.idempotency_key, + request_fingerprint=row.request_fingerprint, + promoted_execution_id=row.promoted_execution_id, + ) + + +def new_input_row( + *, user_id: Optional[UUID], values: dict, position: int +) -> SessionInputDBE: + return SessionInputDBE( + **values, + position=position, + created_by_id=user_id, + ) diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 7ad3bf6d158..2d56a44bd6a 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -700,6 +700,8 @@ class SessionsConfig(BaseModel): durable_approvals: bool = ( os.getenv("AGENTA_SESSIONS_DURABLE_APPROVALS") or "false" ).lower() in _TRUTHY + queue: bool = (os.getenv("AGENTA_SESSIONS_QUEUE") or "false").lower() in _TRUTHY + steer: bool = (os.getenv("AGENTA_SESSIONS_STEER") or "false").lower() in _TRUTHY late_output: Literal["quarantine", "reject"] = _parse_sessions_late_output() attachments: SessionAttachmentsConfig = SessionAttachmentsConfig() commands: SessionsCommandsConfig = SessionsCommandsConfig() diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py new file mode 100644 index 00000000000..88dc65805d5 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -0,0 +1,156 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState +from oss.src.core.sessions.inputs.service import SessionInputsService +from oss.src.core.sessions.inputs.types import SessionInputBusy +from oss.src.utils.env import env + + +class MemoryInputsDAO: + def __init__(self): + self.items = [] + + @asynccontextmanager + async def transaction(self): + yield self + + async def fetch_by_idempotency_key(self, **kwargs): + return next( + ( + item + for item in self.items + if item.project_id == kwargs["project_id"] + and item.session_id == kwargs["session_id"] + and item.idempotency_key == kwargs["idempotency_key"] + ), + None, + ) + + async def create_input(self, *, user_id, pending_input, prioritize=False, **_kwargs): + item = PendingInput( + id=uuid4(), + created_at=datetime.now(timezone.utc), + created_by_id=user_id, + position=(-1 if prioritize else len(self.items) + 1), + state=PendingInputState.pending, + **pending_input.model_dump(), + ) + self.items.append(item) + return item + + async def list_pending(self, *, project_id, session_id): + return sorted( + [ + item + for item in self.items + if item.project_id == project_id + and item.session_id == session_id + and item.state == PendingInputState.pending + ], + key=lambda item: item.position, + ) + + async def fetch_input(self, *, project_id, session_id, input_id): + return next( + ( + item + for item in self.items + if item.project_id == project_id + and item.session_id == session_id + and item.id == input_id + ), + None, + ) + + async def remove_pending(self, *, project_id, session_id, input_id, **_kwargs): + item = await self.fetch_input( + project_id=project_id, session_id=session_id, input_id=input_id + ) + if item is None or item.state != PendingInputState.pending: + return None + item.state = PendingInputState.removed + return item + + +class Streams: + def __init__(self, *, running=True): + self.running = running + + async def fetch_header(self, **_kwargs): + return SimpleNamespace( + flags=SimpleNamespace(is_running=self.running), turn_id="execution-1" + ) + + +@pytest.mark.asyncio +async def test_busy_queue_is_rejected_when_switch_is_off(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", False) + service = SessionInputsService(inputs_dao=MemoryInputsDAO(), streams_service=Streams()) + + with pytest.raises(SessionInputBusy): + await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="key-1", + ) + + +@pytest.mark.asyncio +async def test_busy_queue_is_durable_and_removable(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + user_id = uuid4() + dao = MemoryInputsDAO() + service = SessionInputsService(inputs_dao=dao, streams_service=Streams()) + + admitted = await service.admit( + project_id=project_id, + user_id=user_id, + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "pending" + assert admitted.input is not None + assert await service.list_pending(project_id=project_id, session_id="session-1") == [ + admitted.input + ] + removed = await service.remove( + project_id=project_id, + user_id=user_id, + session_id="session-1", + input_id=admitted.input.id, + ) + assert removed.state == PendingInputState.removed + assert await service.list_pending(project_id=project_id, session_id="session-1") == [] + + +@pytest.mark.asyncio +async def test_idle_input_executes_without_being_queued(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, streams_service=Streams(running=False) + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + assert dao.items == [] diff --git a/sdks/python/agenta/sdk/decorators/routing.py b/sdks/python/agenta/sdk/decorators/routing.py index 28348cefb4c..b4578c7d37a 100644 --- a/sdks/python/agenta/sdk/decorators/routing.py +++ b/sdks/python/agenta/sdk/decorators/routing.py @@ -1,6 +1,7 @@ # /agenta/sdk/decorators/routing.py import warnings +import httpx from typing import Any, Callable, Optional, AsyncGenerator, Union from json import dumps from uuid import UUID @@ -43,6 +44,7 @@ from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager from agenta.sdk.decorators.running import auto_workflow, inspect_workflow, Workflow from agenta.sdk.engines.running.errors import ErrorStatus +from agenta.sdk.agents.platform.connection import PlatformConnection # --------------------------------------------------------------------------- @@ -235,6 +237,79 @@ def apply_invoke_prelude(req: Request, request: WorkflowInvokeRequest) -> None: } +async def admit_session_input( + req: Request, + request: WorkflowInvokeRequest, + credentials: Optional[str], +) -> Optional[Response]: + if request.on_busy is None or request.session_id is None: + return None + if isinstance(request.meta, dict) and request.meta.get("promoted_input_id"): + return None + + connection = PlatformConnection(authorization=credentials) + api_base = connection.base_url() + if not api_base: + return JSONResponse( + status_code=503, + content={ + "code": "service_unavailable", + "message": "Session admission is unavailable.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + }, + ) + headers = connection.headers(authorization=credentials) + idempotency_key = req.headers.get("Idempotency-Key") + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + try: + async with httpx.AsyncClient(timeout=connection.timeout) as client: + response = await client.post( + f"{api_base}/sessions/control/inputs/admit", + headers=headers, + json={ + "session_id": request.session_id, + "content": request.model_dump(mode="json", exclude_none=True), + "on_busy": request.on_busy, + }, + ) + except httpx.HTTPError: + return JSONResponse( + status_code=503, + content={ + "code": "service_unavailable", + "message": "Session admission is unavailable.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + }, + ) + + if response.status_code == 200: + body = response.json() + execution_id = body.get("execution_id") + if isinstance(execution_id, str) and execution_id: + request.meta = {**(request.meta or {}), "run_id": execution_id} + return None + + try: + body = response.json() + except ValueError: + body = { + "code": "internal_error", + "message": "Session admission returned an invalid response.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + } + if ( + isinstance(body, dict) + and set(body) == {"detail"} + and isinstance(body["detail"], dict) + ): + body = body["detail"] + return JSONResponse(status_code=response.status_code, content=body) + + def _get_request_tracing_context(req: Request) -> TracingContext: context = TracingContext.get().model_copy(deep=True) otel = getattr(req.state, "otel", None) or {} @@ -632,6 +707,11 @@ async def invoke_endpoint(req: Request, request: WorkflowInvokeRequest): apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input( + req, request, credentials + ) + if admission_response is not None: + return admission_response with tracing_context_manager(_get_request_tracing_context(req)): response = await wf.invoke( request=request, diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index 9fbb1772ec5..165c50b200c 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -286,6 +286,7 @@ def _coerce_nested_models(cls, values: Dict[str, Any]) -> Dict[str, Any]: class WorkflowInvokeRequest(WorkflowBaseRequest): data: Optional[WorkflowRequestData] = None + on_busy: Optional[Literal["reject", "queue", "steer"]] = None # back-compat alias diff --git a/services/entrypoints/main.py b/services/entrypoints/main.py index a2c49ef5985..1eef7cf5657 100644 --- a/services/entrypoints/main.py +++ b/services/entrypoints/main.py @@ -10,6 +10,7 @@ from agenta.sdk.decorators.routing import ( create_app, apply_invoke_prelude, + admit_session_input, handle_invoke_success, handle_invoke_failure, handle_inspect_success, @@ -87,6 +88,9 @@ async def services_invoke(req: Request, request: WorkflowInvokeRequest): credentials = req.state.auth.get("credentials") apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input(req, request, credentials) + if admission_response is not None: + return admission_response response = await invoke_workflow(request=request, credentials=credentials) return await handle_invoke_success(req, response) except Exception as exception: From 539e0ba8cb1eb73083a724b78692fd745887e2e2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 13:40:54 +0200 Subject: [PATCH 060/133] feat(api): promote pending session input Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/entrypoints/routers.py | 11 +- ...oss000000028_add_session_pending_inputs.py | 16 ++ api/oss/src/apis/fastapi/sessions/router.py | 21 +- api/oss/src/core/sessions/commands/dtos.py | 1 + api/oss/src/core/sessions/commands/service.py | 192 +++++++++++++++--- .../src/core/sessions/inputs/interfaces.py | 1 + api/oss/src/core/sessions/inputs/service.py | 19 +- api/oss/src/core/workflows/service.py | 3 + .../http/sessions/control_delivery_direct.py | 14 ++ .../src/dbs/postgres/sessions/commands/dao.py | 16 +- .../dbs/postgres/sessions/commands/dbes.py | 2 +- .../src/dbs/postgres/sessions/inputs/dao.py | 15 +- .../src/dbs/postgres/sessions/inputs/dbes.py | 2 + .../tasks/asyncio/sessions/orphan_sweep.py | 7 +- ...test_interaction_continuation_admission.py | 94 +++++++++ .../sessions/test_pending_inputs_service.py | 63 +++++- 16 files changed, 425 insertions(+), 52 deletions(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 8d9f5e08d84..a47c126df63 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -96,6 +96,7 @@ from oss.src.core.folders.service import FoldersService from oss.src.core.workflows.service import WorkflowsService from oss.src.core.workflows.service import SimpleWorkflowsService +from oss.src.core.workflows.dtos import WorkflowServiceRequest from oss.src.core.workflows.static_catalog import StaticWorkflowCatalog from oss.src.core.evaluators.service import EvaluatorsService from oss.src.core.evaluators.service import SimpleEvaluatorsService @@ -1173,9 +1174,17 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - ], control_command_id=command.id, continuation_execution_id=command.target_turn_id, - ) + ), + continue_input=lambda command: workflows_service.invoke_workflow_detached( + project_id=command.project_id, + user_id=command.created_by_id, + request=WorkflowServiceRequest.model_validate(command.data["request"]), + run_id=command.target_turn_id, + control_command_id=command.id, + ), ), executions_dao=session_executions_dao, + inputs_dao=session_inputs_dao, ) workflows_service.set_session_continuation_resumer( session_commands_service.resume_recoverable_continuation diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py index 73bfc2ee158..dd2f93ffcbc 100644 --- a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py @@ -19,6 +19,14 @@ def upgrade() -> None: + op.drop_constraint( + "ck_session_commands_kind", "session_commands", type_="check" + ) + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction', 'continue_input')", + ) op.create_table( "session_inputs", sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), @@ -79,3 +87,11 @@ def downgrade() -> None: op.drop_index("uq_session_inputs_idempotency", table_name="session_inputs") op.drop_index("uq_session_inputs_id", table_name="session_inputs") op.drop_table("session_inputs") + op.drop_constraint( + "ck_session_commands_kind", "session_commands", type_="check" + ) + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction')", + ) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 3ed49e7c202..a77fb8ca771 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -111,6 +111,7 @@ SessionInputNotFound, SessionInputNotRemovable, ) +from oss.src.core.sessions.inputs.dtos import PendingInputState from oss.src.core.sessions.attachments.dtos import Attachment from oss.src.core.sessions.attachments.service import SessionAttachmentsService from oss.src.core.sessions.attachments.types import ( @@ -1042,11 +1043,12 @@ async def ingest_record_event( # window where the watchdog could see no `done`, expose recovery, and replay work that # had already finished while the records worker was still settling core state. if ( - env.agenta.sessions.durable_approvals + (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) and self.commands_service is not None and body.record_type == TERMINAL_RECORD_TYPE and body.turn_id - and (body.attributes or {}).get("stopReason") not in ("paused", "cancelled") + and (body.attributes or {}).get("stopReason") + not in ("paused", "cancelled", "error") ): await self.commands_service.settle_execution_completed( project_id=UUID(project_id), @@ -2635,6 +2637,21 @@ async def admit_session_input( policy=payload.on_busy, idempotency_key=idempotency_key, ) + if ( + payload.on_busy == "steer" + and admission.action == "pending" + and admission.input is not None + and admission.input.state == PendingInputState.pending + ): + # The input is durable before Stop is requested. A refused or unreachable Stop + # therefore never loses the user's message; it stays visible and removable. + await self._service.request_cancel( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=payload.session_id, + expected_execution_id=admission.execution_id, + idempotency_key=f"steer:{admission.input.id}", + ) response = PendingInputAdmissionResponse(**admission.model_dump()) return JSONResponse( status_code=( diff --git a/api/oss/src/core/sessions/commands/dtos.py b/api/oss/src/core/sessions/commands/dtos.py index adb427bf9d4..4ecc4d8d669 100644 --- a/api/oss/src/core/sessions/commands/dtos.py +++ b/api/oss/src/core/sessions/commands/dtos.py @@ -25,6 +25,7 @@ class SessionCommandKind(str, Enum): cancel = "cancel" continue_interaction = "continue_interaction" + continue_input = "continue_input" class SessionCommandState(str, Enum): diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 1bcbdeb238b..f2c04483f2b 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -65,6 +65,7 @@ SessionInteractionTransition, ) from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface from oss.src.core.sessions.streams.dtos import ( SessionStreamCommandRequest, SessionStreamCommandResponse, @@ -129,6 +130,19 @@ def __init__( self.waiting_for_interactions = waiting_for_interactions +class InputContinuationAdmission: + def __init__( + self, + *, + command: SessionCommand, + execution_id: str, + execution_state: SessionExecutionState = SessionExecutionState.pending_delivery, + ) -> None: + self.command = command + self.execution_id = execution_id + self.execution_state = execution_state + + class CommandOutcomeReport: def __init__(self, *, command: SessionCommand, admitted: bool) -> None: self.command = command @@ -149,6 +163,7 @@ def __init__( lock_engine: LockEngine, delivery: ControlDeliveryPort, executions_dao: Optional[SessionExecutionsDAOInterface] = None, + inputs_dao: Optional[SessionInputsDAOInterface] = None, ) -> None: self._dao = commands_dao self._streams = streams_service @@ -156,6 +171,7 @@ def __init__( self._lock = lock_engine self._delivery = delivery self._executions = executions_dao + self._inputs = inputs_dao # -- admission ---------------------------------------------------------- # @@ -750,7 +766,9 @@ async def _execution_is_parked_on_a_gate( async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str ) -> bool: - if not env.agenta.sessions.durable_approvals: + if not ( + env.agenta.sessions.durable_approvals or env.agenta.sessions.queue + ): return False command = await self._dao.fetch_resumable_continuation( project_id=project_id, @@ -758,6 +776,14 @@ async def resume_recoverable_continuation( ) if command is None: return False + if ( + command.kind == SessionCommandKind.continue_interaction + and not env.agenta.sessions.durable_approvals + ) or ( + command.kind == SessionCommandKind.continue_input + and not env.agenta.sessions.queue + ): + return False execution_id = command.target_turn_id if execution_id is None or self._executions is None: return True @@ -829,12 +855,19 @@ async def resume_recoverable_continuation( execution_id = command.target_turn_id if execution_id is None: return True - admission = InteractionContinuationAdmission( - interaction=await self._interaction_for_command(command), - command=command, - execution_id=execution_id, - execution_state=SessionExecutionState.recoverable, - ) + if command.kind == SessionCommandKind.continue_input: + admission: Any = InputContinuationAdmission( + command=command, + execution_id=execution_id, + execution_state=SessionExecutionState.recoverable, + ) + else: + admission = InteractionContinuationAdmission( + interaction=await self._interaction_for_command(command), + command=command, + execution_id=execution_id, + execution_state=SessionExecutionState.recoverable, + ) try: receipt = await self._deliver(command) except Exception as error: # noqa: BLE001 - keep ownership with the durable continuation @@ -1077,7 +1110,10 @@ async def _deliver(self, command: SessionCommand) -> Optional[DeliveryReceipt]: return receipt if receipt.status == "not_held": - if command.kind == SessionCommandKind.continue_interaction: + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): return receipt await self._settle_not_held(command) return receipt @@ -1223,8 +1259,16 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: ) settled = 0 for command in abandoned: - if command.kind == SessionCommandKind.continue_interaction: - if not env.agenta.sessions.durable_approvals: + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): + capability_enabled = ( + env.agenta.sessions.durable_approvals + if command.kind == SessionCommandKind.continue_interaction + else env.agenta.sessions.queue + ) + if not capability_enabled: continue if command.claim_count < max_deliveries: await self._deliver(command) @@ -1288,6 +1332,64 @@ async def _settle_exhausted_continuation(self, command: SessionCommand) -> bool: # -- settlement --------------------------------------------------------- # + async def _promote_next_input( + self, + *, + project_id: UUID, + session_id: str, + parent_execution_id: str, + transaction: Any, + only_policy: Optional[str] = None, + ) -> Optional[InputContinuationAdmission]: + """Promote one durable input and create its continuation in the same commit.""" + if self._inputs is None or self._executions is None: + return None + + execution_id = str(uuid4()) + pending_input = await self._inputs.promote_next( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + only_policy=only_policy, + transaction=transaction, + ) + if pending_input is None: + return None + + await self._executions.create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + parent_execution_id=parent_execution_id, + source_interaction_id=None, + transaction=transaction, + ) + request = dict(pending_input.content) + request_meta = dict(request.get("meta") or {}) + request_meta["promoted_input_id"] = str(pending_input.id) + request["meta"] = request_meta + command = await self._dao.create_command( + user_id=pending_input.created_by_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.continue_input, + target_turn_id=execution_id, + expected_turn_id=parent_execution_id, + data={ + "input_id": str(pending_input.id), + "continuation_execution_id": execution_id, + "request": request, + }, + idempotency_key=f"input:{pending_input.id}", + ), + transaction=transaction, + ) + return InputContinuationAdmission( + command=command, + execution_id=execution_id, + ) + async def settle_execution_lost( self, *, @@ -1306,7 +1408,10 @@ async def settle_execution_lost( ) if ( execution is not None - and env.agenta.sessions.durable_approvals + and ( + env.agenta.sessions.durable_approvals + or env.agenta.sessions.queue + ) and ( execution.source_interaction_id is not None or execution.parent_execution_id is not None @@ -1359,25 +1464,29 @@ async def settle_execution_completed( """Reconcile a persisted runner ending before stale ownership is collapsed.""" if self._executions is None: return True - execution = await self._executions.fetch_execution( - project_id=project_id, - session_id=session_id, - execution_id=execution_id, - ) - if execution is None or ( - execution.source_interaction_id is None - and execution.parent_execution_id is None - ): - return True - if execution.terminal_outcome is not None: - return True - result = await self._executions.settle( - project_id=project_id, - session_id=session_id, - execution_id=execution_id, - terminal_outcome="completed", - settled_by="runner", - ) + admission: Optional[InputContinuationAdmission] = None + async with self._dao.transaction() as transaction: + result = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome="completed", + settled_by="runner", + transaction=transaction, + ) + if result.won and env.agenta.sessions.queue: + admission = await self._promote_next_input( + project_id=project_id, + session_id=session_id, + parent_execution_id=execution_id, + transaction=transaction, + ) + + if admission is not None: + receipt = await self._deliver(admission.command) + if receipt.status != "accepted": + await self._mark_continuation_recoverable(admission) + admission.execution_state = SessionExecutionState.recoverable return result.won or result.settlement.terminal_outcome is not None async def repair_terminal_redis(self) -> int: @@ -1430,7 +1539,10 @@ async def report_outcome( if command is None: raise SessionCommandNotFound(command_id=str(command_id)) - if command.kind == SessionCommandKind.continue_interaction: + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): return await self._report_continuation_outcome( command=command, replica_id=replica_id, @@ -1636,6 +1748,7 @@ async def settle( ) atomic_core_settlement = self._executions is not None cancelled_interactions = 0 + input_admission: Optional[InputContinuationAdmission] = None if atomic_core_settlement: stored_command = await self._dao.fetch_command(command_id=command_id) if stored_command is None: @@ -1676,6 +1789,19 @@ async def settle( or winner.settled_by != settled_by ): raise _SettlementRejected + if ( + result.won + and outcome == SessionCommandOutcome.stopped + and env.agenta.sessions.queue + and env.agenta.sessions.steer + ): + input_admission = await self._promote_next_input( + project_id=project_id, + session_id=stored_command.session_id, + parent_execution_id=execution_id, + only_policy="steer", + transaction=transaction, + ) await self._streams.settle_command( project_id=project_id, @@ -1764,6 +1890,10 @@ async def settle( project_id=project_id, session_id=session_id, ) + if input_admission is not None: + receipt = await self._deliver(input_admission.command) + if receipt.status != "accepted": + await self._mark_continuation_recoverable(input_admission) return settled diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index 2022331f353..437e2c8e621 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -62,6 +62,7 @@ async def promote_next( project_id: UUID, session_id: str, execution_id: str, + only_policy: Optional[str] = None, transaction: Optional[Any] = None, ) -> Optional[PendingInput]: pass diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 262ff953d53..223744a7569 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -58,7 +58,11 @@ async def admit( return PendingInputAdmission(action="execute") current_execution_id = stream.turn_id if stream else None - if policy != "queue" or not env.agenta.sessions.queue: + queue_enabled = env.agenta.sessions.queue + steer_enabled = queue_enabled and env.agenta.sessions.steer + if policy == "steer" and not steer_enabled: + raise SessionInputBusy(current_execution_id=current_execution_id) + if policy not in ("queue", "steer") or not queue_enabled: raise SessionInputBusy(current_execution_id=current_execution_id) if not idempotency_key: raise ValueError("Idempotency-Key is required when queueing input.") @@ -74,24 +78,31 @@ async def admit( if existing is not None: if existing.request_fingerprint != fingerprint: raise SessionInputIdempotencyConflict() - return PendingInputAdmission(action="pending", input=existing) + return PendingInputAdmission( + action="pending", + input=existing, + execution_id=current_execution_id, + ) item = await self._dao.create_input( user_id=user_id, pending_input=PendingInputCreate( project_id=project_id, session_id=session_id, content=content, - policy="queue", + policy=policy, idempotency_key=idempotency_key, request_fingerprint=fingerprint, ), + prioritize=policy == "steer", transaction=transaction, ) # `create_input` rechecks under the session transaction lock, so a concurrent # admission can return the row that won after our optimistic read above. if item.request_fingerprint != fingerprint: raise SessionInputIdempotencyConflict() - return PendingInputAdmission(action="pending", input=item) + return PendingInputAdmission( + action="pending", input=item, execution_id=current_execution_id + ) async def list_pending( self, *, project_id: UUID, session_id: str diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 9e570512409..83053f13338 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -3013,6 +3013,7 @@ async def invoke_workflow_detached( request: WorkflowServiceRequest, # run_id: Optional[str] = None, + control_command_id: Optional[UUID] = None, ) -> WorkflowServiceDetachedResponse: """Fire-and-forget invoke: stream the service and return on the started handshake. @@ -3030,6 +3031,8 @@ async def invoke_workflow_detached( meta = dict(request.meta or {}) meta["run_id"] = run_id meta["project_id"] = str(project_id) + if control_command_id is not None: + meta["control_command_id"] = str(control_command_id) request.meta = meta credentials, service_url = await self._prepare_invoke( diff --git a/api/oss/src/dbs/http/sessions/control_delivery_direct.py b/api/oss/src/dbs/http/sessions/control_delivery_direct.py index 455fc6954c3..98c67cf562b 100644 --- a/api/oss/src/dbs/http/sessions/control_delivery_direct.py +++ b/api/oss/src/dbs/http/sessions/control_delivery_direct.py @@ -58,6 +58,7 @@ def __init__( continue_interaction: Optional[ Callable[[SessionCommand], Awaitable[None]] ] = None, + continue_input: Optional[Callable[[SessionCommand], Awaitable[None]]] = None, ) -> None: self._timeout = ( timeout_seconds @@ -65,6 +66,7 @@ def __init__( else env.agenta.sessions.commands.delivery_timeout_seconds ) self._continue_interaction = continue_interaction + self._continue_input = continue_input async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: if command.kind == SessionCommandKind.continue_interaction: @@ -79,6 +81,18 @@ async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: return DeliveryReceipt(status="unreachable", detail=str(error)) return DeliveryReceipt(status="accepted", replica_id="direct") + if command.kind == SessionCommandKind.continue_input: + if self._continue_input is None: + return DeliveryReceipt( + status="unreachable", + detail="pending input delivery is not configured", + ) + try: + await self._continue_input(command) + except Exception as error: # noqa: BLE001 - transport maps failures to receipts + return DeliveryReceipt(status="unreachable", detail=str(error)) + return DeliveryReceipt(status="accepted", replica_id="direct") + answer = await cancel_runner_execution( command_id=str(command.id), project_id=str(command.project_id), diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 275a030d889..bad66f29662 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -274,8 +274,12 @@ async def fetch_resumable_continuation( .where( SessionCommandDBE.project_id == project_id, SessionCommandDBE.session_id == session_id, - SessionCommandDBE.kind - == SessionCommandKind.continue_interaction.value, + SessionCommandDBE.kind.in_( + ( + SessionCommandKind.continue_interaction.value, + SessionCommandKind.continue_input.value, + ) + ), or_( and_( SessionCommandDBE.state.in_(_OPEN_STATES), @@ -329,8 +333,12 @@ async def execute(session: Any) -> Optional[SessionCommand]: SessionCommandDBE.project_id == project_id, SessionCommandDBE.id == command_id, SessionCommandDBE.target_turn_id == target_turn_id, - SessionCommandDBE.kind - == SessionCommandKind.continue_interaction.value, + SessionCommandDBE.kind.in_( + ( + SessionCommandKind.continue_interaction.value, + SessionCommandKind.continue_input.value, + ) + ), or_( and_( SessionCommandDBE.state diff --git a/api/oss/src/dbs/postgres/sessions/commands/dbes.py b/api/oss/src/dbs/postgres/sessions/commands/dbes.py index 7a7d2da66de..428305a60c0 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dbes.py @@ -26,7 +26,7 @@ class SessionCommandDBE(Base, SessionCommandDBA): name="uq_session_commands_idempotency", ), CheckConstraint( - "kind IN ('cancel', 'continue_interaction')", + "kind IN ('cancel', 'continue_interaction', 'continue_input')", name="ck_session_commands_kind", ), CheckConstraint( diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index 0a295fab3d8..cbe9c8db8f4 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -179,17 +179,20 @@ async def promote_next( project_id: UUID, session_id: str, execution_id: str, + only_policy: Optional[str] = None, transaction: Optional[Any] = None, ) -> Optional[PendingInput]: async def execute(session: Any) -> Optional[PendingInput]: + stmt = select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "pending", + ) + if only_policy is not None: + stmt = stmt.where(SessionInputDBE.policy == only_policy) row = ( await session.execute( - select(SessionInputDBE) - .where( - SessionInputDBE.project_id == project_id, - SessionInputDBE.session_id == session_id, - SessionInputDBE.state == "pending", - ) + stmt .order_by(SessionInputDBE.position, SessionInputDBE.created_at) .limit(1) .with_for_update(skip_locked=True) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dbes.py b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py index 05af2d6570f..c6231acae4c 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py @@ -4,6 +4,7 @@ Column, ForeignKeyConstraint, Index, + PrimaryKeyConstraint, String, text, ) @@ -31,6 +32,7 @@ class SessionInputDBE(Base, ProjectScopeDBA, LifecycleDBA, IdentifierDBA): __table_args__ = ( ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + PrimaryKeyConstraint("project_id", "id"), CheckConstraint( "state IN ('pending', 'promoted', 'removed')", name="ck_session_inputs_state", diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 256db4c3719..9b21b889ac0 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -540,7 +540,10 @@ async def run_orphan_sweep( if ( records_service is not None and commands_service is not None - and env.agenta.sessions.durable_approvals + and ( + env.agenta.sessions.durable_approvals + or env.agenta.sessions.queue + ) ): runner_completed, completion_failures = await _runner_completed_executions( records_service=records_service, @@ -602,7 +605,7 @@ async def run_orphan_sweep( settled_lost = { key for key in unsettled - if env.agenta.sessions.durable_approvals + if (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) and terminal_outcomes.get(key) != "stopped" } diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index ad4ce14236c..ed29d348cba 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -27,6 +27,7 @@ SessionInteractionKind, SessionInteractionStatus, ) +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState class _Commands: @@ -200,6 +201,8 @@ async def settle(self, **kwargs): if kwargs["execution_id"] == self.source.execution_id else self.continuation ) + if current.terminal_outcome is not None: + return SessionExecutionSettlementResult(settlement=current, won=False) settled = current.model_copy( update={ "state": SessionExecutionState.terminal, @@ -257,6 +260,27 @@ async def acknowledge(self, **kwargs): return None +class _Inputs: + def __init__(self, items): + self.items = items + + async def promote_next(self, *, execution_id, **kwargs): + item = next( + (item for item in self.items if item.state == PendingInputState.pending), + None, + ) + if item is None: + return None + promoted = item.model_copy( + update={ + "state": PendingInputState.promoted, + "promoted_execution_id": execution_id, + } + ) + self.items[self.items.index(item)] = promoted + return promoted + + @pytest.mark.asyncio async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): project_id = uuid4() @@ -1000,6 +1024,76 @@ async def test_persisted_completion_terminalizes_continuation_before_recovery(): assert executions.continuation.settled_by == "runner" +@pytest.mark.asyncio +async def test_completion_promotes_exactly_one_pending_input_once(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + user_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs( + [ + PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": ["one"]}}, + position=1, + state=PendingInputState.pending, + policy="queue", + idempotency_key="queue-1", + request_fingerprint="a" * 64, + created_by_id=user_id, + ), + PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": ["two"]}}, + position=2, + state=PendingInputState.pending, + policy="queue", + idempotency_key="queue-2", + request_fingerprint="b" * 64, + created_by_id=user_id, + ), + ] + ) + commands = _Commands() + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + inputs_dao=items, + ) + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[0].state == PendingInputState.promoted + assert items.items[1].state == PendingInputState.pending + assert commands.command.kind == SessionCommandKind.continue_input + assert commands.command.data["request"]["meta"]["promoted_input_id"] == str( + items.items[0].id + ) + assert len(delivery.delivered) == 1 + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[1].state == PendingInputState.pending + assert len(delivery.delivered) == 1 + + @pytest.mark.asyncio async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index 88dc65805d5..6dc4b6c0a3d 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -7,7 +7,10 @@ from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState from oss.src.core.sessions.inputs.service import SessionInputsService -from oss.src.core.sessions.inputs.types import SessionInputBusy +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, +) from oss.src.utils.env import env @@ -154,3 +157,61 @@ async def test_idle_input_executes_without_being_queued(monkeypatch): assert admitted.action == "execute" assert dao.items == [] + + +@pytest.mark.asyncio +async def test_queue_idempotency_returns_same_input_and_rejects_conflicting_reuse( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), streams_service=Streams() + ) + kwargs = { + "project_id": project_id, + "user_id": uuid4(), + "session_id": "session-1", + "content": {"message": "later"}, + "policy": "queue", + "idempotency_key": "key-1", + } + + first = await service.admit(**kwargs) + retry = await service.admit(**kwargs) + assert retry.input.id == first.input.id + + with pytest.raises(SessionInputIdempotencyConflict): + await service.admit(**{**kwargs, "content": {"message": "different"}}) + + +@pytest.mark.asyncio +async def test_steer_is_saved_ahead_of_queued_input(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + dao = MemoryInputsDAO() + service = SessionInputsService(inputs_dao=dao, streams_service=Streams()) + + queued = await service.admit( + project_id=project_id, + user_id=uuid4(), + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="queue-1", + ) + steered = await service.admit( + project_id=project_id, + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="steer", + idempotency_key="steer-1", + ) + + pending = await service.list_pending( + project_id=project_id, session_id="session-1" + ) + assert [item.id for item in pending] == [steered.input.id, queued.input.id] + assert steered.execution_id == "execution-1" From a9a7e3556fda7033e785389c4ff5cc07e01fffa4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 18:11:58 +0200 Subject: [PATCH 061/133] feat(api): steer pending session input Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- api/oss/src/apis/fastapi/sessions/router.py | 24 ++-- api/oss/src/core/sessions/commands/service.py | 39 +++++- .../src/core/sessions/inputs/interfaces.py | 1 + .../src/dbs/postgres/sessions/inputs/dao.py | 6 +- ...test_interaction_continuation_admission.py | 122 +++++++++++++++++- .../sessions/test_session_steer_admission.py | 88 +++++++++++++ 6 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index a77fb8ca771..f610e32b14f 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -164,7 +164,6 @@ SessionDetachRequest, SessionStreamQueryRequest, SessionStreamResponse, - SessionCapabilities, SessionStreamsResponse, # records SessionRecordIngestBody, @@ -2645,13 +2644,22 @@ async def admit_session_input( ): # The input is durable before Stop is requested. A refused or unreachable Stop # therefore never loses the user's message; it stays visible and removable. - await self._service.request_cancel( - project_id=project_id, - user_id=UUID(str(user_id)) if user_id else None, - session_id=payload.session_id, - expected_execution_id=admission.execution_id, - idempotency_key=f"steer:{admission.input.id}", - ) + try: + await self._service.request_cancel( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=payload.session_id, + expected_execution_id=admission.execution_id, + idempotency_key=f"steer:{admission.input.id}", + steer_input_id=admission.input.id, + ) + except Exception as error: # noqa: BLE001 - the input is already durable + log.warning( + "steer stop request failed input=%s session=%s: %s", + admission.input.id, + payload.session_id, + error, + ) response = PendingInputAdmissionResponse(**admission.model_dump()) return JSONResponse( status_code=( diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index f2c04483f2b..0feda3fcb0e 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -207,6 +207,7 @@ async def request_cancel( session_id: str, expected_execution_id: Optional[str] = None, idempotency_key: Optional[str] = None, + steer_input_id: Optional[UUID] = None, ) -> CancelAdmission: if not validate_session_id(session_id): raise SessionIdInvalid(session_id) @@ -263,6 +264,11 @@ async def request_cancel( target_turn_id=None, expected_turn_id=expected_execution_id, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.obsolete, outcome=SessionCommandOutcome.not_running, ) @@ -287,6 +293,11 @@ async def request_cancel( target_turn_id=None, expected_turn_id=None, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.obsolete, outcome=SessionCommandOutcome.superseded_by_newer_turn, ) @@ -321,6 +332,11 @@ async def request_cancel( target_turn_id=target_turn_id, expected_turn_id=expected_execution_id, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.pending, outcome=None, stopping_turn_id=target_turn_id, @@ -367,6 +383,11 @@ async def request_cancel( target_turn_id=target_turn_id, expected_turn_id=expected_execution_id, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.pending, outcome=None, stopping_turn_id=target_turn_id, @@ -766,9 +787,7 @@ async def _execution_is_parked_on_a_gate( async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str ) -> bool: - if not ( - env.agenta.sessions.durable_approvals or env.agenta.sessions.queue - ): + if not (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue): return False command = await self._dao.fetch_resumable_continuation( project_id=project_id, @@ -984,6 +1003,7 @@ async def _insert( target_turn_id: Optional[str], expected_turn_id: Optional[str], idempotency_key: Optional[str], + data: Optional[dict[str, Any]], state: SessionCommandState, outcome: Optional[SessionCommandOutcome], stopping_turn_id: Optional[str] = None, @@ -1016,6 +1036,7 @@ async def _insert( kind=SessionCommandKind.cancel, target_turn_id=target_turn_id, expected_turn_id=expected_turn_id, + data=data, state=state, outcome=outcome, settled_at=received_at if outcome is not None else None, @@ -1339,6 +1360,7 @@ async def _promote_next_input( session_id: str, parent_execution_id: str, transaction: Any, + input_id: Optional[UUID] = None, only_policy: Optional[str] = None, ) -> Optional[InputContinuationAdmission]: """Promote one durable input and create its continuation in the same commit.""" @@ -1350,6 +1372,7 @@ async def _promote_next_input( project_id=project_id, session_id=session_id, execution_id=execution_id, + input_id=input_id, only_policy=only_policy, transaction=transaction, ) @@ -1408,10 +1431,7 @@ async def settle_execution_lost( ) if ( execution is not None - and ( - env.agenta.sessions.durable_approvals - or env.agenta.sessions.queue - ) + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) and ( execution.source_interaction_id is not None or execution.parent_execution_id is not None @@ -1789,16 +1809,21 @@ async def settle( or winner.settled_by != settled_by ): raise _SettlementRejected + steer_input_id = (stored_command.data or {}).get( + "steer_input_id" + ) if ( result.won and outcome == SessionCommandOutcome.stopped and env.agenta.sessions.queue and env.agenta.sessions.steer + and isinstance(steer_input_id, str) ): input_admission = await self._promote_next_input( project_id=project_id, session_id=stored_command.session_id, parent_execution_id=execution_id, + input_id=UUID(steer_input_id), only_policy="steer", transaction=transaction, ) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index 437e2c8e621..25228064074 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -62,6 +62,7 @@ async def promote_next( project_id: UUID, session_id: str, execution_id: str, + input_id: Optional[UUID] = None, only_policy: Optional[str] = None, transaction: Optional[Any] = None, ) -> Optional[PendingInput]: diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index cbe9c8db8f4..7f2f008344e 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -179,6 +179,7 @@ async def promote_next( project_id: UUID, session_id: str, execution_id: str, + input_id: Optional[UUID] = None, only_policy: Optional[str] = None, transaction: Optional[Any] = None, ) -> Optional[PendingInput]: @@ -190,10 +191,11 @@ async def execute(session: Any) -> Optional[PendingInput]: ) if only_policy is not None: stmt = stmt.where(SessionInputDBE.policy == only_policy) + if input_id is not None: + stmt = stmt.where(SessionInputDBE.id == input_id) row = ( await session.execute( - stmt - .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + stmt.order_by(SessionInputDBE.position, SessionInputDBE.created_at) .limit(1) .with_for_update(skip_locked=True) ) diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index ed29d348cba..5328accd433 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -264,9 +264,17 @@ class _Inputs: def __init__(self, items): self.items = items - async def promote_next(self, *, execution_id, **kwargs): + async def promote_next( + self, *, execution_id, input_id=None, only_policy=None, **kwargs + ): item = next( - (item for item in self.items if item.state == PendingInputState.pending), + ( + item + for item in self.items + if item.state == PendingInputState.pending + and (input_id is None or item.id == input_id) + and (only_policy is None or item.policy == only_policy) + ), None, ) if item is None: @@ -1259,3 +1267,113 @@ async def test_a_send_after_the_budget_is_spent_reopens_the_continuation(monkeyp assert commands.command.claim_count == 0 assert delivery.delivered assert delivery.delivered[0].target_turn_id == commands.command.target_turn_id + + +def _pending_input(project_id, *, policy, position): + return PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": [policy]}}, + position=position, + state=PendingInputState.pending, + policy=policy, + idempotency_key=f"{policy}-{position}", + request_fingerprint=str(position) * 64, + created_by_id=uuid4(), + ) + + +def _stop_service(*, project_id, command_data, inputs): + commands = _Commands() + commands.command = SessionCommand( + id=uuid4(), + project_id=project_id, + session_id="session-1", + kind=SessionCommandKind.cancel, + target_turn_id="source-1", + data=command_data, + state=SessionCommandState.pending, + created_at=datetime.now(timezone.utc), + ) + streams = SimpleNamespace( + settle_command=AsyncMock(), + publish_session_ended=AsyncMock(), + ) + interactions = SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=interactions, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=_Executions( + project_id=project_id, + session_id="session-1", + source_id="source-1", + ), + inputs_dao=inputs, + ) + service._reconcile_stopped_redis = AsyncMock() + return service, commands + + +@pytest.mark.asyncio +async def test_manual_stop_pauses_pending_steer(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([steered]) + service, commands = _stop_service( + project_id=project_id, + command_data=None, + inputs=inputs, + ) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.pending + + +@pytest.mark.asyncio +async def test_steer_stop_promotes_its_saved_input_before_queue(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + queued = _pending_input(project_id, policy="queue", position=1) + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([queued, steered]) + service, commands = _stop_service( + project_id=project_id, + command_data={"steer_input_id": str(steered.id)}, + inputs=inputs, + ) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.pending + assert inputs.items[1].state == PendingInputState.promoted + assert commands.command.kind == SessionCommandKind.continue_input + assert commands.command.data["input_id"] == str(steered.id) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py new file mode 100644 index 00000000000..bef8684e2cb --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py @@ -0,0 +1,88 @@ +import json +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from oss.src.apis.fastapi.sessions.models import PendingInputAdmissionRequest +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputState, +) + + +class _Inputs: + def __init__(self, *, project_id, events): + self.events = events + self.item = PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"message": "steer now"}, + position=0, + state=PendingInputState.pending, + policy="steer", + idempotency_key="steer-1", + request_fingerprint="a" * 64, + created_by_id=uuid4(), + ) + + async def admit(self, **_kwargs): + self.events.append("saved") + return PendingInputAdmission( + action="pending", + input=self.item, + execution_id="execution-1", + ) + + +class _FailedStop: + def __init__(self, events): + self.events = events + + async def request_cancel(self, **kwargs): + self.events.append("stop") + assert kwargs["steer_input_id"] is not None + raise RuntimeError("runner unavailable") + + +@pytest.mark.asyncio +async def test_steer_is_saved_before_stop_and_stays_visible_when_stop_fails( + monkeypatch, +): + project_id = uuid4() + user_id = uuid4() + events = [] + inputs = _Inputs(project_id=project_id, events=events) + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.check_action_access", + lambda **_kwargs: _allowed(), + ) + router = SessionControlRouter( + commands_service=_FailedStop(events), + inputs_service=inputs, + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "steer-1"}, + ) + + response = await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id="session-1", + content={"message": "steer now"}, + on_busy="steer", + ), + ) + + assert events == ["saved", "stop"] + assert response.status_code == 202 + assert json.loads(response.body)["input"]["id"] == str(inputs.item.id) + assert inputs.item.state == PendingInputState.pending + + +async def _allowed(): + return True From 033c703133d5eef00e0aecfdd7a4ecef2304586e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 19:11:51 +0200 Subject: [PATCH 062/133] feat(web): use durable session input queue Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- web/mobile/src/features/chat/Composer.tsx | 42 +++++- .../src/features/chat/LiveConversation.tsx | 4 + .../AgentChatSlice/AgentConversation.tsx | 37 ++++- .../components/AgentComposerDock.tsx | 19 +++ .../hooks/useAgentChatSession.ts | 2 + .../hooks/useSessionHydration.ts | 1 + .../api/resources/sessions/client/Client.ts | 119 +++++++++++++++ .../requests/FetchSessionSnapshotRequest.ts | 5 + .../RemovePendingSessionInputRequest.ts | 6 + .../sessions/client/requests/index.ts | 2 + .../src/generated/api/types/PendingInput.ts | 30 ++++ .../api/types/PendingInputResponse.ts | 7 + .../generated/api/types/PendingInputState.ts | 8 + .../api/types/SessionCapabilities.ts | 2 + .../api/types/SessionExecutionSnapshot.ts | 15 ++ .../api/types/SessionPendingSnapshot.ts | 8 + .../api/types/SessionReadSnapshot.ts | 6 + .../api/types/SessionSnapshotResponse.ts | 9 +- .../src/generated/api/types/index.ts | 7 + .../agenta-chat/src/assets/pendingInputs.ts | 96 ++++++++++++ .../src/components/ChatComposer.tsx | 14 ++ .../src/components/QueuedMessagesDock.tsx | 15 +- web/packages/agenta-chat/src/hooks/index.ts | 1 + .../src/hooks/useAgentChatQueue.ts | 55 ++++++- .../src/hooks/useAgentConversation.ts | 46 ++++++ .../src/hooks/useServerSessionInputs.ts | 139 ++++++++++++++++++ .../tests/unit/assets/pendingInputs.test.ts | 78 ++++++++++ .../unit/hooks/useAgentChatQueue.test.ts | 58 +++++++- .../agenta-entities/src/session/api/api.ts | 28 +++- .../src/session/core/schema.ts | 42 ++++++ .../agenta-entities/src/session/index.ts | 6 + .../src/session/state/pendingInputs.ts | 17 +++ .../unit/session-pending-input-api.test.ts | 59 ++++++++ .../src/RichChatInput/RichChatInput.tsx | 4 + .../src/RichChatInput/plugins/SendButton.tsx | 78 ++++++---- .../QueuedMessagesDock.stories.tsx | 19 +++ 36 files changed, 1035 insertions(+), 49 deletions(-) create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts create mode 100644 web/packages/agenta-chat/src/assets/pendingInputs.ts create mode 100644 web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts create mode 100644 web/packages/agenta-entities/src/session/state/pendingInputs.ts create mode 100644 web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index bf8df8cbadf..66907226c29 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -31,16 +31,21 @@ import {useMotionPresets} from "@/lib/motion/presets" export const Composer = ({ sessionId, onSend, + onSteer, disabled = false, waitingOnUser = false, streaming = false, stopping = false, onStop, + queueEnabled = false, + steerEnabled = false, + inputBusy = streaming, inputRef, placeholder, }: { sessionId: string onSend: (input: {text: string; parts?: FileUIPart[]}) => void | Promise + onSteer?: (input: {text: string; parts?: FileUIPart[]}) => void | Promise /** No resolvable agent yet, or the screen is still hydrating. */ disabled?: boolean /** The run is parked on the user (pending approval) — sends will queue. */ @@ -50,6 +55,9 @@ export const Composer = ({ /** The durable Stop request has not settled yet. */ stopping?: boolean onStop?: () => void + queueEnabled?: boolean + steerEnabled?: boolean + inputBusy?: boolean /** Lets the host write into the input — a rewind puts the rewound message back to edit. */ inputRef?: MutableRefObject /** Full placeholder override — used when the composer is gated (no model key). */ @@ -65,7 +73,11 @@ export const Composer = ({ * `extraFiles` are takes that never entered the tray (a voice message sent outright), so * they upload here before the send — the same seam the desktop dock uses. */ - const submit = async (text: string, extraFiles: File[] = []) => { + const submit = async ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => { // Enter and the send button (and a voice take completing) can all fire while an upload // is still in flight; a second pass would re-send the same staged tray. if (sending.current) return @@ -78,13 +90,17 @@ export const Composer = ({ // pop the keyboard straight back up. dismissSoftKeyboardAfterSend(() => richInputRef.current?.blur()) try { - await runSubmit(text, extraFiles) + await runSubmit(text, extraFiles, policy) } finally { sending.current = false } } - const runSubmit = async (text: string, extraFiles: File[] = []) => { + const runSubmit = async ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => { const staged = attachments.files const uploadedExtras = extraFiles.length ? await attachments.uploadExtraFiles(extraFiles) @@ -96,7 +112,8 @@ export const Composer = ({ // `stagedFilesToParts` THROWS on a file whose upload hasn't settled — reachable via // Enter, which the send button's `sendDisabled` guard doesn't cover. const parts = outbound.length > 0 ? stagedFilesToParts(outbound, sessionId) : undefined - await onSend({text, parts}) + if (policy === "steer" && onSteer) await onSteer({text, parts}) + else await onSend({text, parts}) attachments.clearAttachments(staged.map((file) => file.uid)) } catch { // Nothing consumes this promise (RichChatInput's submit is fire-and-forget), so an @@ -186,6 +203,23 @@ export const Composer = ({ streaming={streaming} stopping={stopping} onStop={onStop} + busyActions={ + inputBusy && queueEnabled + ? [ + {label: "Queue", onSubmit: submit}, + ...(steerEnabled && onSteer + ? [ + { + label: "Steer", + onSubmit: (text: string) => + submit(text, [], "steer"), + }, + ] + : []), + ] + : undefined + } + showQueuePauseCopy={inputBusy && queueEnabled} extraPrefix={ conversation.steer({text, parts})} disabled={conversation.isHydrating || modelBlocked} placeholder={ modelBlocked ? "Connect a model to start chatting…" : undefined @@ -685,6 +686,9 @@ export const LiveConversation = ({ })} stopping={stopping} onStop={stopHere} + queueEnabled={conversation.queueEnabled} + steerEnabled={conversation.steerEnabled} + inputBusy={conversation.inputBusy} inputRef={composerRef} /> diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index ccb4e5c39bb..14c5e548208 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -14,6 +14,7 @@ import { useComposerAttachments, useAgentChatQueue, useSessionLivePreview, + useServerSessionInputs, type QueuedMessage, } from "@agenta/chat/hooks" import { @@ -161,6 +162,7 @@ const AgentConversation = ({ runningElsewhere: livenessRunningElsewhere, sharedReaderAdvertised, refreshFromRecords, + revalidate, setSharedSenderReady, } = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent}) const { @@ -367,6 +369,18 @@ const AgentConversation = ({ const consumedRunNonceRef = useRef(null) + const serverInputs = useServerSessionInputs({ + entityId, + sessionId, + messages, + locallyBusy: busy, + onExecuted: revalidate, + }) + + useEffect(() => { + if (status === "ready" || status === "error") void serverInputs.refresh() + }, [status, serverInputs.refresh]) + // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's // release effect doesn't churn on every token. const sendQueued = useCallback( @@ -401,8 +415,12 @@ const AgentConversation = ({ const { queued, submit, + steer, removeQueued, ownsContinuation, + queueEnabled, + steerEnabled, + serverBusy, hitlPending, editingId, beginEdit, @@ -421,6 +439,7 @@ const AgentConversation = ({ markRunOwned, sendQueued, sessionId, + server: serverInputs, }) // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision made @@ -661,6 +680,7 @@ const AgentConversation = ({ fileParts: FileUIPart[] | undefined, consumedUids: string[], stagedFiles: typeof files, + policy: "queue" | "steer" = "queue", ) => { if (editingId) { // A rewrite of a held message: nothing is sent, so the transcript must not move. @@ -674,7 +694,8 @@ const AgentConversation = ({ scrollIntent.armGlide() setStopped(false) // One path: `submit` sends now or queues behind held messages via the shared release gate. - submit({text: trimmed, fileParts, stagedFiles}) + if (policy === "steer") steer({text: trimmed, fileParts, stagedFiles}) + else submit({text: trimmed, fileParts, stagedFiles}) } // The message left the composer — drop its persisted draft (and any pending capture). composer.clearDraft() @@ -684,7 +705,11 @@ const AgentConversation = ({ // A voice take awaits its upload, so the guard keeps a second send from starting meanwhile. const inFlightSubmitRef = useRef(false) - const handleSubmit = (text: string, extraFiles: File[] = []) => + const handleSubmit = ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => runWithInFlightSubmit(inFlightSubmitRef, async () => { const trimmed = text.trim() if (!trimmed && files.length === 0 && extraFiles.length === 0) return @@ -715,7 +740,7 @@ const AgentConversation = ({ } fileParts = parts } - finishSubmit(trimmed, fileParts, stagedUids, files) + finishSubmit(trimmed, fileParts, stagedUids, files, policy) return } @@ -728,7 +753,7 @@ const AgentConversation = ({ const fileParts = outboundFiles.length ? stagedFilesToParts(outboundFiles, sessionId) : undefined - finishSubmit(trimmed, fileParts, stagedUids, outboundFiles) + finishSubmit(trimmed, fileParts, stagedUids, outboundFiles, policy) }) handleSubmitRef.current = handleSubmit @@ -977,6 +1002,7 @@ const AgentConversation = ({ editingId, beginEdit, cancelEdit, + serverBusy, }} modelKey={{...modelKey, entityId}} modelBlocked={modelBlocked} @@ -990,8 +1016,11 @@ const AgentConversation = ({ elicits={elicits} onClientToolOutput={handleClientToolOutput} onSubmit={handleSubmit} + onSteer={(text) => handleSubmit(text, [], "steer")} onStop={handleStop} stopping={stopping} + queueEnabled={queueEnabled} + steerEnabled={steerEnabled} richInputRef={richInputRef} composer={{...composer, handleComposerChange}} attachments={attachments} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index c98522fa541..fd41387f6b2 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -79,8 +79,11 @@ const AgentComposerDock = ({ elicits, onClientToolOutput, onSubmit, + onSteer, onStop, stopping, + queueEnabled, + steerEnabled, richInputRef, composer, attachments, @@ -104,6 +107,7 @@ const AgentComposerDock = ({ editingId: string | null beginEdit: (id: string, draft?: string) => void cancelEdit: () => string + serverBusy: boolean } modelKey: React.ComponentProps modelBlocked: boolean @@ -126,8 +130,11 @@ const AgentComposerDock = ({ elicits: ElicitationDockState onClientToolOutput: ClientToolOutputHandler onSubmit: (text: string) => void | Promise + onSteer: (text: string) => void | Promise onStop: () => void stopping: boolean + queueEnabled: boolean + steerEnabled: boolean richInputRef: RefObject composer: ReturnType attachments: ReturnType @@ -140,6 +147,7 @@ const AgentComposerDock = ({ /** Read at event time — attachments are refused right now (a take in flight, or the above). */ attachmentsBlocked: () => boolean }) => { + const inputBusy = busy || queue.serverBusy const { onboarding, onboardingActive, @@ -467,6 +475,17 @@ const AgentComposerDock = ({ streaming={shouldShowStopControl({busy, hitlPending})} stopping={stopping} onStop={onStop} + busyActions={ + inputBusy && queueEnabled + ? [ + {label: "Queue", onSubmit: submitMessage}, + ...(steerEnabled + ? [{label: "Steer", onSubmit: onSteer}] + : []), + ] + : undefined + } + showQueuePauseCopy={inputBusy && queueEnabled} attachments={attachments} attachmentsBlocked={attachmentsBlocked} composerDisabled={composerDisabled} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 54a31aa0a82..36b5b546316 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -367,6 +367,7 @@ export const useAgentChatSession = ({ stoppingTurnId, sharedReaderAdvertised, refreshFromRecords, + revalidate, } = useSessionHydration({ sessionId, initialMessages, @@ -841,6 +842,7 @@ export const useAgentChatSession = ({ sharedReaderAdvertised, refreshFromRecords, setSharedSenderReady, + revalidate, stopped, stopping, setStopped, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 4440f20087c..f76b2853a29 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -664,5 +664,6 @@ export const useSessionHydration = ({ stoppingTurnId: liveness.stoppingTurnId, sharedReaderAdvertised: liveness.sharedReader, refreshFromRecords, + revalidate: refreshFromRecords, } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index e5b33539a7f..21d235aaaff 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2453,6 +2453,125 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "DELETE", "/sessions/"); } + /** Fetch the durable execution and pending-input snapshot for a session. */ + public fetchSessionSnapshot( + request: AgentaApi.FetchSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__fetchSessionSnapshot(request, requestOptions)); + } + + private async __fetchSessionSnapshot( + request: AgentaApi.FetchSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionSnapshotResponse, rawResponse: _response.rawResponse }; + } + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); + } + + /** Remove a pending input before it is promoted. */ + public removePendingSessionInput( + request: AgentaApi.RemovePendingSessionInputRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__removePendingSessionInput(request, requestOptions)); + } + + private async __removePendingSessionInput( + request: AgentaApi.RemovePendingSessionInputRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}/inputs/${core.url.encodePathParam(inputId)}`, + ), + method: "DELETE", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.PendingInputResponse, rawResponse: _response.rawResponse }; + } + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "DELETE", + "/sessions/{session_id}/inputs/{input_id}", + ); + } + /** * @param {AgentaApi.ArchiveSessionRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts new file mode 100644 index 00000000000..04d6595b5f9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface FetchSessionSnapshotRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts new file mode 100644 index 00000000000..1f06a0de023 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface RemovePendingSessionInputRequest { + session_id: string; + input_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 0dcd28e629b..474e3c53195 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -8,10 +8,12 @@ export type { DownloadSessionAttachmentContentRequest } from "./DownloadSessionA export type { DownloadSessionMountFileRequest } from "./DownloadSessionMountFileRequest.js"; export type { FetchInteractionRequest } from "./FetchInteractionRequest.js"; export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; +export type { FetchSessionSnapshotRequest } from "./FetchSessionSnapshotRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; export type { FetchTurnRequest } from "./FetchTurnRequest.js"; export type { GetSessionSnapshotRequest } from "./GetSessionSnapshotRequest.js"; export type { ResumeSessionContinuationRequest } from "./ResumeSessionContinuationRequest.js"; +export type { RemovePendingSessionInputRequest } from "./RemovePendingSessionInputRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; export type { SessionAttachmentReferenceRequest } from "./SessionAttachmentReferenceRequest.js"; export type { SessionDetachRequest } from "./SessionDetachRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts new file mode 100644 index 00000000000..8c5e429ed89 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts @@ -0,0 +1,30 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInput { + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + project_id: string; + session_id: string; + content: Record; + position: number; + state: AgentaApi.PendingInputState; + policy: PendingInput.Policy; + idempotency_key: string; + request_fingerprint: string; + promoted_execution_id?: (string | null) | undefined; +} + +export namespace PendingInput { + export const Policy = { + Queue: "queue", + Steer: "steer", + } as const; + export type Policy = (typeof Policy)[keyof typeof Policy]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts new file mode 100644 index 00000000000..b86833ced6c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInputResponse { + input: AgentaApi.PendingInput; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts new file mode 100644 index 00000000000..db7fa013c30 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export const PendingInputState = { + Pending: "pending", + Promoted: "promoted", + Removed: "removed", +} as const; +export type PendingInputState = (typeof PendingInputState)[keyof typeof PendingInputState]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts index 879fde8d3bd..39303b0960b 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts @@ -2,4 +2,6 @@ export interface SessionCapabilities { durable_approvals?: boolean | undefined; + queue?: boolean | undefined; + steer?: boolean | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts new file mode 100644 index 00000000000..fa81e46bed8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionExecutionSnapshot { + id?: (string | null) | undefined; + state?: SessionExecutionSnapshot.State | undefined; +} + +export namespace SessionExecutionSnapshot { + export const State = { + Idle: "idle", + Running: "running", + Stopping: "stopping", + } as const; + export type State = (typeof State)[keyof typeof State]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts new file mode 100644 index 00000000000..e814d1014d9 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionPendingSnapshot { + inputs?: AgentaApi.PendingInput[] | undefined; + interactions?: AgentaApi.SessionInteraction[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts new file mode 100644 index 00000000000..76d449941db --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionReadSnapshot { + latest_sequence?: number | undefined; + history_complete?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts index 3253bdc3987..9477163d9b4 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -3,8 +3,9 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotResponse { - session: AgentaApi.SessionStream; - execution?: (AgentaApi.SessionTurn | null) | undefined; - pending: AgentaApi.SessionSnapshotPending; - read: AgentaApi.SessionRecordsReadState; + session?: (AgentaApi.SessionStream | null) | undefined; + execution?: AgentaApi.SessionExecutionSnapshot | undefined; + pending?: AgentaApi.SessionPendingSnapshot | undefined; + read?: AgentaApi.SessionReadSnapshot | undefined; + capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 7edb941ab86..56625309c86 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -316,6 +316,9 @@ export * from "./OrganizationDetails.js"; export * from "./OrganizationDomainResponse.js"; export * from "./OrganizationProviderResponse.js"; export * from "./OrganizationUpdate.js"; +export * from "./PendingInput.js"; +export * from "./PendingInputResponse.js"; +export * from "./PendingInputState.js"; export * from "./OTelEventInput.js"; export * from "./OTelEventOutput.js"; export * from "./OTelHashInput.js"; @@ -380,6 +383,7 @@ export * from "./SessionAttachmentResponse.js"; export * from "./SessionAttachmentsResponse.js"; export * from "./SessionCancelRequest.js"; export * from "./SessionDelivery.js"; +export * from "./SessionExecutionSnapshot.js"; export * from "./SessionExcludeRequest.js"; export * from "./SessionExpansion.js"; export * from "./SessionHeartbeatResult.js"; @@ -398,6 +402,7 @@ export * from "./SessionInteractionStatus.js"; export * from "./SessionInteractionsResponse.js"; export * from "./SessionListItem.js"; export * from "./SessionMessagePreview.js"; +export * from "./SessionPendingSnapshot.js"; export * from "./SessionMount.js"; export * from "./SessionMountQuery.js"; export * from "./SessionMountsResponse.js"; @@ -411,6 +416,7 @@ export * from "./SessionSnapshotPending.js"; export * from "./SessionSnapshotResponse.js"; export * from "./SessionReference.js"; export * from "./SessionResponse.js"; +export * from "./SessionReadSnapshot.js"; export * from "./SessionStream.js"; export * from "./SessionStreamCommandResponse.js"; export * from "./SessionStreamFlags.js"; @@ -419,6 +425,7 @@ export * from "./SessionStreamQueryFlags.js"; export * from "./SessionStreamResponse.js"; export * from "./SessionStreamsResponse.js"; export * from "./SessionTranscriptWindowing.js"; +export * from "./SessionSnapshotResponse.js"; export * from "./SessionsResponse.js"; export * from "./SessionTrigger.js"; export * from "./SessionTriggerKind.js"; diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts new file mode 100644 index 00000000000..9f52517bb83 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -0,0 +1,96 @@ +import type {PendingSessionInput, SessionSnapshotResponse} from "@agenta/entities/session" +import type {FileUIPart} from "ai" + +import type {QueuedMessage} from "../hooks/useAgentChatQueue" + +export interface SessionPendingInputView { + capabilities: {queue: boolean; steer: boolean} + executionState: "idle" | "running" | "stopping" + queued: QueuedMessage[] +} + +const asRecord = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null + +const filePartFromBlock = (block: Record): FileUIPart | null => { + const url = block.uri ?? block.url + if (typeof url !== "string" || !url) return null + return { + type: "file", + url, + mediaType: + typeof block.mime_type === "string" + ? block.mime_type + : typeof block.mediaType === "string" + ? block.mediaType + : "application/octet-stream", + filename: typeof block.filename === "string" ? block.filename : undefined, + } +} + +export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedMessage | null => { + const data = asRecord(input.content.data) + const inputs = asRecord(data?.inputs) + const messages = Array.isArray(inputs?.messages) ? inputs.messages : [] + const message = [...messages] + .reverse() + .map(asRecord) + .find((candidate) => candidate?.role === "user") + if (!message || !input.id) return null + + let text = "" + const fileParts: FileUIPart[] = [] + let attachmentCount = 0 + if (typeof message.content === "string") { + text = message.content + } else if (Array.isArray(message.content)) { + for (const raw of message.content) { + const block = asRecord(raw) + if (!block) continue + if (block.type === "text" && typeof block.text === "string") text += block.text + if (["attachment", "image", "resource"].includes(String(block.type))) { + attachmentCount += 1 + const part = filePartFromBlock(block) + if (part) fileParts.push(part) + } + } + } else if (Array.isArray(message.parts)) { + for (const raw of message.parts) { + const part = asRecord(raw) + if (!part) continue + if (part.type === "text" && typeof part.text === "string") text += part.text + if (part.type === "file") { + attachmentCount += 1 + const filePart = filePartFromBlock(part) + if (filePart) fileParts.push(filePart) + } + } + } + + return { + id: input.id, + text, + fileParts: fileParts.length ? fileParts : undefined, + attachmentCount, + policy: input.policy, + source: "server", + editable: false, + } +} + +export const reduceSessionPendingInputs = ( + snapshot: SessionSnapshotResponse | null, +): SessionPendingInputView => ({ + capabilities: { + queue: snapshot?.capabilities.queue ?? false, + steer: snapshot?.capabilities.steer ?? false, + }, + executionState: snapshot?.execution.state ?? "idle", + queued: (snapshot?.pending.inputs ?? []) + .filter((input) => input.state === "pending") + .sort((left, right) => left.position - right.position) + .map(pendingInputToQueuedMessage) + .filter((input): input is QueuedMessage => input !== null), +}) diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index 79059a47974..ccf23463796 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -55,6 +55,10 @@ export interface ChatComposerProps { /** The Stop request is pending or accepted, awaiting the stream's terminal event. */ stopping?: boolean onStop?: () => void + /** Capability-gated controls shown beside Stop while the session is busy. */ + busyActions?: {label: string; onSubmit: (text: string) => void}[] + /** Explain the manual Stop rule while durable Queue is available. */ + showQueuePauseCopy?: boolean /** Read at event time — attachments are refused right now (a voice take in flight…). */ attachmentsBlocked?: () => boolean /** The composer itself is unusable (gates the paperclip alongside `uploadsEnabled`). */ @@ -88,6 +92,8 @@ export const ChatComposer = ({ streaming, stopping, onStop, + busyActions, + showQueuePauseCopy, attachmentsBlocked, composerDisabled, onViewAttachment, @@ -170,6 +176,7 @@ export const ChatComposer = ({ streaming={streaming} stopping={stopping} onStop={onStop} + busyActions={busyActions} prefix={
{extraPrefix} @@ -207,6 +214,13 @@ export const ChatComposer = ({ } trailing={trailing} + footer={ + showQueuePauseCopy ? ( +

+ Stop pauses the queue. It resumes after your next message. +

+ ) : null + } /> ) diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index 6747967c1ac..149aaffc149 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -104,6 +104,7 @@ const Row = ({ }) => { const text = message.text.trim() const files = message.fileParts ?? [] + const attachmentCount = Math.max(files.length, message.attachmentCount ?? 0) return (
) : ( - {files.length ? "(attachments only)" : "(empty message)"} + {attachmentCount ? "(attachments only)" : "(empty message)"} )} + {attachmentCount > files.length ? ( + + {attachmentCount} attachment{attachmentCount === 1 ? "" : "s"} + + ) : null} + {message.policy === "steer" ? ( + + Steer + + ) : null} {/* Revealed on hover, but always present for keyboard and while this row is under edit — an action you can only reach with a pointer is not an action on mobile. */} Cancel - ) : onEdit ? ( + ) : onEdit && message.editable !== false ? ( + + {busyActions?.map((action) => ( + + ))} + {streaming ? ( + + + + + + ) : null} ) } diff --git a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx index bbbf2e87a15..c42910e851c 100644 --- a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx +++ b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx @@ -76,6 +76,25 @@ export const Held: Story = { render: () => , } +/** Durable rows are shared across browsers: removable, not locally editable, with Steer marked. */ +export const ServerBacked: Story = { + render: () => ( + + ), +} + /** Past five rows the body scrolls and the card stops growing; the header stays put. */ export const Overflowing: Story = { render: () => ( From 876d9e0bc384f2b55efdcfb8916a0db6f2f3485a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 4 Sep 2026 19:20:38 +0200 Subject: [PATCH 063/133] test(sessions): cover pending input transactions Claude-Session: https://claude.ai/code/session_01GAqSs7fw6QRi2n1ZJ2tmAV --- ...oss000000028_add_session_pending_inputs.py | 8 +- .../tasks/asyncio/sessions/orphan_sweep.py | 5 +- .../sessions/test_pending_inputs_service.py | 22 +- .../unit/sessions/test_session_inputs_dao.py | 488 ++++++++++++++++++ .../test_invoke_dispatch_parity_routing.py | 4 + .../test_session_input_admission_routing.py | 160 ++++++ 6 files changed, 668 insertions(+), 19 deletions(-) create mode 100644 api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py create mode 100644 sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py index dd2f93ffcbc..437cb7d173b 100644 --- a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py @@ -19,9 +19,7 @@ def upgrade() -> None: - op.drop_constraint( - "ck_session_commands_kind", "session_commands", type_="check" - ) + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") op.create_check_constraint( "ck_session_commands_kind", "session_commands", @@ -87,9 +85,7 @@ def downgrade() -> None: op.drop_index("uq_session_inputs_idempotency", table_name="session_inputs") op.drop_index("uq_session_inputs_id", table_name="session_inputs") op.drop_table("session_inputs") - op.drop_constraint( - "ck_session_commands_kind", "session_commands", type_="check" - ) + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") op.create_check_constraint( "ck_session_commands_kind", "session_commands", diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 9b21b889ac0..fa2caf35976 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -540,10 +540,7 @@ async def run_orphan_sweep( if ( records_service is not None and commands_service is not None - and ( - env.agenta.sessions.durable_approvals - or env.agenta.sessions.queue - ) + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) ): runner_completed, completion_failures = await _runner_completed_executions( records_service=records_service, diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index 6dc4b6c0a3d..d889f1ceaf0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -34,7 +34,9 @@ async def fetch_by_idempotency_key(self, **kwargs): None, ) - async def create_input(self, *, user_id, pending_input, prioritize=False, **_kwargs): + async def create_input( + self, *, user_id, pending_input, prioritize=False, **_kwargs + ): item = PendingInput( id=uuid4(), created_at=datetime.now(timezone.utc), @@ -93,7 +95,9 @@ async def fetch_header(self, **_kwargs): @pytest.mark.asyncio async def test_busy_queue_is_rejected_when_switch_is_off(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", False) - service = SessionInputsService(inputs_dao=MemoryInputsDAO(), streams_service=Streams()) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), streams_service=Streams() + ) with pytest.raises(SessionInputBusy): await service.admit( @@ -125,9 +129,9 @@ async def test_busy_queue_is_durable_and_removable(monkeypatch): assert admitted.action == "pending" assert admitted.input is not None - assert await service.list_pending(project_id=project_id, session_id="session-1") == [ - admitted.input - ] + assert await service.list_pending( + project_id=project_id, session_id="session-1" + ) == [admitted.input] removed = await service.remove( project_id=project_id, user_id=user_id, @@ -135,7 +139,9 @@ async def test_busy_queue_is_durable_and_removable(monkeypatch): input_id=admitted.input.id, ) assert removed.state == PendingInputState.removed - assert await service.list_pending(project_id=project_id, session_id="session-1") == [] + assert ( + await service.list_pending(project_id=project_id, session_id="session-1") == [] + ) @pytest.mark.asyncio @@ -210,8 +216,6 @@ async def test_steer_is_saved_ahead_of_queued_input(monkeypatch): idempotency_key="steer-1", ) - pending = await service.list_pending( - project_id=project_id, session_id="session-1" - ) + pending = await service.list_pending(project_id=project_id, session_id="session-1") assert [item.id for item in pending] == [steered.input.id, queued.input.id] assert steered.execution_id == "execution-1" diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py new file mode 100644 index 00000000000..2b51dfb5529 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -0,0 +1,488 @@ +"""Postgres transaction guarantees for durable session input admission and promotion.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +import uuid + +from fastapi import HTTPException +import pytest +from sqlalchemy import text + +from oss.src.apis.fastapi.sessions.models import PendingInputAdmissionRequest +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +import oss.src.apis.fastapi.sessions.router as router_module +from oss.src.core.sessions.commands.dtos import ( + SessionCommandCreate, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.inputs.dtos import PendingInputCreate, PendingInputState +from oss.src.core.sessions.inputs.service import SessionInputsService, input_fingerprint +from oss.src.core.sessions.inputs.types import SessionInputNotRemovable +from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine +import oss.src.models.db_models # noqa: F401 +from oss.src.utils.env import env + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def input_scope(): + engine = get_transactions_engine() + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + session_id = f"input-dao-{project_id.hex[:12]}" + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "input-dao-test", + "email": f"input-dao-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + { + "id": organization_id, + "name": "input-dao-test-org", + "owner_id": user_id, + }, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + { + "id": workspace_id, + "name": "input-dao-test-workspace", + "organization_id": organization_id, + }, + ) + await session.execute( + text( + "INSERT INTO projects " + "(id, project_name, workspace_id, organization_id) " + "VALUES (:id, :project_name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "project_name": "input-dao-test-project", + "workspace_id": workspace_id, + "organization_id": organization_id, + }, + ) + + yield { + "engine": engine, + "project_id": project_id, + "user_id": user_id, + "session_id": session_id, + } + + async with engine.session() as session: + await session.execute( + text("DELETE FROM session_inputs WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM session_commands WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM session_executions WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM projects WHERE id = :id"), {"id": project_id} + ) + await session.execute( + text("DELETE FROM workspaces WHERE id = :id"), {"id": workspace_id} + ) + await session.execute( + text("DELETE FROM organizations WHERE id = :id"), + {"id": organization_id}, + ) + await session.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + + +def _input(scope, *, key: str, message: str, policy: str = "queue"): + content = { + "session_id": scope["session_id"], + "data": {"messages": [message]}, + } + return PendingInputCreate( + project_id=scope["project_id"], + session_id=scope["session_id"], + content=content, + policy=policy, + idempotency_key=key, + request_fingerprint=input_fingerprint(content=content, policy=policy), + ) + + +class _BusyStreams: + async def fetch_header(self, **_kwargs): + return SimpleNamespace( + flags=SimpleNamespace(is_running=True), turn_id="source-turn" + ) + + +class _UnreachableDelivery: + async def deliver(self, **_kwargs): + return DeliveryReceipt(status="unreachable") + + async def acknowledge(self, **_kwargs): + return None + + +def _settlement_service(scope, inputs): + streams = SimpleNamespace( + settle_command=AsyncMock(), + publish_session_ended=AsyncMock(), + ) + interactions = SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ) + service = SessionCommandsService( + commands_dao=SessionCommandsDAO(engine=scope["engine"]), + streams_service=streams, + interactions_service=interactions, + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=SessionExecutionsDAO(engine=scope["engine"]), + inputs_dao=inputs, + ) + service._reconcile_stopped_redis = AsyncMock() + return service + + +async def _pending_command(scope, *, data=None): + return await SessionCommandsDAO(engine=scope["engine"]).create_command( + user_id=scope["user_id"], + command=SessionCommandCreate( + project_id=scope["project_id"], + session_id=scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="source-turn", + state=SessionCommandState.pending, + data=data, + ), + ) + + +async def test_completion_promotes_one_fifo_input_in_the_settlement_transaction( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + first = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-1", message="first"), + ) + second = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-2", message="second"), + ) + service = _settlement_service(input_scope, inputs) + + assert await service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + assert await service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + ) + ).state == PendingInputState.promoted + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + ] == [second.id] + + +async def test_manual_stop_commits_without_promoting_pending_input( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + pending = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-paused", message="later"), + ) + command = await _pending_command(input_scope) + service = _settlement_service(input_scope, inputs) + + settled = await service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=pending.id, + ) + ).state == PendingInputState.pending + + +async def test_concurrent_idempotent_admission_returns_one_postgres_row( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + service = SessionInputsService( + inputs_dao=SessionInputsDAO(engine=input_scope["engine"]), + streams_service=_BusyStreams(), + ) + kwargs = { + "project_id": input_scope["project_id"], + "user_id": input_scope["user_id"], + "session_id": input_scope["session_id"], + "content": {"message": "same"}, + "policy": "queue", + "idempotency_key": "same-key", + } + + first, retry = await asyncio.wait_for( + asyncio.gather(service.admit(**kwargs), service.admit(**kwargs)), timeout=5 + ) + + assert first.input.id == retry.input.id + assert ( + len( + await service.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + ) + == 1 + ) + + +async def test_conflicting_key_returns_the_409_envelope(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + inputs = SessionInputsService( + inputs_dao=SessionInputsDAO(engine=input_scope["engine"]), + streams_service=_BusyStreams(), + ) + router = SessionControlRouter( + commands_service=SimpleNamespace(), inputs_service=inputs + ) + request = SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ), + headers={"Idempotency-Key": "conflicting-key"}, + ) + await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "first"}, + on_busy="queue", + ), + ) + + with pytest.raises(HTTPException) as raised: + await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "different"}, + on_busy="queue", + ), + ) + + assert raised.value.status_code == 409 + assert raised.value.detail["code"] == "idempotency_key_reused" + assert raised.value.detail["retryable"] is False + + +async def test_promoted_input_cannot_be_removed(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + item = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="promoted", message="go"), + ) + await inputs.promote_next( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="next-turn", + ) + service = SessionInputsService(inputs_dao=inputs, streams_service=_BusyStreams()) + + with pytest.raises(SessionInputNotRemovable): + await service.remove( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=item.id, + ) + + +async def test_steer_is_committed_before_failed_stop_and_stays_first( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + dao = SessionInputsDAO(engine=input_scope["engine"]) + await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + inputs = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + events = [] + + class FailedStop: + async def request_cancel(self, **kwargs): + pending = await dao.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + assert pending[0].id == kwargs["steer_input_id"] + events.append("stop-after-commit") + raise RuntimeError("runner unavailable") + + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionControlRouter(commands_service=FailedStop(), inputs_service=inputs) + response = await router.admit_session_input( + SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ), + headers={"Idempotency-Key": "steer"}, + ), + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "steer now"}, + on_busy="steer", + ), + ) + + pending = await dao.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert response.status_code == 202 + assert json.loads(response.body)["input"]["id"] == str(pending[0].id) + assert [item.idempotency_key for item in pending] == ["steer", "older"] + assert events == ["stop-after-commit"] + + +async def test_steer_stop_promotes_only_the_bound_input(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + older = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, key="steer", message="steer now", policy="steer" + ), + prioritize=True, + ) + command = await _pending_command( + input_scope, data={"steer_input_id": str(steer.id)} + ) + service = _settlement_service(input_scope, inputs) + + settled = await service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.promoted + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=older.id, + ) + ).state == PendingInputState.pending + continuation = await SessionExecutionsDAO( + engine=input_scope["engine"] + ).fetch_execution( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).promoted_execution_id, + ) + assert continuation.state == SessionExecutionState.recoverable diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py index e78e6b3b95d..d9a32570ae5 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py @@ -24,6 +24,7 @@ from agenta.sdk.decorators.routing import ( route, apply_invoke_prelude, + admit_session_input, handle_invoke_success, handle_invoke_failure, ) @@ -74,6 +75,9 @@ async def dispatch_invoke(req: Request, request: WorkflowInvokeRequest): credentials = req.state.auth.get("credentials") apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input(req, request, credentials) + if admission_response is not None: + return admission_response response = await invoke_workflow(request=request, credentials=credentials) return await handle_invoke_success(req, response) except Exception as exception: diff --git a/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py b/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py new file mode 100644 index 00000000000..cb76bc968b4 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py @@ -0,0 +1,160 @@ +"""Durable Queue/Steer admission at the two shared invoke entrypoints.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from starlette.requests import Request + +from agenta.sdk.decorators.routing import admit_session_input +from agenta.sdk.models.workflows import WorkflowInvokeRequest + + +def _request(*, idempotency_key: str = "input-1") -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/invoke", + "headers": [(b"idempotency-key", idempotency_key.encode())], + "query_string": b"", + } + ) + + +class _Client: + def __init__(self, response, captured): + self.response = response + self.captured = captured + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, url, **kwargs): + self.captured.update({"url": url, **kwargs}) + if isinstance(self.response, Exception): + raise self.response + return self.response + + +def _platform(): + connection = MagicMock() + connection.base_url.return_value = "https://platform.example/api" + connection.headers.return_value = {"Authorization": "opaque-test-value"} + connection.timeout = 3 + return connection + + +@pytest.mark.asyncio +async def test_queue_admission_forwards_the_stable_key_and_returns_202(): + captured = {} + upstream = SimpleNamespace( + status_code=202, + json=lambda: { + "action": "pending", + "input": {"id": "00000000-0000-0000-0000-000000000001"}, + }, + ) + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + data={"inputs": {"messages": [{"role": "user", "content": "later"}]}}, + ) + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(upstream, captured), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response.status_code == 202 + assert json.loads(response.body)["action"] == "pending" + assert ( + captured["url"] == "https://platform.example/api/sessions/control/inputs/admit" + ) + assert captured["headers"]["Idempotency-Key"] == "input-1" + assert captured["json"]["on_busy"] == "queue" + assert captured["json"]["content"]["session_id"] == "session-1" + + +@pytest.mark.asyncio +async def test_idle_admission_continues_invoke_with_the_server_execution_id(): + captured = {} + upstream = SimpleNamespace( + status_code=200, + json=lambda: {"action": "execute", "execution_id": "execution-2"}, + ) + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + data={"inputs": {"value": "now"}}, + ) + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(upstream, captured), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response is None + assert request.meta["run_id"] == "execution-2" + + +@pytest.mark.asyncio +async def test_promoted_input_skips_admission_to_avoid_recursive_queueing(): + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + meta={"promoted_input_id": "00000000-0000-0000-0000-000000000001"}, + data={"inputs": {"value": "promoted"}}, + ) + + with patch("agenta.sdk.decorators.routing.httpx.AsyncClient") as client: + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response is None + client.assert_not_called() + + +@pytest.mark.asyncio +async def test_precommit_transport_failure_returns_a_retryable_503(): + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="steer", + data={"inputs": {"value": "urgent"}}, + ) + transport_error = httpx.ConnectError("unreachable") + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(transport_error, {}), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + body = json.loads(response.body) + assert response.status_code == 503 + assert body["retryable"] is True + assert body["next_step"] == "Retry with the same idempotency key." From f4cca6940802bf4aeb04f2a9a0f4a2c993a65dfc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 02:22:34 +0200 Subject: [PATCH 064/133] fix(sessions): fence queued input ownership Keep detached continuations and client-held messages under one durable queue owner. Exclude failed terminal records from normal-completion promotion. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/entrypoints/routers.py | 10 +- api/oss/src/core/sessions/inputs/service.py | 13 ++- api/oss/src/core/sessions/records/service.py | 2 +- .../sessions/test_late_record_quarantine.py | 10 +- .../sessions/test_pending_inputs_service.py | 76 +++++++++++++++ .../sessions/test_record_ingest_endpoint.py | 7 +- services/runner/src/tracing/otel.ts | 11 +-- .../tests/unit/harness-cancel-park.test.ts | 6 +- .../src/hooks/useAgentChatQueue.ts | 62 +++++++++++- .../unit/hooks/useAgentChatQueue.test.ts | 96 ++++++++++++++++++- .../unit/session-pending-input-api.test.ts | 5 +- 11 files changed, 273 insertions(+), 25 deletions(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index a47c126df63..a61b7960f53 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1143,11 +1143,6 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - records_service=records_service, ) -session_inputs_service = SessionInputsService( - inputs_dao=session_inputs_dao, - streams_service=session_streams_service, -) - # Durable session commands (Stop). The control-delivery adapter is chosen by one setting. # `direct` posts the command to the runner's own /cancel over the hop that already carries hard # kill; `long_poll` is not built yet, and naming it fails at boot rather than silently falling @@ -1186,6 +1181,11 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - executions_dao=session_executions_dao, inputs_dao=session_inputs_dao, ) +session_inputs_service = SessionInputsService( + inputs_dao=session_inputs_dao, + streams_service=session_streams_service, + continuation_resumer=session_commands_service.resume_recoverable_continuation, +) workflows_service.set_session_continuation_resumer( session_commands_service.resume_recoverable_continuation ) diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 223744a7569..00ed8c2a2da 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -1,6 +1,6 @@ import hashlib import json -from typing import Any, Dict, List, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional from uuid import UUID from oss.src.core.sessions.inputs.dtos import ( @@ -36,9 +36,11 @@ def __init__( *, inputs_dao: SessionInputsDAOInterface, streams_service: SessionStreamsService, + continuation_resumer: Optional[Callable[..., Awaitable[bool]]] = None, ) -> None: self._dao = inputs_dao self._streams = streams_service + self._continuation_resumer = continuation_resumer async def admit( self, @@ -54,6 +56,15 @@ async def admit( project_id=project_id, session_id=session_id ) busy = bool(stream and stream.flags and stream.flags.is_running) + if ( + not busy + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) + and self._continuation_resumer is not None + ): + busy = await self._continuation_resumer( + project_id=project_id, + session_id=session_id, + ) if not busy: return PendingInputAdmission(action="execute") diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index 1202b33cb60..591933f1b41 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -146,7 +146,7 @@ async def _settle_completed_continuations( and (record.attributes or {}).get(RECORD_SETTLED_BY_ATTRIBUTE) != SETTLED_BY_WATCHDOG and (record.attributes or {}).get("stopReason") - not in ("paused", "cancelled") + not in ("paused", "cancelled", "error") } for project_id, session_id, execution_id in candidates: try: diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py index 3733c3982f0..addf7732cdf 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -19,6 +19,8 @@ from typing import Dict, List, Optional, Sequence, Set, Tuple from uuid import UUID, uuid4 +import pytest + from oss.src.core.sessions.records.dtos import ( RECORD_SETTLED_BY_ATTRIBUTE, SETTLED_BY_WATCHDOG, @@ -403,7 +405,8 @@ async def test_paused_or_quarantined_done_does_not_complete_a_continuation(monke assert _quarantined(service.records_dao)[-1].record_type == "done" -async def test_cancelled_done_arriving_first_leaves_stop_settlement_to_win(monkeypatch): +@pytest.mark.parametrize("stop_reason", ["cancelled", "error"]) +async def test_non_completing_done_does_not_claim_completion(monkeypatch, stop_reason): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) executions = _ExecutionSettlements() @@ -417,10 +420,13 @@ async def test_cancelled_done_arriving_first_leaves_stop_settlement_to_win(monke service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) await service.append_many( - events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + events=[_event("done", attributes={"type": "done", "stopReason": stop_reason})] ) assert executions.rows[(_SESSION, _TURN)].terminal_outcome is None + if stop_reason == "error": + return + result = await executions.settle( project_id=_PROJECT, session_id=_SESSION, diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index d889f1ceaf0..539218fecf4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone from types import SimpleNamespace +from unittest.mock import AsyncMock from uuid import uuid4 import pytest @@ -165,6 +166,81 @@ async def test_idle_input_executes_without_being_queued(monkeypatch): assert dao.items == [] +@pytest.mark.asyncio +async def test_detached_executing_continuation_keeps_input_in_the_queue(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + continuation_resumer = AsyncMock(return_value=True) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after the continuation"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "pending" + assert admitted.input is not None + continuation_resumer.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_parked_continuation_still_allows_input_to_execute(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + continuation_resumer = AsyncMock(return_value=False) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "steer the parked turn"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + assert dao.items == [] + + +@pytest.mark.asyncio +async def test_queue_flag_off_keeps_the_idle_path_without_a_continuation_probe( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", False) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + continuation_resumer = AsyncMock(return_value=True) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "old path"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + continuation_resumer.assert_not_awaited() + + @pytest.mark.asyncio async def test_queue_idempotency_returns_same_input_and_rejects_conflicting_reuse( monkeypatch, diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 77bd45e89c1..9e1ce473a84 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -194,8 +194,9 @@ async def publish(**kwargs): ) -async def test_cancelled_terminal_does_not_settle_continuation_as_completed( - monkeypatch, +@pytest.mark.parametrize("stop_reason", ["cancelled", "error"]) +async def test_non_completing_terminal_does_not_settle_continuation_as_completed( + monkeypatch, stop_reason ): monkeypatch.setattr( "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", @@ -213,7 +214,7 @@ async def test_cancelled_terminal_does_not_settle_continuation_as_completed( record_type="done", record_source="agent", turn_id="continuation-1", - attributes={"stopReason": "cancelled"}, + attributes={"stopReason": stop_reason}, ) with ( diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index b9ea3f01174..52bf0b9c317 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -2085,14 +2085,13 @@ export function createSandboxAgentOtel( // Mark a non-completing turn's terminal record so a cold reload can tell it from a real turn // boundary (the FE adoption heuristic and hydration read this). A completed turn omits it. // - // `cancelled` rides here for the same reason `paused` does, and closes a real gap: without - // it a stopped turn is indistinguishable from a finished one in Postgres, so neither the - // frontend nor the release gate can tell a Stop from a completion. Kept as an explicit - // allowlist rather than passing `stopReason` through, so a harness-reported value such as - // `end_turn` or `max_tokens` cannot start appearing on the terminal record by accident. + // These non-completing outcomes must remain distinguishable from a normal finish in Postgres. + // Keep an explicit allowlist so arbitrary harness reasons cannot leak into the record contract. record({ type: "done", - ...(stopReason === "paused" || stopReason === "cancelled" + ...(stopReason === "paused" || + stopReason === "cancelled" || + stopReason === "error" ? { stopReason } : {}), ...(runTraceId ? { traceId: runTraceId } : {}), diff --git a/services/runner/tests/unit/harness-cancel-park.test.ts b/services/runner/tests/unit/harness-cancel-park.test.ts index 8ccc1d8066b..69fd6b3bb9d 100644 --- a/services/runner/tests/unit/harness-cancel-park.test.ts +++ b/services/runner/tests/unit/harness-cancel-park.test.ts @@ -328,8 +328,12 @@ describe("the terminal done record", () => { assert.equal(doneRecordFor("paused").stopReason, "paused"); }); + it("carries an error so a failed turn cannot settle as a normal completion", () => { + assert.equal(doneRecordFor("error").stopReason, "error"); + }); + it("omits the field for a completed turn and for every harness-reported reason", () => { - // An explicit two-value allowlist, so `end_turn` / `max_tokens` / a future harness string + // An explicit allowlist, so `end_turn` / `max_tokens` / a future harness string // cannot start appearing on the terminal record by accident. assert.equal(doneRecordFor("end_turn").stopReason, undefined); assert.equal(doneRecordFor("max_tokens").stopReason, undefined); diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 0cf59709a5e..f99de070bad 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -207,6 +207,66 @@ export const useAgentChatQueue = ({ return message }, []) + // A message held before an approval answer predates the server-owned continuation. Move it + // under the same durable admission before that continuation can promote a different input. + const migrationRef = useRef(null) + const migrationRetryTimerRef = useRef | null>(null) + const [migrationRetry, setMigrationRetry] = useState(0) + useEffect( + () => () => { + if (migrationRetryTimerRef.current) clearTimeout(migrationRetryTimerRef.current) + }, + [], + ) + useEffect(() => { + const head = queued[0] + const submitToServer = server?.submit + if ( + !continuationExecutionId || + !continuationHold || + !server?.capabilities.queue || + !submitToServer || + !head || + migrationRef.current + ) { + return + } + + migrationRef.current = head.id + void submitToServer(head, "queue") + .then(() => { + if (sessionId) { + const stored = queuedBySession.get(sessionId) + if (stored) { + const remaining = stored.filter((item) => item.id !== head.id) + if (remaining.length > 0) queuedBySession.set(sessionId, remaining) + else queuedBySession.delete(sessionId) + } + } + setQueued((items) => items.filter((item) => item.id !== head.id)) + }) + .catch(() => { + if (migrationRef.current !== head.id) return + migrationRef.current = null + migrationRetryTimerRef.current = setTimeout(() => { + migrationRetryTimerRef.current = null + migrationRef.current = null + setMigrationRetry((attempt) => attempt + 1) + }, 2_000) + }) + .finally(() => { + if (migrationRef.current === head.id) migrationRef.current = null + }) + }, [ + continuationExecutionId, + continuationHold, + migrationRetry, + queued, + sessionId, + server?.capabilities.queue, + server?.submit, + ]) + // Send now only if idle, unlatched, and the queue is empty; otherwise append (FIFO). const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { @@ -348,7 +408,7 @@ export const useAgentChatQueue = ({ releasingRef.current = false return } - if (releasingRef.current || queued.length === 0) return + if (releasingRef.current || migrationRef.current || queued.length === 0) return if (!canReleaseNow) return releasingRef.current = true const [head, ...rest] = queued diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 5e9a2594200..c151790973c 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -3,10 +3,7 @@ import {act, renderHook} from "@testing-library/react" import type {FileUIPart, UIMessage} from "ai" import {describe, expect, it, vi} from "vitest" -import { - useAgentChatQueue, - type ServerQueueAdapter, -} from "../../../src/hooks/useAgentChatQueue" +import {useAgentChatQueue, type ServerQueueAdapter} from "../../../src/hooks/useAgentChatQueue" // The pure release predicates (`canReleaseQueuedMessage`, `isHitlPending`) are unit-tested in // the playground package; these tests cover the HOOK's stateful behavior on top of them: @@ -65,6 +62,7 @@ interface HarnessProps { acceptedRunPending?: boolean resumeOrphaned?: boolean recoverable?: boolean + continuationExecutionId?: string | null sessionId?: string server?: ServerQueueAdapter } @@ -162,6 +160,96 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("moves a client-held input behind the durable queue when its continuation starts", async () => { + const durable = { + id: "already-queued", + text: "server first", + source: "server" as const, + editable: false, + } + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [durable], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + server, + } + const {result, rerender, sendQueued} = setup(paused) + + act(() => result.current.submit({text: "held by this tab"})) + expect(server.submit).not.toHaveBeenCalled() + + await act(async () => { + rerender({ + ...paused, + continuationExecutionId: "continuation-1", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + await Promise.resolve() + }) + + expect(server.submit).toHaveBeenCalledOnce() + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "held by this tab"}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toEqual([durable]) + }) + + it("does not release a held input while its durable admission is still in flight", async () => { + let acceptAdmission: (() => void) | undefined + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [], + submit: vi.fn( + () => + new Promise((resolve) => { + acceptAdmission = resolve + }), + ), + remove: vi.fn().mockResolvedValue(undefined), + } + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + server, + } + const {result, rerender, sendQueued} = setup(paused) + + act(() => result.current.submit({text: "held by this tab"})) + rerender({ + ...paused, + continuationExecutionId: "a1-continuation-execution", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + expect(server.submit).toHaveBeenCalledOnce() + + rerender({ + ...paused, + continuationExecutionId: "a1-continuation-execution", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + + await act(async () => { + acceptAdmission?.() + await Promise.resolve() + }) + + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(0) + }) + it("renders and removes server rows without releasing them through the local queue", () => { const durable = { id: "input-1", diff --git a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts index 774d1fee098..d830443c250 100644 --- a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts @@ -15,7 +15,10 @@ vi.mock("@agenta/sdk/resources", () => ({ getLowPriorityMountsClient: vi.fn(), })) -import {fetchSessionSnapshot as readSnapshot, removePendingSessionInput} from "../../src/session/api/api" +import { + fetchSessionSnapshot as readSnapshot, + removePendingSessionInput, +} from "../../src/session/api/api" beforeEach(() => { fetchSnapshot.mockReset() From ab75d64c3ebc036b2383281a5e109abb0ff4c0f9 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 02:51:39 +0200 Subject: [PATCH 065/133] fix(sessions): exclude failed runner terminals Align watchdog completion reconciliation with synchronous record ingest so paused, cancelled, and error terminals cannot promote queued work. Cover each non-success stop reason against the tracing database. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/records/interfaces.py | 2 +- .../src/dbs/postgres/sessions/records/dao.py | 5 ++-- .../test_late_record_quarantine_dao.py | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/api/oss/src/core/sessions/records/interfaces.py b/api/oss/src/core/sessions/records/interfaces.py index 08443be6355..a80490907d6 100644 --- a/api/oss/src/core/sessions/records/interfaces.py +++ b/api/oss/src/core/sessions/records/interfaces.py @@ -99,5 +99,5 @@ async def runner_completed_turns( project_id: UUID, keys: Sequence[Tuple[str, str]], ) -> Set[Tuple[str, str]]: - """Turns with an effective, non-paused runner terminal record.""" + """Turns with an effective, successful runner terminal record.""" raise NotImplementedError diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index 8767d09fac8..5c8792773d7 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -544,8 +544,9 @@ async def runner_completed_turns( "", ) != SETTLED_BY_WATCHDOG, - func.coalesce(RecordDBE.attributes["stopReason"].astext, "") - != "paused", + func.coalesce(RecordDBE.attributes["stopReason"].astext, "").notin_( + ("paused", "cancelled", "error") + ), tuple_(RecordDBE.session_id, RecordDBE.turn_id).in_(keys), ) .distinct() diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py index 2d90c28626d..86eaa85ba68 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py @@ -158,6 +158,29 @@ async def test_settled_by_narrows_the_answer_to_one_writer(): ) == {(session_id, watchdog_turn)} +@pytest.mark.parametrize("stop_reason", ["paused", "cancelled", "error"]) +async def test_runner_completion_excludes_non_success_terminal_reasons(stop_reason): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event( + project_id, + session_id, + turn_id, + "done", + attributes={"type": "done", "stopReason": stop_reason}, + ) + ] + ) + + assert await dao.runner_completed_turns( + project_id=project_id, keys=[(session_id, turn_id)] + ) == set() + + async def test_a_redelivery_keeps_the_first_quarantine_instant(): project_id, session_id = _ids() turn_id = f"turn-{uuid.uuid4().hex[:8]}" From f978137d545d69579cd5e3b7bc712769457a33cc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 02:52:32 +0200 Subject: [PATCH 066/133] fix(sessions): deduplicate idle input retries Consult durable input idempotency before observing execution state so a completed queued request cannot execute again while idle. Return the original promoted execution and reject conflicting reuse on the same path. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/sessions/inputs/service.py | 17 ++++++++++- .../sessions/test_pending_inputs_service.py | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 00ed8c2a2da..e6128386ff4 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -52,6 +52,22 @@ async def admit( policy: str, idempotency_key: Optional[str], ) -> PendingInputAdmission: + fingerprint = input_fingerprint(content=content, policy=policy) + if idempotency_key: + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=session_id, + idempotency_key=idempotency_key, + ) + if existing is not None: + if existing.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + return PendingInputAdmission( + action="pending", + input=existing, + execution_id=existing.promoted_execution_id, + ) + stream = await self._streams.fetch_header( project_id=project_id, session_id=session_id ) @@ -78,7 +94,6 @@ async def admit( if not idempotency_key: raise ValueError("Idempotency-Key is required when queueing input.") - fingerprint = input_fingerprint(content=content, policy=policy) async with self._dao.transaction() as transaction: existing = await self._dao.fetch_by_idempotency_key( project_id=project_id, diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index 539218fecf4..9a4106242b5 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -267,6 +267,36 @@ async def test_queue_idempotency_returns_same_input_and_rejects_conflicting_reus await service.admit(**{**kwargs, "content": {"message": "different"}}) +@pytest.mark.asyncio +async def test_idle_retry_returns_the_existing_promoted_input(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + dao = MemoryInputsDAO() + streams = Streams() + service = SessionInputsService(inputs_dao=dao, streams_service=streams) + kwargs = { + "project_id": project_id, + "user_id": uuid4(), + "session_id": "session-1", + "content": {"message": "once"}, + "policy": "queue", + "idempotency_key": "key-1", + } + + first = await service.admit(**kwargs) + first.input.state = PendingInputState.promoted + first.input.promoted_execution_id = "execution-2" + streams.running = False + + retry = await service.admit(**kwargs) + + assert retry.action == "pending" + assert retry.input.id == first.input.id + assert retry.execution_id == "execution-2" + with pytest.raises(SessionInputIdempotencyConflict): + await service.admit(**{**kwargs, "content": {"message": "different"}}) + + @pytest.mark.asyncio async def test_steer_is_saved_ahead_of_queued_input(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", True) From cdc831dd74dcf4d42b35c92c676f68f8dd56efdb Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 02:54:51 +0200 Subject: [PATCH 067/133] fix(sessions): serialize input admission with settlement Lock the observed execution before durable input admission so completion settlement and queue insertion share one serialization point. Recheck the locked terminal outcome and execute directly when settlement won the race. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/entrypoints/routers.py | 1 + api/oss/src/core/sessions/inputs/service.py | 13 +++++ .../unit/sessions/test_session_inputs_dao.py | 58 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index a61b7960f53..056c73d2566 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1184,6 +1184,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - session_inputs_service = SessionInputsService( inputs_dao=session_inputs_dao, streams_service=session_streams_service, + executions_dao=session_executions_dao, continuation_resumer=session_commands_service.resume_recoverable_continuation, ) workflows_service.set_session_continuation_resumer( diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index e6128386ff4..319b80cbd15 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -16,6 +16,7 @@ SessionInputNotFound, SessionInputNotRemovable, ) +from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.utils.env import env @@ -36,10 +37,12 @@ def __init__( *, inputs_dao: SessionInputsDAOInterface, streams_service: SessionStreamsService, + executions_dao: Optional[SessionExecutionsDAOInterface] = None, continuation_resumer: Optional[Callable[..., Awaitable[bool]]] = None, ) -> None: self._dao = inputs_dao self._streams = streams_service + self._executions = executions_dao self._continuation_resumer = continuation_resumer async def admit( @@ -95,6 +98,14 @@ async def admit( raise ValueError("Idempotency-Key is required when queueing input.") async with self._dao.transaction() as transaction: + source_execution = None + if self._executions is not None and current_execution_id is not None: + source_execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=current_execution_id, + transaction=transaction, + ) existing = await self._dao.fetch_by_idempotency_key( project_id=project_id, session_id=session_id, @@ -109,6 +120,8 @@ async def admit( input=existing, execution_id=current_execution_id, ) + if source_execution is not None and source_execution.terminal_outcome is not None: + return PendingInputAdmission(action="execute") item = await self._dao.create_input( user_id=user_id, pending_input=PendingInputCreate( diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 2b51dfb5529..2577d9253c1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -161,6 +161,17 @@ async def fetch_header(self, **_kwargs): ) +class _SettlementRaceStreams(_BusyStreams): + def __init__(self): + self.observed_busy = asyncio.Event() + self.allow_admission = asyncio.Event() + + async def fetch_header(self, **_kwargs): + self.observed_busy.set() + await self.allow_admission.wait() + return await super().fetch_header() + + class _UnreachableDelivery: async def deliver(self, **_kwargs): return DeliveryReceipt(status="unreachable") @@ -246,6 +257,53 @@ async def test_completion_promotes_one_fifo_input_in_the_settlement_transaction( ] == [second.id] +async def test_admission_rechecks_settlement_under_the_execution_lock( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + streams = _SettlementRaceStreams() + admission_service = SessionInputsService( + inputs_dao=inputs, + streams_service=streams, + executions_dao=executions, + ) + settlement_service = _settlement_service(input_scope, inputs) + async with input_scope["engine"].session() as transaction: + await executions.lock_for_control( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + transaction=transaction, + ) + + admission_task = asyncio.create_task( + admission_service.admit( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + content={"message": "arrived during settlement"}, + policy="queue", + idempotency_key="settlement-race", + ) + ) + await streams.observed_busy.wait() + + assert await settlement_service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + streams.allow_admission.set() + admission = await admission_task + + assert admission.action == "execute" + assert await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) == [] + + async def test_manual_stop_commits_without_promoting_pending_input( input_scope, monkeypatch ): From 8dd945232088afb01cebd2177ffd465c536e9c5b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 02:57:07 +0200 Subject: [PATCH 068/133] fix(chat): route queue sends through durable admission Send every Queue-capable composer submission through server admission regardless of the client snapshot busy flag. Keep transient failures in the local queue and cover stale-idle multi-tab admission. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/hooks/useAgentChatQueue.ts | 16 +++---- .../unit/hooks/useAgentChatQueue.test.ts | 45 ++++++++++++++++--- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index f99de070bad..0b12d39fd05 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -271,6 +271,14 @@ export const useAgentChatQueue = ({ const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const message: QueuedMessage = {...item, id: generateId()} + if (server?.capabilities.queue) { + void server.submit(message, "queue").catch(() => { + // The server did not accept ownership. Preserve the input in the original + // page-session queue so a transient admission failure never clears user work. + setQueued((q) => [...q, message]) + }) + return + } if (recoverable && retryContinuation) { setQueued((q) => [...q, message]) if (!retryingContinuationRef.current) { @@ -283,14 +291,6 @@ export const useAgentChatQueue = ({ } return } - if (server?.capabilities.queue && server.busy) { - void server.submit(message, "queue").catch(() => { - // The server did not accept ownership. Preserve the input in the original - // page-session queue so a transient admission failure never clears user work. - setQueued((q) => [...q, message]) - }) - return - } if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { releasingRef.current = true lastSentRef.current = message diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index c151790973c..4983684072c 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -160,6 +160,27 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("lets the server admit Queue-capable sends from a stale-idle snapshot", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + result.current.submit({text: "server decides"}) + }) + + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "server decides"}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + }) + it("moves a client-held input behind the durable queue when its continuation starts", async () => { const durable = { id: "already-queued", @@ -171,7 +192,10 @@ describe("useAgentChatQueue", () => { capabilities: {queue: true, steer: true}, busy: false, queued: [durable], - submit: vi.fn().mockResolvedValue(undefined), + submit: vi + .fn() + .mockRejectedValueOnce(new Error("admission unavailable")) + .mockResolvedValueOnce(undefined), remove: vi.fn().mockResolvedValue(undefined), } const paused: HarnessProps = { @@ -182,8 +206,15 @@ describe("useAgentChatQueue", () => { } const {result, rerender, sendQueued} = setup(paused) - act(() => result.current.submit({text: "held by this tab"})) - expect(server.submit).not.toHaveBeenCalled() + await act(async () => { + result.current.submit({text: "held by this tab"}) + await Promise.resolve() + }) + expect(server.submit).toHaveBeenCalledOnce() + expect(result.current.queued).toEqual([ + durable, + expect.objectContaining({text: "held by this tab"}), + ]) await act(async () => { rerender({ @@ -194,8 +225,8 @@ describe("useAgentChatQueue", () => { await Promise.resolve() }) - expect(server.submit).toHaveBeenCalledOnce() - expect(server.submit).toHaveBeenCalledWith( + expect(server.submit).toHaveBeenCalledTimes(2) + expect(server.submit).toHaveBeenLastCalledWith( expect.objectContaining({text: "held by this tab"}), "queue", ) @@ -203,7 +234,7 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toEqual([durable]) }) - it("does not release a held input while its durable admission is still in flight", async () => { + it("does not release an input locally while durable admission is still in flight", async () => { let acceptAdmission: (() => void) | undefined const server: ServerQueueAdapter = { capabilities: {queue: true, steer: true}, @@ -239,7 +270,7 @@ describe("useAgentChatQueue", () => { messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], }) expect(sendQueued).not.toHaveBeenCalled() - expect(result.current.queued).toHaveLength(1) + expect(result.current.queued).toHaveLength(0) await act(async () => { acceptAdmission?.() From 4c47b6a533eba47698fe1343b639bf8b79b4eba1 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:00:01 +0200 Subject: [PATCH 069/133] fix(sessions): bind steer to collapsed stop Attach the first Steer input to the open Stop command that wins command collapse, including concurrent insert collisions. Preserve existing command data and verify the bound input is the one promoted by settlement. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/commands/interfaces.py | 11 +++++ api/oss/src/core/sessions/commands/service.py | 24 +++++++++- .../src/dbs/postgres/sessions/commands/dao.py | 48 ++++++++++++++++++- .../sessions/test_session_cancel_admission.py | 45 ++++++++++++++++- .../unit/sessions/test_session_inputs_dao.py | 17 +++++-- 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index a4fb8ecb8bf..1ec0268bb08 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -122,6 +122,17 @@ async def fetch_open_command( """The open (`pending` or `claimed`) command for this exact target, if one exists. This is what collapses two Stops in a row onto one command.""" + async def bind_steer_input( + self, + *, + project_id: UUID, + command_id: UUID, + input_id: UUID, + transaction: Optional[Any] = None, + ) -> SessionCommand: + """Bind the first Steer input to an open Stop command.""" + raise NotImplementedError + async def fetch_resumable_continuation( self, *, diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 0feda3fcb0e..d14fcef6320 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -313,6 +313,12 @@ async def request_cancel( target_turn_id=target_turn_id, ) if open_command is not None: + if steer_input_id is not None: + open_command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=open_command.id, + input_id=steer_input_id, + ) if open_command.state == SessionCommandState.pending: await self._deliver(open_command) return CancelAdmission( @@ -366,7 +372,16 @@ async def request_cancel( transaction=transaction, ) if open_command is not None: - command = open_command + command = ( + await self._dao.bind_steer_input( + project_id=project_id, + command_id=open_command.id, + input_id=steer_input_id, + transaction=transaction, + ) + if steer_input_id is not None + else open_command + ) else: await self._executions.set_state( project_id=project_id, @@ -394,6 +409,13 @@ async def request_cancel( transaction=transaction, ) command = created.command + if steer_input_id is not None: + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=command.id, + input_id=steer_input_id, + transaction=transaction, + ) cancelled_interactions = ( await self._interactions.cancel_session_pending( project_id=project_id, diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index bad66f29662..33ea8677b74 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -10,7 +10,8 @@ from typing import Any, Dict, List, Optional from uuid import UUID -from sqlalchemy import and_, func, or_, select, update as sa_update +from sqlalchemy import and_, cast, func, or_, select, update as sa_update +from sqlalchemy.dialects.postgresql import JSON, JSONB from sqlalchemy.exc import IntegrityError from oss.src.utils.logging import get_module_logger @@ -253,6 +254,51 @@ async def execute(session: Any) -> Optional[SessionCommand]: async with self.engine.session() as session: return await execute(session) + async def bind_steer_input( + self, + *, + project_id: UUID, + command_id: UUID, + input_id: UUID, + transaction: Optional[Any] = None, + ) -> SessionCommand: + async def execute(session: Any) -> SessionCommand: + row = ( + await session.execute( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionCommandDBE.data["steer_input_id"].astext.is_(None), + ) + .values( + data=cast( + func.coalesce( + cast(SessionCommandDBE.data, JSONB), cast({}, JSONB) + ).op("||")(cast({"steer_input_id": str(input_id)}, JSONB)), + JSON, + ) + ) + .returning(SessionCommandDBE) + ) + ).scalar_one_or_none() + if row is None: + row = ( + await session.execute( + select(SessionCommandDBE).where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + ) + ) + ).scalar_one() + return map_command_dbe_to_dto(row) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def fetch_resumable_continuation( self, *, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py index ac03638be9a..1ff40cc025e 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -134,7 +134,9 @@ async def fetch_by_idempotency_key( return row return None - async def fetch_open_command(self, *, project_id, session_id, kind, target_turn_id): + async def fetch_open_command( + self, *, project_id, session_id, kind, target_turn_id, transaction=None + ): for row in reversed(self.rows): if ( row.project_id == project_id @@ -147,6 +149,19 @@ async def fetch_open_command(self, *, project_id, session_id, kind, target_turn_ return row return None + async def bind_steer_input( + self, *, project_id, command_id, input_id, transaction=None + ): + for index, row in enumerate(self.rows): + if row.project_id != project_id or row.id != command_id: + continue + data = dict(row.data or {}) + data.setdefault("steer_input_id", str(input_id)) + bound = row.model_copy(update={"data": data}) + self.rows[index] = bound + return bound + raise AssertionError("command to bind was not found") + async def fetch_command(self, *, command_id, project_id=None): for row in self.rows: if row.id == command_id: @@ -890,6 +905,34 @@ async def test_reused_idempotency_key_rejects_a_different_expected_execution( assert len(delivery.delivered) == 1 +@pytest.mark.asyncio +async def test_steer_binds_to_an_already_open_stop(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + first = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + steer_input_id = uuid4() + + steered = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + steer_input_id=steer_input_id, + ) + + assert steered.command.id == first.command.id + assert steered.command.data == {"steer_input_id": str(steer_input_id)} + assert len(dao.rows) == 1 + + @pytest.mark.asyncio async def test_a_reachable_runner_that_does_not_hold_the_session_settles_at_once( lock_engine, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 2577d9253c1..8d198ac83d5 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -299,9 +299,12 @@ async def test_admission_rechecks_settlement_under_the_execution_lock( admission = await admission_task assert admission.action == "execute" - assert await inputs.list_pending( - project_id=input_scope["project_id"], session_id=input_scope["session_id"] - ) == [] + assert ( + await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + == [] + ) async def test_manual_stop_commits_without_promoting_pending_input( @@ -500,9 +503,13 @@ async def test_steer_stop_promotes_only_the_bound_input(input_scope, monkeypatch ), prioritize=True, ) - command = await _pending_command( - input_scope, data={"steer_input_id": str(steer.id)} + command = await _pending_command(input_scope) + command = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=steer.id, ) + assert command.data == {"steer_input_id": str(steer.id)} service = _settlement_service(input_scope, inputs) settled = await service.settle( From 72611c85e2fb4536d595590d1275fa3e74101431 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:02:04 +0200 Subject: [PATCH 070/133] fix(sessions): expose recoverable promoted inputs Include promoted inputs while their continuation is pending delivery or recoverable so snapshots do not hide accepted work after delivery failure. Remove them from the queue view once the continuation reaches running. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/dbs/postgres/sessions/inputs/dao.py | 24 +++++++++++++++++-- .../unit/sessions/test_session_inputs_dao.py | 20 ++++++++++++++++ .../agenta-chat/src/assets/pendingInputs.ts | 2 +- .../tests/unit/assets/pendingInputs.test.ts | 21 ++++++++++++++-- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index 7f2f008344e..78508a590ed 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -2,7 +2,7 @@ from typing import Any, List, Optional from uuid import UUID -from sqlalchemy import func, select, text, update as sa_update +from sqlalchemy import and_, func, or_, select, text, update as sa_update from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface @@ -11,6 +11,7 @@ new_input_row, to_pending_input, ) +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE from oss.src.dbs.postgres.shared.engine import ( TransactionsEngine, get_transactions_engine, @@ -120,10 +121,29 @@ async def list_pending( rows = ( await session.execute( select(SessionInputDBE) + .outerjoin( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id + == SessionInputDBE.project_id, + SessionExecutionDBE.session_id + == SessionInputDBE.session_id, + SessionExecutionDBE.execution_id + == SessionInputDBE.promoted_execution_id, + ), + ) .where( SessionInputDBE.project_id == project_id, SessionInputDBE.session_id == session_id, - SessionInputDBE.state == "pending", + or_( + SessionInputDBE.state == "pending", + and_( + SessionInputDBE.state == "promoted", + SessionExecutionDBE.state.in_( + ("pending_delivery", "recoverable") + ), + ), + ), ) .order_by(SessionInputDBE.position, SessionInputDBE.created_at) ) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 8d198ac83d5..ebcc95ba782 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -249,6 +249,26 @@ async def test_completion_promotes_one_fifo_input_in_the_settlement_transaction( input_id=first.id, ) ).state == PendingInputState.promoted + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + ] == [first.id, second.id] + + await SessionExecutionsDAO(engine=input_scope["engine"]).set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + ) + ).promoted_execution_id, + state=SessionExecutionState.running, + expected_states=[SessionExecutionState.recoverable], + ) assert [ item.id for item in await inputs.list_pending( diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts index 9f52517bb83..f9a8f30ac40 100644 --- a/web/packages/agenta-chat/src/assets/pendingInputs.ts +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -89,7 +89,7 @@ export const reduceSessionPendingInputs = ( }, executionState: snapshot?.execution.state ?? "idle", queued: (snapshot?.pending.inputs ?? []) - .filter((input) => input.state === "pending") + .filter((input) => input.state === "pending" || input.state === "promoted") .sort((left, right) => left.position - right.position) .map(pendingInputToQueuedMessage) .filter((input): input is QueuedMessage => input !== null), diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts index b63ffdd027c..4468a61fac6 100644 --- a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -10,15 +10,16 @@ const input = ( position: number, content: unknown, policy: "queue" | "steer" = "queue", + state: "pending" | "promoted" = "pending", ) => ({ id, session_id: "session-1", content: {data: {inputs: {messages: [{role: "user", content}]}}}, position, - state: "pending" as const, + state, policy, created_at: null, - promoted_execution_id: null, + promoted_execution_id: state === "promoted" ? "continuation-1" : null, }) describe("pending input reducer", () => { @@ -68,6 +69,22 @@ describe("pending input reducer", () => { ]) }) + it("keeps a promoted input visible while its continuation is recoverable", () => { + const recoverable = input("input-1", 1, "retry me", "queue", "promoted") + + const view = reduceSessionPendingInputs({ + session: null, + execution: {id: null, state: "idle"}, + pending: {inputs: [recoverable], interactions: []}, + read: {latest_sequence: 0, history_complete: true}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + + expect(view.queued).toEqual([ + expect.objectContaining({id: "input-1", text: "retry me", source: "server"}), + ]) + }) + it("defaults an absent or failed snapshot to the legacy client queue", () => { expect(reduceSessionPendingInputs(null)).toEqual({ capabilities: {queue: false, steer: false}, From 8238f7d93f23f292cfc6d95300544af57e90635e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:03:26 +0200 Subject: [PATCH 071/133] fix(sessions): guard missing input delivery receipts Treat a missing delivery reservation receipt as a recoverable continuation failure after normal completion and Steer settlement. Cover both paths when another worker moved the continuation command first. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/sessions/commands/service.py | 8 +-- ...test_interaction_continuation_admission.py | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index d14fcef6320..eefe52d9125 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -1526,8 +1526,8 @@ async def settle_execution_completed( if admission is not None: receipt = await self._deliver(admission.command) - if receipt.status != "accepted": - await self._mark_continuation_recoverable(admission) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(admission, receipt) admission.execution_state = SessionExecutionState.recoverable return result.won or result.settlement.terminal_outcome is not None @@ -1939,8 +1939,8 @@ async def settle( ) if input_admission is not None: receipt = await self._deliver(input_admission.command) - if receipt.status != "accepted": - await self._mark_continuation_recoverable(input_admission) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(input_admission, receipt) return settled diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index 5328accd433..c084c1b95e0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -1102,6 +1102,35 @@ async def test_completion_promotes_exactly_one_pending_input_once(monkeypatch): assert len(delivery.delivered) == 1 +@pytest.mark.asyncio +async def test_completion_handles_a_lost_input_delivery_reservation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs([_pending_input(project_id, policy="queue", position=1)]) + commands = _Commands() + commands.record_delivery_attempt = AsyncMock(return_value=None) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + inputs_dao=items, + ) + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[0].state == PendingInputState.promoted + assert executions.continuation.state == SessionExecutionState.recoverable + + @pytest.mark.asyncio async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) @@ -1377,3 +1406,32 @@ async def test_steer_stop_promotes_its_saved_input_before_queue(monkeypatch): assert inputs.items[1].state == PendingInputState.promoted assert commands.command.kind == SessionCommandKind.continue_input assert commands.command.data["input_id"] == str(steered.id) + + +@pytest.mark.asyncio +async def test_steer_handles_a_lost_input_delivery_reservation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([steered]) + service, commands = _stop_service( + project_id=project_id, + command_data={"steer_input_id": str(steered.id)}, + inputs=inputs, + ) + commands.record_delivery_attempt = AsyncMock(return_value=None) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.promoted + assert service._executions.continuation.state == SessionExecutionState.recoverable From 6b9d4a35bc0f0684232e9af4ed932db1dc872fb4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:06:05 +0200 Subject: [PATCH 072/133] refactor(sessions): defer replay snapshot projection Remove the unused constant read projection from the session snapshot contract and generated TypeScript client. Keep the durable queue snapshot focused on execution, pending work, and capabilities until replay semantics are implemented. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/models.py | 6 ------ api/oss/src/apis/fastapi/sessions/router.py | 2 -- api/oss/src/core/sessions/inputs/service.py | 5 ++++- .../unit/sessions/test_late_record_quarantine_dao.py | 9 ++++++--- .../src/generated/api/types/SessionReadSnapshot.ts | 6 ------ .../src/generated/api/types/SessionSnapshotResponse.ts | 1 - .../agenta-api-client/src/generated/api/types/index.ts | 1 - .../agenta-chat/tests/unit/assets/pendingInputs.test.ts | 2 -- web/packages/agenta-entities/src/session/core/schema.ts | 6 ------ .../tests/unit/session-pending-input-api.test.ts | 1 - 10 files changed, 10 insertions(+), 29 deletions(-) delete mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index c430ecd07c3..b48fd561f64 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -139,18 +139,12 @@ class SessionPendingSnapshot(BaseModel): interactions: List[SessionInteraction] = Field(default_factory=list) -class SessionReadSnapshot(BaseModel): - latest_sequence: int = 0 - history_complete: bool = True - - class SessionSnapshotResponse(BaseModel): session: Optional[SessionStream] = None execution: SessionExecutionSnapshot = Field( default_factory=SessionExecutionSnapshot ) pending: SessionPendingSnapshot = Field(default_factory=SessionPendingSnapshot) - read: SessionReadSnapshot = Field(default_factory=SessionReadSnapshot) capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index f610e32b14f..a4425c10c37 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -208,7 +208,6 @@ SessionCapabilities, SessionExecutionSnapshot, SessionPendingSnapshot, - SessionReadSnapshot, SessionSnapshotResponse, ) from oss.src.apis.fastapi.sessions.utils import ( @@ -2321,7 +2320,6 @@ async def fetch_session_snapshot( session=stream, execution=SessionExecutionSnapshot(id=execution_id, state=state), pending=SessionPendingSnapshot(inputs=inputs, interactions=interactions), - read=SessionReadSnapshot(), capabilities=SessionCapabilities( durable_approvals=env.agenta.sessions.durable_approvals, queue=env.agenta.sessions.queue, diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 319b80cbd15..21ceb95c54e 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -120,7 +120,10 @@ async def admit( input=existing, execution_id=current_execution_id, ) - if source_execution is not None and source_execution.terminal_outcome is not None: + if ( + source_execution is not None + and source_execution.terminal_outcome is not None + ): return PendingInputAdmission(action="execute") item = await self._dao.create_input( user_id=user_id, diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py index 86eaa85ba68..9b8a43aeefd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py @@ -176,9 +176,12 @@ async def test_runner_completion_excludes_non_success_terminal_reasons(stop_reas ] ) - assert await dao.runner_completed_turns( - project_id=project_id, keys=[(session_id, turn_id)] - ) == set() + assert ( + await dao.runner_completed_turns( + project_id=project_id, keys=[(session_id, turn_id)] + ) + == set() + ) async def test_a_redelivery_keeps_the_first_quarantine_instant(): diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts deleted file mode 100644 index 76d449941db..00000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionReadSnapshot.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -export interface SessionReadSnapshot { - latest_sequence?: number | undefined; - history_complete?: boolean | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts index 9477163d9b4..6655c2b2167 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -6,6 +6,5 @@ export interface SessionSnapshotResponse { session?: (AgentaApi.SessionStream | null) | undefined; execution?: AgentaApi.SessionExecutionSnapshot | undefined; pending?: AgentaApi.SessionPendingSnapshot | undefined; - read?: AgentaApi.SessionReadSnapshot | undefined; capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 56625309c86..2a1fbb5aa30 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -416,7 +416,6 @@ export * from "./SessionSnapshotPending.js"; export * from "./SessionSnapshotResponse.js"; export * from "./SessionReference.js"; export * from "./SessionResponse.js"; -export * from "./SessionReadSnapshot.js"; export * from "./SessionStream.js"; export * from "./SessionStreamCommandResponse.js"; export * from "./SessionStreamFlags.js"; diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts index 4468a61fac6..c4e22b8dc78 100644 --- a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -31,7 +31,6 @@ describe("pending input reducer", () => { inputs: [input("older", 20, "queued"), input("steer", 10, "redirect", "steer")], interactions: [], }, - read: {latest_sequence: 0, history_complete: true}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) @@ -76,7 +75,6 @@ describe("pending input reducer", () => { session: null, execution: {id: null, state: "idle"}, pending: {inputs: [recoverable], interactions: []}, - read: {latest_sequence: 0, history_complete: true}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 04f4e7423f2..010c3dfdca5 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -314,12 +314,6 @@ export const sessionSnapshotResponseSchema = z.object({ interactions: z.array(sessionInteractionSchema).default([]), }) .default({inputs: [], interactions: []}), - read: z - .object({ - latest_sequence: z.number().default(0), - history_complete: z.boolean().default(true), - }) - .default({latest_sequence: 0, history_complete: true}), capabilities: z .object({ durable_approvals: z.boolean().optional().default(false), diff --git a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts index d830443c250..45778987a99 100644 --- a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts @@ -31,7 +31,6 @@ describe("session pending-input API", () => { session: null, execution: {state: "running"}, pending: {inputs: [], interactions: []}, - read: {latest_sequence: 0, history_complete: true}, capabilities: {queue: true, steer: false}, }) From 3e2c82b295dc680000591e59d515d6a6b029213c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:38:49 +0200 Subject: [PATCH 073/133] fix(sessions): queue behind promoted continuations Recheck pending inputs after a source execution settles so admission preserves the successor selected by settlement. Queue the new input behind that continuation and report its execution id. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/inputs/interfaces.py | 6 +- api/oss/src/core/sessions/inputs/service.py | 22 ++++++- .../src/dbs/postgres/sessions/inputs/dao.py | 13 +++- .../unit/sessions/test_session_inputs_dao.py | 62 +++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index 25228064074..74bb97f2c50 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -34,7 +34,11 @@ async def fetch_by_idempotency_key( @abstractmethod async def list_pending( - self, *, project_id: UUID, session_id: str + self, + *, + project_id: UUID, + session_id: str, + transaction: Optional[Any] = None, ) -> List[PendingInput]: pass diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 21ceb95c54e..6956c2d4405 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -99,6 +99,7 @@ async def admit( async with self._dao.transaction() as transaction: source_execution = None + successor_execution_id = None if self._executions is not None and current_execution_id is not None: source_execution = await self._executions.lock_for_control( project_id=project_id, @@ -124,7 +125,22 @@ async def admit( source_execution is not None and source_execution.terminal_outcome is not None ): - return PendingInputAdmission(action="execute") + pending = await self._dao.list_pending( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + successor_execution_id = next( + ( + item.promoted_execution_id + for item in pending + if item.state == PendingInputState.promoted + and item.promoted_execution_id is not None + ), + None, + ) + if successor_execution_id is None: + return PendingInputAdmission(action="execute") item = await self._dao.create_input( user_id=user_id, pending_input=PendingInputCreate( @@ -143,7 +159,9 @@ async def admit( if item.request_fingerprint != fingerprint: raise SessionInputIdempotencyConflict() return PendingInputAdmission( - action="pending", input=item, execution_id=current_execution_id + action="pending", + input=item, + execution_id=successor_execution_id or current_execution_id, ) async def list_pending( diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index 78508a590ed..2b049d9560f 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -115,9 +115,13 @@ async def execute(session: Any) -> Optional[PendingInput]: return await execute(session) async def list_pending( - self, *, project_id: UUID, session_id: str + self, + *, + project_id: UUID, + session_id: str, + transaction: Optional[Any] = None, ) -> List[PendingInput]: - async with self.engine.session() as session: + async def execute(session: Any) -> List[PendingInput]: rows = ( await session.execute( select(SessionInputDBE) @@ -150,6 +154,11 @@ async def list_pending( ).scalars() return [to_pending_input(row) for row in rows] + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def fetch_input( self, *, project_id: UUID, session_id: str, input_id: UUID ) -> Optional[PendingInput]: diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index ebcc95ba782..b5211fd754e 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -327,6 +327,68 @@ async def test_admission_rechecks_settlement_under_the_execution_lock( ) +async def test_admission_queues_behind_input_promoted_by_settlement( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + older = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + streams = _SettlementRaceStreams() + admission_service = SessionInputsService( + inputs_dao=inputs, + streams_service=streams, + executions_dao=executions, + ) + settlement_service = _settlement_service(input_scope, inputs) + async with input_scope["engine"].session() as transaction: + await executions.lock_for_control( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + transaction=transaction, + ) + + admission_task = asyncio.create_task( + admission_service.admit( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + content={"message": "arrived during settlement"}, + policy="queue", + idempotency_key="settlement-race", + ) + ) + await streams.observed_busy.wait() + + assert await settlement_service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + streams.allow_admission.set() + admission = await admission_task + + promoted = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=older.id, + ) + assert promoted.state == PendingInputState.promoted + assert admission.action == "pending" + assert admission.execution_id == promoted.promoted_execution_id + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + ] == [older.id, admission.input.id] + + async def test_manual_stop_commits_without_promoting_pending_input( input_scope, monkeypatch ): From b5b30e812056650e8e8650ea0cb6c3d7b88312e9 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:38:49 +0200 Subject: [PATCH 074/133] fix(sessions): align steer settlement locks Serialize Steer binding and Stop settlement by locking the execution before reading or updating the command. Re-read the command inside settlement so a winning bind is promoted. Cover both bind-first and settlement-first interleavings against Postgres. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/commands/interfaces.py | 1 + api/oss/src/core/sessions/commands/service.py | 98 ++++----- .../src/dbs/postgres/sessions/commands/dao.py | 10 +- .../sessions/test_session_cancel_admission.py | 2 +- .../unit/sessions/test_session_inputs_dao.py | 195 +++++++++++++++++- 5 files changed, 252 insertions(+), 54 deletions(-) diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index 1ec0268bb08..cabdd44d628 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -160,6 +160,7 @@ async def fetch_command( *, command_id: UUID, project_id: Optional[UUID] = None, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: """One command by id. `project_id` is optional because the runner reports an outcome with the command id alone and holds no project credential.""" diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index eefe52d9125..4dbe6cec12b 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -306,50 +306,46 @@ async def request_cancel( command = created.command return CancelAdmission(command=command, execution_id=None, accepted=False) - open_command = await self._dao.fetch_open_command( - project_id=project_id, - session_id=session_id, - kind=SessionCommandKind.cancel, - target_turn_id=target_turn_id, - ) - if open_command is not None: - if steer_input_id is not None: - open_command = await self._dao.bind_steer_input( - project_id=project_id, - command_id=open_command.id, - input_id=steer_input_id, - ) - if open_command.state == SessionCommandState.pending: - await self._deliver(open_command) - return CancelAdmission( - command=open_command, - execution_id=target_turn_id, - accepted=True, - ) - if self._executions is None or not hasattr( self._executions, "lock_for_control" ): - created = await self._insert( + open_command = await self._dao.fetch_open_command( project_id=project_id, - user_id=user_id, session_id=session_id, - received_at=received_at, + kind=SessionCommandKind.cancel, target_turn_id=target_turn_id, - expected_turn_id=expected_execution_id, - idempotency_key=idempotency_key, - data=( - {"steer_input_id": str(steer_input_id)} - if steer_input_id is not None - else None - ), - state=SessionCommandState.pending, - outcome=None, - stopping_turn_id=target_turn_id, ) - if not created.inserted: - return self._admission_for_existing(created.command) - command = created.command + if open_command is not None: + command = ( + await self._dao.bind_steer_input( + project_id=project_id, + command_id=open_command.id, + input_id=steer_input_id, + ) + if steer_input_id is not None + else open_command + ) + else: + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command else: cancelled_interactions = 0 async with self._dao.transaction() as transaction: @@ -1809,13 +1805,7 @@ async def settle( ) try: async with self._dao.transaction() as transaction: - settled = await self._dao.settle_command( - settle=transition, - transaction=transaction, - ) - if settled is None: - raise _SettlementRejected - + result = None if execution_id and terminal and settled_by: result = await self._executions.settle( project_id=project_id, @@ -1825,15 +1815,29 @@ async def settle( settled_by=settled_by, transaction=transaction, ) + stored_command = await self._dao.fetch_command( + command_id=command_id, + project_id=project_id, + transaction=transaction, + ) + if stored_command is None: + raise _SettlementRejected + settled = await self._dao.settle_command( + settle=transition, + transaction=transaction, + ) + if settled is None: + raise _SettlementRejected + + if execution_id and terminal and settled_by: + assert result is not None winner = result.settlement if not result.won and ( winner.terminal_outcome != outcome.value or winner.settled_by != settled_by ): raise _SettlementRejected - steer_input_id = (stored_command.data or {}).get( - "steer_input_id" - ) + steer_input_id = (settled.data or {}).get("steer_input_id") if ( result.won and outcome == SessionCommandOutcome.stopped diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 33ea8677b74..59f12f99d08 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -431,8 +431,9 @@ async def fetch_command( *, command_id: UUID, project_id: Optional[UUID] = None, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = select(SessionCommandDBE).where( SessionCommandDBE.id == command_id, ) @@ -440,7 +441,12 @@ async def fetch_command( stmt = stmt.where(SessionCommandDBE.project_id == project_id) result = await session.execute(stmt) dbe = result.scalars().first() - return map_command_dbe_to_dto(dbe) if dbe is not None else None + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def claim_commands( self, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py index 1ff40cc025e..f2b25187bd9 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -162,7 +162,7 @@ async def bind_steer_input( return bound raise AssertionError("command to bind was not found") - async def fetch_command(self, *, command_id, project_id=None): + async def fetch_command(self, *, command_id, project_id=None, transaction=None): for row in self.rows: if row.id == command_id: return row diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index b5211fd754e..c573dbf52d4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -20,7 +20,9 @@ SessionCommandState, ) from oss.src.core.sessions.commands.interfaces import DeliveryReceipt +from oss.src.core.sessions.commands import service as commands_service_module from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import ExecutionExpectationFailed from oss.src.core.sessions.executions.dtos import SessionExecutionState from oss.src.core.sessions.inputs.dtos import PendingInputCreate, PendingInputState from oss.src.core.sessions.inputs.service import SessionInputsService, input_fingerprint @@ -157,7 +159,9 @@ def _input(scope, *, key: str, message: str, policy: str = "queue"): class _BusyStreams: async def fetch_header(self, **_kwargs): return SimpleNamespace( - flags=SimpleNamespace(is_running=True), turn_id="source-turn" + flags=SimpleNamespace(is_running=True), + turn_id="source-turn", + turn_started_at=None, ) @@ -180,7 +184,7 @@ async def acknowledge(self, **_kwargs): return None -def _settlement_service(scope, inputs): +def _settlement_service(scope, inputs, *, commands=None, executions=None): streams = SimpleNamespace( settle_command=AsyncMock(), publish_session_ended=AsyncMock(), @@ -190,18 +194,69 @@ def _settlement_service(scope, inputs): publish_session_pending_cancelled=AsyncMock(), ) service = SessionCommandsService( - commands_dao=SessionCommandsDAO(engine=scope["engine"]), + commands_dao=commands or SessionCommandsDAO(engine=scope["engine"]), streams_service=streams, interactions_service=interactions, lock_engine=None, delivery=_UnreachableDelivery(), - executions_dao=SessionExecutionsDAO(engine=scope["engine"]), + executions_dao=executions or SessionExecutionsDAO(engine=scope["engine"]), inputs_dao=inputs, ) service._reconcile_stopped_redis = AsyncMock() return service +def _cancel_service(scope, inputs, *, executions): + return SessionCommandsService( + commands_dao=SessionCommandsDAO(engine=scope["engine"]), + streams_service=_BusyStreams(), + interactions_service=SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ), + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=executions, + inputs_dao=inputs, + ) + + +class _PausingExecutionsDAO(SessionExecutionsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.locked = asyncio.Event() + self.release = asyncio.Event() + + async def lock_for_control(self, **kwargs): + execution = await super().lock_for_control(**kwargs) + if not self.locked.is_set(): + self.locked.set() + await self.release.wait() + return execution + + +class _ObservedExecutionsDAO(SessionExecutionsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.lock_attempted = asyncio.Event() + + async def lock_for_control(self, **kwargs): + self.lock_attempted.set() + return await super().lock_for_control(**kwargs) + + +class _ObservedCommandsDAO(SessionCommandsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.command_observed = asyncio.Event() + + async def fetch_command(self, **kwargs): + command = await super().fetch_command(**kwargs) + if kwargs.get("transaction") is None: + self.command_observed.set() + return command + + async def _pending_command(scope, *, data=None): return await SessionCommandsDAO(engine=scope["engine"]).create_command( user_id=scope["user_id"], @@ -633,3 +688,135 @@ async def test_steer_stop_promotes_only_the_bound_input(input_scope, monkeypatch ).promoted_execution_id, ) assert continuation.state == SessionExecutionState.recoverable + + +async def test_steer_bind_wins_before_stop_settlement(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, + "get_running_owner", + AsyncMock(return_value="source-turn"), + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, key="steer-first", message="steer first", policy="steer" + ), + prioritize=True, + ) + command = await _pending_command(input_scope) + bind_executions = _PausingExecutionsDAO(engine=input_scope["engine"]) + bind_service = _cancel_service(input_scope, inputs, executions=bind_executions) + settlement_commands = _ObservedCommandsDAO(engine=input_scope["engine"]) + settlement_service = _settlement_service( + input_scope, + inputs, + commands=settlement_commands, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + + bind_task = asyncio.create_task( + bind_service.request_cancel( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + expected_execution_id="source-turn", + steer_input_id=steer.id, + ) + ) + await asyncio.wait_for(bind_executions.locked.wait(), timeout=5) + settlement_task = asyncio.create_task( + settlement_service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + ) + await asyncio.wait_for(settlement_commands.command_observed.wait(), timeout=5) + bind_executions.release.set() + admission, settled = await asyncio.wait_for( + asyncio.gather(bind_task, settlement_task), timeout=5 + ) + + assert admission.command.data == {"steer_input_id": str(steer.id)} + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.promoted + + +async def test_stop_settlement_wins_before_steer_bind(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, + "get_running_owner", + AsyncMock(return_value="source-turn"), + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, + key="settlement-first", + message="settlement first", + policy="steer", + ), + prioritize=True, + ) + command = await _pending_command(input_scope) + settlement_executions = _PausingExecutionsDAO(engine=input_scope["engine"]) + settlement_service = _settlement_service( + input_scope, inputs, executions=settlement_executions + ) + bind_executions = _ObservedExecutionsDAO(engine=input_scope["engine"]) + bind_service = _cancel_service(input_scope, inputs, executions=bind_executions) + + settlement_task = asyncio.create_task( + settlement_service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + ) + await asyncio.wait_for(settlement_executions.locked.wait(), timeout=5) + bind_task = asyncio.create_task( + bind_service.request_cancel( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + expected_execution_id="source-turn", + steer_input_id=steer.id, + ) + ) + await asyncio.wait_for(bind_executions.lock_attempted.wait(), timeout=5) + settlement_executions.release.set() + + assert await asyncio.wait_for(settlement_task, timeout=5) is not None + with pytest.raises(ExecutionExpectationFailed): + await asyncio.wait_for(bind_task, timeout=5) + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.pending + settled_command = await SessionCommandsDAO( + engine=input_scope["engine"] + ).fetch_command(command_id=command.id) + assert settled_command.data is None From db083076c37736ac56b1c9b46757c2c69d1f499e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 03:50:31 +0200 Subject: [PATCH 075/133] fix(sessions): preserve running queue successors Revalidate promoted successors independently of the public pending list and lock the active continuation until admission finishes. Cover delivery advancing the promoted continuation to running before admission resumes. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/inputs/interfaces.py | 10 ++++++ api/oss/src/core/sessions/inputs/service.py | 12 ++----- .../src/dbs/postgres/sessions/inputs/dao.py | 32 +++++++++++++++++++ .../unit/sessions/test_session_inputs_dao.py | 27 +++++++++++++--- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index 74bb97f2c50..ca544249007 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -42,6 +42,16 @@ async def list_pending( ) -> List[PendingInput]: pass + @abstractmethod + async def fetch_active_successor( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[PendingInput]: + pass + @abstractmethod async def fetch_input( self, *, project_id: UUID, session_id: str, input_id: UUID diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 6956c2d4405..4339804aa72 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -125,19 +125,13 @@ async def admit( source_execution is not None and source_execution.terminal_outcome is not None ): - pending = await self._dao.list_pending( + successor = await self._dao.fetch_active_successor( project_id=project_id, session_id=session_id, transaction=transaction, ) - successor_execution_id = next( - ( - item.promoted_execution_id - for item in pending - if item.state == PendingInputState.promoted - and item.promoted_execution_id is not None - ), - None, + successor_execution_id = ( + successor.promoted_execution_id if successor is not None else None ) if successor_execution_id is None: return PendingInputAdmission(action="execute") diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index 2b049d9560f..78004ee6f2e 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -159,6 +159,38 @@ async def execute(session: Any) -> List[PendingInput]: async with self.engine.session() as session: return await execute(session) + async def fetch_active_successor( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[PendingInput]: + row = ( + await transaction.execute( + select(SessionInputDBE) + .join( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id == SessionInputDBE.project_id, + SessionExecutionDBE.session_id == SessionInputDBE.session_id, + SessionExecutionDBE.execution_id + == SessionInputDBE.promoted_execution_id, + ), + ) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "promoted", + SessionExecutionDBE.terminal_outcome.is_(None), + ) + .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + .limit(1) + .with_for_update(of=SessionExecutionDBE) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + async def fetch_input( self, *, project_id: UUID, session_id: str, input_id: UUID ) -> Optional[PendingInput]: diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index c573dbf52d4..edc2f109d65 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -382,7 +382,7 @@ async def test_admission_rechecks_settlement_under_the_execution_lock( ) -async def test_admission_queues_behind_input_promoted_by_settlement( +async def test_admission_queues_behind_running_input_promoted_by_settlement( input_scope, monkeypatch ): monkeypatch.setattr(env.agenta.sessions, "queue", True) @@ -424,15 +424,32 @@ async def test_admission_queues_behind_input_promoted_by_settlement( session_id=input_scope["session_id"], execution_id="source-turn", ) - streams.allow_admission.set() - admission = await admission_task - promoted = await inputs.fetch_input( project_id=input_scope["project_id"], session_id=input_scope["session_id"], input_id=older.id, ) assert promoted.state == PendingInputState.promoted + running = await executions.set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=promoted.promoted_execution_id, + state=SessionExecutionState.running, + expected_states=[SessionExecutionState.recoverable], + ) + assert running is not None + assert running.state == SessionExecutionState.running + assert ( + await inputs.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + == [] + ) + + streams.allow_admission.set() + admission = await admission_task + assert admission.action == "pending" assert admission.execution_id == promoted.promoted_execution_id assert [ @@ -441,7 +458,7 @@ async def test_admission_queues_behind_input_promoted_by_settlement( project_id=input_scope["project_id"], session_id=input_scope["session_id"], ) - ] == [older.id, admission.input.id] + ] == [admission.input.id] async def test_manual_stop_commits_without_promoting_pending_input( From 744007fdc24c3bfd1d8394e6b54ad581017431e6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 09:27:59 +0200 Subject: [PATCH 076/133] fix(sessions): advertise durable queue capabilities Build session capabilities through one helper so stream and snapshot responses expose the same feature flags. Cover both API flag combinations and the shared desktop/mobile durable admission request. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/router.py | 18 ++-- .../test_respond_interaction_durable.py | 22 ++++- .../unit/hooks/useServerSessionInputs.test.ts | 91 +++++++++++++++++++ 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index a4425c10c37..5e87cea0fba 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -224,6 +224,14 @@ _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,128}$") +def _session_capabilities() -> SessionCapabilities: + return SessionCapabilities( + durable_approvals=env.agenta.sessions.durable_approvals, + queue=env.agenta.sessions.queue, + steer=env.agenta.sessions.queue and env.agenta.sessions.steer, + ) + + def _idempotency_key_too_long_response() -> JSONResponse: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -534,9 +542,7 @@ async def fetch_session_stream( ) return SessionStreamResponse( stream=sanitize_session_stream(stream), - capabilities=SessionCapabilities( - durable_approvals=env.agenta.sessions.durable_approvals - ), + capabilities=_session_capabilities(), ) @intercept_exceptions() @@ -2320,11 +2326,7 @@ async def fetch_session_snapshot( session=stream, execution=SessionExecutionSnapshot(id=execution_id, state=state), pending=SessionPendingSnapshot(inputs=inputs, interactions=interactions), - capabilities=SessionCapabilities( - durable_approvals=env.agenta.sessions.durable_approvals, - queue=env.agenta.sessions.queue, - steer=env.agenta.sessions.queue and env.agenta.sessions.steer, - ), + capabilities=_session_capabilities(), ) @intercept_exceptions() diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py index 25bd3f81033..f890e329f3e 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -3,6 +3,8 @@ from unittest.mock import AsyncMock from uuid import uuid4 +import pytest + from oss.src.apis.fastapi.sessions import router as router_module from oss.src.apis.fastapi.sessions.models import SessionInteractionRespondRequest from oss.src.apis.fastapi.sessions.router import InteractionsRouter @@ -355,10 +357,26 @@ async def test_feature_off_batch_without_path_anchor_returns_422(monkeypatch): interactions.fetch_interaction.assert_not_awaited() -async def test_session_stream_response_advertises_durable_approvals(monkeypatch): +@pytest.mark.parametrize( + ("queue_enabled", "steer_enabled", "expected_queue", "expected_steer"), + [ + (True, True, True, True), + (True, False, True, False), + (False, True, False, False), + ], +) +async def test_session_stream_response_advertises_capabilities( + monkeypatch, + queue_enabled, + steer_enabled, + expected_queue, + expected_steer, +): project_id = uuid4() service = SimpleNamespace(fetch=AsyncMock(return_value=None)) monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr(env.agenta.sessions, "queue", queue_enabled) + monkeypatch.setattr(env.agenta.sessions, "steer", steer_enabled) monkeypatch.setattr( router_module, "check_action_access", AsyncMock(return_value=True) ) @@ -375,3 +393,5 @@ async def test_session_stream_response_advertises_durable_approvals(monkeypatch) ) assert response.capabilities.durable_approvals is True + assert response.capabilities.queue is expected_queue + assert response.capabilities.steer is expected_steer diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts new file mode 100644 index 00000000000..fb14f36d7a5 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import {act, renderHook, waitFor} from "@testing-library/react" +import type {UIMessage} from "ai" +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {buildAgentRequest, fetchSnapshot, removeInput} = vi.hoisted(() => ({ + buildAgentRequest: vi.fn(), + fetchSnapshot: vi.fn(), + removeInput: vi.fn(), +})) + +vi.mock("@agenta/entities/session", async () => { + const {atom} = await import("jotai") + return { + fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => + fetchSnapshot(sessionId), + ), + removePendingSessionInputAtom: atom( + null, + (_get, _set, params: {sessionId: string; inputId: string}) => removeInput(params), + ), + } +}) + +vi.mock("@agenta/playground/agent-chat", () => ({buildAgentRequest})) + +import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" + +const fetchMock = vi.fn() +vi.stubGlobal("fetch", fetchMock) + +beforeEach(() => { + buildAgentRequest.mockReset() + fetchSnapshot.mockReset() + removeInput.mockReset() + fetchMock.mockReset() +}) + +describe("useServerSessionInputs", () => { + it("reads queue support from the snapshot and submits durable admission", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: {id: "turn-1", state: "running"}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 202})) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: true, + }), + ) + + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + + await act(async () => { + await result.current.submit( + {id: "input-1", text: "run this next", source: "local"}, + "queue", + ) + }) + + expect(fetchSnapshot).toHaveBeenCalledWith("session-1") + expect(buildAgentRequest).toHaveBeenCalledWith( + "revision-1", + [expect.objectContaining({id: "input-1", role: "user"})], + {sessionId: "session-1"}, + ) + expect(fetchMock).toHaveBeenCalledWith( + "https://agent.test/invoke", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({"Idempotency-Key": "input-1"}), + body: JSON.stringify({ + session_id: "session-1", + data: {inputs: {messages: []}}, + on_busy: "queue", + }), + }), + ) + }) +}) From afd0b1db3eeb15c4d8df7ec1e93fa154ae5f9cca Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 09:48:17 +0200 Subject: [PATCH 077/133] fix(chat): keep stop available for durable runs Derive the composer Stop state from local or server-owned execution state on desktop and mobile. Keep Escape scoped to the active composer and use the mobile server command when only durable liveness remains. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/mobile/src/features/chat/Composer.tsx | 3 +- .../AgentChatSlice/AgentConversation.tsx | 9 +-- .../components/AgentComposerDock.tsx | 5 +- .../src/components/ChatComposer.tsx | 18 ++++- .../unit/ChatComposer.runControls.test.tsx | 65 +++++++++++++++++++ 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 66907226c29..3b339b784eb 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -68,6 +68,7 @@ export const Composer = ({ const richInputRef = inputRef ?? ownInputRef const sending = useRef(false) const presets = useMotionPresets() + const stoppable = inputBusy && !waitingOnUser /** * `extraFiles` are takes that never entered the tray (a voice message sent outright), so @@ -200,7 +201,7 @@ export const Composer = ({ dictating={dictating} placeholder={placeholder} waitingOnUser={waitingOnUser} - streaming={streaming} + streaming={streaming || stoppable} stopping={stopping} onStop={onStop} busyActions={ diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 14c5e548208..abbd581cd68 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -624,12 +624,6 @@ const AgentConversation = ({ // Radix cancels Escape for a layer but still lets it reach us, and it never touches // Alt+G, which only the overlay check catches. if (e.defaultPrevented || isOverlayOpen()) return - // An IME user presses Escape to cancel composition, not to stop the run. - if (e.key === "Escape" && !e.isComposing && busyRef.current) { - e.preventDefault() - handleStop() - return - } // Approve answers ONE gate, never the dock's "Approve all": a mis-press should not // grant a tool the user never read. if (isAltChord(e) && e.code === "KeyG" && pendingApprovals.length > 0) { @@ -639,7 +633,7 @@ const AgentConversation = ({ } document.addEventListener("keydown", onKey) return () => document.removeEventListener("keydown", onKey) - }, [activeSessionId, sessionId, busyRef, handleStop, pendingApprovals, handleApprovalResponse]) + }, [activeSessionId, sessionId, pendingApprovals, handleApprovalResponse]) // A keyboard switch (Alt+1…9 / Alt+Z / Alt+X) lands the caret here. antd mounts a never-visited // pane only on activation, so this effect runs on that mount and a first-visit switch focuses @@ -1021,6 +1015,7 @@ const AgentConversation = ({ stopping={stopping} queueEnabled={queueEnabled} steerEnabled={steerEnabled} + stopShortcutEnabled={activeSessionId === sessionId} richInputRef={richInputRef} composer={{...composer, handleComposerChange}} attachments={attachments} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index fd41387f6b2..5f68e7b8a96 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -84,6 +84,7 @@ const AgentComposerDock = ({ stopping, queueEnabled, steerEnabled, + stopShortcutEnabled, richInputRef, composer, attachments, @@ -135,6 +136,7 @@ const AgentComposerDock = ({ stopping: boolean queueEnabled: boolean steerEnabled: boolean + stopShortcutEnabled: boolean richInputRef: RefObject composer: ReturnType attachments: ReturnType @@ -472,9 +474,10 @@ const AgentComposerDock = ({ initialMarkdown={composer.initialDraft} slashCommands={slash.sections} onChange={composer.handleComposerChange} - streaming={shouldShowStopControl({busy, hitlPending})} + streaming={shouldShowStopControl({busy: inputBusy, hitlPending})} stopping={stopping} onStop={onStop} + stopShortcutEnabled={stopShortcutEnabled} busyActions={ inputBusy && queueEnabled ? [ diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index ccf23463796..6884b530395 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -7,8 +7,9 @@ * attachment ENGINE (staging + uploads) arrives as the `useComposerAttachments` result so * hosts control the rollout flag and the viewer wiring. */ -import {Suspense, lazy, useRef, type ReactNode, type RefObject} from "react" +import {Suspense, lazy, useEffect, useRef, type ReactNode, type RefObject} from "react" +import {isOverlayOpen} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui/height-collapse" import type {RichChatInputHandle, SlashCommandSection} from "@agenta/ui/rich-chat-input" import {Button, SimpleTooltip} from "@agenta/ui/ui" @@ -55,6 +56,8 @@ export interface ChatComposerProps { /** The Stop request is pending or accepted, awaiting the stream's terminal event. */ stopping?: boolean onStop?: () => void + /** Only the active session owns the global Escape shortcut. */ + stopShortcutEnabled?: boolean /** Capability-gated controls shown beside Stop while the session is busy. */ busyActions?: {label: string; onSubmit: (text: string) => void}[] /** Explain the manual Stop rule while durable Queue is available. */ @@ -92,6 +95,7 @@ export const ChatComposer = ({ streaming, stopping, onStop, + stopShortcutEnabled = true, busyActions, showQueuePauseCopy, attachmentsBlocked, @@ -123,6 +127,18 @@ export const ChatComposer = ({ // iPhone was being shown the `⌘` variant specifically. const hasKeyboard = useHardwareKeyboard() + useEffect(() => { + if (!streaming || !onStop || !stopShortcutEnabled) return + const stopOnEscape = (event: KeyboardEvent) => { + if (event.defaultPrevented || isOverlayOpen()) return + if (event.key !== "Escape" || event.isComposing) return + event.preventDefault() + onStop() + } + document.addEventListener("keydown", stopOnEscape) + return () => document.removeEventListener("keydown", stopOnEscape) + }, [onStop, stopShortcutEnabled, streaming]) + return ( + +const renderComposer = async (busyActions?: {label: string; onSubmit: (text: string) => void}[]) => { + const onStop = vi.fn() + render( + , + ) + await screen.findByRole("button", {name: "Stop"}) + return onStop +} + +describe("ChatComposer running controls", () => { + it("keeps Stop and Escape on a fresh session's first running turn", async () => { + const onStop = await renderComposer() + + expect(screen.getByRole("button", {name: "Stop"}).getAttribute("aria-keyshortcuts")).toBe( + "Escape", + ) + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).toHaveBeenCalledOnce() + }) + + it("keeps Stop and Escape beside Queue and Steer for a durable queued turn", async () => { + const onStop = await renderComposer([ + {label: "Queue", onSubmit: vi.fn()}, + {label: "Steer", onSubmit: vi.fn()}, + ]) + + expect(screen.getByRole("button", {name: "Queue"})).toBeTruthy() + expect(screen.getByRole("button", {name: "Steer"})).toBeTruthy() + expect(screen.getByRole("button", {name: "Stop"})).toBeTruthy() + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).toHaveBeenCalledOnce() + }) +}) From ea12e611561cb752a249100aa0940eba3141ba5f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 09:53:45 +0200 Subject: [PATCH 078/133] fix(chat): require durable queue admission Await Queue-capable composer submissions and propagate admission failures instead of presenting them as browser-only queued messages. Restore the desktop draft on failure; mobile reuses its existing retry surface. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/mobile/src/features/chat/Composer.tsx | 10 ++- .../AgentChatSlice/AgentConversation.tsx | 11 +-- .../components/AgentComposerDock.tsx | 9 ++- .../src/assets/composerRunState.ts | 9 +++ web/packages/agenta-chat/src/assets/index.ts | 1 + .../src/hooks/useAgentChatQueue.ts | 7 +- .../src/hooks/useAgentConversation.ts | 2 +- .../unit/ChatComposer.runControls.test.tsx | 67 +++++++++++++++---- .../unit/hooks/useAgentChatQueue.test.ts | 47 ++++++++----- 9 files changed, 116 insertions(+), 47 deletions(-) create mode 100644 web/packages/agenta-chat/src/assets/composerRunState.ts diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 3b339b784eb..8b90561464c 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -1,6 +1,6 @@ import {useEffect, useRef, type MutableRefObject} from "react" -import {describeAccepted} from "@agenta/chat/assets" +import {describeAccepted, isComposerRunStoppable} from "@agenta/chat/assets" import { AttachmentDropOverlay, ChatComposer, @@ -68,7 +68,11 @@ export const Composer = ({ const richInputRef = inputRef ?? ownInputRef const sending = useRef(false) const presets = useMotionPresets() - const stoppable = inputBusy && !waitingOnUser + const stoppable = isComposerRunStoppable({ + localStreaming: streaming, + serverBusy: inputBusy, + waitingOnUser, + }) /** * `extraFiles` are takes that never entered the tray (a voice message sent outright), so @@ -201,7 +205,7 @@ export const Composer = ({ dictating={dictating} placeholder={placeholder} waitingOnUser={waitingOnUser} - streaming={streaming || stoppable} + streaming={stoppable} stopping={stopping} onStop={onStop} busyActions={ diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index abbd581cd68..6c5bde41f9d 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -669,7 +669,7 @@ const AgentConversation = ({ useVirtuoso, }) - const finishSubmit = ( + const finishSubmit = async ( trimmed: string, fileParts: FileUIPart[] | undefined, consumedUids: string[], @@ -689,7 +689,7 @@ const AgentConversation = ({ setStopped(false) // One path: `submit` sends now or queues behind held messages via the shared release gate. if (policy === "steer") steer({text: trimmed, fileParts, stagedFiles}) - else submit({text: trimmed, fileParts, stagedFiles}) + else await submit({text: trimmed, fileParts, stagedFiles}) } // The message left the composer — drop its persisted draft (and any pending capture). composer.clearDraft() @@ -734,7 +734,7 @@ const AgentConversation = ({ } fileParts = parts } - finishSubmit(trimmed, fileParts, stagedUids, files, policy) + await finishSubmit(trimmed, fileParts, stagedUids, files, policy) return } @@ -747,7 +747,10 @@ const AgentConversation = ({ const fileParts = outboundFiles.length ? stagedFilesToParts(outboundFiles, sessionId) : undefined - finishSubmit(trimmed, fileParts, stagedUids, outboundFiles, policy) + await finishSubmit(trimmed, fileParts, stagedUids, outboundFiles, policy) + }).catch(() => { + richInputRef.current?.setMarkdown(text) + attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) }) handleSubmitRef.current = handleSubmit diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 5f68e7b8a96..a3503712ff2 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -2,7 +2,7 @@ import {useCallback, useEffect, useRef, type RefObject} from "react" import { CHAT_COLUMN, - shouldShowStopControl, + isComposerRunStoppable, type ApprovalSubmissionOutcome, } from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" @@ -150,6 +150,11 @@ const AgentComposerDock = ({ attachmentsBlocked: () => boolean }) => { const inputBusy = busy || queue.serverBusy + const stoppable = isComposerRunStoppable({ + localStreaming: busy, + serverBusy: queue.serverBusy, + waitingOnUser: hitlPending, + }) const { onboarding, onboardingActive, @@ -474,7 +479,7 @@ const AgentComposerDock = ({ initialMarkdown={composer.initialDraft} slashCommands={slash.sections} onChange={composer.handleComposerChange} - streaming={shouldShowStopControl({busy: inputBusy, hitlPending})} + streaming={stoppable} stopping={stopping} onStop={onStop} stopShortcutEnabled={stopShortcutEnabled} diff --git a/web/packages/agenta-chat/src/assets/composerRunState.ts b/web/packages/agenta-chat/src/assets/composerRunState.ts new file mode 100644 index 00000000000..026ba0343be --- /dev/null +++ b/web/packages/agenta-chat/src/assets/composerRunState.ts @@ -0,0 +1,9 @@ +export const isComposerRunStoppable = ({ + localStreaming, + serverBusy, + waitingOnUser, +}: { + localStreaming: boolean + serverBusy: boolean + waitingOnUser: boolean +}): boolean => (localStreaming || serverBusy) && !waitingOnUser diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 45b8fb46cce..8c1cc5e2374 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -12,5 +12,6 @@ export * from "./jumpToLatest" export * from "./boundedRequest" export * from "./serverOwnedApproval" export * from "./continuationPreflight" +export * from "./composerRunState" export {startupLabelFromDataPart} from "./startupPhases" export {getMessageTurnId, latestTurnId} from "./agentTurn" diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 0b12d39fd05..b3cf6da1e2b 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -272,12 +272,7 @@ export const useAgentChatQueue = ({ (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const message: QueuedMessage = {...item, id: generateId()} if (server?.capabilities.queue) { - void server.submit(message, "queue").catch(() => { - // The server did not accept ownership. Preserve the input in the original - // page-session queue so a transient admission failure never clears user work. - setQueued((q) => [...q, message]) - }) - return + return server.submit(message, "queue") } if (recoverable && retryContinuation) { setQueued((q) => [...q, message]) diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 4ed038ff10e..31559d062b1 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -1123,7 +1123,7 @@ export const useAgentConversation = ({ clearSessionTurnId(sessionId) setStopped(false) // One path: `submit` sends now or queues behind held messages via the release gate. - submit({text: trimmed, fileParts}) + await submit({text: trimmed, fileParts}) // The message left the composer — drop its persisted draft (per-session store). composerDraftBySession.delete(sessionId) }, diff --git a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx index 50172e1ea77..0c5b033ba5f 100644 --- a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx @@ -5,7 +5,9 @@ import {cleanup, fireEvent, render, screen} from "@testing-library/react" import {afterEach, describe, expect, it, vi} from "vitest" import {DEFAULT_ATTACHMENT_LIMITS} from "../../src/assets/attachmentRules" +import {isComposerRunStoppable} from "../../src/assets/composerRunState" import {ChatComposer} from "../../src/components/ChatComposer" +import QueuedMessagesDock from "../../src/components/QueuedMessagesDock" import type {useComposerAttachments} from "../../src/hooks/useComposerAttachments" afterEach(cleanup) @@ -24,24 +26,55 @@ const attachments = { uploads: {retry: vi.fn(), canRetry: vi.fn()}, } as unknown as ReturnType -const renderComposer = async (busyActions?: {label: string; onSubmit: (text: string) => void}[]) => { +const renderComposer = async ({ + localStreaming, + serverBusy = false, + queued = false, + busyActions, +}: { + localStreaming: boolean + serverBusy?: boolean + queued?: boolean + busyActions?: {label: string; onSubmit: (text: string) => void}[] +}) => { const onStop = vi.fn() + const streaming = isComposerRunStoppable({ + localStreaming, + serverBusy, + waitingOnUser: false, + }) render( - , + <> + {queued ? ( + + ) : null} + + , ) - await screen.findByRole("button", {name: "Stop"}) + await screen.findByRole("button", {name: "Stop"}, {timeout: 5_000}) return onStop } describe("ChatComposer running controls", () => { it("keeps Stop and Escape on a fresh session's first running turn", async () => { - const onStop = await renderComposer() + const onStop = await renderComposer({localStreaming: true}) expect(screen.getByRole("button", {name: "Stop"}).getAttribute("aria-keyshortcuts")).toBe( "Escape", @@ -51,11 +84,17 @@ describe("ChatComposer running controls", () => { }) it("keeps Stop and Escape beside Queue and Steer for a durable queued turn", async () => { - const onStop = await renderComposer([ - {label: "Queue", onSubmit: vi.fn()}, - {label: "Steer", onSubmit: vi.fn()}, - ]) + const onStop = await renderComposer({ + localStreaming: false, + serverBusy: true, + queued: true, + busyActions: [ + {label: "Queue", onSubmit: vi.fn()}, + {label: "Steer", onSubmit: vi.fn()}, + ], + }) + expect(screen.getByText("1 queued message")).toBeTruthy() expect(screen.getByRole("button", {name: "Queue"})).toBeTruthy() expect(screen.getByRole("button", {name: "Steer"})).toBeTruthy() expect(screen.getByRole("button", {name: "Stop"})).toBeTruthy() diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 4983684072c..0f40189354f 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -131,7 +131,7 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) - it("hands busy Queue and Steer submissions to the durable server adapter", async () => { + it("hands the primary composer submit and explicit Steer to durable admission", async () => { const server: ServerQueueAdapter = { capabilities: {queue: true, steer: true}, busy: true, @@ -181,6 +181,26 @@ describe("useAgentChatQueue", () => { expect(sendQueued).not.toHaveBeenCalled() }) + it("never reports a failed durable admission as a client-only queued message", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockRejectedValue(new Error("admission unavailable")), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + await expect(result.current.submit({text: "keep this draft"})).rejects.toThrow( + "admission unavailable", + ) + }) + + expect(result.current.queued).toHaveLength(0) + expect(sendQueued).not.toHaveBeenCalled() + }) + it("moves a client-held input behind the durable queue when its continuation starts", async () => { const durable = { id: "already-queued", @@ -192,41 +212,34 @@ describe("useAgentChatQueue", () => { capabilities: {queue: true, steer: true}, busy: false, queued: [durable], - submit: vi - .fn() - .mockRejectedValueOnce(new Error("admission unavailable")) - .mockResolvedValueOnce(undefined), + submit: vi.fn().mockResolvedValue(undefined), remove: vi.fn().mockResolvedValue(undefined), } const paused: HarnessProps = { status: "ready", messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], stopped: false, - server, } const {result, rerender, sendQueued} = setup(paused) await act(async () => { - result.current.submit({text: "held by this tab"}) - await Promise.resolve() + await result.current.submit({text: "held by this tab"}) }) - expect(server.submit).toHaveBeenCalledOnce() - expect(result.current.queued).toEqual([ - durable, - expect.objectContaining({text: "held by this tab"}), - ]) + expect(server.submit).not.toHaveBeenCalled() + expect(result.current.queued).toEqual([expect.objectContaining({text: "held by this tab"})]) await act(async () => { rerender({ ...paused, - continuationExecutionId: "continuation-1", + server, + continuationExecutionId: "a1-continuation-execution", messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], }) await Promise.resolve() }) - expect(server.submit).toHaveBeenCalledTimes(2) - expect(server.submit).toHaveBeenLastCalledWith( + expect(server.submit).toHaveBeenCalledOnce() + expect(server.submit).toHaveBeenCalledWith( expect.objectContaining({text: "held by this tab"}), "queue", ) @@ -256,7 +269,7 @@ describe("useAgentChatQueue", () => { } const {result, rerender, sendQueued} = setup(paused) - act(() => result.current.submit({text: "held by this tab"})) + act(() => void result.current.submit({text: "held by this tab"})) rerender({ ...paused, continuationExecutionId: "a1-continuation-execution", From 1b3939791a15d6764676c2a9606aa1f3cafaa0c7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 10:14:02 +0200 Subject: [PATCH 079/133] fix(sessions): release completed turn before queue delivery Reconcile the completed execution's Redis generation after the durable settlement commit and before delivering its promoted continuation. This prevents the continuation heartbeat from colliding with the source turn's final heartbeat and waiting for the abandoned-command sweep. Add a release-aware regression that measures done-record-to-admission latency below one second. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/sessions/commands/service.py | 8 +++ ...test_interaction_continuation_admission.py | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 4dbe6cec12b..728ea2a583b 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -1521,6 +1521,14 @@ async def settle_execution_completed( ) if admission is not None: + # The terminal record can arrive before the runner's final `is_running=false` beat. + # Release and fence that completed generation first, or the promoted continuation's + # first heartbeat sees the old `running` owner and rejects immediate delivery. + await self._reconcile_stopped_redis( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) receipt = await self._deliver(admission.command) if receipt is None or receipt.status != "accepted": await self._mark_continuation_recoverable(admission, receipt) diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index c084c1b95e0..fc184fda2ea 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -1,5 +1,6 @@ from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone +from time import monotonic from types import SimpleNamespace from unittest.mock import AsyncMock from uuid import uuid4 @@ -1079,6 +1080,7 @@ async def test_completion_promotes_exactly_one_pending_input_once(monkeypatch): executions_dao=executions, inputs_dao=items, ) + service._reconcile_stopped_redis = AsyncMock() assert await service.settle_execution_completed( project_id=project_id, @@ -1102,6 +1104,63 @@ async def test_completion_promotes_exactly_one_pending_input_once(monkeypatch): assert len(delivery.delivered) == 1 +@pytest.mark.asyncio +async def test_done_record_admits_promoted_input_within_one_second(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + source_reconciled = False + admitted_at = None + + class _ReleaseAwareDelivery: + async def deliver(self, **kwargs): + nonlocal admitted_at + if not source_reconciled: + return DeliveryReceipt( + status="unreachable", detail="source execution still owns running" + ) + admitted_at = monotonic() + return DeliveryReceipt(status="accepted", replica_id="runner-1") + + async def acknowledge(self, **kwargs): + return None + + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs([_pending_input(project_id, policy="queue", position=1)]) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_ReleaseAwareDelivery(), + executions_dao=executions, + inputs_dao=items, + ) + + async def reconcile_source(**kwargs): + nonlocal source_reconciled + source_reconciled = True + + service._reconcile_stopped_redis = AsyncMock(side_effect=reconcile_source) + done_record_at = monotonic() + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + + assert items.items[0].state == PendingInputState.promoted + service._reconcile_stopped_redis.assert_awaited_once_with( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert admitted_at is not None + assert admitted_at - done_record_at < 1 + + @pytest.mark.asyncio async def test_completion_handles_a_lost_input_delivery_reservation(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", True) @@ -1121,6 +1180,7 @@ async def test_completion_handles_a_lost_input_delivery_reservation(monkeypatch) executions_dao=executions, inputs_dao=items, ) + service._reconcile_stopped_redis = AsyncMock() assert await service.settle_execution_completed( project_id=project_id, From 8356fc6f132a7d6b6d6788ce9ba19335d1ce9da1 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 10:16:05 +0200 Subject: [PATCH 080/133] fix(chat): hide remote-run banner for idle queued input Expose the authoritative snapshot execution state to the desktop conversation and suppress stale running-elsewhere liveness only when that execution is idle with durable queued work. Keep the queued badge visible, and preserve the banner for genuinely running remote executions. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 11 +++++- .../AgentChatSlice/state/liveness.test.ts | 38 ++++++++++++++++++- .../AgentChatSlice/state/liveness.ts | 16 ++++++++ .../src/hooks/useServerSessionInputs.ts | 2 + .../unit/hooks/useServerSessionInputs.test.ts | 1 + 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 6c5bde41f9d..7f09509ceac 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -77,7 +77,7 @@ import {useScrollIntent} from "./hooks/useScrollIntent" import {useTranscriptScroll} from "./hooks/useTranscriptScroll" import {useTurnInspector} from "./hooks/useTurnInspector" import {useVirtuosoTranscript} from "./hooks/useVirtuosoTranscript" -import {deriveSessionRemoteTurnPresentation} from "./state/liveness" +import {deriveSessionRemoteTurnPresentation, shouldShowRunningElsewhere} from "./state/liveness" import {useChatScopeKey} from "./state/scope" import { activeSessionIdAtomFamily, @@ -441,6 +441,11 @@ const AgentConversation = ({ sessionId, server: serverInputs, }) + const showRunningElsewhere = shouldShowRunningElsewhere({ + runningElsewhere, + executionState: serverInputs.executionState, + pendingInputCount: serverInputs.queued.length, + }) // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision made // in THIS mount marks the resume as live — a restored approval-requested tail the user answers @@ -990,7 +995,9 @@ const AgentConversation = ({ entityId={entityId} messages={messages} busy={busy} - showRunningElsewhere={remoteTurn.showStrip} + showRunningElsewhere={ + remoteTurn.showStrip && showRunningElsewhere + } connectionWarning={connectionWarning} hitlPending={hitlPending} queue={{ diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index eee1e713ccb..a03200c7669 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -8,7 +8,11 @@ */ import {describe, expect, it} from "vitest" -import {deriveSessionRemoteTurnPresentation, isRunningElsewhere} from "./liveness" +import { + deriveSessionRemoteTurnPresentation, + isRunningElsewhere, + shouldShowRunningElsewhere, +} from "./liveness" /** A session this browser has never run: no settle stamp, so the flag is trusted as-is. */ const neverRanHere = {localStatus: "idle", localSettledAt: undefined} as const @@ -159,3 +163,35 @@ describe("deriveSessionRemoteTurnPresentation", () => { ).toBe(false) }) }) + +describe("shouldShowRunningElsewhere", () => { + it("hides stale remote liveness while an idle execution shows its queued input", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "idle", + pendingInputCount: 1, + }), + ).toBe(false) + }) + + it("keeps the warning for a genuinely running execution with queued work", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "running", + pendingInputCount: 1, + }), + ).toBe(true) + }) + + it("keeps the warning for an idle snapshot without queued work", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "idle", + pendingInputCount: 0, + }), + ).toBe(true) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index fdfa0f5e2c8..318b35f695f 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -128,6 +128,22 @@ export const isRunningElsewhere = ({ /** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation + +/** + * The session snapshot is the execution authority for the open conversation. A stale stream-row + * liveness flag must not put a remote-run warning beside a durable queued item when that snapshot + * already says the execution is idle. + */ +export const shouldShowRunningElsewhere = ({ + runningElsewhere, + executionState, + pendingInputCount, +}: { + runningElsewhere: boolean + executionState: "idle" | "running" | "stopping" + pendingInputCount: number +}): boolean => runningElsewhere && !(executionState === "idle" && pendingInputCount > 0) + /** `isRunningElsewhere` bound to this session's local status and the shared liveness query. */ export const sessionRunningElsewhereAtomFamily = atomFamily((sessionId: string) => atom((get): boolean => diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index 9b17efe0b90..79834524d0a 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -11,6 +11,7 @@ import type {QueuedMessage} from "./useAgentChatQueue" export interface ServerSessionInputs { capabilities: SessionPendingInputView["capabilities"] + executionState: SessionPendingInputView["executionState"] busy: boolean queued: QueuedMessage[] submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise @@ -130,6 +131,7 @@ export const useServerSessionInputs = ({ return { capabilities: view.capabilities, + executionState: view.executionState, busy: locallyBusy || view.executionState !== "idle", queued: view.queued, submit, diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index fb14f36d7a5..9e670f3b3e1 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -61,6 +61,7 @@ describe("useServerSessionInputs", () => { ) await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(result.current.executionState).toBe("running") await act(async () => { await result.current.submit( From 737b343e436486d760f7766f52eff5d48bec1297 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 10:32:29 +0200 Subject: [PATCH 081/133] fix(chat): deliver steer during active streams Release accepted fresh-run admissions when their response headers arrive while continuing to consume the stream in the background. This lets later Queue and Steer actions reach durable admission during the active turn. Propagate refused Steer admissions through desktop and mobile callers so the existing failure card restores and keeps the draft instead of clearing it silently. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 2 +- .../src/hooks/useAgentChatQueue.ts | 10 +-- .../src/hooks/useAgentConversation.ts | 2 +- .../src/hooks/useServerSessionInputs.ts | 16 +++-- .../unit/hooks/useAgentChatQueue.test.ts | 20 ++++++ .../unit/hooks/useAgentConversation.test.ts | 35 +++++++++- .../unit/hooks/useServerSessionInputs.test.ts | 70 +++++++++++++++++++ 7 files changed, 141 insertions(+), 14 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 7f09509ceac..ccf937c0ece 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -693,7 +693,7 @@ const AgentConversation = ({ scrollIntent.armGlide() setStopped(false) // One path: `submit` sends now or queues behind held messages via the shared release gate. - if (policy === "steer") steer({text: trimmed, fileParts, stagedFiles}) + if (policy === "steer") await steer({text: trimmed, fileParts, stagedFiles}) else await submit({text: trimmed, fileParts, stagedFiles}) } // The message left the composer — drop its persisted draft (and any pending capture). diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index b3cf6da1e2b..6ec0cf5037f 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -310,12 +310,12 @@ export const useAgentChatQueue = ({ ) const steer = useCallback( - (item: {text: string; fileParts?: FileUIPart[]}) => { - if (!server?.capabilities.steer || !server.busy) return + async (item: {text: string; fileParts?: FileUIPart[]}) => { + if (!server?.capabilities.steer || !server.busy) { + throw new Error("The session is not ready to accept a Steer input.") + } const message: QueuedMessage = {...item, id: generateId()} - void server.submit(message, "steer").catch(() => { - setQueued((q) => [...q, message]) - }) + await server.submit(message, "steer") }, [server], ) diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 31559d062b1..b2dd591de4e 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -1138,7 +1138,7 @@ export const useAgentConversation = ({ if (!trimmed && fileObjs.length === 0 && refParts.length === 0) return const encoded = fileObjs.length ? await filesToParts(fileObjs) : undefined const merged = [...(encoded?.parts ?? []), ...refParts] - steer({text: trimmed, fileParts: merged.length ? merged : undefined}) + await steer({text: trimmed, fileParts: merged.length ? merged : undefined}) composerDraftBySession.delete(sessionId) }, [sessionId, steer], diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index 79834524d0a..ac24c2c4d9d 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -109,12 +109,16 @@ export const useServerSessionInputs = ({ return } - // The local stream can settle between the click and admission. If the server admits - // this as a fresh 200 run, keep its sender-owned response alive, then adopt its durable - // transcript through the host's normal guarded revalidation. - await response.arrayBuffer() - await refresh() - onExecutedRef.current?.() + // Admission succeeded when the response headers arrived. Keep consuming a fresh 200 + // run in the background so the composer can admit Queue/Steer while that run streams. + void response + .arrayBuffer() + .catch(() => undefined) + .then(async () => { + await refresh() + onExecutedRef.current?.() + }) + .catch(() => undefined) }, [refresh, sessionId], ) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 0f40189354f..312c0c2243d 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -201,6 +201,26 @@ describe("useAgentChatQueue", () => { expect(sendQueued).not.toHaveBeenCalled() }) + it("propagates a refused Steer without inventing a client-only queued message", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockRejectedValue(new Error("steer refused")), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + await expect(result.current.steer({text: "keep steering draft"})).rejects.toThrow( + "steer refused", + ) + }) + + expect(result.current.queued).toHaveLength(0) + expect(sendQueued).not.toHaveBeenCalled() + }) + it("moves a client-held input behind the durable queue when its continuation starts", async () => { const durable = { id: "already-queued", diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 176d5e3fcc5..b86aec2dfd7 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -21,7 +21,10 @@ import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -const {resumeContinuation} = vi.hoisted(() => ({resumeContinuation: vi.fn()})) +const {snapshotViaAtom, resumeContinuation} = vi.hoisted(() => ({ + snapshotViaAtom: vi.fn(), + resumeContinuation: vi.fn(), +})) vi.mock("@agenta/playground/agent-chat", async (importOriginal) => { const actual = await importOriginal() @@ -51,6 +54,9 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { fetchSessionInteractionStatesAtom: atom(null, () => new Map()), fetchSessionSnapshot: vi.fn(), querySessionTranscript: vi.fn(), + fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => + snapshotViaAtom(sessionId), + ), resumeSessionContinuationAtom: atom(null, () => resumeContinuation()), } }) @@ -62,6 +68,7 @@ vi.mock("@agenta/entities/trace", () => ({ import {useAgentConversation} from "../../../src/hooks/useAgentConversation" import {ACCEPTED_SENDER_DISCONNECT_MESSAGE, TRANSPORT_ERROR_MESSAGE} from "../../../src/model/error" import { + composerDraftBySession, getSessionTurnId, markSessionFresh, setSessionTurnId, @@ -274,6 +281,8 @@ beforeEach(() => { } as SessionSnapshot) vi.mocked(querySessionTranscript).mockReset() vi.mocked(querySessionTranscript).mockResolvedValue([]) + snapshotViaAtom.mockReset() + snapshotViaAtom.mockResolvedValue(null) resumeContinuation.mockReset() resumeContinuation.mockResolvedValue(false) vi.mocked(buildAgentRequest).mockClear() @@ -289,6 +298,30 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { + it("keeps a Steer draft when durable admission is refused", async () => { + snapshotViaAtom.mockResolvedValue({ + session: null, + execution: {id: "turn-1", state: "running"}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 409})) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + composerDraftBySession.set(sessionId, "keep steering draft") + const {result} = mount(store, "rev-1", sessionId) + await waitFor(() => expect(result.current.steerEnabled).toBe(true)) + + await act(async () => { + await expect(result.current.steer({text: "keep steering draft"})).rejects.toThrow( + "The input was not accepted (409).", + ) + }) + + expect(composerDraftBySession.get(sessionId)).toBe("keep steering draft") + }) + it("redelivers a durable continuation before request build and suppresses direct invoke", async () => { resumeContinuation.mockResolvedValueOnce(true) const store = createStore() diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 9e670f3b3e1..9eb61e72cd2 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -89,4 +89,74 @@ describe("useServerSessionInputs", () => { }), ) }) + + it("releases admission after a fresh run's headers while its response keeps streaming", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: {id: "turn-1", state: "running"}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + let closeResponse!: () => void + const body = new ReadableStream({ + start(controller) { + closeResponse = () => controller.close() + }, + }) + fetchMock.mockResolvedValue(new Response(body, {status: 200})) + const onExecuted = vi.fn() + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + onExecuted, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + + await act(async () => { + await result.current.submit({id: "input-1", text: "start"}, "queue") + }) + + expect(onExecuted).not.toHaveBeenCalled() + closeResponse() + await waitFor(() => expect(onExecuted).toHaveBeenCalledOnce()) + }) + + it("rejects a refused Steer admission", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: {id: "turn-1", state: "running"}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 409})) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + + await act(async () => { + await expect( + result.current.submit({id: "steer-1", text: "redirect"}, "steer"), + ).rejects.toThrow("The input was not accepted (409).") + }) + }) }) From 973ed106d9d60c0aad14b976d75f8bb751c9e725 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 10:57:43 +0200 Subject: [PATCH 082/133] test(chat): cover running-elsewhere admission Exercise Enter, Queue, and Steer after a fresh admission releases at response headers while the source tab is observing the server-owned run. Assert each durable request refreshes the pending badge, and a refusal restores the draft with the existing failure card. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../unit/hooks/useServerSessionInputs.test.ts | 282 +++++++++++++++++- 1 file changed, 277 insertions(+), 5 deletions(-) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 9eb61e72cd2..667904243ef 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -1,7 +1,19 @@ // @vitest-environment jsdom -import {act, renderHook, waitFor} from "@testing-library/react" +import {createElement, createRef, Fragment, useMemo, useRef, useState, type RefObject} from "react" + +import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {act, cleanup, fireEvent, render, renderHook, screen, waitFor} from "@testing-library/react" import type {UIMessage} from "ai" -import {beforeEach, describe, expect, it, vi} from "vitest" +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest" + +import {DEFAULT_ATTACHMENT_LIMITS} from "../../../src/assets/attachmentRules" +import {isComposerRunStoppable} from "../../../src/assets/composerRunState" +import {ChatComposer} from "../../../src/components/ChatComposer" +import QueuedMessagesDock from "../../../src/components/QueuedMessagesDock" +import {RunningElsewhereStrip} from "../../../src/components/RunningElsewhereStrip" +import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" +import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" +import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" const {buildAgentRequest, fetchSnapshot, removeInput} = vi.hoisted(() => ({ buildAgentRequest: vi.fn(), @@ -22,13 +34,34 @@ vi.mock("@agenta/entities/session", async () => { } }) -vi.mock("@agenta/playground/agent-chat", () => ({buildAgentRequest})) - -import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" +vi.mock("@agenta/playground/agent-chat", async (importOriginal) => ({ + ...(await importOriginal()), + buildAgentRequest, +})) const fetchMock = vi.fn() vi.stubGlobal("fetch", fetchMock) +beforeAll(() => { + // Lexical asks the DOM selection's text node for geometry after Enter clears the editor. + const rect = () => new DOMRect() + for (const prototype of [ + Node.prototype, + Text.prototype, + HTMLElement.prototype, + Range.prototype, + ]) { + Object.defineProperty(prototype, "getBoundingClientRect", { + configurable: true, + value: rect, + }) + } + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }) +}) + beforeEach(() => { buildAgentRequest.mockReset() fetchSnapshot.mockReset() @@ -36,6 +69,197 @@ beforeEach(() => { fetchMock.mockReset() }) +afterEach(cleanup) + +interface PendingInput { + id: string + session_id: string + content: {data: {inputs: {messages: {role: string; content: string}[]}}} + position: number + state: "pending" + policy: "queue" | "steer" + created_at: null + promoted_execution_id: null +} + +const runningSnapshot = (inputs: PendingInput[] = []) => ({ + session: null, + execution: {id: "turn-1", state: "running" as const}, + pending: {inputs, interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, +}) + +const RunningElsewhereAdmissionHarness = ({ + inputRef, +}: { + inputRef: RefObject +}) => { + const server = useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + // The browser owns no AI-SDK stream; only the server snapshot says the run is active. + locallyBusy: false, + }) + const queue = useAgentChatQueue({ + status: "ready", + messages: [], + stopped: false, + markRunOwned: vi.fn(), + sendQueued: vi.fn(), + server, + }) + const sending = useRef(false) + const [freshAdmissionReleased, setFreshAdmissionReleased] = useState(false) + const [rejections, setRejections] = useState<{name: string; reason: string}[]>([]) + const attachments = useMemo( + () => + ({ + uploadsEnabled: false, + files: [], + rejections, + limits: DEFAULT_ATTACHMENT_LIMITS, + atMax: false, + attachmentsSettled: true, + uploadBlockReason: undefined, + addFiles: vi.fn(), + removeFile: vi.fn(), + dismissRejection: (index: number) => + setRejections((items) => items.filter((_, at) => at !== index)), + uploads: {retry: vi.fn(), canRetry: vi.fn()}, + }) as unknown as ReturnType, + [rejections], + ) + + const submit = async (text: string, policy: "queue" | "steer" = "queue") => { + // Mirrors the desktop/mobile submit guard that exposed the original loss: the initial + // fresh-run response must release this before any busy action can be admitted. + if (sending.current) return + sending.current = true + try { + if (policy === "steer") await queue.steer({text}) + else await queue.submit({text}) + } catch { + inputRef.current?.setMarkdown(text) + setRejections([{name: "Message", reason: "wasn't sent — try again."}]) + } finally { + sending.current = false + } + } + + const startFreshRun = async () => { + await submit("start the turn") + setFreshAdmissionReleased(true) + } + const stoppable = isComposerRunStoppable({ + localStreaming: false, + serverBusy: server.busy, + waitingOnUser: false, + }) + + return createElement( + Fragment, + null, + createElement( + "button", + {type: "button", onClick: () => void startFreshRun()}, + "Start fresh run", + ), + freshAdmissionReleased ? createElement("span", null, "Fresh admission released") : null, + createElement(RunningElsewhereStrip), + createElement(QueuedMessagesDock, { + queued: queue.queued, + onRemove: vi.fn(), + held: false, + }), + createElement(ChatComposer, { + inputRef, + onSubmit: (text) => submit(text), + attachments, + streaming: stoppable, + onStop: vi.fn(), + busyActions: + server.busy && queue.queueEnabled + ? [ + {label: "Queue", onSubmit: (text) => void submit(text)}, + ...(queue.steerEnabled + ? [ + { + label: "Steer", + onSubmit: (text: string) => void submit(text, "steer"), + }, + ] + : []), + ] + : undefined, + }), + ) +} + +const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolean} = {}) => { + const pending: PendingInput[] = [] + let requestCount = 0 + let closeFreshResponse = () => {} + + fetchSnapshot.mockImplementation(async () => runningSnapshot(pending)) + buildAgentRequest.mockImplementation( + async (_entityId: string, messages: UIMessage[], options: {sessionId: string}) => { + const outbound = messages.at(-1) + const content = (outbound?.parts ?? []) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") + return { + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: { + session_id: options.sessionId, + data: {inputs: {messages: [{role: "user", content}]}}, + }, + } + }, + ) + fetchMock.mockImplementation(async (_input, init) => { + requestCount += 1 + if (requestCount === 1) { + const body = new ReadableStream({ + start(controller) { + closeFreshResponse = () => controller.close() + }, + }) + return new Response(body, {status: 200}) + } + if (refuse) return new Response(null, {status: 409}) + + const request = JSON.parse(String(init?.body)) as { + data: {inputs: {messages: {role: string; content: string}[]}} + on_busy: "queue" | "steer" + } + const headers = init?.headers as Record + pending.push({ + id: headers["Idempotency-Key"], + session_id: "session-1", + content: {data: {inputs: {messages: request.data.inputs.messages}}}, + position: pending.length + 1, + state: "pending", + policy: request.on_busy, + created_at: null, + promoted_execution_id: null, + }) + return new Response(null, {status: 202}) + }) + + const inputRef = createRef() + render(createElement(RunningElsewhereAdmissionHarness, {inputRef})) + await screen.findByText(/This session is running somewhere else/) + await screen.findByLabelText("Chat message") + await screen.findByRole("button", {name: "Start fresh run"}) + fireEvent.click(screen.getByRole("button", {name: "Start fresh run"})) + await screen.findByText("Fresh admission released") + + return {closeFreshResponse, inputRef} +} + describe("useServerSessionInputs", () => { it("reads queue support from the snapshot and submits durable admission", async () => { fetchSnapshot.mockResolvedValue({ @@ -159,4 +383,52 @@ describe("useServerSessionInputs", () => { ).rejects.toThrow("The input was not accepted (409).") }) }) + + it.each([ + ["Enter", "queue"], + ["Queue button", "queue"], + ["Steer button", "steer"], + ] as const)( + "admits %s durably while the source tab looks running elsewhere", + async (interaction, policy) => { + const {closeFreshResponse, inputRef} = await setupRunningElsewhereAdmission() + const text = `say ${interaction}` + act(() => inputRef.current?.setMarkdown(text)) + await waitFor(() => expect(inputRef.current?.getMarkdown()).toBe(text)) + + if (interaction === "Enter") { + const editor = screen.getByLabelText("Chat message") + fireEvent.focus(editor) + fireEvent.keyDown(editor, { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + }) + } else { + await waitFor(() => + expect( + screen.getByRole("button", {name: interaction.split(" ")[0]}), + ).toBeTruthy(), + ) + fireEvent.click(screen.getByRole("button", {name: interaction.split(" ")[0]})) + } + + await screen.findByText("1 queued message") + const admission = fetchMock.mock.calls.at(-1)?.[1] + expect(JSON.parse(String(admission?.body))).toMatchObject({on_busy: policy}) + closeFreshResponse() + }, + ) + + it("keeps the draft and shows the failure card when admission is refused elsewhere", async () => { + const {closeFreshResponse, inputRef} = await setupRunningElsewhereAdmission({refuse: true}) + act(() => inputRef.current?.setMarkdown("keep this draft")) + fireEvent.click(await screen.findByRole("button", {name: "Queue"})) + + await screen.findByTitle("Message wasn't sent — try again.") + expect(inputRef.current?.getMarkdown()).toBe("keep this draft") + expect(screen.queryByText("1 queued message")).toBeNull() + closeFreshResponse() + }) }) From d25b83d2367f2d5512242b767b9d8f4bc6235457 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:16:50 +0200 Subject: [PATCH 083/133] test(chat): match the milestone 2 running-elsewhere copy The strip was reworded on the milestone 2 base: it now says the turn is still running and warns that a still transcript may mean the run already ended. The durable admission suite still asserted the older sentence. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 667904243ef..f6447994dab 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -251,7 +251,7 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea const inputRef = createRef() render(createElement(RunningElsewhereAdmissionHarness, {inputRef})) - await screen.findByText(/This session is running somewhere else/) + await screen.findByText(/This turn is still running/) await screen.findByLabelText("Chat message") await screen.findByRole("button", {name: "Start fresh run"}) fireEvent.click(screen.getByRole("button", {name: "Start fresh run"})) From b8eaeb333d078c537bc6433347ee2f15b2c115d3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:19:52 +0200 Subject: [PATCH 084/133] fix(web): bind the remote-run gate and the steer payload to milestone 2 Milestone 2 renames the liveness flag this component destructures, and its steer sender takes only text and file parts. The rebase carried increment 7's call sites unchanged, so the remote-run gate referenced a name that no longer exists and the steer call passed a staged-file list the sender does not accept. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/oss/src/components/AgentChatSlice/AgentConversation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index ccf937c0ece..5b9298667a4 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -442,7 +442,7 @@ const AgentConversation = ({ server: serverInputs, }) const showRunningElsewhere = shouldShowRunningElsewhere({ - runningElsewhere, + runningElsewhere: livenessRunningElsewhere, executionState: serverInputs.executionState, pendingInputCount: serverInputs.queued.length, }) @@ -693,7 +693,7 @@ const AgentConversation = ({ scrollIntent.armGlide() setStopped(false) // One path: `submit` sends now or queues behind held messages via the shared release gate. - if (policy === "steer") await steer({text: trimmed, fileParts, stagedFiles}) + if (policy === "steer") await steer({text: trimmed, fileParts}) else await submit({text: trimmed, fileParts, stagedFiles}) } // The message left the composer — drop its persisted draft (and any pending capture). From b92f46e17e8db1edd32c2e85a5158d0f258158bc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:21:23 +0200 Subject: [PATCH 085/133] style(web): format the merged liveness module Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/oss/src/components/AgentChatSlice/state/liveness.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index 318b35f695f..23ac76468e3 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -128,7 +128,6 @@ export const isRunningElsewhere = ({ /** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation - /** * The session snapshot is the execution authority for the open conversation. A stale stream-row * liveness flag must not put a remote-run warning beside a durable queued item when that snapshot From e7a32321886ab7090e33b58b59348c809096be1a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 14:54:55 +0200 Subject: [PATCH 086/133] fix(sessions): unify the session snapshot contract Milestone 2 and increment 7 each built a snapshot endpoint on GET /sessions/{session_id}, independently: increment 7 was cut from a milestone 1 head that did not carry milestone 2's. Assembled, the API defined SessionSnapshotResponse twice and registered two handlers on one path, so the second definition shadowed the first and only one endpoint was reachable. The web client could export only one fetchSessionSnapshot, which orphaned the other reader. Milestone 2 merges first, so its contract is the base. There is now one response model that keeps milestone 2's required session and read watermark and adds increment 7's fields, one handler that fills both halves, and one client function both readers call. The two execution questions stay separate because they are different questions. `execution` is the last turn, whose end_time tells the live preview whether that turn is still running. `execution_state` is the session's current lifecycle, derived from the stream row, which is what the durable queue admits against. Capabilities come from the same helper the streams endpoint uses, so a client can never see the two disagree. The pending-input list is optional in effect: a deployment without the inputs service still gets the reconnect half and reports an empty queue. The generated client was hand-edited rather than regenerated. The generator builds from a live OpenAPI document; the spec this worktree can produce covers OSS only, so a full regeneration would have deleted the cloud surface the committed client carries. Both feature sets keep their tests: the milestone 2 snapshot, sequence and replay suites and the live preview hook, and increment 7's queue admission and capability suites. Two new API tests pin that one call carries both halves, and that the queue half degrades to empty when the inputs service is absent. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/models.py | 33 ++--- api/oss/src/apis/fastapi/sessions/router.py | 78 ++++-------- .../unit/sessions/test_session_snapshot.py | 118 +++++++++++++++++- .../api/resources/sessions/client/Client.ts | 57 --------- .../requests/FetchSessionSnapshotRequest.ts | 5 - .../sessions/client/requests/index.ts | 1 - .../api/types/SessionPendingSnapshot.ts | 8 -- .../api/types/SessionSnapshotPending.ts | 2 +- .../api/types/SessionSnapshotResponse.ts | 8 +- .../src/generated/api/types/index.ts | 1 - .../agenta-chat/src/assets/pendingInputs.ts | 6 +- .../tests/unit/assets/pendingInputs.test.ts | 20 ++- .../unit/hooks/useAgentConversation.test.ts | 10 +- .../unit/hooks/useServerSessionInputs.test.ts | 41 ++++-- .../agenta-entities/src/session/api/api.ts | 17 +-- .../src/session/core/schema.ts | 74 +++++------ .../agenta-entities/src/session/index.ts | 2 - .../unit/session-pending-input-api.test.ts | 12 +- 18 files changed, 284 insertions(+), 209 deletions(-) delete mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts delete mode 100644 web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index b48fd561f64..4235d35bc59 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -134,20 +134,6 @@ class SessionExecutionSnapshot(BaseModel): state: Literal["idle", "running", "stopping"] = "idle" -class SessionPendingSnapshot(BaseModel): - inputs: List[PendingInput] = Field(default_factory=list) - interactions: List[SessionInteraction] = Field(default_factory=list) - - -class SessionSnapshotResponse(BaseModel): - session: Optional[SessionStream] = None - execution: SessionExecutionSnapshot = Field( - default_factory=SessionExecutionSnapshot - ) - pending: SessionPendingSnapshot = Field(default_factory=SessionPendingSnapshot) - capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) - - class PendingInputResponse(BaseModel): input: PendingInput @@ -215,15 +201,32 @@ class SessionRecordsQueryResponse(BaseModel): class SessionSnapshotPending(BaseModel): - inputs: List[Any] = Field(default_factory=list) + inputs: List[PendingInput] = Field(default_factory=list) interactions: List[SessionInteraction] = Field(default_factory=list) class SessionSnapshotResponse(BaseModel): + """One snapshot for every reader of an open session. + + `session`, `execution` and `read` are the reconnect half: the stream row, the latest turn + (whose `end_time` says whether that turn is still live), and the durable sequence watermark + a reader replays from. + + `execution_state` and `pending.inputs` are the queue half. `execution_state` is the + session's CURRENT lifecycle derived from the stream row, which is a different question from + `execution`: that names the last turn, this says whether anything is running right now. + `capabilities` reports the same flags the streams endpoint reports, from the same helper, so + a client never sees the two disagree. + """ + session: SessionStream execution: Optional[SessionTurn] = None + execution_state: SessionExecutionSnapshot = Field( + default_factory=SessionExecutionSnapshot + ) pending: SessionSnapshotPending read: SessionRecordsReadState + capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) class SessionRecordResponse(BaseModel): diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 5e87cea0fba..a548a8324b2 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -207,8 +207,6 @@ PendingInputAdmissionResponse, SessionCapabilities, SessionExecutionSnapshot, - SessionPendingSnapshot, - SessionSnapshotResponse, ) from oss.src.apis.fastapi.sessions.utils import ( compute_session_response_windowing, @@ -2129,15 +2127,8 @@ def __init__( tags=["Sessions"], ) if inputs_service is not None: - self.router.add_api_route( - "/sessions/{session_id}", - self.fetch_session_snapshot, - methods=["GET"], - operation_id="fetch_session_snapshot", - response_model=SessionSnapshotResponse, - response_model_exclude_none=True, - tags=["Sessions"], - ) + # The snapshot itself is `get_session_snapshot`, registered below: one route serves + # both the reconnect watermark and the durable queue. self.router.add_api_route( "/sessions/{session_id}/inputs/{input_id}", self.remove_pending_input, @@ -2227,11 +2218,35 @@ async def get_session_snapshot( status=SessionInteractionStatus.pending, ), ) + # The durable queue half. It is optional: a deployment without the inputs service still + # gets the reconnect half, and reports an empty queue rather than failing the snapshot. + inputs = ( + await self.inputs_service.list_pending( + project_id=project_id, + session_id=session_id, + ) + if self.inputs_service is not None + else [] + ) + # Derived from the stream row, not from `execution`: that names the last turn, this says + # whether anything is running right now, which is what the queue admits against. + execution_state = SessionExecutionSnapshot( + id=session.stopping_turn_id or session.turn_id, + state=( + "stopping" + if session.stopping_turn_id + else "running" + if session.flags.is_running + else "idle" + ), + ) return SessionSnapshotResponse( session=sanitize_session_stream(session), execution=execution, - pending=SessionSnapshotPending(interactions=interactions), + execution_state=execution_state, + pending=SessionSnapshotPending(inputs=inputs, interactions=interactions), read=read, + capabilities=_session_capabilities(), ) @intercept_exceptions() @@ -2290,45 +2305,6 @@ async def query_sessions( windowing=response_windowing, ) - @intercept_exceptions() - async def fetch_session_snapshot( - self, request: Request, session_id: str - ) -> SessionSnapshotResponse: - _validate_session_id_http(session_id) - project_id = UUID(str(request.state.project_id)) - user_id = request.state.user_id - if not await check_action_access( - user_uid=str(user_id), - project_id=str(project_id), - permission=Permission.VIEW_SESSIONS, - ): - raise FORBIDDEN_EXCEPTION - stream = await self.streams_service.fetch_header( - project_id=project_id, session_id=session_id - ) - interactions = await self.interactions_service.query_interactions( - project_id=project_id, - query=SessionInteractionQuery( - session_id=session_id, status=SessionInteractionStatus.pending - ), - ) - inputs = await self.inputs_service.list_pending( - project_id=project_id, session_id=session_id - ) - state = "idle" - execution_id = stream.turn_id if stream else None - if stream and stream.stopping_turn_id: - state = "stopping" - execution_id = stream.stopping_turn_id - elif stream and stream.flags.is_running: - state = "running" - return SessionSnapshotResponse( - session=stream, - execution=SessionExecutionSnapshot(id=execution_id, state=state), - pending=SessionPendingSnapshot(inputs=inputs, interactions=interactions), - capabilities=_session_capabilities(), - ) - @intercept_exceptions() async def remove_pending_input( self, request: Request, session_id: str, input_id: UUID diff --git a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py index 3e10f1ee8cb..83969e4fdf3 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py @@ -5,8 +5,9 @@ from fastapi import FastAPI, Request from oss.src.apis.fastapi.sessions.router import SessionsRootRouter +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState from oss.src.core.sessions.records.dtos import SessionRecordsReadState -from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.core.sessions.streams.dtos import SessionStream, SessionStreamFlags from oss.src.utils.env import env @@ -108,3 +109,118 @@ async def test_snapshot_forces_incomplete_when_stream_marker_is_present(): ) assert snapshot.read.history_complete is False + + +@pytest.mark.asyncio +async def test_snapshot_carries_the_queue_half_when_the_inputs_service_is_wired(): + """One route serves both readers. + + Milestone 2's live preview reads `session`, `execution` and `read`; the durable queue reads + `execution_state`, `pending.inputs` and `capabilities`. Both halves come from this one call, + so a client can never see a snapshot and a capability report that disagree. + """ + project_id = uuid4() + stream = SessionStream( + id=uuid4(), + project_id=project_id, + session_id="session-1", + turn_id="turn-live", + flags=SessionStreamFlags(is_alive=True, is_running=True, is_attached=False), + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=9, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + pending = PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"data": {"inputs": {"messages": []}}}, + position=1, + state=PendingInputState.pending, + policy="queue", + idempotency_key="key-1", + request_fingerprint="fingerprint-1", + ) + inputs = AsyncMock() + inputs.list_pending.return_value = [pending] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch.object(env.agenta.sessions, "durable_approvals", True), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + # The reconnect half is unchanged. + assert snapshot.session.session_id == "session-1" + assert snapshot.read.latest_sequence == 9 + # The queue half rides along. + assert snapshot.execution_state.state == "running" + assert snapshot.execution_state.id == "turn-live" + assert [item.id for item in snapshot.pending.inputs] == [pending.id] + assert snapshot.capabilities.durable_approvals is True + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + + +@pytest.mark.asyncio +async def test_snapshot_reports_an_empty_queue_without_the_inputs_service(): + project_id = uuid4() + stream = SessionStream(id=uuid4(), project_id=project_id, session_id="session-1") + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=0, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + assert snapshot.pending.inputs == [] + assert snapshot.execution_state.state == "idle" diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 21d235aaaff..5c9723b6a59 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2453,63 +2453,6 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "DELETE", "/sessions/"); } - /** Fetch the durable execution and pending-input snapshot for a session. */ - public fetchSessionSnapshot( - request: AgentaApi.FetchSessionSnapshotRequest, - requestOptions?: SessionsClient.RequestOptions, - ): core.HttpResponsePromise { - return core.HttpResponsePromise.fromPromise(this.__fetchSessionSnapshot(request, requestOptions)); - } - - private async __fetchSessionSnapshot( - request: AgentaApi.FetchSessionSnapshotRequest, - requestOptions?: SessionsClient.RequestOptions, - ): Promise> { - const { session_id: sessionId } = request; - const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); - const _headers: core.Fetcher.Args["headers"] = mergeHeaders( - _authRequest.headers, - this._options?.headers, - requestOptions?.headers, - ); - const _response = await core.fetcher({ - url: core.url.join( - (await core.Supplier.get(this._options.baseUrl)) ?? - (await core.Supplier.get(this._options.environment)) ?? - environments.AgentaApiEnvironment.Default, - `sessions/${core.url.encodePathParam(sessionId)}`, - ), - method: "GET", - headers: _headers, - queryParameters: requestOptions?.queryParams, - timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, - maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, - withCredentials: true, - abortSignal: requestOptions?.abortSignal, - fetchFn: this._options?.fetch, - logging: this._options.logging, - }); - if (_response.ok) { - return { data: _response.body as AgentaApi.SessionSnapshotResponse, rawResponse: _response.rawResponse }; - } - if (_response.error.reason === "status-code") { - switch (_response.error.statusCode) { - case 422: - throw new AgentaApi.UnprocessableEntityError( - _response.error.body as AgentaApi.HttpValidationError, - _response.rawResponse, - ); - default: - throw new errors.AgentaApiError({ - statusCode: _response.error.statusCode, - body: _response.error.body, - rawResponse: _response.rawResponse, - }); - } - } - return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); - } - /** Remove a pending input before it is promoted. */ public removePendingSessionInput( request: AgentaApi.RemovePendingSessionInputRequest, diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts deleted file mode 100644 index 04d6595b5f9..00000000000 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/FetchSessionSnapshotRequest.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -export interface FetchSessionSnapshotRequest { - session_id: string; -} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 474e3c53195..432d82e7393 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -8,7 +8,6 @@ export type { DownloadSessionAttachmentContentRequest } from "./DownloadSessionA export type { DownloadSessionMountFileRequest } from "./DownloadSessionMountFileRequest.js"; export type { FetchInteractionRequest } from "./FetchInteractionRequest.js"; export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; -export type { FetchSessionSnapshotRequest } from "./FetchSessionSnapshotRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; export type { FetchTurnRequest } from "./FetchTurnRequest.js"; export type { GetSessionSnapshotRequest } from "./GetSessionSnapshotRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts deleted file mode 100644 index e814d1014d9..00000000000 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionPendingSnapshot.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -import type * as AgentaApi from "../index.js"; - -export interface SessionPendingSnapshot { - inputs?: AgentaApi.PendingInput[] | undefined; - interactions?: AgentaApi.SessionInteraction[] | undefined; -} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts index 9a107715202..d82246d13dd 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts @@ -3,6 +3,6 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotPending { - inputs?: unknown[] | undefined; + inputs?: AgentaApi.PendingInput[] | undefined; interactions?: AgentaApi.SessionInteraction[] | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts index 6655c2b2167..faa917c72a5 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -3,8 +3,10 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotResponse { - session?: (AgentaApi.SessionStream | null) | undefined; - execution?: AgentaApi.SessionExecutionSnapshot | undefined; - pending?: AgentaApi.SessionPendingSnapshot | undefined; + session: AgentaApi.SessionStream; + execution?: (AgentaApi.SessionTurn | null) | undefined; + execution_state?: AgentaApi.SessionExecutionSnapshot | undefined; + pending: AgentaApi.SessionSnapshotPending; + read: AgentaApi.SessionRecordsReadState; capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 2a1fbb5aa30..90cc78d924b 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -402,7 +402,6 @@ export * from "./SessionInteractionStatus.js"; export * from "./SessionInteractionsResponse.js"; export * from "./SessionListItem.js"; export * from "./SessionMessagePreview.js"; -export * from "./SessionPendingSnapshot.js"; export * from "./SessionMount.js"; export * from "./SessionMountQuery.js"; export * from "./SessionMountsResponse.js"; diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts index f9a8f30ac40..bd6dc62936b 100644 --- a/web/packages/agenta-chat/src/assets/pendingInputs.ts +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -1,4 +1,4 @@ -import type {PendingSessionInput, SessionSnapshotResponse} from "@agenta/entities/session" +import type {PendingSessionInput, SessionSnapshot} from "@agenta/entities/session" import type {FileUIPart} from "ai" import type {QueuedMessage} from "../hooks/useAgentChatQueue" @@ -81,13 +81,13 @@ export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedM } export const reduceSessionPendingInputs = ( - snapshot: SessionSnapshotResponse | null, + snapshot: SessionSnapshot | null, ): SessionPendingInputView => ({ capabilities: { queue: snapshot?.capabilities.queue ?? false, steer: snapshot?.capabilities.steer ?? false, }, - executionState: snapshot?.execution.state ?? "idle", + executionState: snapshot?.execution_state.state ?? "idle", queued: (snapshot?.pending.inputs ?? []) .filter((input) => input.state === "pending" || input.state === "promoted") .sort((left, right) => left.position - right.position) diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts index c4e22b8dc78..f6a77f869f2 100644 --- a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -25,8 +25,14 @@ const input = ( describe("pending input reducer", () => { it("orders the server snapshot and preserves Steer priority", () => { const view = reduceSessionPendingInputs({ - session: null, - execution: {id: "run-1", state: "stopping"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "run-1", state: "stopping"}, + read: {latest_sequence: 0, history_complete: true}, pending: { inputs: [input("older", 20, "queued"), input("steer", 10, "redirect", "steer")], interactions: [], @@ -72,8 +78,14 @@ describe("pending input reducer", () => { const recoverable = input("input-1", 1, "retry me", "queue", "promoted") const view = reduceSessionPendingInputs({ - session: null, - execution: {id: null, state: "idle"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: null, state: "idle"}, + read: {latest_sequence: 0, history_complete: true}, pending: {inputs: [recoverable], interactions: []}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index b86aec2dfd7..29d579a74de 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -300,8 +300,14 @@ afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { it("keeps a Steer draft when durable admission is refused", async () => { snapshotViaAtom.mockResolvedValue({ - session: null, - execution: {id: "turn-1", state: "running"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, pending: {inputs: [], interactions: []}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index f6447994dab..dec5f18d9ff 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -82,10 +82,17 @@ interface PendingInput { promoted_execution_id: null } +/** The unified snapshot: the reconnect half plus the queue half, as the API now returns it. */ const runningSnapshot = (inputs: PendingInput[] = []) => ({ - session: null, - execution: {id: "turn-1", state: "running" as const}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running" as const}, pending: {inputs, interactions: []}, + read: {latest_sequence: 0, history_complete: true}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) @@ -263,8 +270,14 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea describe("useServerSessionInputs", () => { it("reads queue support from the snapshot and submits durable admission", async () => { fetchSnapshot.mockResolvedValue({ - session: null, - execution: {id: "turn-1", state: "running"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, pending: {inputs: [], interactions: []}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) @@ -316,8 +329,14 @@ describe("useServerSessionInputs", () => { it("releases admission after a fresh run's headers while its response keeps streaming", async () => { fetchSnapshot.mockResolvedValue({ - session: null, - execution: {id: "turn-1", state: "running"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, pending: {inputs: [], interactions: []}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) @@ -356,8 +375,14 @@ describe("useServerSessionInputs", () => { it("rejects a refused Steer admission", async () => { fetchSnapshot.mockResolvedValue({ - session: null, - execution: {id: "turn-1", state: "running"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, pending: {inputs: [], interactions: []}, capabilities: {durable_approvals: true, queue: true, steer: true}, }) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 0d6bd51b941..692d7dc9348 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -24,7 +24,6 @@ import { sessionStreamSchema, sessionMountsResponseSchema, sessionStreamResponseSchema, - sessionSnapshotResponseSchema, sessionStreamsResponseSchema, type MountFile, type Mount, @@ -37,7 +36,6 @@ import { type SessionOrigin, type SessionStream, type SessionStreamCommandResponse, - type SessionSnapshotResponse, type SessionsQueryResponse, type SessionWindowing, } from "../core/schema" @@ -154,24 +152,29 @@ export interface SessionScopedParams { abortSignal?: AbortSignal } +/** + * The one snapshot read. It carries the reconnect half (the stream row, the last turn, the + * durable watermark) and the queue half (the current lifecycle, the pending inputs, the feature + * capabilities), so the live preview and the durable queue never ask two endpoints that can + * disagree. + */ export async function fetchSessionSnapshot({ sessionId, projectId, appId, abortSignal, -}: SessionScopedParams): Promise { +}: SessionScopedParams): Promise { if (!projectId || !sessionId) return null const data = await callFern("[fetchSessionSnapshot]", () => - getSessionsClient().fetchSessionSnapshot( + getSessionsClient().getSessionSnapshot( {session_id: sessionId}, projectScopedRequest(projectId, appId, abortSignal), ), ) if (!data) return null - return ( - safeParseWithLogging(sessionSnapshotResponseSchema, data, "[fetchSessionSnapshot]") ?? null - ) + + return safeParseWithLogging(sessionSnapshotSchema, data, "[fetchSessionSnapshot]") ?? null } export async function removePendingSessionInput({ diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 010c3dfdca5..bbb773a7edd 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -254,15 +254,50 @@ export const sessionRecordsReadStateSchema = z.object({ history_complete: z.boolean(), }) -/** Atomic reconnect read: durable watermark plus lifecycle and pending-work context. */ +export const pendingSessionInputSchema = z.object({ + id: z.string(), + session_id: z.string(), + content: z.record(z.string(), z.unknown()), + position: z.number(), + state: z.enum(["pending", "promoted", "removed"]), + policy: z.enum(["queue", "steer"]), + created_at: z.string().nullish(), + promoted_execution_id: z.string().nullish(), +}) + +/** + * Atomic read for every reader of an open session. + * + * The reconnect half is `session`, `execution` and `read`: the stream row, the last turn (whose + * `end_time` says whether it is still live), and the durable watermark to replay from. + * + * The queue half is `execution_state` and `pending.inputs`. `execution_state` is the session's + * CURRENT lifecycle, derived server-side from the stream row, which is a different question from + * `execution`: that names the last turn, this says whether anything is running right now. + * `capabilities` mirrors the streams endpoint from the same server helper, so the two can never + * disagree. + */ export const sessionSnapshotSchema = z.object({ session: sessionStreamSchema, execution: z.record(z.string(), z.unknown()).nullable().optional(), + execution_state: z + .object({ + id: z.string().nullish(), + state: z.enum(["idle", "running", "stopping"]).default("idle"), + }) + .default({state: "idle"}), pending: z.object({ - inputs: z.array(z.unknown()).default([]), + inputs: z.array(pendingSessionInputSchema).default([]), interactions: z.array(sessionInteractionSchema).default([]), }), read: sessionRecordsReadStateSchema, + capabilities: z + .object({ + durable_approvals: z.boolean().optional().default(false), + queue: z.boolean().optional().default(false), + steer: z.boolean().optional().default(false), + }) + .default({durable_approvals: false, queue: false, steer: false}), }) export const sessionStreamsResponseSchema = z.object({ @@ -289,40 +324,6 @@ export const sessionStreamResponseSchema = z.object({ .default({durable_approvals: false}), }) -export const pendingSessionInputSchema = z.object({ - id: z.string(), - session_id: z.string(), - content: z.record(z.string(), z.unknown()), - position: z.number(), - state: z.enum(["pending", "promoted", "removed"]), - policy: z.enum(["queue", "steer"]), - created_at: z.string().nullish(), - promoted_execution_id: z.string().nullish(), -}) - -export const sessionSnapshotResponseSchema = z.object({ - session: sessionStreamSchema.nullish(), - execution: z - .object({ - id: z.string().nullish(), - state: z.enum(["idle", "running", "stopping"]).default("idle"), - }) - .default({state: "idle"}), - pending: z - .object({ - inputs: z.array(pendingSessionInputSchema).default([]), - interactions: z.array(sessionInteractionSchema).default([]), - }) - .default({inputs: [], interactions: []}), - capabilities: z - .object({ - durable_approvals: z.boolean().optional().default(false), - queue: z.boolean().optional().default(false), - steer: z.boolean().optional().default(false), - }) - .default({durable_approvals: false, queue: false, steer: false}), -}) - /** Control-call result for the prompt × force command matrix. */ export const sessionStreamCommandResponseSchema = z.object({ mode: z.string(), @@ -361,7 +362,6 @@ export type SessionWindowing = z.infer export type SessionsQueryResponse = z.infer export type SessionStreamCommandResponse = z.infer export type PendingSessionInput = z.infer -export type SessionSnapshotResponse = z.infer /** One entry in a mount's durable file listing. `path` is relative to the mount root; folders * are flagged (`is_folder`) or implied by nested file paths. The backend lists the whole tree diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 5f676ed2726..dd8a909c208 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -71,7 +71,6 @@ export { sessionRecordsReadStateSchema, sessionSnapshotSchema, pendingSessionInputSchema, - sessionSnapshotResponseSchema, sessionsQueryResponseSchema, type SessionRecord, type SessionRecordsQueryResponse, @@ -96,7 +95,6 @@ export { type SessionWindowing, type SessionStreamCommandResponse, type PendingSessionInput, - type SessionSnapshotResponse, type StreamStatusCode, type CommandMode, mountFileSchema, diff --git a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts index 45778987a99..7a8d9a5cc12 100644 --- a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts @@ -7,7 +7,7 @@ const {fetchSnapshot, removeInput} = vi.hoisted(() => ({ vi.mock("@agenta/sdk/resources", () => ({ getSessionsClient: () => ({ - fetchSessionSnapshot: fetchSnapshot, + getSessionSnapshot: fetchSnapshot, removePendingSessionInput: removeInput, }), getLowPrioritySessionsClient: vi.fn(), @@ -28,9 +28,15 @@ beforeEach(() => { describe("session pending-input API", () => { it("reads the shared snapshot through the scoped Fern client", async () => { fetchSnapshot.mockResolvedValue({ - session: null, - execution: {state: "running"}, + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session/1", + }, + execution: null, + execution_state: {state: "running"}, pending: {inputs: [], interactions: []}, + read: {latest_sequence: 0, history_complete: true}, capabilities: {queue: true, steer: false}, }) From dcf47536278ab57070d820a0626bfc23d2ec93ee Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 15:40:23 +0200 Subject: [PATCH 087/133] fix(sessions): keep the queue snapshot reachable without a stream Return queue lifecycle, pending work, and capabilities when the shared reader is disabled or a fresh session has no stream row yet. Make reconnect-only fields nullable across the API and client contracts, and keep reconnect readers from consuming missing session data. Add API and client regressions for flag-off, fresh-session, full-contract, and queue-enable behavior. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/models.py | 11 ++- api/oss/src/apis/fastapi/sessions/router.py | 53 +++++----- .../unit/sessions/test_session_snapshot.py | 98 +++++++++++++++++++ .../api/types/SessionSnapshotResponse.ts | 4 +- .../src/hooks/useSessionLivePreview.ts | 8 +- .../agenta-chat/src/model/livePreview.ts | 2 +- .../unit/hooks/useServerSessionInputs.test.ts | 24 +++++ .../unit/hooks/useSessionLivePreview.test.tsx | 34 +++++++ .../src/session/core/schema.ts | 10 +- .../unit/session-pending-input-api.test.ts | 21 ++++ 10 files changed, 221 insertions(+), 44 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 4235d35bc59..1cc67c095a2 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -208,9 +208,10 @@ class SessionSnapshotPending(BaseModel): class SessionSnapshotResponse(BaseModel): """One snapshot for every reader of an open session. - `session`, `execution` and `read` are the reconnect half: the stream row, the latest turn - (whose `end_time` says whether that turn is still live), and the durable sequence watermark - a reader replays from. + `session`, `execution` and `read` are the nullable reconnect half: the stream row, the + latest turn (whose `end_time` says whether that turn is still live), and the durable + sequence watermark a reader replays from. They are absent when the shared reader is off or + before a fresh session has a stream row. `execution_state` and `pending.inputs` are the queue half. `execution_state` is the session's CURRENT lifecycle derived from the stream row, which is a different question from @@ -219,13 +220,13 @@ class SessionSnapshotResponse(BaseModel): a client never sees the two disagree. """ - session: SessionStream + session: Optional[SessionStream] = None execution: Optional[SessionTurn] = None execution_state: SessionExecutionSnapshot = Field( default_factory=SessionExecutionSnapshot ) pending: SessionSnapshotPending - read: SessionRecordsReadState + read: Optional[SessionRecordsReadState] = None capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index a548a8324b2..3e30e116d13 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -2175,8 +2175,6 @@ async def get_session_snapshot( request: Request, session_id: str, ) -> SessionSnapshotResponse: - if not env.sessions.shared_reader: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) _validate_session_id_http(session_id) if not await check_action_access( user_uid=str(request.state.user_id), @@ -2184,33 +2182,33 @@ async def get_session_snapshot( permission=Permission.VIEW_SESSIONS, ): raise FORBIDDEN_EXCEPTION - if not all( - ( - self.streams_service, - self.records_service, - self.interactions_service, - self.turns_service, - ) + if self.streams_service is None or self.interactions_service is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + if env.sessions.shared_reader and ( + self.records_service is None or self.turns_service is None ): raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) project_id = UUID(str(request.state.project_id)) - session = await self.streams_service.fetch( - project_id=project_id, - session_id=session_id, - ) - if session is None: - raise SessionStreamNotFound(session_id) - read = await self.records_service.get_read_state( - project_id=project_id, - session_id=session_id, - ) - if getattr(session, "history_incomplete", False): - read = read.model_copy(update={"history_complete": False}) - execution = await self.turns_service.latest_turn( + stream = await self.streams_service.fetch( project_id=project_id, session_id=session_id, ) + session = None + execution = None + read = None + if env.sessions.shared_reader and stream is not None: + session = sanitize_session_stream(stream) + read = await self.records_service.get_read_state( + project_id=project_id, + session_id=session_id, + ) + if getattr(stream, "history_incomplete", False): + read = read.model_copy(update={"history_complete": False}) + execution = await self.turns_service.latest_turn( + project_id=project_id, + session_id=session_id, + ) interactions = await self.interactions_service.query_interactions( project_id=project_id, query=SessionInteractionQuery( @@ -2228,20 +2226,19 @@ async def get_session_snapshot( if self.inputs_service is not None else [] ) - # Derived from the stream row, not from `execution`: that names the last turn, this says - # whether anything is running right now, which is what the queue admits against. + # The stream remains the lifecycle source even when its reconnect representation is hidden. execution_state = SessionExecutionSnapshot( - id=session.stopping_turn_id or session.turn_id, + id=(stream.stopping_turn_id or stream.turn_id) if stream else None, state=( "stopping" - if session.stopping_turn_id + if stream and stream.stopping_turn_id else "running" - if session.flags.is_running + if stream and stream.flags.is_running else "idle" ), ) return SessionSnapshotResponse( - session=sanitize_session_stream(session), + session=session, execution=execution, execution_state=execution_state, pending=SessionSnapshotPending(inputs=inputs, interactions=interactions), diff --git a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py index 83969e4fdf3..863ba3156da 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py @@ -26,6 +26,104 @@ def _request(project_id, user_id) -> Request: return request +@pytest.mark.asyncio +async def test_snapshot_keeps_queue_capabilities_when_shared_reader_is_off(): + project_id = uuid4() + stream = SessionStream( + id=uuid4(), + project_id=project_id, + session_id="session-1", + turn_id="turn-live", + flags=SessionStreamFlags(is_alive=True, is_running=True, is_attached=False), + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + inputs = AsyncMock() + inputs.list_pending.return_value = [] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", False), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + assert snapshot.session is None + assert snapshot.execution is None + assert snapshot.read is None + assert snapshot.execution_state.state == "running" + assert snapshot.execution_state.id == "turn-live" + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + records.get_read_state.assert_not_awaited() + turns.latest_turn.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_snapshot_is_idle_before_a_fresh_session_has_a_stream_row(): + project_id = uuid4() + streams = AsyncMock() + streams.fetch.return_value = None + records = AsyncMock() + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + inputs = AsyncMock() + inputs.list_pending.return_value = [] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + assert snapshot.session is None + assert snapshot.execution is None + assert snapshot.read is None + assert snapshot.execution_state.state == "idle" + assert snapshot.execution_state.id is None + assert snapshot.pending.inputs == [] + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + records.get_read_state.assert_not_awaited() + turns.latest_turn.assert_not_awaited() + + @pytest.mark.asyncio async def test_snapshot_groups_session_execution_pending_and_read_watermark(): project_id = uuid4() diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts index faa917c72a5..7c61975ca80 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -3,10 +3,10 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotResponse { - session: AgentaApi.SessionStream; + session?: (AgentaApi.SessionStream | null) | undefined; execution?: (AgentaApi.SessionTurn | null) | undefined; execution_state?: AgentaApi.SessionExecutionSnapshot | undefined; pending: AgentaApi.SessionSnapshotPending; - read: AgentaApi.SessionRecordsReadState; + read?: (AgentaApi.SessionRecordsReadState | null) | undefined; capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts index e2293ec8378..11cc48cde12 100644 --- a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts +++ b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts @@ -157,9 +157,11 @@ export const useSessionLivePreview = ({ if (disposed || currentGeneration !== generation) return const snapshotRunning = isSessionSnapshotRunning(snapshot ?? undefined) setRunningFromSnapshot(snapshotRunning) - if (snapshot && !snapshotRunning) onExecutionSettledRef.current?.() + if (snapshot?.session && snapshot.read && !snapshotRunning) { + onExecutionSettledRef.current?.() + } - if (snapshot && projectId) { + if (snapshot?.session && snapshot.read && projectId) { try { const transcript = await readBoundedTranscript(snapshot.read.latest_sequence) if (!transcript) { @@ -186,7 +188,7 @@ export const useSessionLivePreview = ({ } } durable = createSessionDurableEventState( - snapshot?.read.latest_sequence ?? durable.latestSequence, + snapshot?.read?.latest_sequence ?? durable.latestSequence, ) connection = connectSessionLiveEvents({ sessionId, diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts index 0e552eb9756..fed0b775dbf 100644 --- a/web/packages/agenta-chat/src/model/livePreview.ts +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -73,7 +73,7 @@ export const withoutSharedSenderAcceptanceMessages = (messages: UIMessage[]): UI /** Atomic refresh verdict: the latest execution exists, is not complete, and the session still * owns the running flag from the same snapshot read. */ export const isSessionSnapshotRunning = (snapshot: SessionSnapshot | undefined): boolean => - snapshot?.session.flags?.is_running === true && + snapshot?.session?.flags?.is_running === true && snapshot.execution != null && snapshot.execution.end_time == null diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index dec5f18d9ff..534c2b9e662 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -268,6 +268,30 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea } describe("useServerSessionInputs", () => { + it("enables Queue from a snapshot without reconnect data", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {id: null, state: "idle"}, + read: null, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(result.current.capabilities.steer).toBe(true) + expect(result.current.executionState).toBe("idle") + }) + it("reads queue support from the snapshot and submits durable admission", async () => { fetchSnapshot.mockResolvedValue({ session: { diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx index 195a39b0c10..ea4444a9fbd 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -88,6 +88,40 @@ describe("useSessionLivePreview", () => { expect(result.current.readerReady).toBe(false) }) + it("treats a null-session snapshot as no reconnect data", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {state: "idle"}, + pending: {inputs: [], interactions: []}, + read: null, + capabilities: {queue: true, steer: true}, + }) + const onDisconnect = vi.fn().mockResolvedValue(true) + const onExecutionSettled = vi.fn() + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + onExecutionSettled, + }), + {wrapper}, + ) + + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + expect(mocks.querySessionTranscript).not.toHaveBeenCalled() + expect(onDisconnect).toHaveBeenCalledWith(undefined) + expect(onExecutionSettled).not.toHaveBeenCalled() + }) + it("loads and adopts the transcript through the snapshot before following its cursor", async () => { const records = deferred<[]>() const adopted = deferred() diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index bbb773a7edd..5d5cefef6a5 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -268,8 +268,8 @@ export const pendingSessionInputSchema = z.object({ /** * Atomic read for every reader of an open session. * - * The reconnect half is `session`, `execution` and `read`: the stream row, the last turn (whose - * `end_time` says whether it is still live), and the durable watermark to replay from. + * The nullable reconnect half is `session`, `execution` and `read`: the stream row, the last turn + * (whose `end_time` says whether it is still live), and the durable watermark to replay from. * * The queue half is `execution_state` and `pending.inputs`. `execution_state` is the session's * CURRENT lifecycle, derived server-side from the stream row, which is a different question from @@ -278,8 +278,8 @@ export const pendingSessionInputSchema = z.object({ * disagree. */ export const sessionSnapshotSchema = z.object({ - session: sessionStreamSchema, - execution: z.record(z.string(), z.unknown()).nullable().optional(), + session: sessionStreamSchema.nullish().default(null), + execution: z.record(z.string(), z.unknown()).nullish().default(null), execution_state: z .object({ id: z.string().nullish(), @@ -290,7 +290,7 @@ export const sessionSnapshotSchema = z.object({ inputs: z.array(pendingSessionInputSchema).default([]), interactions: z.array(sessionInteractionSchema).default([]), }), - read: sessionRecordsReadStateSchema, + read: sessionRecordsReadStateSchema.nullish().default(null), capabilities: z .object({ durable_approvals: z.boolean().optional().default(false), diff --git a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts index 7a8d9a5cc12..806390d8bc8 100644 --- a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts @@ -49,6 +49,27 @@ describe("session pending-input API", () => { ) }) + it("accepts a queue snapshot without reconnect data", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {state: "idle"}, + pending: {inputs: [], interactions: []}, + read: null, + capabilities: {queue: true, steer: true}, + }) + + await expect( + readSnapshot({projectId: "project-1", sessionId: "session-1"}), + ).resolves.toMatchObject({ + session: null, + execution: null, + read: null, + execution_state: {state: "idle"}, + capabilities: {queue: true, steer: true}, + }) + }) + it("removes a pending input through the generated route", async () => { removeInput.mockResolvedValue({input: {id: "input-1"}}) From fa993fffbd79b15b05e23f88762275c78852e162 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:20:12 +0200 Subject: [PATCH 088/133] fix(sessions): fail open during capability negotiation Probe session capabilities in the background with a two-second transport deadline and no retries. Cache both supported and unsupported results so legacy sends and approvals never wait on or repeat a failed probe. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../agenta-entities/src/session/api/api.ts | 122 ++++++++++++++---- .../src/session/core/schema.ts | 4 +- .../session-continuation-resume-api.test.ts | 46 ++++++- 3 files changed, 139 insertions(+), 33 deletions(-) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 692d7dc9348..632fef2d4ff 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -195,7 +195,27 @@ export async function removePendingSessionInput({ return !!data } -const durableApprovalsCapabilityCache = new Map>() +const SESSION_CAPABILITY_TIMEOUT_SECONDS = 2 +const SESSION_CAPABILITY_NEGATIVE_RETRY_MS = 30_000 + +interface SessionFeatureCapabilities { + durableApprovals: boolean + queue: boolean + steer: boolean +} + +interface SessionCapabilityCacheEntry { + result?: SessionFeatureCapabilities + retryAt?: number + request?: Promise +} + +const noSessionCapabilities: SessionFeatureCapabilities = { + durableApprovals: false, + queue: false, + steer: false, +} +const durableApprovalsCapabilityCache = new Map() const durableApprovalsCapabilityKey = ({projectId, sessionId}: SessionScopedParams): string => JSON.stringify([projectId, sessionId]) @@ -210,6 +230,77 @@ export function invalidateSessionDurableApprovalsCapability( durableApprovalsCapabilityCache.delete(durableApprovalsCapabilityKey(params)) } +const hasSessionCapability = (capabilities: SessionFeatureCapabilities): boolean => + capabilities.durableApprovals || capabilities.queue || capabilities.steer + +const cachedSessionCapabilities = (key: string): SessionFeatureCapabilities | null => { + const cached = durableApprovalsCapabilityCache.get(key) + if (!cached?.result) return null + if (hasSessionCapability(cached.result) || Date.now() < (cached.retryAt ?? 0)) { + return cached.result + } + return null +} + +const negotiateSessionCapabilities = async ({ + sessionId, + projectId, + appId, + abortSignal, +}: SessionScopedParams): Promise => { + if (!projectId || !sessionId) return noSessionCapabilities + + const key = durableApprovalsCapabilityKey({projectId, sessionId}) + const cached = cachedSessionCapabilities(key) + if (cached) return cached + + const existing = durableApprovalsCapabilityCache.get(key) + if (existing?.request) return existing.request + + const entry: SessionCapabilityCacheEntry = {} + const request = (async () => { + let capabilities = noSessionCapabilities + try { + const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => + getSessionsClient().fetchSessionStream( + {session_id: sessionId}, + { + ...projectScopedRequest(projectId, appId, abortSignal), + timeoutInSeconds: SESSION_CAPABILITY_TIMEOUT_SECONDS, + maxRetries: 0, + }, + ), + ) + const validated = data + ? safeParseWithLogging( + sessionStreamResponseSchema, + data, + "[fetchSessionDurableApprovalsCapability]", + ) + : null + capabilities = { + durableApprovals: validated?.capabilities.durable_approvals ?? false, + queue: validated?.capabilities.queue ?? false, + steer: validated?.capabilities.steer ?? false, + } + } catch { + capabilities = noSessionCapabilities + } + + if (durableApprovalsCapabilityCache.get(key) === entry) { + entry.result = capabilities + entry.retryAt = hasSessionCapability(capabilities) + ? undefined + : Date.now() + SESSION_CAPABILITY_NEGATIVE_RETRY_MS + entry.request = undefined + } + return capabilities + })() + entry.request = request + durableApprovalsCapabilityCache.set(key, entry) + return request +} + export interface QueryInteractionsParams extends Omit { /** Omit for a PROJECT-WIDE query — the backend treats `session_id` as optional, so one call * returns every matching interaction across the project (the pending-approvals badge @@ -725,32 +816,11 @@ export async function fetchSessionDurableApprovalsCapability({ if (!projectId || !sessionId) return false const key = durableApprovalsCapabilityKey({projectId, sessionId}) - const cached = durableApprovalsCapabilityCache.get(key) - if (cached) return cached + const cached = cachedSessionCapabilities(key) + if (cached) return cached.durableApprovals - let request: Promise | undefined - request = (async () => { - const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => - getSessionsClient().fetchSessionStream( - {session_id: sessionId}, - projectScopedRequest(projectId, appId, abortSignal), - ), - ) - if (!data) { - if (durableApprovalsCapabilityCache.get(key) === request) { - durableApprovalsCapabilityCache.delete(key) - } - return false - } - const validated = safeParseWithLogging( - sessionStreamResponseSchema, - data, - "[fetchSessionDurableApprovalsCapability]", - ) - return validated?.capabilities.durable_approvals ?? false - })() - durableApprovalsCapabilityCache.set(key, request) - return request + void negotiateSessionCapabilities({sessionId, projectId, appId, abortSignal}) + return false } export interface CommandSessionStreamParams extends SessionScopedParams { diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 5d5cefef6a5..7c1087b2a3f 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -319,9 +319,11 @@ export const sessionStreamResponseSchema = z.object({ capabilities: z .object({ durable_approvals: z.boolean().optional().default(false), + queue: z.boolean().optional().default(false), + steer: z.boolean().optional().default(false), }) .optional() - .default({durable_approvals: false}), + .default({durable_approvals: false, queue: false, steer: false}), }) /** Control-call result for the prompt × force command matrix. */ diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 1d5f5bc6626..35c555773fe 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -72,12 +72,16 @@ describe("fetchSessionDurableApprovalsCapability", () => { capabilities: {durable_approvals: true}, }) - await expect( - fetchSessionDurableApprovalsCapability({ - projectId: "project-1", - sessionId: "session-1", - }), - ).resolves.toBe(true) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(false) + await vi.waitFor(() => + expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true), + ) + expect(fetchStream).toHaveBeenCalledWith( + {session_id: "session-1"}, + expect.objectContaining({timeoutInSeconds: 2, maxRetries: 0}), + ) }) it("shares one request per session until the session reconnects", async () => { @@ -91,6 +95,7 @@ describe("fetchSessionDurableApprovalsCapability", () => { fetchSessionDurableApprovalsCapability(scope), fetchSessionDurableApprovalsCapability(scope), ]) + await vi.waitFor(() => expect(fetchStream).toHaveBeenCalledTimes(1)) await fetchSessionDurableApprovalsCapability(scope) expect(fetchStream).toHaveBeenCalledTimes(1) @@ -114,4 +119,33 @@ describe("fetchSessionDurableApprovalsCapability", () => { }), ).resolves.toBe(false) }) + + it("does not delay a legacy send while capability negotiation is slow", async () => { + fetchStream.mockImplementation(() => new Promise(() => undefined)) + const prepare = vi.fn().mockResolvedValue("legacy send") + + const capability = await fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }) + const result = capability ? "durable path" : await prepare() + + expect(result).toBe("legacy send") + expect(prepare).toHaveBeenCalledOnce() + expect(fetchStream).toHaveBeenCalledOnce() + }) + + it("caches a failed negotiation instead of retrying it on every send", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined) + fetchStream.mockRejectedValue(new Error("route unavailable")) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await fetchSessionDurableApprovalsCapability(scope) + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()) + await fetchSessionDurableApprovalsCapability(scope) + await fetchSessionDurableApprovalsCapability(scope) + + expect(fetchStream).toHaveBeenCalledOnce() + error.mockRestore() + }) }) From 0be8e1659b08cbcf1cda2299b547965a773c1c05 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:23:15 +0200 Subject: [PATCH 089/133] fix(chat): keep remote stop behind queue capability Only advertise composer Stop and Escape for server-owned runs when queue control is available. Local legacy streams remain stoppable, while mobile keeps one strip Stop for a remote flag-off run. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/mobile/src/features/chat/Composer.tsx | 1 + web/mobile/tests/unit/turnStatus.test.ts | 15 +++++++++++ .../assets/composerRunState.test.ts | 26 ++++++++++++++++++ .../components/AgentComposerDock.tsx | 1 + .../src/assets/composerRunState.ts | 4 ++- .../unit/ChatComposer.runControls.test.tsx | 27 +++++++++++++++++++ .../unit/hooks/useServerSessionInputs.test.ts | 1 + 7 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 8b90561464c..7a8ee10ea40 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -71,6 +71,7 @@ export const Composer = ({ const stoppable = isComposerRunStoppable({ localStreaming: streaming, serverBusy: inputBusy, + serverControlEnabled: queueEnabled, waitingOnUser, }) diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index 5ece0284506..510720b718e 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -1,3 +1,4 @@ +import {isComposerRunStoppable} from "@agenta/chat/assets" import {describe, expect, it} from "vitest" import { @@ -99,4 +100,18 @@ describe("showRunningElsewhere", () => { it("keeps a locally parked gate from being labeled remote", () => { expect(showRunningElsewhere({running: true, localStatus: "awaiting"})).toBe(false) }) + + it("renders exactly one Stop for a flag-off remote run", () => { + const stripStop = showRunningElsewhere({running: true, localStatus: "idle"}) + const composerStop = isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }) + + expect([stripStop, composerStop].filter(Boolean)).toHaveLength(1) + expect(stripStop).toBe(true) + expect(composerStop).toBe(false) + }) }) diff --git a/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts b/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts new file mode 100644 index 00000000000..ca36ef7828b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts @@ -0,0 +1,26 @@ +import {isComposerRunStoppable} from "@agenta/chat/assets" +import {describe, expect, it} from "vitest" + +describe("desktop composer run state", () => { + it("does not expose Stop for another browser's run when capabilities are absent", () => { + expect( + isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }), + ).toBe(false) + }) + + it("keeps this browser's legacy stream stoppable when capabilities are absent", () => { + expect( + isComposerRunStoppable({ + localStreaming: true, + serverBusy: false, + serverControlEnabled: false, + waitingOnUser: false, + }), + ).toBe(true) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index a3503712ff2..1db78e5ccec 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -153,6 +153,7 @@ const AgentComposerDock = ({ const stoppable = isComposerRunStoppable({ localStreaming: busy, serverBusy: queue.serverBusy, + serverControlEnabled: queueEnabled, waitingOnUser: hitlPending, }) const { diff --git a/web/packages/agenta-chat/src/assets/composerRunState.ts b/web/packages/agenta-chat/src/assets/composerRunState.ts index 026ba0343be..8011e97ccc7 100644 --- a/web/packages/agenta-chat/src/assets/composerRunState.ts +++ b/web/packages/agenta-chat/src/assets/composerRunState.ts @@ -1,9 +1,11 @@ export const isComposerRunStoppable = ({ localStreaming, serverBusy, + serverControlEnabled, waitingOnUser, }: { localStreaming: boolean serverBusy: boolean + serverControlEnabled: boolean waitingOnUser: boolean -}): boolean => (localStreaming || serverBusy) && !waitingOnUser +}): boolean => (localStreaming || (serverBusy && serverControlEnabled)) && !waitingOnUser diff --git a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx index 0c5b033ba5f..f127696ffcc 100644 --- a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx @@ -29,11 +29,13 @@ const attachments = { const renderComposer = async ({ localStreaming, serverBusy = false, + serverControlEnabled = false, queued = false, busyActions, }: { localStreaming: boolean serverBusy?: boolean + serverControlEnabled?: boolean queued?: boolean busyActions?: {label: string; onSubmit: (text: string) => void}[] }) => { @@ -41,6 +43,7 @@ const renderComposer = async ({ const streaming = isComposerRunStoppable({ localStreaming, serverBusy, + serverControlEnabled, waitingOnUser: false, }) render( @@ -87,6 +90,7 @@ describe("ChatComposer running controls", () => { const onStop = await renderComposer({ localStreaming: false, serverBusy: true, + serverControlEnabled: true, queued: true, busyActions: [ {label: "Queue", onSubmit: vi.fn()}, @@ -101,4 +105,27 @@ describe("ChatComposer running controls", () => { fireEvent.keyDown(document, {key: "Escape"}) expect(onStop).toHaveBeenCalledOnce() }) + + it("keeps a flag-off remote run out of the desktop composer controls", () => { + const onStop = vi.fn() + const streaming = isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }) + + render( + , + ) + + expect(screen.queryByRole("button", {name: "Stop"})).toBeNull() + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).not.toHaveBeenCalled() + }) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 534c2b9e662..3a483047665 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -161,6 +161,7 @@ const RunningElsewhereAdmissionHarness = ({ const stoppable = isComposerRunStoppable({ localStreaming: false, serverBusy: server.busy, + serverControlEnabled: queue.queueEnabled, waitingOnUser: false, }) From 34cca19e122fdc741a6f6b9e6a2b5e4445efb40c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:28:49 +0200 Subject: [PATCH 090/133] fix(chat): avoid flag-off queue snapshot loads Negotiate queue support before loading the unified snapshot, share concurrent mount refreshes, and refresh only after a real transition into a settled chat state. Flag-off sessions now make no queue snapshot request. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/AgentConversation.tsx | 7 ++- .../src/hooks/useAgentConversation.ts | 7 ++- .../src/hooks/useServerSessionInputs.ts | 44 +++++++++++--- .../unit/hooks/useAgentConversation.test.ts | 14 ++++- .../unit/hooks/useServerSessionInputs.test.ts | 60 ++++++++++++++++++- .../agenta-entities/src/session/api/api.ts | 6 +- .../agenta-entities/src/session/index.ts | 8 ++- .../src/session/state/pendingInputs.ts | 7 ++- .../session-continuation-resume-api.test.ts | 21 +++++++ 9 files changed, 157 insertions(+), 17 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 5b9298667a4..c59ff0c52d2 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -377,8 +377,13 @@ const AgentConversation = ({ onExecuted: revalidate, }) + const previousServerInputsStatusRef = useRef(status) useEffect(() => { - if (status === "ready" || status === "error") void serverInputs.refresh() + const previousStatus = previousServerInputsStatusRef.current + previousServerInputsStatusRef.current = status + if (previousStatus !== status && (status === "ready" || status === "error")) { + void serverInputs.refresh() + } }, [status, serverInputs.refresh]) // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 067b9510c9f..d965288e1c1 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -671,8 +671,13 @@ export const useAgentConversation = ({ }, }) + const previousServerInputsStatusRef = useRef(status) useEffect(() => { - if (status === "ready" || status === "error") void serverInputs.refresh() + const previousStatus = previousServerInputsStatusRef.current + previousServerInputsStatusRef.current = status + if (previousStatus !== status && (status === "ready" || status === "error")) { + void serverInputs.refresh() + } }, [status, serverInputs.refresh]) // Queue messages typed while a turn is streaming or paused on a HITL approval; released diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index ac24c2c4d9d..dd207f02e4b 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -1,6 +1,10 @@ import {useCallback, useEffect, useRef, useState} from "react" -import {fetchSessionSnapshotAtom, removePendingSessionInputAtom} from "@agenta/entities/session" +import { + fetchSessionCapabilitiesAtom, + fetchSessionSnapshotAtom, + removePendingSessionInputAtom, +} from "@agenta/entities/session" import {buildAgentRequest} from "@agenta/playground/agent-chat" import type {UIMessage} from "ai" import {useSetAtom} from "jotai" @@ -35,6 +39,7 @@ export const useServerSessionInputs = ({ onExecuted?: () => void }): ServerSessionInputs => { const fetchSnapshot = useSetAtom(fetchSessionSnapshotAtom) + const fetchCapabilities = useSetAtom(fetchSessionCapabilitiesAtom) const removeInput = useSetAtom(removePendingSessionInputAtom) const [viewState, setViewState] = useState<{sessionId: string; view: SessionPendingInputView}>( () => ({sessionId, view: emptyView}), @@ -43,26 +48,49 @@ export const useServerSessionInputs = ({ const messagesRef = useRef(messages) const entityIdRef = useRef(entityId) const onExecutedRef = useRef(onExecuted) + const loadInFlightRef = useRef<{ + sessionId: string + promise: Promise + } | null>(null) messagesRef.current = messages entityIdRef.current = entityId onExecutedRef.current = onExecuted + const load = useCallback((): Promise => { + if (loadInFlightRef.current?.sessionId === sessionId) { + return loadInFlightRef.current.promise + } + const promise = (async () => { + const capabilities = await fetchCapabilities(sessionId) + if (!capabilities.queue) return emptyView + const snapshot = await fetchSnapshot(sessionId) + return snapshot ? reduceSessionPendingInputs(snapshot) : null + })() + const entry = {sessionId, promise} + loadInFlightRef.current = entry + const clear = () => { + if (loadInFlightRef.current === entry) loadInFlightRef.current = null + } + void promise.then(clear, clear) + return promise + }, [fetchCapabilities, fetchSnapshot, sessionId]) + const refresh = useCallback(async () => { - const snapshot = await fetchSnapshot(sessionId) - if (snapshot) setViewState({sessionId, view: reduceSessionPendingInputs(snapshot)}) - }, [fetchSnapshot, sessionId]) + const next = await load() + if (next) setViewState({sessionId, view: next}) + }, [load, sessionId]) useEffect(() => { let cancelled = false - void fetchSnapshot(sessionId).then((snapshot) => { - if (!cancelled && snapshot) { - setViewState({sessionId, view: reduceSessionPendingInputs(snapshot)}) + void load().then((next) => { + if (!cancelled && next) { + setViewState({sessionId, view: next}) } }) return () => { cancelled = true } - }, [fetchSnapshot, sessionId]) + }, [load, sessionId]) // Pending-input events arrive in a later increment. Until then, a small capability-gated // snapshot poll gives every mounted browser the same durable order. diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 925a0c4ffb6..acac99f0338 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -21,7 +21,8 @@ import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -const {snapshotViaAtom, resumeContinuation} = vi.hoisted(() => ({ +const {capabilitiesViaAtom, snapshotViaAtom, resumeContinuation} = vi.hoisted(() => ({ + capabilitiesViaAtom: vi.fn(), snapshotViaAtom: vi.fn(), resumeContinuation: vi.fn(), })) @@ -54,10 +55,14 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { fetchSessionInteractionStatesAtom: atom(null, () => new Map()), fetchSessionSnapshot: vi.fn(), querySessionTranscript: vi.fn(), + fetchSessionCapabilitiesAtom: atom(null, (_get, _set, sessionId: string) => + capabilitiesViaAtom(sessionId), + ), fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => snapshotViaAtom(sessionId), ), resumeSessionContinuationAtom: atom(null, () => resumeContinuation()), + sessionDurableApprovalsCapabilityAtom: atom(null, () => false), } }) @@ -283,6 +288,8 @@ beforeEach(() => { vi.mocked(querySessionTranscript).mockResolvedValue([]) snapshotViaAtom.mockReset() snapshotViaAtom.mockResolvedValue(null) + capabilitiesViaAtom.mockReset() + capabilitiesViaAtom.mockResolvedValue({durableApprovals: false, queue: false, steer: false}) resumeContinuation.mockReset() resumeContinuation.mockResolvedValue(false) vi.mocked(buildAgentRequest).mockClear() @@ -299,6 +306,11 @@ afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { it("keeps a Steer draft when durable admission is refused", async () => { + capabilitiesViaAtom.mockResolvedValue({ + durableApprovals: true, + queue: true, + steer: true, + }) snapshotViaAtom.mockResolvedValue({ session: { id: "11111111-1111-4111-8111-111111111111", diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 3a483047665..5720a572830 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -15,8 +15,9 @@ import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" -const {buildAgentRequest, fetchSnapshot, removeInput} = vi.hoisted(() => ({ +const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput} = vi.hoisted(() => ({ buildAgentRequest: vi.fn(), + fetchCapabilities: vi.fn(), fetchSnapshot: vi.fn(), removeInput: vi.fn(), })) @@ -24,6 +25,9 @@ const {buildAgentRequest, fetchSnapshot, removeInput} = vi.hoisted(() => ({ vi.mock("@agenta/entities/session", async () => { const {atom} = await import("jotai") return { + fetchSessionCapabilitiesAtom: atom(null, (_get, _set, sessionId: string) => + fetchCapabilities(sessionId), + ), fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => fetchSnapshot(sessionId), ), @@ -64,6 +68,8 @@ beforeAll(() => { beforeEach(() => { buildAgentRequest.mockReset() + fetchCapabilities.mockReset() + fetchCapabilities.mockResolvedValue({durableApprovals: true, queue: true, steer: true}) fetchSnapshot.mockReset() removeInput.mockReset() fetchMock.mockReset() @@ -269,6 +275,28 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea } describe("useServerSessionInputs", () => { + it("does not request a queue snapshot when the capability is absent", async () => { + fetchCapabilities.mockResolvedValue({ + durableApprovals: false, + queue: false, + steer: false, + }) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(fetchCapabilities).toHaveBeenCalledOnce()) + expect(fetchSnapshot).not.toHaveBeenCalled() + expect(result.current.capabilities).toEqual({queue: false, steer: false}) + expect(result.current.busy).toBe(false) + }) + it("enables Queue from a snapshot without reconnect data", async () => { fetchSnapshot.mockResolvedValue({ session: null, @@ -289,10 +317,40 @@ describe("useServerSessionInputs", () => { ) await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(fetchSnapshot).toHaveBeenCalledOnce() expect(result.current.capabilities.steer).toBe(true) expect(result.current.executionState).toBe("idle") }) + it("shares the mount load with an immediate ready-state refresh", async () => { + let resolveSnapshot!: (snapshot: ReturnType) => void + fetchSnapshot.mockImplementation( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }), + ) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(fetchSnapshot).toHaveBeenCalledOnce()) + await act(async () => { + const refresh = result.current.refresh() + resolveSnapshot(runningSnapshot()) + await refresh + }) + + expect(fetchSnapshot).toHaveBeenCalledOnce() + expect(result.current.executionState).toBe("running") + }) + it("reads queue support from the snapshot and submits durable admission", async () => { fetchSnapshot.mockResolvedValue({ session: { diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 632fef2d4ff..9bd1caf1cef 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -198,7 +198,7 @@ export async function removePendingSessionInput({ const SESSION_CAPABILITY_TIMEOUT_SECONDS = 2 const SESSION_CAPABILITY_NEGATIVE_RETRY_MS = 30_000 -interface SessionFeatureCapabilities { +export interface SessionFeatureCapabilities { durableApprovals: boolean queue: boolean steer: boolean @@ -242,7 +242,7 @@ const cachedSessionCapabilities = (key: string): SessionFeatureCapabilities | nu return null } -const negotiateSessionCapabilities = async ({ +export const fetchSessionCapabilities = async ({ sessionId, projectId, appId, @@ -819,7 +819,7 @@ export async function fetchSessionDurableApprovalsCapability({ const cached = cachedSessionCapabilities(key) if (cached) return cached.durableApprovals - void negotiateSessionCapabilities({sessionId, projectId, appId, abortSignal}) + void fetchSessionCapabilities({sessionId, projectId, appId, abortSignal}) return false } diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index dd8a909c208..af9fb12b68b 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -19,6 +19,7 @@ export { querySessions, setSessionHeader, fetchSessionStream, + fetchSessionCapabilities, fetchSessionDurableApprovalsCapability, removePendingSessionInput, invalidateSessionDurableApprovalsCapability, @@ -44,6 +45,7 @@ export { type QuerySessionsPageParams, type QuerySessionsParams, type SessionScopedParams, + type SessionFeatureCapabilities, type QueryInteractionsParams, type InteractionScopedParams, type RespondInteractionParams, @@ -102,7 +104,11 @@ export { type MountFile, type Mount, } from "./core/schema" -export {fetchSessionSnapshotAtom, removePendingSessionInputAtom} from "./state/pendingInputs" +export { + fetchSessionCapabilitiesAtom, + fetchSessionSnapshotAtom, + removePendingSessionInputAtom, +} from "./state/pendingInputs" export { deriveStreamNest, deriveSessionLifecycle, diff --git a/web/packages/agenta-entities/src/session/state/pendingInputs.ts b/web/packages/agenta-entities/src/session/state/pendingInputs.ts index 6913ce6215e..93277439ffb 100644 --- a/web/packages/agenta-entities/src/session/state/pendingInputs.ts +++ b/web/packages/agenta-entities/src/session/state/pendingInputs.ts @@ -1,7 +1,12 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" -import {fetchSessionSnapshot, removePendingSessionInput} from "../api/api" +import {fetchSessionCapabilities, fetchSessionSnapshot, removePendingSessionInput} from "../api/api" + +export const fetchSessionCapabilitiesAtom = atom(null, async (get, _set, sessionId: string) => { + const projectId = get(projectIdAtom) ?? "" + return fetchSessionCapabilities({projectId, sessionId}) +}) export const fetchSessionSnapshotAtom = atom(null, async (get, _set, sessionId: string) => { const projectId = get(projectIdAtom) ?? "" diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 35c555773fe..f94bd61487a 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -14,6 +14,7 @@ vi.mock("@agenta/sdk/resources", () => ({ })) import { + fetchSessionCapabilities, fetchSessionDurableApprovalsCapability, invalidateSessionDurableApprovalsCapability, resumeSessionContinuation, @@ -84,6 +85,26 @@ describe("fetchSessionDurableApprovalsCapability", () => { ) }) + it("returns queue capabilities from the same cached negotiation", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: false, queue: true, steer: true}, + }) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await expect(fetchSessionCapabilities(scope)).resolves.toEqual({ + durableApprovals: false, + queue: true, + steer: true, + }) + await expect(fetchSessionCapabilities(scope)).resolves.toEqual({ + durableApprovals: false, + queue: true, + steer: true, + }) + expect(fetchStream).toHaveBeenCalledOnce() + }) + it("shares one request per session until the session reconnects", async () => { fetchStream.mockResolvedValue({ stream: null, From 808851c9ee68c1a66b96b03d72e3d0b3f8609f3a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:48:04 +0200 Subject: [PATCH 091/133] fix(api): propagate resumed continuation execution Return the execution that owns Send so queued and steered inputs lock and target the resumed continuation instead of the stale stream turn. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/apis/fastapi/sessions/router.py | 8 ++-- api/oss/src/core/sessions/commands/service.py | 32 ++++++++-------- api/oss/src/core/sessions/inputs/service.py | 10 +++-- api/oss/src/core/workflows/service.py | 16 ++++---- ...test_interaction_continuation_admission.py | 10 ++--- .../sessions/test_pending_inputs_service.py | 37 +++++++++++++++++-- .../sessions/test_session_commands_dao.py | 4 +- 7 files changed, 79 insertions(+), 38 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 3e30e116d13..b4ebfcdaa36 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -2730,9 +2730,11 @@ async def resume_session_continuation( resumed = False if env.agenta.sessions.durable_approvals: - resumed = await self._service.resume_recoverable_continuation( - project_id=UUID(str(project_id)), - session_id=session_id, + resumed = bool( + await self._service.resume_recoverable_continuation( + project_id=UUID(str(project_id)), + session_id=session_id, + ) ) return SessionContinuationResumeResponse(resumed=resumed) diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 728ea2a583b..942f383e120 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -804,15 +804,15 @@ async def _execution_is_parked_on_a_gate( async def resume_recoverable_continuation( self, *, project_id: UUID, session_id: str - ) -> bool: + ) -> Optional[str]: if not (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue): - return False + return None command = await self._dao.fetch_resumable_continuation( project_id=project_id, session_id=session_id, ) if command is None: - return False + return None if ( command.kind == SessionCommandKind.continue_interaction and not env.agenta.sessions.durable_approvals @@ -820,17 +820,17 @@ async def resume_recoverable_continuation( command.kind == SessionCommandKind.continue_input and not env.agenta.sessions.queue ): - return False + return None execution_id = command.target_turn_id if execution_id is None or self._executions is None: - return True + return execution_id execution = await self._executions.fetch_execution( project_id=project_id, session_id=session_id, execution_id=execution_id, ) if execution is None: - return True + return execution_id if execution.state == SessionExecutionState.running: # A stale heartbeat is not a fencing token: a partitioned runner can still be # executing the approved side effect. Only the watchdog may turn `running` into @@ -846,10 +846,14 @@ async def resume_recoverable_continuation( # * PARKED on its own approval — the continuation raised a new gate and stopped # to wait on the user. Nothing is in flight to destroy, so a Send is a steer # and stays allowed. Allow. - return not await self._execution_is_parked_on_a_gate( - project_id=project_id, - session_id=session_id, - execution_id=execution_id, + return ( + None + if await self._execution_is_parked_on_a_gate( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + else execution_id ) if ( command.state @@ -888,10 +892,8 @@ async def resume_recoverable_continuation( execution=execution, ) if command is None: - return True - execution_id = command.target_turn_id - if execution_id is None: - return True + return execution_id + execution_id = command.target_turn_id or execution_id if command.kind == SessionCommandKind.continue_input: admission: Any = InputContinuationAdmission( command=command, @@ -914,7 +916,7 @@ async def resume_recoverable_continuation( receipt = None if receipt is None or receipt.status != "accepted": await self._mark_continuation_recoverable(admission, receipt) - return True + return execution_id async def _reopen_continuation_attempt( self, diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 4339804aa72..eb23f0fa6d9 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -38,7 +38,7 @@ def __init__( inputs_dao: SessionInputsDAOInterface, streams_service: SessionStreamsService, executions_dao: Optional[SessionExecutionsDAOInterface] = None, - continuation_resumer: Optional[Callable[..., Awaitable[bool]]] = None, + continuation_resumer: Optional[Callable[..., Awaitable[Optional[str]]]] = None, ) -> None: self._dao = inputs_dao self._streams = streams_service @@ -75,19 +75,23 @@ async def admit( project_id=project_id, session_id=session_id ) busy = bool(stream and stream.flags and stream.flags.is_running) + resumed_execution_id: Optional[str] = None if ( not busy and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) and self._continuation_resumer is not None ): - busy = await self._continuation_resumer( + resumed_execution_id = await self._continuation_resumer( project_id=project_id, session_id=session_id, ) + busy = resumed_execution_id is not None if not busy: return PendingInputAdmission(action="execute") - current_execution_id = stream.turn_id if stream else None + current_execution_id = resumed_execution_id or ( + stream.turn_id if stream else None + ) queue_enabled = env.agenta.sessions.queue steer_enabled = queue_enabled and env.agenta.sessions.steer if policy == "steer" and not steer_enabled: diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 83053f13338..327574d14a9 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -277,12 +277,12 @@ def __init__( self.embeds_service = embeds_service self.static_catalog = static_catalog self._watch = watch_publisher - self._session_continuation_resumer: Optional[Callable[..., Awaitable[bool]]] = ( - None - ) + self._session_continuation_resumer: Optional[ + Callable[..., Awaitable[Optional[str]]] + ] = None def set_session_continuation_resumer( - self, callback: Callable[..., Awaitable[bool]] + self, callback: Callable[..., Awaitable[Optional[str]]] ) -> None: self._session_continuation_resumer = callback @@ -298,9 +298,11 @@ async def _resume_pending_session_continuation( or self._session_continuation_resumer is None ): return False - return await self._session_continuation_resumer( - project_id=project_id, - session_id=session_id, + return bool( + await self._session_continuation_resumer( + project_id=project_id, + session_id=session_id, + ) ) @staticmethod diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py index fc184fda2ea..da43b2a83d1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -652,7 +652,7 @@ async def test_next_send_resumes_the_same_open_continuation(): project_id=project_id, session_id="session-1" ) - assert resumed is True + assert resumed == commands.command.target_turn_id assert delivery.delivered[0].id == commands.command.id assert delivery.delivered[0].target_turn_id == "continuation-1" @@ -700,7 +700,7 @@ async def test_exhausted_continuation_stays_recoverable(monkeypatch): resumed = await service.resume_recoverable_continuation( project_id=project_id, session_id="session-1" ) - assert resumed is True + assert resumed == commands.command.target_turn_id assert commands.command.state == SessionCommandState.pending assert delivery.delivered[-1].id == command.id assert delivery.delivered[-1].target_turn_id != "continuation-1" @@ -1226,7 +1226,7 @@ async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): await service.resume_recoverable_continuation( project_id=project_id, session_id="session-1" ) - is False + is None ) assert await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) == 0 assert delivery.delivered == [] @@ -1298,7 +1298,7 @@ async def test_a_late_delivery_failure_does_not_demote_a_running_continuation(): project_id=project_id, session_id="session-1" ) - assert resumed is True + assert resumed == "continuation-1" assert delivery.delivered # The turn the runner is already running keeps `running`. The recoverable projection is # refused, so the card never asks the user to retry work that is under way. @@ -1349,7 +1349,7 @@ async def test_a_send_after_the_budget_is_spent_reopens_the_continuation(monkeyp project_id=project_id, session_id="session-1" ) - assert resumed is True + assert resumed == commands.command.target_turn_id # The exhausted attempt is recorded as ended and the command now targets a NEW execution: # redelivering the old id is what spent the budget in the first place. assert commands.command.target_turn_id != spent.target_turn_id diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index 9a4106242b5..dfc997bd6c4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -169,7 +169,7 @@ async def test_idle_input_executes_without_being_queued(monkeypatch): @pytest.mark.asyncio async def test_detached_executing_continuation_keeps_input_in_the_queue(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", True) - continuation_resumer = AsyncMock(return_value=True) + continuation_resumer = AsyncMock(return_value="continuation-1") dao = MemoryInputsDAO() service = SessionInputsService( inputs_dao=dao, @@ -194,7 +194,7 @@ async def test_detached_executing_continuation_keeps_input_in_the_queue(monkeypa @pytest.mark.asyncio async def test_parked_continuation_still_allows_input_to_execute(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", True) - continuation_resumer = AsyncMock(return_value=False) + continuation_resumer = AsyncMock(return_value=None) dao = MemoryInputsDAO() service = SessionInputsService( inputs_dao=dao, @@ -221,7 +221,7 @@ async def test_queue_flag_off_keeps_the_idle_path_without_a_continuation_probe( ): monkeypatch.setattr(env.agenta.sessions, "queue", False) monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) - continuation_resumer = AsyncMock(return_value=True) + continuation_resumer = AsyncMock(return_value="continuation-1") service = SessionInputsService( inputs_dao=MemoryInputsDAO(), streams_service=Streams(running=False), @@ -241,6 +241,37 @@ async def test_queue_flag_off_keeps_the_idle_path_without_a_continuation_probe( continuation_resumer.assert_not_awaited() +@pytest.mark.asyncio +async def test_steer_targets_the_execution_reopened_by_continuation_resume(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + continuation_resumer = AsyncMock(return_value="continuation-2") + executions = SimpleNamespace( + lock_for_control=AsyncMock(return_value=SimpleNamespace(terminal_outcome=None)) + ) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + executions_dao=executions, + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "steer the resumed continuation"}, + policy="steer", + idempotency_key="key-1", + ) + + assert admitted.execution_id == "continuation-2" + assert ( + executions.lock_for_control.await_args.kwargs["execution_id"] + == "continuation-2" + ) + + @pytest.mark.asyncio async def test_queue_idempotency_returns_same_input_and_rejects_conflicting_reuse( monkeypatch, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index 4dfc50e8a31..202eee28cf7 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -1256,7 +1256,7 @@ async def test_executing_continuation_refuses_a_competing_send( project_id=command_scope["project_id"], session_id=command_scope["session_id"], ) - is True + == "continuation-live" ) @@ -1276,7 +1276,7 @@ async def test_parked_continuation_still_accepts_a_send(command_scope, monkeypat project_id=command_scope["project_id"], session_id=command_scope["session_id"], ) - is False + is None ) From 2838cc64ef1871c24fec49a6cd1435c4f006f128 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:49:06 +0200 Subject: [PATCH 092/133] fix(api): skip invalid durable event fields Validate mapped interaction, message, and tool events at the open-wire boundary so malformed optional strings cannot poison a committed records batch. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/core/sessions/records/events.py | 101 ++++++++---------- .../unit/sessions/test_durable_events.py | 25 +++++ 2 files changed, 72 insertions(+), 54 deletions(-) diff --git a/api/oss/src/core/sessions/records/events.py b/api/oss/src/core/sessions/records/events.py index 1a105e5d5bb..6c0d5551ae5 100644 --- a/api/oss/src/core/sessions/records/events.py +++ b/api/oss/src/core/sessions/records/events.py @@ -4,18 +4,25 @@ from oss.src.core.sessions.records.dtos import ( SESSION_DURABLE_EVENT_TYPES, - InteractionRequestedEvent, - InteractionRespondedEvent, - MessageCompletedEvent, SessionDurableEvent, SessionRecord, - ToolCompletedEvent, ) _EVENT_ADAPTER = TypeAdapter(SessionDurableEvent) +def _validated_event( + *, base: Dict[str, Any], event_type: str, payload: Dict[str, Any] +) -> Optional[SessionDurableEvent]: + try: + return _EVENT_ADAPTER.validate_python( + {**base, "type": event_type, "payload": payload} + ) + except ValidationError: + return None + + def _event_base( record: SessionRecord, *, @@ -72,12 +79,7 @@ def _direct_event( ) if base is None: return None - try: - return _EVENT_ADAPTER.validate_python( - {**base, "type": record.record_type, "payload": payload} - ) - except ValidationError: - return None + return _validated_event(base=base, event_type=record.record_type, payload=payload) def durable_events_from_records( @@ -129,42 +131,35 @@ def durable_events_from_records( "interaction_id": entity_id, "kind": attributes.get("kind"), } - if record.record_type == "interaction_request": - events.append( - InteractionRequestedEvent( - **base, - type="interaction.requested", - payload=payload, - ) - ) - else: - events.append( - InteractionRespondedEvent( - **base, - type="interaction.responded", - payload=payload, - ) - ) + event = _validated_event( + base=base, + event_type=( + "interaction.requested" + if record.record_type == "interaction_request" + else "interaction.responded" + ), + payload=payload, + ) + if event is not None: + events.append(event) continue if record.record_type == "message": role = ( "assistant" if record.record_source == "agent" else record.record_source ) - events.append( - MessageCompletedEvent( - **base, - type="message.completed", - payload={ - "message_id": entity_id, - "role": role or "assistant", - "content": attributes.get( - "content", attributes.get("text", "") - ), - "finish_reason": attributes.get("finish_reason"), - }, - ) + event = _validated_event( + base=base, + event_type="message.completed", + payload={ + "message_id": entity_id, + "role": role or "assistant", + "content": attributes.get("content", attributes.get("text", "")), + "finish_reason": attributes.get("finish_reason"), + }, ) + if event is not None: + events.append(event) continue tool_key = (str(record.turn_id), entity_id) @@ -177,21 +172,19 @@ def durable_events_from_records( call = tool_calls.get(tool_key, {}) is_error = bool(attributes.get("isError")) output = attributes.get("data", attributes.get("output")) - events.append( - ToolCompletedEvent( - **base, - type="tool.completed", - payload={ - "tool_call_id": entity_id, - "name": str( - call.get("name") or attributes.get("name") or "unknown" - ), - "input": call.get("input", attributes.get("input")), - "output": None if is_error else output, - "error": output if is_error else None, - "status": "error" if is_error else "completed", - }, - ) + event = _validated_event( + base=base, + event_type="tool.completed", + payload={ + "tool_call_id": entity_id, + "name": str(call.get("name") or attributes.get("name") or "unknown"), + "input": call.get("input", attributes.get("input")), + "output": None if is_error else output, + "error": output if is_error else None, + "status": "error" if is_error else "completed", + }, ) + if event is not None: + events.append(event) return events diff --git a/api/oss/tests/pytest/unit/sessions/test_durable_events.py b/api/oss/tests/pytest/unit/sessions/test_durable_events.py index ee638e58b8d..752fd1b36e1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_durable_events.py +++ b/api/oss/tests/pytest/unit/sessions/test_durable_events.py @@ -113,6 +113,31 @@ def test_maps_interaction_records_to_durable_lifecycle_events(): assert events[1].payload.kind == "user_approval" +def test_invalid_open_wire_strings_do_not_poison_durable_event_projection(): + records = [ + _record( + sequence=1, + record_type="interaction_request", + attributes={"id": "interaction-1", "kind": {"invalid": True}}, + ), + _record( + sequence=2, + record_type="message", + attributes={"id": "message-1", "text": "bad", "finish_reason": 42}, + ), + _record( + sequence=3, + record_type="message", + attributes={"id": "message-2", "text": "kept", "finish_reason": "stop"}, + ), + ] + + events = durable_events_from_records(records) + + assert [event.entity_id for event in events] == ["message-2"] + assert events[0].payload.finish_reason == "stop" + + def test_non_dict_payload_reads_as_absent_instead_of_raising(): """A record whose `payload` attribute is not a dict must not poison the batch. From 791dc663b1bd0be804f0876117ddfeec40c4e06c Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:50:19 +0200 Subject: [PATCH 093/133] fix(api): reject lost steer command binds Return no command when the guarded bind loses its open-state race, and reject the cancel admission instead of pretending the pending input was attached. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../src/core/sessions/commands/interfaces.py | 2 +- api/oss/src/core/sessions/commands/service.py | 33 +++++++++++++------ .../src/dbs/postgres/sessions/commands/dao.py | 21 +++++------- .../sessions/test_session_cancel_admission.py | 23 +++++++++++++ .../unit/sessions/test_session_inputs_dao.py | 13 ++++++++ 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index cabdd44d628..89bb9ce7afd 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -129,7 +129,7 @@ async def bind_steer_input( command_id: UUID, input_id: UUID, transaction: Optional[Any] = None, - ) -> SessionCommand: + ) -> Optional[SessionCommand]: """Bind the first Steer input to an open Stop command.""" raise NotImplementedError diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 942f383e120..0fb44bbf389 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -316,15 +316,19 @@ async def request_cancel( target_turn_id=target_turn_id, ) if open_command is not None: - command = ( - await self._dao.bind_steer_input( + if steer_input_id is not None: + command = await self._dao.bind_steer_input( project_id=project_id, command_id=open_command.id, input_id=steer_input_id, ) - if steer_input_id is not None - else open_command - ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(open_command.id), + state="closed or already bound", + ) + else: + command = open_command else: created = await self._insert( project_id=project_id, @@ -368,16 +372,20 @@ async def request_cancel( transaction=transaction, ) if open_command is not None: - command = ( - await self._dao.bind_steer_input( + if steer_input_id is not None: + command = await self._dao.bind_steer_input( project_id=project_id, command_id=open_command.id, input_id=steer_input_id, transaction=transaction, ) - if steer_input_id is not None - else open_command - ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(open_command.id), + state="closed or already bound", + ) + else: + command = open_command else: await self._executions.set_state( project_id=project_id, @@ -412,6 +420,11 @@ async def request_cancel( input_id=steer_input_id, transaction=transaction, ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(created.command.id), + state="closed or already bound", + ) cancelled_interactions = ( await self._interactions.cancel_session_pending( project_id=project_id, diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 59f12f99d08..c54a865644c 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -261,8 +261,8 @@ async def bind_steer_input( command_id: UUID, input_id: UUID, transaction: Optional[Any] = None, - ) -> SessionCommand: - async def execute(session: Any) -> SessionCommand: + ) -> Optional[SessionCommand]: + async def execute(session: Any) -> Optional[SessionCommand]: row = ( await session.execute( sa_update(SessionCommandDBE) @@ -270,7 +270,11 @@ async def execute(session: Any) -> SessionCommand: SessionCommandDBE.project_id == project_id, SessionCommandDBE.id == command_id, SessionCommandDBE.state.in_(_OPEN_STATES), - SessionCommandDBE.data["steer_input_id"].astext.is_(None), + or_( + SessionCommandDBE.data["steer_input_id"].astext.is_(None), + SessionCommandDBE.data["steer_input_id"].astext + == str(input_id), + ), ) .values( data=cast( @@ -283,16 +287,7 @@ async def execute(session: Any) -> SessionCommand: .returning(SessionCommandDBE) ) ).scalar_one_or_none() - if row is None: - row = ( - await session.execute( - select(SessionCommandDBE).where( - SessionCommandDBE.project_id == project_id, - SessionCommandDBE.id == command_id, - ) - ) - ).scalar_one() - return map_command_dbe_to_dto(row) + return map_command_dbe_to_dto(row) if row is not None else None if transaction is not None: return await execute(transaction) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py index f2b25187bd9..64f50cba46f 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -933,6 +933,29 @@ async def test_steer_binds_to_an_already_open_stop(lock_engine): assert len(dao.rows) == 1 +@pytest.mark.asyncio +async def test_steer_rejects_an_open_stop_that_closes_before_binding(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + dao.bind_steer_input = AsyncMock(return_value=None) + + with pytest.raises(SessionCommandNotClaimable): + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + steer_input_id=uuid4(), + ) + + @pytest.mark.asyncio async def test_a_reachable_runner_that_does_not_hold_the_session_settles_at_once( lock_engine, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index edc2f109d65..b28426d6c74 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -663,7 +663,20 @@ async def test_steer_stop_promotes_only_the_bound_input(input_scope, monkeypatch command_id=command.id, input_id=steer.id, ) + assert command is not None + rebound = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=steer.id, + ) + rejected = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=older.id, + ) assert command.data == {"steer_input_id": str(steer.id)} + assert rebound is not None and rebound.data == command.data + assert rejected is None service = _settlement_service(input_scope, inputs) settled = await service.settle( From 6e005143e89eeab43a578ab58ed29701140c26f1 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:50:53 +0200 Subject: [PATCH 094/133] fix(api): preserve heartbeat after lease release failure Treat a failed Redis guard release as a bounded lease delay so it cannot replace the heartbeat result already committed to Postgres. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- api/oss/src/dbs/redis/sessions/locks.py | 9 +++++- .../sessions/test_heartbeat_lock_races.py | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/api/oss/src/dbs/redis/sessions/locks.py b/api/oss/src/dbs/redis/sessions/locks.py index fbe3c9b59b9..e6cc347bda6 100644 --- a/api/oss/src/dbs/redis/sessions/locks.py +++ b/api/oss/src/dbs/redis/sessions/locks.py @@ -127,7 +127,14 @@ async def renew() -> None: finally: renewal.cancel() await asyncio.gather(renewal, return_exceptions=True) - await engine.eval(RELEASE_IF_OWNER_LUA, 1, key.encode(), token) + try: + await engine.eval(RELEASE_IF_OWNER_LUA, 1, key.encode(), token) + except Exception: + log.warning( + "heartbeat guard release failed; lease will expire", + session_id=session_id, + exc_info=True, + ) async def acquire_alive( diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py index 591f48c3f4f..d45b5bbdeaa 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py @@ -27,7 +27,9 @@ get_owner, get_running_owner, is_turn_superseded, + session_heartbeat_guard, ) +from oss.src.dbs.redis.sessions.contract import RELEASE_IF_OWNER_LUA from unit.sessions.test_heartbeat_parked_zombie import _FakeStreamsDAO from unit.sessions.test_project_scoped_locks import _FakeRedis @@ -92,6 +94,35 @@ async def _superseded(lock_engine, turn: str) -> bool: # --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_heartbeat_guard_release_failure_does_not_mask_the_body(lock_engine): + redis = lock_engine._client() + original_eval = redis.eval + + async def fail_release(script, numkeys, *keys_and_args): + if script == RELEASE_IF_OWNER_LUA: + raise ConnectionError("redis unavailable") + return await original_eval(script, numkeys, *keys_and_args) + + with ( + patch.object(redis, "eval", new=fail_release), + patch("oss.src.dbs.redis.sessions.locks.log.warning") as warning, + ): + async with session_heartbeat_guard( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + ): + result = "committed" + + assert result == "committed" + warning.assert_called_once_with( + "heartbeat guard release failed; lease will expire", + session_id=_SESSION, + exc_info=True, + ) + + @pytest.mark.asyncio async def test_heartbeat_returns_committed_result_when_guard_lease_is_lost(lock_engine): dao = _FakeStreamsDAO() From 05cebbbb552163a49d528a8ff5cd1dcd449ba4e4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:51:24 +0200 Subject: [PATCH 095/133] fix(api): keep heartbeat fence during lost settlement Require the selected stream timestamp to remain unchanged even when the sweep settled that turn as lost, so a concurrent runner heartbeat wins the collapse race. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../tasks/asyncio/sessions/orphan_sweep.py | 23 ++++--------------- .../sessions/test_orphan_sweep_thresholds.py | 4 ++-- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index fa2caf35976..c25ee7ff91f 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -594,18 +594,6 @@ async def run_orphan_sweep( # Win the stale stream generation before settling its execution or publishing records. # The update and execution settlement share this transaction; an exception rolls both # back, while record ids make a publish-before-commit retry idempotent. - # A turn this pass is settling as LOST must reach rest even when the row's - # `updated_at` moved inside this same pass. The command settlement and the record - # publish above both write through this transaction, so the advance can be our own - # write rather than a sign of life. The `turn_id` guard still protects a row that - # advanced to a NEWER turn, which is the case the timestamp guard exists for. - settled_lost = { - key - for key in unsettled - if (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) - and terminal_outcomes.get(key) != "stopped" - } - collapsed_flags = SessionStreamFlags( is_alive=False, is_running=False, is_attached=False ).model_dump(mode="json") @@ -631,12 +619,11 @@ async def run_orphan_sweep( else SessionStreamDBE.turn_id.is_(None) ), ] - if (project_uuid, session_id, turn_id) not in settled_lost: - conditions.append( - SessionStreamDBE.updated_at == observed_updated_at - if observed_updated_at is not None - else SessionStreamDBE.updated_at.is_(None) - ) + conditions.append( + SessionStreamDBE.updated_at == observed_updated_at + if observed_updated_at is not None + else SessionStreamDBE.updated_at.is_(None) + ) result = await session.execute( sa_update(SessionStreamDBE) .where(*conditions) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 16a321cea49..0134d0245c9 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -352,7 +352,7 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): @pytest.mark.anyio -async def test_turn_settled_lost_by_this_pass_collapses_after_timestamp_advance( +async def test_heartbeat_during_lost_settlement_prevents_collapse( anyio_backend, monkeypatch, ): @@ -378,7 +378,7 @@ async def publish(**_kwargs): publish=publish, ) - assert _swept(row) + assert not _swept(row) @pytest.mark.anyio From 2a053010002313f71d8295b74129ecc6734ac0d4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:54:01 +0200 Subject: [PATCH 096/133] test(web): complete session live test inputs Supply the required hydration reset callback and live-event cursor and event handler so the regression tests remain valid under strict TypeScript. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../hooks/useSessionHydration.livePreview.test.tsx | 1 + .../agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx index 9bd0fc77e4a..a5db4189b7f 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx @@ -215,6 +215,7 @@ describe("desktop durable reconnect", () => { busy: false, setMessages, persistMessages: vi.fn(), + clearRunError: vi.fn(), intent: { armJump: vi.fn(), stickRef: {current: false}, diff --git a/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts index 11c8f0bd6ef..2c00805b5bb 100644 --- a/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts +++ b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts @@ -46,7 +46,9 @@ describe("connectSessionLiveEvents", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined) connectSessionLiveEvents({ sessionId: "session-1", + after: 0, onFrame, + onEvent: vi.fn(), onReady: vi.fn(), onDisconnect: vi.fn(), }) From 730e9f89b14b3d123f96fc88f8998b068eb43a2a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:55:58 +0200 Subject: [PATCH 097/133] fix(frontend): back off approval gate polling Retain the fast first interaction refresh, then exponentially back off to a sixty-second ceiling while a gate remains open. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../AgentChatSlice/hooks/useSessionHydration.test.ts | 9 +++++++++ .../AgentChatSlice/hooks/useSessionHydration.ts | 12 ++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts index 6e0075be3ce..4efaf19dc1c 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts @@ -17,10 +17,19 @@ import {describe, expect, it} from "vitest" import { hasStrandedTail, + nextInteractionGatePollDelay, shouldProtectRenderedInteraction, shouldSkipRecordsRefresh, } from "./useSessionHydration" +describe("nextInteractionGatePollDelay", () => { + it("backs off and caps long-lived interaction gate polling", () => { + expect(nextInteractionGatePollDelay(1_000)).toBe(2_000) + expect(nextInteractionGatePollDelay(32_000)).toBe(60_000) + expect(nextInteractionGatePollDelay(60_000)).toBe(60_000) + }) +}) + describe("shouldSkipRecordsRefresh", () => { it("does not skip when idle and no settle is pending a resume", () => { expect(shouldSkipRecordsRefresh({busy: false, pendingResume: false})).toBe(false) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index f76b2853a29..d4522c2ec3c 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -44,6 +44,10 @@ const REMOTE_RUN_POLL_MS = 15_000 * records until it returns) is still followed. */ const REMOTE_RUN_POLL_MAX_MS = 60_000 const INTERACTION_GATE_POLL_MS = 1_000 +const INTERACTION_GATE_POLL_MAX_MS = 60_000 + +export const nextInteractionGatePollDelay = (delay: number): number => + Math.min(delay * 2, INTERACTION_GATE_POLL_MAX_MS) /** Retry budget for the stranded-first-send record check when the fetch itself fails * (`records: null`). Bounded so a down endpoint gets a short burst, not a hammer; when the budget @@ -628,11 +632,15 @@ export const useSessionHydration = ({ if (activeSessionId !== sessionId || !interactionGateOpen) return let cancelled = false let timer: ReturnType | undefined + let delay = INTERACTION_GATE_POLL_MS const poll = async () => { await refreshFromInteractions() - if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + if (!cancelled) { + delay = nextInteractionGatePollDelay(delay) + timer = setTimeout(poll, delay) + } } - timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + timer = setTimeout(poll, delay) return () => { cancelled = true if (timer) clearTimeout(timer) From 3ec93cf74464f87f835586e9822e60674ac59bf6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 16:55:58 +0200 Subject: [PATCH 098/133] fix(chat): accept mid-execution live preview attach Treat the first observed frame as the connection baseline and enforce contiguous frame indexes only after that baseline exists. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- web/packages/agenta-chat/src/model/livePreview.ts | 3 +-- .../tests/unit/model/livePreview.test.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts index fed0b775dbf..7b1e0c0ba1a 100644 --- a/web/packages/agenta-chat/src/model/livePreview.ts +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -158,8 +158,7 @@ export const reduceSessionLivePreview = ( const current = state.byExecution[frame.execution_id] if (current && frame.frame_index <= current.lastFrameIndex) return state - const expectedFrameIndex = current ? current.lastFrameIndex + 1 : 0 - if (frame.frame_index !== expectedFrameIndex) { + if (current && frame.frame_index !== current.lastFrameIndex + 1) { return {...createSessionLivePreviewState(), gapDetected: true} } diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts index 420c50ff6d6..e1413646aad 100644 --- a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -129,17 +129,17 @@ describe("session live preview reducer", () => { expect(sessionLivePreviewMessages(stale)[0].parts).toEqual([{type: "text", text: "newer"}]) }) - it("suppresses a late join whose first frame index is above zero", () => { - const gapped = reduceSessionLivePreview( + it("accepts a late join and enforces contiguous indexes after its first frame", () => { + const joined = reduceSessionLivePreview( createSessionLivePreviewState(), frame(2, "text-delta", {delta: "tail"}), ) - const later = reduceSessionLivePreview(gapped, frame(3, "text-delta", {delta: "later"})) + const later = reduceSessionLivePreview(joined, frame(3, "text-delta", {delta: " later"})) - expect(gapped.gapDetected).toBe(true) - expect(gapped.executionOrder).toEqual([]) - expect(sessionLivePreviewMessages(gapped)).toEqual([]) - expect(later).toBe(gapped) + expect(joined.gapDetected).toBe(false) + expect(sessionLivePreviewMessages(later)[0].parts).toEqual([ + {type: "text", text: "tail later"}, + ]) }) it("clears and suppresses a preview after an internal frame gap", () => { From 377774bea891c654b64815c1c277be8ee6e66ac4 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 5 Sep 2026 17:30:33 +0200 Subject: [PATCH 099/133] fix(mobile): release held messages without queue capabilities Clear accepted shared-turn ownership when the invoke stream finishes cleanly so flag-off browser-held messages can drain. Keep disconnects, aborts, and errors behind the durable terminal event. Cover the mobile shared engine and desktop parity. Claude-Session: https://claude.ai/code/session_0164kzT6ttwpBtzvcDC6YzYk --- .../hooks/useAgentChatSession.test.ts | 49 +++++++++++- .../hooks/useAgentChatSession.ts | 33 +++++--- .../src/hooks/useAgentConversation.ts | 33 +++++--- .../unit/hooks/useAgentConversation.test.ts | 78 +++++++++++++++++++ 4 files changed, 172 insertions(+), 21 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index 5520e65a7a9..612bc8d07ef 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -10,7 +10,18 @@ const state = vi.hoisted(() => ({ acceptedRunBySession: new Map(), turnDeliverySourceBySession: new Map(), capturedHooks: undefined as - | {prepareRequest: (args: {messages: UIMessage[]; id?: string}) => Promise} + | { + prepareRequest: (args: {messages: UIMessage[]; id?: string}) => Promise + onData: (part: {type: string; data?: unknown}) => void + onFinish: (args: { + message: UIMessage + messages: UIMessage[] + finishReason?: string + isAbort?: boolean + isDisconnect?: boolean + isError?: boolean + }) => void + } | undefined, messages: [] as UIMessage[], latestTurnId: undefined as string | undefined, @@ -190,6 +201,42 @@ describe("useAgentChatSession execution guard", () => { state.stopStateLoading = false }) + it("settles a desktop accepted turn when its shared invoke stream finishes", () => { + const sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => + state.capturedHooks!.onData({ + type: "data-session-accepted", + data: {executionId: "turn-1"}, + }), + ) + expect(result!.acceptedRunPending).toBe(true) + + act(() => + state.capturedHooks!.onFinish({ + message: {id: "assistant-1", role: "assistant", parts: []}, + messages: [], + }), + ) + expect(result!.acceptedRunPending).toBe(false) + expect(state.acceptedRunBySession.has(sessionId)).toBe(false) + + act(() => root.unmount()) + }) + it("clears the previous turn before sends, regeneration, and SDK automatic requests", async () => { const sessionId = "session-1" let result: ReturnType | undefined diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 36b5b546316..235c956c4b8 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -179,6 +179,18 @@ export const useAgentChatSession = ({ const [turnDeliverySource, setTurnDeliverySource] = useState( () => turnDeliverySourceBySession.get(sessionId) ?? null, ) + const settleSharedTurn = useCallback( + (executionId?: string) => { + const acceptedExecutionId = acceptedExecutionIdRef.current + if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + turnDeliverySourceBySession.delete(sessionId) + setTurnDeliverySource(null) + }, + [sessionId], + ) const sharedSenderReadyRef = useRef(false) const setSharedSenderReady = useCallback((ready: boolean) => { sharedSenderReadyRef.current = ready @@ -253,7 +265,16 @@ export const useAgentChatSession = ({ // `is_running: true` outlived the answer by up to 15s (#5844). Safe to refetch immediately — // the runner awaits its `is_running: false` heartbeat BEFORE closing this stream // (services/runner/src/server.ts `aliveWatchdog.release()`), so the flag is already cleared. - onFinish: ({message, messages: finishedMessages, finishReason}) => { + onFinish: ({ + message, + messages: finishedMessages, + finishReason, + isAbort, + isDisconnect, + isError, + }) => { + // A clean shared invoke close is terminal; a disconnect still waits for the durable event. + if (!isAbort && !isDisconnect && !isError) settleSharedTurn() dispatchStopped({ type: "stream-terminal", messages: finishedMessages, @@ -821,15 +842,7 @@ export const useAgentChatSession = ({ connectionWarning: errorBoundary.connectionWarning, acceptedRunPending, turnDeliverySource, - settleSharedTurn: (executionId?: string) => { - const acceptedExecutionId = acceptedExecutionIdRef.current - if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return - acceptedExecutionIdRef.current = null - acceptedRunBySession.delete(sessionId) - setAcceptedRunPending(false) - turnDeliverySourceBySession.delete(sessionId) - setTurnDeliverySource(null) - }, + settleSharedTurn, sendMessage: sendMessageWithFreshGuard, regenerate: regenerateWithFreshGuard, setMessages, diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index d965288e1c1..d27cd463090 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -329,6 +329,18 @@ export const useAgentConversation = ({ const [turnDeliverySource, setTurnDeliverySource] = useState( () => turnDeliverySourceBySession.get(sessionId) ?? null, ) + const settleAcceptedRun = useCallback( + (executionId?: string) => { + const acceptedExecutionId = acceptedExecutionIdRef.current + if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + turnDeliverySourceBySession.delete(sessionId) + setTurnDeliverySource(null) + }, + [sessionId], + ) // Tracks `busy` for callbacks that outlive a render (the preserve verdict at unmount). const busyRef = useRef(false) // Only a stream THIS client renders. A shared-delivered turn renders from the live frames, @@ -392,7 +404,16 @@ export const useAgentConversation = ({ const label = startupLabelFromDataPart(part) if (label) setTurnStartupLabel(sessionId, label) }, - onFinish: ({message, messages: finishedMessages, finishReason}) => { + onFinish: ({ + message, + messages: finishedMessages, + finishReason, + isAbort, + isDisconnect, + isError, + }) => { + // A clean shared invoke close is terminal; a disconnect still waits for the durable event. + if (!isAbort && !isDisconnect && !isError) settleAcceptedRun() dispatchStopped({ type: "stream-terminal", messages: finishedMessages, @@ -1024,15 +1045,7 @@ export const useAgentConversation = ({ onReadyChange: (ready) => { sharedSenderReadyRef.current = ready }, - onExecutionSettled: (executionId?: string) => { - const acceptedExecutionId = acceptedExecutionIdRef.current - if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return - acceptedExecutionIdRef.current = null - acceptedRunBySession.delete(sessionId) - setAcceptedRunPending(false) - turnDeliverySourceBySession.delete(sessionId) - setTurnDeliverySource(null) - }, + onExecutionSettled: settleAcceptedRun, onDisconnect: revalidate, }) const includePreview = turnDeliverySource !== "legacy" diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index acac99f0338..a84ecbf5982 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -252,6 +252,35 @@ const controlledLegacyResponse = () => { return {response, finish: () => finish()} } +const controlledSharedResponse = (sessionId: string) => { + const encoder = new TextEncoder() + let finish = () => {} + const response = new Response( + new ReadableStream({ + start(controller) { + for (const chunk of [ + {type: "start", messageId: "shared-assistant"}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {sessionId, turnId: "turn-1", executionId: "turn-1"}, + transient: true, + }, + ]) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + finish = () => { + for (const chunk of [{type: "finish-step"}, {type: "finish"}]) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + } + }, + }), + {status: 200, headers: {"content-type": "text/event-stream"}}, + ) + return {response, finish: () => finish()} +} + const fetchMock = vi.fn() vi.stubGlobal("fetch", fetchMock) @@ -305,6 +334,55 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { + it("releases one mobile-held message after a flag-off shared turn finishes", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const first = controlledSharedResponse(sessionId) + const second = controlledSharedResponse(sessionId) + fetchMock.mockResolvedValueOnce(first.response).mockResolvedValueOnce(second.response) + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + ...(opts?.sharedResponse ? {"x-ag-session-response": "shared"} : {}), + }, + requestBody: {session_id: opts?.sessionId}, + })) + const {result} = renderHook( + () => + useAgentConversation({ + entityId: "rev-1", + sessionId, + sharedReaderAdvertised: true, + }), + { + wrapper: ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children), + }, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + act(() => FakeEventSource.instances[0].ready()) + await waitFor(() => expect(result.current.readerReady).toBe(true)) + + act(() => void result.current.send({text: "start"})) + await waitFor(() => expect(result.current.acceptedRunPending).toBe(true)) + act(() => void result.current.send({text: "held on mobile"})) + expect(result.current.queued.map((message) => message.text)).toEqual(["held on mobile"]) + expect(fetchMock).toHaveBeenCalledTimes(1) + + act(() => first.finish()) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + expect(result.current.queued).toHaveLength(0) + act(() => second.finish()) + await waitFor(() => expect(result.current.acceptedRunPending).toBe(false)) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it("keeps a Steer draft when durable admission is refused", async () => { capabilitiesViaAtom.mockResolvedValue({ durableApprovals: true, From d3bb6899a34afe2c05e31944c2aee86e4f4788e8 Mon Sep 17 00:00:00 2001 From: mmabrouk <4510758+mmabrouk@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:48:40 +0000 Subject: [PATCH 100/133] v0.115.2 --- api/pyproject.toml | 2 +- api/uv.lock | 6 +++--- clients/python/pyproject.toml | 2 +- clients/python/uv.lock | 2 +- hosting/kubernetes/helm/Chart.yaml | 4 ++-- sdks/python/pyproject.toml | 2 +- sdks/python/uv.lock | 4 ++-- services/pyproject.toml | 2 +- services/uv.lock | 6 +++--- web/ee/package.json | 2 +- web/mobile/package.json | 2 +- web/oss/package.json | 2 +- web/package.json | 2 +- web/packages/agenta-api-client/package.json | 2 +- 14 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index acb318e7cac..d80951009d0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "0.115.1" +version = "0.115.2" description = "Agenta API" requires-python = ">=3.11,<3.14" authors = [ diff --git a/api/uv.lock b/api/uv.lock index d38067daff9..337db3d3055 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -276,7 +276,7 @@ wheels = [ [[package]] name = "api" -version = "0.115.1" +version = "0.115.2" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 6bd3db608f9..dd101668e58 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" description = "Fern-generated Python client for the Agenta API." requires-python = ">=3.11,<3.14" authors = [ diff --git a/clients/python/uv.lock b/clients/python/uv.lock index 05acbe65210..16ac5c18eb3 100644 --- a/clients/python/uv.lock +++ b/clients/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/hosting/kubernetes/helm/Chart.yaml b/hosting/kubernetes/helm/Chart.yaml index 18586ecf5c0..c61577f0e41 100644 --- a/hosting/kubernetes/helm/Chart.yaml +++ b/hosting/kubernetes/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: agenta description: A Helm chart for deploying Agenta (OSS or EE) on Kubernetes type: application -version: 0.115.1 -appVersion: "v0.115.1" +version: 0.115.2 +appVersion: "v0.115.2" keywords: - agenta - llm diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 9ad4713f066..fabf0b9ad77 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta" -version = "0.115.1" +version = "0.115.2" description = "Agenta is the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your team." readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 1c39c691c98..941abc8930c 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "." } dependencies = [ { name = "agenta-client" }, @@ -85,7 +85,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../../clients/python" } dependencies = [ { name = "httpx" }, diff --git a/services/pyproject.toml b/services/pyproject.toml index c077b94cebf..eb32eae89b3 100644 --- a/services/pyproject.toml +++ b/services/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "services" -version = "0.115.1" +version = "0.115.2" description = "Agenta Services (Chat & Completion)" requires-python = ">=3.11,<3.14" authors = [ diff --git a/services/uv.lock b/services/uv.lock index d0eec0aff2d..ce87d03577d 100644 --- a/services/uv.lock +++ b/services/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -2356,7 +2356,7 @@ wheels = [ [[package]] name = "services" -version = "0.115.1" +version = "0.115.2" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/web/ee/package.json b/web/ee/package.json index 1da5d3cc2e8..6abf6e205c9 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/ee", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/mobile/package.json b/web/mobile/package.json index 76e021f8ad1..ad1bf1b6653 100644 --- a/web/mobile/package.json +++ b/web/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/mobile", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/oss/package.json b/web/oss/package.json index 593d9beb363..48bfdef0870 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/oss", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/package.json b/web/package.json index cc0a88ef4e7..7cc28361a22 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "agenta-web", - "version": "0.115.1", + "version": "0.115.2", "workspaces": [ "ee", "mobile", diff --git a/web/packages/agenta-api-client/package.json b/web/packages/agenta-api-client/package.json index ea2c50c5da0..d47226ea241 100644 --- a/web/packages/agenta-api-client/package.json +++ b/web/packages/agenta-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@agentaai/api-client", - "version": "0.115.1", + "version": "0.115.2", "private": true, "type": "module", "main": "./dist/index.js", From 832a344e0cb8cfb75181e712835e4d1d9c320113 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 01:08:33 +0200 Subject: [PATCH 101/133] fix(sessions): preserve live transcript through durable handoff --- api/oss/src/core/sessions/records/events.py | 17 + .../unit/sessions/test_durable_events.py | 17 + .../sessions/test_records_worker_batching.py | 13 +- services/runner/src/sessions/persist.ts | 6 +- .../runner/tests/unit/session-persist.test.ts | 4 + .../src/features/chat/LiveConversation.tsx | 27 +- web/mobile/tests/unit/turnStatus.test.ts | 26 +- .../AgentChatSlice/AgentConversation.tsx | 24 +- .../components/AgentComposerDock.tsx | 7 - .../hooks/useAgentChatSession.test.ts | 53 ++- .../hooks/useAgentChatSession.ts | 5 +- .../AgentChatSlice/state/liveness.test.ts | 26 +- .../AgentChatSlice/state/liveness.ts | 5 +- .../src/components/RunningElsewhereStrip.tsx | 54 --- .../agenta-chat/src/components/index.ts | 1 - .../src/hooks/useAgentConversation.ts | 3 + .../src/hooks/useSessionLivePreview.ts | 136 +++++-- .../agenta-chat/src/model/livePreview.ts | 193 +++++++++- .../unit/hooks/useSessionLivePreview.test.tsx | 230 ++++++++++- .../tests/unit/model/livePreview.test.ts | 356 +++++++++++++++++- .../src/session/state/livePreview.ts | 4 + 21 files changed, 1014 insertions(+), 193 deletions(-) delete mode 100644 web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx diff --git a/api/oss/src/core/sessions/records/events.py b/api/oss/src/core/sessions/records/events.py index 6c0d5551ae5..560bfd56f30 100644 --- a/api/oss/src/core/sessions/records/events.py +++ b/api/oss/src/core/sessions/records/events.py @@ -126,6 +126,23 @@ def durable_events_from_records( if base is None: continue + # Runner completion is persisted as `done`, including paused and cancelled turns. + if record.record_type == "done": + stop_reason = attributes.get("stopReason") + event = _validated_event( + base=base, + event_type="execution.stopped", + payload={ + "stopped_at": base["created_at"], + "reason": stop_reason + if isinstance(stop_reason, str) and stop_reason + else "completed", + }, + ) + if event is not None: + events.append(event) + continue + if record.record_type in {"interaction_request", "interaction_response"}: payload = { "interaction_id": entity_id, diff --git a/api/oss/tests/pytest/unit/sessions/test_durable_events.py b/api/oss/tests/pytest/unit/sessions/test_durable_events.py index 752fd1b36e1..5c486cabffd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_durable_events.py +++ b/api/oss/tests/pytest/unit/sessions/test_durable_events.py @@ -171,3 +171,20 @@ def test_non_dict_payload_reads_as_absent_instead_of_raising(): "execution.started", ] assert [event.sequence for event in events] == [1, 2] + + +def test_maps_runner_done_records_to_terminal_events(): + for reason in (None, "paused", "cancelled"): + attributes = {"type": "done"} + if reason is not None: + attributes["stopReason"] = reason + record = _record(sequence=7, record_type="done", attributes=attributes) + events = durable_events_from_records([record]) + assert len(events) == 1 + event = events[0] + assert event.type == "execution.stopped" + assert event.execution_id == record.turn_id + assert event.sequence == 7 + assert event.watermark == 7 + assert event.payload.stopped_at == record.created_at + assert event.payload.reason == (reason or "completed") diff --git a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py index ab0e6db4534..24087d16fbd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py @@ -153,7 +153,13 @@ async def test_failed_append_leaves_project_messages_unacknowledged(): @pytest.mark.asyncio -async def test_durable_event_is_published_only_after_record_commit_returns(): +@pytest.mark.parametrize( + "record_type,event_type", + [("message", "message.completed"), ("done", "execution.stopped")], +) +async def test_durable_event_is_published_only_after_record_commit_returns( + record_type, event_type +): project_id = uuid4() committed = False @@ -168,15 +174,16 @@ async def append_many(self, *, events): project_id=project_id, sequence=1, turn_id="turn-1", - record_type="message", + record_type=record_type, record_source="agent", - attributes={"type": "message", "text": "done"}, + attributes={"type": record_type, "text": "done"}, created_at=datetime.now(timezone.utc), ) ] async def publish(**kwargs): assert committed is True + assert kwargs["event"].type == event_type assert kwargs["event"].sequence == 1 assert kwargs["event"].watermark == 1 return True diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 885970c2c32..e642aa0b139 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -154,7 +154,7 @@ async function postEvent( export function persistEvent( sessionId: string, auth: () => string, - event: AgentEvent, + event: AgentEvent & { message_id?: string }, eventIndex: number, sender: string = "agent", recordId?: string, @@ -348,7 +348,7 @@ export function buildPersistingEmitter( persistEvent( sessionId, auth, - { type: "message", text: acc.text }, + { type: "message", text: acc.text, message_id: acc.id }, eventIndex++, "agent", undefined, @@ -378,7 +378,7 @@ export function buildPersistingEmitter( persistEvent( sessionId, auth, - { type: "thought", text: acc.text }, + { type: "thought", text: acc.text, message_id: acc.id }, eventIndex++, "agent", undefined, diff --git a/services/runner/tests/unit/session-persist.test.ts b/services/runner/tests/unit/session-persist.test.ts index d5781e28ffd..9c1f4d155b4 100644 --- a/services/runner/tests/unit/session-persist.test.ts +++ b/services/runner/tests/unit/session-persist.test.ts @@ -436,6 +436,10 @@ describe("buildPersistingEmitter turn/span tagging", () => { const bodies = postedBodies as Array>; assert.equal(bodies.length, 3); + assert.equal( + (bodies[0]["attributes"] as Record)["message_id"], + "m1", + ); for (const body of bodies) { assert.equal(body["turn_id"], "turn-tc"); assert.equal("span_id" in body, false); diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 762369e2c1c..53f5f7bcc62 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -15,7 +15,6 @@ import { ConnectionWarningStrip, ElicitationDock, QueuedMessagesDock, - RunningElsewhereStrip, } from "@agenta/chat/components" import type {QueuedMessage} from "@agenta/chat/hooks" import { @@ -41,6 +40,7 @@ import { turnRowClass, } from "@agenta/ui/components/presentational" import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {useQueryClient} from "@tanstack/react-query" import {useAtomValue, useSetAtom} from "jotai" import {User} from "lucide-react" @@ -49,6 +49,7 @@ import {ScreenScaffold} from "@/components/ScreenScaffold" import {pendingTasksAtom, takePendingTaskAtom} from "../home/pendingTask" import {AppShell} from "../nav/AppShell" +import {livenessQueryKey} from "../sessions/useLivenessPoll" import {ApprovalDock} from "./ApprovalDock" import {Composer} from "./Composer" @@ -223,10 +224,18 @@ export const LiveConversation = ({ takePendingTask, ]) + const queryClient = useQueryClient() + useEffect(() => { + if (conversation.sharedSettledAt) { + void queryClient.invalidateQueries({queryKey: livenessQueryKey(projectId)}) + } + }, [conversation.sharedSettledAt, projectId, queryClient]) const streamingHere = conversation.status === "submitted" || conversation.status === "streaming" const remoteTurn = deriveMobileRemoteTurnPresentation({ livenessRunning: running, - snapshotRunning: conversation.runningFromSnapshot || conversation.acceptedRunPending, + livenessUpdatedAt, + sharedSettledAt: conversation.sharedSettledAt, + snapshotRunning: conversation.runningFromSnapshot, sharedReaderAdvertised: sharedReader, readerReady: conversation.readerReady, ownedContinuation: conversation.acceptedRunPending, @@ -646,20 +655,14 @@ export const LiveConversation = ({
) : null} - {/* A run this device is not driving. Docked with the other strips above the - composer, as on the desktop — it used to be a top bar that also appeared for - THIS device's own turns, duplicating the composer's Stop and shifting the - transcript twice per run. */} {showRunningElsewhere({ - running: remoteTurn.showStrip, + running: remoteTurn.showRemoteStop, localStatus: conversation.runStatus, }) && !streamingHere ? ( - - } - /> +
+ +
) : null} {conversation.connectionWarning ? ( diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index 510720b718e..bb6acdf9836 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -34,35 +34,35 @@ describe("showTrailingWorkingPulse", () => { describe("deriveMobileRemoteTurnPresentation", () => { it.each([ { - name: "renders activity and no strip for a ready reader", + name: "renders activity without remote Stop for a ready reader", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "renders the strip while the reader is not ready", + name: "renders activity and remote Stop while the reader reconnects", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "renders the strip when the feature is off", + name: "renders activity and remote Stop when the reader is off", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "does not render the strip in the tab that owns a continuation", + name: "renders activity without remote Stop for an owned continuation", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveMobileRemoteTurnPresentation(input)).toEqual(expected) }) - it("shows the flag-off observer banner only while session-stream liveness is running", () => { + it("offers legacy remote Stop only while session-stream liveness is running", () => { const input = { snapshotRunning: true, sharedReaderAdvertised: false, @@ -70,20 +70,20 @@ describe("deriveMobileRemoteTurnPresentation", () => { } expect( - deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showRemoteStop, ).toBe(true) expect( - deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showRemoteStop, ).toBe(false) }) - it("hides the banner when the advertised reader is ready", () => { + it("hides remote Stop when the advertised reader is ready", () => { expect( deriveMobileRemoteTurnPresentation({ livenessRunning: true, sharedReaderAdvertised: true, readerReady: true, - }).showStrip, + }).showRemoteStop, ).toBe(false) }) }) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index c59ff0c52d2..23d9d3624c2 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -77,7 +77,11 @@ import {useScrollIntent} from "./hooks/useScrollIntent" import {useTranscriptScroll} from "./hooks/useTranscriptScroll" import {useTurnInspector} from "./hooks/useTurnInspector" import {useVirtuosoTranscript} from "./hooks/useVirtuosoTranscript" -import {deriveSessionRemoteTurnPresentation, shouldShowRunningElsewhere} from "./state/liveness" +import { + deriveSessionRemoteTurnPresentation, + sessionLivenessUpdatedAtAtom, + refreshSessionLivenessAtom, +} from "./state/liveness" import {useChatScopeKey} from "./state/scope" import { activeSessionIdAtomFamily, @@ -168,6 +172,7 @@ const AgentConversation = ({ const { messages: previewMessages, runningFromSnapshot, + sharedSettledAt, readerReady, } = useSessionLivePreview({ sessionId, @@ -178,9 +183,16 @@ const AgentConversation = ({ onExecutionSettled: settleSharedTurn, onDisconnect: refreshFromRecords, }) + const livenessUpdatedAt = useAtomValue(sessionLivenessUpdatedAtAtom) + const refreshLiveness = useSetAtom(refreshSessionLivenessAtom) + useEffect(() => { + if (sharedSettledAt) void refreshLiveness() + }, [sharedSettledAt, refreshLiveness]) const remoteTurn = deriveSessionRemoteTurnPresentation({ livenessRunning: livenessRunningElsewhere, - snapshotRunning: runningFromSnapshot || acceptedRunPending, + livenessUpdatedAt, + sharedSettledAt, + snapshotRunning: runningFromSnapshot, sharedReaderAdvertised, readerReady, ownedContinuation: acceptedRunPending, @@ -446,11 +458,6 @@ const AgentConversation = ({ sessionId, server: serverInputs, }) - const showRunningElsewhere = shouldShowRunningElsewhere({ - runningElsewhere: livenessRunningElsewhere, - executionState: serverInputs.executionState, - pendingInputCount: serverInputs.queued.length, - }) // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision made // in THIS mount marks the resume as live — a restored approval-requested tail the user answers @@ -1000,9 +1007,6 @@ const AgentConversation = ({ entityId={entityId} messages={messages} busy={busy} - showRunningElsewhere={ - remoteTurn.showStrip && showRunningElsewhere - } connectionWarning={connectionWarning} hitlPending={hitlPending} queue={{ diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 1db78e5ccec..bb76f22aa73 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -11,7 +11,6 @@ import { ConnectionWarningStrip, MicPermissionNotice, RecordingBar, - RunningElsewhereStrip, VoiceInputButton, } from "@agenta/chat/components" import { @@ -63,7 +62,6 @@ const AgentComposerDock = ({ entityId, messages, busy, - showRunningElsewhere, connectionWarning, hitlPending, queue, @@ -97,8 +95,6 @@ const AgentComposerDock = ({ entityId: string messages: UIMessage[] busy: boolean - /** Show the disconnected/flag-off fallback for a run this browser is not driving. */ - showRunningElsewhere: boolean /** The sender request disconnected after the session accepted the turn. */ connectionWarning?: string hitlPending: boolean @@ -339,9 +335,6 @@ const AgentComposerDock = ({
{/* Sits with the other docked strips so a session running in another browser reads as busy instead of frozen (#5530). */} - {showRunningElsewhere && !chromeHidden ? ( - - ) : null} {connectionWarning && !chromeHidden ? ( ) : null} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index ce495dcd732..7bc32305800 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -37,6 +37,7 @@ const state = vi.hoisted(() => ({ regenerate: vi.fn(() => Promise.resolve()), sendMessage: vi.fn(() => Promise.resolve()), turnIds: new Map(), + hydrationBusyRef: undefined as {current: boolean} | undefined, busy: false, stop: vi.fn(), })) @@ -195,14 +196,17 @@ vi.mock("./useFileActivityDetector", () => ({ useFileActivityDetector: vi.fn(), })) vi.mock("./useSessionHydration", () => ({ - useSessionHydration: () => ({ - hydratedEmpty: false, - isHydrating: false, - runningElsewhere: false, - sessionTurnId: state.sessionTurnId, - stoppingTurnId: state.stoppingTurnId, - stopStateLoading: state.stopStateLoading, - }), + useSessionHydration: ({busyRef}: {busyRef: {current: boolean}}) => { + state.hydrationBusyRef = busyRef + return { + hydratedEmpty: false, + isHydrating: false, + runningElsewhere: false, + sessionTurnId: state.sessionTurnId, + stoppingTurnId: state.stoppingTurnId, + stopStateLoading: state.stopStateLoading, + } + }, })) vi.mock("./useToolCacheInvalidation", () => ({ useToolCacheInvalidation: vi.fn(), @@ -233,6 +237,39 @@ describe("useAgentChatSession execution guard", () => { state.busy = false }) + it("allows durable hydration for an accepted shared sender while protecting local streaming", async () => { + state.busy = true + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + expect(state.hydrationBusyRef!.current).toBe(true) + + await act(() => state.capturedHooks!.prepareRequest({messages: [], id: "session-1"})) + act(() => + state.capturedHooks!.onData({ + type: "data-session-accepted", + data: {executionId: "accepted-turn"}, + }), + ) + expect(result!.acceptedRunPending).toBe(true) + expect(state.hydrationBusyRef!.current).toBe(false) + + state.busy = false + act(() => root.render(createElement(Probe))) + expect(result!.acceptedRunPending).toBe(true) + expect(state.hydrationBusyRef!.current).toBe(false) + act(() => root.unmount()) + }) + it("settles a desktop accepted turn when its shared invoke stream finishes", () => { const sessionId = "session-1" let result: ReturnType | undefined diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 42c64d328f8..f301813d8af 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -369,6 +369,9 @@ export const useAgentChatSession = ({ messagesRef.current = messages const busyRef = useRef(busy || acceptedRunPending) busyRef.current = busy || acceptedRunPending + // Accepted shared turns receive their content through durable hydration. + const localRenderBusyRef = useRef(busy && !acceptedRunPending) + localRenderBusyRef.current = busy && !acceptedRunPending useEffect(() => { dispatchStopped({type: "transcript", messages}) @@ -395,7 +398,7 @@ export const useAgentChatSession = ({ sessionId, initialMessages, messagesRef, - busyRef, + busyRef: localRenderBusyRef, seenIdsRef, restoredIdsRef, recordWatermarkRef, diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index a03200c7669..709ab5136b0 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -110,35 +110,35 @@ describe("isRunningElsewhere", () => { describe("deriveSessionRemoteTurnPresentation", () => { it.each([ { - name: "renders activity and no strip for a ready reader", + name: "renders activity without remote Stop for a ready reader", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "renders the strip while the reader is not ready", + name: "renders activity and remote Stop while the reader reconnects", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "renders the strip when the feature is off", + name: "renders activity and remote Stop when the reader is off", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "does not render the strip in the tab that owns a continuation", + name: "renders activity without remote Stop for an owned continuation", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveSessionRemoteTurnPresentation(input)).toEqual(expected) }) - it("shows the flag-off observer banner only while session-stream liveness is running", () => { + it("offers legacy remote Stop only while session-stream liveness is running", () => { const input = { snapshotRunning: true, sharedReaderAdvertised: false, @@ -146,20 +146,20 @@ describe("deriveSessionRemoteTurnPresentation", () => { } expect( - deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showRemoteStop, ).toBe(true) expect( - deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showRemoteStop, ).toBe(false) }) - it("hides the banner when the advertised reader is ready", () => { + it("hides remote Stop when the advertised reader is ready", () => { expect( deriveSessionRemoteTurnPresentation({ livenessRunning: true, sharedReaderAdvertised: true, readerReady: true, - }).showStrip, + }).showRemoteStop, ).toBe(false) }) }) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index 23ac76468e3..c35a886568f 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -34,6 +34,9 @@ const aliveStreamsQueryAtom = atomWithQuery((get) => { } }) +export const sessionLivenessUpdatedAtAtom = atom((get) => get(aliveStreamsQueryAtom).dataUpdatedAt) +export const refreshSessionLivenessAtom = atom(null, (get) => get(aliveStreamsQueryAtom).refetch()) + /** `session_id → live stream` map for O(1) per-dot lookup off the single shared query. */ const aliveStreamsMapAtom = atom((get) => { const streams = get(aliveStreamsQueryAtom).data ?? [] @@ -125,7 +128,7 @@ export const isRunningElsewhere = ({ return localSettledAt === undefined || livenessUpdatedAt > localSettledAt } -/** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ +/** Desktop presentation for a remote/shared-path run. */ export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation /** diff --git a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx b/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx deleted file mode 100644 index 9d692eb2b6f..00000000000 --- a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type {ReactNode} from "react" - -import {cn} from "@agenta/ui/ui" - -/** - * "This session is running somewhere else" — shown when the backend reports a live run for the - * session while THIS browser isn't the one streaming it (another tab, another device). - * - * Issue #5530: a second browser gave no sign at all that anything was happening, so a session that - * was mid-turn looked identical to an idle one. This is now the fallback while the shared reader - * is disabled or disconnected; a ready reader streams the transcript and shows turn activity. - * - * NOT shown while this browser is the one streaming: the composer's send button is already a Stop - * and the transcript already shows the turn working, so a second "running" banner is noise — and - * one that mounts and unmounts around every turn shifts the layout twice per run. - * - * The copy stops short of promising the transcript WILL move. `is_running` says a turn took the - * lock, not that anything is still serving it: a runner that dies mid-turn leaves the flag set - * until its shutdown drain completes, or failing that until the execution watchdog settles it - * (`ORPHAN_THRESHOLD_SECONDS`, 120s by default). Measured on a dev stack, that window runs from - * ~20s to a couple of minutes. Asserting progress through it told people to keep waiting on a run - * that was over, so the second sentence names that possibility instead. It is deliberately not a call to action: - * only /m passes a Stop here, and the desktop has no control to point at. - * - * Matches the `running` dot in the session bar (`bg-colorInfo`, pulsing) so the two read as one - * signal. - */ -export const RunningElsewhereStrip = ({ - className, - action, -}: { - className?: string - /** Optional trailing control — /m offers stopping a run this device is not driving. */ - action?: ReactNode -}) => ( -
- - - - - - This turn is still running — the transcript updates as it progresses. If it stays still, - the run may have already ended. - - {action ? {action} : null} -
-) diff --git a/web/packages/agenta-chat/src/components/index.ts b/web/packages/agenta-chat/src/components/index.ts index 484450632a8..f5911610b3a 100644 --- a/web/packages/agenta-chat/src/components/index.ts +++ b/web/packages/agenta-chat/src/components/index.ts @@ -26,7 +26,6 @@ export {default as RecordingWaveform} from "./RecordingWaveform" export {TurnFooter} from "./TurnFooter" export {TurnMetrics} from "./TurnMetrics" export {TurnTimestamp} from "./TurnTimestamp" -export {RunningElsewhereStrip} from "./RunningElsewhereStrip" export {ConnectionWarningStrip} from "./ConnectionWarningStrip" export {SessionHistoryNotice, type SessionHistoryNoticeState} from "./SessionHistoryNotice" export {StartupActivity, WaitingForInput, WorkingDots} from "./TurnActivity" diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index b8ac0c8f162..3cbbde7b2cd 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -219,6 +219,7 @@ export interface AgentConversation { revalidate: () => void /** Atomic snapshot says an unfinished backend execution is still running after refresh. */ runningFromSnapshot: boolean + sharedSettledAt: number /** The shared live-event channel completed replay and is following new frames. */ readerReady: boolean /** This browser's accepted turn is still owned by the shared session path. */ @@ -1039,6 +1040,7 @@ export const useAgentConversation = ({ const { messages: previewMessages, runningFromSnapshot, + sharedSettledAt, readerReady, } = useSessionLivePreview({ sessionId, @@ -1270,6 +1272,7 @@ export const useAgentConversation = ({ sendToolOutput, revalidate, runningFromSnapshot, + sharedSettledAt, readerReady, acceptedRunPending, interactionChanged, diff --git a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts index 11cc48cde12..e33c718a3b8 100644 --- a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts +++ b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts @@ -23,6 +23,9 @@ import { import { isSessionSnapshotRunning, reduceSessionLivePreview, + markSessionLivePreviewTerminal, + retireSessionLivePreview, + retireCoveredSessionLivePreview, sessionLivePreviewMessages, shouldSubscribeToSessionLivePreview, } from "../model/livePreview" @@ -56,7 +59,12 @@ export const useSessionLivePreview = ({ onExecutionSettled?: (executionId?: string) => void /** Adopts a bounded transcript or re-fetches after a later gap/disconnect. */ onDisconnect: (transcript?: SessionTranscript) => boolean | Promise -}): {messages: UIMessage[]; runningFromSnapshot: boolean; readerReady: boolean} => { +}): { + messages: UIMessage[] + runningFromSnapshot: boolean + readerReady: boolean + sharedSettledAt: number +} => { const projectId = useAtomValue(projectIdAtom) const [preview, setPreview] = useAtom(sessionLivePreviewAtomFamily(sessionId)) const clearPreview = useSetAtom(clearSessionLivePreviewAtom) @@ -64,6 +72,7 @@ export const useSessionLivePreview = ({ const revalidateInteractionStates = useSetAtom(revalidateSessionInteractionsAtom) const [runningFromSnapshot, setRunningFromSnapshot] = useState(false) const [readerReady, setReaderReady] = useState(false) + const [sharedSettledAt, setSharedSettledAt] = useState(0) const onDisconnectRef = useRef(onDisconnect) onDisconnectRef.current = onDisconnect const retryHydrationRef = useRef<() => void>(() => undefined) @@ -83,6 +92,7 @@ export const useSessionLivePreview = ({ useEffect(() => { clearPreview(sessionId) + setSharedSettledAt(0) setReaderReady(false) onReadyChangeRef.current?.(false) if (!sharedReaderAdvertised || !sessionId) return @@ -102,7 +112,6 @@ export const useSessionLivePreview = ({ connection = null setReaderReady(false) onReadyChangeRef.current?.(false) - clearPreview(sessionId) } const scheduleReconnect = () => { @@ -127,25 +136,42 @@ export const useSessionLivePreview = ({ const readBoundedTranscript = async ( throughSequence: number, - ): Promise => { + ): Promise<{transcript: SessionTranscript; coveredEntityIds: Set} | null> => { if (!projectId) return null const [records, interactionRowStates] = await Promise.all([ querySessionTranscript({sessionId, projectId, throughSequence}), fetchInteractionStates(sessionId), ]) if (!Array.isArray(records)) return null + const coveredEntityIds = new Set( + records.flatMap((record) => { + const payload = record.payload + if (!payload) return [] + const id = + payload.type === "message" || payload.type === "thought" + ? payload.message_id + : payload.type === "tool_result" + ? payload.id + : undefined + return typeof id === "string" ? [id] : [] + }), + ) return { - messages: transcriptToMessages(records, {interactionRowStates}) ?? [], - recordCount: records.length, - sequenceCursor: throughSequence, - interactionRows: interactionRowStates, + transcript: { + messages: transcriptToMessages(records, {interactionRowStates}) ?? [], + recordCount: records.length, + sequenceCursor: throughSequence, + interactionRows: interactionRowStates, + }, + coveredEntityIds, } } const hydrateAndOpen = async () => { if (disposed || connection || document.visibilityState !== "visible") return + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = null const currentGeneration = ++generation - clearPreview(sessionId) let snapshot try { @@ -158,23 +184,40 @@ export const useSessionLivePreview = ({ const snapshotRunning = isSessionSnapshotRunning(snapshot ?? undefined) setRunningFromSnapshot(snapshotRunning) if (snapshot?.session && snapshot.read && !snapshotRunning) { + setSharedSettledAt(Date.now()) onExecutionSettledRef.current?.() } if (snapshot?.session && snapshot.read && projectId) { try { - const transcript = await readBoundedTranscript(snapshot.read.latest_sequence) - if (!transcript) { + let previewBoundary: typeof preview | undefined + setPreview((current) => { + previewBoundary = current + return current + }) + const bounded = await readBoundedTranscript(snapshot.read.latest_sequence) + if (!bounded) { scheduleReconnect() return } if (disposed || currentGeneration !== generation) return - const adopted = await adoptTranscript(transcript) + const adopted = await adoptTranscript(bounded.transcript) if (disposed || currentGeneration !== generation) return if (!adopted) { scheduleReconnect() return } + if (!snapshotRunning) clearPreview(sessionId) + else + setPreview((current) => ({ + ...retireCoveredSessionLivePreview( + current, + previewBoundary ?? current, + bounded.coveredEntityIds, + bounded.transcript.messages, + ), + gapDetected: false, + })) } catch { scheduleReconnect() return @@ -193,9 +236,12 @@ export const useSessionLivePreview = ({ connection = connectSessionLiveEvents({ sessionId, after: durable.latestSequence, - onFrame: (frame) => - setPreview((current) => reduceSessionLivePreview(current, frame)), + onFrame: (frame) => { + if (disposed || currentGeneration !== generation) return + setPreview((current) => reduceSessionLivePreview(current, frame)) + }, onEvent: (event) => { + if (disposed || currentGeneration !== generation) return const next = reduceSessionDurableEvent(durable, event) if (!shouldRefetchSessionTranscript(durable, next, event)) { durable = next @@ -203,44 +249,67 @@ export const useSessionLivePreview = ({ } durable = next if (event.type === "execution.started") setRunningFromSnapshot(true) + let previewBoundary: typeof preview | undefined + setPreview((current) => { + previewBoundary = current + return ["execution.stopped", "execution.failed", "execution.lost"].includes( + event.type, + ) + ? markSessionLivePreviewTerminal(current, event) + : current + }) if ( event.type === "execution.stopped" || event.type === "execution.failed" || event.type === "execution.lost" ) { setRunningFromSnapshot(false) + setSharedSettledAt(Date.now()) onExecutionSettledRef.current?.(event.execution_id) } const interactionChanged = event.type === "interaction.requested" || event.type === "interaction.responded" if (interactionChanged) revalidateInteractionStates(sessionId) - // Completed durable rows replace temporary frames in the transcript source. - clearPreview(sessionId) - const refresh = - interactionChanged || event.type === "tool.completed" - ? readBoundedTranscript(next.latestSequence).then((transcript) => - transcript ? adoptTranscript(transcript) : false, - ) - : adoptTranscript() - void refresh.then( - (adopted) => { - if (!adopted && !disposed) scheduleReconnect() - }, - () => { - if (!disposed) scheduleReconnect() - }, - ) + void readBoundedTranscript(next.latestSequence) + .then(async (bounded) => { + if (disposed || currentGeneration !== generation) return + const transcript = bounded?.transcript + const adopted = transcript ? await adoptTranscript(transcript) : false + if (disposed || currentGeneration !== generation) return + if (adopted) { + setPreview((current) => + retireSessionLivePreview( + bounded && previewBoundary + ? retireCoveredSessionLivePreview( + current, + previewBoundary ?? current, + bounded.coveredEntityIds, + bounded.transcript.messages, + ) + : current, + event, + previewBoundary, + transcript?.messages, + ), + ) + } else scheduleReconnect() + }) + .catch(() => { + if (!disposed && currentGeneration === generation) scheduleReconnect() + }) }, onReady: ({watermark}) => { + if (disposed || currentGeneration !== generation) return durable = completeSessionDurableEventReplay(durable, watermark) reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS setReaderReady(true) onReadyChangeRef.current?.(true) }, onDisconnect: ({reconnect}) => { + if (disposed || currentGeneration !== generation) return close() - void adoptTranscript() + // Reconcile saved records and preview together during snapshot recovery. if (reconnect) scheduleReconnect() }, }) @@ -265,6 +334,7 @@ export const useSessionLivePreview = ({ if (reconnectTimer) clearTimeout(reconnectTimer) document.removeEventListener("visibilitychange", onVisibility) close() + clearPreview(sessionId) } }, [ clearPreview, @@ -279,15 +349,13 @@ export const useSessionLivePreview = ({ useEffect(() => { if (!preview.gapDetected) return - const retryHydration = retryHydrationRef.current - void Promise.resolve(onDisconnectRef.current()).then((adopted) => { - if (!adopted) retryHydration() - }, retryHydration) + retryHydrationRef.current() }, [preview.gapDetected]) return { messages: useMemo(() => sessionLivePreviewMessages(preview), [preview]), runningFromSnapshot: sharedReaderAdvertised && runningFromSnapshot, readerReady: sharedReaderAdvertised && subscribed && readerReady, + sharedSettledAt: sharedReaderAdvertised ? sharedSettledAt : 0, } } diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts index 7b1e0c0ba1a..ea99c794526 100644 --- a/web/packages/agenta-chat/src/model/livePreview.ts +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -1,6 +1,7 @@ import { createSessionLivePreviewState, type SessionSnapshot, + type SessionDurableEvent, type SessionLiveFrame, type SessionLivePreviewExecution, type SessionLivePreviewState, @@ -33,9 +34,11 @@ export const shouldSubscribeToSessionLivePreview = ({ sender?: boolean }): boolean => sharedReaderAdvertised && (sender || runningElsewhere) -/** Choose the live activity treatment only while the shared reader is actually connected. */ +/** Run activity follows execution state, not whether its reader is connected. */ export const deriveRemoteTurnPresentation = ({ livenessRunning, + livenessUpdatedAt = Infinity, + sharedSettledAt = 0, snapshotRunning = false, sharedReaderAdvertised, readerReady, @@ -43,18 +46,22 @@ export const deriveRemoteTurnPresentation = ({ }: { /** Milestone-1 session-stream liveness; the only running source when the reader is disabled. */ livenessRunning: boolean + livenessUpdatedAt?: number + sharedSettledAt?: number /** Atomic shared-reader snapshot state. Ignored while the reader capability is disabled. */ snapshotRunning?: boolean sharedReaderAdvertised: boolean readerReady: boolean /** This tab answered the gate and owns the continuation even if its invoke stream detached. */ ownedContinuation?: boolean -}): {showActivity: boolean; showStrip: boolean} => { - const running = livenessRunning || (sharedReaderAdvertised && snapshotRunning) - const showActivity = running && sharedReaderAdvertised && readerReady +}): {showActivity: boolean; showRemoteStop: boolean} => { + const livenessIsFresh = !sharedReaderAdvertised || livenessUpdatedAt > sharedSettledAt + const running = + (livenessRunning && livenessIsFresh) || + (sharedReaderAdvertised && (snapshotRunning || ownedContinuation)) return { - showActivity, - showStrip: running && !showActivity && !ownedContinuation, + showActivity: running, + showRemoteStop: running && !(sharedReaderAdvertised && readerReady) && !ownedContinuation, } } @@ -153,17 +160,36 @@ export const reduceSessionLivePreview = ( state: SessionLivePreviewState, frame: SessionLiveFrame, ): SessionLivePreviewState => { - if (state.gapDetected) return state - const current = state.byExecution[frame.execution_id] + // Both timestamps originate at the runner: buffered frames can arrive after their done row. + if ( + current?.terminalCreatedAt && + Date.parse(frame.created_at) < Date.parse(current.terminalCreatedAt) + ) + return state if (current && frame.frame_index <= current.lastFrameIndex) return state - if (current && frame.frame_index !== current.lastFrameIndex + 1) { - return {...createSessionLivePreviewState(), gapDetected: true} + const expectedFrameIndex = current ? current.lastFrameIndex + 1 : 0 + const gap = Boolean(current && frame.frame_index !== expectedFrameIndex) + const incompleteEntityIds = new Set(current?.incompleteEntityIds ?? []) + if (gap) { + for (const [id, entity] of Object.entries(current?.byEntity ?? {})) { + if (entity.part.type === "text" || entity.part.type === "reasoning") + incompleteEntityIds.add(id) + } } + const isDelta = frame.type === "text-delta" || frame.type === "reasoning-delta" + if (isDelta && (gap || (!current?.byEntity[frame.entity_id] && frame.frame_index !== 0))) + incompleteEntityIds.add(frame.entity_id) + if (frame.type === "text-start" || frame.type === "reasoning-start") + incompleteEntityIds.delete(frame.entity_id) const previousPart = current?.byEntity[frame.entity_id]?.part - const nextPart = applyFrame(previousPart, frame) + const nextPart = current?.retiredEntityIds?.includes(frame.entity_id) + ? undefined + : incompleteEntityIds.has(frame.entity_id) && isDelta + ? previousPart + : applyFrame(previousPart, frame) const execution: SessionLivePreviewExecution = current ?? { entityOrder: [], byEntity: {}, @@ -174,10 +200,11 @@ export const reduceSessionLivePreview = ( executionOrder: current ? state.executionOrder : [...state.executionOrder, frame.execution_id], - gapDetected: false, + gapDetected: state.gapDetected || gap, byExecution: { ...state.byExecution, [frame.execution_id]: { + ...execution, entityOrder: nextPart && !previousPart ? [...execution.entityOrder, frame.entity_id] @@ -185,15 +212,155 @@ export const reduceSessionLivePreview = ( byEntity: nextPart ? { ...execution.byEntity, - [frame.entity_id]: {part: nextPart}, + [frame.entity_id]: { + part: nextPart, + complete: [ + "text-end", + "reasoning-end", + "tool-output-available", + "tool-output-error", + ].includes(frame.type), + }, } : execution.byEntity, lastFrameIndex: frame.frame_index, + incompleteEntityIds: [...incompleteEntityIds], + }, + }, + } +} + +/** A prompt boundary closes its old entities, but leaves their text visible until adoption. */ +export const markSessionLivePreviewTerminal = ( + state: SessionLivePreviewState, + event: Pick, +): SessionLivePreviewState => { + const executionId = event.execution_id + const execution = state.byExecution[executionId] ?? { + entityOrder: [], + byEntity: {}, + lastFrameIndex: -1, + } + return { + ...state, + executionOrder: state.executionOrder.includes(executionId) + ? state.executionOrder + : [...state.executionOrder, executionId], + byExecution: { + ...state.byExecution, + [executionId]: { + ...execution, + lastFrameIndex: -1, + terminalCreatedAt: event.created_at, + retiredEntityIds: [ + ...new Set([...(execution.retiredEntityIds ?? []), ...execution.entityOrder]), + ], + }, + }, + } +} + +/** Retire only output captured before adoption; a resumed prompt can already be streaming. */ +export const retireSessionLivePreview = ( + state: SessionLivePreviewState, + event: SessionDurableEvent, + boundary: SessionLivePreviewState = state, + adoptedMessages: UIMessage[] = [], +): SessionLivePreviewState => { + const terminal = ["execution.stopped", "execution.failed", "execution.lost"].includes( + event.type, + ) + const entityId = + event.type === "message.completed" + ? event.payload.message_id + : event.type === "tool.completed" + ? event.payload.tool_call_id + : undefined + if (!terminal && typeof entityId !== "string") return state + const execution = state.byExecution[event.execution_id] ?? { + entityOrder: [], + byEntity: {}, + lastFrameIndex: -1, + } + const captured = boundary.byExecution[event.execution_id] + const durableReasoning = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])), + ), + ) + const capturedReasoning = (captured?.entityOrder ?? []).filter((id) => { + const part = captured?.byEntity[id]?.part + return ( + captured?.byEntity[id]?.complete === true && + part?.type === "reasoning" && + durableReasoning.has(String(part.text)) + ) + }) + const candidates = terminal + ? (captured?.entityOrder ?? []) + : [entityId as string, ...capturedReasoning] + const retired = candidates.filter( + (id) => execution.byEntity[id]?.part === captured?.byEntity[id]?.part, + ) + const byEntity = {...execution.byEntity} + for (const id of retired) delete byEntity[id] + return { + ...state, + executionOrder: state.executionOrder.includes(event.execution_id) + ? state.executionOrder + : [...state.executionOrder, event.execution_id], + byExecution: { + ...state.byExecution, + [event.execution_id]: { + ...execution, + entityOrder: execution.entityOrder.filter((id) => !retired.includes(id)), + byEntity, + retiredEntityIds: [...new Set([...(execution.retiredEntityIds ?? []), ...retired])], }, }, } } +/** Reconcile a running snapshot without dropping parts absent from its committed record prefix. */ +export const retireCoveredSessionLivePreview = ( + state: SessionLivePreviewState, + boundary: SessionLivePreviewState, + coveredEntityIds: ReadonlySet, + adoptedMessages: UIMessage[], +): SessionLivePreviewState => { + const durableReasoning = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])), + ), + ) + const byExecution = {...state.byExecution} + for (const executionId of boundary.executionOrder) { + const captured = boundary.byExecution[executionId] + const current = state.byExecution[executionId] + if (!captured || !current) continue + const retired = captured.entityOrder.filter((id) => { + const entity = captured.byEntity[id] + if (!entity || current.byEntity[id]?.part !== entity.part) return false + return ( + coveredEntityIds.has(id) || + (entity.complete === true && + entity.part.type === "reasoning" && + durableReasoning.has(String(entity.part.text))) + ) + }) + if (!retired.length) continue + const byEntity = {...current.byEntity} + for (const id of retired) delete byEntity[id] + byExecution[executionId] = { + ...current, + byEntity, + entityOrder: current.entityOrder.filter((id) => !retired.includes(id)), + retiredEntityIds: [...new Set([...(current.retiredEntityIds ?? []), ...retired])], + } + } + return {...state, byExecution} +} + /** Build disposable UI messages from the collapsed entity state. */ export const sessionLivePreviewMessages = (state: SessionLivePreviewState): UIMessage[] => state.executionOrder.flatMap((executionId) => { diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx index 50a23589555..3da72088a1c 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -2,7 +2,7 @@ import {createElement, type ReactNode} from "react" import type {SessionInteractionRowStates, SessionRecord} from "@agenta/entities/session" import {projectIdAtom} from "@agenta/shared/state" -import {act, renderHook, waitFor} from "@testing-library/react" +import {act, cleanup, renderHook, waitFor} from "@testing-library/react" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" @@ -61,6 +61,7 @@ describe("useSessionLivePreview", () => { }) afterEach(() => { + cleanup() vi.useRealTimers() vi.unstubAllGlobals() }) @@ -412,6 +413,233 @@ describe("useSessionLivePreview", () => { expect(mocks.revalidateInteractionStates).toHaveBeenCalledOnce() }) + it("keeps streamed text during durable adoption and preserves its cursor after a tool completes", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const adopted = deferred() + const onDisconnect = vi.fn().mockResolvedValueOnce(true).mockReturnValue(adopted.promise) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const connection = mocks.connectSessionLiveEvents.mock.calls[0][0] + const emit = ( + index: number, + type: string, + payload: Record, + entity = "text-1", + ) => + connection.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: `turn-1:${index}`, + frame_index: index, + entity_id: entity, + type, + payload, + created_at: "2026-09-06T00:00:00Z", + }) + act(() => { + emit(0, "text-start", {}) + emit(1, "text-delta", {delta: "Still writing"}) + emit( + 2, + "tool-input-available", + {toolCallId: "tool-1", toolName: "shell", input: {}}, + "tool-1", + ) + connection.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-1", + sequence: 1, + watermark: 1, + type: "tool.completed", + payload: {tool_call_id: "tool-1"}, + created_at: "2026-09-06T00:00:00Z", + }) + }) + expect(result.current.messages[0].parts).toContainEqual({ + type: "text", + text: "Still writing", + }) + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(2)) + act(() => emit(3, "text-delta", {delta: " more"})) + await act(async () => adopted.resolve(true)) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more"}, + ]) + act(() => emit(4, "text-delta", {delta: " text"})) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more text"}, + ]) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce() + act(() => connection.onDisconnect({reason: "connection_lost", reconnect: true})) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more text"}, + ]) + }) + + it("retires snapshot-covered preview after reconnect while keeping unfinished text", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const onDisconnect = vi.fn().mockResolvedValue(true) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + const emit = ( + connection: typeof first, + index: number, + type: string, + payload: Record, + entity: string, + ) => + connection.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: `turn-1:${index}`, + frame_index: index, + entity_id: entity, + type, + payload, + created_at: "2026-09-06T00:00:00Z", + }) + act(() => { + emit(first, 0, "text-start", {}, "text-1") + emit(first, 1, "text-delta", {delta: "Saved answer"}, "text-1") + emit(first, 2, "text-end", {}, "text-1") + emit(first, 3, "text-start", {}, "text-2") + emit(first, 4, "text-delta", {delta: "Live prefix"}, "text-2") + first.onDisconnect({reason: "connection_lost", reconnect: true}) + }) + // Disconnection must not adopt an unbounded transcript while its matching + // preview remains visible; snapshot recovery reconciles them together. + expect(onDisconnect).toHaveBeenCalledOnce() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 1}, + }) + mocks.querySessionTranscript.mockResolvedValue([ + record("saved-row", {type: "message", message_id: "text-1", text: "Saved answer"}), + ]) + act(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }) + document.dispatchEvent(new Event("visibilitychange")) + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2)) + expect(result.current.messages[0].parts).toEqual([{type: "text", text: "Live prefix"}]) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + expect(second.after).toBe(1) + act(() => emit(second, 5, "text-delta", {delta: " continues"}, "text-2")) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Live prefix continues"}, + ]) + expect(onDisconnect.mock.calls.at(-1)?.[0].messages[0].parts).toContainEqual({ + type: "text", + text: "Saved answer", + }) + act(() => emit(second, 7, "text-delta", {delta: " missing middle"}, "text-2")) + expect(onDisconnect).toHaveBeenCalledTimes(2) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Live prefix continues"}, + ]) + }) + + it("does not let a pending retry interrupt a reader reopened after visibility changes", async () => { + vi.useFakeTimers() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "execution-1", end_time: null}, + read: {latest_sequence: 7}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper}, + ) + await act(async () => vi.advanceTimersByTimeAsync(0)) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + act(() => first.onDisconnect({reason: "connection_lost", reconnect: true})) + await act(async () => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }) + document.dispatchEvent(new Event("visibilitychange")) + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }) + document.dispatchEvent(new Event("visibilitychange")) + await vi.advanceTimersByTimeAsync(0) + }) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + act(() => second.onReady({watermark: 7})) + expect(result.current.readerReady).toBe(true) + await act(async () => vi.advanceTimersByTimeAsync(5_000)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + expect(result.current.readerReady).toBe(true) + expect(result.current.runningFromSnapshot).toBe(true) + }) + it("backs reconnects off and resets the delay only after ready", async () => { vi.useFakeTimers() mocks.fetchSessionSnapshot.mockResolvedValue({ diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts index e1413646aad..c9e18d7643b 100644 --- a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -6,6 +6,8 @@ import { deriveRemoteTurnPresentation, isSessionSnapshotRunning, reduceSessionLivePreview, + retireSessionLivePreview, + markSessionLivePreviewTerminal, sessionLivePreviewMessages, shouldRefreshLegacyObserverLiveness, shouldSubscribeToSessionLivePreview, @@ -129,7 +131,7 @@ describe("session live preview reducer", () => { expect(sessionLivePreviewMessages(stale)[0].parts).toEqual([{type: "text", text: "newer"}]) }) - it("accepts a late join and enforces contiguous indexes after its first frame", () => { + it("accepts a late join cursor without rendering a missing text prefix", () => { const joined = reduceSessionLivePreview( createSessionLivePreviewState(), frame(2, "text-delta", {delta: "tail"}), @@ -137,12 +139,12 @@ describe("session live preview reducer", () => { const later = reduceSessionLivePreview(joined, frame(3, "text-delta", {delta: " later"})) expect(joined.gapDetected).toBe(false) - expect(sessionLivePreviewMessages(later)[0].parts).toEqual([ - {type: "text", text: "tail later"}, - ]) + expect(sessionLivePreviewMessages(joined)).toEqual([]) + expect(sessionLivePreviewMessages(later)).toEqual([]) + expect(later.byExecution["turn-1"].lastFrameIndex).toBe(3) }) - it("clears and suppresses a preview after an internal frame gap", () => { + it("preserves a preview and suppresses further deltas after an internal frame gap", () => { const first = reduceSessionLivePreview( createSessionLivePreviewState(), frame(0, "text-delta", {delta: "hello"}), @@ -151,8 +153,8 @@ describe("session live preview reducer", () => { const missing = reduceSessionLivePreview(gapped, frame(1, "text-delta", {delta: " world"})) expect(gapped.gapDetected).toBe(true) - expect(gapped.executionOrder).toEqual([]) - expect(sessionLivePreviewMessages(gapped)).toEqual([]) + expect(gapped.executionOrder).toEqual(["turn-1"]) + expect(sessionLivePreviewMessages(gapped)).toEqual(sessionLivePreviewMessages(first)) expect(missing).toBe(gapped) }) @@ -257,32 +259,348 @@ describe("legacy observer liveness refresh", () => { }) }) +describe("durable preview handoff", () => { + it("retires only adopted tool output while preserving text and the next frame cursor", () => { + let state = createSessionLivePreviewState() + state = reduceSessionLivePreview(state, frame(0, "text-start", {})) + state = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "Still writing"})) + state = reduceSessionLivePreview( + state, + frame( + 2, + "tool-input-available", + { + toolCallId: "tool-1", + toolName: "shell", + input: {}, + }, + "tool-1", + ), + ) + state = retireSessionLivePreview(state, { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-1", + sequence: 1, + watermark: 1, + type: "tool.completed", + payload: {tool_call_id: "tool-1"}, + created_at: "2026-09-06T00:00:00Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Still writing"}, + ]) + state = reduceSessionLivePreview(state, frame(3, "text-delta", {delta: " more"})) + expect(state.gapDetected).toBe(false) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Still writing more"}, + ]) + state = reduceSessionLivePreview( + state, + frame( + 4, + "tool-output-available", + { + toolCallId: "tool-1", + output: "done", + }, + "tool-1", + ), + ) + expect(sessionLivePreviewMessages(state)[0].parts).toHaveLength(1) + }) + + it("renders a resumed execution whose paused turn had no preview frames", () => { + let state = markSessionLivePreviewTerminal(createSessionLivePreviewState(), { + execution_id: "turn-1", created_at: "2026-09-06T00:00:00Z", + }) + state = reduceSessionLivePreview(state, {...frame(0, "text-delta", {delta: "Resumed"}), created_at: "2026-09-06T00:00:01Z"}) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([{type: "text", text: "Resumed"}]) + }) + + it("preserves a same-execution resumed prompt when paused adoption finishes late", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Before approval"}), + ) + const boundary = state + state = markSessionLivePreviewTerminal(state, { + execution_id: "turn-1", + created_at: "2026-09-06T00:00:00Z", + }) + const marked = state + state = reduceSessionLivePreview(state, { + ...frame(12, "text-delta", {delta: "Delayed old text"}, "late-old-entity"), + created_at: "2026-09-05T23:59:59Z", + }) + expect(state).toBe(marked) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-start", {}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + state = reduceSessionLivePreview(state, { + ...frame(1, "text-delta", {delta: "Resumed"}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + state = retireSessionLivePreview( + state, + { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "paused-record", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {reason: "paused"}, + created_at: "2026-09-06T00:00:00Z", + }, + boundary, + ) + state = reduceSessionLivePreview(state, { + ...frame(2, "text-delta", {delta: " successfully"}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Resumed successfully"}, + ]) + expect(state.gapDetected).toBe(false) + }) + + it("retires only reasoning actually present in the adopted durable transcript", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "reasoning-start", {}, "reason-1"), + ) + state = reduceSessionLivePreview( + state, + frame(1, "reasoning-delta", {delta: "Earlier thought"}, "reason-1"), + ) + state = reduceSessionLivePreview(state, frame(2, "reasoning-end", {}, "reason-1")) + state = reduceSessionLivePreview(state, frame(3, "text-start", {}, "text-1")) + state = reduceSessionLivePreview(state, frame(4, "text-delta", {delta: "Answer"}, "text-1")) + const boundary = state + state = reduceSessionLivePreview(state, frame(5, "reasoning-start", {}, "reason-2")) + state = reduceSessionLivePreview( + state, + frame(6, "reasoning-delta", {delta: "Still thinking"}, "reason-2"), + ) + const durable = [ + { + id: "durable", + role: "assistant" as const, + parts: [ + {type: "reasoning" as const, text: "Earlier thought"}, + {type: "text" as const, text: "Answer"}, + ], + }, + ] + state = retireSessionLivePreview( + state, + { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "text-record", + sequence: 2, + watermark: 2, + type: "message.completed", + payload: {message_id: "text-1"}, + created_at: "2026-09-06T00:00:00Z", + }, + boundary, + durable, + ) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "reasoning", text: "Still thinking"}, + ]) + expect( + [...durable, ...sessionLivePreviewMessages(state)] + .flatMap((message) => message.parts) + .filter((part) => part.type === "reasoning" && part.text === "Earlier thought"), + ).toHaveLength(1) + }) + + it("continues new complete entities after a gap without joining incomplete text", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Prefix"}), + ) + state = reduceSessionLivePreview(state, frame(2, "text-delta", {delta: "missing middle"})) + state = {...state, gapDetected: false} + state = reduceSessionLivePreview(state, frame(5, "text-start", {}, "new-text")) + state = reduceSessionLivePreview( + state, + frame(6, "text-delta", {delta: "New complete message"}, "new-text"), + ) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Prefix"}, + {type: "text", text: "New complete message"}, + ]) + }) + + it("retires a terminal execution without erasing another live execution", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Finished"}), + ) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-delta", {delta: "Next turn"}), + execution_id: "turn-2", + }) + state = retireSessionLivePreview(state, { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-done", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {}, + created_at: "2026-09-06T00:00:00Z", + }) + expect(sessionLivePreviewMessages(state).map((message) => message.parts)).toEqual([ + [{type: "text", text: "Next turn"}], + ]) + const late = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "late"})) + expect(sessionLivePreviewMessages(late)).toEqual(sessionLivePreviewMessages(state)) + }) + + it("retains visible text when a missing frame requires durable catch-up", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-start", {}), + ) + state = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "Visible prefix"})) + state = reduceSessionLivePreview(state, frame(3, "text-delta", {delta: "after gap"})) + expect(state.gapDetected).toBe(true) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Visible prefix"}, + ]) + }) +}) + describe("remote turn presentation", () => { + it("ignores liveness cached before shared completion", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + snapshotRunning: false, + sharedSettledAt: 20, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + }), + ).toEqual({showActivity: false, showRemoteStop: false}) + }) + + it("keeps an accepted continuation active before its first shared event", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: false, + sharedSettledAt: 20, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + ownedContinuation: true, + }), + ).toEqual({showActivity: true, showRemoteStop: false}) + }) + + it("allows a new remote run after liveness is refreshed", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + livenessUpdatedAt: 30, + sharedSettledAt: 20, + snapshotRunning: false, + sharedReaderAdvertised: true, + readerReady: true, + }).showActivity, + ).toBe(true) + }) + + it("uses liveness until any shared completion is known", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + snapshotRunning: false, + sharedSettledAt: 0, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + }).showActivity, + ).toBe(true) + }) + + it("keeps activity across reader connection changes and clears when the execution settles", () => { + for (const readerReady of [false, true, false]) { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: true, + sharedReaderAdvertised: true, + readerReady, + }).showActivity, + ).toBe(true) + } + for (const sharedReaderAdvertised of [false, true]) { + for (const readerReady of [false, true]) { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: false, + sharedReaderAdvertised, + readerReady, + }), + ).toEqual({showActivity: false, showRemoteStop: false}) + } + } + }) + + it("shows activity for accepted sender ownership before snapshot liveness catches up", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }), + ).toEqual({showActivity: true, showRemoteStop: false}) + }) + it.each([ { name: "uses turn activity once the advertised reader is ready", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "uses the fallback strip before the reader is ready", + name: "keeps activity while the reader connects and offers remote Stop", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "uses the fallback strip when the feature is off", + name: "keeps activity for a legacy observer and offers remote Stop", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "never gives an owned continuation the fallback strip", + name: "shows activity for an owned continuation without remote Stop", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveRemoteTurnPresentation(input)).toEqual(expected) @@ -296,16 +614,16 @@ describe("remote turn presentation", () => { } expect(deriveRemoteTurnPresentation({...base, livenessRunning: true})).toEqual({ - showActivity: false, - showStrip: true, + showActivity: true, + showRemoteStop: true, }) expect(deriveRemoteTurnPresentation({...base, livenessRunning: false})).toEqual({ showActivity: false, - showStrip: false, + showRemoteStop: false, }) }) - it("uses activity instead of the banner when the shared reader is ready", () => { + it("uses snapshot activity when the shared reader is ready", () => { expect( deriveRemoteTurnPresentation({ livenessRunning: false, @@ -313,6 +631,6 @@ describe("remote turn presentation", () => { sharedReaderAdvertised: true, readerReady: true, }), - ).toEqual({showActivity: true, showStrip: false}) + ).toEqual({showActivity: true, showRemoteStop: false}) }) }) diff --git a/web/packages/agenta-entities/src/session/state/livePreview.ts b/web/packages/agenta-entities/src/session/state/livePreview.ts index b8360c34efd..25fe92aec62 100644 --- a/web/packages/agenta-entities/src/session/state/livePreview.ts +++ b/web/packages/agenta-entities/src/session/state/livePreview.ts @@ -3,12 +3,16 @@ import {atomFamily} from "jotai-family" export interface SessionLivePreviewEntityState { part: Record & {type: string} + complete?: boolean } export interface SessionLivePreviewExecution { entityOrder: string[] byEntity: Record lastFrameIndex: number + retiredEntityIds?: string[] + incompleteEntityIds?: string[] + terminalCreatedAt?: string } /** From fab59e4d95e16e0635257c91bf1ae39c1f9a16c7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 01:18:20 +0200 Subject: [PATCH 102/133] style(tests): format session preview regression cases --- .../agenta-chat/tests/unit/model/livePreview.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts index c9e18d7643b..8443bf0a787 100644 --- a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -314,10 +314,16 @@ describe("durable preview handoff", () => { it("renders a resumed execution whose paused turn had no preview frames", () => { let state = markSessionLivePreviewTerminal(createSessionLivePreviewState(), { - execution_id: "turn-1", created_at: "2026-09-06T00:00:00Z", + execution_id: "turn-1", + created_at: "2026-09-06T00:00:00Z", }) - state = reduceSessionLivePreview(state, {...frame(0, "text-delta", {delta: "Resumed"}), created_at: "2026-09-06T00:00:01Z"}) - expect(sessionLivePreviewMessages(state)[0].parts).toEqual([{type: "text", text: "Resumed"}]) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-delta", {delta: "Resumed"}), + created_at: "2026-09-06T00:00:01Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Resumed"}, + ]) }) it("preserves a same-execution resumed prompt when paused adoption finishes late", () => { From a6f279541fee14d8c0f5e21a4055fac3b85692e7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 01:32:12 +0200 Subject: [PATCH 103/133] fix(chat): reset remote stop state for each turn --- web/mobile/src/features/chat/LiveConversation.tsx | 6 +++++- .../tests/unit/hooks/useServerSessionInputs.test.ts | 3 --- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 53f5f7bcc62..2eb5cba5646 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -661,7 +661,11 @@ export const LiveConversation = ({ }) && !streamingHere ? (
- +
) : null} diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 5720a572830..2a48d567c1b 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -10,7 +10,6 @@ import {DEFAULT_ATTACHMENT_LIMITS} from "../../../src/assets/attachmentRules" import {isComposerRunStoppable} from "../../../src/assets/composerRunState" import {ChatComposer} from "../../../src/components/ChatComposer" import QueuedMessagesDock from "../../../src/components/QueuedMessagesDock" -import {RunningElsewhereStrip} from "../../../src/components/RunningElsewhereStrip" import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" @@ -180,7 +179,6 @@ const RunningElsewhereAdmissionHarness = ({ "Start fresh run", ), freshAdmissionReleased ? createElement("span", null, "Fresh admission released") : null, - createElement(RunningElsewhereStrip), createElement(QueuedMessagesDock, { queued: queue.queued, onRemove: vi.fn(), @@ -265,7 +263,6 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea const inputRef = createRef() render(createElement(RunningElsewhereAdmissionHarness, {inputRef})) - await screen.findByText(/This turn is still running/) await screen.findByLabelText("Chat message") await screen.findByRole("button", {name: "Start fresh run"}) fireEvent.click(screen.getByRole("button", {name: "Start fresh run"})) From 20cd767af42a314b993f5d95483bc8e1f627ff59 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 01:43:54 +0200 Subject: [PATCH 104/133] fix(runner): finish teardown before promoting steered input --- services/runner/src/server.ts | 4 +- .../runner/src/sessions/applied-commands.ts | 2 + .../runner/src/sessions/control-channel.ts | 18 ++++- .../runner/src/sessions/execution-registry.ts | 30 +++++++- .../tests/unit/control-command-apply.test.ts | 74 +++++++++++++++++++ .../unit/sandbox-agent-orchestration.test.ts | 8 +- 6 files changed, 127 insertions(+), 9 deletions(-) diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 0de9a38bbd7..c71ffbf3e84 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -943,6 +943,7 @@ async function runAndStreamWithApiBaseResolved( } let result: AgentRunResult; + let teardownCompleted = true; try { // Not a bare `await run(...)`: an await inside the run that never settles would keep this // function parked forever, and with it the terminal record below AND the alive watchdog's @@ -985,6 +986,7 @@ async function runAndStreamWithApiBaseResolved( // The run is still pending and may never settle. Give the turn the ending the runner // owes it, and let the abandoned run keep its own teardown if it ever unwinds. turnClosed = true; + teardownCompleted = false; const message = `${ABANDONED_TURN_MARKER}: ${outcome.reason}`; process.stderr.write( `[sessions] ABANDONED session=${sessionId ?? "-"} turn=${turnId ?? "-"}: ${outcome.reason}\n`, @@ -1031,7 +1033,7 @@ async function runAndStreamWithApiBaseResolved( // Same `finally` as the watchdog release, so a run that threw still leaves the registry // clean. Scoped to this turn id, so a turn that finishes after its successor registered // cannot unregister the successor. - if (sessionOwned) unregisterExecution(sessionId, turnId); + if (sessionOwned) unregisterExecution(sessionId, turnId, teardownCompleted); } // Streaming delivered the events live, so don't echo them in the terminal record. diff --git a/services/runner/src/sessions/applied-commands.ts b/services/runner/src/sessions/applied-commands.ts index 71e1206b149..6088863b857 100644 --- a/services/runner/src/sessions/applied-commands.ts +++ b/services/runner/src/sessions/applied-commands.ts @@ -23,6 +23,8 @@ export interface AppliedCommand { executionId: string | null; result: "applied" | "obsolete"; appliedAt: number; + /** Duplicate deliveries must respect the original execution's teardown boundary. */ + settled?: Promise; } /** diff --git a/services/runner/src/sessions/control-channel.ts b/services/runner/src/sessions/control-channel.ts index 752f7ab32ea..d780a6265db 100644 --- a/services/runner/src/sessions/control-channel.ts +++ b/services/runner/src/sessions/control-channel.ts @@ -116,6 +116,7 @@ export async function applyCommand( if (seen) { // A no-op that STILL acknowledges. Aborting a second time could kill a newer turn; not // acknowledging would leave the command open until the settlement sweep gave up on it. + await seen.settled; const outcome: ControlOutcome = { result: seen.result, execution: { @@ -139,12 +140,17 @@ export async function applyCommand( // Remember BEFORE aborting. A duplicate that arrives while the first abort is still settling // must find the command already taken, not start a second one. + let settleCommand!: () => void; + const settled = new Promise((resolve) => { + settleCommand = resolve; + }); rememberCommand( { commandId: command.id, executionId: outcome.execution.id, executionState: outcome.execution.state, result: outcome.result, + settled, }, now(), ); @@ -156,6 +162,11 @@ export async function applyCommand( // ACP `session/cancel` to the harness and lets the environment be PARKED rather than // deleted (see `cancel-turn.ts` and `shouldPark`). Stop keeps the session warm. live.abort(); + if ((await live.released) === false) { + throw new Error( + "Stopped execution did not finish releasing its environment.", + ); + } log( `aborted command=${command.id} session=${command.sessionId} turn=${live.turnId}`, ); @@ -187,10 +198,9 @@ export async function applyCommand( } } - // Reported as soon as the abort is issued, not after the harness settles. The command's job - // is to deliver the Stop; the turn's own teardown then writes its transcript and parks the - // sandbox on its own clock, which can take seconds. Waiting for it would make a Stop that - // worked look stuck. + settleCommand(); + // The transport already acknowledged Stop. A stopped outcome may promote Steer, so it + // must follow teardown rather than merely issuing the abort. await report(command, outcome).catch((error) => { log( `outcome report failed command=${command.id}: ${ diff --git a/services/runner/src/sessions/execution-registry.ts b/services/runner/src/sessions/execution-registry.ts index 0d83a2f3820..34059ea2bd2 100644 --- a/services/runner/src/sessions/execution-registry.ts +++ b/services/runner/src/sessions/execution-registry.ts @@ -53,11 +53,19 @@ export interface LiveExecution { * environment that was about to be parked. So the applier reads this flag and does nothing. */ settled?: boolean; + /** Resolves after teardown and the final ownership release, not merely prompt settlement. */ + released?: Promise; /** Stop the run. Aborting is what makes the turn end `cancelled`. */ abort: () => void; } const executions = new Map(); +const releases = new Map< + string, + { promise: Promise; resolve: (safeToContinue: boolean) => void } +>(); +const releaseKey = (sessionId: string, turnId: string) => + JSON.stringify([sessionId, turnId]); /** * Register a run as live. A second registration for the same session REPLACES the first, @@ -65,6 +73,17 @@ const executions = new Map(); * time a replacement turn starts. */ export function registerExecution(execution: LiveExecution): void { + const key = releaseKey(execution.sessionId, execution.turnId); + let completion = releases.get(key); + if (!completion) { + let resolve!: (safeToContinue: boolean) => void; + const promise = new Promise((done) => { + resolve = done; + }); + completion = { promise, resolve }; + releases.set(key, completion); + } + execution.released = completion.promise; executions.set(execution.sessionId, execution); } @@ -98,7 +117,14 @@ export function noteExecutionSettled(sessionId: string, turnId: string): void { * Remove a run, but only if it is still the one registered. A turn that finishes after its * successor registered must not unregister the successor. */ -export function unregisterExecution(sessionId: string, turnId: string): void { +export function unregisterExecution( + sessionId: string, + turnId: string, + safeToContinue = true, +): void { + const key = releaseKey(sessionId, turnId); + releases.get(key)?.resolve(safeToContinue); + releases.delete(key); const current = executions.get(sessionId); if (current && current.turnId === turnId) executions.delete(sessionId); } @@ -129,5 +155,7 @@ export function liveExecutions(): LiveExecution[] { /** Test seam: drop everything. Never called by the server. */ export function resetExecutionsForTest(): void { + for (const completion of releases.values()) completion.resolve(false); + releases.clear(); executions.clear(); } diff --git a/services/runner/tests/unit/control-command-apply.test.ts b/services/runner/tests/unit/control-command-apply.test.ts index 825d0bc3bfa..2ff7be2c015 100644 --- a/services/runner/tests/unit/control-command-apply.test.ts +++ b/services/runner/tests/unit/control-command-apply.test.ts @@ -643,6 +643,80 @@ describe("applyCommand", () => { }); }); +describe("Stop teardown before outcome", () => { + it("aborts once immediately but holds original and duplicate outcomes until release", async () => { + const { execution, aborts } = liveRun(); + registerExecution(execution); + const { reported, report } = collector(); + const first = applyCommand(command(), { report }); + const duplicate = applyCommand(command(), { report }); + await Promise.resolve(); + assert.equal(aborts.length, 1); + assert.equal( + reported.length, + 0, + "Steer cannot promote into the still-busy environment", + ); + noteExecutionSettled(SESSION, TURN); + await Promise.resolve(); + assert.equal(reported.length, 0, "prompt settlement precedes teardown"); + unregisterExecution(SESSION, TURN); + await Promise.all([first, duplicate]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "stopped"), + ); + }); + + it("reports failure for an abandoned turn, including an already-waiting duplicate", async () => { + registerExecution(liveRun().execution); + const { reported, report } = collector(); + const first = applyCommand(command(), { report }); + const duplicate = applyCommand(command(), { report }); + unregisterExecution(SESSION, TURN, false); + await Promise.all([first, duplicate]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "failed"), + ); + }); + + it("does not strand a duplicate when abort throws before teardown", async () => { + registerExecution( + liveRun({ + abort: () => { + throw new Error("abort failed"); + }, + }).execution, + ); + const { reported, report } = collector(); + await Promise.all([ + applyCommand(command(), { report }), + applyCommand(command(), { report }), + ]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "failed"), + ); + unregisterExecution(SESSION, TURN); + }); + + it("does not let unregistering an older execution release its successor", async () => { + const older = liveRun({ turnId: "older" }).execution; + const current = liveRun().execution; + registerExecution(older); + registerExecution(current); + const { reported, report } = collector(); + const pending = applyCommand(command(), { report }); + unregisterExecution(SESSION, "older"); + await Promise.resolve(); + assert.equal(reported.length, 0); + unregisterExecution(SESSION, TURN); + await pending; + assert.equal(reported.length, 1); + }); +}); + describe("the execution registry", () => { it("refuses a lookup from another project once the scope is known", () => { const { execution } = liveRun(); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 75245aceb74..a1c9b2e71d3 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -57,6 +57,7 @@ import { } from "../utils/sandbox-agent-harness.ts"; import { findExecution, + unregisterExecution, registerExecution, resetExecutionsForTest, } from "../../src/sessions/execution-registry.ts"; @@ -2698,7 +2699,7 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { ); await pauseTeardownStarted; - const outcome = await applyCommand( + const stopped = applyCommand( { id: "command-stop-during-pause-teardown", projectId, @@ -2709,8 +2710,7 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { }, { report: async () => {} }, ); - assert.equal(outcome.execution.state, "stopped"); - + assert.equal(controller.signal.aborted, true); releasePauseTeardown(); const result = await turn; @@ -2719,6 +2719,8 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { assert.equal(result.stopReason, "cancelled"); assert.equal(result.cancelSettled, true); assert.equal(findExecution(projectId, sessionId)?.settled, true); + unregisterExecution(sessionId, turnId); + assert.equal((await stopped).execution.state, "stopped"); }); it("effective ask with no decision pauses the tool, no harness reply (F-024)", async () => { From 186b96d6582844391436fe3a6391bd96c678fbdb Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 02:12:09 +0200 Subject: [PATCH 105/133] fix(sdk): retain builder configuration for approval resumes --- .../sdk/agents/utils/effective_config.py | 8 ++--- .../pytest/unit/agents/test_wire_contract.py | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/sdks/python/agenta/sdk/agents/utils/effective_config.py b/sdks/python/agenta/sdk/agents/utils/effective_config.py index 3911bd4069f..57ec9590ea1 100644 --- a/sdks/python/agenta/sdk/agents/utils/effective_config.py +++ b/sdks/python/agenta/sdk/agents/utils/effective_config.py @@ -19,8 +19,8 @@ replayed run re-resolves the same credentials from the project vault. The cost is deliberate: an author who inlines a static header into an MCP connection loses that header on a replayed resume rather than having it persisted in a second place. -- **Size cap.** Measured over the dev corpus (n=326 revisions with parameters): avg 761 B, - p90 1.4 KB, max 20 KB — the large ones are entirely tool JSON-Schema. Anything over +- **Size cap.** The playground builder config includes its tool catalog and measured + 147 KB in live QA, larger than saved agent revisions. Anything over :data:`MAX_STAMPED_BYTES` is dropped WHOLE with a warning (a truncated blob would be invalid JSON, and a silently truncated config is worse than none); the resume then degrades to today's references-only hydration. @@ -35,8 +35,8 @@ log = get_module_logger(__name__) -# 3x the largest config measured in the dev corpus. Over this the blob is not stamped at all. -MAX_STAMPED_BYTES = 64 * 1024 +# Keep normal builder turns (147 KB observed) resumable while bounding durable config size. +MAX_STAMPED_BYTES = 256 * 1024 # Keys that can hold a raw credential VALUE on a tool/MCP entry or its connection descriptor. # `credentials` is deliberately NOT here: it holds vault key names, which the replay needs. diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index c421d1601e6..cf68c820641 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -775,6 +775,36 @@ def test_effective_parameters_preserve_tool_input_schema_properties(): assert payload["effectiveParameters"]["agent"]["tools"][0] == tool +def test_effective_parameters_preserve_builder_sized_configuration(): + # The normal playground builder measured 147,209 bytes with 18 tools. Dropping + # that config silently resumes against the saved agent, which has no builder tools. + parameters = { + "agent": { + "instructions": {"agents_md": "Build and update the current agent."}, + "tools": [ + { + "name": "commit_revision" + if index == 0 + else f"builder_tool_{index}", + "description": "Builder operation schema documentation. " * 210, + "inputSchema": {"type": "object", "properties": {}}, + } + for index in range(18) + ], + } + } + assert 147_209 <= len(json.dumps(parameters).encode("utf-8")) <= 160_000 + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="Configure this agent")], + session_id="sess-builder", + effective_parameters=parameters, + ) + assert payload["effectiveParameters"] == parameters + + def test_effective_parameters_over_the_cap_are_dropped_whole(): # A truncated config is invalid JSON and a silently-truncated one is worse than none, so an # oversize blob is not stamped at all (the resume degrades to reference hydration). From e3cecf05ec1d4f5bb9b0770803408bc821c79608 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 02:34:41 +0200 Subject: [PATCH 106/133] fix(api): persist final heartbeat state after execution settlement --- .../src/dbs/postgres/sessions/streams/dao.py | 7 +- .../test_watchdog_collapse_persistence.py | 75 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/api/oss/src/dbs/postgres/sessions/streams/dao.py b/api/oss/src/dbs/postgres/sessions/streams/dao.py index 399db927ad3..1a8983a6a59 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dao.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dao.py @@ -538,6 +538,7 @@ async def update( SessionExecutionDBE.project_id == project_id, SessionExecutionDBE.session_id == session_id, SessionExecutionDBE.execution_id == stream.expected_turn_id, + SessionExecutionDBE.terminal_outcome.is_not(None), ) .exists() ) @@ -561,7 +562,11 @@ async def update( SessionStreamDBE.flags.contains( {"is_alive": True, "is_running": True} ), - ~terminal_execution_exists, + # Final idle beats must persist after settlement; active beats + # must not revive an execution that has already ended. + (~terminal_execution_exists) + if stream.flags is None or stream.flags.is_running + else True, ) .values(**values) .returning(SessionStreamDBE) diff --git a/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py index ea48e847019..22659e6cbdd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py +++ b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py @@ -586,3 +586,78 @@ async def heartbeat_is_blocked_on_the_sweep(): "is_running": False, "is_attached": False, } + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "settled,is_running,collapsed,replaced,accepted", + [ + (False, True, False, False, True), + (True, False, False, False, True), + (True, True, False, False, False), + (True, False, True, False, False), + (True, False, False, True, False), + ], +) +async def test_heartbeat_mirror_distinguishes_active_and_settled_execution( + anyio_backend, wd_engine, settled, is_running, collapsed, replaced, accepted +): + session_id = "wd-" + uuid.uuid4().hex[:12] + turn_id = str(uuid.uuid4()) + project_id = await _seed_scenario(wd_engine, session_id=session_id, turn_id=turn_id) + async with wd_engine.session() as transaction: + # Use the actual M3 admission writer: active execution rows exist before completion. + await SessionExecutionsDAO(wd_engine).create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=turn_id, + parent_execution_id="previous-turn", + source_interaction_id=None, + transaction=transaction, + ) + if settled: + await transaction.execute( + text( + "UPDATE session_executions SET state='terminal', " + "terminal_outcome='stopped', settled_by='runner', settled_at=NOW() " + "WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + if collapsed: + await transaction.execute( + text( + "UPDATE session_streams SET flags=" + '\'{"is_alive": false,"is_running": false,"is_attached": false}\'::jsonb ' + "WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + if replaced: + await transaction.execute( + text( + "UPDATE session_streams SET turn_id='new-turn' WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + await transaction.commit() + + mirrored = await SessionStreamsDAO(wd_engine).update( + project_id=project_id, + user_id=None, + session_id=session_id, + stream=SessionStreamEdit( + flags=SessionStreamFlags( + is_alive=True, is_running=is_running, is_attached=False + ), + turn_id=turn_id, + expected_turn_id=turn_id, + ), + ) + assert (mirrored is not None) is accepted + persisted = await SessionStreamsDAO(wd_engine).get_by_session_id( + project_id=project_id, session_id=session_id + ) + assert persisted.flags.is_running is (is_running if accepted else not collapsed) + assert persisted.flags.is_alive is (not collapsed) + assert persisted.turn_id == ("new-turn" if replaced else turn_id) From d1c4490d798658404eb2064cdb6120508a1024a5 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 02:47:09 +0200 Subject: [PATCH 107/133] fix(tests): avoid stale app responses during navigation --- .../tests/playwright/unit/api-helpers.spec.ts | 50 ++++++++++++++++++- .../fixtures/base.fixture/apiHelpers/index.ts | 13 ++--- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/web/oss/tests/playwright/unit/api-helpers.spec.ts b/web/oss/tests/playwright/unit/api-helpers.spec.ts index 769000a9322..6ff0925cebd 100644 --- a/web/oss/tests/playwright/unit/api-helpers.spec.ts +++ b/web/oss/tests/playwright/unit/api-helpers.spec.ts @@ -1,12 +1,13 @@ import { appMatchesType, + getApp, selectLatestAppRevisions, } from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" import type { APP_TYPE, ListAppsItem, } from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers/types" -import {expect, test} from "@playwright/test" +import {expect, test, type Page} from "@playwright/test" const app = (flags: ListAppsItem["flags"]): ListAppsItem => ({ @@ -85,3 +86,50 @@ test("latest revision selection keeps records with a missing version", () => { expect(latest?.version).toBeUndefined() expect(latest?.flags?.is_agent).toBe(true) }) + +test("app lookup ignores an old document response during navigation", async () => { + const artifact = app({is_application: true}) + let navigationFinished = false + const requestedUrls: string[] = [] + const fakePage = { + url: () => "https://example.test/w/workspace/p/project/settings?tab=llms", + goto: async () => { + navigationFinished = true + }, + waitForURL: async () => {}, + // A settings-page query can finish while goto replaces its document. Its + // status is valid, but Chromium no longer owns the response body. + waitForResponse: async () => ({ + ok: () => true, + text: async () => { + throw new Error("Network.getResponseBody: No resource with given identifier found") + }, + }), + request: { + post: async (url: string) => { + expect(navigationFinished).toBe(true) + requestedUrls.push(url) + return { + ok: () => true, + json: async () => + url.includes("/revisions/query") + ? { + workflow_revisions: [ + { + workflow_id: artifact.id, + version: "1", + flags: {is_chat: true}, + }, + ], + } + : {workflows: [artifact], count: 1}, + } + }, + }, + } as unknown as Page + + expect(await getApp(fakePage, "chat")).toEqual(artifact) + expect(requestedUrls).toHaveLength(2) + expect(new URL(requestedUrls[0]).pathname).toMatch(/\/workflows\/query$/) + expect(new URL(requestedUrls[0]).searchParams.get("project_id")).toBe("project") +}) diff --git a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts index ba0119801ed..429df735112 100644 --- a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts +++ b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts @@ -460,16 +460,17 @@ export const appMatchesType = ( } export const getApp = async (page: Page, type: APP_TYPE = "completion") => { - const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, { - route: "/workflows/query", - method: "POST", - }) - const projectBasePath = getProjectScopedBasePath(page) await page.goto(`${projectBasePath}/prompts`, {waitUntil: "domcontentloaded"}) await page.waitForURL("**/prompts", {waitUntil: "domcontentloaded"}) - const data = await appsResponse + // A background query from the previous document can finish during navigation. + // Read fixture data through the request context, whose response survives that navigation. + const queryUrl = new URL(`${getApiURL(page)}/workflows/query`) + queryUrl.searchParams.set("project_id", getProjectId(page)) + const response = await page.request.post(queryUrl.toString(), {data: {}}) + expect(response.ok()).toBe(true) + const data = (await response.json()) as {workflows: ListAppsItem[]; count: number} const apps = data.workflows ?? [] expect(Array.isArray(apps)).toBe(true) From bda6a6edad574efe76dfb8d470e9279f6b30f58e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 03:08:06 +0200 Subject: [PATCH 108/133] fix(frontend): refresh configuration after live agent commits --- .../AgentChatSlice/AgentConversation.tsx | 2 + .../hooks/useAgentChatSession.test.ts | 56 ++++++++- .../hooks/useAgentChatSession.ts | 43 +++---- .../src/assets/committedRevisions.ts | 65 ++++++++++ web/packages/agenta-chat/src/assets/index.ts | 2 + .../src/hooks/useSessionLivePreview.ts | 19 ++- .../unit/assets/committedRevisions.test.ts | 75 +++++++++++ .../unit/hooks/useSessionLivePreview.test.tsx | 119 ++++++++++++++++++ 8 files changed, 357 insertions(+), 24 deletions(-) create mode 100644 web/packages/agenta-chat/src/assets/committedRevisions.ts create mode 100644 web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 23d9d3624c2..9ef341ecc49 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -166,6 +166,7 @@ const AgentConversation = ({ runningElsewhere: livenessRunningElsewhere, sharedReaderAdvertised, refreshFromRecords, + onCommittedRevision, revalidate, setSharedSenderReady, } = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent}) @@ -182,6 +183,7 @@ const AgentConversation = ({ onReadyChange: setSharedSenderReady, onExecutionSettled: settleSharedTurn, onDisconnect: refreshFromRecords, + onCommittedRevision, }) const livenessUpdatedAt = useAtomValue(sessionLivenessUpdatedAtAtom) const refreshLiveness = useSetAtom(refreshSessionLivenessAtom) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index 7bc32305800..273c9ae557e 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -40,6 +40,9 @@ const state = vi.hoisted(() => ({ hydrationBusyRef: undefined as {current: boolean} | undefined, busy: false, stop: vi.fn(), + switchEntity: vi.fn(), + setCommitSignal: vi.fn(), + invalidateCommit: vi.fn(), })) vi.mock("@agenta/chat/assets", () => ({ @@ -122,7 +125,7 @@ vi.mock("@agenta/entities/session", () => ({ vi.mock("@agenta/entities/trace", () => ({markTraceAsFresh: vi.fn()})) vi.mock("@agenta/entities/workflow", () => ({ - invalidateAgentCommittedRevisionCache: vi.fn(), + invalidateAgentCommittedRevisionCache: state.invalidateCommit, workflowMolecule: { selectors: {configuration: () => "workflow-configuration"}, }, @@ -168,7 +171,12 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("jotai", () => ({ useAtomValue: () => state.projectId, - useSetAtom: () => vi.fn(), + useSetAtom: (atom: string) => + atom === "switch-entity" + ? state.switchEntity + : atom === "commit-signal" + ? state.setCommitSignal + : vi.fn(), useStore: () => ({ get: (atom: string) => { if (atom === "record-counts" || atom === "session-messages") return {} @@ -224,6 +232,10 @@ describe("useAgentChatSession execution guard", () => { state.cancelSessionExecution.mockReset() state.resolveStopExecution.mockReset() state.stop.mockReset() + state.switchEntity.mockClear() + state.setCommitSignal.mockClear() + state.invalidateCommit.mockClear() + state.messages = [] state.resolveStopExecution.mockImplementation(async ({readExecutionId}) => { const executionId = readExecutionId() return executionId ? {status: "resolved", executionId} : {status: "settled"} @@ -237,6 +249,46 @@ describe("useAgentChatSession execution guard", () => { state.busy = false }) + it("deduplicates live-reader and native commit notifications through the same config switch", () => { + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + const revision = {revisionId: "revision-2", version: "2"} + act(() => { + result!.onCommittedRevision(revision) + result!.onCommittedRevision(revision) + }) + state.messages = [ + { + id: "commit", + role: "assistant", + parts: [{type: "data-committed-revision", data: revision}], + } as UIMessage, + ] + act(() => root.render(createElement(Probe))) + expect(state.invalidateCommit).toHaveBeenCalledOnce() + expect(state.switchEntity).toHaveBeenCalledExactlyOnceWith({ + currentEntityId: "revision-1", + newEntityId: "revision-2", + }) + expect(state.setCommitSignal).toHaveBeenCalledExactlyOnceWith({ + revisionId: "revision-2", + version: "2", + prevParameters: null, + at: expect.any(Number), + }) + act(() => root.unmount()) + }) + it("allows durable hydration for an accepted shared sender while protecting local streaming", async () => { state.busy = true let result: ReturnType | undefined diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index f301813d8af..4af6f35cf08 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -659,33 +659,33 @@ export const useAgentChatSession = ({ // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. const committedRevisionsSeenRef = useRef>(new Set()) const setAgentCommitSignal = useSetAtom(agentSelfCommitSignalAtom) + const onCommittedRevision = useCallback( + (data?: {revisionId?: string; version?: string}) => { + const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" + if (committedRevisionsSeenRef.current.has(key)) return + committedRevisionsSeenRef.current.add(key) + invalidateAgentCommittedRevisionCache() + if (data?.revisionId && data.revisionId !== entityId) { + const prevParameters = store.get(workflowMolecule.selectors.configuration(entityId)) + setAgentCommitSignal({ + revisionId: data.revisionId, + version: data.version, + prevParameters: prevParameters ?? null, + at: Date.now(), + }) + switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) + } + }, + [entityId, switchEntity, store, setAgentCommitSignal], + ) useEffect(() => { for (const message of messages) { for (const part of message.parts) { if ((part as {type?: string}).type !== "data-committed-revision") continue - const data = (part as {data?: {revisionId?: string; version?: string}}).data - // A stable key per commit: prefer the revision id, fall back to the whole payload. - const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" - if (committedRevisionsSeenRef.current.has(key)) continue - committedRevisionsSeenRef.current.add(key) - invalidateAgentCommittedRevisionCache() - if (data?.revisionId && data.revisionId !== entityId) { - // Capture the OUTGOING revision's parameters before switching, so the config - // panel can show what the agent changed (per-section indicators + summary). - const prevParameters = store.get( - workflowMolecule.selectors.configuration(entityId), - ) - setAgentCommitSignal({ - revisionId: data.revisionId, - version: data.version, - prevParameters: prevParameters ?? null, - at: Date.now(), - }) - switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) - } + onCommittedRevision((part as {data?: {revisionId?: string; version?: string}}).data) } } - }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) + }, [messages, onCommittedRevision]) const projectId = useAtomValue(projectIdAtom) const expectedStopExecutionIdRef = useRef(undefined) @@ -912,6 +912,7 @@ export const useAgentChatSession = ({ runningElsewhere, sharedReaderAdvertised, refreshFromRecords, + onCommittedRevision, setSharedSenderReady, revalidate, stopped, diff --git a/web/packages/agenta-chat/src/assets/committedRevisions.ts b/web/packages/agenta-chat/src/assets/committedRevisions.ts new file mode 100644 index 00000000000..8ff4fc7edc6 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/committedRevisions.ts @@ -0,0 +1,65 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {canonicalClientToolName} from "@agenta/shared/clientTools" + +export interface CommittedRevision { + variantId: string + revisionId: string + version: string +} + +const committedRevisionData = (output: unknown): CommittedRevision | null => { + let value = output + if (typeof value === "string") { + try { + value = JSON.parse(value) + } catch { + return null + } + } + if (!value || typeof value !== "object") return null + const payload = value as Record + if (payload.status !== "committed" && !payload.count) return null + if (!payload.workflow_revision || typeof payload.workflow_revision !== "object") return null + const revision = payload.workflow_revision as Record + const variantId = revision.workflow_variant_id ?? revision.variant_id + const revisionId = revision.id ?? revision.workflow_revision_id ?? revision.revision_id + const version = revision.version + if ( + typeof variantId !== "string" || + !variantId || + typeof revisionId !== "string" || + !revisionId || + (typeof version !== "string" && typeof version !== "number") || + !String(version) + ) + return null + return {variantId, revisionId, version: String(version)} +} + +/** Notifications learned during this mounted reader, never historical side effects. */ +export const liveCommittedRevisions = ( + records: SessionRecord[], + afterSequence?: number, +): CommittedRevision[] => { + if (afterSequence === undefined) return [] + const names = new Map() + const revisions = new Map() + for (const row of records) { + const payload = row.payload + if (!payload || typeof payload.id !== "string") continue + if (payload.type === "tool_call" && typeof payload.name === "string") + names.set(payload.id, canonicalClientToolName(payload.name)) + if ( + payload.type !== "tool_result" || + payload.isError || + payload.denied || + typeof row.sequence !== "number" || + row.sequence <= afterSequence || + names.get(payload.id) !== "commit_revision" + ) + continue + const revision = committedRevisionData(payload.data ?? payload.output) + if (revision) revisions.set(revision.revisionId, revision) + } + return [...revisions.values()] +} diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 834854a86fa..cbfdca91cff 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -16,3 +16,5 @@ export * from "./composerRunState" export {startupLabelFromDataPart} from "./startupPhases" export {getMessageTurnId, latestTurnId} from "./agentTurn" export * from "./resolveStopExecution" + +export {liveCommittedRevisions, type CommittedRevision} from "./committedRevisions" diff --git a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts index e33c718a3b8..40cd8d3b97e 100644 --- a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts +++ b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts @@ -12,6 +12,7 @@ import {projectIdAtom} from "@agenta/shared/state" import type {UIMessage} from "ai" import {useAtom, useAtomValue, useSetAtom} from "jotai" +import {liveCommittedRevisions, type CommittedRevision} from "../assets/committedRevisions" import type {SessionTranscript} from "../assets/loadSession" import {transcriptToMessages} from "../assets/transcriptToMessages" import { @@ -43,6 +44,7 @@ export const useSessionLivePreview = ({ runningElsewhere, sender, onReadyChange, + onCommittedRevision, onExecutionSettled, onDisconnect, }: { @@ -53,6 +55,8 @@ export const useSessionLivePreview = ({ runningElsewhere: boolean /** Subscribe before this browser sends its next turn. */ sender?: boolean + /** Reports commits learned after initial hydration, once their transcript is adopted. */ + onCommittedRevision?: (revision: CommittedRevision) => void /** Non-reactive request-pipeline signal: true only while the shared event route is ready. */ onReadyChange?: (ready: boolean) => void /** Reports the shared path's durable terminal verdict for the current execution. */ @@ -73,6 +77,8 @@ export const useSessionLivePreview = ({ const [runningFromSnapshot, setRunningFromSnapshot] = useState(false) const [readerReady, setReaderReady] = useState(false) const [sharedSettledAt, setSharedSettledAt] = useState(0) + const onCommittedRevisionRef = useRef(onCommittedRevision) + onCommittedRevisionRef.current = onCommittedRevision const onDisconnectRef = useRef(onDisconnect) onDisconnectRef.current = onDisconnect const retryHydrationRef = useRef<() => void>(() => undefined) @@ -106,6 +112,7 @@ export const useSessionLivePreview = ({ let reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS let generation = 0 let durable = createSessionDurableEventState() + let liveBaselineSequence: number | undefined const close = () => { connection?.close() @@ -136,7 +143,11 @@ export const useSessionLivePreview = ({ const readBoundedTranscript = async ( throughSequence: number, - ): Promise<{transcript: SessionTranscript; coveredEntityIds: Set} | null> => { + ): Promise<{ + transcript: SessionTranscript + coveredEntityIds: Set + committedRevisions: CommittedRevision[] + } | null> => { if (!projectId) return null const [records, interactionRowStates] = await Promise.all([ querySessionTranscript({sessionId, projectId, throughSequence}), @@ -164,6 +175,7 @@ export const useSessionLivePreview = ({ interactionRows: interactionRowStates, }, coveredEntityIds, + committedRevisions: liveCommittedRevisions(records, liveBaselineSequence), } } @@ -207,6 +219,9 @@ export const useSessionLivePreview = ({ scheduleReconnect() return } + for (const revision of bounded.committedRevisions) + onCommittedRevisionRef.current?.(revision) + liveBaselineSequence ??= snapshot.read.latest_sequence if (!snapshotRunning) clearPreview(sessionId) else setPreview((current) => ({ @@ -278,6 +293,8 @@ export const useSessionLivePreview = ({ const adopted = transcript ? await adoptTranscript(transcript) : false if (disposed || currentGeneration !== generation) return if (adopted) { + for (const revision of bounded?.committedRevisions ?? []) + onCommittedRevisionRef.current?.(revision) setPreview((current) => retireSessionLivePreview( bounded && previewBoundary diff --git a/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts b/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts new file mode 100644 index 00000000000..849240e608d --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts @@ -0,0 +1,75 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {describe, expect, it} from "vitest" +import {liveCommittedRevisions} from "../../../src/assets/committedRevisions" + +const output = { + status: "committed", + workflow_revision: { + id: "01a0740f-777a-79e3-90cf-da5cf00adba7", + workflow_variant_id: "01a07403-a0b6-7b03-ab5a-a8380ddc80ea", + version: "2", + }, +} +const row = (sequence: number, payload: Record): SessionRecord => ({ + id: `row-${sequence}`, + session_id: "session-1", + project_id: "project-1", + sequence, + event_index: sequence, + sender: "agent", + session_update: String(payload.type), + payload, + created_at: null, +}) +const records = ( + name = "commit_revision", + result: Record = {output: JSON.stringify(output)}, +) => [ + row(24, {type: "tool_call", id: "call-1", name, input: {}}), + row(25, {type: "tool_result", id: "call-1", ...result}), +] + +describe("liveCommittedRevisions", () => { + it.each([ + "commit_revision", + "mcp__agenta-tools__commit_revision", + "mcp.agenta-tools.commit_revision", + ])("projects the captured successful output for %s", (name) => { + expect(liveCommittedRevisions(records(name), 23)).toEqual([ + { + revisionId: output.workflow_revision.id, + variantId: output.workflow_revision.workflow_variant_id, + version: "2", + }, + ]) + }) + it("keeps initial and reopened history inert", () => { + expect(liveCommittedRevisions(records())).toEqual([]) + expect(liveCommittedRevisions(records(), 25)).toEqual([]) + }) + it("ignores failed, denied, malformed and unrelated tool results", () => { + for (const result of [ + {output, isError: true}, + {output, denied: true}, + {output: "invalid json"}, + {output: {status: "committed"}}, + ]) + expect(liveCommittedRevisions(records("commit_revision", result), 23)).toEqual([]) + expect(liveCommittedRevisions(records("other_tool"), 23)).toEqual([]) + }) + it("supports the legacy successful result and deduplicates a revision", () => { + const legacy = { + count: 1, + workflow_revision: {revision_id: "rev-2", variant_id: "var-1", version: 2}, + } + expect( + liveCommittedRevisions( + [ + ...records("commit_revision", {data: legacy}), + ...records("commit_revision", {data: legacy}), + ], + 23, + ), + ).toEqual([{revisionId: "rev-2", variantId: "var-1", version: "2"}]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx index 3da72088a1c..69b95343d4f 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -66,6 +66,125 @@ describe("useSessionLivePreview", () => { vi.unstubAllGlobals() }) + it.each(["live", "reconnect", "history"])( + "delivers live commit callbacks after confirmed adoption without replaying history (%s)", + async (mode) => { + const output = { + status: "committed", + workflow_revision: { + id: "revision-2", + workflow_variant_id: "variant-1", + version: "2", + }, + } + const rows = [ + { + ...record("call", { + type: "tool_call", + id: "commit-1", + name: "commit_revision", + input: {}, + }), + sequence: 1, + }, + { + ...record("result", { + type: "tool_result", + id: "commit-1", + output: JSON.stringify(output), + }), + sequence: 2, + }, + ] + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + read: {latest_sequence: mode === "history" ? 2 : 0}, + }) + mocks.querySessionTranscript.mockResolvedValue(mode === "history" ? rows : []) + // A watch may already have adopted this watermark: confirmation returns true + // without replacing messages. The notification must still reach the host. + const onDisconnect = vi.fn().mockResolvedValue(true) + const onCommittedRevision = vi.fn() + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: false, + sender: true, + onDisconnect, + onCommittedRevision, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const notificationParts = () => + onCommittedRevision.mock.calls.map(([revision]) => revision) + expect(notificationParts()).toEqual([]) + if (mode === "history") return + + mocks.querySessionTranscript.mockResolvedValue(rows) + const connection = mocks.connectSessionLiveEvents.mock.calls[0][0] + const event = { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "result", + sequence: 2, + watermark: 2, + type: "tool.completed", + payload: {tool_call_id: "commit-1", name: "commit_revision", output}, + created_at: "2026-09-06T00:00:00Z", + } + if (mode === "live") act(() => connection.onEvent(event)) + else { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + read: {latest_sequence: 2}, + }) + act(() => { + connection.onDisconnect({reason: "connection_lost", reconnect: true}) + document.dispatchEvent(new Event("visibilitychange")) + }) + } + await waitFor(() => expect(notificationParts()).toHaveLength(1)) + expect(notificationParts()[0]).toEqual({ + revisionId: "revision-2", + variantId: "variant-1", + version: "2", + }) + const current = mocks.connectSessionLiveEvents.mock.calls.at(-1)![0] + act(() => current.onEvent(event)) + await act(async () => Promise.resolve()) + expect(notificationParts()).toHaveLength(1) + // A later terminal adoption must not lose the live commit; host revision-ID dedup + // handles repeated notifications while transcript messages remain side-effect-free. + act(() => + current.onEvent({ + ...event, + sequence: 3, + watermark: 3, + frame_or_event_id: "done", + type: "execution.stopped", + payload: {}, + }), + ) + await waitFor(() => expect(notificationParts()).toHaveLength(2)) + for (const [transcript] of onDisconnect.mock.calls) { + expect( + transcript.messages + .flatMap((message: {parts: {type: string}[]}) => message.parts) + .some((part: {type: string}) => part.type === "data-committed-revision"), + ).toBe(false) + } + }, + ) + it("keeps the flag-off path snapshot-free", async () => { const store = createStore() store.set(projectIdAtom, "project-1") From e30b0f86d6a3da33e10fede87a57d2ddd0a57549 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 03:11:11 +0200 Subject: [PATCH 109/133] fix(frontend): retire tool previews when durable approvals take over --- .../agenta-chat/src/model/livePreview.ts | 15 +++ .../unit/hooks/useSessionLivePreview.test.tsx | 91 +++++++++++++++++++ .../tests/unit/model/livePreview.test.ts | 55 +++++++++++ 3 files changed, 161 insertions(+) diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts index ea99c794526..cd4831ed964 100644 --- a/web/packages/agenta-chat/src/model/livePreview.ts +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -333,6 +333,18 @@ export const retireCoveredSessionLivePreview = ( message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])), ), ) + const durableToolIds = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => + (part.type === "dynamic-tool" || part.type.startsWith("tool-")) && + "toolCallId" in part && + "state" in part && + part.state !== "input-streaming" + ? [part.toolCallId] + : [], + ), + ), + ) const byExecution = {...state.byExecution} for (const executionId of boundary.executionOrder) { const captured = boundary.byExecution[executionId] @@ -343,6 +355,9 @@ export const retireCoveredSessionLivePreview = ( if (!entity || current.byEntity[id]?.part !== entity.part) return false return ( coveredEntityIds.has(id) || + ((entity.part.state === "input-streaming" || + entity.part.state === "input-available") && + durableToolIds.has(String(entity.part.toolCallId))) || (entity.complete === true && entity.part.type === "reasoning" && durableReasoning.has(String(entity.part.text))) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx index 69b95343d4f..d4c71467170 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -185,6 +185,97 @@ describe("useSessionLivePreview", () => { }, ) + it("retires an old approval preview when recovery adopts a newer running turn", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const onDisconnect = vi.fn().mockResolvedValue(true) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + const tool = { + type: "tool_call", + id: "bash-call", + name: "bash", + input: {command: "sleep 12"}, + } + mocks.querySessionTranscript.mockResolvedValue([record("call", tool)]) + onDisconnect.mockResolvedValueOnce(false) + act(() => { + first.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "old-turn", + frame_or_event_id: "old:0", + frame_index: 0, + entity_id: "bash-call", + type: "tool-input-available", + payload: {toolCallId: "bash-call", toolName: "bash", input: tool.input}, + created_at: "2026-09-06T00:00:00Z", + }) + first.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "old-turn", + frame_or_event_id: "paused", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {reason: "paused"}, + created_at: "2026-09-06T00:00:01Z", + }) + }) + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(2)) + expect(result.current.messages[0].parts).toMatchObject([{toolCallId: "bash-call"}]) + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "new-turn", end_time: null}, + read: {latest_sequence: 3}, + }) + act(() => { + first.onDisconnect({reason: "connection_lost", reconnect: true}) + document.dispatchEvent(new Event("visibilitychange")) + }) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2)) + expect(result.current.messages).toEqual([]) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + act(() => + second.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "new-turn", + frame_or_event_id: "new:0", + frame_index: 0, + entity_id: "new-answer", + type: "text-delta", + payload: {delta: "New answer stays visible"}, + created_at: "2026-09-06T00:00:02Z", + }), + ) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "New answer stays visible"}, + ]) + }) + it("keeps the flag-off path snapshot-free", async () => { const store = createStore() store.set(projectIdAtom, "project-1") diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts index 8443bf0a787..1edd99aacae 100644 --- a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -7,6 +7,7 @@ import { isSessionSnapshotRunning, reduceSessionLivePreview, retireSessionLivePreview, + retireCoveredSessionLivePreview, markSessionLivePreviewTerminal, sessionLivePreviewMessages, shouldRefreshLegacyObserverLiveness, @@ -33,6 +34,60 @@ const frame = ( }) describe("session live preview reducer", () => { + it("hands a paused tool preview to its durable approval without leaving running dots", () => { + const toolCallId = "native-bash-call" + const state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame( + 0, + "tool-input-available", + {toolCallId, toolName: "bash", input: {command: "sleep 12"}}, + toolCallId, + ), + ) + const durable = [ + { + id: "saved-approval", + role: "assistant" as const, + parts: [ + { + type: "tool-bash" as const, + toolCallId, + state: "approval-requested" as const, + input: {command: "sleep 12"}, + approval: {id: "approval-1"}, + }, + ], + }, + ] + const retired = retireCoveredSessionLivePreview(state, state, new Set(), durable) + expect(sessionLivePreviewMessages(retired)).toEqual([]) + expect(durable[0].parts[0].state).toBe("approval-requested") + + const completedWhileReading = reduceSessionLivePreview( + state, + frame(1, "tool-output-error", {toolCallId, errorText: "tool failed"}, toolCallId), + ) + const preserved = retireCoveredSessionLivePreview( + completedWhileReading, + state, + new Set(), + durable, + ) + expect(sessionLivePreviewMessages(preserved)[0].parts).toMatchObject([ + {state: "output-error"}, + ]) + const alreadyCompleted = retireCoveredSessionLivePreview( + completedWhileReading, + completedWhileReading, + new Set(), + durable, + ) + expect(sessionLivePreviewMessages(alreadyCompleted)[0].parts).toMatchObject([ + {state: "output-error"}, + ]) + }) + it("removes the control-only invoke message but preserves an invoke error", () => { const accepted = { id: "accepted", From 094ee6004cd36459ecefacc85bd538528a74db10 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 03:14:03 +0200 Subject: [PATCH 110/133] fix(api): keep queued messages behind pending approvals --- api/entrypoints/routers.py | 1 + api/oss/src/core/sessions/inputs/service.py | 115 +++++++++++--- .../sessions/test_pending_inputs_service.py | 148 +++++++++++++++++- 3 files changed, 239 insertions(+), 25 deletions(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 056c73d2566..2646fc86099 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1183,6 +1183,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) - ) session_inputs_service = SessionInputsService( inputs_dao=session_inputs_dao, + interactions_dao=interactions_dao, streams_service=session_streams_service, executions_dao=session_executions_dao, continuation_resumer=session_commands_service.resume_recoverable_continuation, diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index eb23f0fa6d9..e10fd04c605 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -16,6 +16,10 @@ SessionInputNotFound, SessionInputNotRemovable, ) +from oss.src.core.sessions.interactions.dtos import SessionInteractionStatus +from oss.src.core.sessions.interactions.interfaces import ( + SessionInteractionsDAOInterface, +) from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.utils.env import env @@ -38,11 +42,13 @@ def __init__( inputs_dao: SessionInputsDAOInterface, streams_service: SessionStreamsService, executions_dao: Optional[SessionExecutionsDAOInterface] = None, + interactions_dao: Optional[SessionInteractionsDAOInterface] = None, continuation_resumer: Optional[Callable[..., Awaitable[Optional[str]]]] = None, ) -> None: self._dao = inputs_dao self._streams = streams_service self._executions = executions_dao + self._interactions = interactions_dao self._continuation_resumer = continuation_resumer async def admit( @@ -75,6 +81,19 @@ async def admit( project_id=project_id, session_id=session_id ) busy = bool(stream and stream.flags and stream.flags.is_running) + # A parked approval has no running heartbeat but still owns Queue. + queued_behind_interaction = bool( + policy == "queue" + and env.agenta.sessions.queue + and stream + and stream.turn_id + and await self._has_pending_interaction( + project_id=project_id, + session_id=session_id, + execution_id=stream.turn_id, + ) + ) + busy = busy or queued_behind_interaction resumed_execution_id: Optional[str] = None if ( not busy @@ -101,6 +120,7 @@ async def admit( if not idempotency_key: raise ValueError("Idempotency-Key is required when queueing input.") + retry_after_interaction = False async with self._dao.transaction() as transaction: source_execution = None successor_execution_id = None @@ -128,40 +148,89 @@ async def admit( if ( source_execution is not None and source_execution.terminal_outcome is not None + and not ( + queued_behind_interaction + and await self._has_pending_interaction( + project_id=project_id, + session_id=session_id, + execution_id=current_execution_id, + transaction=transaction, + ) + ) ): - successor = await self._dao.fetch_active_successor( - project_id=project_id, - session_id=session_id, + if queued_behind_interaction: + # An approval can win while Queue waits for the execution lock. + # Re-enter admission outside this transaction so its continuation, + # rather than a fresh run, owns the queued message. + retry_after_interaction = True + else: + successor = await self._dao.fetch_active_successor( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + successor_execution_id = ( + successor.promoted_execution_id + if successor is not None + else None + ) + if successor_execution_id is None: + return PendingInputAdmission(action="execute") + if not retry_after_interaction: + item = await self._dao.create_input( + user_id=user_id, + pending_input=PendingInputCreate( + project_id=project_id, + session_id=session_id, + content=content, + policy=policy, + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + ), + prioritize=policy == "steer", transaction=transaction, ) - successor_execution_id = ( - successor.promoted_execution_id if successor is not None else None - ) - if successor_execution_id is None: - return PendingInputAdmission(action="execute") - item = await self._dao.create_input( + # `create_input` rechecks under the session transaction lock, so a concurrent + # admission can return the row that won after our optimistic read above. + if item.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + if retry_after_interaction: + return await self.admit( + project_id=project_id, user_id=user_id, - pending_input=PendingInputCreate( - project_id=project_id, - session_id=session_id, - content=content, - policy=policy, - idempotency_key=idempotency_key, - request_fingerprint=fingerprint, - ), - prioritize=policy == "steer", - transaction=transaction, + session_id=session_id, + content=content, + policy=policy, + idempotency_key=idempotency_key, ) - # `create_input` rechecks under the session transaction lock, so a concurrent - # admission can return the row that won after our optimistic read above. - if item.request_fingerprint != fingerprint: - raise SessionInputIdempotencyConflict() return PendingInputAdmission( action="pending", input=item, execution_id=successor_execution_id or current_execution_id, ) + async def _has_pending_interaction( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> bool: + if self._interactions is None: + return False + interactions = await self._interactions.fetch_turn_interactions( + project_id=project_id, + session_id=session_id, + turn_id=execution_id, + transaction=transaction, + for_update=transaction is not None, + ) + return any( + interaction.status == SessionInteractionStatus.pending + for interaction in interactions + ) + async def list_pending( self, *, project_id: UUID, session_id: str ) -> List[PendingInput]: diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py index dfc997bd6c4..45ea9dbfb21 100644 --- a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -6,6 +6,7 @@ import pytest +from oss.src.core.sessions.interactions.dtos import SessionInteractionStatus from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState from oss.src.core.sessions.inputs.service import SessionInputsService from oss.src.core.sessions.inputs.types import ( @@ -192,7 +193,7 @@ async def test_detached_executing_continuation_keeps_input_in_the_queue(monkeypa @pytest.mark.asyncio -async def test_parked_continuation_still_allows_input_to_execute(monkeypatch): +async def test_no_recoverable_continuation_keeps_idle_input_executable(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "queue", True) continuation_resumer = AsyncMock(return_value=None) dao = MemoryInputsDAO() @@ -206,7 +207,7 @@ async def test_parked_continuation_still_allows_input_to_execute(monkeypatch): project_id=uuid4(), user_id=uuid4(), session_id="session-1", - content={"message": "steer the parked turn"}, + content={"message": "normal idle input"}, policy="queue", idempotency_key="key-1", ) @@ -356,3 +357,146 @@ async def test_steer_is_saved_ahead_of_queued_input(monkeypatch): pending = await service.list_pending(project_id=project_id, session_id="session-1") assert [item.id for item in pending] == [steered.input.id, queued.input.id] assert steered.execution_id == "execution-1" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_outcome", [None, "completed"]) +async def test_queue_waits_for_unanswered_approval_even_after_execution_settles( + monkeypatch, terminal_outcome +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + pending = SimpleNamespace(status=SessionInteractionStatus.pending) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock(return_value=[pending]) + ) + executions = SimpleNamespace( + lock_for_control=AsyncMock( + return_value=SimpleNamespace(terminal_outcome=terminal_outcome) + ) + ) + resumer = AsyncMock(return_value=None) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + executions_dao=executions, + interactions_dao=interactions, + continuation_resumer=resumer, + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after approval"}, + policy="queue", + idempotency_key="queued-1", + ) + assert admitted.action == "pending" + assert admitted.input == dao.items[0] + assert admitted.execution_id == "execution-1" + assert pending.status == SessionInteractionStatus.pending + resumer.assert_not_awaited() + if terminal_outcome: + assert ( + interactions.fetch_turn_interactions.await_args.kwargs["for_update"] is True + ) + + +@pytest.mark.asyncio +async def test_steer_can_replace_unanswered_approval(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + interactions = SimpleNamespace(fetch_turn_interactions=AsyncMock()) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + interactions_dao=interactions, + continuation_resumer=AsyncMock(return_value=None), + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "replace this"}, + policy="steer", + idempotency_key="steer-1", + ) + assert admitted.action == "execute" + assert dao.items == [] + interactions.fetch_turn_interactions.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status", [SessionInteractionStatus.resolved, SessionInteractionStatus.cancelled] +) +async def test_idle_queue_does_not_wait_on_old_answered_approval(monkeypatch, status): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock(return_value=[SimpleNamespace(status=status)]) + ) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + interactions_dao=interactions, + continuation_resumer=AsyncMock(return_value=None), + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="queue", + idempotency_key="idle-1", + ) + assert admitted.action == "execute" + + +@pytest.mark.asyncio +async def test_approval_winning_queue_lock_keeps_input_behind_its_continuation( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock( + side_effect=[ + [SimpleNamespace(status=SessionInteractionStatus.pending)], + [SimpleNamespace(status=SessionInteractionStatus.responded)], + [SimpleNamespace(status=SessionInteractionStatus.responded)], + ] + ) + ) + executions = SimpleNamespace( + lock_for_control=AsyncMock( + side_effect=[ + SimpleNamespace(terminal_outcome="completed"), + SimpleNamespace(terminal_outcome=None), + ] + ) + ) + resumer = AsyncMock(return_value="approved-continuation") + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + interactions_dao=interactions, + executions_dao=executions, + continuation_resumer=resumer, + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after the approved tool"}, + policy="queue", + idempotency_key="queue-approval-race", + ) + assert admitted.action == "pending" + assert admitted.execution_id == "approved-continuation" + assert len(dao.items) == 1 + resumer.assert_awaited_once() + assert ( + executions.lock_for_control.await_args.kwargs["execution_id"] + == "approved-continuation" + ) From 9623488b6c5f7514b72fcfa562b21cf503a10932 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 03:43:32 +0200 Subject: [PATCH 111/133] test: retry named connection propagation in playground fixtures --- .../playground/assets/secretPropagation.ts | 12 ++++++++ .../playwright/acceptance/playground/tests.ts | 6 +--- .../unit/secret-propagation.spec.ts | 30 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts create mode 100644 web/oss/tests/playwright/unit/secret-propagation.spec.ts diff --git a/web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts b/web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts new file mode 100644 index 00000000000..b387c42e5ea --- /dev/null +++ b/web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts @@ -0,0 +1,12 @@ +export const isSecretPropagationFailure = (response: Record | null): boolean => { + // A newly recreated fixture connection may precede the service's cached inventory. + if ( + response?.status?.code === 400 && + response.status.type === "https://agenta.ai/docs/misc/errors#v0:schemas:unknown-connection" + ) { + return true + } + + const raw = JSON.stringify(response ?? {}).toLowerCase() + return raw.includes("invalid-secrets") || raw.includes("no api key found for model") +} diff --git a/web/oss/tests/playwright/acceptance/playground/tests.ts b/web/oss/tests/playwright/acceptance/playground/tests.ts index 83d963ddd3a..8bc436f2e60 100644 --- a/web/oss/tests/playwright/acceptance/playground/tests.ts +++ b/web/oss/tests/playwright/acceptance/playground/tests.ts @@ -2,16 +2,12 @@ import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture" import {getKnownLatestRevisionId} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" import {expect, pollLocatorState} from "@agenta/web-tests/utils" +import {isSecretPropagationFailure} from "./assets/secretPropagation" import {RoleType, VariantFixtures} from "./assets/types" const SECRET_PROPAGATION_TIMEOUT_MS = 65_000 const SECRET_PROPAGATION_POLL_MS = 5_000 -const isSecretPropagationFailure = (response: Record | null): boolean => { - const raw = JSON.stringify(response ?? {}).toLowerCase() - return raw.includes("invalid-secrets") || raw.includes("no api key found for model") -} - const waitForSuccessfulRun = async ( triggerRun: () => Promise, waitForRunResponse: () => Promise | null>, diff --git a/web/oss/tests/playwright/unit/secret-propagation.spec.ts b/web/oss/tests/playwright/unit/secret-propagation.spec.ts new file mode 100644 index 00000000000..6fd9ae90fb0 --- /dev/null +++ b/web/oss/tests/playwright/unit/secret-propagation.spec.ts @@ -0,0 +1,30 @@ +import {expect, test} from "@playwright/test" + +import {isSecretPropagationFailure} from "../acceptance/playground/assets/secretPropagation" + +test("retries a stale named connection inventory without masking ordinary run errors", () => { + expect( + isSecretPropagationFailure({ + status: { + code: 400, + type: "https://agenta.ai/docs/misc/errors#v0:schemas:unknown-connection", + message: "No provider connection named 'replacement'. Known connections: previous.", + }, + }), + ).toBe(true) + expect(isSecretPropagationFailure({status: {code: 500, message: "unknown-connection"}})).toBe( + false, + ) + expect( + isSecretPropagationFailure({status: {code: 400, type: "other:unknown-connection"}}), + ).toBe(false) + expect(isSecretPropagationFailure({status: {code: 429, message: "Rate limit exceeded"}})).toBe( + false, + ) + expect(isSecretPropagationFailure({status: {code: 200}, data: "unknown-connection"})).toBe( + false, + ) + expect(isSecretPropagationFailure(null)).toBe(false) + expect(isSecretPropagationFailure({status: {type: "#v0:schemas:invalid-secrets"}})).toBe(true) + expect(isSecretPropagationFailure({status: {message: "No API key found for model"}})).toBe(true) +}) From 8295adeeeee62b4f52964fec1191a58d4cedd535 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 03:56:03 +0200 Subject: [PATCH 112/133] fix(frontend): negotiate shared responses for durable chat inputs --- .../AgentChatSlice/AgentConversation.tsx | 1 + .../src/hooks/useAgentConversation.ts | 1 + .../src/hooks/useServerSessionInputs.ts | 10 ++++- .../unit/hooks/useServerSessionInputs.test.ts | 40 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 9ef341ecc49..e42600b4b97 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -388,6 +388,7 @@ const AgentConversation = ({ sessionId, messages, locallyBusy: busy, + isSharedReaderReady: () => readerReady, onExecuted: revalidate, }) diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 3cbbde7b2cd..e3dc8762494 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -691,6 +691,7 @@ export const useAgentConversation = ({ sessionId, messages, locallyBusy: busy, + isSharedReaderReady: () => sharedSenderReadyRef.current, onExecuted: () => { void loadSessionMessages(sessionId, adoptServerTranscript).then(adoptServerTranscript) }, diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index dd207f02e4b..ba1c9ecedb0 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -30,12 +30,15 @@ export const useServerSessionInputs = ({ sessionId, messages, locallyBusy, + isSharedReaderReady, onExecuted, }: { entityId: string sessionId: string messages: UIMessage[] locallyBusy: boolean + /** Read current transport readiness when admitting input, including after reconnect. */ + isSharedReaderReady?: () => boolean onExecuted?: () => void }): ServerSessionInputs => { const fetchSnapshot = useSetAtom(fetchSessionSnapshotAtom) @@ -48,6 +51,7 @@ export const useServerSessionInputs = ({ const messagesRef = useRef(messages) const entityIdRef = useRef(entityId) const onExecutedRef = useRef(onExecuted) + const isSharedReaderReadyRef = useRef(isSharedReaderReady) const loadInFlightRef = useRef<{ sessionId: string promise: Promise @@ -55,6 +59,7 @@ export const useServerSessionInputs = ({ messagesRef.current = messages entityIdRef.current = entityId onExecutedRef.current = onExecuted + isSharedReaderReadyRef.current = isSharedReaderReady const load = useCallback((): Promise => { if (loadInFlightRef.current?.sessionId === sessionId) { @@ -113,7 +118,10 @@ export const useServerSessionInputs = ({ const request = await buildAgentRequest( entityIdRef.current, [...messagesRef.current, outbound], - {sessionId}, + { + sessionId, + ...(isSharedReaderReadyRef.current?.() ? {sharedResponse: true} : {}), + }, ) if (!request) throw new Error("The agent is not ready to accept input.") diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 2a48d567c1b..5430206c6c3 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -348,6 +348,46 @@ describe("useServerSessionInputs", () => { expect(result.current.executionState).toBe("running") }) + it.each(["queue", "steer"] as const)( + "negotiates the current reader readiness for %s admission", + async (policy) => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {}, + requestBody: {}, + }) + fetchMock.mockImplementation(async () => new Response(null, {status: 202})) + const {result, rerender} = renderHook( + ({ready}) => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: false, + isSharedReaderReady: () => ready, + }), + {initialProps: {ready: false}}, + ) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + const submit = result.current.submit + await act(() => submit({id: "before-ready", text: "first", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({sessionId: "session-1"}) + rerender({ready: true}) + await act(() => submit({id: "ready", text: "next", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({ + sessionId: "session-1", + sharedResponse: true, + }) + rerender({ready: false}) + await act(() => submit({id: "disconnected", text: "last", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({sessionId: "session-1"}) + expect( + fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).on_busy), + ).toEqual([policy, policy, policy]) + }, + ) + it("reads queue support from the snapshot and submits durable admission", async () => { fetchSnapshot.mockResolvedValue({ session: { From 19b0077c6ec1298624decfa2b7400893fb11f57f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 04:09:06 +0200 Subject: [PATCH 113/133] fix(api): keep input behind starting approval continuations --- .../core/sessions/executions/interfaces.py | 10 +++ api/oss/src/core/sessions/inputs/service.py | 13 ++++ .../dbs/postgres/sessions/executions/dao.py | 23 +++++++ .../unit/sessions/test_session_inputs_dao.py | 66 +++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/api/oss/src/core/sessions/executions/interfaces.py b/api/oss/src/core/sessions/executions/interfaces.py index c1af50e7365..73f8ddb381e 100644 --- a/api/oss/src/core/sessions/executions/interfaces.py +++ b/api/oss/src/core/sessions/executions/interfaces.py @@ -33,6 +33,16 @@ async def lock_for_control( """Ensure and row-lock the source execution for Stop/answer arbitration.""" raise NotImplementedError + async def lock_active_continuation( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[SessionExecutionSettlement]: + """Lock the unsettled continuation that still owns this session.""" + raise NotImplementedError + async def create_continuation( self, *, diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index e10fd04c605..2860c2ab779 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -174,6 +174,19 @@ async def admit( if successor is not None else None ) + if successor_execution_id is None and self._executions is not None: + # Redis can announce a resumed turn before the durable header moves off + # its terminal parent. Approval children have no promoted input row. + continuation = await self._executions.lock_active_continuation( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + successor_execution_id = ( + continuation.execution_id + if continuation is not None + else None + ) if successor_execution_id is None: return PendingInputAdmission(action="execute") if not retry_after_interaction: diff --git a/api/oss/src/dbs/postgres/sessions/executions/dao.py b/api/oss/src/dbs/postgres/sessions/executions/dao.py index 2772a216cd4..abbdffad22c 100644 --- a/api/oss/src/dbs/postgres/sessions/executions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/executions/dao.py @@ -97,6 +97,29 @@ async def lock_for_control( ).scalar_one() return _to_dto(row) + async def lock_active_continuation( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[SessionExecutionSettlement]: + row = ( + await transaction.execute( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.parent_execution_id.is_not(None), + SessionExecutionDBE.terminal_outcome.is_(None), + ) + .order_by(SessionExecutionDBE.execution_id) + .limit(1) + .with_for_update() + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + async def create_continuation( self, *, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index b28426d6c74..72600404162 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -850,3 +850,69 @@ async def test_stop_settlement_wins_before_steer_bind(input_scope, monkeypatch): engine=input_scope["engine"] ).fetch_command(command_id=command.id) assert settled_command.data is None + + +@pytest.mark.parametrize("policy", ["queue", "steer"]) +@pytest.mark.parametrize( + "state", ["pending_delivery", "running", "recoverable", "terminal"] +) +async def test_admission_follows_approval_continuation_before_stream_header_catches_up( + input_scope, monkeypatch, policy, state +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + scope = { + "project_id": input_scope["project_id"], + "session_id": input_scope["session_id"], + } + async with input_scope["engine"].session() as transaction: + await executions.settle( + **scope, + execution_id="source-turn", + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + await executions.create_continuation( + **scope, + execution_id="approved-child", + parent_execution_id="source-turn", + source_interaction_id=None, + transaction=transaction, + ) + if state == "terminal": + await executions.settle( + **scope, + execution_id="approved-child", + terminal_outcome="completed", + settled_by="runner", + transaction=transaction, + ) + else: + await executions.set_state( + **scope, + execution_id="approved-child", + state=SessionExecutionState(state), + transaction=transaction, + ) + service = SessionInputsService( + inputs_dao=inputs, + streams_service=_BusyStreams(), + executions_dao=executions, + ) + admission = await service.admit( + **scope, + user_id=input_scope["user_id"], + content={"message": "after approved work"}, + policy=policy, + idempotency_key="approval-start-gap", + ) + if state == "terminal": + assert admission.action == "execute" + assert await inputs.list_pending(**scope) == [] + else: + assert admission.action == "pending" + assert admission.execution_id == "approved-child" + assert len(await inputs.list_pending(**scope)) == 1 From c9e54b39c149a402c34c479b5526a9e88a9feb26 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 14:41:56 +0200 Subject: [PATCH 114/133] fix(agents): resume queued turns after questionnaire answers --- .../sessions/interactions_dispatcher.py | 45 +++++---- .../sessions/test_interactions_dispatcher.py | 74 +++++++++++++++ .../AgentChatSlice/AgentConversation.tsx | 14 ++- .../hooks/useAgentChatSession.test.ts | 94 +++++++++++++++++-- .../hooks/useAgentChatSession.ts | 56 ++++++----- .../src/clientTools/ClientToolPart.tsx | 4 +- .../src/components/ElicitationDock.tsx | 29 +++++- .../src/hooks/useAgentConversation.ts | 54 ++++++----- .../components/elicitationDockSettle.test.tsx | 12 +++ .../unit/hooks/useAgentConversation.test.ts | 54 ++++++++++- .../src/session/state/interactionAnswer.ts | 18 ++-- .../unit/session-interaction-answer.test.ts | 86 +++++++++++++++++ 12 files changed, 450 insertions(+), 90 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index c71d14747c9..97c9acd6e3a 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -9,8 +9,8 @@ ``session-identity.ts`` ``approvalDecisionForToolCall``). The client payload stays minimal: ``{approved: bool, tool_call_id?, message?}``. -Every other interaction kind keeps the original passthrough contract -(``data.inputs = answer``). +Agent ``client_tool`` answers replay the same conversation with their structured output or +error as the tool result. Other interaction kinds retain ``data.inputs = answer``. The resume also carries the gated turn's own config when the runner stamped one on the row (``data.parameters``): sending it inline suppresses reference hydration in the SDK resolver, @@ -233,6 +233,18 @@ def _gated_call_shape( return {"name": request.tool, "args": request.args} +def _is_agent_interaction_answer(interaction: SessionInteraction, answer: Any) -> bool: + if not isinstance(answer, dict): + return False + return ( + interaction.kind == SessionInteractionKind.user_approval + and isinstance(answer.get("approved"), bool) + ) or ( + interaction.kind == SessionInteractionKind.client_tool + and answer.get("outcome") in ("completed", "error") + ) + + def compose_approval_messages( records: List[SessionRecord], interaction: SessionInteraction, @@ -288,14 +300,18 @@ def compose_approval_messages_many( gated_call["input"] = shape["args"] messages.append({"role": "assistant", "content": [gated_call]}) - envelope = { - "type": "tool_result", - "toolCallId": gated_id, - "output": { + envelope: Dict[str, Any] = {"type": "tool_result", "toolCallId": gated_id} + if interaction.kind == SessionInteractionKind.client_tool: + is_error = answer.get("outcome") == "error" + envelope["output"] = ( + answer.get("error") if is_error else answer.get("output", {}) + ) + envelope["isError"] = is_error + else: + envelope["output"] = { "approved": bool(answer.get("approved")), "interactionToken": interaction.token, - }, - } + } gated_name = gated_call.get("toolName") or shape.get("name") if gated_name: envelope["toolName"] = gated_name @@ -347,11 +363,7 @@ async def _compose_inputs( interaction: SessionInteraction, answer: Any, ) -> Dict[str, Any]: - if ( - interaction.kind == SessionInteractionKind.user_approval - and isinstance(answer, dict) - and isinstance(answer.get("approved"), bool) - ): + if _is_agent_interaction_answer(interaction, answer): records: List[SessionRecord] = [] if self.records_service is not None: try: @@ -418,12 +430,7 @@ async def respond_many( selector = ( data.selector.model_dump(mode="json") if data and data.selector else None ) - if all( - item.kind == SessionInteractionKind.user_approval - and isinstance(answer, dict) - and isinstance(answer.get("approved"), bool) - for item, answer in resolved - ): + if all(_is_agent_interaction_answer(item, answer) for item, answer in resolved): records: List[SessionRecord] = [] if self.records_service is not None: try: diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index fc4939624ae..bde88224897 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock +import pytest + from oss.src.apis.fastapi.sessions.models import SessionInteractionCreateRequest from oss.src.core.sessions.interactions.dtos import SessionInteractionKind from oss.src.core.sessions.records.dtos import SessionRecord @@ -984,3 +986,75 @@ def test_keyed_references_drops_families_the_invoke_does_not_accept(): assert keyed_references([SessionReference(key="application", slug="app-1")]) == { "application": {"slug": "app-1"} } + + +@pytest.mark.parametrize("outcome", ["completed", "error"]) +async def test_client_tool_answer_replays_questionnaire_result(outcome): + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.client_tool, + request={ + "tool": "__ag__request_input", + "tool_call_id": "form-call", + "args": {"title": "Choose"}, + }, + ) + records = [ + _record( + project_id, + source="user", + rtype="message", + attributes={"text": "ask a questionnaire"}, + ), + _record( + project_id, + rtype="tool_call", + attributes={ + "id": "form-call", + "name": "__ag__request_input", + "input": {"title": "Choose"}, + }, + index=1, + ), + ] + answer = { + "tool_call_id": "form-call", + "tool_name": "__ag__request_input", + "outcome": outcome, + } + result = {"action": "accept", "content": {"selected": "blue", "default": "UTC"}} + if outcome == "error": + answer["error"] = "Questionnaire could not be rendered" + else: + answer["output"] = result + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with(interaction, records, dispatch_fn) + execution_id = str(uuid4()) + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer=answer, + continuation_execution_id=execution_id, + ) + request = dispatch_fn.await_args.kwargs["request"] + assert dispatch_fn.await_args.kwargs["run_id"] == execution_id + messages = request.data.inputs["messages"] + assert messages[0] == {"role": "user", "content": "ask a questionnaire"} + assert messages[-1]["role"] == "assistant" + assert messages[-1]["content"][-1] == { + "type": "tool_result", + "toolCallId": "form-call", + "toolName": "__ag__request_input", + "output": answer["error"] if outcome == "error" else result, + "isError": outcome == "error", + } + assert ( + sum( + block.get("type") == "tool_call" + for m in messages + if isinstance(m.get("content"), list) + for block in m["content"] + ) + == 1 + ) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index e42600b4b97..44bba67806f 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -156,7 +156,7 @@ const AgentConversation = ({ stopping, setStopped, handleStop, - handleClientToolOutput, + handleClientToolOutput: answerClientTool, markLiveGate, answerApproval, answerApprovals, @@ -497,6 +497,18 @@ const AgentConversation = ({ [answerApproval, markLiveGate, submit], ) + const handleClientToolOutput = useCallback( + async (args: Parameters[0]) => { + approvalResponseOwnerRef.current = args.toolCallId + const outcome = await answerClientTool(args) + if (approvalResponseOwnerRef.current === args.toolCallId) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } + }, + [answerClientTool], + ) + const handleApprovalResponses = useCallback( async (ids: string[], approved: boolean) => { approvalResponseOwnerRef.current = ids[0] diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index 273c9ae557e..0b8e2ac3779 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -41,6 +41,9 @@ const state = vi.hoisted(() => ({ busy: false, stop: vi.fn(), switchEntity: vi.fn(), + respondAnswer: vi.fn(), + addToolOutput: vi.fn(), + durableCapability: false, setCommitSignal: vi.fn(), invalidateCommit: vi.fn(), })) @@ -58,7 +61,30 @@ vi.mock("@agenta/chat/assets", () => ({ ) => build(), resolveStopExecution: state.resolveStopExecution, startupLabelFromDataPart: () => undefined, - submitApprovalForCapability: vi.fn(), + submitApprovalForCapability: async ({ + durableApprovals, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }: { + durableApprovals: boolean + submitDurable: () => Promise + retireDurable: () => void + recordLegacy: () => Promise + releaseLegacy: () => void + }) => { + if (durableApprovals) { + try { + return await submitDurable() + } finally { + retireDurable() + } + } + await recordLegacy() + releaseLegacy() + return {durable: false, recoverable: false} + }, })) vi.mock("@agenta/chat/hooks", () => ({ @@ -155,7 +181,7 @@ vi.mock("@agenta/ui/app-message", () => ({message: {warning: vi.fn()}})) vi.mock("@ai-sdk/react", () => ({ useChat: () => ({ addToolApprovalResponse: vi.fn(), - addToolOutput: vi.fn(), + addToolOutput: state.addToolOutput, error: undefined, messages: state.messages, regenerate: state.regenerate, @@ -172,11 +198,15 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("jotai", () => ({ useAtomValue: () => state.projectId, useSetAtom: (atom: string) => - atom === "switch-entity" - ? state.switchEntity - : atom === "commit-signal" - ? state.setCommitSignal - : vi.fn(), + atom === "respond-interaction-answer" + ? state.respondAnswer + : atom === "session-durable-approvals-capability" + ? () => Promise.resolve(state.durableCapability) + : atom === "switch-entity" + ? state.switchEntity + : atom === "commit-signal" + ? state.setCommitSignal + : vi.fn(), useStore: () => ({ get: (atom: string) => { if (atom === "record-counts" || atom === "session-messages") return {} @@ -227,6 +257,13 @@ describe("useAgentChatSession execution guard", () => { state.acceptedRunBySession.clear() state.turnDeliverySourceBySession.clear() state.turnIds.clear() + state.respondAnswer.mockReset().mockResolvedValue({ + durable: true, + recoverable: false, + executionId: "questionnaire-child", + }) + state.addToolOutput.mockReset().mockResolvedValue(undefined) + state.durableCapability = false state.sendMessage.mockClear() state.regenerate.mockClear() state.cancelSessionExecution.mockReset() @@ -249,6 +286,49 @@ describe("useAgentChatSession execution guard", () => { state.busy = false }) + it("answers a queued questionnaire through server ownership without SDK auto-resume", async () => { + state.durableCapability = true + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + const output = {action: "accept", content: {goal: "Correctness"}} + await act(async () => { + await expect( + result!.handleClientToolOutput({ + toolName: "request_input", + toolCallId: "questionnaire", + output, + }), + ).resolves.toEqual({ + durable: true, + recoverable: false, + executionId: "questionnaire-child", + }) + }) + expect(state.respondAnswer).toHaveBeenCalledWith({ + sessionId: "session-1", + toolCallId: "questionnaire", + resolution: { + tool_call_id: "questionnaire", + tool_name: "request_input", + outcome: "completed", + output, + }, + }) + expect(state.addToolOutput).not.toHaveBeenCalled() + expect(state.capturedHooks!.sendAutomaticallyWhen({messages: []})).toBe(false) + act(() => root.unmount()) + }) + it("deduplicates live-reader and native commit notifications through the same config switch", () => { let result: ReturnType | undefined const root = createRoot(document.createElement("div")) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 4af6f35cf08..2b2364ee126 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -63,7 +63,6 @@ import { isHitlPending, isResumeSend, playgroundController, - recordAnswerThenRelease, type LiveAgentInteraction, } from "@agenta/playground" import {agentSelfCommitSignalAtom} from "@agenta/shared/state" @@ -498,30 +497,30 @@ export const useAgentChatSession = ({ if (isResumeSend({from, to: status})) liveGateInteractionRef.current = null }, [status]) - // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect - // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the - // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching - // is by id — so a cast onto the untyped UIMessage tool map is safe. - const handleClientToolOutput = useCallback( - ({toolName, toolCallId, output, errorText}) => { - // Set synchronously: it holds off transcript adoption for the whole ordered window. + // Durable gates resume on the server; legacy gates still release the local SDK. + const handleClientToolOutput = useCallback( + async ({ + toolName, + toolCallId, + output, + errorText, + }: Parameters[0]) => { liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} - // Ordered, not raced — the resume starts a turn whose sweep cancels every `pending` - // row, so the answer has to be durable first. Capped inside the helper. - void recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ - sessionId, - toolCallId, - resolution: { - tool_call_id: toolCallId, - tool_name: toolName, - ...(errorText !== undefined - ? {outcome: "error", error: errorText} - : {outcome: "completed", output: output ?? {}}), - }, - }), - release: () => { + const resolution = { + tool_call_id: toolCallId, + tool_name: toolName, + ...(errorText !== undefined + ? {outcome: "error", error: errorText} + : {outcome: "completed", output: output ?? {}}), + } + const outcome = await submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({sessionId, toolCallId, resolution}), + releaseLegacy: () => { if (errorText !== undefined) { addToolOutput({ state: "output-error", @@ -538,8 +537,15 @@ export const useAgentChatSession = ({ } }, }) + return outcome }, - [addToolOutput, recordInteractionAnswer, sessionId], + [ + addToolOutput, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) // Orphan detection for the queue's pre-resume hold: the tail is a RESTORED message (this diff --git a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx index c24aed461c8..13b6c9487b7 100644 --- a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx +++ b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx @@ -18,13 +18,13 @@ import {canonicalToolName, resolveClientToolWidget, resolveToolDisplay} from ".. import {clientToolMeta} from "./meta" import UnhandledClientTool from "./UnhandledClientTool" -/** Settle a parked client tool. The panel maps this onto `addToolOutput` (success or error). */ +/** Settle a parked client tool; await durable submission when the host owns it. */ export type ClientToolOutputHandler = (args: { toolName: string toolCallId: string output?: Record errorText?: string -}) => void +}) => void | Promise const ClientToolPart = ({ part, diff --git a/web/packages/agenta-chat/src/components/ElicitationDock.tsx b/web/packages/agenta-chat/src/components/ElicitationDock.tsx index bbab04c75e8..8b322a31150 100644 --- a/web/packages/agenta-chat/src/components/ElicitationDock.tsx +++ b/web/packages/agenta-chat/src/components/ElicitationDock.tsx @@ -16,7 +16,7 @@ * Escape here does NOT settle, unlike `ApprovalCard` and `ConnectionDock`. This card owns a text * field, and Escape-to-back-out-of-typing is the stronger expectation; dismissing is the header ✕. */ -import {useCallback, useEffect, useMemo, useRef} from "react" +import {useCallback, useEffect, useMemo, useRef, useState} from "react" import { buildAcceptResult, @@ -119,11 +119,27 @@ const ElicitationCard = ({ // One settle per card. `meta.settled` only flips after the host's durable write resolves, so the // buttons stay live in between without this latch. const settledRef = useRef(false) + const [submissionError, setSubmissionError] = useState(null) const settle = useCallback( (output: Record) => { if (settledRef.current) return settledRef.current = true - onOutput({toolName: meta.toolName, toolCallId: meta.toolCallId, output}) + setSubmissionError(null) + const failed = (error: unknown) => { + settledRef.current = false + setSubmissionError( + error instanceof Error + ? error.message + : "Could not submit your answer. Try again.", + ) + } + try { + void Promise.resolve( + onOutput({toolName: meta.toolName, toolCallId: meta.toolCallId, output}), + ).catch(failed) + } catch (error) { + failed(error) + } }, [onOutput, meta.toolName, meta.toolCallId], ) @@ -146,6 +162,7 @@ const ElicitationCard = ({ active={active} shortcutsEnabled={shortcutsEnabled} settle={settle} + submissionError={submissionError} /> ) } @@ -206,6 +223,7 @@ const LiveCard = ({ active, shortcutsEnabled, settle, + submissionError, }: { payload: ElicitationRequestPayload meta: ClientToolMeta @@ -214,6 +232,7 @@ const LiveCard = ({ active: boolean shortcutsEnabled: boolean settle: (output: Record) => void + submissionError?: string | null }) => { const cardRef = useRef(null) const form = useMemo(() => buildElicitationSteps(payload), [payload]) @@ -506,10 +525,12 @@ const LiveCard = ({ - {stepper.error ?? stepper.hold ?? ""} + {submissionError ?? stepper.error ?? stepper.hold ?? ""} diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index e3dc8762494..5d1659134d8 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -38,7 +38,6 @@ import { approvalResolution, buildAgentRequest, isResumeSend, - recordAnswerThenRelease, type LiveAgentInteraction, } from "@agenta/playground/agent-chat" import {generateId} from "@agenta/shared/utils" @@ -212,7 +211,7 @@ export interface AgentConversation { /** Headless approval-dock state wired to the live-gate-aware response path. */ approvals: ApprovalDock /** Settle a parked client tool part (widgets call this; the resume predicate auto-resends). */ - sendToolOutput: (args: ToolOutputSettleInput) => void + sendToolOutput: (args: ToolOutputSettleInput) => Promise /** Re-fetch the durable records and adopt the server transcript under the same guards as * revalidate-on-open (never mid-stream, only when strictly ahead). Wire push signals — a * session watch relay, a foreground event — to this. */ @@ -857,29 +856,26 @@ export const useAgentConversation = ({ } }, [pendingApprovalId]) - // Settle a parked client tool (#4920). A widget calls this with the structured reference; - // `addToolOutput` matches the part by `toolCallId` on the last turn and the resume predicate - // auto-resends. `tool` is only the typed-tools key — matching is by id — so a cast onto the - // untyped UIMessage tool map is safe. + // Durable gates resume on the server; legacy gates still release the local SDK. const sendToolOutput = useCallback( - ({toolName, toolCallId, output, errorText}: ToolOutputSettleInput) => { + async ({toolName, toolCallId, output, errorText}: ToolOutputSettleInput) => { + approvalResponseOwnerRef.current = toolCallId liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} - // Ordered like the approval half: the resume starts a turn whose sweep cancels every - // `pending` row, so the answer has to be durable first. Capped inside the helper. - void recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ - sessionId, - toolCallId, - resolution: { - tool_call_id: toolCallId, - tool_name: toolName, - ...(errorText !== undefined - ? {outcome: "error", error: errorText} - : {outcome: "completed", output: output ?? {}}), - }, - }), - release: () => { + const resolution = { + tool_call_id: toolCallId, + tool_name: toolName, + ...(errorText !== undefined + ? {outcome: "error", error: errorText} + : {outcome: "completed", output: output ?? {}}), + } + const outcome = await submitApprovalForCapability({ + durableApprovals: await supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({sessionId, toolCallId, resolution}), + releaseLegacy: () => { if (errorText !== undefined) { addToolOutput({ state: "output-error", @@ -896,8 +892,18 @@ export const useAgentConversation = ({ } }, }) + if (approvalResponseOwnerRef.current === toolCallId) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } }, - [addToolOutput, recordInteractionAnswer, sessionId], + [ + addToolOutput, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) // Publish this session's run state (single source of truth for session-list status dots). diff --git a/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx b/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx index bc92dac6502..06d6b055e7c 100644 --- a/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx +++ b/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx @@ -631,3 +631,15 @@ describe("the controls the dialect grew", () => { expect(screen.getByText("Region")).toBeTruthy() }) }) + +it("shows a failed durable answer and permits retry without a second in-flight submission", async () => { + const {onOutput} = setup(ONE_QUESTION) + onOutput + .mockRejectedValueOnce(new Error("Answer could not be saved")) + .mockResolvedValue(undefined) + fireEvent.click(screen.getByRole("button", {name: "Send answers"})) + await waitFor(() => expect(screen.getByText("Answer could not be saved")).toBeTruthy()) + fireEvent.click(screen.getByRole("button", {name: "Send answers"})) + await waitFor(() => expect(onOutput).toHaveBeenCalledTimes(2)) + expect(screen.queryByText("Answer could not be saved")).toBeNull() +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 8d65c92832f..6695800fabe 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -21,10 +21,18 @@ import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" -const {capabilitiesViaAtom, snapshotViaAtom, resumeContinuation} = vi.hoisted(() => ({ +const { + capabilitiesViaAtom, + snapshotViaAtom, + resumeContinuation, + durableApprovalCapability, + respondAnswer, +} = vi.hoisted(() => ({ capabilitiesViaAtom: vi.fn(), snapshotViaAtom: vi.fn(), resumeContinuation: vi.fn(), + durableApprovalCapability: vi.fn(), + respondAnswer: vi.fn(), })) const approvalRecord = vi.hoisted(() => ({ @@ -73,7 +81,8 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { snapshotViaAtom(sessionId), ), resumeSessionContinuationAtom: atom(null, () => resumeContinuation()), - sessionDurableApprovalsCapabilityAtom: atom(null, () => false), + sessionDurableApprovalsCapabilityAtom: atom(null, () => durableApprovalCapability()), + respondInteractionAnswerAtom: atom(null, (_get, _set, args) => respondAnswer(args)), } }) @@ -309,6 +318,10 @@ const mount = (store: ReturnType, entityId: string, sessionI ) beforeEach(() => { + durableApprovalCapability.mockReset().mockResolvedValue(false) + respondAnswer + .mockReset() + .mockResolvedValue({durable: true, recoverable: false, executionId: "questionnaire-child"}) approvalRecord.defer = false approvalRecord.resolve = undefined FakeEventSource.instances = [] @@ -1154,3 +1167,40 @@ describe("useAgentConversation", () => { }) }) }) + +describe("server-owned client-tool answers", () => { + it.each([false, true])( + "submits client-tool answer durably without a competing local resume (error=%s)", + async (failed) => { + durableApprovalCapability.mockResolvedValue(true) + resumeContinuation.mockResolvedValue(true) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + const output = {action: "accept", content: {goal: "Correctness"}} + + await act(async () => { + await result.current.sendToolOutput({ + toolName: "request_input", + toolCallId: "questionnaire", + ...(failed ? {errorText: "Questionnaire could not be rendered"} : {output}), + }) + }) + + expect(respondAnswer).toHaveBeenCalledWith({ + sessionId, + toolCallId: "questionnaire", + resolution: { + tool_call_id: "questionnaire", + tool_name: "request_input", + ...(failed + ? {outcome: "error", error: "Questionnaire could not be rendered"} + : {outcome: "completed", output}), + }, + }) + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }, + ) +}) diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index b6c2cc5b639..28bba5bb8bb 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -58,7 +58,7 @@ export const sessionDurableApprovalsCapabilityAtom = atom( ) /** - * Submit an approval through the response endpoint and preserve its failure for the card. + * Submit a gate answer through the response endpoint and preserve its failure for the card. * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off server dispatcher * path. Both are server-owned, so callers never also release the local AI SDK gate. */ @@ -70,10 +70,13 @@ export const respondInteractionAnswerAtom = atom( params: { sessionId: string toolCallId: string - approved: boolean - }, + } & ({approved: boolean} | {resolution: Record}), ): Promise<{durable: boolean; recoverable: boolean; executionId?: string}> => { - const {sessionId, toolCallId, approved} = params + const {sessionId, toolCallId} = params + const answer = + "resolution" in params + ? params.resolution + : {approved: params.approved, tool_call_id: toolCallId} const projectId = get(projectIdAtom) ?? "" if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") @@ -91,9 +94,12 @@ export const respondInteractionAnswerAtom = atom( const result = await respondInteraction({ interactionId: row.id, projectId, - answer: {approved, tool_call_id: toolCallId}, + answer, expectedExecutionId: row.turnId, - idempotencyKey: `approval:${row.id}:${approved ? "approve" : "deny"}`, + idempotencyKey: + "resolution" in params + ? `client-tool:${row.id}` + : `approval:${row.id}:${params.approved ? "approve" : "deny"}`, }) if (!result) throw new Error("Approval could not be submitted.") await queryClient.invalidateQueries({queryKey: rowsQueryKey}) diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts new file mode 100644 index 00000000000..67d48c96b05 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts @@ -0,0 +1,86 @@ +import {projectIdAtom} from "@agenta/shared/state" +import {QueryClient} from "@tanstack/react-query" +import {createStore} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" +import {beforeEach, expect, it, vi} from "vitest" +const {respond, transition} = vi.hoisted(() => ({respond: vi.fn(), transition: vi.fn()})) +vi.mock("../../src/session/api/api", () => ({ + respondInteraction: respond, + transitionInteraction: transition, + fetchSessionDurableApprovalsCapability: vi.fn(), + resumeSessionContinuation: vi.fn(), +})) +vi.mock("../../src/session/state/interactionStatus", async () => { + const {atom} = await import("jotai") + return { + sessionInteractionRowsQueryKey: () => ["interaction-rows"], + fetchSessionInteractionStatesAtom: atom( + null, + () => + new Map([ + [ + "questionnaire", + { + id: "interaction-id", + toolCallId: "questionnaire", + token: "token", + turnId: "queued-parent", + }, + ], + ]), + ), + } +}) +import {respondInteractionAnswerAtom} from "../../src/session/state/interactionAnswer" +beforeEach(() => { + respond + .mockReset() + .mockResolvedValue({ + accepted: true, + execution: {id: "answer-child", state: "pending_delivery"}, + }) + transition.mockReset() +}) +it("preserves questionnaire content and stable retry identity without legacy transition", async () => { + const store = createStore() + store.set(projectIdAtom, "project-id") + store.set(queryClientAtom, new QueryClient()) + const resolution = { + tool_call_id: "questionnaire", + tool_name: "request_input", + outcome: "completed", + output: {action: "accept", content: {goal: "Correctness", unchangedDefault: "yes"}}, + } + const args = {sessionId: "session-id", toolCallId: "questionnaire", resolution} + expect(await store.set(respondInteractionAnswerAtom, args)).toEqual({ + durable: true, + recoverable: false, + executionId: "answer-child", + }) + await store.set(respondInteractionAnswerAtom, args) + expect(respond).toHaveBeenNthCalledWith(1, { + projectId: "project-id", + interactionId: "interaction-id", + answer: resolution, + expectedExecutionId: "queued-parent", + idempotencyKey: "client-tool:interaction-id", + }) + expect(respond.mock.calls[1]).toEqual(respond.mock.calls[0]) + expect(transition).not.toHaveBeenCalled() +}) +it("preserves native approval answer and retry identity", async () => { + const store = createStore() + store.set(projectIdAtom, "project-id") + store.set(queryClientAtom, new QueryClient()) + await store.set(respondInteractionAnswerAtom, { + sessionId: "session-id", + toolCallId: "questionnaire", + approved: false, + }) + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ + answer: {approved: false, tool_call_id: "questionnaire"}, + idempotencyKey: "approval:interaction-id:deny", + }), + ) +}) From 0cd481fb3d38cdffd09d13ccc16e4fb1aee427e7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 14:51:55 +0200 Subject: [PATCH 115/133] fix(agents): keep rejected connection answers retryable --- .../src/clientTools/ClientToolPart.tsx | 4 +-- .../src/components/ConnectionDock.tsx | 6 ++-- .../src/clientTools/useConnectFlow.ts | 31 ++++++++++++---- .../tests/unit/useConnectFlow.test.ts | 35 ++++++++++++++++++- .../agenta-shared/src/clientTools/index.ts | 4 +-- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx index 13b6c9487b7..47c2874a373 100644 --- a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx +++ b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx @@ -53,13 +53,13 @@ const ClientToolPart = ({ const settle = useCallback( (args: {output: Record} | {errorText: string}) => { if ("errorText" in args) { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, errorText: args.errorText, }) } else { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, output: args.output, diff --git a/web/packages/agenta-chat/src/components/ConnectionDock.tsx b/web/packages/agenta-chat/src/components/ConnectionDock.tsx index 7d675fc691d..b971bbb4259 100644 --- a/web/packages/agenta-chat/src/components/ConnectionDock.tsx +++ b/web/packages/agenta-chat/src/components/ConnectionDock.tsx @@ -469,13 +469,13 @@ const ConnectBody = ({ const settle = useCallback( (args) => { if ("errorText" in args) { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, errorText: args.errorText, }) } else { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, output: args.output, @@ -537,7 +537,7 @@ const ConnectBody = ({ Connecting {name}… finish signing in from the popup window.
- ) : phase === "error" ? ( + ) : phase === "error" || errorText ? ( {errorText ?? "Connection failed."} diff --git a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts index 5123c679348..21479a6a440 100644 --- a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts +++ b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts @@ -228,12 +228,31 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a teardown() // Leave "connecting" and record the terminal result so the chip paints now. setPhase("idle") - if ("errorText" in result) { - setOutcome({connected: false, reason: result.errorText}) - settle({errorText: result.errorText}) - } else { - setOutcome({connected: result.connected === true, reason: result.reason}) - settle({output: result as Record}) + setErrorText(null) + const onSubmissionError = (error: unknown) => { + // The connection may exist, but the parked answer has not been saved. Keep + // the live action retryable instead of treating it as a settled manual retry. + settledRef.current = false + setOutcome(null) + setErrorText( + error instanceof Error + ? error.message + : "Could not save the answer. Try again.", + ) + } + try { + const submission = + "errorText" in result + ? settle({errorText: result.errorText}) + : settle({output: result as Record}) + setOutcome( + "errorText" in result + ? {connected: false, reason: result.errorText} + : {connected: result.connected === true, reason: result.reason}, + ) + void Promise.resolve(submission).catch(onSubmissionError) + } catch (error) { + onSubmissionError(error) } }, [settle, teardown], diff --git a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts index 3c1d1288484..b34164e5687 100644 --- a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts @@ -8,12 +8,20 @@ * error surfaced anywhere — see the ConnectToolWidget KNOWN_CONNECT_REASONS branch this * message feeds). */ -import {describe, expect, it} from "vitest" +import {act, createElement} from "react" +import {createRoot} from "react-dom/client" +import {describe, expect, it, vi} from "vitest" + +vi.mock("@agenta/entities/gatewayTool", () => ({ + useToolIntegrationDetail: () => ({integration: {auth_schemes: ["oauth"]}, isLoading: false}), + useToolsConnections: () => ({handleCreate: async () => ({connection: {}}), invalidate: vi.fn()}), +})) import { extractConnectErrorMessage, isConnectModeResolving, resolveConnectMode, + useConnectFlow, } from "../../src/clientTools/useConnectFlow" describe("resolveConnectMode", () => { @@ -102,3 +110,28 @@ describe("extractConnectErrorMessage", () => { expect(extractConnectErrorMessage(null)).toBe("Connection failed. Please try again.") }) }) + + +describe("durable connection answer", () => { + it("keeps a rejected parked answer retryable instead of reporting connected", async () => { + const settle = vi.fn().mockRejectedValueOnce(new Error("Answer was not saved")).mockResolvedValue(undefined) + const meta = {toolCallId: "connect-1", input: {integration: "github"}, settled: false} as Parameters[0] + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true) + const host = document.createElement("div") + const root = createRoot(host) + let flow!: ReturnType + const Probe = () => { flow = useConnectFlow(meta, settle); return null } + await act(async () => { root.render(createElement(Probe)) }) + await act(async () => { await flow.runConnect(true) }) + expect(flow.errorText).toBe("Answer was not saved") + expect(flow.outcome).toBeNull() + expect(flow.phase).toBe("idle") + await act(async () => { await flow.runConnect(true) }) + expect(flow.outcome?.connected).toBe(true) + expect(flow.errorText).toBeNull() + expect(settle).toHaveBeenCalledTimes(2) + expect(settle).toHaveBeenLastCalledWith({output: {connected: true, integration: "github", slug: "github"}}) + await act(async () => { root.unmount() }) + vi.unstubAllGlobals() + }) +}) diff --git a/web/packages/agenta-shared/src/clientTools/index.ts b/web/packages/agenta-shared/src/clientTools/index.ts index ba9c26f1b05..bb68a6124af 100644 --- a/web/packages/agenta-shared/src/clientTools/index.ts +++ b/web/packages/agenta-shared/src/clientTools/index.ts @@ -94,8 +94,8 @@ export interface ClientToolMeta { /** Settle the parked part. Mirrors OSS `SettleClientTool`: exactly one of `output`/`errorText`. */ export interface SettleClientTool { - (args: {output: Record}): void - (args: {errorText: string}): void + (args: {output: Record}): void | Promise + (args: {errorText: string}): void | Promise } /** Props every client-tool widget receives — mirrors OSS `ClientToolHandlerProps`. */ From 8a6b6efa53629d13c6a65f3f0058d8b4b39a6fa0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 15:00:42 +0200 Subject: [PATCH 116/133] style(tests): format questionnaire regressions --- .../unit/session-interaction-answer.test.ts | 10 ++--- .../tests/unit/useConnectFlow.test.ts | 42 ++++++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts index 67d48c96b05..87cece6e7ff 100644 --- a/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts @@ -33,12 +33,10 @@ vi.mock("../../src/session/state/interactionStatus", async () => { }) import {respondInteractionAnswerAtom} from "../../src/session/state/interactionAnswer" beforeEach(() => { - respond - .mockReset() - .mockResolvedValue({ - accepted: true, - execution: {id: "answer-child", state: "pending_delivery"}, - }) + respond.mockReset().mockResolvedValue({ + accepted: true, + execution: {id: "answer-child", state: "pending_delivery"}, + }) transition.mockReset() }) it("preserves questionnaire content and stable retry identity without legacy transition", async () => { diff --git a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts index b34164e5687..00261209143 100644 --- a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts @@ -14,7 +14,10 @@ import {describe, expect, it, vi} from "vitest" vi.mock("@agenta/entities/gatewayTool", () => ({ useToolIntegrationDetail: () => ({integration: {auth_schemes: ["oauth"]}, isLoading: false}), - useToolsConnections: () => ({handleCreate: async () => ({connection: {}}), invalidate: vi.fn()}), + useToolsConnections: () => ({ + handleCreate: async () => ({connection: {}}), + invalidate: vi.fn(), + }), })) import { @@ -111,27 +114,46 @@ describe("extractConnectErrorMessage", () => { }) }) - describe("durable connection answer", () => { it("keeps a rejected parked answer retryable instead of reporting connected", async () => { - const settle = vi.fn().mockRejectedValueOnce(new Error("Answer was not saved")).mockResolvedValue(undefined) - const meta = {toolCallId: "connect-1", input: {integration: "github"}, settled: false} as Parameters[0] + const settle = vi + .fn() + .mockRejectedValueOnce(new Error("Answer was not saved")) + .mockResolvedValue(undefined) + const meta = { + toolCallId: "connect-1", + input: {integration: "github"}, + settled: false, + } as Parameters[0] vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true) const host = document.createElement("div") const root = createRoot(host) let flow!: ReturnType - const Probe = () => { flow = useConnectFlow(meta, settle); return null } - await act(async () => { root.render(createElement(Probe)) }) - await act(async () => { await flow.runConnect(true) }) + const Probe = () => { + flow = useConnectFlow(meta, settle) + return null + } + await act(async () => { + root.render(createElement(Probe)) + }) + await act(async () => { + await flow.runConnect(true) + }) expect(flow.errorText).toBe("Answer was not saved") expect(flow.outcome).toBeNull() expect(flow.phase).toBe("idle") - await act(async () => { await flow.runConnect(true) }) + await act(async () => { + await flow.runConnect(true) + }) expect(flow.outcome?.connected).toBe(true) expect(flow.errorText).toBeNull() expect(settle).toHaveBeenCalledTimes(2) - expect(settle).toHaveBeenLastCalledWith({output: {connected: true, integration: "github", slug: "github"}}) - await act(async () => { root.unmount() }) + expect(settle).toHaveBeenLastCalledWith({ + output: {connected: true, integration: "github", slug: "github"}, + }) + await act(async () => { + root.unmount() + }) vi.unstubAllGlobals() }) }) From a2e2d8185cd1e3f201af30b6dd03dd86118a29f2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 15:12:05 +0200 Subject: [PATCH 117/133] fix(agents): retry saved connection answers without reconnecting --- .../src/components/ConnectionDock.tsx | 3 ++- .../src/clientTools/useConnectFlow.ts | 16 ++++++++++++---- .../tests/unit/useConnectFlow.test.ts | 10 +++++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-chat/src/components/ConnectionDock.tsx b/web/packages/agenta-chat/src/components/ConnectionDock.tsx index b971bbb4259..f7b8ccba99c 100644 --- a/web/packages/agenta-chat/src/components/ConnectionDock.tsx +++ b/web/packages/agenta-chat/src/components/ConnectionDock.tsx @@ -560,6 +560,7 @@ const ConnectBody = ({ variant="ghost" className={`text-colorTextSecondary ${touchCls}`} onClick={decline} + disabled={Boolean(errorText)} > Not now @@ -573,7 +574,7 @@ const ConnectBody = ({ } onClick={() => runConnect(true)} > - {phase === "error" ? "Retry" : "Connect"} + {phase === "error" || errorText ? "Retry" : "Connect"} )} diff --git a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts index 21479a6a440..f955aeeabfe 100644 --- a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts +++ b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts @@ -209,6 +209,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // One-shot guard so THIS instance settles the parked call at most once, plus shared cleanup for // the running popup's listener/poll/timeout. `meta.settled` covers the OTHER instance's settle. const settledRef = useRef(false) + const pendingAnswerRef = useRef(null) const activeRef = useRef(active) activeRef.current = active const popupRef = useRef(null) @@ -230,8 +231,8 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a setPhase("idle") setErrorText(null) const onSubmissionError = (error: unknown) => { - // The connection may exist, but the parked answer has not been saved. Keep - // the live action retryable instead of treating it as a settled manual retry. + // Retry the same answer without creating the connection again. + pendingAnswerRef.current = result settledRef.current = false setOutcome(null) setErrorText( @@ -250,7 +251,9 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a ? {connected: false, reason: result.errorText} : {connected: result.connected === true, reason: result.reason}, ) - void Promise.resolve(submission).catch(onSubmissionError) + void Promise.resolve(submission).then(() => { + pendingAnswerRef.current = null + }, onSubmissionError) } catch (error) { onSubmissionError(error) } @@ -281,6 +284,10 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a if (phase === "connecting") return if (settleParkedCall && (!activeRef.current || settledRef.current || meta.settled)) return + if (settleParkedCall && pendingAnswerRef.current) { + finish(pendingAnswerRef.current) + return + } // The integration-detail lookup that picks the real auth mode hasn't resolved yet — // proceeding here would send the agent's raw (possibly wrong, e.g. "oauth" for a // toolkit that only supports api_key) hint. The button is disabled for this same @@ -410,6 +417,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // Explicit cancel while the popup is open: settle the parked call as cancelled (or, when the // call is already settled — a manual retry — just stop). const cancel = useCallback(() => { + if (pendingAnswerRef.current) return teardown() if (!settledRef.current && !meta.settled) finish({connected: false, integration, slug, reason: "cancelled"}) @@ -420,7 +428,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // can respond gracefully / offer an alternative. Distinct from "cancelled" (abandoned popup) so // the agent can tell an explicit decline from a mishap. const decline = useCallback(() => { - if (settledRef.current || meta.settled) return + if (settledRef.current || meta.settled || pendingAnswerRef.current) return finish({connected: false, integration, slug, reason: "declined"}) }, [finish, integration, slug, meta.settled]) diff --git a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts index 00261209143..e739d2b28e2 100644 --- a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts @@ -12,10 +12,12 @@ import {act, createElement} from "react" import {createRoot} from "react-dom/client" import {describe, expect, it, vi} from "vitest" +const {handleCreate} = vi.hoisted(() => ({handleCreate: vi.fn(async () => ({connection: {}}))})) + vi.mock("@agenta/entities/gatewayTool", () => ({ useToolIntegrationDetail: () => ({integration: {auth_schemes: ["oauth"]}, isLoading: false}), useToolsConnections: () => ({ - handleCreate: async () => ({connection: {}}), + handleCreate, invalidate: vi.fn(), }), })) @@ -142,11 +144,17 @@ describe("durable connection answer", () => { expect(flow.errorText).toBe("Answer was not saved") expect(flow.outcome).toBeNull() expect(flow.phase).toBe("idle") + await act(async () => { + flow.decline() + flow.cancel() + }) + expect(settle).toHaveBeenCalledTimes(1) await act(async () => { await flow.runConnect(true) }) expect(flow.outcome?.connected).toBe(true) expect(flow.errorText).toBeNull() + expect(handleCreate).toHaveBeenCalledTimes(1) expect(settle).toHaveBeenCalledTimes(2) expect(settle).toHaveBeenLastCalledWith({ output: {connected: true, integration: "github", slug: "github"}, From 3a0f4f3953bb574a6dd198cf585e1db12fa39cbc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 15:36:40 +0200 Subject: [PATCH 118/133] feat(sessions): send a selected queued input next atomically --- api/oss/src/apis/fastapi/sessions/router.py | 52 +++ api/oss/src/core/sessions/commands/service.py | 169 +++++++- .../src/core/sessions/inputs/interfaces.py | 12 + api/oss/src/core/sessions/inputs/types.py | 6 + .../src/dbs/postgres/sessions/inputs/dao.py | 39 ++ .../unit/sessions/test_session_inputs_dao.py | 375 +++++++++++++++++- 6 files changed, 650 insertions(+), 3 deletions(-) diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index b4ebfcdaa36..b69f5231c6b 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -110,6 +110,7 @@ SessionInputIdempotencyConflict, SessionInputNotFound, SessionInputNotRemovable, + SessionInputRemoved, ) from oss.src.core.sessions.inputs.dtos import PendingInputState from oss.src.core.sessions.attachments.dtos import Attachment @@ -2574,6 +2575,14 @@ def __init__( include_in_schema=False, ) if inputs_service is not None: + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}/send-now", + self.send_pending_input_now, + methods=["POST"], + operation_id="send_pending_session_input_now", + response_model=PendingInputAdmissionResponse, + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/control/inputs/admit", self.admit_session_input, @@ -2583,6 +2592,49 @@ def __init__( tags=["Sessions"], ) + @intercept_exceptions() + @_handle_input_exceptions() + @_handle_command_exceptions() + async def send_pending_input_now( + self, request: Request, session_id: str, input_id: UUID + ) -> JSONResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + admission = await self._service.send_pending_input_now( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + ) + except (SessionInputNotFound, SessionInputRemoved) as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + if isinstance(error, SessionInputNotFound) + else status.HTTP_409_CONFLICT, + detail={ + "code": "pending_input_not_found" + if isinstance(error, SessionInputNotFound) + else "pending_input_removed", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + return JSONResponse( + status_code=status.HTTP_202_ACCEPTED, + content=PendingInputAdmissionResponse(**admission.model_dump()).model_dump( + mode="json", exclude_none=True + ), + ) + @intercept_exceptions() @_handle_input_exceptions() async def admit_session_input( diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 0fb44bbf389..ce0854fbc51 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -66,6 +66,12 @@ ) from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.dtos import PendingInputAdmission, PendingInputState +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputNotFound, + SessionInputRemoved, +) from oss.src.core.sessions.streams.dtos import ( SessionStreamCommandRequest, SessionStreamCommandResponse, @@ -175,6 +181,163 @@ def __init__( # -- admission ---------------------------------------------------------- # + async def send_pending_input_now( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + input_id: UUID, + ) -> PendingInputAdmission: + if not validate_session_id(session_id): + raise SessionIdInvalid(session_id) + if not (env.agenta.sessions.queue and env.agenta.sessions.steer): + raise SessionInputBusy() + if self._inputs is None or self._executions is None: + raise SessionInputBusy() + received_at = datetime.now(timezone.utc) + target_id, _ = await self._resolve_target( + project_id=project_id, session_id=session_id, expected_turn_id=None + ) + if target_id is None: + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + target_id = stream.turn_id if stream else None + if target_id is None: + raise SessionInputBusy() + + continuation = None + cancelled_interactions = 0 + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + transaction=transaction, + ) + if execution.terminal_outcome is not None: + successor = await self._executions.lock_active_continuation( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + if successor is not None: + execution = successor + target_id = successor.execution_id + item = await self._inputs.prioritize_pending( + project_id=project_id, + session_id=session_id, + input_id=input_id, + user_id=user_id, + transaction=transaction, + ) + if item is None: + raise SessionInputNotFound(str(input_id)) + if item.state == PendingInputState.removed: + raise SessionInputRemoved(str(input_id)) + if item.state == PendingInputState.promoted: + return PendingInputAdmission( + action="pending", + input=item, + execution_id=item.promoted_execution_id, + ) + + if execution.terminal_outcome is not None: + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_id, + transaction=transaction, + publish=False, + ) + ) + continuation = await self._promote_next_input( + project_id=project_id, + session_id=session_id, + parent_execution_id=target_id, + input_id=input_id, + transaction=transaction, + ) + assert continuation is not None, "Locked pending input was not promoted" + command = continuation.command + item = item.model_copy( + update={ + "state": PendingInputState.promoted, + "promoted_execution_id": continuation.execution_id, + } + ) + else: + command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_id, + transaction=transaction, + ) + if command is None: + await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + state=SessionExecutionState.stopping, + transaction=transaction, + ) + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_id, + expected_turn_id=target_id, + created_at=received_at, + idempotency_key=f"send-now:{input_id}", + data={"steer_input_id": str(input_id)}, + ), + stopping_turn_id=target_id, + transaction=transaction, + ) + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=command.id, + input_id=input_id, + transaction=transaction, + ) + if command is None: + raise SessionInputBusy(current_execution_id=target_id) + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_id, + transaction=transaction, + publish=False, + ) + ) + if cancelled_interactions: + await self._interactions.publish_session_pending_cancelled( + project_id=project_id, + session_id=session_id, + ) + if continuation is not None: + await self._reconcile_stopped_redis( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + ) + receipt = await self._deliver(command) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(continuation, receipt) + elif command.state == SessionCommandState.pending: + await self._deliver(command) + return PendingInputAdmission( + action="pending", + input=item, + execution_id=continuation.execution_id if continuation else target_id, + ) + async def request_cancel_legacy( self, *, @@ -1863,7 +2026,11 @@ async def settle( steer_input_id = (settled.data or {}).get("steer_input_id") if ( result.won - and outcome == SessionCommandOutcome.stopped + and outcome + in ( + SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, + ) and env.agenta.sessions.queue and env.agenta.sessions.steer and isinstance(steer_input_id, str) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index ca544249007..e4a7128d5a1 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -69,6 +69,18 @@ async def remove_pending( ) -> Optional[PendingInput]: pass + @abstractmethod + async def prioritize_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + transaction: Any, + ) -> Optional[PendingInput]: + pass + @abstractmethod async def promote_next( self, diff --git a/api/oss/src/core/sessions/inputs/types.py b/api/oss/src/core/sessions/inputs/types.py index 2f54957fa27..1f23e82b8ee 100644 --- a/api/oss/src/core/sessions/inputs/types.py +++ b/api/oss/src/core/sessions/inputs/types.py @@ -26,3 +26,9 @@ def __init__(self, input_id: str): class SessionInputIdempotencyConflict(SessionInputError): def __init__(self): super().__init__("This idempotency key was already used for a different input.") + + +class SessionInputRemoved(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The queued input was removed and cannot be sent.") diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index 78004ee6f2e..e06029edf04 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -234,6 +234,45 @@ async def remove_pending( ).scalar_one_or_none() return to_pending_input(row) if row else None + async def prioritize_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + transaction: Any, + ) -> Optional[PendingInput]: + await self._lock_session(transaction, project_id, session_id) + row = ( + await transaction.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + if row is None: + return None + if row.state == "pending" and row.policy != "steer": + minimum = ( + await transaction.execute( + select(func.min(SessionInputDBE.position)).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + ) + ) + ).scalar_one() + row.position = minimum - 1 + row.policy = "steer" + row.updated_at = datetime.now(timezone.utc) + row.updated_by_id = user_id + await transaction.flush() + return to_pending_input(row) + async def promote_next( self, *, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 72600404162..82e6c124df4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -3,7 +3,7 @@ import asyncio import json from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock import uuid from fastapi import HTTPException @@ -26,7 +26,11 @@ from oss.src.core.sessions.executions.dtos import SessionExecutionState from oss.src.core.sessions.inputs.dtos import PendingInputCreate, PendingInputState from oss.src.core.sessions.inputs.service import SessionInputsService, input_fingerprint -from oss.src.core.sessions.inputs.types import SessionInputNotRemovable +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputNotRemovable, + SessionInputRemoved, +) from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO @@ -916,3 +920,370 @@ async def test_admission_follows_approval_continuation_before_stream_header_catc assert admission.action == "pending" assert admission.execution_id == "approved-child" assert len(await inputs.list_pending(**scope)) == 1 + + +@pytest.mark.parametrize("idle", [False, True]) +async def test_send_now_preserves_selected_row_and_remaining_order( + input_scope, monkeypatch, idle +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + service._reconcile_stopped_redis = AsyncMock() + rows = [] + for index in range(3): + values = _input(input_scope, key=f"send-now-{index}", message=str(index)) + values.content["attachments"] = [{"file_id": f"file-{index}"}] + rows.append( + await inputs.create_input( + user_id=input_scope["user_id"], pending_input=values + ) + ) + if idle: + await executions.settle( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + terminal_outcome="completed", + settled_by="runner", + ) + args = dict( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=rows[2].id, + ) + first, second = await asyncio.gather( + service.send_pending_input_now(**args), service.send_pending_input_now(**args) + ) + assert first.input.id == second.input.id == rows[2].id + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=rows[2].id, + ) + assert stored.content == rows[2].content + assert stored.idempotency_key == rows[2].idempotency_key + assert stored.request_fingerprint == rows[2].request_fingerprint + remaining = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [item.id for item in remaining if item.id != rows[2].id] == [ + rows[0].id, + rows[1].id, + ] + assert [item.position for item in remaining if item.id != rows[2].id] == [ + rows[0].position, + rows[1].position, + ] + async with input_scope["engine"].session() as transaction: + commands = ( + ( + await transaction.execute( + text( + "SELECT kind, data FROM session_commands WHERE project_id=:project_id" + ), + {"project_id": input_scope["project_id"]}, + ) + ) + .mappings() + .all() + ) + assert len(commands) == 1 + assert commands[0]["kind"] == ("continue_input" if idle else "cancel") + if idle: + assert stored.state == PendingInputState.promoted + assert commands[0]["data"]["input_id"] == str(rows[2].id) + assert ( + commands[0]["data"]["request"]["attachments"] + == rows[2].content["attachments"] + ) + else: + assert stored.state == PendingInputState.pending + assert remaining[0].id == rows[2].id + assert commands[0]["data"]["steer_input_id"] == str(rows[2].id) + + +async def test_send_now_does_not_resurrect_removed_input(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="removed-send-now", message="removed"), + ) + await inputs.remove_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + with pytest.raises(SessionInputRemoved, match="removed"): + await service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + async with input_scope["engine"].session() as transaction: + count = ( + await transaction.execute( + text( + "SELECT count(*) FROM session_commands WHERE project_id=:project_id" + ), + {"project_id": input_scope["project_id"]}, + ) + ).scalar_one() + assert count == 0 + + +async def test_send_now_stop_promotes_selected_once_and_holds_other_rows( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + rows = [ + await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key=f"selected-{i}", message=str(i)), + ) + for i in range(3) + ] + args = dict( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=rows[1].id, + ) + await service.send_pending_input_now(**args) + commands = SessionCommandsDAO(engine=input_scope["engine"]) + cancel = await commands.fetch_by_idempotency_key( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + idempotency_key=f"send-now:{rows[1].id}", + ) + settlement = _settlement_service(input_scope, inputs) + await settlement.settle( + command_id=cancel.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + retry = await service.send_pending_input_now(**args) + assert retry.input.state == PendingInputState.promoted + async with input_scope["engine"].session() as transaction: + continuations = ( + ( + await transaction.execute( + text( + "SELECT data FROM session_commands WHERE project_id=:project_id AND kind='continue_input'" + ), + {"project_id": input_scope["project_id"]}, + ) + ) + .scalars() + .all() + ) + assert len(continuations) == 1 + assert continuations[0]["input_id"] == str(rows[1].id) + pending = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [row.id for row in pending if row.state == PendingInputState.pending] == [ + rows[0].id, + rows[2].id, + ] + + +async def test_competing_send_now_keeps_losing_row_unchanged(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + rows = [ + await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key=f"compete-{i}", message=str(i)), + ) + for i in range(2) + ] + outcomes = await asyncio.gather( + *( + service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + for row in rows + ), + return_exceptions=True, + ) + assert sum(isinstance(outcome, SessionInputBusy) for outcome in outcomes) == 1 + loser = rows[ + next( + i + for i, outcome in enumerate(outcomes) + if isinstance(outcome, SessionInputBusy) + ) + ] + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=loser.id, + ) + assert stored.policy == "queue" + assert stored.position == loser.position + assert stored.content == loser.content + + +async def test_send_now_route_rejects_cross_session_and_removed_rows( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="route-selected", message="original"), + ) + router = SessionControlRouter( + commands_service=service, inputs_service=SimpleNamespace() + ) + request = SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ) + ) + with pytest.raises(HTTPException) as missing: + await router.send_pending_input_now(request, "another-session", row.id) + assert missing.value.status_code == 404 + await inputs.remove_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + with pytest.raises(HTTPException) as removed: + await router.send_pending_input_now(request, input_scope["session_id"], row.id) + assert removed.value.status_code == 409 + assert removed.value.detail["code"] == "pending_input_removed" + + +async def test_send_now_parked_input_continuation_advances_when_runner_not_held( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, "get_running_owner", AsyncMock(return_value=None) + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + async with input_scope["engine"].session() as transaction: + await executions.create_continuation( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="parked-input-child", + parent_execution_id="source-turn", + source_interaction_id=None, + transaction=transaction, + ) + await executions.set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="parked-input-child", + state=SessionExecutionState.running, + transaction=transaction, + ) + first = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="held-first", message="later"), + ) + selected = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="held-selected", message="now"), + ) + service = _settlement_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=(None, None)) + service._streams.fetch_header = AsyncMock( + return_value=SimpleNamespace( + turn_id="parked-input-child", flags=SimpleNamespace(is_running=False) + ) + ) + service._interactions.cancel_session_pending.return_value = 1 + + class ParkedDelivery(_UnreachableDelivery): + async def deliver(self, *, command): + return DeliveryReceipt( + status="not_held" + if command.kind == SessionCommandKind.cancel + else "unreachable" + ) + + service._delivery = ParkedDelivery() + await service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=selected.id, + ) + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=selected.id, + ) + assert stored.state == PendingInputState.promoted + pending = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [row.id for row in pending if row.state == PendingInputState.pending] == [ + first.id + ] + service._interactions.cancel_session_pending.assert_any_await( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + only_turn_id="parked-input-child", + transaction=ANY, + publish=False, + ) + async with input_scope["engine"].session() as transaction: + count = ( + await transaction.execute( + text( + "SELECT count(*) FROM session_commands WHERE project_id=:project_id AND kind='continue_input'" + ), + {"project_id": input_scope["project_id"]}, + ) + ).scalar_one() + assert count == 1 From 68ccffa03b9b62e9ccbd7d78b6588401513ff391 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 15:50:54 +0200 Subject: [PATCH 119/133] feat(client): generate selected queued input Send Now endpoint --- .../api/resources/sessions/client/Client.ts | 78 +++++++++++++++++++ .../SendPendingSessionInputNowRequest.ts | 13 ++++ .../sessions/client/requests/index.ts | 1 + .../types/PendingInputAdmissionResponse.ts | 17 ++++ .../src/generated/api/types/index.ts | 1 + 5 files changed, 110 insertions(+) create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 5c9723b6a59..1cce2ad1799 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2802,4 +2802,82 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); } + + /** + * @param {AgentaApi.SendPendingSessionInputNowRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.sendPendingSessionInputNow({ + * session_id: "session_id", + * input_id: "input_id" + * }) + */ + public sendPendingSessionInputNow( + request: AgentaApi.SendPendingSessionInputNowRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__sendPendingSessionInputNow(request, requestOptions)); + } + + private async __sendPendingSessionInputNow( + request: AgentaApi.SendPendingSessionInputNowRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}/inputs/${core.url.encodePathParam(inputId)}/send-now`, + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: _response.body as AgentaApi.PendingInputAdmissionResponse, + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/sessions/{session_id}/inputs/{input_id}/send-now", + ); + } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts new file mode 100644 index 00000000000..7a3ae27efca --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id", + * input_id: "input_id" + * } + */ +export interface SendPendingSessionInputNowRequest { + session_id: string; + input_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 432d82e7393..3baf4850e75 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -36,3 +36,4 @@ export type { SignSessionMountCredentialsRequest } from "./SignSessionMountCrede export type { UnarchiveSessionRequest } from "./UnarchiveSessionRequest.js"; export type { WatchProjectRequest } from "./WatchProjectRequest.js"; export type { WatchSessionStreamRequest } from "./WatchSessionStreamRequest.js"; +export type { SendPendingSessionInputNowRequest } from "./SendPendingSessionInputNowRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts new file mode 100644 index 00000000000..346dde22fc5 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts @@ -0,0 +1,17 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInputAdmissionResponse { + action: PendingInputAdmissionResponse.Action; + input?: (AgentaApi.PendingInput | null) | undefined; + execution_id?: (string | null) | undefined; +} + +export namespace PendingInputAdmissionResponse { + export const Action = { + Execute: "execute", + Pending: "pending", + } as const; + export type Action = (typeof Action)[keyof typeof Action]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 90cc78d924b..9eca31cdcfc 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -717,3 +717,4 @@ export * from "./Workspace.js"; export * from "./WorkspaceMemberResponse.js"; export * from "./WorkspacePermission.js"; export * from "./WorkspaceResponse.js"; +export * from "./PendingInputAdmissionResponse.js"; From 9367cc2f010a5eb50ebd02419a635e417d42ba62 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 15:59:35 +0200 Subject: [PATCH 120/133] feat(frontend): send queued messages from their existing row --- web/mobile/src/features/chat/Composer.tsx | 17 -------- .../src/features/chat/LiveConversation.tsx | 1 + .../components/AgentComposerDock.tsx | 14 +------ .../components/QueuedMessagesDock.tsx | 3 ++ .../src/components/ChatComposer.tsx | 2 +- .../src/components/QueuedMessagesDock.tsx | 37 +++++++++++++++++- .../src/hooks/useAgentChatQueue.ts | 3 ++ .../src/hooks/useAgentConversation.ts | 3 ++ .../src/hooks/useServerSessionInputs.ts | 17 ++++++++ .../unit/ChatComposer.runControls.test.tsx | 32 +++++++++++++++ .../unit/hooks/useServerSessionInputs.test.ts | 36 ++++++++++++++++- .../agenta-entities/src/session/api/api.ts | 17 ++++++++ .../agenta-entities/src/session/index.ts | 2 + .../src/session/state/pendingInputs.ts | 15 ++++++- .../src/RichChatInput/plugins/SendButton.tsx | 11 ++++-- .../richChatInputBusySend.render.test.tsx | 39 +++++++++++++++++++ .../QueuedMessagesDock.stories.tsx | 16 +++++++- 17 files changed, 226 insertions(+), 39 deletions(-) create mode 100644 web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 7a8ee10ea40..43247ff12d6 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -38,7 +38,6 @@ export const Composer = ({ stopping = false, onStop, queueEnabled = false, - steerEnabled = false, inputBusy = streaming, inputRef, placeholder, @@ -209,22 +208,6 @@ export const Composer = ({ streaming={stoppable} stopping={stopping} onStop={onStop} - busyActions={ - inputBusy && queueEnabled - ? [ - {label: "Queue", onSubmit: submit}, - ...(steerEnabled && onSteer - ? [ - { - label: "Steer", - onSubmit: (text: string) => - submit(text, [], "steer"), - }, - ] - : []), - ] - : undefined - } showQueuePauseCopy={inputBusy && queueEnabled} extraPrefix={ void + sendQueuedNow?: (id: string) => Promise editingId: string | null beginEdit: (id: string, draft?: string) => void cancelEdit: () => string @@ -323,6 +322,7 @@ const AgentComposerDock = ({ queued={queue.queued} held={hitlPending} onRemove={queue.removeQueued} + onSendNow={queue.sendQueuedNow} onEdit={editQueued} onCancelEdit={cancelQueuedEdit} editingId={queue.editingId} @@ -477,16 +477,6 @@ const AgentComposerDock = ({ stopping={stopping} onStop={onStop} stopShortcutEnabled={stopShortcutEnabled} - busyActions={ - inputBusy && queueEnabled - ? [ - {label: "Queue", onSubmit: submitMessage}, - ...(steerEnabled - ? [{label: "Steer", onSubmit: onSteer}] - : []), - ] - : undefined - } showQueuePauseCopy={inputBusy && queueEnabled} attachments={attachments} attachmentsBlocked={attachmentsBlocked} diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx index 8e1c0cc9a0e..0c2ff15a608 100644 --- a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx @@ -18,6 +18,7 @@ interface AgentQueuedMessagesDockProps { /** The run is parked on the user, so the queue is held rather than merely waiting. */ held: boolean onRemove: (id: string) => void + onSendNow?: (id: string) => Promise onEdit: (message: QueuedMessage) => void onCancelEdit: () => void editingId: string | null @@ -28,6 +29,7 @@ const AgentQueuedMessagesDock = ({ queued, held, onRemove, + onSendNow, onEdit, onCancelEdit, editingId, @@ -46,6 +48,7 @@ const AgentQueuedMessagesDock = ({ queued={shownRef.current} held={held} onRemove={onRemove} + onSendNow={onSendNow} onEdit={onEdit} onCancelEdit={onCancelEdit} editingId={editingId} diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index 6884b530395..7ee84e4e62e 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -186,7 +186,7 @@ export const ChatComposer = ({ onPasteFile={(pasted) => { if (!attachmentsBlocked?.()) addFiles(Array.from(pasted)) }} - sendForceEnabled={files.length > 0 && attachmentsSettled} + sendForceEnabled={files.length > 0} sendDisabled={files.length > 0 && !attachmentsSettled} sendDisabledReason={uploadBlockReason} streaming={streaming} diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index 149aaffc149..be6645bf481 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -94,6 +94,7 @@ const Row = ({ onEdit, onCancelEdit, onRemove, + onSendNow, }: { message: QueuedMessage editing: boolean @@ -101,13 +102,16 @@ const Row = ({ onEdit?: (message: QueuedMessage) => void onCancelEdit?: () => void onRemove: (id: string) => void + onSendNow?: (id: string) => Promise }) => { + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) const text = message.text.trim() const files = message.fileParts ?? [] const attachmentCount = Math.max(files.length, message.attachmentCount ?? 0) return (
@@ -135,11 +139,32 @@ const Row = ({ edit — an action you can only reach with a pointer is not an action on mobile. */} + {onSendNow && message.source === "server" ? ( + + ) : null} {editing ? ( + {error ? ( + + {error} + + ) : null}
) } @@ -180,6 +210,7 @@ export interface QueuedMessagesDockProps { /** The run is parked on the user (HITL), so the queue is held rather than merely waiting. */ held?: boolean onRemove: (id: string) => void + onSendNow?: (id: string) => Promise /** Hand a row's content to the host's composer. Omit on surfaces without an editable input. */ onEdit?: (message: QueuedMessage) => void /** Abandon the edit; the host puts the stashed draft back. */ @@ -195,6 +226,7 @@ const QueuedMessagesDock = ({ queued, held = false, onRemove, + onSendNow, onEdit, onCancelEdit, editingId = null, @@ -263,6 +295,7 @@ const QueuedMessagesDock = ({ onEdit={onEdit} onCancelEdit={onCancelEdit} onRemove={onRemove} + onSendNow={onSendNow} /> ))} diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 6ec0cf5037f..b07e17f2fb1 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -31,6 +31,7 @@ export interface ServerQueueAdapter { queued: QueuedMessage[] submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise remove: (id: string) => Promise + sendNow?: (id: string) => Promise } interface UseAgentChatQueueArgs { @@ -419,6 +420,8 @@ export const useAgentChatQueue = ({ submit, steer, removeQueued, + sendQueuedNow: + server?.capabilities.queue && server.capabilities.steer ? server.sendNow : undefined, /** This tab received the durable respond body for this still-running execution. */ ownsContinuation, queueEnabled: !!server?.capabilities.queue, diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 5d1659134d8..ac520835d05 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -199,6 +199,7 @@ export interface AgentConversation { * connect). Typed messages queue rather than send while this holds. */ hitlPending: boolean removeQueued: (id: string) => void + sendQueuedNow?: (id: string) => Promise /** Id of the held message the composer is editing, or null. */ editingId: string | null /** Borrow the composer for `id`, stashing the draft it currently holds. */ @@ -712,6 +713,7 @@ export const useAgentConversation = ({ submit, steer, removeQueued, + sendQueuedNow, ownsContinuation, queueEnabled, steerEnabled, @@ -1271,6 +1273,7 @@ export const useAgentConversation = ({ steer: steerInput, hitlPending, removeQueued, + sendQueuedNow, editingId, beginEdit, cancelEdit, diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index ba1c9ecedb0..048137e37f7 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -4,6 +4,7 @@ import { fetchSessionCapabilitiesAtom, fetchSessionSnapshotAtom, removePendingSessionInputAtom, + sendPendingSessionInputNowAtom, } from "@agenta/entities/session" import {buildAgentRequest} from "@agenta/playground/agent-chat" import type {UIMessage} from "ai" @@ -20,6 +21,7 @@ export interface ServerSessionInputs { queued: QueuedMessage[] submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise remove: (id: string) => Promise + sendNow: (id: string) => Promise refresh: () => Promise } @@ -44,6 +46,7 @@ export const useServerSessionInputs = ({ const fetchSnapshot = useSetAtom(fetchSessionSnapshotAtom) const fetchCapabilities = useSetAtom(fetchSessionCapabilitiesAtom) const removeInput = useSetAtom(removePendingSessionInputAtom) + const sendInputNow = useSetAtom(sendPendingSessionInputNowAtom) const [viewState, setViewState] = useState<{sessionId: string; view: SessionPendingInputView}>( () => ({sessionId, view: emptyView}), ) @@ -169,6 +172,19 @@ export const useServerSessionInputs = ({ [refresh, removeInput, sessionId], ) + const sendNow = useCallback( + async (id: string) => { + if (!view.capabilities.queue || !view.capabilities.steer) { + throw new Error("Send Now is not available for this session.") + } + if (!(await sendInputNow({sessionId, inputId: id}))) { + throw new Error("The queued message could not be sent. Try again.") + } + await refresh() + }, + [refresh, sendInputNow, sessionId, view.capabilities.queue, view.capabilities.steer], + ) + return { capabilities: view.capabilities, executionState: view.executionState, @@ -176,6 +192,7 @@ export const useServerSessionInputs = ({ queued: view.queued, submit, remove, + sendNow, refresh, } } diff --git a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx index f127696ffcc..8b002b0991f 100644 --- a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx @@ -129,3 +129,35 @@ describe("ChatComposer running controls", () => { expect(onStop).not.toHaveBeenCalled() }) }) + +describe("queued row Send Now", () => { + it("targets the chosen row and retains every row when admission fails", async () => { + const sendNow = vi.fn().mockRejectedValue(new Error("unavailable")) + const remove = vi.fn() + render( + , + ) + fireEvent.click(screen.getAllByRole("button", {name: "Send Now"})[1]) + expect(await screen.findByRole("alert")).toBeTruthy() + expect(sendNow).toHaveBeenCalledWith("selected") + expect(remove).not.toHaveBeenCalled() + expect(screen.getByText("older message")).toBeTruthy() + expect(screen.getByText("chosen message")).toBeTruthy() + sendNow.mockResolvedValueOnce(undefined) + fireEvent.click(screen.getAllByRole("button", {name: "Send Now"})[1]) + expect(sendNow).toHaveBeenCalledTimes(2) + }) + + it("does not offer the server action on a local fallback row", () => { + render() + expect(screen.queryByRole("button", {name: "Send Now"})).toBeNull() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 5430206c6c3..d8fa309aada 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -14,11 +14,12 @@ import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" -const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput} = vi.hoisted(() => ({ +const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput, sendInputNow} = vi.hoisted(() => ({ buildAgentRequest: vi.fn(), fetchCapabilities: vi.fn(), fetchSnapshot: vi.fn(), removeInput: vi.fn(), + sendInputNow: vi.fn(), })) vi.mock("@agenta/entities/session", async () => { @@ -30,6 +31,7 @@ vi.mock("@agenta/entities/session", async () => { fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => fetchSnapshot(sessionId), ), + sendPendingSessionInputNowAtom: atom(null, (_get, _set, params: {sessionId: string; inputId: string}) => sendInputNow(params)), removePendingSessionInputAtom: atom( null, (_get, _set, params: {sessionId: string; inputId: string}) => removeInput(params), @@ -71,6 +73,7 @@ beforeEach(() => { fetchCapabilities.mockResolvedValue({durableApprovals: true, queue: true, steer: true}) fetchSnapshot.mockReset() removeInput.mockReset() + sendInputNow.mockReset() fetchMock.mockReset() }) @@ -577,3 +580,34 @@ describe("useServerSessionInputs", () => { closeFreshResponse() }) }) + + +describe("selected queued input Send Now", () => { + it("uses the selected row identity without invoking or removing its content", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + sendInputNow.mockResolvedValue(true) + const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + await act(() => result.current.sendNow("selected-row")) + expect(sendInputNow).toHaveBeenCalledWith({sessionId: "session-1", inputId: "selected-row"}) + expect(removeInput).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(fetchSnapshot.mock.calls.length).toBeGreaterThan(1) + }) + + it("surfaces an admission failure without removing the pending input", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + sendInputNow.mockResolvedValue(false) + const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + await expect(result.current.sendNow("selected-row")).rejects.toThrow("could not be sent") + expect(removeInput).not.toHaveBeenCalled() + }) + + it("does not call the action when the server capability is disabled", async () => { + fetchCapabilities.mockResolvedValue({durableApprovals: false, queue: false, steer: false}) + const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + await expect(result.current.sendNow("selected-row")).rejects.toThrow("not available") + expect(sendInputNow).not.toHaveBeenCalled() + }) +}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 9bd1caf1cef..6c12e799a26 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -195,6 +195,23 @@ export async function removePendingSessionInput({ return !!data } +export async function sendPendingSessionInputNow({ + sessionId, + projectId, + appId, + abortSignal, + inputId, +}: SessionScopedParams & {inputId: string}): Promise { + if (!projectId || !sessionId || !inputId) return false + const data = await callFern("[sendPendingSessionInputNow]", () => + getSessionsClient().sendPendingSessionInputNow( + {session_id: sessionId, input_id: inputId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + return !!data +} + const SESSION_CAPABILITY_TIMEOUT_SECONDS = 2 const SESSION_CAPABILITY_NEGATIVE_RETRY_MS = 30_000 diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index af9fb12b68b..b04a91cfdee 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -22,6 +22,7 @@ export { fetchSessionCapabilities, fetchSessionDurableApprovalsCapability, removePendingSessionInput, + sendPendingSessionInputNow, invalidateSessionDurableApprovalsCapability, commandSessionStream, cancelSessionExecution, @@ -108,6 +109,7 @@ export { fetchSessionCapabilitiesAtom, fetchSessionSnapshotAtom, removePendingSessionInputAtom, + sendPendingSessionInputNowAtom, } from "./state/pendingInputs" export { deriveStreamNest, diff --git a/web/packages/agenta-entities/src/session/state/pendingInputs.ts b/web/packages/agenta-entities/src/session/state/pendingInputs.ts index 93277439ffb..d9ce53c7273 100644 --- a/web/packages/agenta-entities/src/session/state/pendingInputs.ts +++ b/web/packages/agenta-entities/src/session/state/pendingInputs.ts @@ -1,7 +1,12 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" -import {fetchSessionCapabilities, fetchSessionSnapshot, removePendingSessionInput} from "../api/api" +import { + fetchSessionCapabilities, + fetchSessionSnapshot, + removePendingSessionInput, + sendPendingSessionInputNow, +} from "../api/api" export const fetchSessionCapabilitiesAtom = atom(null, async (get, _set, sessionId: string) => { const projectId = get(projectIdAtom) ?? "" @@ -20,3 +25,11 @@ export const removePendingSessionInputAtom = atom( return removePendingSessionInput({projectId, ...params}) }, ) + +export const sendPendingSessionInputNowAtom = atom( + null, + async (get, _set, params: {sessionId: string; inputId: string}) => { + const projectId = get(projectIdAtom) ?? "" + return sendPendingSessionInputNow({projectId, ...params}) + }, +) diff --git a/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx b/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx index f7a61509675..1591ded8540 100644 --- a/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx +++ b/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx @@ -16,7 +16,7 @@ interface SendButtonProps { disabled?: boolean /** Tooltip shown when a caller blocks submit. */ disabledReason?: ReactNode - /** When true, the button becomes a Stop button for the in-flight stream. */ + /** Keep Stop accessible for the in-flight stream, beside Send when a draft exists. */ streaming?: boolean stopping?: boolean /** Request a durable stop — required for the `streaming` state. */ @@ -26,8 +26,7 @@ interface SendButtonProps { } /** Circular send button. Mirrors the Cmd/Ctrl+Enter path via the shared submit helper. - * While a stream is in flight it morphs into a Stop button (single affordance, no extra - * stop control alongside it). */ + * While a stream is in flight, an empty composer shows Stop; a draft shows Send beside Stop. */ export function SendButton({ onSubmit, forceEnabled, @@ -48,6 +47,7 @@ export function SendButton({ }, [editor]) const handleClick = () => { + if (disabled) return if (empty) { if (forceEnabled) onSubmit("") return @@ -67,7 +67,7 @@ export function SendButton({ key={action.label} size="sm" variant="ghost" - disabled={empty && !forceEnabled} + disabled={disabled || (empty && !forceEnabled)} onClick={() => { if (empty) { if (forceEnabled) action.onSubmit("") @@ -110,6 +110,9 @@ export function SendButton({ ) : null} + {!empty || forceEnabled ? ( + + ) : null} ) } diff --git a/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx new file mode 100644 index 00000000000..fc718a8454b --- /dev/null +++ b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx @@ -0,0 +1,39 @@ +/** @vitest-environment jsdom */ +import {createRef} from "react" + +import {act, cleanup, fireEvent, render, screen} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {RichChatInput, type RichChatInputHandle} from "../../src/RichChatInput" + +afterEach(cleanup) + +describe("busy composer standard Send", () => { + it("keeps Stop when empty and sends a draft through the normal callback", async () => { + const onSubmit = vi.fn() + const onStop = vi.fn() + const ref = createRef() + render() + expect(screen.getByRole("button", {name: "Stop"})).toBeTruthy() + expect(screen.queryByRole("button", {name: "Send"})).toBeNull() + await act(async () => ref.current?.setMarkdown("next message")) + fireEvent.click(screen.getByRole("button", {name: "Send"})) + expect(onSubmit).toHaveBeenCalledWith("next message") + expect(screen.queryByRole("button", {name: "Queue"})).toBeNull() + expect(screen.queryByRole("button", {name: "Steer"})).toBeNull() + fireEvent.click(screen.getByRole("button", {name: "Stop"})) + expect(onStop).toHaveBeenCalledOnce() + }) + + it("shows standard Send for attachment-only drafts and respects upload blocking", () => { + const onSubmit = vi.fn() + const view = render() + const send = screen.getByRole("button", {name: "Send"}) as HTMLButtonElement + expect(send.disabled).toBe(true) + fireEvent.click(send) + expect(onSubmit).not.toHaveBeenCalled() + view.rerender() + fireEvent.click(screen.getByRole("button", {name: "Send"})) + expect(onSubmit).toHaveBeenCalledWith("") + }) +}) diff --git a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx index c42910e851c..cde60e8d314 100644 --- a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx +++ b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx @@ -76,10 +76,12 @@ export const Held: Story = { render: () => , } -/** Durable rows are shared across browsers: removable, not locally editable, with Steer marked. */ +/** Durable rows are shared across browsers, with a selected-row Send Now action. */ export const ServerBacked: Story = { render: () => ( {}} + touch editable initial={[ {...THREE[0], source: "server", editable: false, policy: "steer"}, @@ -154,3 +156,15 @@ export const WithAttachments: Story = { export const Touch: Story = { render: () => , } + +export const SendNowFailure: Story = { + render: () => ( + { + throw new Error("Unavailable") + }} + /> + ), +} From 5e58370d85f958026a67f9abf9805f5ca794758e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 16:04:21 +0200 Subject: [PATCH 121/133] style: format Send Now regression tests --- .../unit/ChatComposer.runControls.test.tsx | 8 ++- .../unit/hooks/useServerSessionInputs.test.ts | 49 ++++++++++++++----- .../richChatInputBusySend.render.test.tsx | 14 +++++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx index 8b002b0991f..f765d80d7e0 100644 --- a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx @@ -157,7 +157,13 @@ describe("queued row Send Now", () => { }) it("does not offer the server action on a local fallback row", () => { - render() + render( + , + ) expect(screen.queryByRole("button", {name: "Send Now"})).toBeNull() }) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index d8fa309aada..9ad2bef4188 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -14,13 +14,15 @@ import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" -const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput, sendInputNow} = vi.hoisted(() => ({ - buildAgentRequest: vi.fn(), - fetchCapabilities: vi.fn(), - fetchSnapshot: vi.fn(), - removeInput: vi.fn(), - sendInputNow: vi.fn(), -})) +const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput, sendInputNow} = vi.hoisted( + () => ({ + buildAgentRequest: vi.fn(), + fetchCapabilities: vi.fn(), + fetchSnapshot: vi.fn(), + removeInput: vi.fn(), + sendInputNow: vi.fn(), + }), +) vi.mock("@agenta/entities/session", async () => { const {atom} = await import("jotai") @@ -31,7 +33,10 @@ vi.mock("@agenta/entities/session", async () => { fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => fetchSnapshot(sessionId), ), - sendPendingSessionInputNowAtom: atom(null, (_get, _set, params: {sessionId: string; inputId: string}) => sendInputNow(params)), + sendPendingSessionInputNowAtom: atom( + null, + (_get, _set, params: {sessionId: string; inputId: string}) => sendInputNow(params), + ), removePendingSessionInputAtom: atom( null, (_get, _set, params: {sessionId: string; inputId: string}) => removeInput(params), @@ -581,12 +586,18 @@ describe("useServerSessionInputs", () => { }) }) - describe("selected queued input Send Now", () => { it("uses the selected row identity without invoking or removing its content", async () => { fetchSnapshot.mockResolvedValue(runningSnapshot()) sendInputNow.mockResolvedValue(true) - const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) await act(() => result.current.sendNow("selected-row")) expect(sendInputNow).toHaveBeenCalledWith({sessionId: "session-1", inputId: "selected-row"}) @@ -598,7 +609,14 @@ describe("selected queued input Send Now", () => { it("surfaces an admission failure without removing the pending input", async () => { fetchSnapshot.mockResolvedValue(runningSnapshot()) sendInputNow.mockResolvedValue(false) - const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) await expect(result.current.sendNow("selected-row")).rejects.toThrow("could not be sent") expect(removeInput).not.toHaveBeenCalled() @@ -606,7 +624,14 @@ describe("selected queued input Send Now", () => { it("does not call the action when the server capability is disabled", async () => { fetchCapabilities.mockResolvedValue({durableApprovals: false, queue: false, steer: false}) - const {result} = renderHook(() => useServerSessionInputs({entityId: "revision-1", sessionId: "session-1", messages: [], locallyBusy: true})) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) await expect(result.current.sendNow("selected-row")).rejects.toThrow("not available") expect(sendInputNow).not.toHaveBeenCalled() }) diff --git a/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx index fc718a8454b..2337ee27ea5 100644 --- a/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx +++ b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx @@ -27,12 +27,22 @@ describe("busy composer standard Send", () => { it("shows standard Send for attachment-only drafts and respects upload blocking", () => { const onSubmit = vi.fn() - const view = render() + const view = render( + , + ) const send = screen.getByRole("button", {name: "Send"}) as HTMLButtonElement expect(send.disabled).toBe(true) fireEvent.click(send) expect(onSubmit).not.toHaveBeenCalled() - view.rerender() + view.rerender( + , + ) fireEvent.click(screen.getByRole("button", {name: "Send"})) expect(onSubmit).toHaveBeenCalledWith("") }) From 868000f08c617fd5aca8472d6160173760d0a742 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 16:45:37 +0200 Subject: [PATCH 122/133] fix(sessions): preserve inputs reserved by Send Now --- .../src/dbs/postgres/sessions/inputs/dao.py | 21 +++++++ .../unit/sessions/test_session_inputs_dao.py | 63 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index e06029edf04..b31c57134fc 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -6,6 +6,8 @@ from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.types import SessionInputNotRemovable +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE from oss.src.dbs.postgres.sessions.inputs.mappings import ( new_input_row, @@ -215,6 +217,25 @@ async def remove_pending( user_id: Optional[UUID], ) -> Optional[PendingInput]: async with self.engine.session() as session: + # Serialize with admission before checking its committed command reservation. + await self._lock_session(session, project_id, session_id) + reserved = ( + await session.execute( + select(SessionCommandDBE.id) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind == "cancel", + SessionCommandDBE.state.in_(("pending", "claimed")), + SessionCommandDBE.deleted_at.is_(None), + SessionCommandDBE.data["steer_input_id"].astext + == str(input_id), + ) + .limit(1) + ) + ).scalar_one_or_none() + if reserved is not None: + raise SessionInputNotRemovable(str(input_id)) row = ( await session.execute( sa_update(SessionInputDBE) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 82e6c124df4..84f4fe7be0e 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -1287,3 +1287,66 @@ async def deliver(self, *, command): ) ).scalar_one() assert count == 1 + + +async def test_send_now_reservation_blocks_concurrent_removal(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="reserved-send-now", message="selected"), + ) + reserved = asyncio.Event() + release = asyncio.Event() + original_prioritize = inputs.prioritize_pending + + async def pause_reserved(**kwargs): + selected = await original_prioritize(**kwargs) + reserved.set() + await release.wait() + return selected + + monkeypatch.setattr(inputs, "prioritize_pending", pause_reserved) + args = dict( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + sending = asyncio.create_task(service.send_pending_input_now(**args)) + await asyncio.wait_for(reserved.wait(), timeout=5) + removing = asyncio.create_task(inputs.remove_pending(**args)) + await asyncio.sleep(0) + release.set() + admission = await sending + with pytest.raises(SessionInputNotRemovable): + await removing + assert admission.input.id == row.id + stored = await inputs.fetch_input( + project_id=args["project_id"], session_id=args["session_id"], input_id=row.id + ) + assert stored.state == PendingInputState.pending + assert stored.content == row.content + + async with input_scope["engine"].session() as transaction: + await transaction.execute( + text( + "UPDATE session_commands SET state='claimed' WHERE project_id=:project_id" + ), + {"project_id": args["project_id"]}, + ) + with pytest.raises(SessionInputNotRemovable): + await inputs.remove_pending(**args) + async with input_scope["engine"].session() as transaction: + await transaction.execute( + text( + "UPDATE session_commands SET state='obsolete', outcome='lost' WHERE project_id=:project_id" + ), + {"project_id": args["project_id"]}, + ) + removed = await inputs.remove_pending(**args) + assert removed.state == PendingInputState.removed From 9a6dddad5c24e12f1be5ba55f5bcf60376918c54 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 16:51:37 +0200 Subject: [PATCH 123/133] fix(frontend): remove queue pause footer copy --- web/mobile/src/features/chat/Composer.tsx | 1 - .../AgentChatSlice/components/AgentComposerDock.tsx | 2 -- .../agenta-chat/src/components/ChatComposer.tsx | 10 ---------- 3 files changed, 13 deletions(-) diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 43247ff12d6..ea97cafc66d 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -208,7 +208,6 @@ export const Composer = ({ streaming={stoppable} stopping={stopping} onStop={onStop} - showQueuePauseCopy={inputBusy && queueEnabled} extraPrefix={ boolean }) => { - const inputBusy = busy || queue.serverBusy const stoppable = isComposerRunStoppable({ localStreaming: busy, serverBusy: queue.serverBusy, @@ -477,7 +476,6 @@ const AgentComposerDock = ({ stopping={stopping} onStop={onStop} stopShortcutEnabled={stopShortcutEnabled} - showQueuePauseCopy={inputBusy && queueEnabled} attachments={attachments} attachmentsBlocked={attachmentsBlocked} composerDisabled={composerDisabled} diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index 7ee84e4e62e..e8979dba08a 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -60,8 +60,6 @@ export interface ChatComposerProps { stopShortcutEnabled?: boolean /** Capability-gated controls shown beside Stop while the session is busy. */ busyActions?: {label: string; onSubmit: (text: string) => void}[] - /** Explain the manual Stop rule while durable Queue is available. */ - showQueuePauseCopy?: boolean /** Read at event time — attachments are refused right now (a voice take in flight…). */ attachmentsBlocked?: () => boolean /** The composer itself is unusable (gates the paperclip alongside `uploadsEnabled`). */ @@ -97,7 +95,6 @@ export const ChatComposer = ({ onStop, stopShortcutEnabled = true, busyActions, - showQueuePauseCopy, attachmentsBlocked, composerDisabled, onViewAttachment, @@ -230,13 +227,6 @@ export const ChatComposer = ({
} trailing={trailing} - footer={ - showQueuePauseCopy ? ( -

- Stop pauses the queue. It resumes after your next message. -

- ) : null - } /> ) From c30669d01be9068975d230d2c4312a01cebaa3ec Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 16:57:43 +0200 Subject: [PATCH 124/133] fix(chat): resolve session capabilities before admitting input --- .../src/features/chat/LiveConversation.tsx | 9 +- .../AgentChatSlice/AgentConversation.tsx | 10 +- .../hooks/useAgentChatSession.ts | 6 +- .../src/assets/serverOwnedApproval.ts | 11 +- .../src/hooks/useAgentChatQueue.ts | 67 ++++++---- .../src/hooks/useAgentConversation.ts | 8 +- .../src/hooks/useServerSessionInputs.ts | 40 ++++-- .../unit/assets/serverOwnedApproval.test.ts | 21 +++ .../unit/hooks/useAgentChatQueue.test.ts | 121 ++++++++++++++++++ .../unit/hooks/useAgentConversation.test.ts | 56 +++++++- .../unit/hooks/useServerSessionInputs.test.ts | 25 ++++ .../agenta-entities/src/session/api/api.ts | 58 ++++----- .../session-continuation-resume-api.test.ts | 91 +++++++------ 13 files changed, 396 insertions(+), 127 deletions(-) diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 2eb5cba5646..ee78aeaaedd 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -727,15 +727,18 @@ export const LiveConversation = ({ ) : null} { + onSend={async ({text, parts}) => { setStoppingHere(false) // An open edit rewrites its held message instead of sending. The // input clears on submit, so the displaced draft goes back after. if (!conversation.editingId) { - conversation.send({text, parts}) + await conversation.send({text, parts}) return } - const draft = conversation.commitEdit({text, fileParts: parts}) + const draft = await conversation.commitEdit({ + text, + fileParts: parts, + }) if (draft) requestAnimationFrame(() => composerRef.current?.setMarkdown(draft), diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 44bba67806f..3f7990f65d3 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -643,8 +643,12 @@ const AgentConversation = ({ if (consumedRunNonceRef.current === pendingRun.nonce) return consumedRunNonceRef.current = pendingRun.nonce scrollIntent.follow() - submit({text: pendingRun.text}) - setPendingRun(null) + void Promise.resolve(submit({text: pendingRun.text})) + .then(() => setPendingRun(null)) + .catch(() => { + richInputRef.current?.setMarkdown(pendingRun.text) + attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) + }) }, [pendingRun, activeSessionId, sessionId, submit, setPendingRun]) // Run-level shortcuts. They live here, not in the panel's session hook, because only this @@ -711,7 +715,7 @@ const AgentConversation = ({ if (editingId) { // A rewrite of a held message: nothing is sent, so the transcript must not move. // The input clears itself on submit, so the displaced draft goes back after that. - const draft = commitEdit({text: trimmed, fileParts, stagedFiles}) + const draft = await commitEdit({text: trimmed, fileParts, stagedFiles}) if (draft) requestAnimationFrame(() => richInputRef.current?.setMarkdown(draft)) } else { // Glide to the bottom; the min-h-full active turn makes that show the new question at the diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 2b2364ee126..b50a2413dc7 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -427,7 +427,7 @@ export const useAgentChatSession = ({ const answerApproval = useCallback( async (approvalId: string, approved: boolean) => { return submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswer({ sessionId, @@ -459,7 +459,7 @@ export const useAgentChatSession = ({ const answerApprovals = useCallback( async (toolCallIds: string[], approved: boolean) => { return submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswers({sessionId, toolCallIds, approved}), retireDurable: () => { liveGateInteractionRef.current = null @@ -514,7 +514,7 @@ export const useAgentChatSession = ({ : {outcome: "completed", output: output ?? {}}), } const outcome = await submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), retireDurable: () => { liveGateInteractionRef.current = null diff --git a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts index 54c97b4ce05..f0f5373bb79 100644 --- a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts +++ b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts @@ -35,13 +35,20 @@ export async function submitApprovalForCapability({ recordLegacy, releaseLegacy, }: { - durableApprovals: boolean + durableApprovals: boolean | Promise submitDurable: () => Promise retireDurable: () => void recordLegacy: () => Promise releaseLegacy: () => void }): Promise { - if (durableApprovals) { + let durable: boolean + try { + durable = await durableApprovals + } catch (error) { + retireDurable() + throw error + } + if (durable) { return submitServerOwnedApproval({submit: submitDurable, retire: retireDurable}) } await recordAnswerThenRelease({record: recordLegacy, release: releaseLegacy}) diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 6ec0cf5037f..1bb88ecd3b2 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -27,6 +27,7 @@ export interface QueuedMessage { export interface ServerQueueAdapter { capabilities: {queue: boolean; steer: boolean} + resolveCapabilities?: () => Promise<{queue: boolean; steer: boolean}> busy: boolean queued: QueuedMessage[] submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise @@ -178,6 +179,9 @@ export const useAgentChatQueue = ({ !continuationHold && (canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled)) + const canReleaseNowRef = useRef(canReleaseNow) + canReleaseNowRef.current = canReleaseNow + // A stop voids the gate for release (above), so it must void it for reporting too — else the // aborted turn's lingering `approval-requested` part still reads as "awaiting" while `submit` // sends immediately. Keep `hitlPending` in lockstep with the release decision. @@ -271,29 +275,38 @@ export const useAgentChatQueue = ({ const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const message: QueuedMessage = {...item, id: generateId()} - if (server?.capabilities.queue) { - return server.submit(message, "queue") - } - if (recoverable && retryContinuation) { - setQueued((q) => [...q, message]) - if (!retryingContinuationRef.current) { - retryingContinuationRef.current = true - void retryContinuation() - .catch(() => false) - .finally(() => { - retryingContinuationRef.current = false - }) + const admit = (queue: boolean) => { + if (queue && server) { + return server.submit(message, "queue") + } + if (recoverable && retryContinuation) { + setQueued((q) => [...q, message]) + if (!retryingContinuationRef.current) { + retryingContinuationRef.current = true + void retryContinuation() + .catch(() => false) + .finally(() => { + retryingContinuationRef.current = false + }) + } + return + } + if ( + !releasingRef.current && + queuedRef.current.length === 0 && + canReleaseNowRef.current + ) { + releasingRef.current = true + lastSentRef.current = message + markRunOwned() + sendQueued(message) + } else { + setQueued((q) => [...q, message]) } - return - } - if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { - releasingRef.current = true - lastSentRef.current = message - markRunOwned() - sendQueued(message) - } else { - setQueued((q) => [...q, message]) } + return server?.resolveCapabilities + ? server.resolveCapabilities().then((capabilities) => admit(capabilities.queue)) + : admit(server?.capabilities.queue === true) }, [canReleaseNow, recoverable, retryContinuation, markRunOwned, sendQueued, server], ) @@ -363,13 +376,17 @@ export const useAgentChatQueue = ({ const commitEdit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const id = editingId - setEditingId(null) - const draft = takeStash() const target = id ? queuedRef.current.find((m) => m.id === id) : undefined if (!target) { - submit(item) - return draft + const submission = submit(item) + const finish = () => { + setEditingId(null) + return takeStash() + } + return submission ? submission.then(finish) : finish() } + setEditingId(null) + const draft = takeStash() const fileParts = [...(target.fileParts ?? []), ...(item.fileParts ?? [])] const stagedFiles = [...(target.stagedFiles ?? []), ...(item.stagedFiles ?? [])] // Edited down to nothing and carrying no files: there is no message left to hold. diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 5d1659134d8..3881d88a27e 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -207,7 +207,7 @@ export interface AgentConversation { cancelEdit: () => string /** Rewrite the edited message with the composer's content (or queue it anew if it drained). * Returns the draft the session displaced, for the host to put back. */ - commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string + commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string | Promise /** Headless approval-dock state wired to the live-gate-aware response path. */ approvals: ApprovalDock /** Settle a parked client tool part (widgets call this; the resume predicate auto-resends). */ @@ -743,7 +743,7 @@ export const useAgentConversation = ({ approvalResponseOwnerRef.current = args.id liveGateInteractionRef.current = {kind: "approval", id: args.id} const outcome = await submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswer({ sessionId, @@ -781,7 +781,7 @@ export const useAgentConversation = ({ approvalResponseOwnerRef.current = args.ids[0] liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} const outcome = await submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswers({ sessionId, @@ -869,7 +869,7 @@ export const useAgentConversation = ({ : {outcome: "completed", output: output ?? {}}), } const outcome = await submitApprovalForCapability({ - durableApprovals: await supportsDurableApprovals(sessionId), + durableApprovals: supportsDurableApprovals(sessionId), submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), retireDurable: () => { liveGateInteractionRef.current = null diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index ba1c9ecedb0..4bdcd59f448 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -6,8 +6,9 @@ import { removePendingSessionInputAtom, } from "@agenta/entities/session" import {buildAgentRequest} from "@agenta/playground/agent-chat" +import {projectIdAtom} from "@agenta/shared/state" import type {UIMessage} from "ai" -import {useSetAtom} from "jotai" +import {useAtomValue, useSetAtom} from "jotai" import {reduceSessionPendingInputs, type SessionPendingInputView} from "../assets/pendingInputs" @@ -21,6 +22,7 @@ export interface ServerSessionInputs { submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise remove: (id: string) => Promise refresh: () => Promise + resolveCapabilities: () => Promise } const emptyView = reduceSessionPendingInputs(null) @@ -41,19 +43,23 @@ export const useServerSessionInputs = ({ isSharedReaderReady?: () => boolean onExecuted?: () => void }): ServerSessionInputs => { + const projectId = useAtomValue(projectIdAtom) + const scope = JSON.stringify([projectId, sessionId]) + const scopeRef = useRef(scope) + scopeRef.current = scope const fetchSnapshot = useSetAtom(fetchSessionSnapshotAtom) const fetchCapabilities = useSetAtom(fetchSessionCapabilitiesAtom) const removeInput = useSetAtom(removePendingSessionInputAtom) - const [viewState, setViewState] = useState<{sessionId: string; view: SessionPendingInputView}>( - () => ({sessionId, view: emptyView}), + const [viewState, setViewState] = useState<{scope: string; view: SessionPendingInputView}>( + () => ({scope, view: emptyView}), ) - const view = viewState.sessionId === sessionId ? viewState.view : emptyView + const view = viewState.scope === scope ? viewState.view : emptyView const messagesRef = useRef(messages) const entityIdRef = useRef(entityId) const onExecutedRef = useRef(onExecuted) const isSharedReaderReadyRef = useRef(isSharedReaderReady) const loadInFlightRef = useRef<{ - sessionId: string + scope: string promise: Promise } | null>(null) messagesRef.current = messages @@ -62,40 +68,41 @@ export const useServerSessionInputs = ({ isSharedReaderReadyRef.current = isSharedReaderReady const load = useCallback((): Promise => { - if (loadInFlightRef.current?.sessionId === sessionId) { + if (loadInFlightRef.current?.scope === scope) { return loadInFlightRef.current.promise } const promise = (async () => { const capabilities = await fetchCapabilities(sessionId) + if (!capabilities) return null if (!capabilities.queue) return emptyView const snapshot = await fetchSnapshot(sessionId) return snapshot ? reduceSessionPendingInputs(snapshot) : null })() - const entry = {sessionId, promise} + const entry = {scope, promise} loadInFlightRef.current = entry const clear = () => { if (loadInFlightRef.current === entry) loadInFlightRef.current = null } void promise.then(clear, clear) return promise - }, [fetchCapabilities, fetchSnapshot, sessionId]) + }, [fetchCapabilities, fetchSnapshot, sessionId, scope]) const refresh = useCallback(async () => { const next = await load() - if (next) setViewState({sessionId, view: next}) - }, [load, sessionId]) + if (next && scopeRef.current === scope) setViewState({scope, view: next}) + }, [load, scope]) useEffect(() => { let cancelled = false void load().then((next) => { if (!cancelled && next) { - setViewState({sessionId, view: next}) + setViewState({scope, view: next}) } }) return () => { cancelled = true } - }, [load, sessionId]) + }, [load, scope]) // Pending-input events arrive in a later increment. Until then, a small capability-gated // snapshot poll gives every mounted browser the same durable order. @@ -105,6 +112,14 @@ export const useServerSessionInputs = ({ return () => clearInterval(timer) }, [refresh, view.capabilities.queue]) + const resolveCapabilities = useCallback(async () => { + const capabilities = await fetchCapabilities(sessionId) + if (!capabilities || scopeRef.current !== scope) { + throw new Error("Session capabilities are unavailable. Please try again.") + } + return {queue: capabilities.queue, steer: capabilities.steer} + }, [fetchCapabilities, sessionId, scope]) + const submit = useCallback( async (message: QueuedMessage, policy: "queue" | "steer") => { const outbound: UIMessage = { @@ -177,5 +192,6 @@ export const useServerSessionInputs = ({ submit, remove, refresh, + resolveCapabilities, } } diff --git a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts index 66fe373035a..a8e5b0b1c2c 100644 --- a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts @@ -49,3 +49,24 @@ describe("submitApprovalForCapability", () => { expect(releaseLegacy).toHaveBeenCalledOnce() }) }) + +it("retires only local ownership when capability discovery fails before answering", async () => { + const failure = new Error("Session is unavailable") + const submitDurable = vi.fn() + const retireDurable = vi.fn() + const recordLegacy = vi.fn() + const releaseLegacy = vi.fn() + await expect( + submitApprovalForCapability({ + durableApprovals: Promise.reject(failure), + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }), + ).rejects.toBe(failure) + expect(retireDurable).toHaveBeenCalledOnce() + expect(submitDurable).not.toHaveBeenCalled() + expect(recordLegacy).not.toHaveBeenCalled() + expect(releaseLegacy).not.toHaveBeenCalled() +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 312c0c2243d..13d6b1b1c92 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -784,3 +784,124 @@ describe("useAgentChatQueue: reclaiming a sent message", () => { expect(result.current.takeLastSent()).toMatchObject({text: "held"}) }) }) + +describe("cold session capability admission", () => { + it.each([true, false])( + "waits for queue=%s before choosing the first send owner", + async (queue) => { + let resolve!: (value: {queue: boolean; steer: boolean}) => void + const capability = new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }) + const submitServer = vi.fn().mockResolvedValue(undefined) + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: submitServer, + remove: vi.fn(), + resolveCapabilities: () => capability, + } + const {result, sendQueued} = setup({...settledEmpty, server}) + const fileParts = [ + { + type: "file", + url: "https://qa.invalid/file", + mediaType: "text/plain", + filename: "notes.txt", + }, + ] as FileUIPart[] + let submission: unknown + act(() => { + submission = result.current.submit({text: "first", fileParts}) + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(submitServer).not.toHaveBeenCalled() + await act(async () => { + resolve({queue, steer: queue}) + await submission + }) + if (queue) { + expect(submitServer).toHaveBeenCalledWith( + expect.objectContaining({text: "first", fileParts}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + } else { + expect(sendQueued).toHaveBeenCalledWith( + expect.objectContaining({text: "first", fileParts}), + ) + expect(submitServer).not.toHaveBeenCalled() + } + }, + ) + it("rejects unknown capability admission instead of falling back to native", async () => { + const failure = new Error("Session is unavailable") + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: () => Promise.reject(failure), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + await expect(result.current.submit({text: "keep this draft"})).rejects.toBe(failure) + expect(sendQueued).not.toHaveBeenCalled() + expect(server.submit).not.toHaveBeenCalled() + }) +}) + +it("keeps an edit and its displaced draft when a drained target cannot be readmitted", async () => { + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: vi.fn().mockRejectedValue(new Error("unavailable")), + } + const view = setup({status: "ready", messages: [], stopped: false, server}) + act(() => view.result.current.beginEdit("already-drained", "my displaced draft")) + await act(async () => { + await expect(view.result.current.commitEdit({text: "edited answer"})).rejects.toThrow( + "unavailable", + ) + }) + expect(view.result.current.editingId).toBe("already-drained") + let restored: string | undefined + act(() => { + restored = view.result.current.cancelEdit() + }) + expect(restored).toBe("my displaced draft") + expect(view.sendQueued).not.toHaveBeenCalled() +}) + +it("uses current busy state when validated legacy capability arrives", async () => { + let resolve!: (caps: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "ready", messages: [], stopped: false, server}) + let pending: void | Promise + act(() => { + pending = view.result.current.submit({text: "hold while starting"}) + }) + view.rerender({status: "streaming", messages: [], stopped: false, server}) + await act(async () => { + resolve({queue: false, steer: false}) + await pending + }) + expect(view.sendQueued).not.toHaveBeenCalled() + expect(view.result.current.queued.map((message) => message.text)).toEqual([ + "hold while starting", + ]) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 6695800fabe..79eca6f4407 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -396,7 +396,9 @@ describe("useAgentConversation", () => { act(() => void result.current.send({text: "start"})) await waitFor(() => expect(result.current.acceptedRunPending).toBe(true)) - act(() => void result.current.send({text: "held on mobile"})) + await act(async () => { + await result.current.send({text: "held on mobile"}) + }) expect(result.current.queued.map((message) => message.text)).toEqual(["held on mobile"]) expect(fetchMock).toHaveBeenCalledTimes(1) @@ -409,6 +411,22 @@ describe("useAgentConversation", () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + it("keeps the composer draft when initial capabilities are unknown", async () => { + capabilitiesViaAtom.mockResolvedValue(null) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + composerDraftBySession.set(sessionId, "keep first message") + const {result} = mount(store, "rev-1", sessionId) + await act(async () => { + await expect(result.current.send({text: "keep first message"})).rejects.toThrow( + "capabilities are unavailable", + ) + }) + expect(composerDraftBySession.get(sessionId)).toBe("keep first message") + expect(fetchMock).not.toHaveBeenCalled() + }) + it("keeps a Steer draft when durable admission is refused", async () => { capabilitiesViaAtom.mockResolvedValue({ durableApprovals: true, @@ -1169,6 +1187,42 @@ describe("useAgentConversation", () => { }) describe("server-owned client-tool answers", () => { + it("waits for initial capabilities before submitting a questionnaire answer", async () => { + let resolve!: (enabled: boolean) => void + durableApprovalCapability.mockImplementation( + () => + new Promise((done) => { + resolve = done + }), + ) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + const answer = { + toolName: "request_input", + toolCallId: "questionnaire", + output: {action: "accept", content: {goal: "Correctness"}}, + } + let pending!: Promise + act(() => { + pending = result.current.sendToolOutput(answer) + }) + await act(async () => { + await Promise.resolve() + }) + expect(respondAnswer).not.toHaveBeenCalled() + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + await act(async () => { + resolve(true) + await pending + }) + expect(respondAnswer).toHaveBeenCalledOnce() + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + it.each([false, true])( "submits client-tool answer durably without a competing local resume (error=%s)", async (failed) => { diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 5430206c6c3..85c8b167ee0 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -1,6 +1,8 @@ // @vitest-environment jsdom import {createElement, createRef, Fragment, useMemo, useRef, useState, type RefObject} from "react" +import {projectIdAtom} from "@agenta/shared/state" +import {createStore, Provider} from "jotai" import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" import {act, cleanup, fireEvent, render, renderHook, screen, waitFor} from "@testing-library/react" import type {UIMessage} from "ai" @@ -272,6 +274,29 @@ const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolea } describe("useServerSessionInputs", () => { + it("reloads capabilities when project scope becomes available", async () => { + const store = createStore() + fetchCapabilities.mockImplementation(async () => + store.get(projectIdAtom) ? {queue: true, steer: true, durableApprovals: true} : null, + ) + fetchSnapshot.mockResolvedValue(runningSnapshot([])) + const {result} = renderHook( + () => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: false, + }), + {wrapper: ({children}) => createElement(Provider, {store}, children)}, + ) + await waitFor(() => expect(fetchCapabilities).toHaveBeenCalledOnce()) + expect(fetchSnapshot).not.toHaveBeenCalled() + act(() => store.set(projectIdAtom, "project-ready")) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(fetchCapabilities).toHaveBeenCalledTimes(2) + }) + it("does not request a queue snapshot when the capability is absent", async () => { fetchCapabilities.mockResolvedValue({ durableApprovals: false, diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 9bd1caf1cef..c156811084b 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -207,14 +207,9 @@ export interface SessionFeatureCapabilities { interface SessionCapabilityCacheEntry { result?: SessionFeatureCapabilities retryAt?: number - request?: Promise + request?: Promise } -const noSessionCapabilities: SessionFeatureCapabilities = { - durableApprovals: false, - queue: false, - steer: false, -} const durableApprovalsCapabilityCache = new Map() const durableApprovalsCapabilityKey = ({projectId, sessionId}: SessionScopedParams): string => @@ -247,8 +242,8 @@ export const fetchSessionCapabilities = async ({ projectId, appId, abortSignal, -}: SessionScopedParams): Promise => { - if (!projectId || !sessionId) return noSessionCapabilities +}: SessionScopedParams): Promise => { + if (!projectId || !sessionId) return null const key = durableApprovalsCapabilityKey({projectId, sessionId}) const cached = cachedSessionCapabilities(key) @@ -259,7 +254,7 @@ export const fetchSessionCapabilities = async ({ const entry: SessionCapabilityCacheEntry = {} const request = (async () => { - let capabilities = noSessionCapabilities + let capabilities: SessionFeatureCapabilities | null = null try { const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => getSessionsClient().fetchSessionStream( @@ -278,20 +273,23 @@ export const fetchSessionCapabilities = async ({ "[fetchSessionDurableApprovalsCapability]", ) : null - capabilities = { - durableApprovals: validated?.capabilities.durable_approvals ?? false, - queue: validated?.capabilities.queue ?? false, - steer: validated?.capabilities.steer ?? false, - } + capabilities = validated + ? { + durableApprovals: validated.capabilities.durable_approvals, + queue: validated.capabilities.queue, + steer: validated.capabilities.steer, + } + : null } catch { - capabilities = noSessionCapabilities + capabilities = null } if (durableApprovalsCapabilityCache.get(key) === entry) { - entry.result = capabilities - entry.retryAt = hasSessionCapability(capabilities) - ? undefined - : Date.now() + SESSION_CAPABILITY_NEGATIVE_RETRY_MS + entry.result = capabilities ?? undefined + entry.retryAt = + !capabilities || hasSessionCapability(capabilities) + ? undefined + : Date.now() + SESSION_CAPABILITY_NEGATIVE_RETRY_MS entry.request = undefined } return capabilities @@ -806,21 +804,13 @@ export async function fetchSessionStream({ return validated?.stream ?? null } -/** Server-owned feature capability. Missing/failed responses mean legacy behavior. */ -export async function fetchSessionDurableApprovalsCapability({ - sessionId, - projectId, - appId, - abortSignal, -}: SessionScopedParams): Promise { - if (!projectId || !sessionId) return false - - const key = durableApprovalsCapabilityKey({projectId, sessionId}) - const cached = cachedSessionCapabilities(key) - if (cached) return cached.durableApprovals - - void fetchSessionCapabilities({sessionId, projectId, appId, abortSignal}) - return false +/** Resolve the approval owner before mutating either the server gate or the local transcript. */ +export async function fetchSessionDurableApprovalsCapability( + params: SessionScopedParams, +): Promise { + const capabilities = await fetchSessionCapabilities(params) + if (!capabilities) throw new Error("Session capabilities are unavailable. Please try again.") + return capabilities.durableApprovals } export interface CommandSessionStreamParams extends SessionScopedParams { diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index f94bd61487a..85bebfb1666 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -75,7 +75,7 @@ describe("fetchSessionDurableApprovalsCapability", () => { const scope = {projectId: "project-1", sessionId: "session-1"} - await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(false) + await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true) await vi.waitFor(() => expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true), ) @@ -127,46 +127,57 @@ describe("fetchSessionDurableApprovalsCapability", () => { expect(fetchStream).toHaveBeenCalledTimes(2) }) - it.each([ - ["older API", {stream: null}], - ["failed request", null], - ])("uses legacy behavior for %s", async (_case, response) => { - fetchStream.mockResolvedValue(response) - - await expect( - fetchSessionDurableApprovalsCapability({ - projectId: "project-1", - sessionId: "session-1", - }), - ).resolves.toBe(false) - }) - - it("does not delay a legacy send while capability negotiation is slow", async () => { - fetchStream.mockImplementation(() => new Promise(() => undefined)) - const prepare = vi.fn().mockResolvedValue("legacy send") - - const capability = await fetchSessionDurableApprovalsCapability({ - projectId: "project-1", - sessionId: "session-1", - }) - const result = capability ? "durable path" : await prepare() - - expect(result).toBe("legacy send") - expect(prepare).toHaveBeenCalledOnce() - expect(fetchStream).toHaveBeenCalledOnce() - }) - - it("caches a failed negotiation instead of retrying it on every send", async () => { - const error = vi.spyOn(console, "error").mockImplementation(() => undefined) - fetchStream.mockRejectedValue(new Error("route unavailable")) + it.each([["older API", {stream: null}]])( + "uses legacy behavior for %s", + async (_case, response) => { + fetchStream.mockResolvedValue(response) + + await expect( + fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }), + ).resolves.toBe(false) + }, + ) + + it("retries unknown capability without caching it as unsupported", async () => { + fetchStream + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({stream: null, capabilities: {durable_approvals: true}}) const scope = {projectId: "project-1", sessionId: "session-1"} + await expect(fetchSessionDurableApprovalsCapability(scope)).rejects.toThrow( + "capabilities are unavailable", + ) + await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true) + expect(fetchStream).toHaveBeenCalledTimes(2) + }) +}) - await fetchSessionDurableApprovalsCapability(scope) - await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()) - await fetchSessionDurableApprovalsCapability(scope) - await fetchSessionDurableApprovalsCapability(scope) - - expect(fetchStream).toHaveBeenCalledOnce() - error.mockRestore() +it("keeps an initial approval answer waiting for capability discovery", async () => { + let resolve!: (value: unknown) => void + fetchStream.mockImplementation( + () => + new Promise((done) => { + resolve = done + }), + ) + let settled = false + const result = fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }).then((value) => { + settled = true + return value }) + await Promise.resolve() + expect(settled).toBe(false) + resolve({stream: null, capabilities: {durable_approvals: true}}) + await expect(result).resolves.toBe(true) +}) +it("keeps missing project scope unknown", async () => { + await expect( + fetchSessionCapabilities({projectId: "", sessionId: "session-1"}), + ).resolves.toBeNull() + expect(fetchStream).not.toHaveBeenCalled() }) From 5f57fdb8682774d56b1802534d1534e9a0b7a174 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 16:58:13 +0200 Subject: [PATCH 125/133] fix(frontend): connect desktop queued Send Now action --- web/oss/src/components/AgentChatSlice/AgentConversation.tsx | 2 ++ .../components/AgentChatSlice/components/AgentComposerDock.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 44bba67806f..805f6e3bc17 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -437,6 +437,7 @@ const AgentConversation = ({ submit, steer, removeQueued, + sendQueuedNow, ownsContinuation, queueEnabled, steerEnabled, @@ -1027,6 +1028,7 @@ const AgentConversation = ({ queue={{ queued, removeQueued, + sendQueuedNow, editingId, beginEdit, cancelEdit, diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 1fc31a2207a..4a4a2ca1c09 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -99,7 +99,7 @@ const AgentComposerDock = ({ queue: { queued: QueuedMessage[] removeQueued: (id: string) => void - sendQueuedNow?: (id: string) => Promise + sendQueuedNow: ((id: string) => Promise) | undefined editingId: string | null beginEdit: (id: string, draft?: string) => void cancelEdit: () => string From 17860eab984e6d695e16a3346c054a9936ae05c9 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 17:18:56 +0200 Subject: [PATCH 126/133] fix(chat): retain admission ownership through manual retries --- .../AgentChatSlice/AgentConversation.tsx | 7 +++- .../src/hooks/useAgentChatQueue.ts | 5 ++- .../unit/hooks/useAgentChatQueue.test.ts | 32 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 3f7990f65d3..4b9695b9bba 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -644,7 +644,9 @@ const AgentConversation = ({ consumedRunNonceRef.current = pendingRun.nonce scrollIntent.follow() void Promise.resolve(submit({text: pendingRun.text})) - .then(() => setPendingRun(null)) + .then(() => + setPendingRun((current) => (current?.nonce === pendingRun.nonce ? null : current)), + ) .catch(() => { richInputRef.current?.setMarkdown(pendingRun.text) attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) @@ -723,9 +725,12 @@ const AgentConversation = ({ // Clear any prior "stopped" marker — it's resolved by asking again. scrollIntent.armGlide() setStopped(false) + // Clear only the pending run this manual retry took over, after admission succeeds. + const pendingRunNonce = consumedRunNonceRef.current // One path: `submit` sends now or queues behind held messages via the shared release gate. if (policy === "steer") await steer({text: trimmed, fileParts}) else await submit({text: trimmed, fileParts, stagedFiles}) + setPendingRun((current) => (current?.nonce === pendingRunNonce ? null : current)) } // The message left the composer — drop its persisted draft (and any pending capture). composer.clearDraft() diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 1bb88ecd3b2..3e52ca988f3 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -324,7 +324,10 @@ export const useAgentChatQueue = ({ const steer = useCallback( async (item: {text: string; fileParts?: FileUIPart[]}) => { - if (!server?.capabilities.steer || !server.busy) { + const capabilities = server?.resolveCapabilities + ? await server.resolveCapabilities() + : server?.capabilities + if (!capabilities?.steer || !server?.busy) { throw new Error("The session is not ready to accept a Steer input.") } const message: QueuedMessage = {...item, id: generateId()} diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 13d6b1b1c92..c56d951b7b8 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -905,3 +905,35 @@ it("uses current busy state when validated legacy capability arrives", async () "hold while starting", ]) }) + +it("waits for cold Steer capabilities before admitting the explicit input", async () => { + let resolve!: (capabilities: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "streaming", messages: [], stopped: false, server}) + let pending!: Promise + act(() => { + pending = view.result.current.steer({text: "change direction"}) + }) + void pending.catch(() => undefined) + expect(server.submit).not.toHaveBeenCalled() + expect(resolve).toBeTypeOf("function") + await act(async () => { + resolve({queue: true, steer: true}) + await pending + }) + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "change direction"}), + "steer", + ) + expect(view.sendQueued).not.toHaveBeenCalled() +}) From 2f08d5d8a5f8b78ff43882c3451357fbc9e89dd6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 19:30:53 +0200 Subject: [PATCH 127/133] fix(chat): preserve failed admission and expose queued Send Now --- .../src/features/chat/LiveConversation.tsx | 99 ++++++++++--------- web/mobile/src/features/home/pendingTask.ts | 44 +++++++++ .../tests/unit/pendingTaskAdmission.test.ts | 82 +++++++++++++++ .../src/components/QueuedMessagesDock.tsx | 6 +- .../src/hooks/useAgentChatQueue.ts | 4 +- .../unit/hooks/useAgentChatQueue.test.ts | 28 ++++++ .../unit/hooks/useServerSessionInputs.test.ts | 4 +- .../agenta-entities/src/session/api/api.ts | 9 +- .../src/session/core/schema.ts | 6 ++ .../session-continuation-resume-api.test.ts | 22 ++++- 10 files changed, 253 insertions(+), 51 deletions(-) create mode 100644 web/mobile/tests/unit/pendingTaskAdmission.test.ts diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 71ef016bdc0..077431f55b4 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -46,19 +46,16 @@ import {User} from "lucide-react" import {ContentRail} from "@/components/ContentRail" import {ScreenScaffold} from "@/components/ScreenScaffold" +import {Button} from "@/components/ui/button" -import {pendingTasksAtom, takePendingTaskAtom} from "../home/pendingTask" +import {pendingTasksAtom, failPendingTaskAtom, sendPendingTaskAtom} from "../home/pendingTask" import {AppShell} from "../nav/AppShell" import {livenessQueryKey} from "../sessions/useLivenessPoll" import {ApprovalDock} from "./ApprovalDock" import {Composer} from "./Composer" import {ConnectModelStrip} from "./ConnectModelStrip" -import { - MODEL_KEY_WAIT_LIMIT_MS, - PENDING_TASK_NOT_SENT_MESSAGE, - pendingTaskDecision, -} from "./pendingTaskPolicy" +import {MODEL_KEY_WAIT_LIMIT_MS, pendingTaskDecision} from "./pendingTaskPolicy" import {ChatLoading} from "./states/ChatStates" import {StopButton} from "./StopButton" import {cancelledStopAction} from "./stopHereState" @@ -173,55 +170,43 @@ export const LiveConversation = ({ input?.focus() }, [cancelEdit]) - // A task started from Home lands here as a stashed message: the session did not exist when - // it was typed, and the first send is what creates it. Ref-guarded and the slot is consumed - // on read, so a re-render (or React 18's double-invoke in dev) cannot send it twice. Held - // until hydration settles, or the engine would send into a transcript it is still filling, - // and held while the vault is unresolved or the model gate is up, so the first message is not - // spent on a run that cannot succeed — it goes out on its own the moment a key lands (or the - // vault says one already exists). The guard holds the SESSION it - // fired for, not a bare flag: this component survives a session switch, and a flag would - // swallow the next session's stashed task. - - // Peek at the parked task WITHOUT consuming it — used only for display while the gate holds. - // `takePendingTaskAtom` removes the entry; this read leaves it in place for the send effect. + // Keep Home tasks session-scoped until admission; failures require an explicit retry. const pendingTasks = useAtomValue(pendingTasksAtom) - const heldTaskText = pendingTasks[sessionId]?.text ?? null - - const takePendingTask = useSetAtom(takePendingTaskAtom) - const sentPendingTaskFor = useRef(null) - const [pendingTaskError, setPendingTaskError] = useState(null) + const pendingTask = pendingTasks[sessionId] + const heldTaskText = pendingTask?.delivery === "sending" ? null : pendingTask?.text + const sendPendingTask = useSetAtom(sendPendingTaskAtom) + const failPendingTask = useSetAtom(failPendingTaskAtom) + const pendingTaskError = pendingTask?.delivery === "failed" const {isHydrating, revalidate, send, stop, voidPendingResume} = conversation useEffect(() => { + if (!pendingTask || pendingTask.delivery) return const decision = pendingTaskDecision({ sessionId, - sentFor: sentPendingTaskFor.current, + sentFor: null, hydrating: isHydrating, modelKeyLoading, modelKeyWaitedMs, modelBlocked, }) if (decision === "hold") return - const task = takePendingTask(sessionId) - if (!task) return - // Consumed either way — a released task must not replay on the next render. - sentPendingTaskFor.current = sessionId if (decision === "abandon") { - setPendingTaskError(PENDING_TASK_NOT_SENT_MESSAGE) - // Hand the text back so "try again" is one tap. The composer is usable here: the gate - // is not up, because an unresolved vault never raises it. - if (task.text) composerRef.current?.setMarkdown(task.text) + failPendingTask(sessionId) return } - void send({text: task.text, parts: task.parts}) + void sendPendingTask({ + sessionId, + send: (task) => send({text: task.text, parts: task.parts}), + }) }, [ + pendingTask, isHydrating, modelKeyLoading, modelKeyWaitedMs, modelBlocked, send, sessionId, - takePendingTask, + sendPendingTask, + failPendingTask, ]) const queryClient = useQueryClient() @@ -550,11 +535,7 @@ export const LiveConversation = ({ } else { body = ( - {/* A task typed before any provider key exists is held in `pendingTasksAtom` - (not yet sent — the gate is up). Render it as a user bubble so the person - can see what they wrote, matching desktop parity: the desktop shows the - held seed above the connect-model banner. Cleared the moment the gate - drops and the send effect fires (`takePendingTaskAtom` removes the entry). */} + {/* A held or failed Home task stays visible until accepted. */} {heldTaskText ? (
- {/* The parked task gave up waiting for the vault. Its text is back in the - composer, so this says what happened and the send is one tap away. */} + {/* Failed Home tasks retain their original text and files for retry. */} {pendingTaskError ? ( -

- {pendingTaskError} -

+
+ + The message was not sent. Your text and attachments are + saved. + + {pendingTask?.parts?.map((part, index) => ( + + {part.filename || "Attachment"} + + ))} + +
) : null} { set(pendingTasksAtom, rest) return task }) + +export const failPendingTaskAtom = atom(null, (get, set, sessionId: string) => { + const tasks = get(pendingTasksAtom) + const task = tasks[sessionId] + if (task && task.delivery !== "sending") { + set(pendingTasksAtom, {...tasks, [sessionId]: {...task, delivery: "failed"}}) + } +}) + +export const sendPendingTaskAtom = atom( + null, + async ( + get, + set, + { + sessionId, + send, + retry = false, + }: { + sessionId: string + send: (task: PendingTask) => Promise + retry?: boolean + }, + ) => { + const task = get(pendingTasksAtom)[sessionId] + if (!task || task.delivery === "sending" || (task.delivery === "failed" && !retry)) return + const sending: PendingTask = {...task, delivery: "sending"} + set(pendingTasksAtom, {...get(pendingTasksAtom), [sessionId]: sending}) + try { + await send(sending) + } catch { + const tasks = get(pendingTasksAtom) + if (tasks[sessionId] === sending) { + set(pendingTasksAtom, {...tasks, [sessionId]: {...sending, delivery: "failed"}}) + } + return + } + const tasks = get(pendingTasksAtom) + if (tasks[sessionId] !== sending) return + const {[sessionId]: _sent, ...rest} = tasks + set(pendingTasksAtom, rest) + }, +) diff --git a/web/mobile/tests/unit/pendingTaskAdmission.test.ts b/web/mobile/tests/unit/pendingTaskAdmission.test.ts new file mode 100644 index 00000000000..5e467414d13 --- /dev/null +++ b/web/mobile/tests/unit/pendingTaskAdmission.test.ts @@ -0,0 +1,82 @@ +import {createStore} from "jotai" +import {describe, expect, it, vi} from "vitest" + +import { + pendingTasksAtom, + sendPendingTaskAtom, + stashPendingTaskAtom, +} from "../../src/features/home/pendingTask" + +const task = { + agentId: "agent", + text: "keep this task", + parts: [ + { + type: "file" as const, + url: "https://files.test/brief.pdf", + mediaType: "application/pdf", + filename: "brief.pdf", + }, + ], +} + +describe("mobile Home task admission", () => { + it("retains failed text/files and retries only explicitly, clearing on success", async () => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + const send = vi + .fn() + .mockRejectedValueOnce(new Error("capabilities unavailable")) + .mockResolvedValueOnce(undefined) + await store.set(sendPendingTaskAtom, {sessionId: "one", send}) + expect(store.get(pendingTasksAtom).one).toEqual({...task, delivery: "failed"}) + await store.set(sendPendingTaskAtom, {sessionId: "one", send}) + expect(send).toHaveBeenCalledOnce() + await store.set(sendPendingTaskAtom, {sessionId: "one", send, retry: true}) + expect(send).toHaveBeenLastCalledWith({...task, delivery: "sending"}) + expect(store.get(pendingTasksAtom).one).toBeUndefined() + }) + + it("deduplicates concurrent mounts while admission is pending", async () => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + let resolve!: () => void + const send = vi.fn( + () => + new Promise((done) => { + resolve = done + }), + ) + const first = store.set(sendPendingTaskAtom, {sessionId: "one", send}) + await store.set(sendPendingTaskAtom, {sessionId: "one", send, retry: true}) + expect(send).toHaveBeenCalledOnce() + resolve() + await first + expect(store.get(pendingTasksAtom).one).toBeUndefined() + }) + + it.each([false, true])( + "does not overwrite a newer task or another session after old completion (failure=%s)", + async (failure) => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + let resolve!: () => void + let reject!: (error: Error) => void + const send = vi.fn( + () => + new Promise((yes, no) => { + resolve = yes + reject = no + }), + ) + const pending = store.set(sendPendingTaskAtom, {sessionId: "one", send}) + const newer = {...task, text: "newer"} + store.set(stashPendingTaskAtom, {sessionId: "one", task: newer}) + store.set(stashPendingTaskAtom, {sessionId: "two", task}) + if (failure) reject(new Error("old failure")) + else resolve() + await pending + expect(store.get(pendingTasksAtom)).toEqual({one: newer, two: task}) + }, + ) +}) diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index be6645bf481..03c40142462 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -135,11 +135,11 @@ const Row = ({ Steer ) : null} - {/* Revealed on hover, but always present for keyboard and while this row is under - edit — an action you can only reach with a pointer is not an action on mobile. */} + {/* Keep Send Now discoverable without hovering. Other row actions also stay visible + during editing, keyboard focus, and on touch surfaces. */} { + const serverBusyRef = useRef(server?.busy) + serverBusyRef.current = server?.busy const [queued, setQueued] = useState( () => (sessionId && queuedBySession.get(sessionId)) || [], ) @@ -328,7 +330,7 @@ export const useAgentChatQueue = ({ const capabilities = server?.resolveCapabilities ? await server.resolveCapabilities() : server?.capabilities - if (!capabilities?.steer || !server?.busy) { + if (!capabilities?.steer || !serverBusyRef.current || !server) { throw new Error("The session is not ready to accept a Steer input.") } const message: QueuedMessage = {...item, id: generateId()} diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index c56d951b7b8..264daf942e5 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -937,3 +937,31 @@ it("waits for cold Steer capabilities before admitting the explicit input", asyn ) expect(view.sendQueued).not.toHaveBeenCalled() }) + +it("refuses cold Steer if the session settles while capabilities resolve", async () => { + let resolve!: (capabilities: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "streaming", messages: [], stopped: false, server}) + let pending!: Promise + act(() => { + pending = view.result.current.steer({text: "too late"}) + }) + void pending.catch(() => undefined) + view.rerender({status: "ready", messages: [], stopped: false, server: {...server, busy: false}}) + await act(async () => { + resolve({queue: true, steer: true}) + await expect(pending).rejects.toThrow("not ready") + }) + expect(server.submit).not.toHaveBeenCalled() + expect(view.sendQueued).not.toHaveBeenCalled() +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 96a7825b3e6..337fdaf8969 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -54,7 +54,9 @@ vi.mock("@agenta/playground/agent-chat", async (importOriginal) => ({ const fetchMock = vi.fn() vi.stubGlobal("fetch", fetchMock) -beforeAll(() => { +beforeAll(async () => { + // Load the real lazy editor before the one-second interaction assertions start. + await import("@agenta/ui/rich-chat-input") // Lexical asks the DOM selection's text node for geometry after Enter clears the editor. const rect = () => new DOMRect() for (const prototype of [ diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index cc5c5a3dea3..e93a4fb0b30 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -13,6 +13,7 @@ import {z} from "zod" import {safeParseWithLogging} from "../../shared/utils/zodSchema" import { mountFileContentResponseSchema, + pendingInputAdmissionResponseSchema, mountFileListResponseSchema, sessionInteractionResponseSchema, sessionInteractionsResponseSchema, @@ -209,7 +210,13 @@ export async function sendPendingSessionInputNow({ projectScopedRequest(projectId, appId, abortSignal), ), ) - return !!data + return ( + safeParseWithLogging( + pendingInputAdmissionResponseSchema, + data, + "[sendPendingSessionInputNow]", + ) !== null + ) } const SESSION_CAPABILITY_TIMEOUT_SECONDS = 2 diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index 7c1087b2a3f..fe0be143a2d 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -265,6 +265,12 @@ export const pendingSessionInputSchema = z.object({ promoted_execution_id: z.string().nullish(), }) +export const pendingInputAdmissionResponseSchema = z.object({ + action: z.enum(["execute", "pending"]), + input: pendingSessionInputSchema.nullish(), + execution_id: z.string().nullish(), +}) + /** * Atomic read for every reader of an open session. * diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 85bebfb1666..0cb08d4ca37 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -1,11 +1,16 @@ import type {SessionCapabilities, SessionStreamResponse} from "@agentaai/api-client" import {beforeEach, describe, expect, expectTypeOf, it, vi} from "vitest" -const {resume, fetchStream} = vi.hoisted(() => ({resume: vi.fn(), fetchStream: vi.fn()})) +const {resume, fetchStream, sendNow} = vi.hoisted(() => ({ + resume: vi.fn(), + fetchStream: vi.fn(), + sendNow: vi.fn(), +})) vi.mock("@agenta/sdk/resources", () => ({ getSessionsClient: () => ({ resumeSessionContinuation: resume, + sendPendingSessionInputNow: sendNow, fetchSessionStream: fetchStream, }), getLowPrioritySessionsClient: vi.fn(), @@ -15,6 +20,7 @@ vi.mock("@agenta/sdk/resources", () => ({ import { fetchSessionCapabilities, + sendPendingSessionInputNow, fetchSessionDurableApprovalsCapability, invalidateSessionDurableApprovalsCapability, resumeSessionContinuation, @@ -181,3 +187,17 @@ it("keeps missing project scope unknown", async () => { ).resolves.toBeNull() expect(fetchStream).not.toHaveBeenCalled() }) + +it.each([ + [{action: "execute", execution_id: "execution"}, true], + [{action: "pending"}, true], + [{action: "unknown"}, false], + [{}, false], + [{action: "pending", input: {id: "incomplete"}}, false], + [null, false], +])("validates Send Now admission %j", async (response, accepted) => { + sendNow.mockResolvedValue(response) + await expect( + sendPendingSessionInputNow({projectId: "project", sessionId: "session", inputId: "input"}), + ).resolves.toBe(accepted) +}) From bc1c1cdb7523e3fbe9ea64e5e15d828ca0e3e88a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 19:33:29 +0200 Subject: [PATCH 128/133] fix(chat): restore editing of queued messages on web and mobile --- api/oss/src/apis/fastapi/sessions/models.py | 6 +- api/oss/src/apis/fastapi/sessions/router.py | 68 +++++ api/oss/src/core/sessions/inputs/dtos.py | 17 +- .../src/core/sessions/inputs/interfaces.py | 21 +- api/oss/src/core/sessions/inputs/service.py | 107 ++++++++ api/oss/src/core/sessions/inputs/types.py | 12 + .../src/dbs/postgres/sessions/inputs/dao.py | 72 +++++- .../unit/sessions/test_session_inputs_dao.py | 244 +++++++++++++++++- .../src/features/chat/LiveConversation.tsx | 11 +- .../AgentChatSlice/AgentConversation.tsx | 2 +- .../components/QueuedMessagesDock.tsx | 2 +- .../api/resources/sessions/client/Client.ts | 80 ++++++ .../requests/PendingInputUpdateRequest.ts | 18 ++ .../sessions/client/requests/index.ts | 1 + .../api/types/PendingInputAttachment.ts | 7 + .../src/generated/api/types/index.ts | 1 + .../agenta-chat/src/assets/pendingInputs.ts | 2 +- .../src/components/QueuedMessagesDock.tsx | 26 +- .../src/hooks/useAgentChatQueue.ts | 37 ++- .../src/hooks/useAgentConversation.ts | 2 +- .../src/hooks/useServerSessionInputs.ts | 25 +- .../tests/unit/QueuedMessagesDock.test.tsx | 23 +- .../tests/unit/assets/pendingInputs.test.ts | 11 +- .../unit/hooks/useAgentChatQueue.test.ts | 108 ++++++++ .../unit/hooks/useServerSessionInputs.test.ts | 71 ++++- .../agenta-entities/src/session/api/api.ts | 23 ++ .../agenta-entities/src/session/index.ts | 2 + .../src/session/state/pendingInputs.ts | 18 ++ .../QueuedMessagesDock.stories.tsx | 18 +- 29 files changed, 983 insertions(+), 52 deletions(-) create mode 100644 web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts create mode 100644 web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 9c4372f8e81..b6280d3896a 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -29,7 +29,7 @@ from oss.src.core.sessions.mounts.dtos import SessionMount, SessionMountQuery from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn, SessionTurnQuery from oss.src.core.sessions.types import SessionReference -from oss.src.core.sessions.inputs.dtos import PendingInput +from oss.src.core.sessions.inputs.dtos import PendingInputUpdate, PendingInput from oss.src.core.shared.dtos import OTelSpanId, Windowing from oss.src.dbs.postgres.sessions.streams.dao import MAX_SESSION_QUERY_LIMIT @@ -607,3 +607,7 @@ class SessionControlOutcomeResponse(BaseModel): class SessionContinuationResumeResponse(BaseModel): resumed: bool + + +class PendingInputUpdateRequest(PendingInputUpdate): + pass diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index b69f5231c6b..96f486e81f3 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -110,6 +110,8 @@ SessionInputIdempotencyConflict, SessionInputNotFound, SessionInputNotRemovable, + SessionInputNotEditable, + SessionInputContentInvalid, SessionInputRemoved, ) from oss.src.core.sessions.inputs.dtos import PendingInputState @@ -204,6 +206,7 @@ SessionResponse, SessionsResponse, PendingInputResponse, + PendingInputUpdateRequest, PendingInputAdmissionRequest, PendingInputAdmissionResponse, SessionCapabilities, @@ -2130,6 +2133,14 @@ def __init__( if inputs_service is not None: # The snapshot itself is `get_session_snapshot`, registered below: one route serves # both the reconnect watermark and the durable queue. + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}", + self.update_pending_input, + methods=["PATCH"], + operation_id="update_pending_session_input", + response_model=PendingInputResponse, + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/{session_id}/inputs/{input_id}", self.remove_pending_input, @@ -2303,6 +2314,63 @@ async def query_sessions( windowing=response_windowing, ) + @intercept_exceptions() + async def update_pending_input( + self, + request: Request, + session_id: str, + input_id: UUID, + payload: PendingInputUpdateRequest, + ) -> PendingInputResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + item = await self.inputs_service.update( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + update=payload, + ) + except SessionInputNotFound as error: + raise HTTPException( + status_code=404, + detail={ + "code": "pending_input_not_found", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + except SessionInputNotEditable as error: + raise HTTPException( + status_code=409, + detail={ + "code": "pending_input_not_editable", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + except SessionInputContentInvalid as error: + raise HTTPException( + status_code=422, + detail={ + "code": "pending_input_content_invalid", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + return PendingInputResponse(input=item) + @intercept_exceptions() async def remove_pending_input( self, request: Request, session_id: str, input_id: UUID diff --git a/api/oss/src/core/sessions/inputs/dtos.py b/api/oss/src/core/sessions/inputs/dtos.py index bb3764a0105..56e5323e2ca 100644 --- a/api/oss/src/core/sessions/inputs/dtos.py +++ b/api/oss/src/core/sessions/inputs/dtos.py @@ -1,9 +1,9 @@ from datetime import datetime from enum import Enum -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, List, Literal, Optional from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from oss.src.core.shared.dtos import Identifier, Lifecycle @@ -45,3 +45,16 @@ class PendingInputPromotion(BaseModel): input: PendingInput execution_id: str created_at: datetime + + +class PendingInputAttachment(BaseModel): + model_config = ConfigDict(extra="forbid") + uri: str = Field(min_length=1) + mime_type: str = Field(min_length=1) + filename: Optional[str] = None + + +class PendingInputUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + text: str + attachments: List[PendingInputAttachment] = Field(default_factory=list) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py index e4a7128d5a1..575bac48ac7 100644 --- a/api/oss/src/core/sessions/inputs/interfaces.py +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, AsyncContextManager, List, Optional +from typing import Any, AsyncContextManager, Dict, List, Optional from uuid import UUID from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate @@ -93,3 +93,22 @@ async def promote_next( transaction: Optional[Any] = None, ) -> Optional[PendingInput]: pass + + @abstractmethod + async def lock_pending_for_edit( + self, *, project_id: UUID, session_id: str, input_id: UUID, transaction: Any + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def update_content( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + content: Dict[str, Any], + user_id: Optional[UUID], + transaction: Any, + ) -> PendingInput: + pass diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 2860c2ab779..0a6c225b2d7 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import hashlib import json from typing import Any, Awaitable, Callable, Dict, List, Optional @@ -8,6 +10,7 @@ PendingInputAdmission, PendingInputCreate, PendingInputState, + PendingInputUpdate, ) from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface from oss.src.core.sessions.inputs.types import ( @@ -15,6 +18,7 @@ SessionInputIdempotencyConflict, SessionInputNotFound, SessionInputNotRemovable, + SessionInputContentInvalid, ) from oss.src.core.sessions.interactions.dtos import SessionInteractionStatus from oss.src.core.sessions.interactions.interfaces import ( @@ -35,6 +39,81 @@ def input_fingerprint(*, content: Dict[str, Any], policy: str) -> str: return hashlib.sha256(canonical).hexdigest() +def edit_pending_input_content( + content: Dict[str, Any], update: PendingInputUpdate +) -> Dict[str, Any]: + edited = deepcopy(content) + data = edited.get("data") + inputs = data.get("inputs") if isinstance(data, dict) else None + messages = inputs.get("messages") if isinstance(inputs, dict) else None + if not isinstance(messages, list): + raise SessionInputContentInvalid( + "The queued input has no editable user message." + ) + message = next( + ( + item + for item in reversed(messages) + if isinstance(item, dict) and item.get("role") == "user" + ), + None, + ) + if message is None: + raise SessionInputContentInvalid( + "The queued input has no editable user message." + ) + original = message.get("content") + field = "content" + if isinstance(original, str): + if not update.attachments: + message[field] = update.text + return edited + blocks = [{"type": "text", "text": original}] + elif isinstance(original, list): + blocks = original + elif isinstance(message.get("parts"), list): + field = "parts" + blocks = message[field] + else: + raise SessionInputContentInvalid( + "The queued user message uses an unsupported content format." + ) + kept = [] + wrote_text = False + for block in blocks: + if isinstance(block, dict) and block.get("type") == "text": + if not wrote_text: + kept.append({**block, "text": update.text}) + wrote_text = True + else: + kept.append(block) + if not wrote_text and update.text: + kept.insert(0, {"type": "text", "text": update.text}) + uris = { + block.get("uri", block.get("url")) + for block in kept + if isinstance(block, dict) + and isinstance(block.get("uri", block.get("url")), str) + } + for attachment in update.attachments: + if attachment.uri in uris: + continue + if field == "parts": + block = { + "type": "file", + "url": attachment.uri, + "mediaType": attachment.mime_type, + } + if attachment.filename is not None: + block["filename"] = attachment.filename + else: + block = {"type": "attachment", **attachment.model_dump(exclude_none=True)} + kept.append(block) + uris.add(attachment.uri) + message[field] = kept + return edited + + class SessionInputsService: def __init__( self, @@ -275,3 +354,31 @@ async def remove( if existing is not None and existing.state != PendingInputState.pending: raise SessionInputNotRemovable(str(input_id)) raise SessionInputNotFound(str(input_id)) + + async def update( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + update: PendingInputUpdate, + ) -> PendingInput: + async with self._dao.transaction() as transaction: + item = await self._dao.lock_pending_for_edit( + project_id=project_id, + session_id=session_id, + input_id=input_id, + transaction=transaction, + ) + if item is None: + raise SessionInputNotFound(str(input_id)) + content = edit_pending_input_content(item.content, update) + return await self._dao.update_content( + project_id=project_id, + session_id=session_id, + input_id=input_id, + content=content, + user_id=user_id, + transaction=transaction, + ) diff --git a/api/oss/src/core/sessions/inputs/types.py b/api/oss/src/core/sessions/inputs/types.py index 1f23e82b8ee..0a351606e52 100644 --- a/api/oss/src/core/sessions/inputs/types.py +++ b/api/oss/src/core/sessions/inputs/types.py @@ -32,3 +32,15 @@ class SessionInputRemoved(SessionInputError): def __init__(self, input_id: str): self.input_id = input_id super().__init__("The queued input was removed and cannot be sent.") + + +class SessionInputNotEditable(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__( + "The queued input is no longer editable because it was removed, promoted, or selected to run next." + ) + + +class SessionInputContentInvalid(SessionInputError): + pass diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py index b31c57134fc..81096574842 100644 --- a/api/oss/src/dbs/postgres/sessions/inputs/dao.py +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -1,12 +1,15 @@ from datetime import datetime, timezone -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID from sqlalchemy import and_, func, or_, select, text, update as sa_update from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface -from oss.src.core.sessions.inputs.types import SessionInputNotRemovable +from oss.src.core.sessions.inputs.types import ( + SessionInputNotRemovable, + SessionInputNotEditable, +) from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE from oss.src.dbs.postgres.sessions.inputs.mappings import ( @@ -305,6 +308,7 @@ async def promote_next( transaction: Optional[Any] = None, ) -> Optional[PendingInput]: async def execute(session: Any) -> Optional[PendingInput]: + await self._lock_session(session, project_id, session_id) stmt = select(SessionInputDBE).where( SessionInputDBE.project_id == project_id, SessionInputDBE.session_id == session_id, @@ -333,3 +337,67 @@ async def execute(session: Any) -> Optional[PendingInput]: return await execute(transaction) async with self.engine.session() as session: return await execute(session) + + async def lock_pending_for_edit( + self, *, project_id: UUID, session_id: str, input_id: UUID, transaction: Any + ) -> Optional[PendingInput]: + await self._lock_session(transaction, project_id, session_id) + row = ( + await transaction.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + if row is None: + return None + reserved = ( + await transaction.execute( + select(SessionCommandDBE.id) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind == "cancel", + SessionCommandDBE.state.in_(("pending", "claimed")), + SessionCommandDBE.deleted_at.is_(None), + SessionCommandDBE.data["steer_input_id"].astext == str(input_id), + ) + .limit(1) + ) + ).scalar_one_or_none() + if row.state != "pending" or reserved is not None: + raise SessionInputNotEditable(str(input_id)) + return to_pending_input(row) + + async def update_content( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + content: Dict[str, Any], + user_id: Optional[UUID], + transaction: Any, + ) -> PendingInput: + row = ( + await transaction.execute( + sa_update(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + SessionInputDBE.state == "pending", + ) + .values( + content=content, + updated_at=datetime.now(timezone.utc), + updated_by_id=user_id, + ) + .returning(SessionInputDBE) + ) + ).scalar_one() + return to_pending_input(row) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 84f4fe7be0e..19abce4857c 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -24,10 +24,22 @@ from oss.src.core.sessions.commands.service import SessionCommandsService from oss.src.core.sessions.commands.types import ExecutionExpectationFailed from oss.src.core.sessions.executions.dtos import SessionExecutionState -from oss.src.core.sessions.inputs.dtos import PendingInputCreate, PendingInputState -from oss.src.core.sessions.inputs.service import SessionInputsService, input_fingerprint +from oss.src.core.sessions.inputs.dtos import ( + PendingInputCreate, + PendingInputState, + PendingInputUpdate, + PendingInputAttachment, +) +from oss.src.core.sessions.inputs.service import ( + SessionInputsService, + input_fingerprint, + edit_pending_input_content, +) from oss.src.core.sessions.inputs.types import ( SessionInputBusy, + SessionInputNotEditable, + SessionInputContentInvalid, + SessionInputNotFound, SessionInputNotRemovable, SessionInputRemoved, ) @@ -1350,3 +1362,231 @@ async def pause_reserved(**kwargs): ) removed = await inputs.remove_pending(**args) assert removed.state == PendingInputState.removed + + +@pytest.mark.asyncio +async def test_edit_pending_preserves_payload_identity_and_retry_attachments( + input_scope, +): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + values = _input(input_scope, key="edit-existing", message="unused") + values.content = { + "data": { + "inputs": { + "messages": [ + {"role": "system", "content": "history"}, + { + "id": "user-id", + "role": "user", + "parts": [ + {"type": "text", "text": "before"}, + { + "type": "file", + "url": "agenta://old", + "mediaType": "text/plain", + "opaque": True, + }, + ], + }, + ] + }, + "parameters": {"agent": {"instructions": "keep-config"}}, + }, + "references": {"revision": {"id": "keep-revision"}}, + } + values.request_fingerprint = input_fingerprint( + content=values.content, policy=values.policy + ) + row = await dao.create_input(user_id=input_scope["user_id"], pending_input=values) + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="agenta://new", mime_type="text/plain", filename="new.txt" + ) + ], + ) + for _ in range(2): + edited = await service.update( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=update, + ) + original_retry = await service.admit( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + user_id=input_scope["user_id"], + content=values.content, + policy=values.policy, + idempotency_key=values.idempotency_key, + ) + assert original_retry.input.id == row.id + assert original_retry.input.content == edited.content + assert edited.id == row.id and edited.position == row.position + assert ( + edited.request_fingerprint == row.request_fingerprint + and edited.idempotency_key == row.idempotency_key + ) + assert edited.content["references"] == values.content["references"] + assert edited.content["data"]["parameters"] == values.content["data"]["parameters"] + messages = edited.content["data"]["inputs"]["messages"] + assert messages[0] == values.content["data"]["inputs"]["messages"][0] + assert messages[1]["id"] == "user-id" + assert messages[1]["parts"] == [ + {"type": "text", "text": "after"}, + { + "type": "file", + "url": "agenta://old", + "mediaType": "text/plain", + "opaque": True, + }, + { + "type": "file", + "url": "agenta://new", + "mediaType": "text/plain", + "filename": "new.txt", + }, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("row_state", ["pending", "claimed", "promoted", "removed"]) +async def test_edit_pending_rejects_promoted_and_reserved_rows(input_scope, row_state): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + row = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-reserved", message="original"), + ) + if row_state in ("pending", "claimed"): + await _pending_command(input_scope, data={"steer_input_id": str(row.id)}) + if row_state == "claimed": + async with input_scope["engine"].session() as tx: + await tx.execute( + text( + "UPDATE session_commands SET state='claimed' WHERE project_id=:project" + ), + {"project": input_scope["project_id"]}, + ) + else: + async with input_scope["engine"].session() as tx: + await tx.execute( + text("UPDATE session_inputs SET state=:state WHERE id=:id"), + {"state": row_state, "id": row.id}, + ) + with pytest.raises(SessionInputNotEditable): + await service.update( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=PendingInputUpdate(text="changed"), + ) + stored = await dao.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + assert stored.content == row.content + + +@pytest.mark.asyncio +async def test_promotion_waits_for_edited_head_instead_of_skipping_it(input_scope): + dao = SessionInputsDAO(engine=input_scope["engine"]) + first = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-first", message="first"), + ) + await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-second", message="second"), + ) + async with dao.transaction() as tx: + await dao.lock_pending_for_edit( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + transaction=tx, + ) + promotion = asyncio.create_task( + dao.promote_next( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="next", + ) + ) + await asyncio.sleep(0.05) + assert not promotion.done() + await dao.update_content( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + content={"edited": "head"}, + user_id=input_scope["user_id"], + transaction=tx, + ) + promoted = await asyncio.wait_for(promotion, 2) + assert promoted.id == first.id + assert promoted.content == {"edited": "head"} + + +@pytest.mark.asyncio +async def test_edit_pending_scope_and_invalid_content_leave_row_unchanged(input_scope): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + row = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="invalid-edit", message="opaque"), + ) + args = dict( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=PendingInputUpdate(text="new"), + ) + with pytest.raises(SessionInputNotFound): + await service.update(**{**args, "project_id": uuid.uuid4()}) + with pytest.raises(SessionInputContentInvalid): + await service.update(**args) + stored = await dao.fetch_input( + project_id=args["project_id"], session_id=args["session_id"], input_id=row.id + ) + assert stored.content == row.content + + +@pytest.mark.parametrize( + "original", + [ + "before", + [ + {"type": "text", "text": "before"}, + {"type": "attachment", "uri": "agenta://old", "opaque": True}, + ], + ], +) +def test_edit_pending_canonical_content_keeps_attachments(original): + content = { + "data": {"inputs": {"messages": [{"role": "user", "content": original}]}} + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment(uri="agenta://new", mime_type="text/plain") + ], + ) + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + blocks = edited["data"]["inputs"]["messages"][0]["content"] + assert blocks[0] == {"type": "text", "text": "after"} + assert blocks[-1] == { + "type": "attachment", + "uri": "agenta://new", + "mime_type": "text/plain", + } + if isinstance(original, list): + assert blocks[1] == original[1] + assert content["data"]["inputs"]["messages"][0]["content"] == original diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 363a2fb1426..6a70c756aea 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -640,7 +640,7 @@ export const LiveConversation = ({ /> {/* What you have lined up stays visible while a gate is open: the queued message is the acknowledgement that the user's Send was not lost. */} - {conversation.queued.length > 0 ? ( + {conversation.queued.length > 0 || conversation.editingId ? (
{ + onSend={async ({text, parts}) => { setStoppingHere(false) // An open edit rewrites its held message instead of sending. The // input clears on submit, so the displaced draft goes back after. if (!conversation.editingId) { - conversation.send({text, parts}) + await conversation.send({text, parts}) return } - const draft = conversation.commitEdit({text, fileParts: parts}) + const draft = await conversation.commitEdit({ + text, + fileParts: parts, + }) if (draft) requestAnimationFrame(() => composerRef.current?.setMarkdown(draft), diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 805f6e3bc17..31f361c6f23 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -712,7 +712,7 @@ const AgentConversation = ({ if (editingId) { // A rewrite of a held message: nothing is sent, so the transcript must not move. // The input clears itself on submit, so the displaced draft goes back after that. - const draft = commitEdit({text: trimmed, fileParts, stagedFiles}) + const draft = await commitEdit({text: trimmed, fileParts, stagedFiles}) if (draft) requestAnimationFrame(() => richInputRef.current?.setMarkdown(draft)) } else { // Glide to the bottom; the min-h-full active turn makes that show the new question at the diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx index 0c2ff15a608..ac06afc3f18 100644 --- a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx @@ -35,7 +35,7 @@ const AgentQueuedMessagesDock = ({ editingId, className, }: AgentQueuedMessagesDockProps) => { - const open = queued.length > 0 + const open = queued.length > 0 || !!editingId // Latch the last non-empty queue: emptying it starts the collapse, and without this the rows // would vanish first and leave an empty box folding shut. const shownRef = useRef(queued) diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 1cce2ad1799..67a332dabff 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2803,6 +2803,86 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); } + /** + * @param {AgentaApi.PendingInputUpdateRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.updatePendingSessionInput({ + * session_id: "session_id", + * input_id: "input_id", + * text: "text" + * }) + */ + public updatePendingSessionInput( + request: AgentaApi.PendingInputUpdateRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__updatePendingSessionInput(request, requestOptions)); + } + + private async __updatePendingSessionInput( + request: AgentaApi.PendingInputUpdateRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId, ..._body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}/inputs/${core.url.encodePathParam(inputId)}`, + ), + method: "PATCH", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.PendingInputResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "PATCH", + "/sessions/{session_id}/inputs/{input_id}", + ); + } + + /** * @param {AgentaApi.SendPendingSessionInputNowRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts new file mode 100644 index 00000000000..f02ff509a98 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * session_id: "session_id", + * input_id: "input_id", + * text: "text" + * } + */ +export interface PendingInputUpdateRequest { + session_id: string; + input_id: string; + text: string; + attachments?: AgentaApi.PendingInputAttachment[]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 3baf4850e75..b1a49966361 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -37,3 +37,4 @@ export type { UnarchiveSessionRequest } from "./UnarchiveSessionRequest.js"; export type { WatchProjectRequest } from "./WatchProjectRequest.js"; export type { WatchSessionStreamRequest } from "./WatchSessionStreamRequest.js"; export type { SendPendingSessionInputNowRequest } from "./SendPendingSessionInputNowRequest.js"; +export { type PendingInputUpdateRequest } from "./PendingInputUpdateRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts new file mode 100644 index 00000000000..be79885ff3e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PendingInputAttachment { + uri: string; + mime_type: string; + filename?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 9eca31cdcfc..21811f862d9 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -718,3 +718,4 @@ export * from "./WorkspaceMemberResponse.js"; export * from "./WorkspacePermission.js"; export * from "./WorkspaceResponse.js"; export * from "./PendingInputAdmissionResponse.js"; +export * from "./PendingInputAttachment.js"; diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts index bd6dc62936b..b5fb5bcd02c 100644 --- a/web/packages/agenta-chat/src/assets/pendingInputs.ts +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -76,7 +76,7 @@ export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedM attachmentCount, policy: input.policy, source: "server", - editable: false, + editable: input.state === "pending", } } diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index be6645bf481..6459f9eb0e4 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -125,21 +125,11 @@ const Row = ({ {attachmentCount ? "(attachments only)" : "(empty message)"} )} - {attachmentCount > files.length ? ( - - {attachmentCount} attachment{attachmentCount === 1 ? "" : "s"} - - ) : null} - {message.policy === "steer" ? ( - - Steer - - ) : null} - {/* Revealed on hover, but always present for keyboard and while this row is under - edit — an action you can only reach with a pointer is not an action on mobile. */} + {/* Keep Send Now discoverable without hovering. Other row actions also stay visible + during editing, keyboard focus, and on touch surfaces. */} message.id === editingId) + return (
{/* px-3 so the icon starts on the same 13px line as the row text below it and the @@ -280,6 +272,14 @@ const QueuedMessagesDock = ({ />
+ {editingMissingRow ? ( +
+ This message is no longer queued. + +
+ ) : null} {/* The composer sits directly below, so a hard mount/unmount teleports it by the body's full height. `HeightCollapse` is the app's one collapse primitive — the same motion as the accordion sections and the sibling docks — and it owns aria-hidden diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index b07e17f2fb1..eec04acb411 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -32,6 +32,7 @@ export interface ServerQueueAdapter { submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise remove: (id: string) => Promise sendNow?: (id: string) => Promise + edit?: (id: string, item: {text: string; fileParts?: FileUIPart[]}) => Promise } interface UseAgentChatQueueArgs { @@ -327,12 +328,17 @@ export const useAgentChatQueue = ({ // clicking edit on a half-written message would silently destroy it. const [editingId, setEditingId] = useState(null) const stashRef = useRef("") + const editSessionRef = useRef<{server: boolean} | null>(null) /** Open a session on `id`, stashing the composer's current draft. */ - const beginEdit = useCallback((id: string, draft = "") => { - stashRef.current = draft - setEditingId(id) - }, []) + const beginEdit = useCallback( + (id: string, draft = "") => { + editSessionRef.current = {server: !!server?.queued.some((message) => message.id === id)} + stashRef.current = draft + setEditingId(id) + }, + [server], + ) /** Take the stashed draft back, once. Both ends of a session hand the composer back. */ const takeStash = useCallback(() => { @@ -343,6 +349,7 @@ export const useAgentChatQueue = ({ /** Close the session without touching the message. Returns the draft to restore. */ const cancelEdit = useCallback(() => { + editSessionRef.current = null setEditingId(null) return takeStash() }, [takeStash]) @@ -355,8 +362,7 @@ export const useAgentChatQueue = ({ * Attachments MERGE rather than replace — the composer only submits newly staged files, so * replacing would delete the queued message's originals on every text-only edit. * - * The queue drains on its own, so the target can leave mid-edit. Nothing is left to rewrite - * then, and the content becomes a new queued message instead of vanishing. + * A drained local target becomes a new message; durable edits instead preserve server refusal. * * Returns the stashed draft, exactly as `cancelEdit` does: committing consumes the composer, * so the text the session displaced has to come back here too or it is lost for good. @@ -364,6 +370,23 @@ export const useAgentChatQueue = ({ const commitEdit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const id = editingId + const editSession = editSessionRef.current + if (id && editSession?.server) { + const save = server?.edit + if (!save) return Promise.reject(new Error("This queued message cannot be edited.")) + return save(id, item).then( + () => { + if (editSessionRef.current !== editSession) return "" + editSessionRef.current = null + setEditingId(null) + return takeStash() + }, + (error: unknown) => { + if (editSessionRef.current !== editSession) return "" + throw error + }, + ) + } setEditingId(null) const draft = takeStash() const target = id ? queuedRef.current.find((m) => m.id === id) : undefined @@ -392,7 +415,7 @@ export const useAgentChatQueue = ({ ) return draft }, - [editingId, submit, takeStash], + [editingId, server, submit, takeStash], ) // Release the queue head once the stream settles; the latch caps it at one per settle. Both diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index ac520835d05..83977005aba 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -208,7 +208,7 @@ export interface AgentConversation { cancelEdit: () => string /** Rewrite the edited message with the composer's content (or queue it anew if it drained). * Returns the draft the session displaced, for the host to put back. */ - commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string + commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string | Promise /** Headless approval-dock state wired to the live-gate-aware response path. */ approvals: ApprovalDock /** Settle a parked client tool part (widgets call this; the resume predicate auto-resends). */ diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index 048137e37f7..6ab1603cdc0 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -5,9 +5,10 @@ import { fetchSessionSnapshotAtom, removePendingSessionInputAtom, sendPendingSessionInputNowAtom, + updatePendingSessionInputAtom, } from "@agenta/entities/session" import {buildAgentRequest} from "@agenta/playground/agent-chat" -import type {UIMessage} from "ai" +import type {FileUIPart, UIMessage} from "ai" import {useSetAtom} from "jotai" import {reduceSessionPendingInputs, type SessionPendingInputView} from "../assets/pendingInputs" @@ -22,6 +23,7 @@ export interface ServerSessionInputs { submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise remove: (id: string) => Promise sendNow: (id: string) => Promise + edit: (id: string, item: {text: string; fileParts?: FileUIPart[]}) => Promise refresh: () => Promise } @@ -47,6 +49,7 @@ export const useServerSessionInputs = ({ const fetchCapabilities = useSetAtom(fetchSessionCapabilitiesAtom) const removeInput = useSetAtom(removePendingSessionInputAtom) const sendInputNow = useSetAtom(sendPendingSessionInputNowAtom) + const updateInput = useSetAtom(updatePendingSessionInputAtom) const [viewState, setViewState] = useState<{sessionId: string; view: SessionPendingInputView}>( () => ({sessionId, view: emptyView}), ) @@ -172,6 +175,25 @@ export const useServerSessionInputs = ({ [refresh, removeInput, sessionId], ) + const edit = useCallback( + async (id: string, item: {text: string; fileParts?: FileUIPart[]}) => { + if (!view.capabilities.queue) throw new Error("Queue editing is not available.") + const updated = await updateInput({ + sessionId, + inputId: id, + text: item.text, + attachments: item.fileParts?.map((part) => ({ + uri: part.url, + mime_type: part.mediaType, + ...(part.filename ? {filename: part.filename} : {}), + })), + }) + if (!updated) throw new Error("The queued message could not be updated. Try again.") + await refresh() + }, + [refresh, sessionId, updateInput, view.capabilities.queue], + ) + const sendNow = useCallback( async (id: string) => { if (!view.capabilities.queue || !view.capabilities.steer) { @@ -193,6 +215,7 @@ export const useServerSessionInputs = ({ submit, remove, sendNow, + edit, refresh, } } diff --git a/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx index 3c8e463b9f7..3ad9a88d939 100644 --- a/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx +++ b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx @@ -1,5 +1,9 @@ +// @vitest-environment jsdom import {renderToStaticMarkup} from "react-dom/server" -import {describe, expect, it} from "vitest" +import {cleanup, fireEvent, render, screen} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" + +afterEach(cleanup) import QueuedMessagesDock from "../../src/components/QueuedMessagesDock" @@ -17,3 +21,20 @@ describe("QueuedMessagesDock", () => { expect(markup).toContain("continue afterward") }) }) + +it.each([false, true])( + "keeps cancel editing reachable after the edited row leaves (touch=%s)", + (touch) => { + const cancel = vi.fn() + const props = {onRemove: vi.fn(), onCancelEdit: cancel, editingId: "edited", touch} + const {rerender} = render( + , + ) + fireEvent.click(screen.getByRole("button", {name: "Collapse"})) + rerender() + expect(screen.getByText("This message is no longer queued.")).toBeTruthy() + fireEvent.click(screen.getByRole("button", {name: "Cancel editing"})) + expect(cancel).toHaveBeenCalledOnce() + expect(props.onRemove).not.toHaveBeenCalled() + }, +) diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts index f6a77f869f2..9ab74111f83 100644 --- a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -48,7 +48,7 @@ describe("pending input reducer", () => { ]) }) - it("reduces neutral text and attachment blocks without making server rows editable", () => { + it("makes pending rows editable while preserving opaque attachment counts", () => { const queued = pendingInputToQueuedMessage( input("input-1", 1, [ {type: "text", text: "Check this"}, @@ -62,7 +62,7 @@ describe("pending input reducer", () => { text: "Check this", attachmentCount: 2, source: "server", - editable: false, + editable: true, }) expect(queued?.fileParts).toEqual([ { @@ -91,7 +91,12 @@ describe("pending input reducer", () => { }) expect(view.queued).toEqual([ - expect.objectContaining({id: "input-1", text: "retry me", source: "server"}), + expect.objectContaining({ + id: "input-1", + text: "retry me", + source: "server", + editable: false, + }), ]) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 312c0c2243d..f4d89c34e44 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -784,3 +784,111 @@ describe("useAgentChatQueue: reclaiming a sent message", () => { expect(result.current.takeLastSent()).toMatchObject({text: "held"}) }) }) + +describe("durable queued edits", () => { + it("keeps the edit and draft until same-row persistence succeeds, including a retry", async () => { + const edit = vi + .fn() + .mockRejectedValueOnce(new Error("conflict")) + .mockResolvedValueOnce(undefined) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [ + {id: "first", text: "first", source: "server"}, + {id: "selected", text: "old", source: "server"}, + ], + submit: vi.fn(), + remove: vi.fn(), + edit, + } + const {result, sendQueued} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("selected", "original draft")) + await act(async () => { + await expect(result.current.commitEdit({text: "new"})).rejects.toThrow("conflict") + }) + expect(result.current.editingId).toBe("selected") + expect(result.current.queued.map((row) => row.id)).toEqual(["first", "selected"]) + let restored: string | undefined + await act(async () => { + restored = await result.current.commitEdit({text: "new"}) + }) + expect(restored).toBe("original draft") + expect(result.current.editingId).toBeNull() + expect(edit).toHaveBeenNthCalledWith(2, "selected", {text: "new"}) + expect(server.submit).not.toHaveBeenCalled() + expect(server.remove).not.toHaveBeenCalled() + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("does not submit a new message if the durable row leaves the queue during editing", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [{id: "selected", text: "old", source: "server"}], + submit: vi.fn(), + remove: vi.fn(), + edit: vi.fn().mockRejectedValue(new Error("already promoted")), + } + const {result, rerender, sendQueued} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("selected", "draft")) + rerender({...settledEmpty, server: {...server, queued: []}}) + await act(async () => { + await expect(result.current.commitEdit({text: "new"})).rejects.toThrow( + "already promoted", + ) + }) + expect(result.current.editingId).toBe("selected") + expect(server.submit).not.toHaveBeenCalled() + expect(sendQueued).not.toHaveBeenCalled() + let restored = "" + act(() => { + restored = result.current.cancelEdit() + }) + expect(restored).toBe("draft") + }) +}) + +it.each([false, true])( + "does not overwrite a newer edit when an older save settles (failure=%s)", + async (failure) => { + let resolve!: () => void + let reject!: (error: Error) => void + const edit = vi.fn( + () => + new Promise((yes, no) => { + resolve = yes + reject = no + }), + ) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [ + {id: "first", text: "old", source: "server"}, + {id: "second", text: "other", source: "server"}, + ], + submit: vi.fn(), + remove: vi.fn(), + edit, + } + const {result} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("first", "original draft")) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "changed"}) + }) + act(() => result.current.beginEdit("second", "new draft")) + await act(async () => { + if (failure) reject(new Error("old failure")) + else resolve() + expect(await saving).toBe("") + }) + expect(result.current.editingId).toBe("second") + let restored = "" + act(() => { + restored = result.current.cancelEdit() + }) + expect(restored).toBe("new draft") + }, +) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 9ad2bef4188..2c132ec0fb9 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -14,15 +14,21 @@ import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" -const {buildAgentRequest, fetchCapabilities, fetchSnapshot, removeInput, sendInputNow} = vi.hoisted( - () => ({ - buildAgentRequest: vi.fn(), - fetchCapabilities: vi.fn(), - fetchSnapshot: vi.fn(), - removeInput: vi.fn(), - sendInputNow: vi.fn(), - }), -) +const { + buildAgentRequest, + fetchCapabilities, + fetchSnapshot, + removeInput, + sendInputNow, + updateInput, +} = vi.hoisted(() => ({ + buildAgentRequest: vi.fn(), + fetchCapabilities: vi.fn(), + fetchSnapshot: vi.fn(), + removeInput: vi.fn(), + sendInputNow: vi.fn(), + updateInput: vi.fn(), +})) vi.mock("@agenta/entities/session", async () => { const {atom} = await import("jotai") @@ -33,6 +39,7 @@ vi.mock("@agenta/entities/session", async () => { fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => fetchSnapshot(sessionId), ), + updatePendingSessionInputAtom: atom(null, (_get, _set, params) => updateInput(params)), sendPendingSessionInputNowAtom: atom( null, (_get, _set, params: {sessionId: string; inputId: string}) => sendInputNow(params), @@ -79,6 +86,7 @@ beforeEach(() => { fetchSnapshot.mockReset() removeInput.mockReset() sendInputNow.mockReset() + updateInput.mockReset() fetchMock.mockReset() }) @@ -636,3 +644,48 @@ describe("selected queued input Send Now", () => { expect(sendInputNow).not.toHaveBeenCalled() }) }) + +describe("durable queued input editing", () => { + it("patches only the chosen row text and new attachments, then reloads the shared snapshot", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + updateInput.mockResolvedValue(true) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + await act(() => + result.current.edit("row-2", { + text: "corrected", + fileParts: [ + { + type: "file", + url: "https://files.test/new.pdf", + mediaType: "application/pdf", + filename: "new.pdf", + }, + ], + }), + ) + expect(updateInput).toHaveBeenCalledWith({ + sessionId: "session-1", + inputId: "row-2", + text: "corrected", + attachments: [ + { + uri: "https://files.test/new.pdf", + mime_type: "application/pdf", + filename: "new.pdf", + }, + ], + }) + expect(fetchSnapshot.mock.calls.length).toBeGreaterThan(1) + expect(removeInput).not.toHaveBeenCalled() + expect(buildAgentRequest).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 6c12e799a26..519e231bbab 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -195,6 +195,29 @@ export async function removePendingSessionInput({ return !!data } +export async function updatePendingSessionInput({ + sessionId, + projectId, + appId, + abortSignal, + inputId, + text, + attachments, +}: SessionScopedParams & { + inputId: string + text: string + attachments?: {uri: string; mime_type: string; filename?: string}[] +}): Promise { + if (!projectId || !sessionId || !inputId) return false + const data = await callFern("[updatePendingSessionInput]", () => + getSessionsClient().updatePendingSessionInput( + {session_id: sessionId, input_id: inputId, text, attachments}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + return !!data +} + export async function sendPendingSessionInputNow({ sessionId, projectId, diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index b04a91cfdee..5b1189e978b 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -23,6 +23,7 @@ export { fetchSessionDurableApprovalsCapability, removePendingSessionInput, sendPendingSessionInputNow, + updatePendingSessionInput, invalidateSessionDurableApprovalsCapability, commandSessionStream, cancelSessionExecution, @@ -110,6 +111,7 @@ export { fetchSessionSnapshotAtom, removePendingSessionInputAtom, sendPendingSessionInputNowAtom, + updatePendingSessionInputAtom, } from "./state/pendingInputs" export { deriveStreamNest, diff --git a/web/packages/agenta-entities/src/session/state/pendingInputs.ts b/web/packages/agenta-entities/src/session/state/pendingInputs.ts index d9ce53c7273..035f8b6b821 100644 --- a/web/packages/agenta-entities/src/session/state/pendingInputs.ts +++ b/web/packages/agenta-entities/src/session/state/pendingInputs.ts @@ -6,6 +6,7 @@ import { fetchSessionSnapshot, removePendingSessionInput, sendPendingSessionInputNow, + updatePendingSessionInput, } from "../api/api" export const fetchSessionCapabilitiesAtom = atom(null, async (get, _set, sessionId: string) => { @@ -33,3 +34,20 @@ export const sendPendingSessionInputNowAtom = atom( return sendPendingSessionInputNow({projectId, ...params}) }, ) + +export const updatePendingSessionInputAtom = atom( + null, + async ( + get, + _set, + params: { + sessionId: string + inputId: string + text: string + attachments?: {uri: string; mime_type: string; filename?: string}[] + }, + ) => { + const projectId = get(projectIdAtom) ?? "" + return updatePendingSessionInput({projectId, ...params}) + }, +) diff --git a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx index cde60e8d314..bcbbe302caa 100644 --- a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx +++ b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx @@ -84,11 +84,11 @@ export const ServerBacked: Story = { touch editable initial={[ - {...THREE[0], source: "server", editable: false, policy: "steer"}, + {...THREE[0], source: "server", editable: true, policy: "steer"}, { ...THREE[1], source: "server", - editable: false, + editable: true, policy: "queue", attachmentCount: 1, }, @@ -168,3 +168,17 @@ export const SendNowFailure: Story = { /> ), } + +export const EditedRowNoLongerQueued: Story = { + render: () => ( + + + + ), +} From 8bc8629cbb2d5fe4a0556b48091b15846a8a54c6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 19:43:28 +0200 Subject: [PATCH 129/133] style(chat): shorten queued action comment --- web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index 03c40142462..6b23cdcd7b2 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -135,8 +135,7 @@ const Row = ({ Steer
) : null} - {/* Keep Send Now discoverable without hovering. Other row actions also stay visible - during editing, keyboard focus, and on touch surfaces. */} + {/* Keep Send Now visible without hover. */} Date: Sun, 6 Sep 2026 20:06:13 +0200 Subject: [PATCH 130/133] fix(chat): preserve queued edit ownership during admission --- .../src/hooks/useAgentChatQueue.ts | 56 +++++- .../unit/hooks/useAgentChatQueue.test.ts | 159 ++++++++++++++++++ .../agenta-entities/src/session/api/api.ts | 6 +- .../src/session/core/schema.ts | 4 + .../session-continuation-resume-api.test.ts | 36 +++- 5 files changed, 251 insertions(+), 10 deletions(-) diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index f32be28cf11..9c2559afd06 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -215,9 +215,19 @@ export const useAgentChatQueue = ({ return message }, []) + const [editingId, setEditingId] = useState(null) + const stashRef = useRef("") + const editSessionRef = useRef<{id: string; server: boolean} | null>(null) + // A message held before an approval answer predates the server-owned continuation. Move it // under the same durable admission before that continuation can promote a different input. const migrationRef = useRef(null) + const migrationPromiseRef = useRef<{ + id: string + promise: Promise + retry: () => Promise + failed: boolean + } | null>(null) const migrationRetryTimerRef = useRef | null>(null) const [migrationRetry, setMigrationRetry] = useState(0) useEffect( @@ -235,14 +245,16 @@ export const useAgentChatQueue = ({ !server?.capabilities.queue || !submitToServer || !head || + editingId === head.id || migrationRef.current ) { return } migrationRef.current = head.id - void submitToServer(head, "queue") - .then(() => { + const retry = () => + submitToServer(head, "queue").then(() => { + if (editSessionRef.current?.id === head.id) editSessionRef.current.server = true if (sessionId) { const stored = queuedBySession.get(sessionId) if (stored) { @@ -253,7 +265,11 @@ export const useAgentChatQueue = ({ } setQueued((items) => items.filter((item) => item.id !== head.id)) }) + const migration = {id: head.id, promise: retry(), retry, failed: false} + migrationPromiseRef.current = migration + void migration.promise .catch(() => { + migration.failed = true if (migrationRef.current !== head.id) return migrationRef.current = null migrationRetryTimerRef.current = setTimeout(() => { @@ -264,10 +280,13 @@ export const useAgentChatQueue = ({ }) .finally(() => { if (migrationRef.current === head.id) migrationRef.current = null + if (migrationPromiseRef.current === migration && !migration.failed) + migrationPromiseRef.current = null }) }, [ continuationExecutionId, continuationHold, + editingId, migrationRetry, queued, sessionId, @@ -344,14 +363,14 @@ export const useAgentChatQueue = ({ // An edit session BORROWS the composer: the target's text goes in, and whatever the user had // already typed is stashed and handed back when the session ends (either way). Without that, // clicking edit on a half-written message would silently destroy it. - const [editingId, setEditingId] = useState(null) - const stashRef = useRef("") - const editSessionRef = useRef<{server: boolean} | null>(null) /** Open a session on `id`, stashing the composer's current draft. */ const beginEdit = useCallback( (id: string, draft = "") => { - editSessionRef.current = {server: !!server?.queued.some((message) => message.id === id)} + editSessionRef.current = { + id, + server: !!server?.queued.some((message) => message.id === id), + } stashRef.current = draft setEditingId(id) }, @@ -389,10 +408,31 @@ export const useAgentChatQueue = ({ (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const id = editingId const editSession = editSessionRef.current - if (id && editSession?.server) { + const migration = + migrationPromiseRef.current?.id === id ? migrationPromiseRef.current : null + if ( + id && + (editSession?.server || + migration || + server?.queued.some((message) => message.id === id)) + ) { const save = server?.edit if (!save) return Promise.reject(new Error("This queued message cannot be edited.")) - return save(id, item).then( + if (editSession && server?.queued.some((message) => message.id === id)) + editSession.server = true + if (migration?.failed) { + migration.failed = false + migration.promise = migration.retry().catch((error: unknown) => { + migration.failed = true + throw error + }) + } + const saved = migration + ? migration.promise.then(() => + editSessionRef.current === editSession ? save(id, item) : undefined, + ) + : save(id, item) + return saved.then( () => { if (editSessionRef.current !== editSession) return "" editSessionRef.current = null diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 33fb125b40b..448a45b68e9 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -1073,3 +1073,162 @@ it("refuses cold Steer if the session settles while capabilities resolve", async expect(server.submit).not.toHaveBeenCalled() expect(view.sendQueued).not.toHaveBeenCalled() }) + +it("holds local-to-server migration while its row is being edited", async () => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + act(() => result.current.beginEdit(id, "draft")) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + edit: vi.fn(), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + expect(server.submit).not.toHaveBeenCalled() + await act(async () => { + await result.current.commitEdit({text: "edited before migration"}) + }) + expect(server.submit).toHaveBeenCalledOnce() + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({id, text: "edited before migration"}), + "queue", + ) +}) + +it.each(["pending", "accepted", "promoted"] as const)( + "keeps same-row durable editing when migration is %s and snapshot lags", + async (state) => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender, sendQueued} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + let accept!: () => void + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn( + () => + new Promise((resolve) => { + accept = resolve + }), + ), + remove: vi.fn(), + edit: + state === "promoted" + ? vi.fn().mockRejectedValue(new Error("already promoted")) + : vi.fn().mockResolvedValue(undefined), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + expect(server.submit).toHaveBeenCalledOnce() + act(() => result.current.beginEdit(id, "original draft")) + if (state !== "pending") + await act(async () => { + accept() + await Promise.resolve() + }) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "corrected"}) + }) + if (state === "pending") { + expect(server.edit).not.toHaveBeenCalled() + await act(async () => { + accept() + expect(await saving).toBe("original draft") + }) + } else if (state === "promoted") { + await act(async () => { + await expect(saving).rejects.toThrow("already promoted") + }) + expect(result.current.editingId).toBe(id) + } else { + await act(async () => { + expect(await saving).toBe("original draft") + }) + } + expect(server.edit).toHaveBeenCalledWith(id, {text: "corrected"}) + expect(server.submit).toHaveBeenCalledOnce() + expect(sendQueued).not.toHaveBeenCalled() + }, +) + +it("retains observed server ownership after a failed edit and a later missing snapshot row", async () => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + act(() => result.current.beginEdit(id, "draft")) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [{id, text: "old", source: "server"}], + submit: vi.fn(), + remove: vi.fn(), + edit: vi + .fn() + .mockRejectedValueOnce(new Error("retry")) + .mockRejectedValueOnce(new Error("promoted")), + } + rerender({...props, server}) + await act(async () => { + await expect(result.current.commitEdit({text: "corrected"})).rejects.toThrow("retry") + }) + rerender({...props, server: {...server, queued: []}}) + await act(async () => { + await expect(result.current.commitEdit({text: "corrected"})).rejects.toThrow("promoted") + }) + expect(server.edit).toHaveBeenCalledTimes(2) + expect(server.submit).not.toHaveBeenCalled() + expect(result.current.queued[0].text).toBe("old") +}) + +it("replays the original admission after an ambiguous migration failure before patching the edit", async () => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender, unmount} = setup(props) + act(() => result.current.submit({text: "original admission"})) + const original = result.current.queued[0] + let reject!: (error: Error) => void + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi + .fn() + .mockImplementationOnce( + () => + new Promise((_yes, no) => { + reject = no + }), + ) + .mockResolvedValueOnce(undefined), + remove: vi.fn(), + edit: vi.fn().mockResolvedValue(undefined), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + act(() => result.current.beginEdit(original.id, "draft")) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "corrected"}) + }) + await act(async () => { + reject(new Error("response lost")) + await expect(saving).rejects.toThrow("response lost") + }) + expect(result.current.editingId).toBe(original.id) + expect(server.edit).not.toHaveBeenCalled() + await act(async () => { + expect(await result.current.commitEdit({text: "corrected"})).toBe("draft") + }) + expect(server.submit).toHaveBeenNthCalledWith(1, original, "queue") + expect(server.submit).toHaveBeenNthCalledWith(2, original, "queue") + expect(server.edit).toHaveBeenCalledOnce() + expect(server.edit).toHaveBeenCalledWith(original.id, {text: "corrected"}) + expect(result.current.queued).toEqual([]) + unmount() +}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 488461fce9b..e885853bd8d 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -14,6 +14,7 @@ import {safeParseWithLogging} from "../../shared/utils/zodSchema" import { mountFileContentResponseSchema, pendingInputAdmissionResponseSchema, + pendingInputResponseSchema, mountFileListResponseSchema, sessionInteractionResponseSchema, sessionInteractionsResponseSchema, @@ -216,7 +217,10 @@ export async function updatePendingSessionInput({ projectScopedRequest(projectId, appId, abortSignal), ), ) - return !!data + return ( + safeParseWithLogging(pendingInputResponseSchema, data, "[updatePendingSessionInput]") !== + null + ) } export async function sendPendingSessionInputNow({ diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index fe0be143a2d..7cc6a2d8b66 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -265,6 +265,10 @@ export const pendingSessionInputSchema = z.object({ promoted_execution_id: z.string().nullish(), }) +export const pendingInputResponseSchema = z.object({ + input: pendingSessionInputSchema, +}) + export const pendingInputAdmissionResponseSchema = z.object({ action: z.enum(["execute", "pending"]), input: pendingSessionInputSchema.nullish(), diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts index 0cb08d4ca37..92e946afe97 100644 --- a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -1,16 +1,18 @@ import type {SessionCapabilities, SessionStreamResponse} from "@agentaai/api-client" import {beforeEach, describe, expect, expectTypeOf, it, vi} from "vitest" -const {resume, fetchStream, sendNow} = vi.hoisted(() => ({ +const {resume, fetchStream, sendNow, updateInput} = vi.hoisted(() => ({ resume: vi.fn(), fetchStream: vi.fn(), sendNow: vi.fn(), + updateInput: vi.fn(), })) vi.mock("@agenta/sdk/resources", () => ({ getSessionsClient: () => ({ resumeSessionContinuation: resume, sendPendingSessionInputNow: sendNow, + updatePendingSessionInput: updateInput, fetchSessionStream: fetchStream, }), getLowPrioritySessionsClient: vi.fn(), @@ -20,6 +22,7 @@ vi.mock("@agenta/sdk/resources", () => ({ import { fetchSessionCapabilities, + updatePendingSessionInput, sendPendingSessionInputNow, fetchSessionDurableApprovalsCapability, invalidateSessionDurableApprovalsCapability, @@ -201,3 +204,34 @@ it.each([ sendPendingSessionInputNow({projectId: "project", sessionId: "session", inputId: "input"}), ).resolves.toBe(accepted) }) + +it.each([ + [ + { + input: { + id: "input", + session_id: "session", + content: {data: {inputs: {messages: []}}}, + position: 1, + state: "pending", + policy: "queue", + }, + }, + true, + ], + [{}, false], + [{input: null}, false], + [{input: {id: "input"}}, false], + [{action: "pending"}, false], + [null, false], +])("validates a queued edit receipt %j", async (response, accepted) => { + updateInput.mockResolvedValue(response) + await expect( + updatePendingSessionInput({ + projectId: "project", + sessionId: "session", + inputId: "input", + text: "edited", + }), + ).resolves.toBe(accepted) +}) From 1445ef5ce0cd007030c278eebab9acb494942426 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 20:54:29 +0200 Subject: [PATCH 131/133] fix(agent): preserve queued input attachments --- api/oss/src/core/sessions/inputs/dtos.py | 1 + api/oss/src/core/sessions/inputs/service.py | 43 ++++++- .../unit/sessions/test_session_inputs_dao.py | 118 +++++++++++++++++- .../api/types/PendingInputAttachment.ts | 1 + .../agenta-chat/src/assets/pendingInputs.ts | 36 ++++-- .../src/components/QueuedMessagesDock.tsx | 2 +- .../src/hooks/useAgentChatQueue.ts | 17 +-- .../src/hooks/useServerSessionInputs.ts | 2 + .../tests/unit/assets/pendingInputs.test.ts | 47 ++++++- .../unit/hooks/useAgentChatQueue.test.ts | 102 ++++++++------- .../unit/hooks/useServerSessionInputs.test.ts | 2 + .../agenta-entities/src/session/api/api.ts | 7 +- .../src/session/state/pendingInputs.ts | 7 +- 13 files changed, 314 insertions(+), 71 deletions(-) diff --git a/api/oss/src/core/sessions/inputs/dtos.py b/api/oss/src/core/sessions/inputs/dtos.py index 56e5323e2ca..a74235ec229 100644 --- a/api/oss/src/core/sessions/inputs/dtos.py +++ b/api/oss/src/core/sessions/inputs/dtos.py @@ -52,6 +52,7 @@ class PendingInputAttachment(BaseModel): uri: str = Field(min_length=1) mime_type: str = Field(min_length=1) filename: Optional[str] = None + attachment_id: Optional[str] = None class PendingInputUpdate(BaseModel): diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py index 0a6c225b2d7..93ff2a12a99 100644 --- a/api/oss/src/core/sessions/inputs/service.py +++ b/api/oss/src/core/sessions/inputs/service.py @@ -95,8 +95,25 @@ def edit_pending_input_content( if isinstance(block, dict) and isinstance(block.get("uri", block.get("url")), str) } + attachment_ids = set() + for block in kept: + if not isinstance(block, dict): + continue + attachment_id = block.get("attachmentId", block.get("attachment_id")) + provider_metadata = block.get("providerMetadata") + agenta_metadata = ( + provider_metadata.get("agenta") + if isinstance(provider_metadata, dict) + else None + ) + if not attachment_id and isinstance(agenta_metadata, dict): + attachment_id = agenta_metadata.get("attachmentId") + if isinstance(attachment_id, str) and attachment_id: + attachment_ids.add(attachment_id) for attachment in update.attachments: - if attachment.uri in uris: + if attachment.uri in uris or ( + attachment.attachment_id and attachment.attachment_id in attachment_ids + ): continue if field == "parts": block = { @@ -104,12 +121,34 @@ def edit_pending_input_content( "url": attachment.uri, "mediaType": attachment.mime_type, } + if attachment.attachment_id is not None: + block["providerMetadata"] = { + "agenta": {"attachmentId": attachment.attachment_id} + } + if attachment.filename is not None: + block["filename"] = attachment.filename + elif attachment.attachment_id is not None: + block = { + "type": "attachment", + "attachmentId": attachment.attachment_id, + "mimeType": attachment.mime_type, + } if attachment.filename is not None: block["filename"] = attachment.filename else: - block = {"type": "attachment", **attachment.model_dump(exclude_none=True)} + block = { + "type": "image" + if attachment.mime_type.startswith("image/") + else "resource", + "uri": attachment.uri, + "mimeType": attachment.mime_type, + } + if attachment.filename is not None: + block["filename"] = attachment.filename kept.append(block) uris.add(attachment.uri) + if attachment.attachment_id: + attachment_ids.add(attachment.attachment_id) message[field] = kept return edited diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 19abce4857c..87096e7dbcc 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -1583,10 +1583,124 @@ def test_edit_pending_canonical_content_keeps_attachments(original): blocks = edited["data"]["inputs"]["messages"][0]["content"] assert blocks[0] == {"type": "text", "text": "after"} assert blocks[-1] == { - "type": "attachment", + "type": "resource", "uri": "agenta://new", - "mime_type": "text/plain", + "mimeType": "text/plain", } if isinstance(original, list): assert blocks[1] == original[1] assert content["data"]["inputs"]["messages"][0]["content"] == original + + +def test_edit_pending_canonical_content_preserves_durable_attachment_identity(): + old_attachment_id = "01995d1a-2f83-7c4d-8a6b-123456789abc" + new_attachment_id = "01996b6c-7b6b-7000-8000-000000000001" + original_attachment = { + "type": "attachment", + "attachmentId": old_attachment_id, + "mimeType": "application/pdf", + "filename": "old.pdf", + } + content = { + "data": { + "inputs": { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "before"}, + original_attachment, + ], + } + ] + } + } + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="https://files.test/old.pdf", + mime_type="application/pdf", + filename="old.pdf", + attachment_id=old_attachment_id, + ), + PendingInputAttachment( + uri="https://files.test/new.png", + mime_type="image/png", + filename="new.png", + attachment_id=new_attachment_id, + ), + ], + ) + + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + assert edited["data"]["inputs"]["messages"][0]["content"] == [ + {"type": "text", "text": "after"}, + original_attachment, + { + "type": "attachment", + "attachmentId": new_attachment_id, + "mimeType": "image/png", + "filename": "new.png", + }, + ] + + +def test_edit_pending_ui_parts_preserves_durable_attachment_identity(): + old_attachment_id = "01995d1a-2f83-7c4d-8a6b-123456789abc" + new_attachment_id = "01996b6c-7b6b-7000-8000-000000000001" + original_file = { + "type": "file", + "url": "https://files.test/old.pdf", + "mediaType": "application/pdf", + "filename": "old.pdf", + "providerMetadata": {"agenta": {"attachmentId": old_attachment_id}}, + } + content = { + "data": { + "inputs": { + "messages": [ + { + "role": "user", + "parts": [ + {"type": "text", "text": "before"}, + original_file, + ], + } + ] + } + } + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="https://other-host.test/old.pdf", + mime_type="application/pdf", + filename="old.pdf", + attachment_id=old_attachment_id, + ), + PendingInputAttachment( + uri="https://files.test/new.png", + mime_type="image/png", + filename="new.png", + attachment_id=new_attachment_id, + ), + ], + ) + + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + assert edited["data"]["inputs"]["messages"][0]["parts"] == [ + {"type": "text", "text": "after"}, + original_file, + { + "type": "file", + "url": "https://files.test/new.png", + "mediaType": "image/png", + "filename": "new.png", + "providerMetadata": {"agenta": {"attachmentId": new_attachment_id}}, + }, + ] diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts index be79885ff3e..de6a34b0ac6 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts @@ -4,4 +4,5 @@ export interface PendingInputAttachment { uri: string; mime_type: string; filename?: (string | null) | undefined; + attachment_id?: (string | null) | undefined; } diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts index b5fb5bcd02c..f585c5f1f27 100644 --- a/web/packages/agenta-chat/src/assets/pendingInputs.ts +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -3,6 +3,8 @@ import type {FileUIPart} from "ai" import type {QueuedMessage} from "../hooks/useAgentChatQueue" +import {attachmentContentUrl} from "./transcriptToMessages" + export interface SessionPendingInputView { capabilities: {queue: boolean; steer: boolean} executionState: "idle" | "running" | "stopping" @@ -14,19 +16,33 @@ const asRecord = (value: unknown): Record | null => ? (value as Record) : null -const filePartFromBlock = (block: Record): FileUIPart | null => { - const url = block.uri ?? block.url +const filePartFromBlock = ( + block: Record, + sessionId: string, +): FileUIPart | null => { + const metadata = asRecord(block.providerMetadata) + const agenta = asRecord(metadata?.agenta) + const attachmentId = block.attachmentId ?? block.attachment_id ?? agenta?.attachmentId + const reference = typeof attachmentId === "string" && attachmentId ? attachmentId : null + const url = reference ? attachmentContentUrl(sessionId, reference) : (block.uri ?? block.url) if (typeof url !== "string" || !url) return null + const mediaType = block.mimeType ?? block.mime_type ?? block.mediaType + const size = block.size ?? agenta?.size return { type: "file", url, - mediaType: - typeof block.mime_type === "string" - ? block.mime_type - : typeof block.mediaType === "string" - ? block.mediaType - : "application/octet-stream", + mediaType: typeof mediaType === "string" ? mediaType : "application/octet-stream", filename: typeof block.filename === "string" ? block.filename : undefined, + ...(reference + ? { + providerMetadata: { + agenta: { + attachmentId: reference, + ...(typeof size === "number" ? {size} : {}), + }, + }, + } + : {}), } } @@ -52,7 +68,7 @@ export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedM if (block.type === "text" && typeof block.text === "string") text += block.text if (["attachment", "image", "resource"].includes(String(block.type))) { attachmentCount += 1 - const part = filePartFromBlock(block) + const part = filePartFromBlock(block, input.session_id) if (part) fileParts.push(part) } } @@ -63,7 +79,7 @@ export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedM if (part.type === "text" && typeof part.text === "string") text += part.text if (part.type === "file") { attachmentCount += 1 - const filePart = filePartFromBlock(part) + const filePart = filePartFromBlock(part, input.session_id) if (filePart) fileParts.push(filePart) } } diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index f64e60c5f39..7f85477bda0 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -151,7 +151,7 @@ const Row = ({ } }} > - Send Now + {sending || message.policy === "steer" ? "Sending" : "Send Now"} ) : null} {editing ? ( diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 9c2559afd06..733a5dc6885 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -408,18 +408,19 @@ export const useAgentChatQueue = ({ (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const id = editingId const editSession = editSessionRef.current + const serverOwnsInput = + editSession?.server || server?.queued.some((message) => message.id === id) + if (id && serverOwnsInput) { + if (editSession) editSession.server = true + setQueued((queue) => queue.filter((message) => message.id !== id)) + if (migrationRef.current === id) migrationRef.current = null + if (migrationPromiseRef.current?.id === id) migrationPromiseRef.current = null + } const migration = migrationPromiseRef.current?.id === id ? migrationPromiseRef.current : null - if ( - id && - (editSession?.server || - migration || - server?.queued.some((message) => message.id === id)) - ) { + if (id && (serverOwnsInput || migration)) { const save = server?.edit if (!save) return Promise.reject(new Error("This queued message cannot be edited.")) - if (editSession && server?.queued.some((message) => message.id === id)) - editSession.server = true if (migration?.failed) { migration.failed = false migration.promise = migration.retry().catch((error: unknown) => { diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts index d055b6ddd04..373367e3507 100644 --- a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -12,6 +12,7 @@ import {projectIdAtom} from "@agenta/shared/state" import type {FileUIPart, UIMessage} from "ai" import {useAtomValue, useSetAtom} from "jotai" +import {attachmentIdForPart} from "../assets/files" import {reduceSessionPendingInputs, type SessionPendingInputView} from "../assets/pendingInputs" import type {QueuedMessage} from "./useAgentChatQueue" @@ -200,6 +201,7 @@ export const useServerSessionInputs = ({ attachments: item.fileParts?.map((part) => ({ uri: part.url, mime_type: part.mediaType, + attachment_id: attachmentIdForPart(part) ?? undefined, ...(part.filename ? {filename: part.filename} : {}), })), }) diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts index 9ab74111f83..34ec2dea640 100644 --- a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -48,7 +48,7 @@ describe("pending input reducer", () => { ]) }) - it("makes pending rows editable while preserving opaque attachment counts", () => { + it("makes pending rows editable and retains uploaded attachment references", () => { const queued = pendingInputToQueuedMessage( input("input-1", 1, [ {type: "text", text: "Check this"}, @@ -65,6 +65,15 @@ describe("pending input reducer", () => { editable: true, }) expect(queued?.fileParts).toEqual([ + { + type: "file", + url: expect.stringContaining( + "/sessions/attachments/asset-1/content?session_id=session-1", + ), + mediaType: "application/octet-stream", + filename: "brief.pdf", + providerMetadata: {agenta: {attachmentId: "asset-1"}}, + }, { type: "file", url: "https://files.test/image.png", @@ -74,6 +83,42 @@ describe("pending input reducer", () => { ]) }) + it.each(["content", "parts"])("preserves durable file identity from %s", (field) => { + const attachmentId = "01995d1a-2f83-7c4d-8a6b-123456789abc" + const row = input("input-1", 1, "") + const block = + field === "content" + ? { + type: "attachment", + attachmentId, + mimeType: "text/plain", + filename: "notes.txt", + size: 42, + } + : { + type: "file", + url: "https://old-host.test/content", + mediaType: "text/plain", + filename: "notes.txt", + providerMetadata: {agenta: {attachmentId, size: 42}}, + } + row.content.data.inputs.messages = [ + {role: "user", [field]: [block]}, + ] as typeof row.content.data.inputs.messages + const queued = pendingInputToQueuedMessage(row) + expect(queued?.fileParts).toEqual([ + { + type: "file", + url: expect.stringContaining( + `/sessions/attachments/${attachmentId}/content?session_id=session-1`, + ), + mediaType: "text/plain", + filename: "notes.txt", + providerMetadata: {agenta: {attachmentId, size: 42}}, + }, + ]) + }) + it("keeps a promoted input visible while its continuation is recoverable", () => { const recoverable = input("input-1", 1, "retry me", "queue", "promoted") diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 448a45b68e9..d4f0d8ae68b 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -1185,50 +1185,62 @@ it("retains observed server ownership after a failed edit and a later missing sn }) expect(server.edit).toHaveBeenCalledTimes(2) expect(server.submit).not.toHaveBeenCalled() - expect(result.current.queued[0].text).toBe("old") -}) - -it("replays the original admission after an ambiguous migration failure before patching the edit", async () => { - const props = {...settledEmpty, status: "streaming"} - const {result, rerender, unmount} = setup(props) - act(() => result.current.submit({text: "original admission"})) - const original = result.current.queued[0] - let reject!: (error: Error) => void - const server: ServerQueueAdapter = { - capabilities: {queue: true, steer: true}, - busy: true, - queued: [], - submit: vi - .fn() - .mockImplementationOnce( - () => - new Promise((_yes, no) => { - reject = no - }), - ) - .mockResolvedValueOnce(undefined), - remove: vi.fn(), - edit: vi.fn().mockResolvedValue(undefined), - } - rerender({...props, server, continuationExecutionId: "continuation"}) - act(() => result.current.beginEdit(original.id, "draft")) - let saving!: string | Promise - act(() => { - saving = result.current.commitEdit({text: "corrected"}) - }) - await act(async () => { - reject(new Error("response lost")) - await expect(saving).rejects.toThrow("response lost") - }) - expect(result.current.editingId).toBe(original.id) - expect(server.edit).not.toHaveBeenCalled() - await act(async () => { - expect(await result.current.commitEdit({text: "corrected"})).toBe("draft") - }) - expect(server.submit).toHaveBeenNthCalledWith(1, original, "queue") - expect(server.submit).toHaveBeenNthCalledWith(2, original, "queue") - expect(server.edit).toHaveBeenCalledOnce() - expect(server.edit).toHaveBeenCalledWith(original.id, {text: "corrected"}) expect(result.current.queued).toEqual([]) - unmount() + expect(result.current.editingId).toBe(id) }) + +it.each([false, true])( + "recovers an ambiguous migration before editing (server observed=%s)", + async (observed) => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender, unmount} = setup(props) + act(() => result.current.submit({text: "original admission"})) + const original = result.current.queued[0] + let reject!: (error: Error) => void + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi + .fn() + .mockImplementationOnce( + () => + new Promise((_yes, no) => { + reject = no + }), + ) + .mockResolvedValueOnce(undefined), + remove: vi.fn(), + edit: vi.fn().mockResolvedValue(undefined), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + act(() => result.current.beginEdit(original.id, "draft")) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "corrected"}) + }) + await act(async () => { + reject(new Error("response lost")) + await expect(saving).rejects.toThrow("response lost") + }) + expect(result.current.editingId).toBe(original.id) + expect(server.edit).not.toHaveBeenCalled() + if (observed) { + rerender({ + ...props, + server: {...server, queued: [{...original, source: "server"}]}, + continuationExecutionId: "continuation", + }) + } + await act(async () => { + expect(await result.current.commitEdit({text: "corrected"})).toBe("draft") + }) + expect(server.submit).toHaveBeenNthCalledWith(1, original, "queue") + if (observed) expect(server.submit).toHaveBeenCalledOnce() + else expect(server.submit).toHaveBeenNthCalledWith(2, original, "queue") + expect(server.edit).toHaveBeenCalledOnce() + expect(server.edit).toHaveBeenCalledWith(original.id, {text: "corrected"}) + expect(result.current.queued).toEqual(observed ? [{...original, source: "server"}] : []) + unmount() + }, +) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts index 093a9bc6245..59975ad1309 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -694,6 +694,7 @@ describe("durable queued input editing", () => { url: "https://files.test/new.pdf", mediaType: "application/pdf", filename: "new.pdf", + providerMetadata: {agenta: {attachmentId: "attachment-1"}}, }, ], }), @@ -707,6 +708,7 @@ describe("durable queued input editing", () => { uri: "https://files.test/new.pdf", mime_type: "application/pdf", filename: "new.pdf", + attachment_id: "attachment-1", }, ], }) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index e885853bd8d..6d8ebfdd10a 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -208,7 +208,12 @@ export async function updatePendingSessionInput({ }: SessionScopedParams & { inputId: string text: string - attachments?: {uri: string; mime_type: string; filename?: string}[] + attachments?: { + uri: string + mime_type: string + filename?: string + attachment_id?: string + }[] }): Promise { if (!projectId || !sessionId || !inputId) return false const data = await callFern("[updatePendingSessionInput]", () => diff --git a/web/packages/agenta-entities/src/session/state/pendingInputs.ts b/web/packages/agenta-entities/src/session/state/pendingInputs.ts index 035f8b6b821..a37aef415b5 100644 --- a/web/packages/agenta-entities/src/session/state/pendingInputs.ts +++ b/web/packages/agenta-entities/src/session/state/pendingInputs.ts @@ -44,7 +44,12 @@ export const updatePendingSessionInputAtom = atom( sessionId: string inputId: string text: string - attachments?: {uri: string; mime_type: string; filename?: string}[] + attachments?: { + uri: string + mime_type: string + filename?: string + attachment_id?: string + }[] }, ) => { const projectId = get(projectIdAtom) ?? "" From d68162e06c2899c9f1099958ce79629b3c982d3e Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 6 Sep 2026 21:05:13 +0200 Subject: [PATCH 132/133] fix(api): reject blank queued attachment ids --- api/oss/src/core/sessions/inputs/dtos.py | 2 +- .../tests/pytest/unit/sessions/test_session_inputs_dao.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/oss/src/core/sessions/inputs/dtos.py b/api/oss/src/core/sessions/inputs/dtos.py index a74235ec229..f86d2507d6c 100644 --- a/api/oss/src/core/sessions/inputs/dtos.py +++ b/api/oss/src/core/sessions/inputs/dtos.py @@ -52,7 +52,7 @@ class PendingInputAttachment(BaseModel): uri: str = Field(min_length=1) mime_type: str = Field(min_length=1) filename: Optional[str] = None - attachment_id: Optional[str] = None + attachment_id: Optional[str] = Field(default=None, min_length=1) class PendingInputUpdate(BaseModel): diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py index 87096e7dbcc..5c2a8fc32f1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -1569,6 +1569,12 @@ async def test_edit_pending_scope_and_invalid_content_leave_row_unchanged(input_ ], ) def test_edit_pending_canonical_content_keeps_attachments(original): + with pytest.raises(ValueError): + PendingInputAttachment( + uri="agenta://invalid", + mime_type="text/plain", + attachment_id="", + ) content = { "data": {"inputs": {"messages": [{"role": "user", "content": original}]}} } From 377227fe1d363a354fd4bf697dee730857964390 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 7 Sep 2026 12:26:32 +0200 Subject: [PATCH 133/133] fix(sessions): enable release features by default --- api/oss/src/utils/env.py | 10 +++--- .../unit/utils/test_env_runner_config.py | 31 +++++++++++++++++++ .../docker-compose/ee/docker-compose.dev.yml | 2 +- .../ee/docker-compose.gh.local.yml | 2 +- .../docker-compose/ee/docker-compose.gh.yml | 2 +- hosting/docker-compose/ee/env.ee.dev.example | 9 +++--- hosting/docker-compose/ee/env.ee.gh.example | 9 +++--- .../docker-compose/oss/docker-compose.dev.yml | 2 +- .../oss/docker-compose.gh.local.yml | 2 +- .../oss/docker-compose.gh.ssl.yml | 2 +- .../docker-compose/oss/docker-compose.gh.yml | 2 +- .../docker-compose/oss/env.oss.dev.example | 9 +++--- hosting/docker-compose/oss/env.oss.gh.example | 9 +++--- hosting/kubernetes/helm/values.schema.json | 2 +- hosting/kubernetes/helm/values.yaml | 2 +- services/runner/src/sessions/live-frames.ts | 2 +- .../runner/tests/unit/live-frames.test.ts | 22 +++++++++++++ .../runner/tests/unit/session-persist.test.ts | 9 +++++- 18 files changed, 96 insertions(+), 32 deletions(-) diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 69a453e50f6..a812f962ba2 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -704,10 +704,10 @@ class SessionsConfig(BaseModel): durable_stop: bool = _sessions_durable_stop_enabled() durable_approvals: bool = ( - os.getenv("AGENTA_SESSIONS_DURABLE_APPROVALS") or "false" + os.getenv("AGENTA_SESSIONS_DURABLE_APPROVALS") or "true" ).lower() in _TRUTHY - queue: bool = (os.getenv("AGENTA_SESSIONS_QUEUE") or "false").lower() in _TRUTHY - steer: bool = (os.getenv("AGENTA_SESSIONS_STEER") or "false").lower() in _TRUTHY + queue: bool = (os.getenv("AGENTA_SESSIONS_QUEUE") or "true").lower() in _TRUTHY + steer: bool = (os.getenv("AGENTA_SESSIONS_STEER") or "true").lower() in _TRUTHY late_output: Literal["quarantine", "reject"] = _parse_sessions_late_output() attachments: SessionAttachmentsConfig = SessionAttachmentsConfig() commands: SessionsCommandsConfig = SessionsCommandsConfig() @@ -1563,7 +1563,7 @@ class SessionsRedisConfig(BaseModel): """ sequence_writes: bool = ( - os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "false" + os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "true" ).lower() in _TRUTHY alive_ttl_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_ALIVE_TTL_SECONDS") @@ -1606,7 +1606,7 @@ class SessionsRedisConfig(BaseModel): or 900 ) shared_reader: bool = ( - os.getenv("AGENTA_SESSIONS_SHARED_READER") or "false" + os.getenv("AGENTA_SESSIONS_SHARED_READER") or "true" ).lower() in _TRUTHY live_auth_recheck_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS") diff --git a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py index caf102f5b4a..37c1ca2ecbb 100644 --- a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py +++ b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py @@ -112,3 +112,34 @@ def test_sandbox_runner_honors_explicit_restricted(monkeypatch): finally: monkeypatch.delenv("AGENTA_SERVICES_CODE_SANDBOX_RUNNER", raising=False) importlib.reload(env) + + +@pytest.mark.parametrize( + "configured, expected", [(None, True), ("", True), ("true", True), ("false", False)] +) +def test_session_features_default_on_and_honor_overrides( + monkeypatch, configured, expected +): + try: + with monkeypatch.context() as context: + for name in ( + "AGENTA_SESSIONS_DURABLE_APPROVALS", + "AGENTA_SESSIONS_QUEUE", + "AGENTA_SESSIONS_STEER", + "AGENTA_SESSIONS_SHARED_READER", + "AGENTA_SESSIONS_SEQUENCE_WRITES", + ): + if configured is None: + context.delenv(name, raising=False) + else: + context.setenv(name, configured) + importlib.reload(env) + sessions_config = env.SessionsConfig() + redis_config = env.SessionsRedisConfig() + assert sessions_config.durable_approvals is expected + assert sessions_config.queue is expected + assert sessions_config.steer is expected + assert redis_config.shared_reader is expected + assert redis_config.sequence_writes is expected + finally: + importlib.reload(env) diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index e90fdd70593..a3290bcc1e6 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -518,7 +518,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index b1092ce9ea1..12dd2752a3b 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -346,7 +346,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 7c3bc8bdcc8..7aaaff28291 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -355,7 +355,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 6cb33aa2012..82f3bb75f42 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -140,13 +140,14 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # The disposable live-frame stream keeps at most this many frames per deployment. # AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 # AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 -# Enable both switches to publish runner frames and advertise the shared reader. -# AGENTA_RUNNER_LIVE_FRAMES=false -# AGENTA_SESSIONS_SHARED_READER=false +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 94398f700c1..85b044150c0 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -141,13 +141,14 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # The disposable live-frame stream keeps at most this many frames per deployment. # AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 # AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 -# Enable both switches to publish runner frames and advertise the shared reader. -# AGENTA_RUNNER_LIVE_FRAMES=false -# AGENTA_SESSIONS_SHARED_READER=false +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index be93fd29a8c..1ecc1ff673f 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -483,7 +483,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index f6ab7134c8f..d8befb3f562 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -342,7 +342,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index ed34c52f944..51b8b734daa 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -368,7 +368,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === NETWORK ============================================== # networks: - agenta-gh-ssl-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index 952d971bf1d..f579f32db5d 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -373,7 +373,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} - AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 7a84c167870..d4d65187be3 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -146,13 +146,14 @@ NEXT_PUBLIC_AGENT_FILE_UPLOADS=true AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # The disposable live-frame stream keeps at most this many frames per deployment. # AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 # AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 -# Enable both switches to publish runner frames and advertise the shared reader. -# AGENTA_RUNNER_LIVE_FRAMES=false -# AGENTA_SESSIONS_SHARED_READER=false +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index dacff6d5b24..6d6f8fa5423 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -146,13 +146,14 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # The disposable live-frame stream keeps at most this many frames per deployment. # AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 # AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 -# Enable both switches to publish runner frames and advertise the shared reader. -# AGENTA_RUNNER_LIVE_FRAMES=false -# AGENTA_SESSIONS_SHARED_READER=false +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 4ed369b9773..e5f1683d6fd 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -307,7 +307,7 @@ "externalUrl": { "type": "string", "description": "AGENTA_RUNNER_INTERNAL_URL override pointing at an external runner." }, "piAgentDir": { "type": "string", "description": "PI_CODING_AGENT_DIR for local Pi runs (default /pi-agent); unset means no Agenta extension for the run (the runner logs a warning)." }, "logLevel": { "type": "string", "description": "AGENTA_RUNNER_LOG_LEVEL read by the runner service." }, - "liveFrames": { "type": "boolean", "description": "AGENTA_RUNNER_LIVE_FRAMES; opt-in temporary live-frame publication. Defaults to false." }, + "liveFrames": { "type": "boolean", "description": "AGENTA_RUNNER_LIVE_FRAMES; temporary live-frame publication. Defaults to true." }, "providers": { "type": "object", "additionalProperties": false, diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 64f7e4791e3..d6ba68a9b20 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -138,7 +138,7 @@ redisDurable: # ================================================================== # # agentRunner: # enabled: true -# liveFrames: false # AGENTA_RUNNER_LIVE_FRAMES; opt in to temporary live-frame relay +# liveFrames: true # AGENTA_RUNNER_LIVE_FRAMES; set false to disable live-frame relay # providers: # enabled: [local] # AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS (rendered comma-joined) # default: local # AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER (must be one of enabled) diff --git a/services/runner/src/sessions/live-frames.ts b/services/runner/src/sessions/live-frames.ts index e2a29b5edc3..ba4c6425d6c 100644 --- a/services/runner/src/sessions/live-frames.ts +++ b/services/runner/src/sessions/live-frames.ts @@ -52,7 +52,7 @@ interface LiveFramePublisherOptions { function envEnabled(): boolean { return ["1", "true", "yes", "on"].includes( - String(process.env[LIVE_FRAMES_ENV] ?? "") + String(process.env[LIVE_FRAMES_ENV] || "true") .trim() .toLowerCase(), ); diff --git a/services/runner/tests/unit/live-frames.test.ts b/services/runner/tests/unit/live-frames.test.ts index ac80600979b..b8352596d22 100644 --- a/services/runner/tests/unit/live-frames.test.ts +++ b/services/runner/tests/unit/live-frames.test.ts @@ -162,6 +162,28 @@ describe("LiveFramePublisher", () => { ); }); + it.each([undefined, "", "true"])( + "publishes live frames with default or enabled configuration %s", + async (configured) => { + if (configured === undefined) + delete process.env.AGENTA_RUNNER_LIVE_FRAMES; + else process.env.AGENTA_RUNNER_LIVE_FRAMES = configured; + const frames: LiveFrameEnvelope[] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-default", + executionId: "execution-default", + auth: () => "Secret test", + send: async (batch) => { + frames.push(...batch); + }, + }); + publisher.emit({ type: "message_start", id: "message-1" }); + await publisher.whenIdle(); + assert.equal(frames.length, 1); + assert.equal(frames[0].type, "text-start"); + }, + ); + it("sends no live frames when the feature flag is off", async () => { process.env.AGENTA_RUNNER_LIVE_FRAMES = "false"; let calls = 0; diff --git a/services/runner/tests/unit/session-persist.test.ts b/services/runner/tests/unit/session-persist.test.ts index 9c1f4d155b4..c6c3c7ead88 100644 --- a/services/runner/tests/unit/session-persist.test.ts +++ b/services/runner/tests/unit/session-persist.test.ts @@ -434,7 +434,14 @@ describe("buildPersistingEmitter turn/span tagging", () => { emit({ type: "tool_result", id: "call_1", output: "ok" }); await flush(); - const bodies = postedBodies as Array>; + const liveBatches = postedBodies.filter(Array.isArray); + assert.equal(liveBatches.length, 1); + for (const frame of liveBatches[0]) { + assert.equal(frame.execution_id, "turn-tc"); + } + const bodies = (postedBodies as Array>).filter( + (body) => "record_type" in body, + ); assert.equal(bodies.length, 3); assert.equal( (bodies[0]["attributes"] as Record)["message_id"],