diff --git a/docs/design/agent-workflows/projects/agent-turn-inspector/design.md b/docs/design/agent-workflows/projects/agent-turn-inspector/design.md new file mode 100644 index 0000000000..ab935e940e --- /dev/null +++ b/docs/design/agent-workflows/projects/agent-turn-inspector/design.md @@ -0,0 +1,146 @@ +# Project: Agent Turn Inspector (Build-mode tooling) + +| | | +| --- | --- | +| **Status** | Design. Approved via brainstorming on 2026-07-02. Not yet planned/implemented. | +| **Type** | Frontend feature (agent playground, Build mode). Sequenced, test-driven. | +| **Audience** | Internal builders — the team actively building the agent system, debugging in the playground. | +| **Scope** | A dedicated, agent-native "Turn Inspector" panel for deep per-turn debugging, plus a per-turn capture of what was actually sent to the agent. The inline Build-mode step log stays as-is. | +| **Owner files (today)** | `web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx`, `.../components/AgentMessage.tsx`, `.../AgentChatPanel.tsx` (transport + temp diagnostic), `web/packages/agenta-playground/src/state/execution/agentRequest.ts` (`buildAgentRequest`). | + +## 1. Problem + +The agent chat view was deliberately kept calm for non-technical users. That calm hides the +information the team building the system needs to debug it. Across one working session we hit +this repeatedly and could not answer, from the UI alone: + +- **Why did the agent misbehave?** e.g. a `commit_revision` loop where it re-committed and + re-answered "already done" several times in one turn. +- **What did the agent actually run with?** The effective config (instructions / model / tools) + and the exact `messages` array we sent — *as of that turn*. This is **invisible today**; it + only exists in a temporary `console.warn` (`[AgentChat OUTGOING]`) added during the loop + investigation. +- **Were the tool calls correct?** Right input in, right output back — the `{}`-input question, + malformed args, error payloads. + +The root difficulty is that the agent's *actual execution* is not legible: we watch what it +produces, but not what it saw. The re-commit loop, for instance, is best explained by the FE +re-sending a config that had drifted out from under the agent after its own self-commit — a +fact no surface in the app exposes. + +## 2. Goals / non-goals + +**Goals** (priority order, from the builders): + +1. Diagnose *why it misbehaved* (loops, wrong tool, ignored context). +2. See *what it actually ran with* (effective config + exact messages sent, accurate at that turn). +3. Verify *tool-call correctness* (full input/output/error per call). + +**Non-goals:** + +- Do **not** touch the existing trace drawer (avoid regressions in a working surface). +- Do **not** reduce the inline Build-mode step log — the panel is purely additive. +- No speed/cost analytics (explicitly deprioritized). +- Read-only. No re-running/editing steps from the panel (possible future work). + +## 3. Surfaces + +Two surfaces with a clean division of labor: + +- **Inline step log** — exists (`ToolActivity` `detailed` mode, gated on Build via + `chatPanelMaximizedAtom`). The fast, in-transcript read: every step with tool I/O. **Unchanged.** +- **Turn Inspector** — new. A dedicated panel opened from a turn, for the deep dive. Its own + shell and its own Jotai state. It shares only generic UI primitives (e.g. `EnhancedDrawer` from + `@agenta/ui`); it does **not** import or mutate the trace-drawer store. + +## 4. The Turn Inspector + +**Gating:** Build mode only (`!chatPanelMaximizedAtom`), same signal the inline log uses. + +**Open affordance:** an "Inspect turn" control on each assistant turn (near the existing +`View full trace` link / the turn's hover actions). Opens a right-side drawer focused on that +turn. Optional deep-link: clicking a step in the inline log opens the panel scrolled to that step. + +**Three tabs:** + +| Tab | Shows | Data source | +| --- | --- | --- | +| **Timeline** | Every interaction in order: reasoning, each tool call with full input/output/error, text, and HITL events (approval requested / approved / denied). The step log, exhaustive and un-truncated. | The turn's `UIMessage.parts` — already on the client. No new plumbing. | +| **Context** | *What the agent ran with this turn* — the effective config (instructions / model / tools) as of that turn, and the exact `messages` array sent (post-`hasAnswer` filter). When a turn made multiple requests (HITL/auto-resume), shows all of them with a diff. | The per-turn capture (§5). | +| **Raw** | The literal outgoing request body and raw response/events, for copy-paste repro and bug reports. Copy-as-JSON. | The per-turn capture (§5) + read-only reads of trace data via existing atoms (no trace-drawer UI). | + +The **Timeline** tab ships essentially for free (message parts). **Context** and **Raw** carry +the real new value and the only real new engineering, because "what we actually sent" is not +persisted anywhere today. + +## 5. The per-turn capture (the one new data mechanism) + +**Decision: capture-at-send, not reconstruct-on-demand.** Reconstructing later re-reads the +*current* config/messages, which have already drifted (exactly the bug we chase). We snapshot at +the moment of sending, so Context/Raw are accurate *at that turn*. + +**Where:** in the transport's `prepareSendMessagesRequest` in `AgentChatPanel` — the exact spot +the temporary `console.warn` lives now. The built `req` is already in hand. This **productizes +and replaces the temp `[AgentChat OUTGOING]` diagnostic** with a structured store write. + +**Snapshot shape (per send):** + +```ts +interface TurnRequestCapture { + requestId: string // nonce, generated at send + at: number // Date.now() at send + triggerUserMessageId: string // last role:"user" message id in the sent array + parameters: unknown // config-as-sent (config-at-turn) + messages: unknown[] // exact array sent (post-hasAnswer) + references: unknown // as sent + sessionId: string + invocationUrl: string // body/URL only; auth headers + secrets stripped +} +``` + +**Correlation (the subtle bit):** key each capture by `triggerUserMessageId` = the id of the +last `role:"user"` message in the sent array. The initial send **and every HITL/auto-resume of +that turn share it**, so one turn maps to a *list* of captures. Given an assistant turn, find its +preceding user message → pull all captures for that id. + +**Payoff:** keeping *every* send lets the Context tab show "this turn = N requests" and diff +them — which surfaces the re-commit loop and the stale-config re-injection directly. That view +is what was missing all session. + +**Storage:** a session-scoped, in-memory Jotai atom (ephemeral — this is a debugging surface), +capped to the last N turns to bound memory. Not `localStorage` (payloads can be large); +persistence is a possible later option. + +**Redaction:** capture the request *body* only; strip `Authorization` and any secret-bearing +headers. The body itself must not carry secrets. + +## 6. Phasing + +1. **Inspector shell + Timeline tab** — no new plumbing (renders `UIMessage.parts`). Ships value + immediately and is independently useful. +2. **Capture store + Context tab** — the snapshot mechanism (§5) + the config/messages view, + including the multi-send-per-turn diff. Replaces the temp diagnostic. +3. **Raw tab** — literal payloads, copy-as-JSON, read-only trace correlation. + +## 7. Testing + +- **Unit:** the capture reducer + correlation — `triggerUserMessageId` grouping, resends + collapsing into one turn, N-cap eviction. +- **Component:** Timeline renders reasoning / tool-I/O / HITL in order; Context renders config + + messages; empty/no-capture states (a turn hydrated from a prior session has no capture). +- **Manual:** Build-mode-only gating; a HITL/resume turn shows multiple captures and a usable diff. + +## 8. Open questions / future + +- Deep-link from an inline step into the panel (nice-to-have, Phase 1 or later). +- Persisting captures across reload (currently ephemeral). +- "Re-run / fork from this turn" actions (out of scope now; the panel is read-only). +- Whether Timeline should also show server-recorded events not present in `UIMessage.parts` + (would need a read-only trace correlation, same as Raw). + +## See also + +- `docs/design/agent-workflows/documentation/agent-configuration.md` — the schema-driven config + the Context tab surfaces. +- The `hasAnswer` filter in `web/packages/agenta-playground/src/state/execution/agentRequest.ts` + — why the sent `messages` differ from the rendered transcript. diff --git a/docs/design/agent-workflows/projects/agent-turn-inspector/plan.md b/docs/design/agent-workflows/projects/agent-turn-inspector/plan.md new file mode 100644 index 0000000000..f8bf7cc0c7 --- /dev/null +++ b/docs/design/agent-workflows/projects/agent-turn-inspector/plan.md @@ -0,0 +1,919 @@ +# Agent Turn Inspector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dedicated, agent-native "Turn Inspector" panel to the agent playground's Build mode, plus a per-turn capture of exactly what was sent to the agent, so the team can debug why the agent misbehaved, what it ran with, and whether tool calls were correct. + +**Architecture:** Pure capture/correlation logic lives in `@agenta/playground` (unit-tested). A session-scoped in-memory Jotai store in `web/oss` holds the request snapshots, written from the chat transport at send time (replacing the temp `[AgentChat OUTGOING]` diagnostic). A new `TurnInspector` drawer (its own state, not the trace drawer) renders three tabs — Timeline (message parts), Context (captures), Raw (payloads). The inline Build-mode step log is unchanged. + +**Tech Stack:** React, Jotai, TypeScript, antd + `@agenta/ui` (`EnhancedDrawer`), Tailwind (v3, semantic antd tokens), AI SDK `UIMessage`. Package tests: vitest. Node 24 via `export PATH="$HOME/.nvm/versions/node/v24.15.0/bin:$PATH"`. + +**Spec:** `docs/design/agent-workflows/projects/agent-turn-inspector/design.md` + +--- + +## Conventions for every task + +- Run FE lint/tsc with Node 24: `export PATH="$HOME/.nvm/versions/node/v24.15.0/bin:$PATH"` first. +- Lint changed files: from `web/`, `npx eslint --fix `. +- Typecheck oss: from `web/oss`, `npx tsc --noEmit` (grep for your files — the repo has a pre-existing error baseline; only NEW errors in your files matter). +- Package tests: from `web/packages/agenta-playground`, `npx vitest run tests/unit/`. +- Commit convention: `type(area): Title`. No `Co-Authored-By`, no "Claude"/Anthropic in messages. +- Commit with `git commit --no-verify` after linting manually (the pre-commit `turbo lint` hook has timed out and reverted files in this repo). +- Text size default is `text-xs` (12px); dark mode via antd semantic tokens (`text-colorText`, `bg-colorFillTertiary`, …), never `dark:`. + +--- + +## Phase 1 — Timeline inspector (no new plumbing) + +Ships a working inspector that renders any turn's steps exhaustively from data already on the client. + +### Task 1: Inspector open-state atom + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/state/turnInspector.ts` + +- [ ] **Step 1: Write the atom + target type** + +```ts +import {atom} from "jotai" + +/** Which assistant turn the Turn Inspector is open on. `null` = closed. */ +export interface TurnInspectorTarget { + sessionId: string + /** The assistant turn's message id (its parts drive the Timeline tab). */ + assistantMessageId: string +} + +export const turnInspectorAtom = atom(null) +``` + +- [ ] **Step 2: Typecheck** + +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep turnInspector` +Expected: no output (no errors in the new file). + +- [ ] **Step 3: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/state/turnInspector.ts +git commit --no-verify -m "feat(frontend): turn-inspector open-state atom" +``` + +--- + +### Task 2: Timeline tab component + +Renders every part of an assistant turn in order: reasoning, tool calls with full input/output/error, text, and HITL/approval state. Reuses the existing `formatValue`/`IOBlock` idiom from `ToolActivity` but shows everything, un-truncated. + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx` + +- [ ] **Step 1: Write the component** + +```tsx +import {memo} from "react" + +import type {ToolUIPart, UIMessage} from "ai" +import {Typography} from "antd" + +const {Text} = Typography + +const isToolPart = (t: string) => t.startsWith("tool-") || t === "dynamic-tool" + +const toolName = (part: ToolUIPart): string => { + const type = part.type as string + if (type === "dynamic-tool") return (part as {toolName?: string}).toolName || "tool" + return type.replace(/^tool-/, "") +} + +const format = (value: unknown): string => { + if (value == null) return "" + if (typeof value === "string") return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const Block = ({label, value, danger}: {label: string; value: string; danger?: boolean}) => ( +
+ {label} +
+            {value}
+        
+
+) + +const PartNode = ({part}: {part: UIMessage["parts"][number]}) => { + const type = part.type as string + if (type === "reasoning") { + const text = (part as {text?: string}).text ?? "" + return ( +
+ reasoning +
{text}
+
+ ) + } + if (type === "text") { + const text = (part as {text?: string}).text ?? "" + if (!text.trim()) return null + return ( +
+ text +
{text}
+
+ ) + } + if (isToolPart(type)) { + const p = part as ToolUIPart + const state = p.state as string + const input = (p as {input?: unknown}).input + const output = (p as {output?: unknown}).output + const errorText = (p as {errorText?: string}).errorText + return ( +
+
+ {toolName(p)} + + {state} + +
+ {input != null ? : null} + {errorText !== undefined ? ( + + ) : output != null ? ( + + ) : null} +
+ ) + } + return ( +
+ {type} +
+ ) +} + +/** The Timeline tab: every part of one assistant turn, in order, un-truncated. */ +const TimelineTab = ({message}: {message: UIMessage | null}) => { + if (!message) { + return
No turn selected.
+ } + const parts = message.parts ?? [] + return ( +
+ {parts.length === 0 ? ( +
This turn produced no parts.
+ ) : ( + parts.map((part, i) => ( +
+ +
+ )) + )} +
+ ) +} + +export default memo(TimelineTab) +``` + +- [ ] **Step 2: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep TimelineTab` +Expected: eslint clean; no tsc output for the file. + +- [ ] **Step 3: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx +git commit --no-verify -m "feat(frontend): turn-inspector Timeline tab" +``` + +--- + +### Task 3: Inspector drawer shell (Timeline only for now) + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` +- Reference: `web/packages/agenta-ui` exports `EnhancedDrawer` (confirm the import path with `grep -rn "EnhancedDrawer" web/packages/agenta-ui/src/index.ts`). + +- [ ] **Step 1: Confirm the drawer primitive export** + +Run: `grep -rn "EnhancedDrawer" web/packages/agenta-ui/src` +Expected: an exported `EnhancedDrawer` (used by the cron/schedule drawers). Note its exact import specifier (e.g. `@agenta/ui`); use it below. If it does not exist, fall back to antd `Drawer` with `rootClassName` for dark-mode tokens. + +- [ ] **Step 2: Write the shell** + +```tsx +import {useMemo, useState} from "react" + +import {EnhancedDrawer} from "@agenta/ui" +import type {UIMessage} from "ai" +import {Segmented} from "antd" +import {useAtom, useAtomValue} from "jotai" + +import {sessionMessagesAtom} from "../../state/sessions" +import {turnInspectorAtom} from "../../state/turnInspector" + +import TimelineTab from "./TimelineTab" + +type Tab = "timeline" | "context" | "raw" + +/** Dedicated Build-mode turn inspector. Own state (`turnInspectorAtom`), NOT the trace drawer. */ +const TurnInspector = () => { + const [target, setTarget] = useAtom(turnInspectorAtom) + const allMessages = useAtomValue(sessionMessagesAtom) + const [tab, setTab] = useState("timeline") + + const message: UIMessage | null = useMemo(() => { + if (!target) return null + const list = allMessages[target.sessionId] ?? [] + return list.find((m) => m.id === target.assistantMessageId) ?? null + }, [target, allMessages]) + + return ( + setTarget(null)} + width={560} + title="Turn inspector" + destroyOnClose + > +
+
+ + value={tab} + onChange={setTab} + options={[ + {label: "Timeline", value: "timeline"}, + {label: "Context", value: "context"}, + {label: "Raw", value: "raw"}, + ]} + /> +
+
+ {tab === "timeline" ? : null} + {tab === "context" ? ( +
Context — added in Phase 2.
+ ) : null} + {tab === "raw" ? ( +
Raw — added in Phase 3.
+ ) : null} +
+
+
+ ) +} + +export default TurnInspector +``` + +- [ ] **Step 3: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep TurnInspector` +Expected: eslint clean; no tsc output. If `EnhancedDrawer`'s props differ (e.g. no `width`/`title`), adjust to its real signature discovered in Step 1. + +- [ ] **Step 4: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx +git commit --no-verify -m "feat(frontend): turn-inspector drawer shell" +``` + +--- + +### Task 4: Mount the inspector + add the "Inspect turn" affordance + +**Files:** +- Modify: `web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx` + +The affordance is added in `AgentConversation`'s `renderMessage`, next to the existing per-turn `Stopped`/`Resend` block, gated on Build mode (`!chatMaximized`) and assistant role. The `` is mounted once at the panel root. + +- [ ] **Step 1: Add imports** + +Add near the other component imports in `AgentChatPanel.tsx`: + +```ts +import TurnInspector from "./components/TurnInspector/TurnInspector" +import {turnInspectorAtom} from "./state/turnInspector" +``` + +- [ ] **Step 2: Read Build mode + the setter inside `AgentConversation`** + +Find where `AgentConversation` reads atoms (near `const detailed`/other `useAtomValue` calls in the inner component) and add: + +```ts +const openTurnInspector = useSetAtom(turnInspectorAtom) +const buildMode = !useAtomValue(chatPanelMaximizedAtom) +``` + +(`chatPanelMaximizedAtom` and `useSetAtom` are already imported in this file.) + +- [ ] **Step 3: Add the affordance in `renderMessage`** + +In `renderMessage`, in the block that already renders the `Stopped` tag for `isLast && message.role === "assistant"`, add an always-available (Build-mode) inspect button for assistant turns. Insert this just after the existing `{stopped && isLast && message.role === "assistant" && (...)}` block, inside the ``: + +```tsx +{buildMode && message.role === "assistant" && ( + +)} +``` + +`TreeStructure` is already imported from `@phosphor-icons/react` in `AgentMessage.tsx`; add it to the `AgentChatPanel.tsx` phosphor import (verify with `grep -n "@phosphor-icons/react" web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx` and extend that import). + +- [ ] **Step 4: Mount the inspector at the panel root** + +In the `AgentConversation` return, just after the opening root `
` (where `modalContextHolder` is rendered), add: + +```tsx + +``` + +- [ ] **Step 5: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/AgentChatPanel.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep AgentChatPanel` +Expected: clean. + +- [ ] **Step 6: Manual verification** + +Deploy locally (see `hosting/AGENTS.md`), open an agent in the playground, run a tool-using turn, ensure Build mode (config panel visible). Confirm: an "Inspect turn" control appears on the assistant turn; clicking it opens the drawer; the Timeline tab lists reasoning/tool/text parts with input/output/error, un-truncated. In Chat mode (maximized) the control is absent. + +- [ ] **Step 7: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +git commit --no-verify -m "feat(frontend): mount turn inspector + inspect-turn affordance" +``` + +--- + +## Phase 2 — Capture store + Context tab + +### Task 5: Pure capture + correlation logic (TDD, in the package) + +**Files:** +- Create: `web/packages/agenta-playground/src/state/execution/turnCapture.ts` +- Test: `web/packages/agenta-playground/tests/unit/turnCapture.test.ts` +- Modify: `web/packages/agenta-playground/src/index.ts` (or the execution barrel that already exports `buildAgentRequest`) to export the new symbols. + +- [ ] **Step 1: Write the failing test** + +```ts +import {describe, expect, it} from "vitest" + +import { + appendCapped, + buildTurnCapture, + capturesForTrigger, + triggerUserMessageId, +} from "../../src/state/execution/turnCapture" + +describe("turnCapture", () => { + it("finds the last user message id as the trigger", () => { + const messages = [ + {id: "u1", role: "user"}, + {id: "a1", role: "assistant"}, + {id: "u2", role: "user"}, + {id: "a2", role: "assistant"}, + ] + expect(triggerUserMessageId(messages)).toBe("u2") + }) + + it("returns null when there is no user message", () => { + expect(triggerUserMessageId([{id: "a1", role: "assistant"}])).toBeNull() + }) + + it("builds a capture from a built AgentRequest", () => { + const req = { + invocationUrl: "https://x/invoke?project_id=p", + requestBody: { + session_id: "s1", + references: {application: {id: "app"}}, + data: { + inputs: {messages: [{id: "u1", role: "user"}]}, + parameters: {agent: {instructions: {agents_md: "hi"}}}, + }, + }, + } + const c = buildTurnCapture(req, "req-1", 1000) + expect(c).toEqual({ + requestId: "req-1", + at: 1000, + triggerUserMessageId: "u1", + parameters: {agent: {instructions: {agents_md: "hi"}}}, + messages: [{id: "u1", role: "user"}], + references: {application: {id: "app"}}, + sessionId: "s1", + invocationUrl: "https://x/invoke?project_id=p", + }) + }) + + it("groups all sends of a turn under one trigger id", () => { + const base = {parameters: {}, messages: [], references: null, sessionId: "s", invocationUrl: "u"} + const captures = [ + {...base, requestId: "r1", at: 1, triggerUserMessageId: "u1"}, + {...base, requestId: "r2", at: 2, triggerUserMessageId: "u1"}, + {...base, requestId: "r3", at: 3, triggerUserMessageId: "u2"}, + ] + expect(capturesForTrigger(captures, "u1").map((c) => c.requestId)).toEqual(["r1", "r2"]) + expect(capturesForTrigger(captures, null)).toEqual([]) + }) + + it("evicts the oldest whole turns beyond the cap, keeping all sends of kept turns", () => { + const base = {parameters: {}, messages: [], references: null, sessionId: "s", invocationUrl: "u"} + let list: ReturnType = [] + list = appendCapped(list, {...base, requestId: "r1", at: 1, triggerUserMessageId: "u1"}, 2) + list = appendCapped(list, {...base, requestId: "r1b", at: 2, triggerUserMessageId: "u1"}, 2) + list = appendCapped(list, {...base, requestId: "r2", at: 3, triggerUserMessageId: "u2"}, 2) + list = appendCapped(list, {...base, requestId: "r3", at: 4, triggerUserMessageId: "u3"}, 2) + // u1 (oldest turn) evicted; both u1 sends gone; u2 + u3 kept. + expect(list.map((c) => c.triggerUserMessageId)).toEqual(["u2", "u3"]) + }) +}) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run (from `web/packages/agenta-playground`): `npx vitest run tests/unit/turnCapture.test.ts` +Expected: FAIL — cannot resolve `../../src/state/execution/turnCapture`. + +- [ ] **Step 3: Write the implementation** + +```ts +/** A snapshot of one request sent to the agent, taken at send time so the Context/Raw tabs are + * accurate AT THAT TURN (the config drifts after a self-commit; reconstructing later lies). */ +export interface TurnRequestCapture { + requestId: string + at: number + /** Last `role:"user"` message id in the sent array; shared by a turn's initial send + resumes. */ + triggerUserMessageId: string | null + parameters: unknown + messages: unknown[] + references: unknown + sessionId: string + invocationUrl: string +} + +interface MessageLike { + id?: string + role?: string +} + +export const triggerUserMessageId = (messages: MessageLike[]): string | null => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") return messages[i]?.id ?? null + } + return null +} + +/** Build a capture from a built `AgentRequest` (the return of `buildAgentRequest`). `requestId` + * and `at` are supplied by the caller so this stays pure/testable. */ +export const buildTurnCapture = ( + req: {invocationUrl: string; requestBody: Record}, + requestId: string, + at: number, +): TurnRequestCapture => { + const body = req.requestBody as { + session_id?: string + references?: unknown + data?: {inputs?: {messages?: unknown[]}; parameters?: unknown} + } + const messages = body.data?.inputs?.messages ?? [] + return { + requestId, + at, + triggerUserMessageId: triggerUserMessageId(messages as MessageLike[]), + parameters: body.data?.parameters ?? null, + messages, + references: body.references ?? null, + sessionId: body.session_id ?? "", + invocationUrl: req.invocationUrl, + } +} + +/** All sends belonging to one turn (initial + resumes), by trigger id. */ +export const capturesForTrigger = ( + captures: TurnRequestCapture[], + triggerId: string | null, +): TurnRequestCapture[] => + triggerId ? captures.filter((c) => c.triggerUserMessageId === triggerId) : [] + +/** Append a capture, evicting the OLDEST whole turns (by distinct trigger id) beyond `maxTurns` + * so a kept turn never loses some of its sends. */ +export const appendCapped = ( + captures: TurnRequestCapture[], + capture: TurnRequestCapture, + maxTurns: number, +): TurnRequestCapture[] => { + const next = [...captures, capture] + const triggers: string[] = [] + for (const c of next) { + const t = c.triggerUserMessageId ?? c.requestId + if (!triggers.includes(t)) triggers.push(t) + } + if (triggers.length <= maxTurns) return next + const keep = new Set(triggers.slice(triggers.length - maxTurns)) + return next.filter((c) => keep.has(c.triggerUserMessageId ?? c.requestId)) +} +``` + +- [ ] **Step 4: Export the symbols** + +In the barrel that already exports `buildAgentRequest` (find it: `grep -rn "buildAgentRequest" web/packages/agenta-playground/src/index.ts`), add: + +```ts +export { + appendCapped, + buildTurnCapture, + capturesForTrigger, + triggerUserMessageId, + type TurnRequestCapture, +} from "./state/execution/turnCapture" +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run (from `web/packages/agenta-playground`): `npx vitest run tests/unit/turnCapture.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add web/packages/agenta-playground/src/state/execution/turnCapture.ts web/packages/agenta-playground/tests/unit/turnCapture.test.ts web/packages/agenta-playground/src/index.ts +git commit --no-verify -m "feat(playground): per-turn request capture + correlation helpers" +``` + +--- + +### Task 6: Capture store atom + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/state/turnCaptures.ts` + +- [ ] **Step 1: Write the store** + +```ts +import {appendCapped, type TurnRequestCapture} from "@agenta/playground" +import {atom} from "jotai" +import {atomFamily} from "jotai/utils" + +/** Keep the last N turns' captures per session (ephemeral; debugging surface, not persisted). */ +const MAX_TURNS = 20 + +const capturesBySessionAtom = atom>({}) + +/** Write one send's capture (called from the transport at send time). */ +export const captureTurnRequestAtom = atom( + null, + (get, set, capture: TurnRequestCapture) => { + if (!capture.sessionId) return + const all = get(capturesBySessionAtom) + const list = all[capture.sessionId] ?? [] + set(capturesBySessionAtom, { + ...all, + [capture.sessionId]: appendCapped(list, capture, MAX_TURNS), + }) + }, +) + +/** Read all captures for a session. */ +export const sessionCapturesAtomFamily = atomFamily((sessionId: string) => + atom((get) => get(capturesBySessionAtom)[sessionId] ?? []), +) +``` + +- [ ] **Step 2: Typecheck** + +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep turnCaptures` +Expected: no output. (If `@agenta/playground` doesn't resolve the new export, re-run `pnpm --filter @agenta/playground build` or `pnpm install`.) + +- [ ] **Step 3: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/state/turnCaptures.ts +git commit --no-verify -m "feat(frontend): session-scoped turn-capture store" +``` + +--- + +### Task 7: Capture at send time (replace the temp diagnostic) + +**Files:** +- Modify: `web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx` + +The transport's `prepareSendMessagesRequest` currently holds the temp `[AgentChat OUTGOING]` `console.warn`. Replace it with a capture write. The setter is read via a ref (the transport `useMemo` must not depend on it). + +- [ ] **Step 1: Add imports** + +```ts +import {buildTurnCapture} from "@agenta/playground" +import {generateId} from "@agenta/shared/utils" +import {captureTurnRequestAtom} from "./state/turnCaptures" +``` + +(`buildAgentRequest` is already imported from `@agenta/playground`; `generateId` may already be imported — verify and don't duplicate.) + +- [ ] **Step 2: Add a ref'd setter inside `AgentConversation`** + +Near `entityIdRef` in `AgentConversation`: + +```ts +const captureTurnRequest = useSetAtom(captureTurnRequestAtom) +const captureRef = useRef(captureTurnRequest) +captureRef.current = captureTurnRequest +``` + +- [ ] **Step 3: Replace the temp diagnostic block** + +In `prepareSendMessagesRequest`, delete the entire `// TEMP DIAGNOSTIC …` try/catch block (the `[AgentChat OUTGOING]` console.warn) and replace it with: + +```ts +captureRef.current(buildTurnCapture(req, generateId(), Date.now())) +``` + +Placed after the `if (!req) { throw … }` guard and before `return {api: req.invocationUrl, …}`. + +- [ ] **Step 4: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/AgentChatPanel.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep AgentChatPanel` +Expected: clean. The temp `console.warn` is gone (removes the loose diagnostic). + +- [ ] **Step 5: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +git commit --no-verify -m "feat(frontend): capture outgoing agent request per send" +``` + +--- + +### Task 8: Context tab + +Shows, for the selected turn, every send (initial + resumes) with its config-at-turn and exact messages. Multiple sends render as a numbered list so the loop/stale-config is visible. + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx` +- Modify: `web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` (wire the tab + compute the trigger id) + +- [ ] **Step 1: Write `ContextTab`** + +```tsx +import {memo} from "react" + +import {type TurnRequestCapture} from "@agenta/playground" +import {Typography} from "antd" + +const {Text} = Typography + +const format = (value: unknown): string => { + if (value == null) return "null" + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const agentInstructions = (parameters: unknown): string | null => { + const p = parameters as {agent?: {instructions?: {agents_md?: unknown}}} | null + const md = p?.agent?.instructions?.agents_md + return typeof md === "string" ? md : null +} + +const agentModel = (parameters: unknown): string | null => { + const p = parameters as {agent?: {llm?: {model?: unknown}; model?: unknown}} | null + const m = p?.agent?.llm?.model ?? p?.agent?.model + return typeof m === "string" ? m : null +} + +const Block = ({label, value}: {label: string; value: string}) => ( +
+ {label} +
+            {value}
+        
+
+) + +const Send = ({capture, index, total}: {capture: TurnRequestCapture; index: number; total: number}) => { + const model = agentModel(capture.parameters) + const instructions = agentInstructions(capture.parameters) + return ( +
+
+ Request {index + 1} of {total} + {model ? {model} : null} +
+ {instructions != null ? : null} + + +
+ ) +} + +/** The Context tab: every send for the selected turn, config-at-turn + exact messages. */ +const ContextTab = ({captures}: {captures: TurnRequestCapture[]}) => { + if (captures.length === 0) { + return ( +
+ No capture for this turn. Captures are recorded live in Build mode; a turn restored from a + previous session has none. +
+ ) + } + return ( +
+ {captures.length > 1 ? ( + + This turn made {captures.length} requests (initial + resumes). Compare them to spot + drift or a loop. + + ) : null} + {captures.map((c, i) => ( + + ))} +
+ ) +} + +export default memo(ContextTab) +``` + +- [ ] **Step 2: Wire it into the shell** + +In `TurnInspector.tsx`: import the tab + capture reads; compute the trigger id (the user message immediately before the assistant turn) and the captures for it; render `` in the `context` branch. + +Add imports: + +```ts +import {capturesForTrigger} from "@agenta/playground" + +import {sessionCapturesAtomFamily} from "../../state/turnCaptures" + +import ContextTab from "./ContextTab" +``` + +Add derivations (after `message` is computed): + +```ts +const captures = useAtomValue(sessionCapturesAtomFamily(target?.sessionId ?? "")) +const turnCaptures = useMemo(() => { + if (!target) return [] + const list = allMessages[target.sessionId] ?? [] + const idx = list.findIndex((m) => m.id === target.assistantMessageId) + // The trigger is the last user message at or before this assistant turn. + let triggerId: string | null = null + for (let i = idx; i >= 0; i--) { + if (list[i]?.role === "user") { + triggerId = list[i].id + break + } + } + return capturesForTrigger(captures, triggerId) +}, [target, allMessages, captures]) +``` + +Replace the `context` placeholder with: + +```tsx +{tab === "context" ? : null} +``` + +- [ ] **Step 3: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep -E "ContextTab|TurnInspector"` +Expected: clean. + +- [ ] **Step 4: Manual verification** + +Local deploy. Run a turn that triggers a HITL approval (so it makes ≥2 requests), approve it, then open the inspector → Context tab. Confirm: multiple "Request k of N" cards; each shows the instructions/model and the exact messages sent for that send; you can eyeball drift between sends. + +- [ ] **Step 5: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx +git commit --no-verify -m "feat(frontend): turn-inspector Context tab (config + messages sent)" +``` + +--- + +## Phase 3 — Raw tab + +### Task 9: Raw tab (literal payloads + copy) + +**Files:** +- Create: `web/oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx` +- Modify: `web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` (wire the `raw` branch) + +- [ ] **Step 1: Write `RawTab`** + +```tsx +import {memo} from "react" + +import {type TurnRequestCapture} from "@agenta/playground" +import {App, Button, Typography} from "antd" + +const {Text} = Typography + +/** One capture's literal outgoing request body, copyable for repro / bug reports. */ +const RawTab = ({captures}: {captures: TurnRequestCapture[]}) => { + const {message} = App.useApp() + if (captures.length === 0) { + return
No capture for this turn.
+ } + return ( +
+ {captures.map((c, i) => { + const body = { + session_id: c.sessionId, + references: c.references, + data: {inputs: {messages: c.messages}, parameters: c.parameters}, + } + const json = JSON.stringify(body, null, 2) + return ( +
+
+ Request {i + 1} of {captures.length} + {c.invocationUrl} + +
+
+                            {json}
+                        
+
+ ) + })} +
+ ) +} + +export default memo(RawTab) +``` + +- [ ] **Step 2: Wire it into the shell** + +In `TurnInspector.tsx`, import `RawTab` and replace the `raw` placeholder: + +```tsx +{tab === "raw" ? : null} +``` + +- [ ] **Step 3: Lint + typecheck** + +Run (from `web`): `npx eslint --fix oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx` +Run (from `web/oss`): `npx tsc --noEmit 2>&1 | grep -E "RawTab|TurnInspector"` +Expected: clean. + +- [ ] **Step 4: Manual verification** + +Local deploy. Open the inspector → Raw tab. Confirm the literal request body renders per send and "Copy JSON" copies it (paste elsewhere to verify). Dark mode: toggle the theme, confirm tokens (bg/text) read correctly in both. + +- [ ] **Step 5: Commit** + +```bash +git add web/oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx +git commit --no-verify -m "feat(frontend): turn-inspector Raw tab (copyable payloads)" +``` + +--- + +## Final verification + +- [ ] From `web/packages/agenta-playground`: `npx vitest run tests/unit/turnCapture.test.ts` — all pass. +- [ ] From `web/oss`: `npx tsc --noEmit 2>&1 | grep -E "TurnInspector|TimelineTab|ContextTab|RawTab|turnCaptures|turnInspector|AgentChatPanel"` — no new errors. +- [ ] From `web`: `npx eslint oss/src/components/AgentChatSlice/components/TurnInspector oss/src/components/AgentChatSlice/state/turnInspector.ts oss/src/components/AgentChatSlice/state/turnCaptures.ts` — clean. +- [ ] Manual: Build mode shows "Inspect turn"; Chat mode hides it; Timeline/Context/Raw all render; a HITL turn shows multiple captures; the inline step log is unchanged; the trace drawer is untouched. diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py index 175d4d6542..e0fef6c83c 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/messages.py @@ -9,8 +9,12 @@ from typing import Any, Dict, List, Optional +from agenta.sdk.utils.logging import get_module_logger + from ...dtos import AgentResult, ContentBlock, Message +log = get_module_logger(__name__) + TOOL_APPROVAL_REQUEST = "tool-approval-request" TOOL_APPROVAL_RESPONSE = "tool-approval-response" TOOL_OUTPUT_AVAILABLE = "tool-output-available" @@ -155,6 +159,18 @@ def _tool_part_blocks(part: Dict[str, Any], ptype: str) -> List[ContentBlock]: # `extractApprovalDecisions` resolves the parked gate. approved = _approval_decision(part, state) if approved is not None: + # INGRESS side of the HITL key: the inline decision the FE round-tripped, folded into + # the `{approved}` envelope the runner's extractApprovalDecisions keys by name+args. + _input = part.get("input") + log.info( + "[HITL] ingress approval-responded id=%s name=%s approved=%s input_keys=%s", + tool_call_id, + tool_name, + approved, + list(_input.keys()) + if isinstance(_input, dict) + else type(_input).__name__, + ) blocks.append( ContentBlock( type="tool_result", diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py index f75f29ad24..31443b1383 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py @@ -4,11 +4,16 @@ import json from typing import Any, AsyncIterator, Dict, Iterator, Optional +from uuid import uuid4 + +from agenta.sdk.utils.logging import get_module_logger from ...dtos import AgentResult from ...streaming import AgentStream from .messages import TOOL_APPROVAL_REQUEST +log = get_module_logger(__name__) + # The AI SDK UI message stream (`ai@6`) validates the `finish` frame's # `finishReason` against this closed set. The runner surfaces the model's raw @@ -122,18 +127,34 @@ async def agent_run_to_vercel_parts( elif etype == "tool_call": tool_call_id = data.get("id") tool_name = data.get("name") + # A repeat tool_call for an already-seen id is an input REFRESH: the runner + # re-emits the call once the real args arrive on a later ACP tool_call_update + # (Pi announces the call first with absent/`{}` args). Emit `tool-input-start` + # only the first time — a second start would reset the FE tool part after its + # approval/output. Mirrors the seen-id refresh in `_interaction_parts`. + first_seen = tool_call_id not in seen_tool_calls seen_tool_calls.add(tool_call_id) - tool_names_by_id[tool_call_id] = tool_name - yield { - "type": "tool-input-start", - "toolCallId": tool_call_id, - "toolName": tool_name, - } + # Record the name only on first sight. On a repeat (arg-refresh) keep the + # best-known name: an intervening approval may have upgraded it to the STABLE + # spec name, and this refresh carries only the drift-prone ACP title — letting it + # clobber the spec name re-breaks the cross-turn resume key (the HITL loop). + if first_seen: + tool_names_by_id[tool_call_id] = tool_name + tool_name = tool_names_by_id.get(tool_call_id) or tool_name + if first_seen: + yield { + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + } yield { "type": "tool-input-available", "toolCallId": tool_call_id, "toolName": tool_name, - "input": data.get("input"), + # Prefer `rawInput` (the tool's real args, per ACP); the runner leaves the + # plain `input` empty on some tool-call paths — mirrors the approval / + # client-tool reads in `_interaction_parts` so every path shows real args. + "input": data.get("rawInput") or data.get("input"), } if data.get("render") is not None: yield _render_part(tool_call_id, data["render"]) @@ -169,7 +190,7 @@ async def agent_run_to_vercel_parts( if data.get("render") is not None: yield _render_part(tool_call_id, data["render"]) elif etype == "interaction_request": - for part in _interaction_parts(data, seen_tool_calls): + for part in _interaction_parts(data, seen_tool_calls, tool_names_by_id): yield part elif etype == "data": part: Dict[str, Any] = { @@ -224,7 +245,7 @@ async def agent_stream_to_vercel_stream( events: AsyncIterator[Dict[str, Any]], *, session_id: Optional[str] = None, - message_id: str = "msg-1", + message_id: Optional[str] = None, trace_id: Optional[str] = None, ) -> AsyncIterator[Dict[str, Any]]: """Project a stream of neutral agenta events into Vercel UI Message Stream parts. @@ -236,7 +257,13 @@ async def agent_stream_to_vercel_stream( ``done`` events; ``trace_id`` is passed in by routing (off the response), since there is no run to fall back to here. """ - start: Dict[str, Any] = {"type": "start", "messageId": message_id} + # Every turn needs a UNIQUE messageId — the client keys messages by it, so a shared constant + # collides across turns (duplicate React keys, dropped turns). Prefer the run's trace_id (stable, + # correlatable), else a fresh uuid. + resolved_message_id = message_id or ( + f"msg-{trace_id}" if trace_id else f"msg-{uuid4().hex}" + ) + start: Dict[str, Any] = {"type": "start", "messageId": resolved_message_id} if session_id is not None: start["messageMetadata"] = {"sessionId": session_id} yield start @@ -293,18 +320,34 @@ async def agent_stream_to_vercel_stream( elif etype == "tool_call": tool_call_id = data.get("id") tool_name = data.get("name") + # A repeat tool_call for an already-seen id is an input REFRESH: the runner + # re-emits the call once the real args arrive on a later ACP tool_call_update + # (Pi announces the call first with absent/`{}` args). Emit `tool-input-start` + # only the first time — a second start would reset the FE tool part after its + # approval/output. Mirrors the seen-id refresh in `_interaction_parts`. + first_seen = tool_call_id not in seen_tool_calls seen_tool_calls.add(tool_call_id) - tool_names_by_id[tool_call_id] = tool_name - yield { - "type": "tool-input-start", - "toolCallId": tool_call_id, - "toolName": tool_name, - } + # Record the name only on first sight. On a repeat (arg-refresh) keep the + # best-known name: an intervening approval may have upgraded it to the STABLE + # spec name, and this refresh carries only the drift-prone ACP title — letting it + # clobber the spec name re-breaks the cross-turn resume key (the HITL loop). + if first_seen: + tool_names_by_id[tool_call_id] = tool_name + tool_name = tool_names_by_id.get(tool_call_id) or tool_name + if first_seen: + yield { + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + } yield { "type": "tool-input-available", "toolCallId": tool_call_id, "toolName": tool_name, - "input": data.get("input"), + # Prefer `rawInput` (the tool's real args, per ACP); the runner leaves the + # plain `input` empty on some tool-call paths — mirrors the approval / + # client-tool reads in `_interaction_parts` so every path shows real args. + "input": data.get("rawInput") or data.get("input"), } if data.get("render") is not None: yield _render_part(tool_call_id, data["render"]) @@ -340,7 +383,7 @@ async def agent_stream_to_vercel_stream( if data.get("render") is not None: yield _render_part(tool_call_id, data["render"]) elif etype == "interaction_request": - for part in _interaction_parts(data, seen_tool_calls): + for part in _interaction_parts(data, seen_tool_calls, tool_names_by_id): yield part elif etype == "data": part: Dict[str, Any] = { @@ -382,7 +425,9 @@ async def agent_stream_to_vercel_stream( def _interaction_parts( - data: Dict[str, Any], seen_tool_calls: set + data: Dict[str, Any], + seen_tool_calls: set, + tool_names_by_id: Optional[Dict[Any, Any]] = None, ) -> Iterator[Dict[str, Any]]: """Project a neutral ``interaction_request`` event to Vercel stream parts. @@ -393,16 +438,34 @@ def _interaction_parts( didn't, synthesize a tool part from the request payload so the approval has something to render against. """ + names = tool_names_by_id if tool_names_by_id is not None else {} kind = data.get("kind") payload = data.get("payload") or {} if kind == "user_approval": tool_call_id = _approval_tool_call_id(payload) tool_call = payload.get("toolCall") if tool_call_id is not None and isinstance(tool_call, dict): - tool_name = ( - tool_call.get("name") or tool_call.get("title") or tool_call.get("kind") - ) + # Prefer the STABLE resolved spec name over the drift-prone ACP title/kind so the + # part the FE persists (and folds back on resume) keys the same as the live re-raised + # gate (responder.ts `permissionToolName`). Otherwise the cross-turn key silently + # stops matching and the gate re-parks every turn (the HITL resume loop). + tool_name = _approval_tool_name(tool_call) + # Record it as the AUTHORITATIVE name for this id, so a later arg-refresh tool_call + # (which carries only the drift-prone ACP title) cannot downgrade it back and re-break + # the resume key. See the tool_call handler's `tool_names_by_id.get(...)` preference. + names[tool_call_id] = tool_name real_input = tool_call.get("rawInput") or tool_call.get("input") + # EGRESS side of the HITL key: what name+args the FE persists on the approval part + # (and folds back on resume). Compare to the runner's live `[HITL] gate` identity. + log.info( + "[HITL] egress approval-request id=%s name=%s spec=%s input_keys=%s", + tool_call_id, + tool_name, + (_tool_spec_of(tool_call) or {}).get("name"), + list(real_input.keys()) + if isinstance(real_input, dict) + else type(real_input).__name__, + ) if tool_call_id not in seen_tool_calls: # The runner parked without first surfacing the tool call, so # synthesize a tool part for the approval to render against. @@ -419,10 +482,11 @@ def _interaction_parts( "input": real_input, } elif real_input: - # The tool call was already surfaced, often with empty input on a - # cold-replay resume. The approval request carries the real args, so - # re-emit `tool-input-available` to refresh the parked call's input - # instead of persisting `{}` (HITL approve-empty-input bug). + # The tool call was already surfaced (often by the tracing tool_call event, whose + # name is the ACP title/kind, and often with empty input on a cold-replay resume). + # Re-emit `tool-input-available` to refresh BOTH the stable `toolName` and the real + # args, instead of persisting the drift-prone name + `{}` input (HITL + # approve-empty-input / name-drift bug). yield { "type": "tool-input-available", "toolCallId": tool_call_id, @@ -488,6 +552,34 @@ def _render_part(tool_call_id: Any, render: Any) -> Dict[str, Any]: } +def _tool_spec_of(tool_call: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """The resolved agenta tool spec attached to an ACP tool call, under any of its aliases.""" + for key in ("spec", "toolSpec", "resolvedTool", "tool"): + spec = tool_call.get(key) + if isinstance(spec, dict): + return spec + return None + + +def _approval_tool_name(tool_call: Dict[str, Any]) -> Optional[Any]: + """The gated tool's name for the cross-turn resume key. + + Prefer ``resolvedName`` — the recorded ``tool_call`` name the runner stamps on the gate, the + SAME value the transcript folds into the stored key — so the egress names the part exactly as + the responder keys it. Then the resolved spec's canonical ``name`` (when a spec exists). Only + then the ACP display fields (``title``/``kind``), which drift between the park frame and the + permission frame and were the resume-loop root cause. Falls back to ``name -> title -> kind``. + """ + spec = _tool_spec_of(tool_call) + return ( + tool_call.get("resolvedName") + or (spec or {}).get("name") + or tool_call.get("name") + or tool_call.get("title") + or tool_call.get("kind") + ) + + def _approval_tool_call_id(payload: Dict[str, Any]) -> Optional[Any]: tool_call_id = payload.get("toolCallId") if tool_call_id is not None: diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py index 8d9e530deb..a2c7ff277a 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_park.py @@ -155,6 +155,206 @@ async def test_parked_tool_call_refreshes_real_args_on_approval() -> None: assert parts[-1]["type"] == "finish" +@pytest.mark.asyncio +async def test_approval_prefers_resolved_name_over_drifting_title() -> None: + """When the runner stamps ``resolvedName`` (the recorded tool_call name) on the gate, the + egress names the FE part with it — matching the responder's live key — even though the ACP + permission ``title`` is the drift-prone specific command. This is the live Claude-tool shape.""" + run = AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "Terminal", + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "interaction_request", + "id": "perm-1", + "kind": "user_approval", + "payload": { + "toolCallId": "tool-1", + "toolCall": { + "id": "tool-1", + "resolvedName": "Terminal", + "title": "cat ~/.claude/settings.json", + "kind": "execute", + "rawInput": {"command": "cat ~/.claude/settings.json"}, + }, + }, + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "paused"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "paused", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + parts = [part async for part in agent_run_to_vercel_parts(run)] + inputs = [p for p in parts if p.get("type") == "tool-input-available"] + assert inputs[-1]["toolName"] == "Terminal", ( + "the resolved (recorded) name wins over the drifting permission title" + ) + + +def _parked_run_with_spec_name() -> AgentStream: + """A parked turn whose tracing ``tool_call`` surfaced the drift-prone ACP display name + (``Terminal``), while the approval request carries the resolved spec's STABLE canonical name + (``Bash``). The egress must key the FE part on the spec name so the cross-turn resume key + matches the runner's live re-raised gate (which also prefers ``spec.name``).""" + return AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "Terminal", # ACP title/kind — varies across turns + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "interaction_request", + "id": "perm-1", + "kind": "user_approval", + "payload": { + "toolCallId": "tool-1", + "toolCall": { + "id": "tool-1", + "title": "Terminal", + "kind": "execute", + "spec": {"name": "Bash"}, + "rawInput": {"cmd": "ls"}, + }, + }, + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "paused"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "paused", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + + +@pytest.mark.asyncio +async def test_approval_refreshes_part_with_stable_spec_name() -> None: + """The approval refresh re-keys the parked part on the resolved spec name (stable across + cold-replay turns), not the drift-prone ACP title/kind the tracing tool_call surfaced.""" + parts = [ + part async for part in agent_run_to_vercel_parts(_parked_run_with_spec_name()) + ] + inputs = [p for p in parts if p.get("type") == "tool-input-available"] + # The refreshing emit from the approval request names the tool by its stable spec name. + assert inputs[-1]["toolName"] == "Bash" + assert inputs[-1]["input"] == {"cmd": "ls"} + + +def _parked_run_with_late_arg_refresh() -> AgentStream: + """The regression order: the tool_call surfaces (empty), the approval upgrades the part to the + stable spec name (``Bash``), and THEN Pi's real args land on a later tool_call_update — which + the runner replays as a repeat ``tool_call`` carrying only the drift-prone ACP title. That + late refresh must NOT clobber the spec name back to the title, or the cross-turn resume key + breaks and the gate loops again (the regression from the input-`{}` fix).""" + return AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "Terminal", + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "interaction_request", + "id": "perm-1", + "kind": "user_approval", + "payload": { + "toolCallId": "tool-1", + "toolCall": { + "id": "tool-1", + "title": "Terminal", + "kind": "execute", + "spec": {"name": "Bash"}, + "rawInput": {"cmd": "ls"}, + }, + }, + }, + }, + # Pi fills the args in AFTER the gate: a repeat tool_call with only the ACP title. + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "Terminal", + "rawInput": {"cmd": "ls"}, + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "paused"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "paused", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + + +@pytest.mark.asyncio +async def test_late_arg_refresh_does_not_clobber_stable_spec_name() -> None: + """A late arg-refresh (repeat tool_call) after the approval keeps the stable spec name, so the + FE part the resume folds back still keys as ``Bash`` (not the drift-prone ``Terminal``).""" + parts = [ + part + async for part in agent_run_to_vercel_parts(_parked_run_with_late_arg_refresh()) + ] + inputs = [p for p in parts if p.get("type") == "tool-input-available"] + # Every emission for this id keeps the stable name; the last (the late refresh) must too. + assert inputs[-1]["toolName"] == "Bash", ( + "the late arg-refresh must not downgrade the approval's stable spec name" + ) + assert inputs[-1]["input"] == {"cmd": "ls"} + # And there is exactly ONE tool-input-start (the refresh must not reset the part). + assert sum(1 for p in parts if p.get("type") == "tool-input-start") == 1 + + def _commit_revision_run() -> AgentStream: return AgentStream( _records( @@ -317,3 +517,127 @@ async def test_parked_client_tool_emits_unsettled_tool_part() -> None: } in parts assert not any(p.get("type") == "tool-output-available" for p in parts) assert parts[-1]["type"] == "finish" + + +def _plain_tool_call_with_raw_input() -> AgentStream: + """A non-gated tool call where the runner surfaced the real args under ``rawInput`` (the ACP + field) and left the plain ``input`` empty — the common shape behind tool logs showing ``{}`` + for tools that never go through an approval refresh.""" + return AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "read_file", + "input": {}, + "rawInput": {"path": "memory://users/self/profile.md"}, + }, + }, + { + "kind": "event", + "event": { + "type": "tool_result", + "id": "tool-1", + "output": {"ok": True}, + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "stop"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "stop", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + + +@pytest.mark.asyncio +async def test_plain_tool_call_prefers_raw_input() -> None: + """A non-gated tool call projects its real args from ``rawInput`` when the runner leaves the + plain ``input`` empty. Without this, only approval-gated tools recovered their args (via the + approval refresh) and every other tool log rendered ``{}``.""" + parts = [ + part + async for part in agent_run_to_vercel_parts(_plain_tool_call_with_raw_input()) + ] + + inputs = [p for p in parts if p.get("type") == "tool-input-available"] + assert len(inputs) == 1 + assert inputs[0]["input"] == {"path": "memory://users/self/profile.md"} + + +def _tool_call_then_input_refresh_run() -> AgentStream: + """A non-gated tool the runner surfaces up front with an empty input, then re-emits with the + real args once they arrive on the ACP ``tool_call_update`` (the runner's input-refresh).""" + return AgentStream( + _records( + [ + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "query_workflows", + "input": {}, + }, + }, + { + "kind": "event", + "event": { + "type": "tool_call", + "id": "tool-1", + "name": "query_workflows", + "input": {"kind": "agent"}, + }, + }, + { + "kind": "event", + "event": { + "type": "tool_result", + "id": "tool-1", + "output": "3 found", + }, + }, + {"kind": "event", "event": {"type": "done", "stopReason": "stop"}}, + { + "kind": "result", + "result": { + "ok": True, + "output": "", + "stopReason": "stop", + "sessionId": "conv-1", + "traceId": "trace-1", + }, + }, + ] + ) + ) + + +@pytest.mark.asyncio +async def test_repeat_tool_call_refreshes_input_without_a_second_start() -> None: + """A repeat ``tool_call`` for a seen id is an input REFRESH: it re-emits ``tool-input-available`` + with the real args but must NOT emit a second ``tool-input-start`` (which would reset the FE + tool part and make it look like the tool re-ran). Mirrors the gated approval-refresh path.""" + parts = [ + part + async for part in agent_run_to_vercel_parts(_tool_call_then_input_refresh_run()) + ] + + starts = [p for p in parts if p.get("type") == "tool-input-start"] + inputs = [p for p in parts if p.get("type") == "tool-input-available"] + # Exactly one start (the call surfaces once), two inputs (empty, then the refresh with args). + assert len(starts) == 1 + assert [p["input"] for p in inputs] == [{}, {"kind": "agent"}] + # The refresh precedes the result — input is settled before output arrives. + types = [p.get("type") for p in parts] + assert types.index("tool-input-available") < types.index("tool-output-available") diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index d3d816edab..136c79a728 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -36,6 +36,7 @@ import { import { HITLResponder, extractApprovalDecisions, + nonConvergingToolNames, policyFromRequest, type Responder, } from "../responder.ts"; @@ -649,14 +650,31 @@ export async function runSandboxAgent( }; const responder = deps.responderFactory?.(request.permissionPolicy) ?? - new HITLResponder( - extractApprovalDecisions(request), - policyFromRequest(request.permissionPolicy), - hasHumanSurface, - ); + (() => { + const decisions = extractApprovalDecisions(request); + const looping = nonConvergingToolNames(request); + // Dump the STORED side of the HITL resume: the decision keys the transcript folded and any + // tools flagged as non-converging. Compare against the runner's `[HITL] gate ...` lines + // (the LIVE re-raised keys) to see exactly what drifted. + if (hasHumanSurface) { + logger( + `[HITL] resume state: humanSurface=${hasHumanSurface} ` + + `decisions=${JSON.stringify([...decisions.entries()])} ` + + `looping=${JSON.stringify([...looping])}`, + ); + } + return new HITLResponder( + decisions, + policyFromRequest(request.permissionPolicy), + hasHumanSurface, + looping, + logger, + ); + })(); attachPermissionResponder({ session, run, + log: logger, onPark, onCreateInteraction: (token, toolName, toolArgs) => { const cred = runCredential(request); diff --git a/services/runner/src/engines/sandbox_agent/permissions.ts b/services/runner/src/engines/sandbox_agent/permissions.ts index 60408164cf..7f706a2511 100644 --- a/services/runner/src/engines/sandbox_agent/permissions.ts +++ b/services/runner/src/engines/sandbox_agent/permissions.ts @@ -3,7 +3,7 @@ import { decisionToReply, type ClientToolOutcome, type Responder } from "../../r export interface AttachPermissionResponderInput { session: any; - run: { emitEvent: (event: AgentEvent) => void }; + run: { emitEvent: (event: AgentEvent) => void; events?: () => AgentEvent[] }; responder: Responder; /** * Called when the responder PARKS a gate (cross-turn HITL). The orchestration loop uses @@ -14,6 +14,8 @@ export interface AttachPermissionResponderInput { * paused and the resume cold-replays. Fires at most once per turn (the first park wins). */ onPark?: () => void; + /** Diagnostic sink for the raw ACP permission ground truth (name/spec/args the harness sends). */ + log?: (msg: string) => void; /** Called on park to record the parked gate as an interaction (fire-and-forget). */ onCreateInteraction?: ( token: string, @@ -40,6 +42,7 @@ export function attachPermissionResponder({ run, responder, onPark, + log, onCreateInteraction, onResolveInteraction, }: AttachPermissionResponderInput): void { @@ -47,6 +50,40 @@ export function attachPermissionResponder({ const id = String(req?.id ?? ""); const availableReplies: string[] = req?.availableReplies ?? []; const toolCall = req?.toolCall; + // NAME ANCHOR (the HITL resume-loop root cause): Claude-over-ACP names the SAME tool call + // differently across frames — the `session/update` tool_call titles it by category ("Terminal"), + // the permission request titles it by the specific invocation ("cat ~/.claude/settings.json ..."). + // Neither carries a stable `name`/`spec`. The transcript (and thus the stored approval key) + // records the tool_call name, so the cross-turn key must ALSO use it — not the permission + // frame's own drifting title. Recover the recorded tool_call name for THIS id (same id within a + // turn) and stamp it as `resolvedName`, which `permissionToolName` (responder) and + // `_approval_tool_name` (egress) both prefer, so the live re-raised key equals the stored key. + if (toolCall && typeof toolCall === "object" && !toolCall.resolvedName) { + const recorded = recordedToolName(run, toolCall.toolCallId); + if (recorded) toolCall.resolvedName = recorded; + } + // GROUND TRUTH: exactly what the harness hands us for this gate — the resolved spec (and its + // stable name) if any, the drift-prone display fields, and the arg shape. This is the earliest + // and most authoritative HITL log; everything downstream keys off these fields. + if (log) { + const spec = + toolCall?.spec ?? toolCall?.toolSpec ?? toolCall?.resolvedTool ?? toolCall?.tool; + const args = toolCall?.rawInput ?? toolCall?.input; + log( + `[HITL] ACP permission id=${id} ` + + JSON.stringify({ + toolCallId: toolCall?.toolCallId, + specName: spec && typeof spec === "object" ? spec.name : undefined, + specKind: spec && typeof spec === "object" ? spec.kind : undefined, + name: toolCall?.name, + title: toolCall?.title, + kind: toolCall?.kind, + argKeys: + args && typeof args === "object" ? Object.keys(args) : typeof args, + availableReplies, + }), + ); + } // The harness raises a permission request before running ANY gated tool. We branch on the // tool's resolved spec: a `kind: "client"` tool is not really a sandbox permission gate — it // is a tool whose execution belongs to the browser (e.g. `request_connection`, which renders a @@ -133,6 +170,27 @@ export function attachPermissionResponder({ }); } +/** + * The name the runner already recorded for this tool-call id via the `session/update` `tool_call` + * event — the SAME value the transcript folds into the stored approval key. Used to key the live + * permission gate so it matches the stored decision across the cold-replay resume (the ACP + * permission frame's own title drifts from the tool_call's, breaking the key otherwise). + */ +function recordedToolName( + run: { events?: () => AgentEvent[] }, + toolCallId: unknown, +): string | undefined { + if (typeof toolCallId !== "string" || !toolCallId || !run.events) return undefined; + // Last match wins: a later tool_call for the same id (an arg-refresh) carries the same name. + let name: string | undefined; + for (const event of run.events()) { + if (event.type === "tool_call" && event.id === toolCallId && event.name) { + name = event.name; + } + } + return name; +} + function clientToolSpecOf(toolCall: any): any | undefined { return toolCall?.spec ?? toolCall?.toolSpec ?? toolCall?.resolvedTool ?? toolCall?.tool; } diff --git a/services/runner/src/responder.ts b/services/runner/src/responder.ts index a5133d7e41..fbce5b398e 100644 --- a/services/runner/src/responder.ts +++ b/services/runner/src/responder.ts @@ -196,12 +196,45 @@ export class HITLResponder implements Responder { private readonly decisions: ApprovalDecisions, private readonly basePolicy: PermissionPolicy, private readonly hasHumanSurface: boolean, + /** Tool names in a non-converging resume loop; the next gate for one is denied (loop-breaker). */ + private readonly nonConverging: ReadonlySet = new Set(), + /** Diagnostic sink (a miss or a loop-break). No-op by default so the responder stays pure in tests. */ + private readonly log: (msg: string) => void = () => {}, ) {} async onPermission(request: PermissionRequest): Promise { + const toolCall = (request.raw as { toolCall?: unknown } | undefined)?.toolCall; + const name = permissionToolName(toolCall); + const keys = permissionRequestKeys(request); + const stored = this.lookupPermission(request); - if (stored) return stored; - if (this.hasHumanSurface) return "park"; // human must decide; end the turn, tool pending + if (stored) { + this.log( + `[HITL] gate "${name}" -> stored ${stored} (resume matched) keys=${JSON.stringify(keys)}`, + ); + return stored; + } + // Loop-breaker: a gate with no stored decision whose tool has been approved repeatedly + // without ever executing is a non-converging cold-replay resume (the name/arg key never + // re-matched). DENY it — a clean terminal failure the model stops re-issuing — instead of + // parking forever (the infinite re-approval loop). Fail-safe under the real key fix above. + if (name && this.nonConverging.has(name)) { + this.log( + `[HITL] loop-breaker: denying "${name}" — approved repeatedly but never executed ` + + `(resume key never matched; likely name/arg drift). ` + + `keys=${JSON.stringify(keys)} identity=${gateIdentity(toolCall)}`, + ); + return "deny"; + } + if (this.hasHumanSurface) { + // First-time gate (or a fresh park after a miss). Dump the full ground-truth identity so a + // later re-raise can be compared field-by-field to see what drifted. + this.log( + `[HITL] gate "${name}" -> PARK (awaiting human) keys=${JSON.stringify(keys)} ` + + `identity=${gateIdentity(toolCall)}`, + ); + return "park"; // human must decide; end the turn, tool pending + } return this.basePolicy === "deny" ? "deny" : "allow"; // headless: PolicyResponder parity } @@ -213,10 +246,20 @@ export class HITLResponder implements Responder { } private lookupPermission(request: PermissionRequest): PermissionDecision | undefined { - for (const key of permissionRequestKeys(request)) { + const keys = permissionRequestKeys(request); + for (const key of keys) { const decision = this.decisions.get(key); if (isPermissionDecision(decision)) return decision; } + // Diagnostic: a gate that misses while decisions ARE present is the resume-key-drift + // signature (the stored approve/deny key never matched the re-raised gate). Dump both so a + // single live session pins whether the NAME or the ARGS drifted. + if (this.decisions.size > 0) { + this.log( + `[HITL] gate miss keys=${JSON.stringify(keys)} ` + + `stored=${JSON.stringify([...this.decisions.keys()])}`, + ); + } return undefined; } @@ -262,6 +305,39 @@ function permissionRequestKeys(request: PermissionRequest): string[] { return keys; } +/** + * A compact ground-truth dump of an ACP tool call for HITL logs: every name candidate the key + * derivation sees (so a park vs. its re-raise can be diffed field-by-field) plus the arg shape. + * This is the single most useful diagnostic for the resume-loop: it shows whether the harness + * even attaches a resolved `spec` (and its name), and whether title/kind/args drift across turns. + */ +function gateIdentity(toolCall: unknown): string { + const tc = toolCall as + | { + toolCallId?: unknown; + resolvedName?: unknown; + name?: unknown; + title?: unknown; + kind?: unknown; + rawInput?: unknown; + input?: unknown; + } + | undefined; + const spec = specOf(toolCall); + const args = tc?.rawInput ?? tc?.input; + const argKeys = + args && typeof args === "object" ? Object.keys(args as object) : typeof args; + return JSON.stringify({ + id: tc?.toolCallId, + resolvedName: tc?.resolvedName, + specName: spec?.name, + name: tc?.name, + title: tc?.title, + kind: tc?.kind, + argKeys, + }); +} + function clientToolRequestKeys(request: ClientToolRequest): string[] { const keys: string[] = []; if (request.toolCallId) keys.push(request.toolCallId); @@ -270,12 +346,44 @@ function clientToolRequestKeys(request: ClientToolRequest): string[] { return keys; } -/** Resolve the gated tool's name the same way the egress does: name, then title, then kind. */ +/** The resolved agenta tool spec attached to an ACP tool call, under any of its aliases. */ +function specOf(toolCall: unknown): { name?: unknown } | undefined { + const tc = toolCall as + | { + spec?: unknown; + toolSpec?: unknown; + resolvedTool?: unknown; + tool?: unknown; + } + | undefined; + const spec = tc?.spec ?? tc?.toolSpec ?? tc?.resolvedTool ?? tc?.tool; + return spec && typeof spec === "object" ? (spec as { name?: unknown }) : undefined; +} + +/** + * Resolve the gated tool's name for the cold-replay key. Prefer the resolved spec's canonical + * `name` — it is STABLE across turns — over the ACP display fields (`title`/`kind`), which the + * harness can vary between the park turn and the re-raise (a Claude tool has no ACP `name`, so + * the previous `name -> title -> kind` chain keyed on a drift-prone display string and the + * cross-turn key silently stopped matching -> re-park loop). The egress + * (`vercel/stream.py::_interaction_request`) prefers `spec.name` the same way, so the stored key + * and this live key agree. Falls back to the old chain when no spec is resolved (unchanged). + */ function permissionToolName(toolCall: unknown): string | undefined { const tc = toolCall as - | { name?: unknown; title?: unknown; kind?: unknown } + | { resolvedName?: unknown; name?: unknown; title?: unknown; kind?: unknown } | undefined; - for (const candidate of [tc?.name, tc?.title, tc?.kind]) { + // `resolvedName` (the recorded tool_call name, stamped by the engine) comes FIRST: it is the + // same value the transcript folds into the stored key, so preferring it makes the cross-turn + // resume key match. `spec.name` next (when a resolved spec exists), then the drift-prone ACP + // display fields as a last resort. + for (const candidate of [ + tc?.resolvedName, + specOf(toolCall)?.name, + tc?.name, + tc?.title, + tc?.kind, + ]) { if (typeof candidate === "string" && candidate) return candidate; } return undefined; @@ -342,6 +450,56 @@ export function extractApprovalDecisions( return decisions; } +/** + * Tool names whose HITL approvals are NOT converging into real executions — the fingerprint of a + * cold-replay resume loop. A successful approve makes the tool RUN and emit a real `tool_result` + * on the next turn, so a healthy tool has ~one real result per approval. When a tool's `{approved: + * true}` envelopes outnumber its real results by `threshold` or more, the resume key never matched + * (name/arg drift) and the gate re-parked every turn. The caller denies the next gate for such a + * tool (a clean terminal failure) instead of parking forever. Denies (`{approved: false}`) are + * terminal, never a loop, so they are ignored. + */ +export function nonConvergingToolNames( + request: AgentRunRequest, + threshold = 3, +): Set { + const nameById = new Map(); + for (const message of request.messages ?? []) { + const content = message?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block?.type === "tool_call" && block.toolCallId) { + nameById.set(block.toolCallId, block.toolName); + } + } + } + const approved = new Map(); + const executed = new Map(); + for (const message of request.messages ?? []) { + const content = message?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block?.type !== "tool_result") continue; + const name = + block.toolName ?? + (block.toolCallId ? nameById.get(block.toolCallId) : undefined); + if (!name) continue; + const out = block.output; + const flag = + out && typeof out === "object" && !Array.isArray(out) + ? (out as { approved?: unknown }).approved + : undefined; + if (flag === true) approved.set(name, (approved.get(name) ?? 0) + 1); + else if (flag !== false) executed.set(name, (executed.get(name) ?? 0) + 1); + } + } + const looping = new Set(); + for (const [name, count] of approved) { + if (count - (executed.get(name) ?? 0) >= threshold) looping.add(name); + } + return looping; +} + function isPermissionDecision(value: unknown): value is PermissionDecision { return value === "allow" || value === "deny"; } diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index 2f4f4b73fa..82fa05268e 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -661,6 +661,22 @@ function acpBlockText(block: any): string { return ""; } +/** + * Whether a tool's `rawInput` holds real, inspectable args. Pi announces a call with an absent + * or empty `{}` input and fills the args in on a later `tool_call_update`; both placeholders + * count as "no args yet" so we know to refresh the tool_call once the real args land. + */ +function hasToolArgs(input: unknown): boolean { + if (input == null) return false; + if ( + typeof input === "object" && + !Array.isArray(input) && + Object.keys(input as Record).length === 0 + ) + return false; + return true; +} + /** Text of an ACP tool_call `content` array (ToolCallContent[]). */ function acpToolContentText(content: any): string { if (!content) return ""; @@ -872,7 +888,7 @@ export function createSandboxAgentOtel( let reasoningAccumulated = ""; let usage: AgentUsage | undefined; const events: AgentEvent[] = []; - const toolSpans = new Map(); + const toolSpans = new Map(); // Live emission. `record` is the single choke point for every event: it appends to the // result log and, on the streaming path, flushes the event the moment it is built — so @@ -1105,20 +1121,36 @@ export function createSandboxAgentOtel( if (update.rawInput != null) setInputs(span, update.rawInput as Record, capture); } - toolSpans.set(id, { span, name: String(name) }); + // Emit the tool_call up front — the FE tool part, the HITL approval part, and the loop + // breaker all attach to it, so it MUST surface before any approval/result for this id. + // Pi often announces the call with absent/`{}` args and fills them on a later + // `tool_call_update`; we refresh the input there (see below), never by delaying this. record({ type: "tool_call", id: String(id), name: String(name), input: update.rawInput, }); + toolSpans.set(id, { span, name: String(name), hasArgs: hasToolArgs(update.rawInput) }); // A tool_call can arrive already completed (status set up front). maybeCloseTool(id, update); return; } if (kind === "tool_call_update") { - maybeCloseTool(update.toolCallId, update); + // The real args often land here, not on the initial `tool_call`. Refresh the input ONCE, + // by re-recording the tool_call with the real args, so a non-gated tool shows them instead + // of the placeholder `{}`. The egress projects a repeat tool_call for a seen id as an + // input refresh (no new tool-input-start), mirroring the gated approval-refresh path. + const id = update.toolCallId; + const entry = id ? toolSpans.get(id) : undefined; + if (entry && !entry.hasArgs && hasToolArgs(update.rawInput)) { + if (entry.span) + setInputs(entry.span, update.rawInput as Record, capture); + record({ type: "tool_call", id: String(id), name: entry.name, input: update.rawInput }); + entry.hasArgs = true; + } + maybeCloseTool(id, update); return; } diff --git a/services/runner/tests/unit/responder.test.ts b/services/runner/tests/unit/responder.test.ts index 6ea9c40eaf..eee3d06d99 100644 --- a/services/runner/tests/unit/responder.test.ts +++ b/services/runner/tests/unit/responder.test.ts @@ -19,6 +19,7 @@ import { parkedCallKey, decisionToReply, extractApprovalDecisions, + nonConvergingToolNames, policyFromRequest, type PermissionDecision, type PermissionRequest, @@ -466,3 +467,251 @@ describe("emitEvent", () => { assert.equal((ev as any).name, "weather"); }); }); + +// A permission request whose ACP tool call carries a resolved spec (with a STABLE canonical +// name) plus drift-prone display fields (title/kind). The live wire for a Claude tool has no +// `name`, so the key must anchor on `spec.name`, not the title/kind that varies across turns. +function permReqWithSpec( + toolCallId: string, + specName: string, + displayTitle: string, + rawInput?: unknown, +): PermissionRequest { + return { + id: "perm-1", + availableReplies: ["once", "always", "reject"], + raw: { + id: "perm-1", + toolCall: { + toolCallId, + title: displayTitle, + kind: "execute", + spec: { name: specName }, + rawInput, + }, + }, + }; +} + +// The LIVE production shape (from runner logs): a Claude-over-ACP gate with NO spec and a +// permission-frame `title` that is the specific invocation ("cat ~/...") — which DIFFERS from the +// `session/update` tool_call title the transcript stored ("Terminal"). The engine recovers the +// recorded tool_call name and stamps it as `resolvedName`; the key must anchor on THAT. +function permReqResolved( + toolCallId: string, + resolvedName: string, + driftingTitle: string, + rawInput?: unknown, +): PermissionRequest { + return { + id: "perm-1", + availableReplies: ["once", "always", "reject"], + raw: { + id: "perm-1", + toolCall: { toolCallId, resolvedName, title: driftingTitle, kind: "execute", rawInput }, + }, + }; +} + +describe("HITLResponder — recorded-name anchor (the live ACP title-drift loop)", () => { + it("resumes: stored key uses the tool_call name (Terminal) while the permission title drifts (cat ...)", async () => { + // Exactly the logged failure: stored `Terminal#{command,description}` -> allow, re-raised gate + // whose ACP permission title is the full command. Without `resolvedName` the live key would be + // `cat ...#args` and never match -> re-park loop. With it, the live key is `Terminal#args`. + const args = { command: "cat ~/.claude/settings.json", description: "Read global settings" }; + const decisions = new Map([ + [parkedCallKey("Terminal", args)!, "allow"], + ]); + const responder = new HITLResponder(decisions, "auto", true); + assert.equal( + await responder.onPermission( + permReqResolved("fresh-id", "Terminal", "cat ~/.claude/settings.json", args), + ), + "allow", + "the recorded tool_call name anchors the resume despite the drifting permission title", + ); + }); + + it("the loop-breaker also matches on the recorded name (looping set is keyed the same way)", async () => { + const args = { command: "cat x" }; + const responder = new HITLResponder( + new Map(), + "auto", + true, + new Set(["Terminal"]), // nonConverging is keyed by the stored (recorded) name + () => {}, + ); + // The live gate resolves to "Terminal" (not the "cat x" title), so the breaker engages. + assert.equal( + await responder.onPermission(permReqResolved("fresh", "Terminal", "cat x", args)), + "deny", + ); + }); +}); + +describe("HITLResponder — stable spec.name anchor (name-drift fix)", () => { + it("resumes when the stored key used the spec name but the re-raised gate only has a drifting title", async () => { + // Park turn: the tool was stored under its canonical spec name (what the egress now writes). + const decisions = new Map([ + [parkedCallKey("commit_revision", { message: "hi" })!, "allow"], + ]); + const responder = new HITLResponder(decisions, "auto", true); + // Re-raise: fresh id, NO ACP `name`, a display title that differs from the spec name. + // The old `name -> title -> kind` chain would key on "Commit changes" and never match. + assert.equal( + await responder.onPermission( + permReqWithSpec("fresh-id", "commit_revision", "Commit changes", { + message: "hi", + }), + ), + "allow", + "spec.name anchors the resume even when the display title drifts", + ); + }); + + it("SECURITY: the spec-name anchor is still args-scoped (different args re-prompt)", async () => { + const decisions = new Map([ + [parkedCallKey("commit_revision", { message: "hi" })!, "allow"], + ]); + const responder = new HITLResponder(decisions, "auto", true); + assert.equal( + await responder.onPermission( + permReqWithSpec("call-b", "commit_revision", "Commit changes", { + message: "DIFFERENT", + }), + ), + "park", + "a different-args call must re-prompt even under the same spec name", + ); + }); +}); + +describe("nonConvergingToolNames — HITL resume loop detection", () => { + // Build a transcript with `approves` {approved:true} envelopes and `execs` real results for + // one tool, mirroring what the Vercel ingress folds onto each turn. + function transcriptFor( + name: string, + approves: number, + execs: number, + ): AgentRunRequest { + const content: any[] = []; + for (let i = 0; i < approves; i++) + content.push({ + type: "tool_result", + toolCallId: `a-${i}`, + toolName: name, + output: { approved: true }, + }); + for (let i = 0; i < execs; i++) + content.push({ + type: "tool_result", + toolCallId: `e-${i}`, + toolName: name, + output: "real output", + }); + return { messages: [{ role: "tool", content }] }; + } + + it("flags a tool approved repeatedly but never executed", () => { + const looping = nonConvergingToolNames(transcriptFor("commit_revision", 3, 0)); + assert.ok(looping.has("commit_revision")); + }); + + it("does NOT flag a converging tool (each approval produced a real result)", () => { + const looping = nonConvergingToolNames(transcriptFor("edit", 3, 3)); + assert.equal(looping.has("edit"), false); + }); + + it("does NOT flag below the threshold (a couple of retries are tolerated)", () => { + const looping = nonConvergingToolNames(transcriptFor("bash", 2, 0)); + assert.equal(looping.has("bash"), false); + }); + + it("recovers the tool name from the correlated tool_call when the result omits it", () => { + const request: AgentRunRequest = { + messages: [ + { + role: "assistant", + content: [ + { type: "tool_call", toolCallId: "t1", toolName: "deploy" }, + { type: "tool_call", toolCallId: "t2", toolName: "deploy" }, + { type: "tool_call", toolCallId: "t3", toolName: "deploy" }, + ], + }, + { + role: "tool", + content: [ + { type: "tool_result", toolCallId: "t1", output: { approved: true } }, + { type: "tool_result", toolCallId: "t2", output: { approved: true } }, + { type: "tool_result", toolCallId: "t3", output: { approved: true } }, + ], + }, + ], + }; + assert.ok(nonConvergingToolNames(request).has("deploy")); + }); +}); + +describe("HITLResponder — loop-breaker + diagnostics", () => { + it("DENIES a gate whose tool is non-converging, instead of parking forever", async () => { + const logs: string[] = []; + // No stored decision matches (the drift), and the tool is flagged as looping. + const responder = new HITLResponder( + new Map(), + "auto", + true, // human surface -> would normally park + new Set(["commit_revision"]), + (m) => logs.push(m), + ); + assert.equal( + await responder.onPermission( + permReqWithSpec("fresh", "commit_revision", "Commit changes", { + message: "x", + }), + ), + "deny", + "the loop-breaker denies rather than re-parking", + ); + assert.ok( + logs.some((l) => l.includes("loop-breaker")), + "the loop-break is logged for diagnostics", + ); + }); + + it("still parks a NON-looping tool with no stored decision (loop-breaker is scoped)", async () => { + const responder = new HITLResponder( + new Map(), + "auto", + true, + new Set(["commit_revision"]), + () => {}, + ); + assert.equal( + await responder.onPermission( + permReqWithSpec("fresh", "edit", "Edit file", { path: "a.txt" }), + ), + "park", + "a different tool that is not looping still parks normally", + ); + }); + + it("logs a gate MISS when decisions are present but none match (drift diagnostic)", async () => { + const logs: string[] = []; + const decisions = new Map([ + [parkedCallKey("edit", { path: "a.txt" })!, "allow"], + ]); + const responder = new HITLResponder( + decisions, + "auto", + true, + new Set(), + (m) => logs.push(m), + ); + // A gate that does not match the stored key -> park, but the miss is logged with both sides. + await responder.onPermission(permReqWithSpec("fresh", "edit", "Edit", { path: "OTHER" })); + assert.ok( + logs.some((l) => l.includes("gate miss") && l.includes("stored=")), + "the miss dumps live keys and stored keys", + ); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-permissions.test.ts b/services/runner/tests/unit/sandbox-agent-permissions.test.ts index 7e5faa47d2..ff44808a84 100644 --- a/services/runner/tests/unit/sandbox-agent-permissions.test.ts +++ b/services/runner/tests/unit/sandbox-agent-permissions.test.ts @@ -81,6 +81,46 @@ describe("attachPermissionResponder", () => { assert.deepEqual(replies, [{ id: "perm-1", reply: "once" }]); }); + it("stamps resolvedName from the recorded tool_call so the resume key matches (ACP title-drift fix)", async () => { + // Live shape: the `session/update` tool_call named the tool "Terminal"; the permission frame + // titles it by the specific command. The responder must see `resolvedName: "Terminal"` (the + // recorded name) so its key matches the stored `Terminal#args`, not the drifting command title. + let handler: ((req: any) => void) | undefined; + const session = { + onPermissionRequest(cb: (req: any) => void) { + handler = cb; + }, + async respondPermission() {}, + }; + const recorded: AgentEvent[] = [ + { type: "tool_call", id: "tool-1", name: "Terminal", input: {} }, + ]; + const seen: PermissionRequest[] = []; + const responder: Responder = { + async onPermission(request) { + seen.push(request); + return "allow"; + }, + async onClientTool() { + return "deny"; + }, + }; + attachPermissionResponder({ + session, + run: { emitEvent: () => {}, events: () => recorded }, + responder, + }); + handler?.({ + id: "perm-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tool-1", title: "cat ~/.claude/settings.json", kind: "execute" }, + }); + await flushPromises(); + + const tc = (seen[0]?.raw as any)?.toolCall; + assert.equal(tc.resolvedName, "Terminal", "recorded tool_call name stamped onto the gate"); + }); + it("parks: emits the interaction_request but sends NO harness reply (F-024 regression)", async () => { // The park outcome must never reach the harness as a reply: a `reject` would make Claude // emit a failed tool call ("User refused permission") whose tool_result{isError} clobbers diff --git a/services/runner/tests/unit/stream-events.test.ts b/services/runner/tests/unit/stream-events.test.ts index 7ce3a4f2c9..acd8c39f3e 100644 --- a/services/runner/tests/unit/stream-events.test.ts +++ b/services/runner/tests/unit/stream-events.test.ts @@ -115,7 +115,9 @@ describe("createSandboxAgentOtel state machine", () => { } // The structured tool/usage events are still present, with exactly one done. - assert.equal(ofType(events, "tool_call").length, 1, "tool_call present"); + const calls = ofType(events, "tool_call"); + assert.equal(calls.length, 1, "tool_call present"); + assert.deepEqual(calls[0].input, { city: "Paris" }, "tool_call carries the args from the initial notification"); assert.equal(ofType(events, "tool_result").length, 1, "tool_result present"); assert.equal(ofType(events, "usage").length, 1, "usage present"); assert.equal(ofType(events, "done").length, 1, "exactly one done"); @@ -143,4 +145,59 @@ describe("createSandboxAgentOtel state machine", () => { assert.equal(ofType(events, "done").length, 1, "exactly one done without spans"); assert.ok(types(events).indexOf("usage") < types(events).indexOf("done"), "usage precedes done"); }); + + it("scenario 4: surfaces the tool_call up front, then refreshes its input from a later tool_call_update", () => { + // The real Pi wire: the initial `tool_call` announces the call with NO args, and the args + // land on a subsequent `tool_call_update`. The tool_call MUST surface immediately (the FE + // tool part + HITL approval attach to it), and then a second tool_call REFRESHES the input + // once the real args arrive — so a non-gated tool shows its args instead of `{}`. + const emitted: AgentEvent[] = []; + const run = createSandboxAgentOtel({ harness: "pi", model: "openai-codex/x", emit: (e) => emitted.push(e), emitSpans: false }); + run.start({ prompt: "list connections" }); + run.handleUpdate({ sessionUpdate: "tool_call", toolCallId: "c1", title: "list_connections" }); // no rawInput yet + // The call surfaces immediately (emit-first invariant), before any args or result. + assert.equal(ofType(emitted, "tool_call").length, 1, "tool_call emitted up front, on the initial notification"); + run.handleUpdate({ sessionUpdate: "tool_call_update", toolCallId: "c1", rawInput: { limit: 50 } }); // args land here + run.handleUpdate({ sessionUpdate: "tool_call_update", toolCallId: "c1", status: "completed", content: [{ content: { type: "text", text: "ok" } }] }); + run.finish(); + + const calls = ofType(emitted, "tool_call"); + assert.equal(calls.length, 2, "one initial surface + one input refresh"); + assert.deepEqual(calls[calls.length - 1].input, { limit: 50 }, "the refresh carries the real args from the tool_call_update"); + const seq = types(emitted); + assert.ok(seq.indexOf("tool_call") !== -1 && seq.indexOf("tool_call") < seq.indexOf("tool_result"), "tool_call precedes its result"); + }); + + it("scenario 5: an empty initial input is not refreshed when no real args ever arrive", () => { + // An `{}` announcement with no later args stays a single surfaced call — no phantom refresh, + // still a clean tool_call -> tool_result pair. + const emitted: AgentEvent[] = []; + const run = createSandboxAgentOtel({ harness: "pi", model: "openai-codex/x", emit: (e) => emitted.push(e), emitSpans: false }); + run.start({ prompt: "x" }); + run.handleUpdate({ sessionUpdate: "tool_call", toolCallId: "c1", title: "noArgs", rawInput: {} }); // empty placeholder + run.handleUpdate({ sessionUpdate: "tool_call_update", toolCallId: "c1", status: "completed", content: [{ content: { type: "text", text: "done" } }] }); + run.finish(); + + const calls = ofType(emitted, "tool_call"); + assert.equal(calls.length, 1, "single surfaced call, no refresh"); + assert.deepEqual(calls[0].input, {}, "keeps the placeholder input"); + assert.equal(ofType(emitted, "tool_result").length, 1, "tool_result present"); + const seq = types(emitted); + assert.ok(seq.indexOf("tool_call") < seq.indexOf("tool_result"), "tool_call precedes its result"); + }); + + it("scenario 6: a call announced WITH args refreshes only on genuinely new args", () => { + // If the initial notification already has real args, that's the input — a later update that + // merely repeats/omits args must NOT emit a duplicate tool_call. + const emitted: AgentEvent[] = []; + const run = createSandboxAgentOtel({ harness: "pi", model: "openai-codex/x", emit: (e) => emitted.push(e), emitSpans: false }); + run.start({ prompt: "x" }); + run.handleUpdate({ sessionUpdate: "tool_call", toolCallId: "c1", title: "getWeather", rawInput: { city: "Paris" } }); + run.handleUpdate({ sessionUpdate: "tool_call_update", toolCallId: "c1", status: "completed", content: [{ content: { type: "text", text: "sunny" } }] }); + run.finish(); + + const calls = ofType(emitted, "tool_call"); + assert.equal(calls.length, 1, "no refresh — args were present up front"); + assert.deepEqual(calls[0].input, { city: "Paris" }, "keeps the initial args"); + }); }); diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 9f74bd2d04..82a434f910 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -4,6 +4,7 @@ import {invalidateAgentCommittedRevisionCache} from "@agenta/entities/workflow" import { agentShouldResumeAfterApproval, buildAgentRequest, + buildTurnCapture, playgroundController, } from "@agenta/playground" import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" @@ -12,13 +13,14 @@ import {HeightCollapse} from "@agenta/ui" import {RichChatInput, type RichChatInputHandle} from "@agenta/ui/rich-chat-input" import {useChat} from "@ai-sdk/react" import {Bubble} from "@ant-design/x" -import {ArrowDown, Paperclip, UploadSimple} from "@phosphor-icons/react" +import {ArrowDown, Paperclip, TreeStructure, UploadSimple} from "@phosphor-icons/react" import {type UIMessage} from "ai" import {Button, Modal, Tabs, Tag, Tooltip} from "antd" import type {UploadFile} from "antd" import {useAtomValue, useSetAtom, useStore} from "jotai" import {SessionInspectorButton} from "@/oss/components/SessionInspector" +import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore" import {AgentChatTransport} from "./assets/AgentChatTransport" import { @@ -29,13 +31,16 @@ import { import {filesToParts} from "./assets/files" import {messageText, sideEffectingToolsInRange} from "./assets/rewind" import {getMessageTraceId} from "./assets/trace" +import AgentChatEmptyState from "./components/AgentChatEmptyState" import AgentMessage from "./components/AgentMessage" +import ApprovalDock, {getPendingApprovals} from "./components/ApprovalDock" import type {ClientToolOutputHandler} from "./components/clientTools" import ComposerAttachments from "./components/ComposerAttachments" import QueuedMessages from "./components/QueuedMessages" import SessionHistoryMenu from "./components/SessionHistoryMenu" import SessionRail from "./components/SessionRail" import SessionTagBar from "./components/SessionTagBar" +import TurnInspector from "./components/TurnInspector/TurnInspector" import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue" import {chatPanelMaximizedAtom} from "./state/panelLayout" import {useChatScopeKey} from "./state/scope" @@ -52,6 +57,8 @@ import { setSessionStatusAtom, stampMessagesCreatedAtAtom, } from "./state/sessions" +import {captureTurnRequestAtom} from "./state/turnCaptures" +import {turnInspectorAtom} from "./state/turnInspector" /** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` * alert; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to the @@ -61,9 +68,12 @@ const ignoreStreamRejection = () => {} /** Height of the top-edge fade, in px. Shared by the CSS mask and the SC-1 pin so a pinned turn * lands BELOW the fade (otherwise the freshly-asked question renders partially faded). */ const TOP_FADE_PX = 28 -/** Top-edge fade for the message scroll area: transparent at the very top, fully opaque by - * TOP_FADE_PX. Applied as a CSS mask so the content itself fades (correct in any theme). */ -const TOP_FADE_MASK = `linear-gradient(to bottom, transparent 0, #000 ${TOP_FADE_PX}px)` +/** Height of the bottom-edge fade, matching the top so content dissolves into the composer edge. */ +const BOTTOM_FADE_PX = 28 +/** Edge fades for the message scroll area: transparent at the very top, fully opaque by TOP_FADE_PX, + * then fading back to transparent over the last BOTTOM_FADE_PX. Applied as a CSS mask so the content + * itself fades (correct in any theme). */ +const EDGE_FADE_MASK = `linear-gradient(to bottom, transparent 0, #000 ${TOP_FADE_PX}px, #000 calc(100% - ${BOTTOM_FADE_PX}px), transparent 100%)` /** Centered reading column for the chat body. Caps line length / bubble width so a wide (maximized) * panel doesn't sprawl into oversized bubbles and over-spaced turns; freed side space is whitespace. */ const CHAT_COLUMN = "mx-auto w-full max-w-[880px]" @@ -195,6 +205,8 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) const switchEntity = useSetAtom(playgroundController.actions.switchEntity) const setSessionStatus = useSetAtom(setSessionStatusAtom) + const openTurnInspector = useSetAtom(turnInspectorAtom) + const buildMode = !useAtomValue(chatPanelMaximizedAtom) const [files, setFiles] = useState([]) // Files turned away by the guardrails (too big, wrong type, over the count), shown inline. @@ -238,6 +250,13 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const programmaticScrollRef = useRef(false) // Teardown for the in-flight smooth scroll (removes its listeners + fallback timer). const pinCleanupRef = useRef<(() => void) | null>(null) + // Last observed scrollTop. A content shrink (tool gutter collapsing, reasoning folding) clamps + // scrollTop to the new smaller bottom and fires a scroll event that isn't a user gesture; comparing + // against this lets onScroll tell a real scroll-DOWN-to-edge from that clamp (which only decreases). + const lastScrollTopRef = useRef(0) + // rAF handle coalescing the jump-pill measurement (querySelectorAll + getBoundingClientRect) to once + // per frame — a fast wheel/drag and every streamed render would otherwise re-measure a dirtied layout. + const showJumpRafRef = useRef(0) // `useChat` pins its `Chat` (and thus this transport) for the life of the session `id`; it is // NOT recreated when `entityId` changes (only on an `id` change). So the request builder must @@ -247,6 +266,11 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const entityIdRef = useRef(entityId) entityIdRef.current = entityId + // Turn Inspector capture write, read via ref so the transport `useMemo` doesn't depend on it. + const captureTurnRequest = useSetAtom(captureTurnRequestAtom) + const captureRef = useRef(captureTurnRequest) + captureRef.current = captureTurnRequest + // Transport feeds the v6 stream request from the playground pipeline. `api` here is a // placeholder that `prepareSendMessagesRequest` overrides per request. const transport = useMemo( @@ -262,6 +286,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: "This agent workflow has no invocation URL — it can’t be run yet.", ) } + captureRef.current(buildTurnCapture(req, generateId(), Date.now())) return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, }), @@ -347,6 +372,10 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: (item: QueuedMessage) => { stickRef.current = true setShowJump(false) + // Any actual send supersedes a prior user-stop, so clear the marker here (covers the + // queue-release path; the manual path also clears it in handleSubmit) — otherwise the + // "Stopped" tag would smear onto the freshly-sent turn. + setStopped(false) sendMessage( item.fileParts && item.fileParts.length ? item.text @@ -359,13 +388,26 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: ) // 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). + // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — + // it voids the pending gate, so `stopped` lets a fresh send go immediately (not queue). const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({ status, messages, + stopped, sendQueued, }) + // Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the + // composer (not inline in the transcript, so a paused run can't scroll out of reach). Trace + // opens the paused turn's own trace drawer. + const openTraceDrawer = useSetAtom(openTraceDrawerAtom) + const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) + const openPausedTurnTrace = useMemo(() => { + const last = messages[messages.length - 1] + const traceId = last ? getMessageTraceId(last) : undefined + return traceId ? () => openTraceDrawer({traceId}) : undefined + }, [messages, openTraceDrawer]) + // Publish this session's run state (single source of truth: drives the tab bar's status dot // AND the Session inspector's live-watcher signal, which derives "streaming" from `running`). // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed @@ -513,9 +555,26 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: // ── DT4 autoscroll: stick to the bottom of the scrollable area while following ── // The fill (min-h-full turn group) makes "question at top" the scroll bottom for a short answer // and the answer's end the bottom for a long one, so scrollHeight is the right target (+ pb-6 gap). + // Only writes when not already pinned: the ResizeObserver (below) and the follow effect both pin on + // the same streamed growth, so the guard drops the redundant write (and the scroll event it fires). const scrollToBottom = useCallback(() => { const el = scrollRef.current - if (el) el.scrollTop = el.scrollHeight + if (!el) return + const target = el.scrollHeight - el.clientHeight + if (el.scrollTop < target - 0.5) el.scrollTop = target + }, []) + + // Recompute jump-pill visibility, coalesced to one rAF per frame. The measurement (atLiveEdge → + // querySelectorAll + getBoundingClientRect) is display-only, so a one-frame lag is invisible; the + // correctness-critical follow decision (stickRef) and SC-3 anchor stay synchronous in onScroll. + const scheduleShowJump = useCallback(() => { + if (showJumpRafRef.current) return + showJumpRafRef.current = requestAnimationFrame(() => { + showJumpRafRef.current = 0 + const el = scrollRef.current + if (!el) return + setShowJump(!stickRef.current && !atLiveEdge(el)) + }) }, []) // Smoothly scroll the log to `target` (the SC-1 pin / jump-to-latest). Uses the browser's NATIVE @@ -567,7 +626,13 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: }, []) // Stop any in-flight pin animation on unmount (tab close / revision swap). - useEffect(() => () => pinCleanupRef.current?.(), []) + useEffect( + () => () => { + pinCleanupRef.current?.() + if (showJumpRafRef.current) cancelAnimationFrame(showJumpRafRef.current) + }, + [], + ) // After each commit, mark on-screen messages as seen so they don't re-animate on later renders // (e.g. streaming tokens). Done in an effect, not during render, so StrictMode's double invoke @@ -577,21 +642,33 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: }, [messages]) useEffect(() => { - if (stickRef.current) scrollToBottom() + // Don't instant-jump while a programmatic glide (SC-1 submit / jump-to-latest) owns the + // scroll — that snap would override the animation. The glide's own settle re-pins to bottom. + if (stickRef.current && !programmaticScrollRef.current) scrollToBottom() }, [messages, status, scrollToBottom]) const onScroll = useCallback(() => { const el = scrollRef.current if (!el) return + // Track scrollTop even for our own pins (recorded, then ignored) so the next real event has an + // accurate baseline to compare against. + const prevTop = lastScrollTopRef.current + lastScrollTopRef.current = el.scrollTop // Ignore the scroll event our own pin produced — only a real user scroll changes follow state. if (programmaticScrollRef.current) return // Follow ONLY when at the very bottom of the scrollable area; a partial scroll must not enable - // it (that was the yank). The jump pill instead tracks whether the real latest message is in view. - stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24 - setShowJump(!atLiveEdge(el)) - // Remember where the reader is parked so SC-3 can hold it through layout changes above. + // it (that was the yank). Re-arm follow ONLY when the user actively scrolls DOWN to the edge (or + // is already following): a content shrink (tool gutter collapsing to "Used N tools", reasoning + // folding) clamps scrollTop to the new smaller bottom and fires a scroll event, but a clamp only + // ever DECREASES scrollTop, so `> prevTop` rejects it — otherwise the next token would snap the + // min-h-full active turn to the top (reported as the chat "jumping to the top" mid-stream). + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickRef.current = atBottom && (stickRef.current || el.scrollTop > prevTop) + // Anchor is correctness-critical for SC-3 (the RO reads it next resize) → capture synchronously. if (!stickRef.current) recordAnchor() - }, [recordAnchor]) + // Pill is display-only → coalesce its costly measurement to one rAF/frame. + scheduleShowJump() + }, [recordAnchor, scheduleShowJump]) const jumpToLatest = useCallback(() => { const el = scrollRef.current @@ -618,7 +695,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const onResize = () => { if (programmaticScrollRef.current) return if (stickRef.current) { - el.scrollTop = el.scrollHeight + scrollToBottom() // guarded: no-op if the follow effect already pinned this growth return } const a = anchorRef.current @@ -631,6 +708,13 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: } if (!node) return const delta = node.getBoundingClientRect().top - el.getBoundingClientRect().top - a.top + // A stale anchor (its node scrolled far off after a programmatic jump / follow) yields an + // implausible delta; applying it would slam the scroll to the top. Drop it and let the next + // scroll / pointer-down re-anchor. A real collapse/expand moves the anchor well under a viewport. + if (Math.abs(delta) > el.clientHeight) { + anchorRef.current = null + return + } if (Math.abs(delta) > 0.5) { programmaticScrollRef.current = true el.scrollTop += delta @@ -642,7 +726,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const ro = new ResizeObserver(onResize) el.querySelectorAll("[data-mid]").forEach((w) => ro.observe(w)) return () => ro.disconnect() - }, [messages.length]) + }, [messages.length, scrollToBottom]) // SC-1 (submit) / SC-2 (restore): scroll the log to the bottom, once, when armed. With the active // turn reserving a viewport (min-h-full + top padding to clear the fade), "bottom" shows the new @@ -671,11 +755,11 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: // Keep the jump pill honest as content streams/settles: show it when the real latest message is // below the fold (e.g. a long answer growing past the viewport while parked at the top), and hide - // it once that message is visible or while we're following. - useLayoutEffect(() => { - const el = scrollRef.current - if (el) setShowJump(!stickRef.current && !atLiveEdge(el)) - }, [messages, status]) + // it once that message is visible or while we're following. Coalesced (not a sync layout read per + // streamed render) — the pill is display-only, so one frame of lag is imperceptible. + useEffect(() => { + scheduleShowJump() + }, [messages, status, scheduleShowJump]) // SC-4: interaction is intent, not just scrolling. While following, a real text selection inside // the transcript — or opening a link in it — means the reader is engaging here, so release follow @@ -849,7 +933,6 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: isStreaming={busy && isLast} isLastMessage={isLast} onRewind={() => handleRewind(message)} - onApprovalResponse={addToolApprovalResponse} onClientToolOutput={handleClientToolOutput} precededByEmptyAssistant={ index > 0 && isEmptyAssistantTurn(messages[index - 1]) @@ -879,13 +962,25 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId:
)} + {buildMode && message.role === "assistant" && ( + + )}
) } return (
{/* Themed confirm dialogs (rewind-past-a-tool) mount through this holder. */} {modalContextHolder} - {isDragging && ( -
- - Drop files here - - {limits.label} · up to {limits.maxCount},{" "} - {Math.round(limits.maxBytes / 1024 / 1024)} MB each - -
- )} - {/* Stream errors are surfaced inline on the failing turn (red error bubble with the + {/* Chat column. The turn inspector is a flex sibling (below) so it pushes this column + aside rather than overlaying it. */} +
+ {isDragging && ( +
+ + + Drop files here + + + {limits.label} · up to {limits.maxCount},{" "} + {Math.round(limits.maxBytes / 1024 / 1024)} MB each + +
+ )} + {/* Stream errors are surfaced inline on the failing turn (red error bubble with the real reason), stamped in the effect above — no separate top-level banner. */} -
-
{ - scrollRef.current = el - }} - onScroll={onScroll} - role="log" - aria-live="polite" - aria-label="Agent conversation" - // `pt-8` (32px) ≥ the 28px fade so the first message clears it at rest; `pb-6` - // + `[overflow-anchor:none]` are the SC scroll-engineering essentials (browser - // anchoring off so our pin/anchor logic owns the scroll position). - className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden p-3 pt-8 pb-6 [overflow-anchor:none]" - // Fade content into the top edge (under the tab bar) as it scrolls up. A - // gradient mask on the scroll container: transparent at the very top → opaque - // by 28px → opaque the rest of the way. GPU-composited, no JS, theme-agnostic. - style={{ - maskImage: TOP_FADE_MASK, - WebkitMaskImage: TOP_FADE_MASK, - }} - > - {messages.length === 0 && ( -
- Ask a question to start the agent conversation. -
- )} - {messages.slice(0, activeStart).map((m, i) => renderMessage(m, i))} - {activeStart < messages.length && ( - // The active turn reserves a viewport (min-h-full) when there's prior - // conversation, so sticking to the bottom shows the question at the top with the - // answer streaming into the space below — the "pin" is this layout, not JS. - // `pt-8` keeps the question clear of the top fade once it reaches the top. -
- {messages - .slice(activeStart) - .map((m, i) => renderMessage(m, activeStart + i))} -
- )} -
+
+
{ + scrollRef.current = el + }} + onScroll={onScroll} + // Capture a fresh SC-3 anchor before a click acts (expand/collapse a tool step, + // reasoning fold): those resize the transcript without a scroll, so onScroll never + // refreshes the anchor and the ResizeObserver would compensate against a stale one. + onPointerDownCapture={recordAnchor} + role="log" + aria-live="polite" + aria-label="Agent conversation" + // `pt-8` (32px) ≥ the 28px fade so the first message clears it at rest; `pb-6` + // + `[overflow-anchor:none]` are the SC scroll-engineering essentials (browser + // anchoring off so our pin/anchor logic owns the scroll position). + className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overflow-x-hidden p-3 pt-8 pb-6 [overflow-anchor:none]" + // Fade content into the top edge (under the tab bar) and the bottom edge (into the + // composer) as it scrolls. A gradient mask on the scroll container: transparent at + // each edge → opaque across the middle. GPU-composited, no JS, theme-agnostic. + style={{ + maskImage: EDGE_FADE_MASK, + WebkitMaskImage: EDGE_FADE_MASK, + }} + > + {messages.length === 0 && ( + + )} + {messages.slice(0, activeStart).map((m, i) => renderMessage(m, i))} + {activeStart < messages.length && ( + // The active turn reserves a viewport (min-h-full) when there's prior + // conversation, so sticking to the bottom shows the question at the top with the + // answer streaming into the space below — the "pin" is this layout, not JS. + // `pt-8` keeps the question clear of the top fade once it reaches the top. +
+ {messages + .slice(activeStart) + .map((m, i) => renderMessage(m, activeStart + i))} +
+ )} +
- {showJump && ( + {/* Always mounted so it can fade + slide in/out; hidden state is non-interactive and + keeps `-translate-x-1/2` (Tailwind composes x/y translate on one transform). */} - )} -
+
- {/* Queue / approval status sits BETWEEN the messages and the composer, so showing it - never shifts the composer (and the editor) upward. Streaming itself is signalled by - the composer's send button (it becomes a spinning Stop button), so there's no - separate "Streaming…" row. */} - {(hitlPending || queued.length > 0) && ( -
- {queued.length > 0 ? ( + {/* Queue sits BETWEEN the messages and the composer, so showing it never shifts the + composer (and the editor) upward. Streaming itself is signalled by the composer's + send button (it becomes a spinning Stop button), so there's no "Streaming…" row. */} + {queued.length > 0 && ( +
- ) : ( - - )} - {hitlPending ? ( - Waiting for approval - ) : null} -
- )} +
+ )} - {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. + {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. Wrapper `px-3` keeps the session-bar gutter; the input centers on CHAT_COLUMN so it - aligns with the (also centered) message column when the panel is wide. */} -
- addFiles(Array.from(pasted))} - sendForceEnabled={files.length > 0} - streaming={busy} - onStop={handleStop} - prefix={ - // Attach button is gated until the agent service is ready for inline - // file parts (big-agents d4b119af26); paste / drag-to-add still work. - -
+
) } diff --git a/web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts b/web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts index 102924ff59..09d173b59d 100644 --- a/web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts +++ b/web/oss/src/components/AgentChatSlice/assets/AgentChatTransport.ts @@ -1,4 +1,5 @@ import {createNegotiatingFetch, type NegotiatingFetch} from "@agenta/playground" +import {generateId} from "@agenta/shared/utils" import {DefaultChatTransport, type UIMessage, type UIMessageChunk} from "ai" /** @@ -133,7 +134,12 @@ function batchJsonToUiMessageStream( (json as Record)?.trace_id ?? ((json as Record)?.data as Record)?.trace_id - const start: Record = {type: "start", messageId: msg.id ?? "msg-1"} + // Unique fallback id per replay — a constant made every id-less batch turn + // collide on the same React key (duplicate-key warning + dropped turns). + const start: Record = { + type: "start", + messageId: msg.id ?? `msg-batch-${generateId()}`, + } if (sessionId) start.messageMetadata = {sessionId} emit(start) emit({type: "start-step"}) diff --git a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx index 961bb0c486..201b0afc25 100644 --- a/web/oss/src/components/AgentChatSlice/assets/markdown.tsx +++ b/web/oss/src/components/AgentChatSlice/assets/markdown.tsx @@ -31,9 +31,14 @@ export const MD_CLASS = "[&_h4]:mt-2 [&_h4]:mb-0.5 [&_h4]:text-xs [&_h4]:font-semibold " + "[&_h5]:mt-2 [&_h5]:mb-0.5 [&_h5]:text-xs [&_h5]:font-semibold " + "[&_h6]:mt-2 [&_h6]:mb-0.5 [&_h6]:text-xs [&_h6]:font-medium [&_h6]:text-colorTextSecondary " + - // Blockquote — a quiet left-ruled aside (kill the browser's 40px indent). - "[&_blockquote]:my-2 [&_blockquote]:mx-0 [&_blockquote]:border-0 [&_blockquote]:border-l-2 " + - "[&_blockquote]:border-solid [&_blockquote]:border-colorBorderSecondary [&_blockquote]:pl-3 " + + // Blockquote — a quiet left-ruled aside. Layout is forced (!important) so nothing (the UA's + // logical `margin-inline: 40px`, the Bubble's placement styles, etc.) can push the content into + // a centered/over-indented look: no horizontal margin, a small left padding, left-aligned. + // Zero the non-left borders with per-side longhands (NOT `border-0`, whose `border-width` + // shorthand wins over `border-l-2` as an arbitrary variant and drops the left rule). + "[&_blockquote]:my-2 [&_blockquote]:!mx-0 [&_blockquote]:!pl-3 [&_blockquote]:!text-left " + + "[&_blockquote]:border-y-0 [&_blockquote]:border-r-0 [&_blockquote]:border-l-2 " + + "[&_blockquote]:border-solid [&_blockquote]:border-colorTextTertiary " + "[&_blockquote]:text-colorTextSecondary [&_blockquote]:italic " + // Rule, images, emphasis, strikethrough, and task-list checkboxes. "[&_hr]:my-3 [&_hr]:border-0 [&_hr]:border-t [&_hr]:border-solid [&_hr]:border-colorBorderSecondary " + diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx index ffa87b6d38..01c7e82df9 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx @@ -16,6 +16,7 @@ import {createAgentChatTransport} from "../assets/transport" import {persistSessionMessagesAtom, sessionMessagesAtom} from "../state/sessions" import AgentMessage from "./AgentMessage" +import ApprovalDock, {getPendingApprovals} from "./ApprovalDock" import type {ClientToolOutputHandler} from "./clientTools" const {Text} = Typography @@ -103,6 +104,10 @@ const AgentChatConversation = ({ const busy = status === "submitted" || status === "streaming" + // HITL gates the paused turn is blocked on — surfaced in the persistent ApprovalDock above + // the composer (the inline tool row is just an "Awaiting approval" marker). + const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) + // Settle a parked client tool (#4920) — same wrapper as AgentChatPanel. `addToolOutput` matches // the part by `toolCallId` on the last turn; `tool` is only the typed-tools key, so a cast onto // the untyped UIMessage tool map is safe. @@ -266,7 +271,6 @@ const AgentChatConversation = ({ isStreaming={busy && index === messages.length - 1} isLastMessage={index === messages.length - 1} onRewind={handleRewind} - onApprovalResponse={addToolApprovalResponse} onClientToolOutput={handleClientToolOutput} /> ))} @@ -275,71 +279,82 @@ const AgentChatConversation = ({ )} - { - setFiles((prev) => [ - ...prev, - ...Array.from(pasted).map((file) => ({ - uid: `${file.name}-${file.lastModified}-${file.size}`, - name: file.name, - status: "done" as const, - originFileObj: file as UploadFile["originFileObj"], - })), - ]) - setAttachmentsOpen(true) - }} - prefix={ - - + ))} + + + + ) +} + +export default AgentChatEmptyState diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 6ddda91ab7..866e508268 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -29,6 +29,7 @@ import { getMessageUsage, type MessageUsageMetrics, } from "../assets/trace" +import {chatPanelMaximizedAtom} from "../state/panelLayout" import {messageCreatedAtAtomFamily, nowTickAtom, timeAgo} from "../state/sessions" import {ClientToolPart, isClientToolPart, type ClientToolOutputHandler} from "./clientTools" @@ -79,7 +80,6 @@ interface AgentMessageProps { /** Stable across renders (parent passes a `useCallback`'d handler) so the `memo()` below * isn't defeated — the message to rewind to is passed in, not closed over per render. */ onRewind: (message: UIMessage) => void - onApprovalResponse: (args: {id: string; approved: boolean}) => void /** Settle a parked client tool (#4920) — the dispatcher calls this from a widget. */ onClientToolOutput: ClientToolOutputHandler /** The previous turn was also an empty (content-less) assistant turn. Used to collapse a @@ -97,13 +97,22 @@ const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynam * "streaming"`) it auto-expands so the thoughts stream live; once done it auto-collapses to a * "Thought" toggle — click to re-expand. A manual toggle sticks (we stop auto-driving it). */ -const ReasoningPart = ({text, streaming}: {text: string; streaming: boolean}) => { - const [expanded, setExpanded] = useState(streaming) +const ReasoningPart = ({ + text, + streaming, + detailed = false, +}: { + text: string + streaming: boolean + detailed?: boolean +}) => { + // Build mode (`detailed`) keeps reasoning expanded as a step in the log; Chat auto-collapses it. + const [expanded, setExpanded] = useState(streaming || detailed) const userToggled = useRef(false) useEffect(() => { - if (!userToggled.current) setExpanded(streaming) - }, [streaming]) + if (!userToggled.current) setExpanded(streaming || detailed) + }, [streaming, detailed]) return (
@@ -199,13 +208,15 @@ const AgentMessage = ({ isStreaming = false, isLastMessage = false, onRewind, - onApprovalResponse, onClientToolOutput, precededByEmptyAssistant = false, turnTraceId, }: AgentMessageProps) => { const openTraceDrawer = useSetAtom(openTraceDrawerAtom) const isUser = message.role === "user" + // Build vs Chat: Build (config panel open, not maximized) shows the full step log — per-tool + // input/output/error + expanded reasoning; Chat keeps the calm collapsed summary. + const detailed = !useAtomValue(chatPanelMaximizedAtom) const [copied, setCopied] = useState(false) const copyResetTimeoutRef = useRef | null>(null) @@ -226,13 +237,9 @@ const AgentMessage = ({ const timeSummary = traceId ? ownSummary : pairedSummary const messageTime = parseTraceTime(timeSummary.rootSpan?.start_time) ?? createdAt // A failure can reach us two ways: recorded on the trace (backend), or stamped onto the turn - // FE-side from the useChat stream error (AgentChatPanel). Prefer whichever is present. + // FE-side from the useChat stream error (AgentChatPanel). `errorText` is derived below, once + // we know whether the turn produced an answer. const runError = getMessageRunError(message) - const errorText = traceError || runError - // Surface a settled-turn error even when the model emitted partial output before the - // stream died — not only when the turn is answer-less. (`isError` stays answer-less-only - // so the *whole* bubble only turns red when there's nothing else to show.) - const showError = !isStreaming && !!errorText const fullText = message.parts .filter((p) => p.type === "text") .map((p) => (p as {text: string}).text) @@ -272,6 +279,17 @@ const AgentMessage = ({ // read as frozen/broken. Keyed on `isStreaming`, not the conversation-level `busy`, so // earlier answer-less turns don't all light up while a later turn streams. const noResponse = !isUser && !isStreaming && !hasAnswer + + // A trace-leaf error means a model/tool call failed. When the turn still produced an answer, + // the agent recovered from it — that failure belongs inline in ToolActivity ("· N failed"), + // NOT as a run failure. So trust `traceError` only on an answer-less turn (the swallowed + // quota/model error it was written for). A stream death (`runError`) is a real run failure + // even with partial output, so it always counts. + const errorText = noResponse ? traceError || runError : runError + // Surface a settled-turn error even when the model emitted partial output before the stream + // died. (`isError` stays answer-less-only so the *whole* bubble only turns red when there's + // nothing else to show.) + const showError = !isStreaming && !!errorText // A settled no-answer turn whose trace recorded an error → render the bubble itself as a // failure (red), with the message inline — not a nested alert box. const isError = noResponse && showError @@ -300,9 +318,40 @@ const AgentMessage = ({ | {kind: "part"; part: UIMessage["parts"][number]; index: number} | {kind: "tools"; parts: ToolUIPart[]; index: number} | {kind: "clientTool"; part: ToolUIPart; index: number} + // A HITL-approved tool's part LINGERS in `approval-responded` (a perpetual spinner, no output): + // the cold-replay runner re-issues the approved call under a FRESH id, so its execution output + // lands on a SEPARATE sibling part. Drop the answered gate once its executed sibling exists (same + // tool + same input), so the turn shows the single completed call with its output — not a stuck + // spinner beside a duplicate. Until the execution settles, the gate stays (it is genuinely + // in-flight). + const toolIdentity = (p: ToolUIPart): string => { + let inputKey = "" + try { + inputKey = JSON.stringify((p as {input?: unknown}).input ?? null) + } catch { + inputKey = "" + } + return `${p.type}::${inputKey}` + } + const executedToolIdentities = new Set( + message.parts + .filter( + (p) => + isToolPart(p.type) && + ((p as ToolUIPart).state === "output-available" || + (p as ToolUIPart).state === "output-error"), + ) + .map((p) => toolIdentity(p as ToolUIPart)), + ) + const isSupersededGate = (p: ToolUIPart): boolean => + p.state === "approval-responded" && executedToolIdentities.has(toolIdentity(p)) + const renderItems: RenderItem[] = [] message.parts.forEach((part, i) => { if (isToolPart(part.type)) { + // The answered gate whose execution already landed on a sibling part — drop it so the + // turn doesn't show a stuck approval spinner beside the real, completed call. + if (isSupersededGate(part as ToolUIPart)) return // A browser-fulfilled client tool (#4920) renders as its own widget/chip, NOT folded // into the "Used N tools" group — so it breaks any current tool run. if (isClientToolPart(part as ToolUIPart, {isStreaming, isLastMessage})) { @@ -338,6 +387,7 @@ const AgentMessage = ({ key={partKey} text={reasoning.text} streaming={reasoning.state === "streaming"} + detailed={detailed} /> ) } @@ -381,7 +431,7 @@ const AgentMessage = ({ key={`${message.id}-tools-${item.index}`} parts={item.parts} isStreaming={isStreaming} - onApprovalResponse={onApprovalResponse} + detailed={detailed} onViewTrace={onViewTrace} /> ) @@ -444,7 +494,7 @@ const AgentMessage = ({ ) // Control toolbar — an X `Actions` row that sits in a reserved lane BELOW the bubble (the - // `pb-7` on the row), so it never overlays the last content line and never reaches the next + // `pb-10` on the row), so it never overlays the last content line and never reaches the next // turn. The lane is always present (stable height), so revealing it only fades opacity — no // layout shift either way (the scroll engineering is sensitive to hover-driven reflow). // `pointer-events-none` while hidden keeps the invisible buttons unclickable. `Actions` @@ -513,7 +563,7 @@ const AgentMessage = ({ // the left, user bubbles the right, neither spans the full column. return (
placement={isUser ? "end" : "start"} diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx new file mode 100644 index 0000000000..e2350b95f6 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -0,0 +1,245 @@ +import {memo, useEffect, useMemo, useRef, useState} from "react" + +import {HeightCollapse} from "@agenta/ui" +import {ArrowSquareOut, CaretRight, ShieldCheck} from "@phosphor-icons/react" +import type {ToolUIPart, UIMessage} from "ai" +import {Button, Typography} from "antd" + +const {Text} = Typography + +export interface PendingApproval { + approvalId: string + toolName: string + input: unknown +} + +interface ApprovalRef { + id: string +} + +const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" + +/** Friendly name for a tool part — mirrors ToolActivity: `dynamic-tool` carries `toolName`, typed + * parts encode it as `tool-`. */ +const partToolName = (part: ToolUIPart): string => { + const type = part.type as string + if (type === "dynamic-tool") return (part as {toolName?: string}).toolName || "tool" + return type.replace(/^tool-/, "") +} + +/** + * Approvals the run is currently blocked on. HITL only ever pauses the LAST assistant turn (see + * `isHitlPending`), so we read pending tool gates off that turn — a turn can request several at + * once (parallel tool calls), so this returns all of them in order. + */ +export const getPendingApprovals = (messages: UIMessage[]): PendingApproval[] => { + const last = messages[messages.length - 1] + if (!last || last.role !== "assistant") return [] + const out: PendingApproval[] = [] + for (const part of last.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) { + out.push({approvalId: approval.id, toolName: partToolName(p), input: p.input}) + } + } + return out +} + +/** A source label we can state factually from the tool name — not a guessed risk level. */ +const sourceLabel = (name: string): string | null => { + if (name.startsWith("mcp__")) return "MCP tool" + return null +} + +const formatInput = (input: unknown): string => { + if (input == null) return "" + if (typeof input === "string") return input.trim() + try { + return JSON.stringify(input, null, 2) + } catch { + return String(input) + } +} + +interface ApprovalDockProps { + /** Pending gates for the paused turn (index 0 is acted on first). */ + approvals: PendingApproval[] + onApprovalResponse: (args: {id: string; approved: boolean}) => void + /** Open the paused turn's trace drawer (full tool input/output). */ + onViewTrace?: () => void + className?: string +} + +/** + * Persistent human-in-the-loop approval band. Lives in the composer region (between the transcript + * and the input), NOT in the scrolling transcript, so a run paused on a tool gate can't scroll out + * of reach. It owns the Approve/Deny action (the inline tool row is just an "Awaiting approval" + * marker) and surfaces the request's context: which tool, its source, and the exact payload. + * + * A turn can request several gates at once; we act on the first and let the SDK flip its state, + * which re-renders us onto the next — so `responding` resets whenever the current id changes. + */ +const ApprovalDock = ({ + approvals, + onApprovalResponse, + onViewTrace, + className, +}: ApprovalDockProps) => { + const open = approvals.length > 0 + // Latch the last non-empty set so the card stays visible while the dock animates closed — a + // leave transition needs its content to persist through the height collapse. + const shownRef = useRef(approvals) + if (open) shownRef.current = approvals + const shown = shownRef.current + const current = shown[0] + const count = shown.length + + const [responding, setResponding] = useState(false) + const [showPayload, setShowPayload] = useState(false) + + // The current gate changed (we answered one, the next slid in) — re-enable + recollapse. + useEffect(() => { + setResponding(false) + setShowPayload(false) + }, [current?.approvalId]) + + const payload = useMemo(() => (current ? formatInput(current.input) : ""), [current]) + const payloadPreview = payload.replace(/\s+/g, " ").trim() + + const source = current ? sourceLabel(current.toolName) : null + + const respond = (approved: boolean) => { + if (responding || !current) return + setResponding(true) + onApprovalResponse({id: current.approvalId, approved}) + } + const approveAll = () => { + if (responding) return + setResponding(true) + shown.forEach((a) => onApprovalResponse({id: a.approvalId, approved: true})) + } + + // Always mounted; enter + leave animate via the grid-rows 0fr↔1fr height collapse (+ opacity), + // the same idiom as the reasoning block and composer attachments. `inert` while closed drops the + // (clipped, latched) card from tab order + a11y so a keyboard user can't reach hidden buttons. + return ( +
+
+ {current ? ( +
+ {/* Header: a quiet primary cue (not an error tint) + the ask + a count when batched. */} +
+ + + Approval needed to continue + + {count > 1 ? ( + + 1 of {count} + + ) : null} +
+ + {/* Identity: which tool, plus a factual source tag when we can name one. */} +
+ + {current.toolName} + + {source ? ( + + {source} + + ) : null} +
+ + + The agent wants to run this tool before it can keep going. + + + {/* The payload — collapsed to a one-line preview, expandable to the full request. */} + {payload ? ( +
+ + +
+                                        {payload}
+                                    
+
+
+ ) : null} + + {/* Actions: trace on the left, decision on the right. Approve is the single primary. */} +
+ {onViewTrace ? ( + + ) : null} +
+ {count > 1 ? ( + + ) : null} + + +
+
+
+ ) : null} +
+
+ ) +} + +export default memo(ApprovalDock) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index dd13ad76dc..ed7913242e 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useRef, useState} from "react" import {MagnifyingGlass, Plus, Trash} from "@phosphor-icons/react" -import {Button, Empty, Input, Tag, Tooltip} from "antd" +import {Button, Empty, Input, Tooltip} from "antd" import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" @@ -84,9 +84,7 @@ const SessionRailRow = ({ }} className={clsx( "group flex cursor-pointer items-center gap-2 rounded-md border border-solid px-2 py-1.5 transition-colors", - active - ? "border-colorBorder bg-colorFillSecondary" - : "border-transparent hover:bg-colorFillTertiary", + active ? "ag-surface-selected" : "ag-row-hover border-transparent", )} > @@ -107,9 +105,9 @@ const SessionRailRow = ({
{open && !active && ( - + open - + )} {active && } @@ -208,11 +206,11 @@ const SessionRail = ({activeId, className}: SessionRailProps) => { return (
-
+
Sessions
diff --git a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx index 134f89b165..935f688b79 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionTagBar.tsx @@ -149,20 +149,29 @@ const SessionTagBar = ({ onRename={(title) => onRename(session.id, title)} /> ))} - -
) : (
)} - {extra &&
{extra}
} + {/* Fixed session-actions cluster — pinned outside the scroll area so New session (+) sits + at the end of the tab strip without scrolling away, grouped with the inspect/history + controls. */} + {(showSessions || extra) && ( +
+ {showSessions && ( + +
+ )}
) } diff --git a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx index 4e11b844b9..857984e27a 100644 --- a/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ToolActivity.tsx @@ -11,7 +11,7 @@ import { Wrench, } from "@phosphor-icons/react" import type {ToolUIPart} from "ai" -import {Button, Typography} from "antd" +import {Typography} from "antd" const {Text} = Typography @@ -32,6 +32,12 @@ const partToolName = (part: ToolUIPart): string => { const SETTLED = new Set(["output-available", "output-error", "output-denied"]) const isSettled = (state: string) => SETTLED.has(state) +/** Strip a surrounding markdown code fence — backends wrap tool output/errors in ```…```. */ +const stripFence = (value: string): string => { + const m = value.trim().match(/^```[\w-]*\n?([\s\S]*?)\n?```$/) + return m ? m[1].trim() : value +} + /** * Derive a single human line from a tool's output. Output shape is arbitrary, so this stays * conservative: it recognises the common shapes and otherwise returns null (the row then shows @@ -43,7 +49,7 @@ const summarizeOutput = (output: unknown): string | null => { return `${output.length} result${output.length === 1 ? "" : "s"}` } if (typeof output === "string") { - const s = output.trim().replace(/\s+/g, " ") + const s = stripFence(output).trim().replace(/\s+/g, " ") if (!s) return null return s.length > 80 ? `${s.slice(0, 80)}…` : s } @@ -77,79 +83,147 @@ const StatusIcon = ({state}: {state: string}) => { return if (state === "approval-requested") return + // An answered gate whose execution landed on a sibling part (cold-replay fresh id). Usually + // deduped away in AgentMessage; if it slips through, show it as approved — never a stuck spinner. + if (state === "approval-responded") + return return } -interface ApprovalRef { - id: string - approved?: boolean - reason?: string -} - -const ApprovalButtons = ({ - approvalId, - onApprovalResponse, -}: { - approvalId: string - onApprovalResponse: (args: {id: string; approved: boolean}) => void -}) => { - // Guard a double-submit between the click and the SDK flipping the part to - // `approval-responded` (which removes the buttons). Not tied to conversation `busy` — - // an approval can only appear mid-stream, so gating on busy would disable it the whole turn. - const [responding, setResponding] = useState(false) - const respond = (approved: boolean) => { - if (responding) return - setResponding(true) - onApprovalResponse({id: approvalId, approved}) +/** Pretty-print a tool input/output value for the Build-mode step log: a string as-is, anything + * else as indented JSON. Never throws — the raw payload also lives in the trace drawer. */ +const formatValue = (value: unknown): string => { + if (typeof value === "string") return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) } - return ( -
- - -
- ) } -/** One tool's row: name, derived one-line summary, status. Used in both modes. */ +/** One labeled monospace block (input / output / error) in the Build-mode step log. Capped in + * height with its own scroll so a large payload can't blow up the transcript. */ +const IOBlock = ({label, value, danger}: {label: string; value: string; danger?: boolean}) => ( +
+ {label} +
+            {value}
+        
+
+) + +/** One tool's row: name + status, plus (in Build's `detailed` step log) the tool's input and its + * output/error as monospace blocks. Chat mode keeps the quiet one-line summary. The Approve/Deny + * action lives in the persistent ApprovalDock, so a gate here is just marked "Awaiting approval". */ const ToolRow = ({ part, live, - onApprovalResponse, + detailed = false, }: { part: ToolUIPart live: boolean - onApprovalResponse: (args: {id: string; approved: boolean}) => void + detailed?: boolean }) => { const name = partToolName(part) const state = part.state as string - const approval = (part as {approval?: ApprovalRef}).approval - const summary = rowSummary(part) - const running = !isSettled(state) && state !== "approval-requested" - // The line between the name and the trailing status/buttons: the prompt for an approval, - // a live "running…", or the settled one-line output summary. + // `approval-responded` is resolved (the user answered) — not "running". Its execution shows on + // a sibling part, so this must not spin forever (the cold-replay lingering-gate spinner). + const running = + !isSettled(state) && state !== "approval-requested" && state !== "approval-responded" + // The line after the name: an awaiting-approval marker, a live "running…", the settled one-line + // summary (Chat), or a short status word (Build shows the full output block below instead). const midText = - state === "approval-requested" ? "Run this tool?" : live && running ? "running…" : summary + state === "approval-requested" + ? "Awaiting approval" + : state === "approval-responded" + ? "approved" + : live && running + ? "running…" + : detailed + ? state === "output-error" + ? "failed" + : state === "output-denied" + ? "denied" + : null + : rowSummary(part) - return ( -
+ const input = (part as {input?: unknown}).input + const output = (part as {output?: unknown}).output + const errorText = (part as {errorText?: string}).errorText + const hasIO = + detailed && (input != null || state === "output-available" || errorText !== undefined) + // Each detailed step collapses on its own — Build's step log can get long. Default expanded. + const [open, setOpen] = useState(true) + + const header = ( + <> - {name} + + {name} + {midText ? ( {midText} ) : null} + + ) + + return ( +
+ {hasIO ? ( + + ) : ( +
{header}
+ )} - {state === "approval-requested" && approval?.id ? ( - + {hasIO ? ( + +
+ {input != null ? ( + + ) : null} + {errorText !== undefined ? ( + + ) : state === "output-available" && output != null ? ( + , so + // strip the redundant fence instead of printing the backticks. + typeof output === "string" + ? stripFence(output) + : formatValue(output) + } + /> + ) : null} +
+
) : null}
) @@ -160,25 +234,28 @@ interface ToolActivityProps { parts: ToolUIPart[] /** This turn is the one being generated right now. */ isStreaming?: boolean - onApprovalResponse: (args: {id: string; approved: boolean}) => void + /** Build mode: render the full step log (per-tool input + output/error inline), instead of the + * calm collapsed "Used N tools" summary Chat mode shows. */ + detailed?: boolean /** Open the turn's trace drawer (full input/output). Absent if the turn has no trace yet. */ onViewTrace?: () => void } /** - * Renders a group of tool calls inside an agent turn. Two modes: - * - **Live** (streaming + a tool still in flight): a left-gutter timeline, always shown, so - * you watch each tool fire. An `approval-requested` tool surfaces Approve/Deny inline. - * - **Settled**: a single quiet "Used N tools" line; click to expand the per-tool list with - * one-line output summaries and a "View full trace" link. + * Renders a group of tool calls inside an agent turn. Three modes: + * - **Build step log** (`detailed`): a left-gutter timeline of every tool with its input and + * output/error as monospace blocks — the power-user view, scoped to Build mode. + * - **Live** (streaming + a tool still in flight, Chat mode): the same gutter but one-line rows, + * so you watch each tool fire. + * - **Chat settled**: a single quiet "Used N tools" line; click to expand a one-line-summary list. * - * Output is summarised to one line per tool; the raw input/output lives in the trace drawer. - * The FE only renders tool calls — it never executes them. + * An `approval-requested` tool is marked "Awaiting approval" in every mode; the Approve/Deny action + * lives in the persistent ApprovalDock. The FE only renders tool calls — it never executes them. */ const ToolActivity = ({ parts, isStreaming = false, - onApprovalResponse, + detailed = false, onViewTrace, }: ToolActivityProps) => { const anyUnsettled = parts.some((p) => !isSettled(p.state as string)) @@ -186,21 +263,31 @@ const ToolActivity = ({ const approvalPending = parts.some((p) => (p.state as string) === "approval-requested") const [open, setOpen] = useState(false) - // An approval must stay reachable, so force the list open whenever one is pending. + // Keep the gate visible in-context: force the list open whenever one is awaiting approval. const expanded = open || approvalPending - // ---- Live: the gutter timeline (always visible while tools are in flight) ---- - if (live) { + // ---- Build step log (detailed) OR live streaming: the gutter timeline, always visible ---- + if (detailed || live) { return (
{parts.map((part, i) => ( ))} + {detailed && onViewTrace ? ( + + ) : null}
) } @@ -244,7 +331,6 @@ const ToolActivity = ({ key={`${part.toolCallId || part.type}-${i}`} part={part} live={false} - onApprovalResponse={onApprovalResponse} /> ))} {onViewTrace && ( diff --git a/web/oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx b/web/oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx new file mode 100644 index 0000000000..020b470b37 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/TurnInspector/ContextTab.tsx @@ -0,0 +1,98 @@ +import {memo} from "react" + +import {type TurnRequestCapture} from "@agenta/playground" +import {Typography} from "antd" + +const {Text} = Typography + +const format = (value: unknown): string => { + if (value == null) return "null" + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const agentInstructions = (parameters: unknown): string | null => { + const p = parameters as {agent?: {instructions?: {agents_md?: unknown}}} | null + const md = p?.agent?.instructions?.agents_md + return typeof md === "string" ? md : null +} + +const agentModel = (parameters: unknown): string | null => { + const p = parameters as {agent?: {llm?: {model?: unknown}; model?: unknown}} | null + const m = p?.agent?.llm?.model ?? p?.agent?.model + return typeof m === "string" ? m : null +} + +const Block = ({label, value}: {label: string; value: string}) => ( +
+ {label} +
+            {value}
+        
+
+) + +const Send = ({ + capture, + index, + total, +}: { + capture: TurnRequestCapture + index: number + total: number +}) => { + const model = agentModel(capture.parameters) + const instructions = agentInstructions(capture.parameters) + return ( +
+
+ + Request {index + 1} of {total} + + {model ? ( + + {model} + + ) : null} +
+ {instructions != null ? ( + + ) : null} + + +
+ ) +} + +/** The Context tab: every send for the selected turn, config-at-turn + exact messages. */ +const ContextTab = ({captures}: {captures: TurnRequestCapture[]}) => { + if (captures.length === 0) { + return ( +
+ No capture for this turn. Captures are recorded live in Build mode; a turn restored + from a previous session has none. +
+ ) + } + return ( +
+ {captures.length > 1 ? ( + + This turn made {captures.length} requests (initial + resumes). Compare them to + spot drift or a loop. + + ) : null} + {captures.map((c, i) => ( + + ))} +
+ ) +} + +export default memo(ContextTab) diff --git a/web/oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx b/web/oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx new file mode 100644 index 0000000000..b0df1d3007 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/TurnInspector/RawTab.tsx @@ -0,0 +1,60 @@ +import {memo} from "react" + +import {type TurnRequestCapture} from "@agenta/playground" +import {App, Button, Typography} from "antd" + +const {Text} = Typography + +/** One capture's literal outgoing request body, copyable for repro / bug reports. */ +const RawTab = ({captures}: {captures: TurnRequestCapture[]}) => { + const {message} = App.useApp() + if (captures.length === 0) { + return
No capture for this turn.
+ } + return ( +
+ {captures.map((c, i) => { + const body = { + session_id: c.sessionId, + references: c.references, + data: {inputs: {messages: c.messages}, parameters: c.parameters}, + } + const json = JSON.stringify(body, null, 2) + return ( +
+
+ + Request {i + 1} of {captures.length} + + +
+ + {c.invocationUrl} + +
+                            {json}
+                        
+
+ ) + })} +
+ ) +} + +export default memo(RawTab) diff --git a/web/oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx b/web/oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx new file mode 100644 index 0000000000..0ee2d12f17 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/TurnInspector/TimelineTab.tsx @@ -0,0 +1,176 @@ +import {memo, type ReactNode} from "react" + +import {CheckCircle, CircleNotch, Prohibit, Warning} from "@phosphor-icons/react" +import type {ToolUIPart, UIMessage} from "ai" +import {Typography} from "antd" + +const {Text} = Typography + +const isToolPart = (t: string) => t.startsWith("tool-") || t === "dynamic-tool" + +const toolName = (part: ToolUIPart): string => { + const type = part.type as string + if (type === "dynamic-tool") return (part as {toolName?: string}).toolName || "tool" + return type.replace(/^tool-/, "") +} + +const format = (value: unknown): string => { + if (value == null) return "" + if (typeof value === "string") return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +/** Strip a surrounding markdown code fence — backends sometimes wrap an error in ```…```. */ +const stripFence = (value: string): string => { + const m = value.trim().match(/^```[\w-]*\n?([\s\S]*?)\n?```$/) + return m ? m[1].trim() : value +} + +const userText = (message: UIMessage): string => + (message.parts ?? []) + .filter((p) => (p as {type?: string}).type === "text") + .map((p) => (p as {text?: string}).text ?? "") + .join("\n") + .trim() + +const Block = ({label, value, danger}: {label: string; value: string; danger?: boolean}) => ( +
+ {label} +
+            {value}
+        
+
+) + +const ToolStatus = ({state}: {state: string}) => { + if (state === "output-available") + return + if (state === "output-error") + return + if (state === "output-denied") + return + return +} + +/** A labeled, left-ruled timeline row (user message / reasoning / response). */ +const Row = ({label, accent, children}: {label: string; accent?: boolean; children: ReactNode}) => ( +
+ + {label} + + {children} +
+) + +/** A tool call as a distinct, inspectable step card: status + name, then input and output/error. */ +const ToolStep = ({part}: {part: ToolUIPart}) => { + const state = part.state as string + const input = (part as {input?: unknown}).input + const output = (part as {output?: unknown}).output + const errorText = (part as {errorText?: string}).errorText + return ( +
+
+ + + {toolName(part)} + + + {state} + +
+ {input != null ? : null} + {errorText !== undefined ? ( + + ) : output != null ? ( + + ) : null} +
+ ) +} + +const AssistantPart = ({part}: {part: UIMessage["parts"][number]}) => { + const type = part.type as string + if (type === "reasoning") { + const text = (part as {text?: string}).text ?? "" + if (!text.trim()) return null + return ( + +
+ {text} +
+
+ ) + } + if (type === "text") { + const text = (part as {text?: string}).text ?? "" + if (!text.trim()) return null + return ( + +
{text}
+
+ ) + } + if (isToolPart(type)) return + // Drop the AI SDK step boundary markers — they carry no content. + if (type === "step-start" || type === "step-end") return null + return ( + + + + ) +} + +/** + * The Timeline tab: the whole round — the user message that started the turn, then every step the + * agent took (reasoning, tool calls with I/O, responses), in order. Reads the live message parts. + */ +const TimelineTab = ({round}: {round: UIMessage[]}) => { + if (round.length === 0) { + return
No turn selected.
+ } + const nodes: ReactNode[] = [] + round.forEach((msg) => { + if (msg.role === "user") { + nodes.push( + +
+ {userText(msg) || "—"} +
+
, + ) + return + } + ;(msg.parts ?? []).forEach((part, i) => { + nodes.push() + }) + }) + return
{nodes}
+} + +export default memo(TimelineTab) diff --git a/web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx b/web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx new file mode 100644 index 0000000000..a308572b7b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/TurnInspector/TurnInspector.tsx @@ -0,0 +1,129 @@ +import {useMemo, useState} from "react" + +import {capturesForTrigger} from "@agenta/playground" +import {X} from "@phosphor-icons/react" +import type {UIMessage} from "ai" +import {Button} from "antd" +import {useAtom, useAtomValue} from "jotai" + +import {sessionCapturesAtomFamily} from "../../state/turnCaptures" +import {turnInspectorAtom} from "../../state/turnInspector" + +import ContextTab from "./ContextTab" +import RawTab from "./RawTab" +import TimelineTab from "./TimelineTab" + +type Tab = "timeline" | "context" | "raw" + +const TABS: {value: Tab; label: string}[] = [ + {value: "timeline", label: "Timeline"}, + {value: "context", label: "Context"}, + {value: "raw", label: "Raw"}, +] + +const PANEL_WIDTH = 480 + +/** + * Dedicated Build-mode turn inspector. Mounted per session inside `AgentConversation` so it reads + * the LIVE `useChat` `messages` (the same list the transcript renders) — accurate turn + live + * streaming — and opens ONLY for the session that is the current inspector target. Rendered as an + * inline side panel (a flex sibling of the chat column) so it pushes the transcript aside instead of + * overlaying it. Siderail pattern inside (vertical Timeline/Context/Raw rail + bordered content). + * Own state (`turnInspectorAtom`), NOT the trace drawer. + */ +const TurnInspector = ({sessionId, messages}: {sessionId: string; messages: UIMessage[]}) => { + const [target, setTarget] = useAtom(turnInspectorAtom) + const [tab, setTab] = useState("timeline") + + const open = target?.sessionId === sessionId + + // The whole round: the user message that started the turn, then the assistant turn. + const round = useMemo(() => { + if (!open || !target) return [] + const idx = messages.findIndex((m) => m.id === target.assistantMessageId) + if (idx < 0) return [] + const assistant = messages[idx] + let user: UIMessage | null = null + for (let i = idx - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + user = messages[i] + break + } + } + return user ? [user, assistant] : [assistant] + }, [open, target, messages]) + + const captures = useAtomValue(sessionCapturesAtomFamily(sessionId)) + const turnCaptures = useMemo(() => { + if (!open || !target) return [] + const idx = messages.findIndex((m) => m.id === target.assistantMessageId) + // The trigger is the last user message at or before this assistant turn. + let triggerId: string | null = null + for (let i = idx; i >= 0; i--) { + if (messages[i]?.role === "user") { + triggerId = messages[i].id + break + } + } + return capturesForTrigger(captures, triggerId) + }, [open, target, messages, captures]) + + // Stays mounted and animates its width (0↔PANEL_WIDTH) in lockstep with the build/chat mode + // switch, so it slides in/out instead of snapping. Clipped + `inert` while collapsed. + return ( +
+
+
+ + Turn inspector + +
+
+
+ {TABS.map((t) => { + const active = t.value === tab + return ( + + ) + })} +
+
+
+ {tab === "timeline" ? : null} + {tab === "context" ? : null} + {tab === "raw" ? : null} +
+
+
+
+
+ ) +} + +export default TurnInspector diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts index 4618dba11c..d848344732 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatQueue.ts @@ -13,6 +13,10 @@ export interface QueuedMessage { interface UseAgentChatQueueArgs { status: string messages: UIMessage[] + /** The last turn was user-stopped (cancelled). A stop voids any pending approval / imminent + * auto-resume, so the aborted turn's tool parts still reading as mid-HITL must NOT hold a new + * send — a stopped-and-settled conversation is releasable. */ + stopped: boolean /** 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 @@ -23,10 +27,25 @@ interface UseAgentChatQueueArgs { * stream truly settles. It never releases mid human-in-the-loop (a tool-approval gate) — that * decision lives in `canReleaseQueuedMessage`. Releasing one message flips the conversation back * to busy, so the next stays queued until that turn settles too. + * + * Exception: a user STOP. Stopping aborts the run, which cancels any pending approval or the tick + * before an auto-resume — but the aborted turn's tool parts keep their `approval-requested` / + * `approval-responded` / client-tool-result shape, so `canReleaseQueuedMessage` would keep holding. + * When `stopped`, a settled conversation is releasable so a fresh send goes immediately. */ -export const useAgentChatQueue = ({status, messages, sendQueued}: UseAgentChatQueueArgs) => { +export const useAgentChatQueue = ({ + status, + messages, + stopped, + sendQueued, +}: UseAgentChatQueueArgs) => { const [queued, setQueued] = useState([]) + // Settled = the stream is over (done or failed). A stop lands here (abort → "ready"). + const settled = status === "ready" || status === "error" + // Releasable now: the normal gate, OR a stopped-and-settled turn (the stop voided its gate). + const canReleaseNow = canReleaseQueuedMessage(status, messages) || (stopped && settled) + // One latch shared by both send paths caps releases to one per settle and preserves FIFO. const releasingRef = useRef(false) const queuedRef = useRef(queued) @@ -38,18 +57,14 @@ export const useAgentChatQueue = ({status, messages, sendQueued}: UseAgentChatQu const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]}) => { const message: QueuedMessage = {...item, id: generateId()} - if ( - !releasingRef.current && - queuedRef.current.length === 0 && - canReleaseQueuedMessage(status, messages) - ) { + if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { releasingRef.current = true sendQueued(message) } else { setQueued((q) => [...q, message]) } }, - [status, messages, sendQueued], + [canReleaseNow, sendQueued], ) const removeQueued = useCallback((id: string) => { @@ -58,19 +73,22 @@ export const useAgentChatQueue = ({status, messages, sendQueued}: UseAgentChatQu const clearQueue = useCallback(() => setQueued([]), []) - // Release the queue head once the stream settles; the latch caps it at one per settle. + // Release the queue head once the stream settles; the latch caps it at one per settle. Both + // "ready" and "error" are settled — releasing on "error" retries the failed turn with the + // queued message (which clears the error) instead of stranding the queue. "submitted"/ + // "streaming" are in-flight: reset the latch and hold. useEffect(() => { - if (status !== "ready") { + if (!settled) { releasingRef.current = false return } if (releasingRef.current || queued.length === 0) return - if (!canReleaseQueuedMessage(status, messages)) return + if (!canReleaseNow) return releasingRef.current = true const [head, ...rest] = queued setQueued(rest) sendQueued(head) - }, [status, messages, queued, sendQueued]) + }, [settled, canReleaseNow, queued, sendQueued]) return { queued, diff --git a/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts b/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts new file mode 100644 index 0000000000..98321aaa08 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts @@ -0,0 +1,24 @@ +import {appendCapped, type TurnRequestCapture} from "@agenta/playground" +import {atom} from "jotai" +import {atomFamily} from "jotai/utils" + +/** Keep the last N turns' captures per session (ephemeral; debugging surface, not persisted). */ +const MAX_TURNS = 20 + +const capturesBySessionAtom = atom>({}) + +/** Write one send's capture (called from the transport at send time). */ +export const captureTurnRequestAtom = atom(null, (get, set, capture: TurnRequestCapture) => { + if (!capture.sessionId) return + const all = get(capturesBySessionAtom) + const list = all[capture.sessionId] ?? [] + set(capturesBySessionAtom, { + ...all, + [capture.sessionId]: appendCapped(list, capture, MAX_TURNS), + }) +}) + +/** Read all captures for a session. */ +export const sessionCapturesAtomFamily = atomFamily((sessionId: string) => + atom((get) => get(capturesBySessionAtom)[sessionId] ?? []), +) diff --git a/web/oss/src/components/AgentChatSlice/state/turnInspector.ts b/web/oss/src/components/AgentChatSlice/state/turnInspector.ts new file mode 100644 index 0000000000..9030d8f23e --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/turnInspector.ts @@ -0,0 +1,10 @@ +import {atom} from "jotai" + +/** Which assistant turn the Turn Inspector is open on. `null` = closed. */ +export interface TurnInspectorTarget { + sessionId: string + /** The assistant turn's message id (its parts drive the Timeline tab). */ + assistantMessageId: string +} + +export const turnInspectorAtom = atom(null) diff --git a/web/oss/src/components/Layout/assets/Breadcrumbs.tsx b/web/oss/src/components/Layout/assets/Breadcrumbs.tsx index 978fa5deb7..20b5495b5c 100644 --- a/web/oss/src/components/Layout/assets/Breadcrumbs.tsx +++ b/web/oss/src/components/Layout/assets/Breadcrumbs.tsx @@ -81,7 +81,7 @@ const BreadcrumbContainer = memo(({appTheme}: {appTheme: string}) => {
diff --git a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx new file mode 100644 index 0000000000..ef13a9c731 --- /dev/null +++ b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx @@ -0,0 +1,88 @@ +import {useCallback, useMemo} from "react" + +import {isLocalDraftId} from "@agenta/entities/shared" +import {workflowMolecule} from "@agenta/entities/workflow" +import {createWorkflowRevisionAdapter} from "@agenta/entity-ui/selection" +import {playgroundController} from "@agenta/playground" +import {Tooltip} from "antd" +import {useAtomValue, useSetAtom} from "jotai" +import dynamic from "next/dynamic" + +import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" + +const SelectVariant = dynamic(() => import("../Menus/SelectVariant"), {ssr: false}) + +/** + * The agent playground's revision selector — the borderless "variant ⌄" picker plus a compact + * `v{n} ● Draft/Saved` status. Lifted out of the config-panel header (PlaygroundVariantConfigHeader) + * so the page header can host it next to the agent's name. Variant-scoped: it derives everything + * from `variantId`, so it stays in sync wherever it's rendered. + */ +const AgentRevisionSelector = ({variantId}: {variantId: string}) => { + // Project-scoped playground (no app in URL) browses all workflows; app-scoped stays scoped. + const appId = useAtomValue(routerAppIdAtom) + const isProjectScoped = !appId + + const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId || "")) + const isDirty = useAtomValue(workflowMolecule.selectors.isDirty(variantId || "")) + const isLocalDraftVariant = variantId ? isLocalDraftId(variantId) : false + + const _variantId = runnableData?.id ?? null + const variantRevision = (runnableData?.version as number | null) ?? null + const hasChanges = isDirty + + // App browse picker (project-scoped only) — skip-variant, non-evaluator. + const appOnlyAdapter = useMemo( + () => + createWorkflowRevisionAdapter({ + skipVariantLevel: true, + excludeRevisionZero: true, + flags: {is_evaluator: false, is_feedback: false}, + parentLabel: "Application", + }), + [], + ) + + const switchEntity = useSetAtom(playgroundController.actions.switchEntity) + const handleSwitchVariant = useCallback( + (newVariantId: string) => { + switchEntity({currentEntityId: variantId || "", newEntityId: newVariantId}) + }, + [switchEntity, variantId], + ) + + if (!variantId || isLocalDraftVariant) return null + + return ( +
+ handleSwitchVariant(value)} + value={_variantId ?? undefined} + borderlessTrigger + /> + {variantRevision !== null && variantRevision !== undefined && ( + + v{variantRevision} + + )} + + + + {hasChanges ? "Draft" : "Saved"} + + +
+ ) +} + +export default AgentRevisionSelector diff --git a/web/oss/src/components/Playground/Components/MainLayout/index.tsx b/web/oss/src/components/Playground/Components/MainLayout/index.tsx index e03ab92ac9..923ff93f46 100644 --- a/web/oss/src/components/Playground/Components/MainLayout/index.tsx +++ b/web/oss/src/components/Playground/Components/MainLayout/index.tsx @@ -277,10 +277,19 @@ const PlaygroundMainView = ({ className={clsx("flex flex-col grow h-full overflow-hidden", className)} {...divProps} > -
+
@@ -361,6 +372,8 @@ const PlaygroundMainView = ({ !isComparisonView, "grow w-full h-full overflow-auto [&::-webkit-scrollbar]:w-0": isComparisonView, + // Chat = the recessed canvas the message/composer surfaces sit on. + "ag-canvas": isAgentConfig, }, ])} > diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index 7d931653f6..af86498fe4 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -22,7 +22,7 @@ import { type AgentChannelMode, } from "@agenta/playground" import {usePlaygroundLayout} from "@agenta/playground-ui/hooks" -import {bgColors, textColors} from "@agenta/ui" +import {textColors} from "@agenta/ui" import {VersionBadge} from "@agenta/ui/components/presentational" import {CloseOutlined, DownOutlined, MoreOutlined} from "@ant-design/icons" import {Check, Gavel, GearSix, PencilSimple, Plus, Robot} from "@phosphor-icons/react" @@ -51,6 +51,7 @@ import {writePlaygroundSelectionToQuery} from "@/oss/state/url/playground" import {currentWorkflowAtom, currentWorkflowContextAtom} from "@/oss/state/workflow" import {workspaceMemberByIdFamily} from "@/oss/state/workspace/atoms/selectors" +import AgentRevisionSelector from "../AgentRevisionSelector" import type {BaseContainerProps} from "../types" import RunEvaluationButton from "./RunEvaluationButton" @@ -537,8 +538,7 @@ const PlaygroundHeader: React.FC = ({className, ...divPro <>
= ({className, ...divPro ) : null} {isAgentWorkflow ? ( -
+
- + {currentWorkflow?.name || "Agent"} + {rootEntityId ? ( + <> + + + + ) : null}
) : ( diff --git a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx index f8a63cfbdf..65fd4d92e3 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundVariantConfig/assets/PlaygroundVariantConfigHeader.tsx @@ -176,85 +176,72 @@ const PlaygroundVariantConfigHeader = ({ {...divProps} >
- {!embedded && !isLocalDraftVariant && ( - handleSwitchVariant?.(value)} - value={_variantId ?? undefined} - borderlessTrigger={isAgent} - /> - )} - {/* Local draft: show Draft tag then source revision info */} - {isLocalDraftVariant && ( -
- - {variantRevision !== null && variantRevision !== undefined && ( - - from {rawVariantName} v{variantRevision} - - )} -
- )} - {/* Don't show VariantDetailsWithStatus for local drafts — source info is shown above */} - {!isLocalDraftVariant && ( + {isAgent && !embedded ? ( + // Agent playground: the revision selector moved up to the page header (next to the + // agent name), so this bar reads as the config panel's "Configuration" header. + + Configuration + + ) : ( <> - {embedded && !runnableData ? ( -
- - {rawVariantName as any} - + {!embedded && !isLocalDraftVariant && ( + handleSwitchVariant?.(value)} + value={_variantId ?? undefined} + /> + )} + {/* Local draft: show Draft tag then source revision info */} + {isLocalDraftVariant && ( +
+ {variantRevision !== null && variantRevision !== undefined && ( - - rev {String(variantRevision)} + + from {rawVariantName} v{variantRevision} )}
- ) : isAgent && !embedded ? ( - // Compact agent status: a version chip + a state dot, instead of the - // verbose "Last modified" row. Discard stays available in the kebab. -
- {variantRevision !== null && variantRevision !== undefined && ( - - v{variantRevision} - + )} + {/* Don't show VariantDetailsWithStatus for local drafts — source info above */} + {!isLocalDraftVariant && ( + <> + {embedded && !runnableData ? ( +
+ + {rawVariantName as any} + + {variantRevision !== null && + variantRevision !== undefined && ( + + rev {String(variantRevision)} + + )} +
+ ) : ( + )} - - - - {hasChanges ? "Draft" : "Saved"} - - -
- ) : ( - + + )} + {evaluatorLabel && !embedded && ( + + {evaluatorLabel} + )} )} - {evaluatorLabel && !embedded && ( - - {evaluatorLabel} - - )}
{extraActions} diff --git a/web/oss/src/components/Sidebar/Sidebar.tsx b/web/oss/src/components/Sidebar/Sidebar.tsx index e7acd18998..d67ecda9df 100644 --- a/web/oss/src/components/Sidebar/Sidebar.tsx +++ b/web/oss/src/components/Sidebar/Sidebar.tsx @@ -90,7 +90,7 @@ const Sidebar: React.FC<{showSettingsView?: boolean; lastPath?: string}> = ({ }, [openKeys[0]]) return ( -
+
(null) - const sectionSnapshot = useRef | null>(null) - // Snapshot + restore for the build-kit enabled atom (lives outside config; needs separate tracking). + const [draftConfig, setDraftConfig] = useState | null>(null) + const [draftBuildKit, setDraftBuildKit] = useState(null) + const sectionBaseline = useRef<{config: Record; buildKit: boolean} | null>( + null, + ) const store = useStore() const revisionIdRef = useRef(null) - const buildKitEnabledSnapshot = useRef(null) + const applyDraftConfig = useCallback( + (next: Record) => setDraftConfig(next), + [], + ) + // Swallow writes from the draft hook while its drawer is closed (its body isn't rendered, so an + // internal auto-correction effect must not leak into `draftConfig`). The live `mh` handles any + // real auto-correction against the entity. + const noopConfigChange = useCallback(() => {}, []) const openSectionDrawer = useCallback( (key: "model-harness" | "advanced") => { - sectionSnapshot.current = value ?? {} - buildKitEnabledSnapshot.current = store.get( + const snapshotConfig = (value ?? {}) as Record + const snapshotBuildKit = store.get( workflowBuildKitEnabledAtomFamily(revisionIdRef.current ?? ""), ) + setDraftConfig(snapshotConfig) + setDraftBuildKit(snapshotBuildKit) + sectionBaseline.current = {config: snapshotConfig, buildKit: snapshotBuildKit} setOpenSection(key) }, [value, store], ) - const cancelSection = useCallback(() => { - if (sectionSnapshot.current) onChange(sectionSnapshot.current) - if (buildKitEnabledSnapshot.current !== null) { - store.set( - workflowBuildKitEnabledAtomFamily(revisionIdRef.current ?? ""), - buildKitEnabledSnapshot.current, - ) - } + const closeSectionDraft = useCallback(() => { setOpenSection(null) - }, [onChange, store]) - const saveSection = useCallback(() => setOpenSection(null), []) + setDraftConfig(null) + setDraftBuildKit(null) + sectionBaseline.current = null + }, []) + // Cancel: nothing was written live, so just drop the draft. + const cancelSection = closeSectionDraft + const saveSection = useCallback(() => { + if (draftConfig !== null) onChange(draftConfig) + if (draftBuildKit !== null) { + store.set(workflowBuildKitEnabledAtomFamily(revisionIdRef.current ?? ""), draftBuildKit) + } + closeSectionDraft() + }, [draftConfig, draftBuildKit, onChange, store, closeSectionDraft]) + // Enable Save only when the draft actually differs from what we opened with (config or build-kit). + const sectionDirty = + openSection !== null && + sectionBaseline.current !== null && + (!deepEqual(draftConfig, sectionBaseline.current.config) || + draftBuildKit !== sectionBaseline.current.buildKit) // Layout (accordion / tabs / cards) is a global persisted preference; the panel only reads it. const layout = useAtomValue(agentTemplateLayoutAtom) @@ -208,7 +234,29 @@ export function AgentTemplateControl({ // Model & harness + Advanced own a lot of coupled, stateful logic (the model/connection state // feeds both sections), so they live in their own hook that returns the summaries + bodies. + // + // TWO instances, on purpose: + // - `mh` is bound to the LIVE entity — it drives the accordion header summaries + the inline + // tabs bodies. Keeping it live means a section header NEVER reflects the drawer's unsaved draft + // (the reported bug: editing in the open drawer updated the background summary). + // - `mhDraft` is bound to the DRAFT (config + build-kit) — it drives the OPEN section drawer's + // body, so its forms edit the buffer and Save relays it to the entity/atom. When no drawer is + // open its `onChange` is a no-op (and its body isn't rendered), so the extra hook is inert. const mh = useModelHarness({schema, config, onChange, disabled, withTooltip, revisionId}) + const mhDraft = useModelHarness({ + schema, + config: draftConfig ?? config, + onChange: openSection !== null ? applyDraftConfig : noopConfigChange, + disabled, + withTooltip, + revisionId, + buildKitEnabledOverride: + draftBuildKit !== null ? {value: draftBuildKit, onChange: setDraftBuildKit} : undefined, + // "Current" marks the SAVED harness (from the live entity), not the draft pick. + savedHarnessValue: + ((config.harness as Record | undefined)?.kind as string | undefined) ?? + null, + }) // Tool add/remove (inline function, builtin, gateway, workflow reference) lives in its own hook. const { @@ -571,6 +619,10 @@ export function AgentTemplateControl({ inlineContent?: React.ReactNode }[] + // Each config section is a contained card on the raised Config panel — the surface tokens give + // it depth against the panel (see theme-variables.css "Agent Playground surface ladder"). + const sectionCardClass = "ag-surface-card rounded-[11px] px-4" + return (
{sections.length === 0 ? ( @@ -635,28 +687,31 @@ export function AgentTemplateControl({ onOpen={s.onOpen} collapsible={false} noDivider - className="rounded border border-solid border-[var(--ag-c-EAEFF5,#eaeff5)] px-3" + className={sectionCardClass} > {s.content} ))}
) : ( - sections.map((s, index) => ( - - {s.content} - - )) +
+ {sections.map((s) => ( + + {s.content} + + ))} +
)} {editing @@ -732,10 +787,10 @@ export function AgentTemplateControl({ icon={} onCancel={cancelSection} onSave={saveSection} - disabled={disabled} - width={mh.modelHarnessDrawerWidth} + disabled={disabled || !sectionDirty} + width={mhDraft.modelHarnessDrawerWidth} > - {mh.modelHarnessDrawerBody} + {mhDraft.modelHarnessDrawerBody} } onCancel={cancelSection} onSave={saveSection} - disabled={disabled} + disabled={disabled || !sectionDirty} width={880} > - {mh.advancedDrawerBody} + {mhDraft.advancedDrawerBody} {workflowReference?.enabled && ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx index 611648aad6..66dcd3b25a 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ClaudePermissionsControl.tsx @@ -14,10 +14,10 @@ */ import {memo, useCallback, useMemo} from "react" -import {LabeledField} from "@agenta/ui/components/presentational" -import {cn} from "@agenta/ui/styles" import {Input, Select} from "antd" +import {RailField, railInfoLabel} from "../../drawers/shared/RailField" + type ClaudePermissionMode = "default" | "acceptEdits" | "plan" | "bypassPermissions" interface ClaudePermissionsValue { @@ -34,8 +34,6 @@ export interface ClaudePermissionsControlProps { onChange: (value: Record) => void /** Disable the control */ disabled?: boolean - /** Additional CSS classes */ - className?: string /** * The `default_mode` field schema (its `enum` + `title`/`description`). When provided, the mode * option set and the field label/description follow the template schema instead of the @@ -82,7 +80,6 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ value, onChange, disabled = false, - className, modeSchema, }: ClaudePermissionsControlProps) { const current = useMemo(() => readValue(value), [value]) @@ -137,8 +134,8 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ ) return ( -
- + <> + value={current.defaultMode ?? undefined} onChange={(v) => write({defaultMode: v ?? null})} @@ -147,14 +144,14 @@ export const ClaudePermissionsControl = memo(function ClaudePermissionsControl({ placeholder="Claude default" allowClear className="w-full" - size="small" /> - + - - + - - + - - -
+ + ) }) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerFormView.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerFormView.tsx index 850ea0940c..fd7007aa9d 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerFormView.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/McpServerFormView.tsx @@ -12,9 +12,10 @@ */ import {useState} from "react" -import {LabeledField} from "@agenta/ui/components/presentational" import {Input, Select} from "antd" +import {RailField, railInfoLabel} from "../../drawers/shared/RailField" + import {CodeEditor} from "./CodeEditor" type Dict = Record @@ -95,16 +96,16 @@ export function McpServerFormView({value, onChange, disabled}: McpServerFormView return (
- + set("name", e.target.value)} placeholder="my-mcp-server" disabled={disabled} /> - + - + - + ) : ( - + set("url", e.target.value)} placeholder="https://example.com/mcp" disabled={disabled} /> - + )} - + set("env", Object.keys(env).length ? env : undefined)} placeholder={"NODE_ENV=production"} disabled={disabled} /> - + - - - - + +