Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions client/src/components/cos/tabs/AgentsTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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();
};
Expand Down
46 changes: 46 additions & 0 deletions client/src/components/cos/tabs/AgentsTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 20 additions & 9 deletions client/src/components/cos/tabs/RelaunchAgentModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -120,8 +130,9 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC
expandedClassName="max-h-48 overflow-y-auto whitespace-pre-wrap"
/>
<div className="text-sm text-gray-400 mt-2">
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.
</div>
</div>

Expand Down
24 changes: 24 additions & 0 deletions client/src/components/cos/tabs/RelaunchAgentModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-`<button>` element with an `onClick` keyboard-accessible (Enter/Space activation, focusable, `role="button"`). Spread `clickableProps` next to the existing `onClick`. Use instead of hand-rolling `role`/`tabIndex`/`onKeyDown` on clickable `<div>`/`<span>`/`<li>`. Also the shared predicate every app-global key handler consults: `shouldIgnoreGlobalKey(event, { enabledInDialog?, ignoreRepeat?, allowChords? })` — the single "must this handler stand down?" answer (editable target, native Space button activation, ⌘/Ctrl/Alt chord, OS auto-repeat, open `aria-modal` dialog), used by `useKeyboardShortcuts`, `useKeyCapture` AND the voice widget's push-to-talk hotkey so their guard lists can't drift. Add a new guard there, not at one caller. Its parts are exported too: `isEditableTarget(el)`, `isButtonActivation(event)` (true when Space would natively activate the focused button) and `isPressKey(event)` (Enter or Space by either `key` or `code`). Plus `noPointerFocusSurfaceProps` — spread on the ROOT of a surface that owns a key globally (a Space-scored drill, the Morse keyer, RapidReader, the OpenWorld HUD); one capture-phase handler stops a mouse click anywhere inside from parking focus on a button and taking that key over via native activation, covering controls added later too. |
| `agentResumeOutcome.js` | `agentResumeMessage(result, messages, fallback)` — the toast wording for a CoS agent resume/relaunch result. The server answers with a `mode` (what it did to the task) plus `spawned`/`spawnHold` (whether it also STARTED it — a resumed task is force-spawned when a slot is free, and `spawnHold` names the refusal when it isn't). `messages` is keyed by mode as `{ queued, running? }`; a mode with no `running` variant is one that deliberately queues nothing, so it can never be reported as started. Shared by the Resume dialog (`cos/tabs/AgentsTab.jsx`) and `cos/tabs/RelaunchAgentModal.jsx` so neither can say "queued" for a run that already started, or omit the reason it didn't. |
| `appIdentity.js` | `PORTOS_APP_ID` — stable id of the baseline PortOS app (mirrors `server/lib/appIdentity.js`). Dependency-free so a module reachable from a node-env server test (e.g. `components/apps/constants.js`) can identify PortOS without importing `services/apiCore.js`, which pulls in React via `ui/Toast`. `apiCore.js` re-exports it, so `import { PORTOS_APP_ID } from '../services/api'` still works. |
| `applyManuscriptEdits.js` | `applyEditsToContent(content, edits, anchorQuote)` — PREVIEW-ONLY client mirror of the server's accept splice (`server/services/pipeline/manuscriptFix.js`): locate each `find` (nearest the anchor when recurring), drop overlaps, replace bottom-up. Powers the Manuscript editor's whole-manuscript impact preview. |
| `assessmentTuningNotice.js` | `tuningNoticeChip(entry)` — the short form of how a measured local-model assessment says its launch configuration did NOT take effect. `tuningApplied === false` covers two opposite cases and `tuningKey` separates them: a TUNED run whose knobs never reached the daemon (`tuning not applied`), versus an UNTUNED run PortOS could not put back on backend defaults (`not at defaults`) — #4759. Shared by `ModelThroughputReport.jsx`'s table cell and `LocalModelAssessments.jsx`'s result toast; the long form is the server's exclusion reason in `getAssessmentReport`. |
Expand Down
25 changes: 25 additions & 0 deletions client/src/lib/agentResumeOutcome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Toast wording for a resume/relaunch result, shared by the two dialogs that
* dispatch one — Resume (`cos/tabs/AgentsTab.jsx`) and Relaunch
* (`cos/tabs/RelaunchAgentModal.jsx`).
*
* The server (`resumeAgent` in `server/services/agentManagement.js`) answers with a
* `mode` naming what it did to the task, plus `spawned` / `spawnHold` saying
* whether it also STARTED it: a resumed task is force-spawned when an agent slot is
* free, so `requeued` usually means running, not waiting. Two ways to get that
* wrong, and both surfaces can get them wrong identically — hence one helper:
* saying "queued" for a run that already started reads as the click not having
* taken, and saying "queued" with no reason sends the user hunting the task list
* for why it didn't start.
*
* `messages` is keyed by mode, each entry `{ queued, running? }`. A mode with no
* `running` variant is one that deliberately queues NOTHING (`already-active`,
* `superseded`), so it can never be reported as started. `fallback` covers a mode
* this build has no wording for — an unmapped mode must never fall through to a
* message claiming work was queued.
*/
export function agentResumeMessage(result, messages, fallback) {
const entry = messages[result?.mode];
const base = (result?.spawned && entry?.running) || entry?.queued || fallback;
return result?.spawnHold ? `${base} — ${result.spawnHold}` : base;
}
1 change: 1 addition & 0 deletions client/src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export * from './goalFeatureMap.js';

// === Generic UI / collection utilities ===
export * from './a11yKeyboard.js';
export * from './agentResumeOutcome.js';
export * from './appIdentity.js';
export * from './applyManuscriptEdits.js';
export * from './assessmentTuningNotice.js';
Expand Down
Loading