mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[eric] chat: self-heals show themselves: raw runtime errors become a card instead of a blank, a stuck-tool restart and a model compaction get a pill, the collapsed card says what it is waiting on, previews and the resume chip skip harness lines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
f38016efb2
commit
68b9ad32ea
@@ -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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const compactionChip = isCompactionAnchor ? (
|
||||
<CompactionMarker
|
||||
key={`compaction-${item.id}`}
|
||||
collapsedCount={
|
||||
Math.max(0, renderItems.findIndex((it) => 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
<RateLimitPill sessionId={session.id} />
|
||||
<ReconnectWaitPill sessionId={session.id} />
|
||||
<ProviderRetryPill sessionId={session.id} />
|
||||
<ContextRecoveredPill sessionId={session.id} />
|
||||
<SelfHealPill sessionId={session.id} />
|
||||
|
||||
{isGlowing ? (
|
||||
<Box
|
||||
|
||||
@@ -25,6 +25,7 @@ import { renderUserTextWithPills } from './renderUserTextWithPills';
|
||||
import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure';
|
||||
import { THINKING_LABELS } from '../thinkingLabels';
|
||||
import { extractPlatformNote } from '../parsing/toolResultParsing';
|
||||
import { classifySystemNotice } from './systemNoticeKind';
|
||||
import { AgentMessage, retryLastUserMessage } from '@/shared/state/agentsSlice';
|
||||
import { openSettingsCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice';
|
||||
@@ -886,14 +887,44 @@ const MessageBubble: React.FC<Props> = 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 (
|
||||
<Box sx={{ display: 'flex', my: 0.75 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.8,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.status.warning}40`,
|
||||
bgcolor: `${c.status.warning}10`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.7,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorSlime size={22} />
|
||||
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: c.text.primary }}>
|
||||
That request hit a snag
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
Something went wrong on that one. Send your message again to retry.
|
||||
</Typography>
|
||||
<Box component="details" sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
|
||||
<Box component="summary" sx={{ cursor: 'pointer', userSelect: 'none' }}>Details</Box>
|
||||
<Box component="pre" sx={{ m: 0, mt: 0.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'inherit' }}>
|
||||
{sysText}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8125rem', fontStyle: 'italic' }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.8125rem', fontStyle: 'italic', whiteSpace: 'pre-wrap' }}>
|
||||
{sysText}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<openswarm_platform_note>\n${PREAMBLE}\nThe browser card was reused.\n</openswarm_platform_note>`;
|
||||
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 = '<openswarm_session_recap>\nEarlier in this chat you asked for the deploy checklist.\n</openswarm_session_recap>';
|
||||
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 = '<openswarm_session_recap>x</openswarm_platform_note>';
|
||||
assert.equal(extractPlatformNote(raw).body, raw);
|
||||
});
|
||||
|
||||
test('plain text passes through untouched', () => {
|
||||
assert.deepEqual(extractPlatformNote('just output'), { body: 'just output', note: null });
|
||||
});
|
||||
@@ -158,16 +158,17 @@ export interface ParsedMcpResult {
|
||||
|
||||
export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult;
|
||||
|
||||
const PLATFORM_NOTE_RE = /<openswarm_platform_note>([\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('<openswarm_platform_note>')) return { body: rawText, note: null };
|
||||
if (!/<openswarm_(?:platform_note|session_recap)>/.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 '';
|
||||
|
||||
@@ -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 (
|
||||
<Fade in={!!cr} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
title="This chat's memory overflowed mid-reply. OpenSwarm recovered it and retried automatically; nothing was lost."
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.6,
|
||||
alignSelf: 'flex-start',
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
>
|
||||
<RestartAltIcon sx={{ fontSize: 14 }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>Recovered and retried</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SelfHealKind, { text: string; why: (outstandingS: number | null) => 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 (
|
||||
<Fade in={!!heal} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
title={lastCopy.current.why}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.6,
|
||||
alignSelf: 'flex-start',
|
||||
mx: 2,
|
||||
mb: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 999,
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
>
|
||||
<RestartAltIcon sx={{ fontSize: 14 }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastCopy.current.text}</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
// 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<Props> = ({
|
||||
{session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
{session.queued && session.status === 'running' ? 'queued' : friendlyStatusLabel(session.status)}
|
||||
{cardStatusWord(session)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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}'));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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, /<SelfHealPill sessionId=\{session\.id\} \/>/);
|
||||
});
|
||||
|
||||
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'/);
|
||||
});
|
||||
Reference in New Issue
Block a user