diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 633599d4..137c77ae 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -70,7 +70,8 @@ import { composerPlaceholder } from './composerPlaceholder'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; import { ProviderRetryPill, RateLimitPill, ReconnectWaitPill } from './shell/RateLimitPill'; -import { ContextRecoveredPill } from './shell/ContextRecoveredPill'; +import { SelfHealPill } from './shell/SelfHealPill'; +import { countSummarizedUserTurns } from './bubbles/compactionCount'; import ChatInput, { ChatInputHandle } from './ChatInput'; import FollowupChips from './FollowupChips'; import ContextDrawer from './shell/ContextDrawer'; @@ -1841,8 +1842,16 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const reason = session.context_overflow.reason; const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error'; const isOutOfTokens = reason === 'out_of_tokens'; - const title = isOutOfTokens ? 'Out of tokens' : isAuth ? 'Sign-in required' : 'Context full'; - const primaryLabel = isOutOfTokens ? 'Got it' : isAuth ? 'Open Settings' : 'Start a fresh chat'; + const isFreeTrial = reason === 'free_trial_exhausted'; + const isOutOfCredits = reason === 'out_of_credits'; + const opensSettings = isAuth || isFreeTrial || isOutOfCredits; + const title = isOutOfTokens ? 'Out of tokens' + : isFreeTrial ? 'Free runs used up' + : isOutOfCredits ? 'Out of credits' + : isAuth ? 'Sign-in required' : 'Context full'; + const primaryLabel = isOutOfTokens ? 'Got it' + : isFreeTrial ? 'Connect a model' + : opensSettings ? 'Open Settings' : 'Start a fresh chat'; // In the workflow build chat, switching models here also sets the workflow's scheduled run model, so spell that consequence out. const message = isOutOfTokens && workflowEditId ? `${session.context_overflow.message} Whichever model you switch to here becomes the model this workflow runs on.` @@ -1850,7 +1859,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const onPrimary = () => { if (isOutOfTokens) { if (id) dispatch(clearContextOverflow({ sessionId: id })); - } else if (isAuth) { + } else if (opensSettings) { dispatch(openSettingsCard({ tab: 'models' })); } else { const did = session?.dashboard_id; @@ -1907,9 +1916,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const compactionChip = isCompactionAnchor ? ( it.id === session.compacted_through_msg_id) + 1) - } + collapsedCount={countSummarizedUserTurns(activeBranchMessages, session.compacted_through_msg_id)} /> ) : null; @@ -2186,7 +2193,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose - + {isGlowing ? ( = React.memo((props) => { const rawSysText = typeof content === 'string' ? content : JSON.stringify(content); const { body: sysBody, note: sysNote } = extractPlatformNote(rawSysText); const sysText = sysNote || sysBody; - // A raw subprocess/API failure ("Command failed with exit code 1", API Error JSON) is dev jargon, and the same failure is already shown as a friendly card on the assistant side. Swallow just that stderr dump so the user sees one calm card, not jargon beneath it. - // Widen this at your peril: the silent-quit seal's honest lines ride the same system role, and swallowing one turns a stopped agent back into an unexplained Done pill. backend/tests/test_empty_finish.py pins that they survive. - if (/Command failed with exit code|API Error:|invalid_request_error|"type"\s*:\s*"error"|Check stderr output/i.test(sysText)) { - return null; + // A raw runtime dump used to render as null here, on the theory that a friendly card was shown elsewhere; system-role messages never reach parseOpenSwarmError, so the user got "needs attention" over a blank transcript. + if (classifySystemNotice(sysText) === 'raw_error') { + return ( + + + + + + That request hit a snag + + + + Something went wrong on that one. Send your message again to retry. + + + Details + + {sysText} + + + + + ); } return ( - + {sysText} diff --git a/frontend/src/app/pages/AgentChat/bubbles/compactionCount.test.ts b/frontend/src/app/pages/AgentChat/bubbles/compactionCount.test.ts new file mode 100644 index 00000000..441a02c4 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/compactionCount.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AgentMessage } from '@/shared/state/agentsSlice'; +import { countSummarizedUserTurns } from './compactionCount'; + +function msg(id: string, role: AgentMessage['role'], hidden = false): AgentMessage { + return { id, role, content: id, timestamp: '', branch_id: 'main', parent_id: null, hidden }; +} + +// The old marker used the anchor's index in the render list, which counts tool groups and every +// visible item, so "14 earlier turns summarized" was a wrong number that stayed forever. +const transcript = [ + msg('u1', 'user'), + msg('a1', 'assistant'), + msg('tc1', 'tool_call'), + msg('tr1', 'tool_result'), + msg('nudge', 'user', true), + msg('a2', 'assistant'), + msg('u2', 'user'), + msg('a3', 'assistant'), + msg('u3', 'user'), +]; + +test('counts only the user\'s own turns at or before the anchor', () => { + assert.equal(countSummarizedUserTurns(transcript, 'a3'), 2); + assert.equal(countSummarizedUserTurns(transcript, 'u2'), 2); + assert.equal(countSummarizedUserTurns(transcript, 'tr1'), 1); +}); + +test('a hidden harness prompt is not a turn the user took', () => { + assert.equal(countSummarizedUserTurns(transcript, 'a2'), 1); +}); + +test('an unknown or missing anchor yields 0 so the marker says "Older turns summarized"', () => { + assert.equal(countSummarizedUserTurns(transcript, 'nope'), 0); + assert.equal(countSummarizedUserTurns(transcript, null), 0); + assert.equal(countSummarizedUserTurns([], 'u1'), 0); +}); diff --git a/frontend/src/app/pages/AgentChat/bubbles/compactionCount.ts b/frontend/src/app/pages/AgentChat/bubbles/compactionCount.ts new file mode 100644 index 00000000..d8fd41f2 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/compactionCount.ts @@ -0,0 +1,14 @@ +import type { AgentMessage } from '@/shared/state/agentsSlice'; + +/** How many of the user's own turns the compaction folded away: user messages at or before the anchor, hidden prompts excluded. 0 when the anchor is unknown, so the marker falls back to wording without a number. */ +export function countSummarizedUserTurns(messages: readonly AgentMessage[], anchorId: string | null | undefined): number { + if (!anchorId) return 0; + const end = messages.findIndex((m) => m.id === anchorId); + if (end < 0) return 0; + let count = 0; + for (let i = 0; i <= end; i++) { + const m = messages[i]; + if (m.role === 'user' && !m.hidden) count++; + } + return count; +} diff --git a/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.test.ts b/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.test.ts new file mode 100644 index 00000000..510530fd --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.test.ts @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifySystemNotice } from './systemNoticeKind'; + +// The old renderer returned null for a raw dump, on the theory that a friendly card was shown "on the +// assistant side". It is not: system-role messages never reach parseOpenSwarmError, so the user got a +// "needs attention" status over a blank transcript. Every row here is a real backend string. + +test('the SDK subprocess dump is a raw error', () => { + assert.equal( + classifySystemNotice('Error: Command failed with exit code 1\n\nRuntime log tail:\nCheck stderr output for details'), + 'raw_error', + ); +}); + +test('a CLI API Error payload is a raw error even behind a friendly headline', () => { + // The headline is ours, the json after it is the runtime's; unanchored matching keeps the json behind Details. + assert.equal( + classifySystemNotice('The agent runtime reported this turn failed (stop_sequence). API Error: 400 {"error":{"message":"Tool cannot have both defer_loading=true and cache_ (reset after 15s)"}}'), + 'raw_error', + ); + assert.equal(classifySystemNotice('{"type":"error","error":{"type":"invalid_request_error"}}'), 'raw_error'); +}); + +test('the autocompact thrash card the backend writes is a notice, not a dump', () => { + assert.equal( + classifySystemNotice('Error: The agent runtime reported this turn failed (stop_sequence). Autocompact is thrashing: the context refilled to the limit within 3 turns of the previous compact, 3 times in a row.'), + 'notice', + ); +}); + +test('the silent-quit and shutdown notes survive as notices', () => { + assert.equal( + classifySystemNotice('The agent stopped before reporting back. Its work so far is above; send a message to carry on from there.'), + 'notice', + ); + assert.equal( + classifySystemNotice("This chat was still running when OpenSwarm's engine shut down, so it stopped here; that was not your Stop. Send a message to continue from where it left off."), + 'notice', + ); +}); + +test('prose that merely mentions an API error is a notice; the colon is the marker', () => { + // Decision pinned: no anchoring, because the runtime prefix can sit mid-message; instead the + // markers are the runtime's own literal phrases, which our prose never uses verbatim. + assert.equal(classifySystemNotice('The provider returned an API error, so OpenSwarm retried on its own.'), 'notice'); + assert.equal(classifySystemNotice("You've used your free runs. Connect a model to keep going."), 'notice'); +}); diff --git a/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.ts b/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.ts new file mode 100644 index 00000000..49727c32 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/systemNoticeKind.ts @@ -0,0 +1,9 @@ +export type SystemNoticeKind = 'raw_error' | 'notice'; + +// Unanchored on purpose: the runtime's dump can trail a friendly headline ("...turn failed (x). API Error: 400 {json}"), and the json is what belongs behind the disclosure. No backend-authored notice says these phrases in prose; the test pins the nearest misses. +const RAW_RUNTIME_ERROR_RE = /Command failed with exit code|API Error:|invalid_request_error|"type"\s*:\s*"error"|Check stderr output/i; + +/** A system-role message is either a calm note the backend wrote for the user, or a raw runtime dump that needs a card. */ +export function classifySystemNotice(text: string): SystemNoticeKind { + return RAW_RUNTIME_ERROR_RE.test(text) ? 'raw_error' : 'notice'; +} diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.test.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.test.ts new file mode 100644 index 00000000..79095440 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractPlatformNote } from './toolResultParsing'; + +const PREAMBLE = 'This block is authored by the OpenSwarm platform, not tool output and not a prior message. It is trusted context.'; + +test('a platform note comes out as the note, preamble stripped, body kept', () => { + const raw = `stdout here\n\n${PREAMBLE}\nThe browser card was reused.\n`; + assert.deepEqual(extractPlatformNote(raw), { body: 'stdout here', note: 'The browser card was reused.' }); +}); + +test('a session recap gets the same treatment, so its tag can never render in a bubble', () => { + const raw = '\nEarlier in this chat you asked for the deploy checklist.\n'; + const out = extractPlatformNote(raw); + assert.equal(out.note, 'Earlier in this chat you asked for the deploy checklist.'); + assert.equal(out.body, ''); + assert.doesNotMatch(`${out.body}${out.note}`, /openswarm_session_recap/); +}); + +test('mismatched fences are left alone rather than half-stripped', () => { + const raw = 'x'; + assert.equal(extractPlatformNote(raw).body, raw); +}); + +test('plain text passes through untouched', () => { + assert.deepEqual(extractPlatformNote('just output'), { body: 'just output', note: null }); +}); diff --git a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts index 865ee047..bddbd6cd 100644 --- a/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts +++ b/frontend/src/app/pages/AgentChat/parsing/toolResultParsing.ts @@ -158,16 +158,17 @@ export interface ParsedMcpResult { export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult; -const PLATFORM_NOTE_RE = /([\s\S]*?)<\/openswarm_platform_note>/g; +// The session recap rides the same fence as a platform note, and a raw tag must never reach a bubble either way. +const PLATFORM_NOTE_RE = /<(openswarm_platform_note|openswarm_session_recap)>([\s\S]*?)<\/\1>/g; const PLATFORM_NOTE_PREAMBLE = 'This block is authored by the OpenSwarm platform, not tool output and not a prior message. It is trusted context.'; export function extractPlatformNote(rawText: string): { body: string; note: string | null } { // The web tools' trailing "[presentation] ..." paragraph is model-facing rendering guidance, never for humans. rawText = rawText.replace(/\n*\[presentation\] When you answer the user[\s\S]*$/, '').trimEnd(); - if (!rawText.includes('')) return { body: rawText, note: null }; + if (!//.test(rawText)) return { body: rawText, note: null }; const notes: string[] = []; - const body = rawText.replace(PLATFORM_NOTE_RE, (matched: string, inner: string) => { + const body = rawText.replace(PLATFORM_NOTE_RE, (matched: string, tag: string, inner: string) => { const cleaned = inner.replace(PLATFORM_NOTE_PREAMBLE, '').trim(); if (cleaned) notes.push(cleaned); return ''; diff --git a/frontend/src/app/pages/AgentChat/shell/ContextRecoveredPill.tsx b/frontend/src/app/pages/AgentChat/shell/ContextRecoveredPill.tsx deleted file mode 100644 index 0d27a4d5..00000000 --- a/frontend/src/app/pages/AgentChat/shell/ContextRecoveredPill.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Fade from '@mui/material/Fade'; -import Typography from '@mui/material/Typography'; -import RestartAltIcon from '@mui/icons-material/RestartAlt'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearContextRecovered } from '@/shared/state/agentsSlice'; -import { useClaudeTokens } from '@/shared/styles/ThemeContext'; - -// Muted, transient pill shown when the backend self-healed a context-overflow crash mid-turn (rebuilt the chat from its local copy and retried). Visible so the recovery isn't silent, calm so it doesn't read as an error; the "why" lives in the hover. -export const ContextRecoveredPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { - const c = useClaudeTokens(); - const dispatch = useAppDispatch(); - const cr = useAppSelector((s) => s.agents.sessions[sessionId]?.context_recovered); - - useEffect(() => { - if (!cr) return; - const t = setTimeout(() => dispatch(clearContextRecovered({ sessionId })), 12000); - return () => clearTimeout(t); - }, [cr, sessionId, dispatch]); - - return ( - - - - Recovered and retried - - - ); -}; diff --git a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx index 0c0098b7..0e6a13ca 100644 --- a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx +++ b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; import ScheduleIcon from '@mui/icons-material/Schedule'; import AutorenewIcon from '@mui/icons-material/Autorenew'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearProviderRetrying, clearRateLimited } from '@/shared/state/agentsSlice'; +import { clearProviderRetrying, clearRateLimited, clearReconnectWait } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; /** Mid-turn CLI backoff pill (ENG-178): the provider 500/429'd and the CLI is silently waiting up @@ -104,12 +104,25 @@ export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => * rather than finished: it wakes itself on a widening schedule and continues where it left off. * This is the one pill that must NOT auto-clear on a short timer, because the wait it describes can * be fifteen minutes; an agent sitting silent that long is exactly what makes people force-quit and - * lose the task. It clears when the next turn actually lands. */ + * lose the task. It clears when the next turn actually lands; if that frame never arrives it admits + * it is still trying once the announced wait plus grace has passed, and gives up ten minutes later. */ export const ReconnectWaitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { const c = useClaudeTokens(); + const dispatch = useAppDispatch(); const rw = useAppSelector((s) => s.agents.sessions[sessionId]?.reconnect_wait); + const [overdue, setOverdue] = useState(false); + + useEffect(() => { + setOverdue(false); + if (!rw) return; + const graceMs = Math.min((rw.retry_in_s ?? 0) + 120, 30 * 60) * 1000; + const t1 = setTimeout(() => setOverdue(true), graceMs); + const t2 = setTimeout(() => dispatch(clearReconnectWait({ sessionId })), graceMs + 10 * 60_000); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, [rw, sessionId, dispatch]); const label = (() => { + if (overdue) return 'Still trying to reconnect'; const secs = rw?.retry_in_s ?? 0; if (!secs) return 'Connection lost, retrying'; const mins = Math.round(secs / 60); diff --git a/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx b/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx new file mode 100644 index 00000000..0db26056 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/shell/SelfHealPill.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useRef } from 'react'; +import Box from '@mui/material/Box'; +import Fade from '@mui/material/Fade'; +import Typography from '@mui/material/Typography'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { clearSelfHeal, SelfHealKind } from '@/shared/state/agentsSlice'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +const COPY: Record string }> = { + context_overflow: { + text: 'Recovered and retried', + why: () => "This chat's memory overflowed mid-reply. OpenSwarm recovered it and retried automatically; nothing was lost.", + }, + tool_restarted: { + text: 'A stuck tool was restarted; continuing', + why: (s) => `A built-in tool stopped answering for ${Math.round(s ?? 0)} seconds, so OpenSwarm restarted it and the agent is redoing that step.`, + }, + cli_compacted: { + text: 'Older turns summarized to free up room', + why: () => 'The model summarized its own earlier turns to stay within its context window; nothing you sent was lost.', + }, +}; + +// Muted, transient pill for a mid-turn self-heal (context rebuilt, a wedged tool restarted, the model compacting its own history). Visible so the recovery isn't silent, calm so it doesn't read as an error; the "why" lives in the hover. +export const SelfHealPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const heal = useAppSelector((s) => s.agents.sessions[sessionId]?.self_heal); + + useEffect(() => { + if (!heal) return; + const t = setTimeout(() => dispatch(clearSelfHeal({ sessionId })), 12000); + return () => clearTimeout(t); + }, [heal, sessionId, dispatch]); + + // Hold the last copy so the exit fade renders words, not a blank pill. + const copy = heal ? { text: COPY[heal.kind].text, why: COPY[heal.kind].why(heal.outstanding_s) } : null; + const lastCopy = useRef(copy ?? { text: '', why: '' }); + if (copy) lastCopy.current = copy; + + return ( + + + + {lastCopy.current.text} + + + ); +}; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 709107d9..18a11a82 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -60,7 +60,8 @@ import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScro import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; import { setCardSidecar } from '@/shared/state/workflowsSlice'; import { openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; -import { friendlyStatusLabel } from '@/shared/statusLabel'; +import { cardStatusWord } from '@/shared/statusLabel'; +import { lastConversationMessage, resumeOwed } from './resumeOwed'; import { useCanvasWindowResize } from './useCanvasWindowResize'; /** Extract up to 3 substantive user-prompt steps to seed a workflow. */ @@ -608,7 +609,8 @@ const AgentCard: React.FC = ({ tiling.applyZone(zone); }; - const lastMessage = session.messages[session.messages.length - 1]; + // Hidden harness prompts ("Finish the task, then answer in plain text.") used to leak into the preview. + const lastMessage = lastConversationMessage(session.messages); // The card shows a 120-CHARACTER preview, so subscribing to the streaming entry itself made every // token of a long answer re-render all 1,464 lines of this component, on every streaming card at // once. AgentChat already solved this (it takes the message id and lets a leaf own the text); the @@ -680,11 +682,10 @@ const AgentCard: React.FC = ({ // status==='stopped' lit the chip on every deliberately-stopped or ancient session at once // (Eric's board, 2026-08-17); a chat whose last word was the assistant's owes nothing. const pillInterrupted = React.useMemo(() => { - if (session.status !== 'stopped' || session.workflow_run_id) return false; + if (session.workflow_run_id) return false; const branch = session.active_branch_id || 'main'; - const msgs = (session.messages || []).filter((m) => (m.branch_id || 'main') === branch && !m.hidden); - const last = msgs[msgs.length - 1]; - return !!last && last.role === 'user'; + const msgs = (session.messages || []).filter((m) => (m.branch_id || 'main') === branch); + return resumeOwed(session.status, msgs); }, [session.status, session.workflow_run_id, session.messages, session.active_branch_id]); const handleResumeInterrupted = React.useCallback(() => { dispatch(sendMessageThunk({ @@ -1158,7 +1159,7 @@ const AgentCard: React.FC = ({ {session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && ( - {session.queued && session.status === 'running' ? 'queued' : friendlyStatusLabel(session.status)} + {cardStatusWord(session)} )} diff --git a/frontend/src/app/pages/Dashboard/cards/resumeOwed.test.ts b/frontend/src/app/pages/Dashboard/cards/resumeOwed.test.ts new file mode 100644 index 00000000..102a67d7 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/resumeOwed.test.ts @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AgentMessage } from '@/shared/state/agentsSlice'; +import { lastConversationMessage, resumeOwed } from './resumeOwed'; + +function msg(role: AgentMessage['role'], content: string, hidden = false): AgentMessage { + return { id: `${role}-${content.length}`, role, content, timestamp: '', branch_id: 'main', parent_id: null, hidden }; +} + +const SHUTDOWN_NOTE = msg('system', "This chat was still running when OpenSwarm's engine shut down, so it stopped here."); + +test('stopped with the user waiting owes a resume', () => { + assert.equal(resumeOwed('stopped', [msg('user', 'do the thing')]), true); +}); + +test('a system note after the user does not answer the user', () => { + // The chat that most needs the chip is exactly the one whose tail is the shutdown note. + assert.equal(resumeOwed('stopped', [msg('user', 'do the thing'), SHUTDOWN_NOTE]), true); +}); + +test('a hidden harness prompt after the user does not answer either', () => { + assert.equal(resumeOwed('stopped', [msg('user', 'do the thing'), msg('user', 'Finish the task, then answer in plain text.', true)]), true); +}); + +test('the assistant having the last word owes nothing', () => { + assert.equal(resumeOwed('stopped', [msg('user', 'do the thing'), msg('assistant', 'done')]), false); + assert.equal(resumeOwed('stopped', [msg('user', 'do the thing'), msg('assistant', 'done'), SHUTDOWN_NOTE]), false); +}); + +test('only a stopped session can owe one', () => { + assert.equal(resumeOwed('completed', [msg('user', 'do the thing')]), false); + assert.equal(resumeOwed('running', [msg('user', 'do the thing')]), false); + assert.equal(resumeOwed('stopped', []), false); +}); + +test('the preview never picks a hidden prompt or a system note', () => { + const tail = [ + msg('user', 'do the thing'), + msg('assistant', 'on it'), + msg('user', 'The engine process running you was stopped from outside and has been restarted.', true), + SHUTDOWN_NOTE, + ]; + assert.equal(lastConversationMessage(tail)?.content, 'on it'); + assert.equal(lastConversationMessage([msg('tool_call', 'x')]), undefined); +}); diff --git a/frontend/src/app/pages/Dashboard/cards/resumeOwed.ts b/frontend/src/app/pages/Dashboard/cards/resumeOwed.ts new file mode 100644 index 00000000..d134c0f6 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/resumeOwed.ts @@ -0,0 +1,16 @@ +import type { AgentMessage } from '@/shared/state/agentsSlice'; + +/** The last thing a person or the agent actually said; hidden harness prompts and system notes never count. */ +export function lastConversationMessage(messages: readonly AgentMessage[]): AgentMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (!m.hidden && (m.role === 'user' || m.role === 'assistant')) return m; + } + return undefined; +} + +/** Interrupted = stopped while still owing a reply. A system note ("the engine shut down") never answers the user, so it is skipped when finding the tail. */ +export function resumeOwed(status: string, messages: readonly AgentMessage[]): boolean { + if (status !== 'stopped') return false; + return lastConversationMessage(messages)?.role === 'user'; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/pillInterrupted.test.ts b/frontend/src/app/pages/Dashboard/desktop/pillInterrupted.test.ts index 682a76e6..0f54323a 100644 --- a/frontend/src/app/pages/Dashboard/desktop/pillInterrupted.test.ts +++ b/frontend/src/app/pages/Dashboard/desktop/pillInterrupted.test.ts @@ -26,11 +26,11 @@ test('the chip resumes with one click and does not select the card', () => { test('the card derives interrupted from stopped AND an unanswered user message', () => { const cond = card.slice(card.indexOf('const pillInterrupted'), card.indexOf('const handleResumeInterrupted')); - assert.ok(cond.includes("session.status !== 'stopped' || session.workflow_run_id"), 'run sidecars own pause/resume from the workflow card'); + assert.ok(cond.includes('if (session.workflow_run_id) return false'), 'run sidecars own pause/resume from the workflow card'); // The 2026-08-17 board: bare status==stopped lit the chip on EVERY old/deliberately-stopped - // session at once; only a chat whose last visible word is the USER'S owes a resume. - assert.ok(cond.includes("last.role === 'user'"), 'a chat the assistant finished answering owes nothing'); - assert.ok(cond.includes('!m.hidden'), 'hidden harness nudges must not make a finished chat look owed'); + // session at once; only a chat whose last real word is the USER'S owes a resume. The rule itself + // (system notes and hidden nudges skipped) is pinned behaviourally in cards/resumeOwed.test.ts. + assert.ok(cond.includes('resumeOwed(session.status, msgs)'), 'the owed-response rule has one definition'); assert.ok(card.includes('interrupted={pillInterrupted}'), 'wire-check: the flag must reach the pill'); assert.ok(card.includes('onResumeInterrupted={handleResumeInterrupted}')); }); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 4d9d9402..4908ec9f 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -12,6 +12,8 @@ const WELCOME_EXPLORATORY_PROMPT = "want and care about before you start, then do it. If it's already concrete, just do it. " + 'Keep any questions brief and friendly, never a wall of text.'; +export type SelfHealKind = 'context_overflow' | 'tool_restarted' | 'cli_compacted'; + export interface AgentMessage { id: string; role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system' | 'thinking'; @@ -123,7 +125,8 @@ export interface AgentSession { // Parked waiting for the connection back; unlike the pills above this can last minutes, so the UI has to say so. reconnect_wait?: { retry_in_s: number | null; attempt: number | null; at: string } | null; provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null; - context_recovered?: { at: string } | null; + // One transient "OpenSwarm healed something mid-turn" pill; the kind picks the wording. + self_heal?: { kind: SelfHealKind; at: string; outstanding_s: number | null } | null; // Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts. app_deps_changed?: boolean; mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>; @@ -1095,14 +1098,23 @@ const agentsSlice = createSlice({ if (session) session.app_deps_changed = true; }, - setContextRecovered(state, action: PayloadAction<{ sessionId: string }>) { + setSelfHeal( + state, + action: PayloadAction<{ sessionId: string; kind: SelfHealKind; outstandingS?: number | null }> + ) { const session = state.sessions[action.payload.sessionId]; - if (session) session.context_recovered = { at: new Date().toISOString() }; + if (session) { + session.self_heal = { + kind: action.payload.kind, + at: new Date().toISOString(), + outstanding_s: action.payload.outstandingS ?? null, + }; + } }, - clearContextRecovered(state, action: PayloadAction<{ sessionId: string }>) { + clearSelfHeal(state, action: PayloadAction<{ sessionId: string }>) { const session = state.sessions[action.payload.sessionId]; - if (session) session.context_recovered = null; + if (session) session.self_heal = null; }, clearContextOverflow( @@ -1587,8 +1599,8 @@ export const { clearReconnectWait, setProviderRetrying, clearProviderRetrying, - setContextRecovered, - clearContextRecovered, + setSelfHeal, + clearSelfHeal, setAppDepsChanged, clearContextOverflow, setMcpSuggestions, diff --git a/frontend/src/shared/statusLabel.test.ts b/frontend/src/shared/statusLabel.test.ts new file mode 100644 index 00000000..28f0eaed --- /dev/null +++ b/frontend/src/shared/statusLabel.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cardStatusWord, friendlyStatusLabel } from './statusLabel'; + +const at = new Date().toISOString(); + +test('the raw enum reads as plain English', () => { + assert.equal(friendlyStatusLabel('running'), 'working'); + assert.equal(friendlyStatusLabel('waiting_approval'), 'needs your OK'); + assert.equal(friendlyStatusLabel('error'), 'needs attention'); +}); + +test('the admission gate no longer shares the word "queued" with the composer chip', () => { + assert.equal(cardStatusWord({ status: 'running', queued: true }), 'waiting to start'); + assert.equal(cardStatusWord({ status: 'running', queued: false }), 'working'); +}); + +test('a pill the collapsed card cannot show becomes its status word', () => { + assert.equal(cardStatusWord({ status: 'running', reconnect_wait: { at } }), 'waiting for connection'); + assert.equal(cardStatusWord({ status: 'running', rate_limited: { at } }), 'rate limited'); + assert.equal(cardStatusWord({ status: 'running', provider_retrying: { at } }), 'provider busy'); + // Lost connection outranks a throttle: nothing else can progress until it is back. + assert.equal(cardStatusWord({ status: 'running', reconnect_wait: { at }, rate_limited: { at } }), 'waiting for connection'); +}); + +test('stale pill state on a finished session never overrides its real status', () => { + assert.equal(cardStatusWord({ status: 'completed', reconnect_wait: { at }, queued: true }), 'done'); + assert.equal(cardStatusWord({ status: 'error', rate_limited: { at } }), 'needs attention'); +}); diff --git a/frontend/src/shared/statusLabel.ts b/frontend/src/shared/statusLabel.ts index f7a98669..80c5050a 100644 --- a/frontend/src/shared/statusLabel.ts +++ b/frontend/src/shared/statusLabel.ts @@ -8,3 +8,23 @@ export function friendlyStatusLabel(status: string): string { default: return status.replace(/_/g, ' '); } } + +interface CardStatusSource { + status: string; + queued?: boolean; + reconnect_wait?: { at: string } | null; + rate_limited?: { at: string } | null; + provider_retrying?: { at: string } | null; +} + +/** The collapsed card's one status word. A running turn that is really waiting on something says what, so "working" never covers a lost connection or a throttle the expanded chat's pills would show. */ +export function cardStatusWord(s: CardStatusSource): string { + if (s.status === 'running') { + if (s.reconnect_wait) return 'waiting for connection'; + if (s.rate_limited) return 'rate limited'; + if (s.provider_retrying) return 'provider busy'; + // "queued" already means an unsent message in the composer chip; the admission gate gets its own words. + if (s.queued) return 'waiting to start'; + } + return friendlyStatusLabel(s.status); +} diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index d46bb3aa..0cf17700 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -18,7 +18,7 @@ import { setReconnectWait, clearReconnectWait, setProviderRetrying, - setContextRecovered, + setSelfHeal, setAppDepsChanged, setMcpSuggestions, addBranch, @@ -757,7 +757,18 @@ class WebSocketManager { case 'agent:context_recovered': // The backend hit a context-overflow crash mid-turn, rebuilt from its local copy, and retried on its own. Transient muted pill so the recovery is visible without reading like an error. if (session_id) { - store.dispatch(setContextRecovered({ sessionId: session_id })); + store.dispatch(setSelfHeal({ sessionId: session_id, kind: 'context_overflow' })); + } + break; + + case 'agent:tool_recovered': + // The sidecar watchdog restarted a wedged built-in tool and the agent is redoing that step; same muted pill, different words. + if (session_id) { + store.dispatch(setSelfHeal({ + sessionId: session_id, + kind: 'tool_restarted', + outstandingS: typeof data.outstanding_s === 'number' ? data.outstanding_s : null, + })); } break; @@ -775,6 +786,9 @@ class WebSocketManager { sessionId: session_id, throughMsgId: data.compacted_through_msg_id ?? null, })); + } else if (session_id && data.reason === 'cli_compacted') { + // The model compacted its OWN transcript: none of our messages were dropped, so no permanent marker and no token reset, just the transient pill. + store.dispatch(setSelfHeal({ sessionId: session_id, kind: 'cli_compacted' })); } break; @@ -821,6 +835,27 @@ class WebSocketManager { } break; + case 'agent:free_trial_exhausted': + // Same blocked-session slot; the backend designed this card and nothing consumed the event. + if (session_id) { + store.dispatch(setContextOverflow({ + sessionId: session_id, + reason: 'free_trial_exhausted', + message: data.message ?? "You've used your free runs. Connect a model to keep going.", + })); + } + break; + + case 'agent:out_of_credits': + if (session_id) { + store.dispatch(setContextOverflow({ + sessionId: session_id, + reason: 'out_of_credits', + message: data.message ?? "Your model provider reports you're out of credits or over your usage limit.", + })); + } + break; + case 'agent:mcp_suggestions': if (session_id) { store.dispatch(setMcpSuggestions({ diff --git a/frontend/src/shared/ws/selfHealEvents.test.ts b/frontend/src/shared/ws/selfHealEvents.test.ts new file mode 100644 index 00000000..81fb532f --- /dev/null +++ b/frontend/src/shared/ws/selfHealEvents.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +// An event nobody consumes is a silent path: agent:free_trial_exhausted and agent:out_of_credits were +// emitted by the backend with a designed card and had no case in the switch. These pin the routing by +// reading the source, the same way queuedSendBubble.test.ts does. +const ws = fs.readFileSync(path.join(process.cwd(), 'src/shared/ws/WebSocketManager.ts'), 'utf8'); +const chat = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/AgentChat.tsx'), 'utf8'); + +function arm(event: string): string { + const start = ws.indexOf(`case '${event}':`); + assert.ok(start > 0, `${event} must have a case`); + const end = ws.indexOf('\n case ', start + 1); + return ws.slice(start, end); +} + +test('the two blocked-session events fill the shared top-of-transcript slot', () => { + assert.match(arm('agent:free_trial_exhausted'), /setContextOverflow\([\s\S]*reason: 'free_trial_exhausted'/); + assert.match(arm('agent:out_of_credits'), /setContextOverflow\([\s\S]*reason: 'out_of_credits'/); + assert.match(chat, /isFreeTrial \? 'Free runs used up'/); + assert.match(chat, /isOutOfCredits \? 'Out of credits'/); + assert.match(chat, /isFreeTrial \? 'Connect a model'/); + assert.match(chat, /opensSettings \? 'Open Settings'/); +}); + +test('a restarted tool and a CLI compaction ride the self-heal pill', () => { + assert.match(arm('agent:tool_recovered'), /setSelfHeal\([\s\S]*kind: 'tool_restarted'[\s\S]*outstanding_s/); + assert.match(arm('agent:context_recovered'), /kind: 'context_overflow'/); + assert.match(chat, //); +}); + +test('cli_compacted never reaches recordCompaction, which zeroes tokens and plants the permanent marker', () => { + const status = arm('agent:context_status'); + const compacted = status.indexOf("data.reason === 'compacted'"); + const cli = status.indexOf("data.reason === 'cli_compacted'"); + assert.ok(compacted > 0 && cli > compacted, 'both reasons are handled, ours first'); + assert.match(status.slice(compacted, cli), /recordCompaction\(/); + assert.doesNotMatch(status.slice(cli), /recordCompaction\(/); + assert.match(status.slice(cli), /kind: 'cli_compacted'/); +});