diff --git a/client/src/components/cos/tabs/AgentsTab.jsx b/client/src/components/cos/tabs/AgentsTab.jsx index d03645e10f..3b14af18e2 100644 --- a/client/src/components/cos/tabs/AgentsTab.jsx +++ b/client/src/components/cos/tabs/AgentsTab.jsx @@ -8,16 +8,26 @@ import ResumeAgentModal from './ResumeAgentModal'; import RelaunchAgentModal from './RelaunchAgentModal'; import BrailleSpinner from '../../BrailleSpinner'; import InlineConfirmRow from '../../ui/InlineConfirmRow'; +import { agentResumeMessage } from '../../../lib/agentResumeOutcome'; // What each `resumeAgent` outcome actually did (server modes, agentManagement.js). // `already-active` and `superseded` deliberately queue NOTHING — the task is already -// in flight, or a later pause owns it — so an unmapped mode must NOT fall through to -// "created a resume task". The server's `created` flag decides that (see below); this -// map only supplies the specific wording. +// in flight, or a later pause owns it — so they carry no `running` wording, and an +// unmapped mode must NOT fall through to "created a resume task". The server's +// `created` flag decides that (see below); this map only supplies the specific +// wording. `requeued` has both variants because the server force-spawns the resumed +// task when a slot is free — see `agentResumeMessage` for that contract. const RESUME_MESSAGES = { - requeued: 'Resumed — the paused task is queued on its preserved worktree', - 'already-active': 'Its task is already queued or running — nothing new was created', - superseded: 'A later agent now holds this task paused — that pause was left intact', + requeued: { + queued: 'Resumed — the paused task is queued on its preserved worktree', + running: 'Resumed — the paused task is running again on its preserved worktree', + }, + 'new-task': { + queued: 'Resumed — a replacement task is queued', + running: 'Resumed — a replacement task is running', + }, + 'already-active': { queued: 'Its task is already queued or running — nothing new was created' }, + superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' }, }; // Only agents from a manually-filled task form ask for a rating — scheduled/ @@ -165,8 +175,8 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a // A resume that created nothing (`created: false`) never claims it did, even for // a mode this build has no wording for — the completed-agent branch above has no // `created` field at all and did queue a task, so it keeps the default. - toast.success(RESUME_MESSAGES[result.mode] - || (result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`)); + toast.success(agentResumeMessage(result, RESUME_MESSAGES, + result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`)); setResumingAgent(null); onRefresh(); }; diff --git a/client/src/components/cos/tabs/AgentsTab.test.jsx b/client/src/components/cos/tabs/AgentsTab.test.jsx index ce4c6c6f2b..80e3efc9e4 100644 --- a/client/src/components/cos/tabs/AgentsTab.test.jsx +++ b/client/src/components/cos/tabs/AgentsTab.test.jsx @@ -191,6 +191,52 @@ describe('AgentsTab resume routing', () => { expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/resume task/i)); }); + // The server force-spawns the resumed task when a slot is free, so "queued" is the + // exception, not the rule — and a "queued" toast for a run that already started + // reads as the Resume click not having taken. + it('says the resumed task is running when the server started it', async () => { + const user = userEvent.setup(); + api.resumeCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true }); + renderTab([pausedAgent]); + await act(async () => {}); + + await user.click(screen.getByRole('button', { name: 'Resume agent-paused' })); + await user.click(screen.getByRole('button', { name: 'Submit resume' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i))); + }); + + it('names why a resumed task stayed queued instead of leaving the user to hunt for it', async () => { + const user = userEvent.setup(); + api.resumeCosAgent.mockResolvedValue({ + success: true, taskId: 'task-abc', mode: 'requeued', + spawned: false, spawnHold: 'No available agent slots (3/3)', + }); + renderTab([pausedAgent]); + await act(async () => {}); + + await user.click(screen.getByRole('button', { name: 'Resume agent-paused' })); + await user.click(screen.getByRole('button', { name: 'Submit resume' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/))); + }); + + // `new-task` is the mode where the paused task was gone, so a REPLACEMENT was + // queued — and it is force-spawned like any other. Without its own entry it fell + // through to the generic "Created resume task", which says nothing about whether + // the replacement actually started. + it('says a replacement task is running when the server started that too', async () => { + const user = userEvent.setup(); + api.resumeCosAgent.mockResolvedValue({ success: true, taskId: 'task-new', mode: 'new-task', created: true, spawned: true }); + renderTab([pausedAgent]); + await act(async () => {}); + + await user.click(screen.getByRole('button', { name: 'Resume agent-paused' })); + await user.click(screen.getByRole('button', { name: 'Submit resume' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/replacement task is running/i))); + }); + // The default has to be safe by construction, not by keeping a copy of the server's // mode enum in sync — a future non-creating mode this build has no wording for must // not regress to announcing a task that was never queued. diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.jsx index 44820877bd..1c9a1b4db1 100644 --- a/client/src/components/cos/tabs/RelaunchAgentModal.jsx +++ b/client/src/components/cos/tabs/RelaunchAgentModal.jsx @@ -9,15 +9,25 @@ import { FormField } from '../../ui/FormField'; import CollapsibleText from '../../ui/CollapsibleText'; import { useAsyncAction } from '../../../hooks/useAsyncAction'; import { effortAwareModelOptions, seedModelEffort } from '../../../utils/providers'; +import { agentResumeMessage } from '../../../lib/agentResumeOutcome'; // What each relaunch outcome actually did. The server reuses `resumeAgent`'s -// modes (agentManagement.js): only `requeued` restarts the work — `already-active` -// and `superseded` deliberately queue NOTHING, so an unmapped mode must not fall -// through to a message claiming the task was relaunched. +// modes (agentManagement.js): only `requeued` and `new-task` put work back on the +// queue — `already-active` and `superseded` deliberately queue NOTHING, so those +// carry no `running` wording and an unmapped mode falls through to the plain +// fallback rather than a message claiming the task was relaunched. See +// `agentResumeMessage` for the queued-vs-running contract. const RELAUNCH_MESSAGES = { - requeued: 'Relaunched — the task is queued again on its preserved worktree', - 'already-active': 'Its task is already queued or running — nothing new was created', - superseded: 'A later agent now holds this task paused — that pause was left intact', + requeued: { + queued: 'Relaunched — the task is queued again on its preserved worktree', + running: 'Relaunched — the task is running again on its preserved worktree', + }, + 'new-task': { + queued: 'Relaunched — a replacement task is queued', + running: 'Relaunched — a replacement task is running', + }, + 'already-active': { queued: 'Its task is already queued or running — nothing new was created' }, + superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' }, }; /** @@ -72,7 +82,7 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC app: formData.app || undefined, context: formData.note.trim() || undefined }, { silent: true }); - toast.success(RELAUNCH_MESSAGES[result?.mode] || 'Relaunched'); + toast.success(agentResumeMessage(result, RELAUNCH_MESSAGES, 'Relaunched')); onDone?.(result); onClose(); return result; @@ -120,8 +130,9 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC expandedClassName="max-h-48 overflow-y-auto whitespace-pre-wrap" />
- This stops the running agent and requeues the same task on the worktree it leaves - behind — no second agent, and nothing to clean up afterward. + This stops the running agent and restarts the same task on the worktree it leaves + behind — no second agent, and nothing to clean up afterward. It starts right away + when an agent slot is free, and stays queued until one is otherwise.
diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx index 1c3c314acf..ceb1a582bf 100644 --- a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx +++ b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx @@ -123,6 +123,30 @@ describe('RelaunchAgentModal', () => { expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument(); }); + it('says the task is running when the server started it, not that it is queued', async () => { + const user = userEvent.setup(); + api.relaunchCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true }); + renderModal(); + + await user.click(screen.getByRole('button', { name: 'Relaunch Agent' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i))); + expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/queued/i)); + }); + + it('names why a relaunched task stayed queued instead of leaving the user to hunt for it', async () => { + const user = userEvent.setup(); + api.relaunchCosAgent.mockResolvedValue({ + success: true, taskId: 'task-abc', mode: 'requeued', + spawned: false, spawnHold: 'No available agent slots (3/3)', + }); + renderModal(); + + await user.click(screen.getByRole('button', { name: 'Relaunch Agent' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/))); + }); + it('keeps the dialog open and surfaces the error when the relaunch fails', async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/client/src/lib/README.md b/client/src/lib/README.md index bbea8b4bce..d7e1cd4fe3 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -99,6 +99,7 @@ grep -i "what you want to do" client/src/lib/README.md | Module | Purpose | |---|---| | `a11yKeyboard.js` | `clickableProps(handler, { role?, disabled? })` → `{ role, tabIndex, onKeyDown }` and `onActivateKeyDown(handler)` — make a non-`