From 733a7e6bc7707a63130def23c2f5758ef166fc59 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 23 Jul 2026 21:41:51 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(agent-chat):=20warm=20Stop=20=E2=80=94?= =?UTF-8?q?=20cooperatively=20cancel=20a=20turn=20instead=20of=20killing?= =?UTF-8?q?=20or=20leaking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today "Stop" either just aborts the client stream (the runner keeps running and billing) or, behind a flag, kills the whole session. Neither cancels the turn cleanly. This adds a cooperative cancel: Runner: on a user Stop the control-plane `cancel` command drops the alive lock and the heartbeat aborts the run signal. run-turn now races that abort to a distinct `stopReason:"cancelled"` (instead of falling through to the error catch), settles every still-open tool call with a new INTERRUPTED_BY_USER sentinel — no fake success, no orphaned "running" part — and treats continuity like a pause (invalidate, don't advance the ledger). Keep-warm-on-cancel is deferred to v2 (cold replay resumes fine and it avoids the unverified Claude-ACP warm-cancel question); the session stays open, so a follow-up prompt cold-resumes. FE: handleStop sends the `cancel` command by default (turn stops, session stays open) instead of leaving the runner running; the hard kill stays opt-in behind NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION. Runner tsc clean; 78 files / 1245 unit tests pass (incl. a new cancel test). --- .../src/engines/sandbox_agent/run-turn.ts | 41 ++++++++++++++++--- services/runner/src/tracing/otel.ts | 7 ++++ .../unit/session-keepalive-approval.test.ts | 21 ++++++++++ .../AgentChatSlice/AgentConversation.tsx | 16 +++++--- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index 9190d90854..d856d77a13 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -32,6 +32,7 @@ import { import { APPROVED_EXECUTION_RESULT_UNKNOWN, createSandboxAgentOtel, + INTERRUPTED_BY_USER, TOOL_NOT_EXECUTED_PAUSED, } from "../../tracing/otel.ts"; import { @@ -94,6 +95,10 @@ export async function runTurn( ): Promise { const { plan, logger, deps } = env; const sessionId = env.sessionId; + // Race marker for a user Stop (the control-plane `cancel`/`steer` command drops the alive lock, the + // heartbeat aborts `signal`). Distinct from PAUSED/RUN_LIMIT_TRIPPED so the turn ends CLEANLY + // (honest interrupted transcript, keep-warm) instead of falling through to the error catch. + const CANCELLED = Symbol("cancelled"); const continuityStore = deps.sessionContinuityStore ?? sessionContinuityStore; const turnStartedAt = new Date().toISOString(); // `turn_index` is a true conversation-turn counter, not an acquire counter: it advances once per completed turn across every environment serving the session. @@ -743,10 +748,22 @@ export async function runTurn( ); promptPromise.catch(() => {}); } + // A user Stop aborts `signal`, which severs the harness fetch (rejecting the prompt). We want a + // clean cancel, not an error: resolve the race to CANCELLED both when the abort event lands first + // AND when the prompt rejection lands first while already aborted, so the outcome is deterministic + // regardless of ordering. A real (non-abort) prompt rejection is re-thrown into the shared catch. + const cancelled = new Promise((resolve) => { + if (signal?.aborted) resolve(CANCELLED); + else signal?.addEventListener("abort", () => resolve(CANCELLED), { once: true }); + }); const raced = await Promise.race([ - promptPromise, + promptPromise.then( + (value) => value, + (err) => (signal?.aborted ? CANCELLED : Promise.reject(err)), + ), pause.signal.then(() => PAUSED), runLimitTripped.then(() => RUN_LIMIT_TRIPPED), + cancelled, ]); // A tripped run-limit ends the turn as an error: throw into the shared catch below so the // trace is flushed and the caller's teardown reclaims the (wedged) sandbox. @@ -754,7 +771,11 @@ export async function runTurn( throw new Error(runLimitReason ?? "run limit tripped"); } const stopReason = - raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason; + raced === CANCELLED + ? "cancelled" + : raced === PAUSED || pause.active + ? "paused" + : (raced as any)?.stopReason; // Terminalization drains queued gates, classifies pause-time completions, and gives allowed // executions their original per-call bound before the orphan sweep closes the turn. if (stopReason === "paused") { @@ -813,7 +834,16 @@ export async function runTurn( ); } } - const result = raced === PAUSED ? undefined : raced; + if (stopReason === "cancelled") { + // The user Stopped the turn: let any in-flight frames settle, honor real completions that + // already arrived, then settle every STILL-open tool call with the interrupt sentinel so the + // transcript closes HONESTLY — no orphaned "running" parts, no synthetic success. A deliberate + // human halt is not retryable; steer surfaces any new instruction as the next turn's prompt. + await pause.waitForEventDrain().catch(() => {}); + settleBufferedPausedCompletions(); + run.settleOpenToolCalls(() => false, INTERRUPTED_BY_USER); + } + const result = raced === PAUSED || raced === CANCELLED ? undefined : raced; // A parkable pause this turn: hand the still-pending prompt promise to EVERY parked record so a // later resume can await the same continuation (there is one prompt per turn, so every gate // shares it). Set after the race so `promptPromise` exists. @@ -869,6 +899,7 @@ export async function runTurn( // in-memory resume pointer or complete the durable ledger row. if ( stopReason !== "paused" && + stopReason !== "cancelled" && env.continuityTurnIndex !== undefined && sessionId ) { @@ -894,8 +925,8 @@ export async function runTurn( { authorization: turnLedgerContext.authorization, log: logger }, ).catch(() => {}); } - } else if (stopReason === "paused") { - // A pause stopped mid-turn, after the harness may have written a partial turn natively. + } else if (stopReason === "paused" || stopReason === "cancelled") { + // A pause/cancel stopped mid-turn, after the harness may have written a partial turn natively. invalidateContinuity(sessionId, plan.harness, deps); } diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index f446286671..fba0f299c4 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -68,6 +68,13 @@ export const TOOL_NOT_EXECUTED_PAUSED = `${DEFERRED_NOT_EXECUTED_PREFIX}: paused export const APPROVED_EXECUTION_RESULT_UNKNOWN = "APPROVED_EXECUTION_RESULT_UNKNOWN: the approved call started but its result was not observed before the pause ended the turn; do not assume it failed and do not retry a side-effecting call."; +/** Terminal result stamped on every open tool call when the USER stops (cancels) the turn. Unlike + * the pause sentinels this is a deliberate human halt, not a scheduling artifact: the call was cut + * off and may or may not have run, so the model must not silently retry it. Steer (stop + a new + * instruction) surfaces the user's guidance separately as the next turn's prompt. */ +export const INTERRUPTED_BY_USER = + "INTERRUPTED_BY_USER: the user stopped the turn before this call finished; it may not have completed. Do not retry it unless the user asks again."; + // --------------------------------------------------------------------------- // Shared, process-wide tracing infrastructure // --------------------------------------------------------------------------- diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index 6035c7f232..fc702fb5a3 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -1444,6 +1444,27 @@ function updateEvent(update: Record) { } describe("runTurn: real approval park + respondPermission resume", () => { + it("a user Stop (signal abort) ends the turn cleanly as cancelled, not an error", async () => { + const { deps } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + // A turn is in flight; the user hits Stop → the control-plane cancel drops the alive lock → + // the heartbeat aborts the run signal (the fake harness prompt stays pending, as a severed + // fetch would). The turn must resolve to a CLEAN cancel, not fall through to the error catch. + const controller = new AbortController(); + const p = runTurn(acquired.env, engineReq, undefined, controller.signal, { + approvalParkMode: true, + }); + await flush(); + controller.abort(); + const r = await p; + + assert.equal(r.ok, true, "a clean cancel is not an error"); + assert.equal(r.stopReason, "cancelled"); + }); + it("parks a Claude ACP gate (session alive), then answers it live and streams the continuation", async () => { const { calls, deps, captured } = pausableHarness(); const acquired = await acquireEnvironment(engineReq, deps); diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index f38df11416..3fcdb13db8 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -11,6 +11,7 @@ import { } from "react" import { + commandSessionStream, killSession, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, @@ -1232,11 +1233,10 @@ const AgentConversation = ({ const handleStop = useCallback(() => { markStopped() - stop() - // Default Stop only aborts the client stream; the runner survives and keeps billing. When the - // NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION flag is set, also kill the session so the sandbox - // tears down and server-side compute halts. Kill ends the whole session (resume is #5197). - if (doesAgentChatStopKillSession() && projectId && sessionId) { + stop() // abort the client stream immediately + if (!projectId || !sessionId) return + // Opt-in hard kill (NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION): tear the whole session down. + if (doesAgentChatStopKillSession()) { killSession({sessionId, projectId}) .then((ok) => { if (ok) { @@ -1247,7 +1247,13 @@ const AgentConversation = ({ } }) .catch(() => {}) + return } + // Default Stop: cooperatively cancel the CURRENT TURN. The control-plane `cancel` command + // (no inputs, no force) drops the alive lock; the runner closes the turn as interrupted and + // the session STAYS OPEN so a follow-up prompt resumes it — instead of the old behaviour where + // the client stream aborted but the runner kept running and billing. + commandSessionStream({sessionId, projectId}).catch(() => {}) }, [markStopped, stop, projectId, sessionId, queryClient]) // ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ── From 55809d3cf8921fd6eacf6a7b9bc8f5e879134823 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Thu, 23 Jul 2026 22:01:01 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(agent-chat):=20Steer=20=E2=80=94=20den?= =?UTF-8?q?y=20an=20approval=20with=20a=20redirect=20instruction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "reject-with-message" from the approval proposal: a denial can carry an instruction that reruns as the next turn, so the agent hears "no — do this instead" rather than a bare refusal (the redirect case, e.g. "write to staging, not prod"). Pure FE, reusing tested paths — no runner/wire change: the ApprovalDock gets a subtle "Redirect" popover (progressive disclosure — the instruction field only appears on click; bare Deny stays one-click) that answers the gate as denied AND carries the note. handleApprovalResponse then denies via addToolApprovalResponse and sends the note through the existing queue `submit`, which holds it until the paused turn settles and drives the next turn. (v2: fuse the note into the denial itself as model-facing guidance rather than a follow-up turn.) tsc/prettier/eslint clean. --- .../AgentChatSlice/AgentConversation.tsx | 11 +++- .../components/ApprovalDock.tsx | 57 +++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 3fcdb13db8..1eec0e7074 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -1071,11 +1071,16 @@ 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}) => { + (args: {id: string; approved: boolean; message?: string}) => { liveGateInteractionRef.current = {kind: "approval", id: args.id} - addToolApprovalResponse(args) + addToolApprovalResponse({id: args.id, approved: args.approved}) + // Steer: a denial that carries a redirect answers the gate AND sends the instruction as a + // follow-up turn, so the agent hears "no — do this instead" rather than a bare refusal. + // The queue holds it until the paused turn settles, then it drives the next turn. + const steer = args.message?.trim() + if (!args.approved && steer) submit({text: steer}) }, - [addToolApprovalResponse], + [addToolApprovalResponse, submit], ) // Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index 2f2ba36e0c..6f73c3b995 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -1,9 +1,9 @@ import {memo, useEffect, useMemo, useRef, useState} from "react" import {HeightCollapse} from "@agenta/ui" -import {ArrowSquareOut, CaretRight, ShieldCheck} from "@phosphor-icons/react" +import {ArrowSquareOut, CaretRight, ChatText, ShieldCheck} from "@phosphor-icons/react" import type {ToolUIPart, UIMessage} from "ai" -import {Button, Switch, Typography} from "antd" +import {Button, Input, Popover, Switch, Typography} from "antd" import {useAtomValue} from "jotai" import {useAlwaysAllowTool} from "@/oss/hooks/useAlwaysAllowTool" @@ -102,7 +102,7 @@ const PayloadBlock = ({input, label = "Payload"}: {input: unknown; label?: strin interface ApprovalDockProps { /** Pending gates for the paused turn (index 0 is acted on first). */ approvals: PendingApproval[] - onApprovalResponse: (args: {id: string; approved: boolean}) => void + onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void /** Open the paused turn's trace drawer (full tool input/output). */ onViewTrace?: () => void /** Selected agent revision — enables per-tool friendly bodies (approvals/registry). */ @@ -151,6 +151,9 @@ const ApprovalDock = ({ // Armed "always allow this tool" intent for the current gate — applied only when the user // clicks Approve, never on its own (the switch must not progress the flow). const [alwaysAllowArmed, setAlwaysAllowArmed] = useState(false) + // Steer: the "Redirect" popover holds an optional instruction sent WITH the denial. + const [steerOpen, setSteerOpen] = useState(false) + const [steerMessage, setSteerMessage] = useState("") // The current gate changed (we answered one, the next slid in) — re-enable and disarm. Held // during a resolve (current is frozen), so it fires only on a real step or a new batch. @@ -158,6 +161,8 @@ const ApprovalDock = ({ setResponding(false) setResolveSource(null) setAlwaysAllowArmed(false) + setSteerOpen(false) + setSteerMessage("") }, [current?.approvalId]) // Once every gate we fired has settled (left the pending set), drop the latch — the dock then @@ -190,7 +195,7 @@ const ApprovalDock = ({ const grantInfo = current ? infoFor(current.toolName) : null const canAlwaysAllow = Boolean(grantInfo?.eligible && !grantInfo.alreadyAllowed) - const respond = (approved: boolean) => { + const respond = (approved: boolean, message?: string) => { if (responding || !current) return setResponding(true) setResolveSource("one") @@ -208,7 +213,11 @@ const ApprovalDock = ({ return } } - onApprovalResponse({id: current.approvalId, approved}) + onApprovalResponse({ + id: current.approvalId, + approved, + ...(message?.trim() ? {message: message.trim()} : {}), + }) } const approveAll = () => { if (responding) return @@ -337,6 +346,44 @@ const ApprovalDock = ({ Approve all ) : null} + !responding && setSteerOpen(o)} + trigger="click" + placement="topRight" + content={ +
+ + Deny and tell the agent what to do instead — your + note runs as the next message. + + setSteerMessage(e.target.value)} + placeholder="e.g. write to staging, not prod" + disabled={responding} + /> + +
+ } + > + +
From d0be7c8311a602bf571fd62cfabc3c20e39f344b Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 24 Jul 2026 04:11:53 +0200 Subject: [PATCH 3/4] feat(agent-chat): polish approval dock + gate steer behind a flag Approval dock: inline Redirect affordance (replaces the body-portal popover that lingered across sessions), a single primary action, a subtle filled redirect field with a neutral hover/focus border, and the action row / always-allow toggle / redirect panel animate as one coordinated collapse-expand swap; the field auto-focuses on open. Steer (deny + redirect) is UI-complete but gated OFF behind NEXT_PUBLIC_AGENT_CHAT_STEER: the redirect can only run as a follow-up turn today (the harness continues the original prompt on reject and exposes no reject-with-feedback channel), so the control is hidden until the runner-level reject-and-redirect lands. The implementation ships intact behind the flag. --- .../AgentChatSlice/AgentConversation.tsx | 9 +- .../AgentChatSlice/assets/constants.ts | 10 + .../components/ApprovalDock.tsx | 224 +++++++++++------- web/oss/src/lib/helpers/dynamicEnv.ts | 6 + 4 files changed, 162 insertions(+), 87 deletions(-) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 1eec0e7074..a9ec72852d 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -1075,8 +1075,13 @@ const AgentConversation = ({ liveGateInteractionRef.current = {kind: "approval", id: args.id} addToolApprovalResponse({id: args.id, approved: args.approved}) // Steer: a denial that carries a redirect answers the gate AND sends the instruction as a - // follow-up turn, so the agent hears "no — do this instead" rather than a bare refusal. - // The queue holds it until the paused turn settles, then it drives the next turn. + // follow-up turn. It must be its OWN turn, not bundled into the deny-resume: resuming a + // parked gate calls `respondPermission(reject)`, which makes the harness CONTINUE the + // original prompt (run-turn.ts) — so a note fused into that resume gets subordinated to + // the original intent and ignored. As a separate turn it reliably drives the redirect. + // (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() if (!args.approved && steer) submit({text: steer}) }, diff --git a/web/oss/src/components/AgentChatSlice/assets/constants.ts b/web/oss/src/components/AgentChatSlice/assets/constants.ts index 5a534061f9..e9636762e3 100644 --- a/web/oss/src/components/AgentChatSlice/assets/constants.ts +++ b/web/oss/src/components/AgentChatSlice/assets/constants.ts @@ -13,3 +13,13 @@ export const isAgentChatSliceEnabled = (): boolean => */ export const doesAgentChatStopKillSession = (): boolean => (getEnv("NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION") || "").toLowerCase() === "true" + +/** + * Agent chat Steer (deny + redirect): gates the approval dock's "Redirect" control. Off by default + * (opt in with `NEXT_PUBLIC_AGENT_CHAT_STEER=true`). The UI is complete, but the redirect note runs + * as a FOLLOW-UP turn, so the model reasons about the bare denial before the note lands — the harness + * always continues the original prompt on reject and exposes no reject-with-feedback channel. Kept + * behind the flag until the runner-level "reject-and-redirect" lands (see the steer proposal, #5444). + */ +export const isAgentChatSteerEnabled = (): boolean => + (getEnv("NEXT_PUBLIC_AGENT_CHAT_STEER") || "").toLowerCase() === "true" diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index 6f73c3b995..97d5ab3126 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -3,11 +3,12 @@ import {memo, useEffect, useMemo, useRef, useState} from "react" import {HeightCollapse} from "@agenta/ui" import {ArrowSquareOut, CaretRight, ChatText, ShieldCheck} from "@phosphor-icons/react" import type {ToolUIPart, UIMessage} from "ai" -import {Button, Input, Popover, Switch, Typography} from "antd" +import {type GetRef, Button, Input, Switch, Typography} from "antd" import {useAtomValue} from "jotai" import {useAlwaysAllowTool} from "@/oss/hooks/useAlwaysAllowTool" +import {isAgentChatSteerEnabled} from "../assets/constants" import {partToolName, resolveToolDisplay} from "../assets/toolDisplay" import {chatPanelMaximizedAtom} from "../state/panelLayout" @@ -151,9 +152,23 @@ const ApprovalDock = ({ // Armed "always allow this tool" intent for the current gate — applied only when the user // clicks Approve, never on its own (the switch must not progress the flow). const [alwaysAllowArmed, setAlwaysAllowArmed] = useState(false) - // Steer: the "Redirect" popover holds an optional instruction sent WITH the denial. + // Steer: the "Redirect" panel holds an optional instruction sent WITH the denial. const [steerOpen, setSteerOpen] = useState(false) const [steerMessage, setSteerMessage] = useState("") + // The field stays mounted inside the collapse, so `autoFocus` only fires at the dock's initial + // mount (while hidden) — focus it explicitly each time the redirect opens. rAF waits for the + // expand to start so focus lands on a laid-out (not height-0) element. + const steerInputRef = useRef>(null) + useEffect(() => { + if (!steerOpen) return + const raf = requestAnimationFrame(() => steerInputRef.current?.focus()) + return () => cancelAnimationFrame(raf) + }, [steerOpen]) + // 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. Only the control + // is gated; the rest of the implementation ships intact behind it. See [[isAgentChatSteerEnabled]]. + const steerEnabled = isAgentChatSteerEnabled() // The current gate changed (we answered one, the next slid in) — re-enable and disarm. Held // during a resolve (current is frozen), so it fires only on a real step or a new batch. @@ -165,6 +180,15 @@ const ApprovalDock = ({ setSteerMessage("") }, [current?.approvalId]) + // Belt-and-braces: also close + clear the redirect note whenever nothing is pending, so the + // draft never carries into the next gate and the section can't outlive the card. + useEffect(() => { + if (approvals.length === 0) { + setSteerOpen(false) + setSteerMessage("") + } + }, [approvals.length]) + // 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-tool batch). useEffect(() => { @@ -323,105 +347,135 @@ const ApprovalDock = ({ /> )} - {/* Actions: trace on the left, decision on the right. Approve is the single primary. - The trace link is Build-only chrome — Chat keeps the payload expander instead. */} -
- {onViewTrace && !chatMode ? ( - - ) : null} -
- {count > 1 ? ( + {/* Actions: trace on the left, decision on the right; Approve is the single + primary (trace link is Build-only chrome — Chat keeps the payload expander). + The whole row collapses while steering: an explicit deny+redirect shouldn't + leave the yellow Approve competing, so the redirect panel below becomes the + entire action surface. Mirrors the panel's expand (open={!steerOpen} vs + open={steerOpen}) for one smooth swap. */} + +
+ {onViewTrace && !chatMode ? ( + + ) : null} +
+ {count > 1 ? ( + + ) : null} + {steerEnabled ? ( + + ) : null} + - ) : null} - !responding && setSteerOpen(o)} - trigger="click" - placement="topRight" - content={ -
- - Deny and tell the agent what to do instead — your - note runs as the next message. - - setSteerMessage(e.target.value)} - placeholder="e.g. write to staging, not prod" - disabled={responding} - /> - -
- } - > +
+
+
+ + {/* Steer: an inline redirect note, revealed on demand by the Redirect + button above. Kept inside the card (not a body portal) so it collapses + with the dock and can't linger over another session after a tab switch. + It and the always-allow row below share this bottom slot and animate as a + complementary pair (open={steerOpen} vs open={!steerOpen}, same primitive, + same fade) so one expands exactly as the other collapses — no pop-vs-slide. */} + +
+ + Deny this step and tell the agent what to do instead — your note + runs as the next message. + + {/* Filled + borderless-at-rest so the redirect reads as a nested field + of the approval card, subordinate to the main composer below — not a + second, louder input competing with it. The filled variant lights its + border with the full primary on focus (louder than the composer), so + we pin hover/focus to a neutral border and drop the focus glow. */} + setSteerMessage(e.target.value)} + placeholder="e.g. write to staging, not prod — or ask for something else entirely" + disabled={responding} + className="!text-xs hover:!border-colorBorder focus:!border-colorBorder focus:!shadow-none" + /> +
- - - + {/* Default, not primary: Approve is the card's single primary. This + is the confirm for the redirect sub-action, so it stays quiet. */} + +
-
+ {/* Always-allow: arms a config write-through so this tool stops asking. The switch only ARMS the intent (it must not progress the flow); the grant is applied when the user clicks Approve. Shown only for gateway / - custom-function / builtin gates that aren't already allowed. */} + custom-function / builtin gates that aren't already allowed. Collapses while + steering (open={!steerOpen}) — "applies when you approve" contradicts a + deny+redirect — as the mirror of the steer panel's expand, for one smooth swap. */} {canAlwaysAllow ? ( -
- -
- - Always allow{" "} - - {friendly?.label ?? current.toolName} - {" "} - for this agent - - - Applies when you approve; commit to use it in triggers. - + +
+ +
+ + Always allow{" "} + + {friendly?.label ?? current.toolName} + {" "} + for this agent + + + Applies when you approve; commit to use it in triggers. + +
-
+ ) : null}
) : null} diff --git a/web/oss/src/lib/helpers/dynamicEnv.ts b/web/oss/src/lib/helpers/dynamicEnv.ts index 54a7e4c083..971c55b471 100644 --- a/web/oss/src/lib/helpers/dynamicEnv.ts +++ b/web/oss/src/lib/helpers/dynamicEnv.ts @@ -23,6 +23,12 @@ export const processEnv = { // object-store-backed cwd/agent mounts survive and remount on resume (#5197 merged). NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION: process.env.NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION, + // Agent chat Steer (deny + redirect): when "true", the approval dock shows the "Redirect" + // control — deny a step and send a redirect note. Off by default: the UI is complete, but the + // redirect runs as a FOLLOW-UP turn, so the model reasons about the bare denial before it lands + // (the harness always continues the original prompt on reject and exposes no reject-with-feedback + // channel). Gated until the runner-level "reject-and-redirect" lands. + NEXT_PUBLIC_AGENT_CHAT_STEER: process.env.NEXT_PUBLIC_AGENT_CHAT_STEER, // Agent chat message virtualization (react-virtuoso spike): when "true", the playground settings // dropdown exposes the Virtualization section and the chat can window its settled history. Gated // so it's off everywhere unless explicitly enabled while the approach is evaluated. From d748049d496c595e518bd282915fc22406e98b5d Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Fri, 24 Jul 2026 04:18:52 +0200 Subject: [PATCH 4/4] fix(config): tighten the region header to first-row gap (pt-0) --- .../src/DrillInView/components/PlaygroundConfigSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx index 911f1c61d1..95f92e7983 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/components/PlaygroundConfigSection.tsx @@ -1858,9 +1858,9 @@ function PlaygroundConfigSection({ isAgentMarker(fieldSchema?.["x-ag-type-ref"]) || isAgentMarker(fieldSchema?.["x-ag-type"]) ) { - // pt-1 (not py-3) tightens the gap between the region header and the first section + // pt-0 (not py-3) tightens the gap between the region header and the first section // row; pb-3 keeps the bottom breathing room. The loading fallback below matches. - return
{props.defaultRender()}
+ return
{props.defaultRender()}
} return (