From 76cb97b9d06f45278b9be7bcd7a5adfd4e4d8dd9 Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 23 Jun 2026 00:00:13 -0700 Subject: [PATCH] [aidan] feat/run-context: attach a run as removable chat context --- backend/apps/agents/agent_manager.py | 17 +++++-- backend/apps/workflows/models.py | 9 ++++ backend/apps/workflows/workflows.py | 44 ++++++++++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 38 +++++++++++++-- .../src/app/pages/AgentChat/ChatInput.tsx | 11 ++++- .../ChatInput/view/ChatInputView.tsx | 32 ++++++++++++- .../app/pages/Workflows/app/DetailView.tsx | 46 +++++++++++++++---- frontend/src/app/pages/Workflows/app/api.ts | 19 ++++++++ frontend/src/app/pages/Workflows/app/model.ts | 26 +++++++++++ .../src/shared/state/dashboardLayoutSlice.ts | 21 +++++++++ 10 files changed, 242 insertions(+), 21 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index b9248375..5c97c8d4 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -3617,8 +3617,14 @@ class AgentManager: selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None, client_message_id: str | None = None, + prepend_context: str | None = None, ): - """Send a follow-up message to an existing session.""" + """Send a follow-up message to an existing session. + + prepend_context rides along with this single turn (folded into the model + prompt) but never shows in the user's bubble, so a run transcript can be + the context for a question without a separate "I've reviewed it" turn. + """ session = self.sessions.get(session_id) if not session: data = _load_session_data(session_id) @@ -3683,6 +3689,9 @@ class AgentManager: "message": user_msg.model_dump(mode="json"), }) + # The model sees the context prefix; the displayed user_msg above does not. + model_prompt = f"{prepend_context}\n\n{prompt}" if prepend_context else prompt + # Fire a background aux LLM call to generate a 3-6 word verb-phrase # describing this turn ("Auditing the pull request", "Drafting your # email"). The narrator pill swaps from its heuristic verb to this @@ -3725,7 +3734,7 @@ class AgentManager: # error falls through to the normal loop. fast_verdict = "no" fast_brief = "" - if not hidden: + if not hidden and not prepend_context: try: from backend.apps.agents.browser import browser_fast_path _extras = bool(images or context_paths or forced_tools or attached_skills @@ -3741,9 +3750,9 @@ class AgentManager: logger.warning(f"[browser-fast-path] gate error, normal path: {e}") if fast_verdict != "no": - task = asyncio.create_task(self._run_browser_fast_path(session_id, prompt, selected_browser_ids, fast_brief, fast_verdict)) + task = asyncio.create_task(self._run_browser_fast_path(session_id, model_prompt, selected_browser_ids, fast_brief, fast_verdict)) else: - task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids)) + task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids)) self.tasks[session_id] = task async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = "", verdict: str = "act"): diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 36ed17d8..f701c3bd 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -271,6 +271,15 @@ class MissedRunAction(BaseModel): ids: list[str] = Field(default_factory=list) +class AskRunBody(BaseModel): + # Answer a chat question with a finished run's transcript folded in as context. + # run_id picks the run's session to pull in; prompt is the user's question. + run_id: str + prompt: str + mode: Optional[str] = None + model: Optional[str] = None + + class DraftCommitBody(BaseModel): # The model the user settled on in the Edit Agent picker, applied to the # workflow's run model only on Save (save-gated; Discard drops it). diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 71d93d8b..698df960 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -14,6 +14,7 @@ from backend.apps.workflows.models import ( WorkflowRun, WorkflowStep, DraftCommitBody, + AskRunBody, MissedRunAction, GenerateMetadataRequest, GenerateMetadataResponse, @@ -979,6 +980,49 @@ async def edit_agent_session(workflow_id: str): return {"session_id": session.id} +@workflows.router.post("/{workflow_id}/ask-run") +async def ask_run(workflow_id: str, body: AskRunBody): + """Answer a chat question with a run's transcript folded in as context for + that one turn. The run rides along hidden (prepend_context), so the user's + bubble shows just their question and there's no extra "reviewed it" turn. + """ + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + run = next((r for r in storage.list_runs(workflow_id, limit=200) if r.id == body.run_id), None) + if not run or not run.session_id: + raise HTTPException(status_code=404, detail="Run has no chat to attach") + + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(run.session_id) + if sess is None: + try: + sess = await agent_manager.resume_session(run.session_id) + except ValueError: + sess = None + transcript = p_render_test_transcript(getattr(sess, "messages", []) or []) if sess else "" + + edit = await edit_agent_session(workflow_id) + edit_sid = edit["session_id"] + + status_word = { + "success": "completed", "ran_late": "completed (late)", "failure": "failed", + }.get(run.status, run.status) + context = ( + f"The user is asking about a run of this workflow (run {status_word}). Use the run's full " + f"transcript below, including each step's tool calls and results, to answer their question. " + f"Do not summarize unless asked.\n\n=== RUN TRANSCRIPT ===\n{transcript or '(transcript unavailable)'}\n=== END TRANSCRIPT ===" + ) + try: + await agent_manager.send_message( + edit_sid, body.prompt, mode=body.mode, model=body.model, prepend_context=context, + ) + except Exception: + logger.exception("ask-run: failed to answer with run context for workflow %s", workflow_id) + + return {"session_id": edit_sid} + + async def p_end_edit_session(wf) -> None: """End a workflow's Edit-Agent session (after Save or Discard) so the next edit opens a brand-new chat against the current workflow instead of diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 746733d7..3f173402 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -64,6 +64,7 @@ import ContextDrawer from './shell/ContextDrawer'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice'; +import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; import { setCardSidecar, commitDraft, updateWorkflowCard, controlWorkflowRun } from '@/shared/state/workflowsSlice'; import { shallowEqual } from 'react-redux'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -238,6 +239,7 @@ interface QueuedMessage { attachedSkills?: Array<{ id: string; name: string; content: string }>; selectedBrowserIds?: string[]; selectedAppIds?: string[]; + attachedRunId?: string; } interface AgentChatProps { @@ -252,9 +254,20 @@ interface AgentChatProps { // Set when this chat is the workflow build/edit agent: the out-of-tokens card // then warns that switching models here also changes the workflow's run model. workflowEditId?: string; + // View-only transcript (e.g. the Run Monitor): renders messages + tool calls + // but no composer, so the session can't be typed into. + readOnly?: boolean; + // One-shot text to drop into the composer (e.g. a run attached as context). + prefillPrompt?: string; + // A workflow run attached as a removable context chip above the composer; while + // present, each send routes through onSendRunQuestion so the run's transcript + // rides along as hidden context for that turn. + runContext?: WorkflowsRunContext; + onClearRunContext?: () => void; + onSendRunQuestion?: (prompt: string, runId: string) => Promise; } -const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, initialContextPaths, onBranch, workflowEditId }) => { +const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, initialContextPaths, onBranch, workflowEditId, readOnly, prefillPrompt, runContext, onClearRunContext, onSendRunQuestion }) => { const c = useClaudeTokens(); const STATUS_STYLES: Record = { running: { color: c.status.success, bg: c.status.successBg }, @@ -362,6 +375,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [workflowModelNotice, setWorkflowModelNotice] = useState(null); const workflowModelNoticeTimer = useRef | null>(null); + // Read live in the stable handleSend/dispatchMessage closures without busting + // their memo (ChatInput leans on handleSend identity holding across renders). + const runContextRef = useRef(runContext); + runContextRef.current = runContext; + const onSendRunQuestionRef = useRef(onSendRunQuestion); + onSendRunQuestionRef.current = onSendRunQuestion; + const wsRef = useRef | null>(null); // Current status for the WS-cleanup closure (effect deps can't include it). const statusRef = useRef(undefined); @@ -470,6 +490,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } } }); + } else if (msg.attachedRunId && onSendRunQuestionRef.current) { + // Run-context question: the backend folds the run transcript into this one + // turn and echoes the user bubble + answer over WS, so no optimistic thunk. + onSendRunQuestionRef.current(msg.prompt, msg.attachedRunId).catch(() => setAwaitingResponse(false)); } else { if (msg.selectedBrowserIds?.length) { dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); @@ -906,7 +930,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose ) => { if (!id) return; scrollToBottom(); - const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds }; + const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, attachedRunId: runContextRef.current?.runId }; if (agentBusy) { messageQueueRef.current.push(msg); setQueueLength(messageQueueRef.current.length); @@ -1819,7 +1843,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose /> )} - {showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && ( + {showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && !readOnly && ( = ({ sessionId: sessionIdProp, onClose ); })()} - {isStoppableSidecar ? ( + {readOnly ? null : isStoppableSidecar ? ( ) : ( @@ -2274,6 +2298,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose contextEstimate={contextEstimate} sessionId={id} autoFocus={autoFocus} + prefillPrompt={prefillPrompt} + placeholderOverride={runContext ? 'Ask about this run...' : undefined} + runContext={runContext} + onClearRunContext={onClearRunContext} thinkingLevel={session?.thinking_level ?? 'auto'} onThinkingLevelChange={handleThinkingLevelChange} onActivityLabelChange={setPreSendActivityLabel} @@ -2306,7 +2334,7 @@ function WorkflowModelNotice({ c, label }: { c: ReturnType - This workflow will run on {display} after you save. + This workflow will now be using {display}. diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index ecd3c509..8d37d17c 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -19,6 +19,7 @@ import { ChatInputView } from './ChatInput/view/ChatInputView'; import { PastePreviewDialog } from './ChatInput/view/PastePreviewDialog'; import { ICON_MAP, FALLBACK_MODE_BASE } from './ChatInput/modeConfig'; import { AttachedImage, ForcedToolGroup, ChatInputHandle } from './ChatInput/types'; +import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; export type { AttachedImage, ForcedToolGroup, ChatInputHandle }; export type { AttachedSkill } from '@/app/components/editor/richEditorUtils'; @@ -46,9 +47,14 @@ interface Props { // Seed the composer with this text (unsent), so a starter-prompt click opens // the chat with the message already typed, ready for the user to hit send. prefillPrompt?: string; + // Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run..."). + placeholderOverride?: string; + // A workflow run shown as a small removable chip inside the composer. + runContext?: WorkflowsRunContext; + onClearRunContext?: () => void; } -const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt }, ref) => { +const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -349,6 +355,9 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, isRunning={isRunning} queueLength={queueLength} modeConf={modeConf} + placeholderOverride={placeholderOverride} + runContext={runContext} + onClearRunContext={onClearRunContext} handleInput={handleInput} handleEditorClick={handleEditorClick} handleKeyDown={handleKeyDown} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 92a685be..a71c7048 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -10,6 +10,7 @@ import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { TriggerState } from '@/app/components/editor/richEditorUtils'; import { AttachedImage, ForcedToolGroup } from '../types'; +import type { WorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; import { SendBlock } from '../hooks/useContextFiles'; import { ModelPickerState } from '../hooks/useModelPicker'; import { SendBlockBanner } from './SendBlockBanner'; @@ -59,6 +60,9 @@ interface Props { isRunning?: boolean; queueLength: number; modeConf: ModeConf; + placeholderOverride?: string; + runContext?: WorkflowsRunContext; + onClearRunContext?: () => void; handleInput: () => void; handleEditorClick: () => void; handleKeyDown: (e: React.KeyboardEvent) => void; @@ -180,6 +184,32 @@ export const ChatInputView: React.FC = (p) => { /> )} + {p.runContext && ( + + + + + Run attached · {p.runContext.title} + + + + + + + )} + = (p) => { autoRunMode={p.autoRunMode} isRunning={p.isRunning} queueLength={p.queueLength} - placeholderLabel={`${p.modeConf.label}, @ for context, / for commands`} + placeholderLabel={p.placeholderOverride ?? `${p.modeConf.label}, @ for context, / for commands`} onInput={p.handleInput} onClick={p.handleEditorClick} onKeyDown={p.handleKeyDown} diff --git a/frontend/src/app/pages/Workflows/app/DetailView.tsx b/frontend/src/app/pages/Workflows/app/DetailView.tsx index 2b621709..76e538e9 100644 --- a/frontend/src/app/pages/Workflows/app/DetailView.tsx +++ b/frontend/src/app/pages/Workflows/app/DetailView.tsx @@ -1,26 +1,43 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { runWorkflowNow } from '@/shared/state/workflowsSlice'; +import { openWorkflowMonitor, setWorkflowsRunContext, clearWorkflowsRunContext } from '@/shared/state/dashboardLayoutSlice'; import { stepsSignature, isScheduleActive } from '@/app/pages/Workflows/scheduleUtils'; +import { askRun } from './api'; import AgentChat from '@/app/pages/AgentChat/AgentChat'; -import { WC, colorForId, statusChip } from './uiKit'; -import { isRunning } from './model'; +import { useWC, colorForWorkflow, statusChip } from './uiKit'; +import { isRunning, runContextChip } from './model'; import { useEditAgentSession } from './useEditAgentSession'; import { useWorkflowPatch } from './useWorkflowPatch'; import ScheduleCard from './ScheduleCard'; import StepsCard from './StepsCard'; import HistoryCard from './HistoryCard'; +import ColorSwatch from './ColorSwatch'; import type { AppNav } from './types'; const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId }) => { + const WC = useWC(); const dispatch = useAppDispatch(); const patch = useWorkflowPatch(); const workflow = useAppSelector((s) => s.workflows.items[workflowId]); const active = useAppSelector((s) => s.workflows.active); - const sessionId = useEditAgentSession(workflowId, 'modify'); + const sessionId = useEditAgentSession(workflowId); const [name, setName] = useState(workflow?.title ?? ''); + const detailRuns = useAppSelector((s) => s.workflows.runs[workflowId]); + const runContext = useAppSelector((s) => s.dashboardLayout.workflowsRunContext); + // When you Run now from this chat, attach that run as a context chip once it + // finishes, so the next question rides on its transcript (removable, no popup). + const autoCtxRunId = useRef(null); useEffect(() => { setName(workflow?.title ?? ''); }, [workflow?.title]); + useEffect(() => { + const rid = autoCtxRunId.current; + if (!rid) return; + const r = (detailRuns || []).find((x) => x.id === rid); + if (!r || r.status === 'running') return; + autoCtxRunId.current = null; + if (workflow) dispatch(setWorkflowsRunContext(runContextChip(workflow, r))); + }, [detailRuns, workflowId, dispatch, workflow]); if (!workflow) return
; @@ -31,7 +48,9 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId const runNow = () => { if (running) return; - dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) })); + dispatch(runWorkflowNow({ id: workflow.id, signature: stepsSignature(workflow.steps) })) + .unwrap().then((res) => { autoCtxRunId.current = res.run_id || null; }).catch(() => {}); + dispatch(openWorkflowMonitor({ workflowId: workflow.id })); }; const commitName = () => { const t = name.trim(); @@ -41,9 +60,9 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId return ( <>
-
+
-
+ patch(workflow, { color: hex })} size={14} /> setName(e.target.value)} @@ -51,12 +70,12 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} style={{ flex: 1, minWidth: 0, border: 'none', background: 'transparent', fontFamily: "'Newsreader',serif", fontSize: 25, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em' }} /> - {statusText} + {statusText}
{workflow.description &&
{workflow.description}
} @@ -64,7 +83,14 @@ const DetailView: React.FC<{ workflowId: string; nav: AppNav }> = ({ workflowId
{sessionId - ? + ? dispatch(clearWorkflowsRunContext())} + onSendRunQuestion={(prompt, runId) => askRun(workflow.id, { runId, prompt }).then((ok) => { if (!ok) throw new Error('ask-run failed'); })} + /> :
}
diff --git a/frontend/src/app/pages/Workflows/app/api.ts b/frontend/src/app/pages/Workflows/app/api.ts index b42b2075..579be235 100644 --- a/frontend/src/app/pages/Workflows/app/api.ts +++ b/frontend/src/app/pages/Workflows/app/api.ts @@ -23,3 +23,22 @@ export async function ensureEditAgentSession(workflowId: string): Promise