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
6 changes: 4 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2651,8 +2651,10 @@
"listingSeparate": "Publishing a listing is managed separately from Settings.",
"wakeTitle": "Wake",
"mentionsAlwaysWake": "Mentions always wake this agent.",
"wakeOnMessage": "Wake on messages",
"humanOnly": "Human messages only",
"messageWake": "Messages that wake it",
"messageWake_mentions": "Mentions only",
"messageWake_human": "Messages from people",
"messageWake_all": "Every message",
"boardWake": "Wake on board changes",
"heartbeat": "Heartbeat",
"heartbeatInterval": "Interval",
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2642,8 +2642,10 @@
"listingSeparate": "发布市场条目需在设置页之外完成。",
"wakeTitle": "唤醒",
"mentionsAlwaysWake": "提及始终会唤醒此智能体。",
"wakeOnMessage": "收到消息时唤醒",
"humanOnly": "仅限真人消息",
"messageWake": "哪些消息会唤醒它",
"messageWake_mentions": "仅 @提及",
"messageWake_human": "来自真人的消息",
"messageWake_all": "所有消息",
"boardWake": "看板变更时唤醒",
"heartbeat": "心跳间隔",
"heartbeatInterval": "间隔",
Expand Down
120 changes: 120 additions & 0 deletions frontend/src/v2/__tests__/V2AgentWakePods.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,126 @@ describe('per-agent Wake and Pods settings', () => {
));
});

it('shows all three message-wake choices without first opting in (D173)', async () => {
client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{
podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false,
boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60,
}] } });
renderSettings();
expect(await screen.findByRole('group', { name: 'Messages that wake it' })).toBeInTheDocument();
expect(screen.getByLabelText('Mentions only')).toBeChecked();
expect(screen.getByLabelText('Every message')).not.toBeChecked();
fireEvent.click(screen.getByLabelText('Messages from people'));
await waitFor(() => expect(updatePod).toHaveBeenCalledWith(
'pod-1',
{ wakeOnMessage: true, humanOnly: true, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 },
));
expect(screen.getByLabelText('Messages from people')).toBeChecked();
});

it('marks the saved human-only setting and clears it on mentions only', async () => {
renderSettings();
expect(await screen.findByLabelText('Messages from people')).toBeChecked();
fireEvent.click(screen.getByLabelText('Mentions only'));
await waitFor(() => expect(updatePod).toHaveBeenCalledWith(
'pod-1',
{ wakeOnMessage: false, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 },
));
});

it('loads and saves the every-message choice', async () => {
client.get.mockResolvedValue({ data: { turnCap: 30, activePodCount: 1, podCount: 1, installations: [{
podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: true, humanOnly: false,
boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60,
}] } });
renderSettings();
expect(await screen.findByLabelText('Every message')).toBeChecked();
fireEvent.click(screen.getByLabelText('Mentions only'));
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByLabelText('Every message'));
await waitFor(() => expect(updatePod).toHaveBeenLastCalledWith(
'pod-1',
{ wakeOnMessage: true, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 },
));
});

const mentionsOnlyInstall = () => client.get.mockResolvedValue({ data: {
turnCap: 30, activePodCount: 1, podCount: 1, installations: [{
podId: 'pod-1', podName: 'Launch', status: 'active', wakeOnMessage: false, humanOnly: false,
boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60,
}],
} });
const EVERY = { wakeOnMessage: true, humanOnly: false, boardWake: false, heartbeatEnabled: false, heartbeatMinutes: 60 };

it('keeps the radios usable while a pick saves, so a second arrow key reaches the third choice', async () => {
mentionsOnlyInstall();
let settleFirst: (value?: unknown) => void = () => {};
updatePod.mockImplementationOnce(() => new Promise((resolve) => { settleFirst = resolve; }));
renderSettings();
const human = await screen.findByLabelText('Messages from people');
// jsdom has no native arrow-key radio movement: an ArrowDown is a focus
// move plus a change on the next radio.
act(() => { human.focus(); });
fireEvent.click(human);
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1));
expect(human).toBeChecked();
expect(human).not.toBeDisabled();
expect(document.activeElement).toBe(human);

const every = screen.getByLabelText('Every message');
expect(every).not.toBeDisabled();
act(() => { every.focus(); });
fireEvent.click(every);
expect(every).toBeChecked();
expect(document.activeElement).toBe(every);
// The second pick waits for the first, so the server sees them in order.
expect(updatePod).toHaveBeenCalledTimes(1);
await act(async () => { settleFirst({}); });
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(2));
expect(updatePod).toHaveBeenLastCalledWith('pod-1', EVERY);
expect(every).toBeChecked();
});

it('a failed save rolls back to what the server last accepted, not to an unsaved pick', async () => {
mentionsOnlyInstall();
let failFirst: (error: unknown) => void = () => {};
updatePod.mockImplementationOnce(() => new Promise((_, reject) => { failFirst = reject; }));
renderSettings();
fireEvent.click(await screen.findByLabelText('Messages from people'));
fireEvent.click(screen.getByLabelText('Every message'));
// The second pick shows while the first save is pending (a disabled
// fieldset would have swallowed it), so the rollback has something to undo.
expect(screen.getByLabelText('Every message')).toBeChecked();
await act(async () => { failFirst(new Error('down')); });
await waitFor(() => expect(screen.getByLabelText('Mentions only')).toBeChecked());
// The queued pick is dropped with the failure; it never reached the server.
expect(updatePod).toHaveBeenCalledTimes(1);
});

it('a save still in flight for the previous agent does not let the next agent\'s saves overlap', async () => {
mentionsOnlyInstall();
const settles: Array<(value?: unknown) => void> = [];
updatePod.mockImplementation(() => new Promise((resolve) => { settles.push(resolve); }));
const view = renderSettings();
fireEvent.click(await screen.findByLabelText('Messages from people'));
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(1));

view.rerender(
<MemoryRouter><V2AgentWakePods agentName="test-agent" instanceId="other-machine" updatePod={updatePod} /></MemoryRouter>,
);
fireEvent.click(await screen.findByLabelText('Messages from people'));
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(2));

// The old agent's save settles while the new agent's first save is still out.
await act(async () => { settles[0]({}); });
fireEvent.click(screen.getByLabelText('Every message'));
// The new pick queues behind the new agent's pending save instead of racing it.
expect(updatePod).toHaveBeenCalledTimes(2);
await act(async () => { settles[1]({}); });
await waitFor(() => expect(updatePod).toHaveBeenCalledTimes(3));
expect(updatePod).toHaveBeenLastCalledWith('pod-1', EVERY);
});

it('removes a pod installation and leaves the settings view open', async () => {
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true);
renderSettings();
Expand Down
101 changes: 77 additions & 24 deletions frontend/src/v2/agents/V2AgentWakePods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ interface InstallationSettings {
heartbeatMinutes: number;
}

// Message wake is one choice of three, shown together: the human-only
// filter used to appear only after "Wake on messages" was ticked (D173).
const MESSAGE_WAKE_CHOICES = ['mentions', 'human', 'all'] as const;
type MessageWakeChoice = typeof MESSAGE_WAKE_CHOICES[number];
const MESSAGE_WAKE_SETTINGS: Record<MessageWakeChoice, Pick<InstallationSettings, 'wakeOnMessage' | 'humanOnly'>> = {
mentions: { wakeOnMessage: false, humanOnly: false },
human: { wakeOnMessage: true, humanOnly: true },
all: { wakeOnMessage: true, humanOnly: false },
};
const messageWakeChoice = (entry: InstallationSettings): MessageWakeChoice => {
if (!entry.wakeOnMessage) return 'mentions';
return entry.humanOnly ? 'human' : 'all';
};

const V2AgentWakePods: React.FC<{
agentName: string;
instanceId: string;
Expand All @@ -37,6 +51,10 @@ const V2AgentWakePods: React.FC<{
const identity = `${agentName}:${instanceId}`;
const identityRef = useRef(identity);
identityRef.current = identity;
// Per-pod save state; see save() below.
const committedRef = useRef<Record<string, InstallationSettings>>({});
const queuedRef = useRef<Record<string, InstallationSettings | undefined>>({});
const sendingRef = useRef<Record<string, boolean>>({});

useEffect(() => {
let active = true;
Expand All @@ -45,6 +63,9 @@ const V2AgentWakePods: React.FC<{
setBusyPod(null);
setError(null);
setDraftMinutes({});
committedRef.current = {};
queuedRef.current = {};
sendingRef.current = {};
const load = async () => {
try {
const settingsResult = await axios.get<{
Expand All @@ -53,6 +74,7 @@ const V2AgentWakePods: React.FC<{
if (!active) return;
const rows = Array.isArray(settingsResult.data.installations) ? settingsResult.data.installations : [];
setInstallations(rows);
committedRef.current = Object.fromEntries(rows.map((row) => [row.podId, row]));
setActivePodCount(settingsResult.data.activePodCount ?? rows.filter((row) => row.status === 'active').length);
setPodCount(settingsResult.data.podCount ?? rows.length);
setDraftMinutes(Object.fromEntries(rows.map((row) => [row.podId, row.heartbeatMinutes])));
Expand All @@ -71,26 +93,56 @@ const V2AgentWakePods: React.FC<{
return () => { active = false; };
}, [base, query, identity, t]);

// Saves per pod go out one at a time, newest pick last. The message-wake
// radios stay enabled while a save is in flight (disabling them dropped
// keyboard focus to the page), so a second pick queues behind the first
// rather than racing it to the server. A failure rolls the row back to the
// last settings the server accepted, never to an unsaved pick.

const save = async (entry: InstallationSettings, changes: Partial<InstallationSettings>) => {
const requestedIdentity = identity;
setBusyPod(entry.podId);
const { podId } = entry;
const next = { ...entry, ...changes };
// An agent switch replaces these objects. Hold the ones this save started
// with, so a loop still in flight for the old agent never writes into the
// new agent's queue.
const committed = committedRef.current;
const queued = queuedRef.current;
const sending = sendingRef.current;
if (!committed[podId]) committed[podId] = entry;
queued[podId] = next;
setBusyPod(podId);
setError(null);
// Show the pick at once, so the next arrow key moves on from it.
setInstallations((current) => current.map((item) => item.podId === podId ? next : item));
if (sending[podId]) return;
sending[podId] = true;
try {
const next = { ...entry, ...changes };
await updatePod(entry.podId, {
wakeOnMessage: next.wakeOnMessage,
boardWake: next.boardWake,
humanOnly: next.humanOnly,
heartbeatEnabled: next.heartbeatEnabled,
heartbeatMinutes: next.heartbeatMinutes,
});
if (identityRef.current === requestedIdentity) {
setInstallations((current) => current.map((item) => item.podId === entry.podId ? next : item));
while (queued[podId]) {
const target = queued[podId] as InstallationSettings;
queued[podId] = undefined;
try {
// eslint-disable-next-line no-await-in-loop
await updatePod(podId, {
wakeOnMessage: target.wakeOnMessage,
boardWake: target.boardWake,
humanOnly: target.humanOnly,
heartbeatEnabled: target.heartbeatEnabled,
heartbeatMinutes: target.heartbeatMinutes,
});
committed[podId] = target;
} catch (requestError) {
queued[podId] = undefined;
if (identityRef.current === requestedIdentity) {
const saved = committed[podId];
setInstallations((current) => current.map((item) => item.podId === podId ? saved : item));
setError((requestError as { response?: { status?: number } })?.response?.status === 403
? t('agentSettings.forbidden') : t('agentSettings.saveError'));
}
}
}
} catch (requestError) {
if (identityRef.current === requestedIdentity) setError((requestError as { response?: { status?: number } })?.response?.status === 403
? t('agentSettings.forbidden') : t('agentSettings.saveError'));
} finally {
sending[podId] = false;
if (identityRef.current === requestedIdentity) setBusyPod(null);
}
};
Expand Down Expand Up @@ -136,16 +188,17 @@ const V2AgentWakePods: React.FC<{
{installations.map((entry) => (
<div className="v2-agent-wake-pods__pod" key={entry.podId}>
<Link to={`/v2/pods/${entry.podId}`}>{entry.podName}</Link>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.wakeOnMessage')}</span>
<input type="checkbox" checked={entry.wakeOnMessage} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { wakeOnMessage: event.target.checked }); }} />
</label>
{entry.wakeOnMessage && <label className="v2-agent-wake-pods__row v2-agent-wake-pods__subrow">
<span>{t('agentSettings.humanOnly')}</span>
<input type="checkbox" checked={entry.humanOnly} disabled={busyPod === entry.podId}
onChange={(event) => { void save(entry, { humanOnly: event.target.checked }); }} />
</label>}
<fieldset className="v2-agent-wake-pods__choice" aria-busy={busyPod === entry.podId}>
<legend>{t('agentSettings.messageWake')}</legend>
{MESSAGE_WAKE_CHOICES.map((choice) => (
<label className="v2-agent-wake-pods__row v2-agent-wake-pods__subrow" key={choice}>
<span>{t(`agentSettings.messageWake_${choice}`)}</span>
<input type="radio" name={`message-wake-${entry.podId}`} value={choice}
checked={messageWakeChoice(entry) === choice}
onChange={() => { void save(entry, MESSAGE_WAKE_SETTINGS[choice]); }} />
</label>
))}
</fieldset>
<label className="v2-agent-wake-pods__row">
<span>{t('agentSettings.boardWake')}</span>
<input type="checkbox" checked={entry.boardWake} disabled={busyPod === entry.podId}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/agents/v2-agent-wake-pods.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading