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-`