diff --git a/frontend/src/app/pages/AgentChat/tool-ui/hasWorkAfterLatestShowUi.test.ts b/frontend/src/app/pages/AgentChat/tool-ui/hasWorkAfterLatestShowUi.test.ts new file mode 100644 index 00000000..73ff2f05 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/hasWorkAfterLatestShowUi.test.ts @@ -0,0 +1,46 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// ENG-272. The collapsed pill hid a plan/progress widget whenever the session was running, on the +// assumption that a mid-turn plan is stale. That is true when work followed it and false when the +// widget IS the newest thing the agent said, which is exactly what a long-running skill does when it +// re-emits its tracker. Result: the minimized view showed older state all day. Staleness is now +// "did real work land after this widget", which these pin. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hasWorkAfterLatestShowUi } from './showUiPayload.ts'; + +const showUi = (id: string) => ({ role: 'tool_call', content: { id, tool: 'mcp__openswarm-core__ShowUI', input: { component: 'plan', props: {} } } }); +const bash = (id: string) => ({ role: 'tool_call', content: { id, tool: 'Bash', input: { command: 'ls' } } }); +const say = (text: string) => ({ role: 'assistant', content: text }); +const user = (text: string) => ({ role: 'user', content: text }); + +test('a widget that is the newest thing is NOT stale', () => { + assert.equal(hasWorkAfterLatestShowUi([user('go'), bash('b1'), showUi('u1')]), false); +}); + +test('a re-emitted widget is not stale either, which is the whole bug', () => { + // The send-text case: work, widget, more work, widget again. The newest widget stands. + assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), bash('b1'), say('sent the batch'), showUi('u2')]), false); +}); + +test('a tool call after the widget DOES make it stale', () => { + assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), bash('b1')]), true); +}); + +test('a spoken answer after the widget makes it stale', () => { + assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), say('all done')]), true); +}); + +test('an empty assistant message is not work', () => { + assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), say(' ')]), false); +}); + +test('a user message after the widget is not agent work', () => { + // The user typing does not make the agent's own widget stale; the agent has not acted yet. + assert.equal(hasWorkAfterLatestShowUi([showUi('u1'), user('any update?')]), false); +}); + +test('no widget at all reports no work-after', () => { + assert.equal(hasWorkAfterLatestShowUi([user('go'), bash('b1')]), true); + assert.equal(hasWorkAfterLatestShowUi([]), false); +}); diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts index 3f9c0f5f..7645a6f9 100644 --- a/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts +++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiPayload.ts @@ -125,6 +125,26 @@ export function extractPendingAskUi(messages: Array<{ id: string; role: string; return null; } +/** True when real agent work landed AFTER the newest ShowUI, which is what makes that widget stale. + * + * A tool call or a spoken answer after the widget means the plan it drew has moved on. A widget that + * is still the last thing in the transcript is the agent's CURRENT statement, however it is named, + * and a long-running skill re-emitting its tracker lands exactly there (ENG-272). + */ +export function hasWorkAfterLatestShowUi(messages: Array<{ role: string; content: unknown }>): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'tool_call') { + const body = (typeof msg.content === 'object' && msg.content !== null ? msg.content : {}) as { tool?: unknown }; + // The ShowUI itself ends the scan: anything below it is older than the widget. + if (/(^|__)ShowUI$/.test(String(body.tool || ''))) return false; + return true; + } + if (msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.trim()) return true; + } + return false; +} + export function extractLatestShowUi(messages: Array<{ role: string; content: unknown }>): ShowUiPayload | null { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 360fe5cb..ae63bd2c 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -43,7 +43,7 @@ import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardCont import { agentCardMenuRows } from './agentCardMenuRows'; import { extractLatestTodos } from '../desktop/agentTodos'; import { extractLiveSteps } from '../desktop/agentLiveSteps'; -import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; +import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName, hasWorkAfterLatestShowUi } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import { useBrowserPillShot } from '../desktop/useBrowserPillShot'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -724,11 +724,18 @@ const AgentCard: React.FC = ({ [session.messages, session.status], ); const pillArtifact = useMemo(() => { - const artifact = extractLatestShowUi(session.messages || []); + const msgs = session.messages || []; + const artifact = extractLatestShowUi(msgs); if (!artifact) return null; - // A plan/progress widget posted mid-turn goes stale the moment work continues; while running the - // pill prefers living surfaces (browser shot, live steps, todos). Answer widgets still win. - if (session.status === 'running' && /plan|progress/i.test(artifactName(artifact))) return null; + // A plan/progress widget posted mid-turn goes stale the moment work continues, so while running + // the pill prefers living surfaces (browser shot, live steps, todos). But "stale" means NOTHING + // HAPPENED SINCE, not "it is named plan": a skill whose whole job is a live-updating tracker + // re-emits it as the newest thing in the transcript, and hiding that left the collapsed view + // showing older state all day (ENG-272). So only suppress it when real work followed it. + if (session.status === 'running' && /plan|progress/i.test(artifactName(artifact)) + && hasWorkAfterLatestShowUi(msgs)) { + return null; + } return freezeIfDone(artifact, session.status === 'running'); }, [session.messages, session.status]); const pillAskPair = useMemo(