diff --git a/ui/src/components/workbench/AgentsPage.test.tsx b/ui/src/components/workbench/AgentsPage.test.tsx index 93c6fc5c96..f832ac5d34 100644 --- a/ui/src/components/workbench/AgentsPage.test.tsx +++ b/ui/src/components/workbench/AgentsPage.test.tsx @@ -1,73 +1,122 @@ /* @vitest-environment jsdom */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import { InstanceAuthorizationContext } from '../../context/InstanceAuthorizationContext'; +import type { VibeAgentBrief, WorkbenchEventHandlers } from '../../context/ApiContext'; import type { InstanceCapabilities, InstanceRole } from '../../lib/sessionInfo'; import { OWNER_INSTANCE_CAPABILITIES } from '../../lib/sessionInfo'; import { AgentsPage } from './AgentsPage'; -const api = vi.hoisted(() => ({ - listVibeAgents: vi.fn(), - getVibeAgent: vi.fn(), - getVibeAgentOnboarding: vi.fn(), - getRunningAgents: vi.fn(), - connectWorkbenchEvents: vi.fn(), -})); +type FakeApi = { + listVibeAgents: ReturnType; + getVibeAgent: ReturnType; + getVibeAgentOnboarding: ReturnType; + onboardVibeAgents: ReturnType; + updateVibeAgent: ReturnType; + removeVibeAgent: ReturnType; + getRunningAgents: ReturnType; + connectWorkbenchEvents: ReturnType; +}; -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); +const apiRef = vi.hoisted(() => ({ current: null as FakeApi | null })); +const showToast = vi.hoisted(() => vi.fn()); +let handlers: WorkbenchEventHandlers | null = null; -vi.mock('../../context/ApiContext', async (importOriginal) => ({ - ...(await importOriginal()), - useApi: () => api, -})); +vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} +}); -vi.mock('../../context/ToastContext', () => ({ - useToast: () => ({ showToast: vi.fn() }), -})); +vi.mock('../../context/ApiContext', async () => { + const actual = await vi.importActual('../../context/ApiContext'); + return { ...actual, useApi: () => apiRef.current }; +}); -// The detail panel's model catalog is an HTTP read of its own; serve it locally -// so this file is about which requests the page load issues, not about the -// catalog's contents. -vi.mock('../../lib/backendModels', async (importOriginal) => ({ - ...(await importOriginal()), - loadBackendModelsWithRefresh: ( - _api: unknown, - _backend: string, - onLoaded: (payload: { models: string[] }) => void, - ) => { +vi.mock('../../context/ToastContext', () => ({ useToast: () => ({ showToast }) })); +vi.mock('./CapabilityTabs', () => ({ CapabilityTabs: () => null })); +vi.mock('../../lib/backendModels', async () => ({ + loadBackendModelsWithRefresh: (_api: unknown, _backend: string, onLoaded: (payload: { models: string[] }) => void) => { onLoaded({ models: [] }); return () => {}; }, })); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + }), +})); -vi.mock('./AgentGraphTab', () => ({ AgentGraphTab: () => null })); -vi.mock('./NewAgentDialog', () => ({ NewAgentDialog: () => null })); -vi.mock('./RunAgentDialog', () => ({ RunAgentDialog: () => null })); -vi.mock('./GlobalPromptsDialog', () => ({ GlobalPromptsDialog: () => null })); - -const AGENT = { - id: 'agt-claude', - name: 'claude', - display_name: 'claude', - description: null, - backend: 'claude', - model: 'sonnet', +const brief = (name: string, description: string): VibeAgentBrief => ({ + id: `id-${name}`, + name, + display_name: name, + description, + backend: 'codex', + model: null, reasoning_effort: null, enabled: true, archived: false, archived_at: null, - source: 'builtin', - updated_at: '2026-08-19T00:00:00Z', + source: 'custom', + updated_at: '2026-08-21T00:00:00Z', +}); + +const listResult = (agents: VibeAgentBrief | VibeAgentBrief[]) => ({ + ok: true, + agents: Array.isArray(agents) ? agents : [agents], + default_agent_name: Array.isArray(agents) ? agents[0]?.name ?? null : agents.name, +}); + +const fullAgent = (briefAgent: VibeAgentBrief, systemPrompt: string) => ({ + ok: true, + default_agent_name: briefAgent.name, + agent: { + ...briefAgent, + model: briefAgent.model ?? 'gpt-5', + reasoning_effort: briefAgent.reasoning_effort ?? 'medium', + system_prompt: systemPrompt, + created_at: '2026-08-21T00:00:00Z', + metadata: {}, + }, +}); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; }; -// A remote Instance Member: the rank keeps Agent CRUD (`can_manage_agents`) but -// is not the Instance Owner. Bulk onboarding is Owner-only on the HTTP policy, -// so this is exactly the projection that used to 403 on page load. +function makeApi( + listVibeAgents: FakeApi['listVibeAgents'], + getVibeAgent: FakeApi['getVibeAgent'] = vi.fn().mockResolvedValue({ ok: false }), + getVibeAgentOnboarding: FakeApi['getVibeAgentOnboarding'] = vi.fn().mockResolvedValue({ available: false }), + onboardVibeAgents: FakeApi['onboardVibeAgents'] = vi.fn().mockResolvedValue({ available: false }), + updateVibeAgent: FakeApi['updateVibeAgent'] = vi.fn().mockResolvedValue({ ok: false }), + removeVibeAgent: FakeApi['removeVibeAgent'] = vi.fn().mockResolvedValue({ ok: true }), +): FakeApi { + return { + listVibeAgents, + getVibeAgent, + getVibeAgentOnboarding, + onboardVibeAgents, + updateVibeAgent, + removeVibeAgent, + getRunningAgents: vi.fn().mockResolvedValue({ ok: true, counts: { total: 2 } }), + connectWorkbenchEvents: vi.fn((next: WorkbenchEventHandlers) => { + handlers = next; + return vi.fn(); + }), + }; +} + const MEMBER_CAPABILITIES: InstanceCapabilities = { ...OWNER_INSTANCE_CAPABILITIES, is_instance_owner: false, @@ -75,10 +124,27 @@ const MEMBER_CAPABILITIES: InstanceCapabilities = { can_manage_access_members: false, }; -function renderPage(instanceRole: InstanceRole, capabilities: InstanceCapabilities) { +function renderPage( + api: FakeApi, + { + remote = false, + instanceKind = null, + instanceRole = 'owner' as InstanceRole, + canManageAgents = false, + capabilities, + }: { + remote?: boolean; + instanceKind?: 'organization' | 'personal' | null; + instanceRole?: InstanceRole; + canManageAgents?: boolean; + capabilities?: InstanceCapabilities; + } = {}, +) { + apiRef.current = api; + const effectiveCapabilities = capabilities ?? { ...OWNER_INSTANCE_CAPABILITIES, can_manage_agents: canManageAgents }; return render( @@ -87,36 +153,25 @@ function renderPage(instanceRole: InstanceRole, capabilities: InstanceCapabiliti ); } -// jsdom has no layout, so the capability tab strip's scroll-into-view is a -// no-op here; the page itself stays real. -const originalScrollIntoView = Element.prototype.scrollIntoView; - -beforeEach(() => { - Element.prototype.scrollIntoView = vi.fn(); - api.listVibeAgents.mockReset(); - api.listVibeAgents.mockResolvedValue({ ok: true, agents: [AGENT], default_agent_name: 'claude' }); - api.getVibeAgent.mockReset(); - api.getVibeAgent.mockResolvedValue({ ok: true, agent: AGENT }); - api.getVibeAgentOnboarding.mockReset(); - api.getVibeAgentOnboarding.mockResolvedValue({ available: false }); - api.getRunningAgents.mockReset(); - api.getRunningAgents.mockResolvedValue({ ok: true, counts: { total: 0 } }); - api.connectWorkbenchEvents.mockReset(); - api.connectWorkbenchEvents.mockReturnValue(() => {}); -}); - afterEach(() => { cleanup(); - vi.clearAllMocks(); - Element.prototype.scrollIntoView = originalScrollIntoView; + vi.restoreAllMocks(); + apiRef.current = null; + handlers = null; + showToast.mockReset(); }); describe('AgentsPage load requests follow the rank that can serve them', () => { it('does not request the Owner-only onboarding inventory for a member', async () => { - const view = renderPage('member', MEMBER_CAPABILITIES); + const listVibeAgents = vi.fn().mockResolvedValue(listResult(brief('claude', ''))); + const api = makeApi(listVibeAgents); + const view = renderPage(api, { + remote: true, + instanceKind: 'organization', + instanceRole: 'member', + capabilities: MEMBER_CAPABILITIES, + }); - // Wait on a request the member IS entitled to, so the assertion below runs - // after the page load has actually settled rather than before it starts. await waitFor(() => expect(api.listVibeAgents).toHaveBeenCalled()); await waitFor(() => expect(api.getVibeAgent).toHaveBeenCalled()); expect(api.getVibeAgentOnboarding).not.toHaveBeenCalled(); @@ -124,9 +179,2163 @@ describe('AgentsPage load requests follow the rank that can serve them', () => { }); it('still requests the onboarding inventory for the instance owner', async () => { - const view = renderPage('owner', OWNER_INSTANCE_CAPABILITIES); + const listVibeAgents = vi.fn().mockResolvedValue(listResult(brief('claude', ''))); + const api = makeApi(listVibeAgents); + const view = renderPage(api, { + remote: true, + instanceKind: 'organization', + instanceRole: 'owner', + capabilities: OWNER_INSTANCE_CAPABILITIES, + }); await waitFor(() => expect(api.getVibeAgentOnboarding).toHaveBeenCalled()); view.unmount(); }); }); + +describe('AgentsPage reconnect reconciliation', () => { + it('refreshes definitions from the server on the gap edge without bridge-status duplication', async () => { + const stale = brief('stale-agent', 'before the gap'); + const fresh = brief('fresh-agent', 'changed during the gap'); + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(stale)) + .mockResolvedValueOnce(listResult(fresh)); + const api = makeApi(listVibeAgents); + renderPage(api); + + await waitFor(() => expect(screen.getByText('stale-agent')).toBeTruthy()); + expect(handlers).not.toBeNull(); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByText('fresh-agent')).toBeTruthy()); + + expect(listVibeAgents).toHaveBeenNthCalledWith(1, { includeDisabled: true, cache: false }); + expect(listVibeAgents).toHaveBeenNthCalledWith(2, { includeDisabled: true, cache: false }); + expect(api.getRunningAgents).toHaveBeenCalledTimes(2); + + act(() => { + handlers?.onEventBridgeStatus?.({ connected: false }); + handlers?.onEventBridgeStatus?.({ connected: true }); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(listVibeAgents).toHaveBeenCalledTimes(2); + }); + + it('does not retry a failed reconciliation debt until the next reconnect edge', async () => { + const agent = brief('agent-a', 'A'); + const update = vi.fn().mockResolvedValue(fullAgent(agent, 'ack')); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockRejectedValueOnce({ code: 'agent_backend_unavailable', message: 'temporary failure' }) + .mockResolvedValueOnce(fullAgent({ ...agent, description: 'after reconnect' }, 'after reconnect')); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, update); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A edited' } }); + fireEvent.blur(screen.getByDisplayValue('A edited')); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByText('temporary failure')).toBeTruthy()); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + await waitFor(() => expect(screen.getByDisplayValue('after reconnect')).toBeTruthy()); + }); + + it('does not create a follow-on reconciliation request after unmount', async () => { + const agent = brief('agent-a', 'A'); + const pending = deferred>(); + const update = vi.fn().mockResolvedValue(fullAgent(agent, 'ack')); + const getVibeAgent = vi.fn().mockResolvedValueOnce(fullAgent(agent, 'initial')).mockReturnValueOnce(pending.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, update); + const view = renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A edited' } }); + fireEvent.blur(screen.getByDisplayValue('A edited')); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + view.unmount(); + act(() => pending.reject({ code: 'agent_backend_unavailable', message: 'unmounted failure' })); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + }); + + it('keeps an explicit detail dismissal closed across reconnect list publication', async () => { + const agent = brief('agent-a', 'A'); + const listVibeAgents = vi.fn().mockResolvedValue(listResult(agent)); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockResolvedValueOnce(fullAgent(agent, 'explicit selection')); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'common.close' })); + expect(screen.queryByDisplayValue('A')).toBeNull(); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(2)); + expect(screen.queryByDisplayValue('A')).toBeNull(); + expect(getVibeAgent).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('agent-a').closest('button')!); + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + }); + + it('keeps an older pre-gap read from overwriting the post-gap definition snapshot', async () => { + const stale = brief('stale-agent', 'before the gap'); + const fresh = brief('fresh-agent', 'changed during the gap'); + let resolvePreGap!: (value: ReturnType) => void; + const preGap = new Promise>((resolve) => { + resolvePreGap = resolve; + }); + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(stale)) + .mockReturnValueOnce(preGap) + .mockResolvedValueOnce(listResult(fresh)); + const api = makeApi(listVibeAgents); + renderPage(api); + + await waitFor(() => expect(screen.getByText('stale-agent')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'common.refresh' })); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByText('fresh-agent')).toBeTruthy()); + + resolvePreGap(listResult(stale)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText('fresh-agent')).toBeTruthy(); + expect(screen.queryByText('stale-agent')).toBeNull(); + expect(listVibeAgents).toHaveBeenNthCalledWith(3, { includeDisabled: true, cache: false }); + expect(api.getRunningAgents).toHaveBeenCalledTimes(2); + }); + + it('refreshes the selected full definition and keeps one stable subscription', async () => { + const first = brief('agent-a', 'before gap'); + const second = brief('agent-b', 'another agent'); + const changed = { ...first, description: 'after gap' }; + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult([first, second])) + .mockResolvedValueOnce(listResult([changed, second])); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(first, 'before prompt')) + .mockResolvedValueOnce(fullAgent(changed, 'after prompt')) + .mockResolvedValueOnce(fullAgent(second, 'second prompt')); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('before gap')).toBeTruthy()); + expect(api.connectWorkbenchEvents).toHaveBeenCalledTimes(1); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('after gap')).toBeTruthy()); + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + expect(screen.getByDisplayValue('after prompt')).toBeTruthy(); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(api.getRunningAgents).toHaveBeenCalledTimes(2); + + const secondRow = screen.getByText('agent-b').closest('button'); + expect(secondRow).not.toBeNull(); + fireEvent.click(secondRow!); + await waitFor(() => expect(screen.getByDisplayValue('another agent')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(api.connectWorkbenchEvents).toHaveBeenCalledTimes(1); + }); + + it('reissues the pending B selection at reconnect and suppresses the pre-gap B response', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const oldB = deferred>(); + const freshB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(oldB.promise) + .mockReturnValueOnce(freshB.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + + act(() => oldB.resolve(fullAgent({ ...agentB, description: 'old B' }, 'old B response'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A')).toBeTruthy(); + expect(screen.queryByDisplayValue('old B')).toBeNull(); + + act(() => freshB.resolve(fullAgent(agentB, 'B selected'))); + await waitFor(() => expect(screen.getByDisplayValue('B')).toBeTruthy()); + expect(api.connectWorkbenchEvents).toHaveBeenCalledTimes(1); + }); + + it('does not reuse an invalidated A debt stage after B fails', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const patch = deferred(); + const oldDebt = deferred>(); + const pendingB = deferred>(); + const freshDebt = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(oldDebt.promise) + .mockReturnValueOnce(pendingB.promise) + .mockReturnValueOnce(freshDebt.promise); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A edited' } }); + fireEvent.blur(screen.getByDisplayValue('A edited')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + act(() => patch.resolve({ ok: true })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => pendingB.reject({ code: 'agent_backend_unavailable', message: 'B unavailable' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(4)); + + act(() => freshDebt.resolve(fullAgent({ ...agentA, description: 'A after B' }, 'fresh A'))); + await waitFor(() => expect(screen.getByDisplayValue('A after B')).toBeTruthy()); + act(() => oldDebt.resolve(fullAgent(agentA, 'stale A'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A after B')).toBeTruthy(); + expect(getVibeAgent).toHaveBeenCalledTimes(4); + }); + + it.each(['agent_not_found', 'agent_access_forbidden'] as const)( + 'tombstones a stale row on direct selection without a repeat read (%s)', + async (code) => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockRejectedValueOnce({ code, message: 'B disappeared' }); + const listVibeAgents = vi.fn().mockResolvedValue(listResult([agentA, agentB])); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(screen.queryByText('agent-b')).toBeNull()); + expect(screen.getByDisplayValue('A')).toBeTruthy(); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + expect(listVibeAgents).toHaveBeenCalledTimes(2); + expect(showToast).not.toHaveBeenCalled(); + }, + ); + + it('orders a list-first gap retirement before the same-edge accepted-A read', async () => { + const agentA = brief('agent-a', 'A before gap'); + const agentB = brief('agent-b', 'B pending'); + const staleB = deferred>(); + const catchupA = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(staleB.promise) + .mockReturnValueOnce(catchupA.promise); + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult([agentA, agentB])) + .mockResolvedValueOnce(listResult(agentA)); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A before gap')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(listVibeAgents).toHaveBeenCalledTimes(2); + + act(() => catchupA.resolve(fullAgent({ ...agentA, description: 'A after gap' }, 'A changed prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('A after gap')).toBeTruthy()); + act(() => staleB.resolve(fullAgent(agentB, 'stale B'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A after gap')).toBeTruthy(); + expect(getVibeAgent.mock.calls.slice(3).some(([name]) => name === 'agent-b')).toBe(false); + }); + + it.each([ + { label: 'rejected read', response: 'reject' as const }, + { label: 'HTTP ok false', response: 'okfalse' as const }, + ])('continues to accepted A after an unexpected B catch-up failure ($label)', async ({ response }) => { + const agentA = brief('agent-a', 'A before gap'); + const agentB = brief('agent-b', 'B pending'); + const staleB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(staleB.promise) + .mockImplementationOnce(() => response === 'reject' + ? Promise.reject({ code: 'agent_backend_unavailable', message: 'B catch-up failed' }) + : Promise.resolve({ ok: false, message: 'B catch-up failed' })) + .mockResolvedValueOnce(fullAgent({ ...agentA, description: 'A after failed B' }, 'A recovered')); + const listVibeAgents = vi.fn().mockResolvedValue(listResult([agentA, agentB])); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A before gap')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('A after failed B')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent).toHaveBeenNthCalledWith(4, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(listVibeAgents).toHaveBeenCalledTimes(2); + expect(screen.getByText('B catch-up failed')).toBeTruthy(); + act(() => staleB.resolve(fullAgent(agentB, 'stale B'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A after failed B')).toBeTruthy(); + expect(getVibeAgent.mock.calls.slice(4).some(([name]) => name === 'agent-b')).toBe(false); + }); + + it('carries a row-tap drill-down intent through a reconnect replacement read', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const staleB = deferred>(); + const freshB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(staleB.promise) + .mockReturnValueOnce(freshB.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => freshB.resolve(fullAgent({ ...agentB, description: 'B after reconnect' }, 'B after reconnect'))); + await waitFor(() => expect(screen.getByDisplayValue('B after reconnect')).toBeTruthy()); + const detail = screen.getByDisplayValue('B after reconnect').closest('.self-start'); + expect(detail?.className).not.toContain('max-lg:hidden'); + act(() => staleB.resolve(fullAgent(agentB, 'stale B'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('B after reconnect')).toBeTruthy(); + }); + + it('joins the live debt producer when selecting the current Agent again', async () => { + const agent = brief('agent-a', 'description'); + const debtRead = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(debtRead.promise); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'saved prompt' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + const row = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(row).not.toBeNull(); + fireEvent.click(row!); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + act(() => debtRead.resolve(fullAgent({ ...agent, description: 'after debt' }, 'saved prompt'))); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(api.listVibeAgents).toHaveBeenCalledTimes(2)); + expect(screen.getByDisplayValue('after debt')).toBeTruthy(); + expect(updateVibeAgent).toHaveBeenCalledTimes(1); + }); + + it('joins a pending reconnect producer when selecting the current Agent again', async () => { + const agent = brief('agent-a', 'before'); + const reconnectRead = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(reconnectRead.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + const row = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(row).not.toBeNull(); + fireEvent.click(row!); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + act(() => reconnectRead.resolve(fullAgent({ ...agent, description: 'after reconnect' }, 'fresh prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('after reconnect')).toBeTruthy()); + const detail = screen.getByDisplayValue('after reconnect').closest('.self-start'); + expect(detail?.className).not.toContain('max-lg:hidden'); + }); + + it('does not let a lower-floor selection read satisfy later mutation debt', async () => { + const agent = brief('agent-a', 'before'); + const selectionRead = deferred>(); + const debtRead = deferred>(); + const patch = deferred(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(selectionRead.promise) + .mockReturnValueOnce(debtRead.promise); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + const row = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(row).not.toBeNull(); + fireEvent.click(row!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + const description = screen.getByDisplayValue('before'); + fireEvent.change(description, { target: { value: 'local mutation' } }); + fireEvent.blur(description); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { description: 'local mutation' })); + act(() => patch.resolve({ ok: true })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + + act(() => selectionRead.resolve(fullAgent({ ...agent, description: 'stale selection' }, 'stale prompt'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('local mutation')).toBeTruthy(); + + act(() => debtRead.resolve(fullAgent({ ...agent, description: 'authoritative mutation' }, 'fresh prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('authoritative mutation')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(3); + }); + + it.each([ + { label: 'rejected debt read', rejected: true }, + { label: 'ok-false debt read', rejected: false }, + ])('settles every same-agent consumer when the joined stage has a $label', async ({ rejected }) => { + const agent = brief('agent-a', 'description'); + const debtRead = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(debtRead.promise); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'joined prompt' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + const row = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(row).not.toBeNull(); + fireEvent.click(row!); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + if (rejected) { + act(() => debtRead.reject({ message: 'joined debt failed' })); + } else { + act(() => debtRead.resolve({ ok: false, message: 'joined debt failed' } as never)); + } + await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy()); + expect(screen.getByText('joined debt failed')).toBeTruthy(); + expect(updateVibeAgent).toHaveBeenCalledTimes(1); + }); + + it('lets a successful PATCH win over a reconnect GET that settles later', async () => { + const initial = { ...brief('agent-a', 'before patch'), model: 'old-model' }; + const server = { ...initial, description: 'server description', model: 'server-model' }; + const patchResult = fullAgent({ ...initial, description: 'local patch', model: 'patched-model' }, 'patched prompt'); + const staleRead = deferred>(); + const drainRead = deferred>(); + const patch = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'before prompt')) + .mockReturnValueOnce(staleRead.promise) + .mockReturnValueOnce(drainRead.promise); + const listVibeAgents = vi.fn().mockResolvedValue(listResult(server)); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi(listVibeAgents, getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before patch')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + const description = screen.getByDisplayValue('before patch'); + fireEvent.change(description, { target: { value: 'local patch' } }); + fireEvent.blur(description); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { description: 'local patch' })); + act(() => patch.resolve(patchResult)); + await waitFor(() => expect(screen.getByDisplayValue('local patch')).toBeTruthy()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('old-model'); + + act(() => staleRead.resolve(fullAgent(initial, 'stale reconnect prompt'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('local patch')).toBeTruthy(); + expect(screen.queryByDisplayValue('server description')).toBeNull(); + + act(() => drainRead.resolve(fullAgent(server, 'authoritative prompt'))); + await waitFor(() => expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('server-model')); + expect(screen.getByDisplayValue('server description')).toBeTruthy(); + }); + + it('uses operation epochs to suppress an old A -> B -> A selection response', async () => { + const agentA = brief('agent-a', 'A before'); + const agentB = brief('agent-b', 'B'); + const oldA = deferred>(); + const readB = deferred>(); + const readAAgain = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(oldA.promise) + .mockReturnValueOnce(readB.promise) + .mockReturnValueOnce(readAAgain.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A before')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + const agentARow = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(agentARow).not.toBeNull(); + fireEvent.click(agentARow!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(4)); + + act(() => oldA.resolve(fullAgent(agentA, 'old A response'))); + act(() => readB.resolve(fullAgent(agentB, 'B response'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A before')).toBeTruthy(); + + act(() => readAAgain.resolve(fullAgent({ ...agentA, description: 'A after ABA' }, 'new A response'))); + await waitFor(() => expect(screen.getByDisplayValue('A after ABA')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent).toHaveBeenNthCalledWith(4, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }); + + it.each([ + { label: 'A succeeds', failA: false }, + { label: 'A fails', failA: true }, + ])('isolates field settlement across A/B panel instances when $label', async ({ failA }) => { + const agentA = brief('agent-a', 'A initial'); + const agentB = brief('agent-b', 'B initial'); + const readB = deferred>(); + const drainB = deferred>(); + const patchA = deferred(); + const patchB = deferred(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A prompt')) + .mockReturnValueOnce(readB.promise) + .mockReturnValueOnce(drainB.promise); + const updateVibeAgent = vi.fn() + .mockReturnValueOnce(patchA.promise) + .mockReturnValueOnce(patchB.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A initial')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A initial'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { description: 'A local' })); + + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => readB.resolve(fullAgent(agentB, 'B prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('B initial')).toBeTruthy()); + + fireEvent.change(screen.getByDisplayValue('B initial'), { target: { value: 'B local' } }); + fireEvent.blur(screen.getByDisplayValue('B local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-b', { description: 'B local' })); + + act(() => (failA ? patchA.reject({ message: 'A failed' }) : patchA.resolve({ ok: true }))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('B local')).toBeTruthy(); + + act(() => patchB.resolve({ ok: true })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => drainB.resolve(fullAgent({ ...agentB, description: 'B authoritative' }, 'B final prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('B authoritative')).toBeTruthy()); + }); + + it('keeps the current selection intent when DELETE fails', async () => { + const agent = brief('agent-a', 'A'); + const reconnect = deferred>(); + const removeVibeAgent = vi.fn().mockResolvedValue({ ok: false, message: 'delete rejected' }); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockReturnValueOnce(reconnect.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, undefined, removeVibeAgent); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + await waitFor(() => expect(removeVibeAgent).toHaveBeenCalledWith('agent-a')); + expect(screen.getByDisplayValue('A')).toBeTruthy(); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => reconnect.resolve(fullAgent({ ...agent, description: 'A refreshed' }, 'reconciled'))); + await waitFor(() => expect(screen.getByDisplayValue('A refreshed')).toBeTruthy()); + }); + + it('does not let successful DELETE cancel a newer pending selection', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const removeVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent, undefined, undefined, undefined, removeVibeAgent); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + await waitFor(() => expect(removeVibeAgent).toHaveBeenCalledWith('agent-a')); + act(() => pendingB.resolve(fullAgent(agentB, 'B selected'))); + await waitFor(() => expect(screen.getByDisplayValue('B')).toBeTruthy()); + expect(screen.queryByDisplayValue('A initial')).toBeNull(); + }); + + it('rolls a failed current selection back to the accepted Agent', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const failedB = deferred>(); + const reconnectA = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(failedB.promise) + .mockReturnValueOnce(reconnectA.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => failedB.reject({ code: 'agent_backend_unavailable', message: 'B unavailable' })); + await waitFor(() => expect(screen.getByText('B unavailable')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => reconnectA.resolve(fullAgent({ ...agentA, description: 'A after rollback' }, 'A reconciled'))); + await waitFor(() => expect(screen.getByDisplayValue('A after rollback')).toBeTruthy()); + }); + + it('treats concurrent PATCH responses as acknowledgements and drains once', async () => { + const initial = { ...brief('agent-a', 'base description'), model: 'base-model', enabled: true }; + const patchOne = deferred>(); + const patchTwo = deferred>(); + const drain = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'base prompt')) + .mockReturnValueOnce(drain.promise); + const updateVibeAgent = vi.fn() + .mockReturnValueOnce(patchOne.promise) + .mockReturnValueOnce(patchTwo.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('base description')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('base description'), { target: { value: 'local description' } }); + fireEvent.blur(screen.getByDisplayValue('local description')); + fireEvent.click(screen.getByRole('switch')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => patchTwo.resolve(fullAgent({ ...initial, description: 'wrong second', enabled: false }, 'wrong second prompt'))); + act(() => patchOne.resolve(fullAgent({ ...initial, description: 'wrong first', enabled: true }, 'wrong first prompt'))); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + expect(screen.queryByDisplayValue('wrong first prompt')).toBeNull(); + expect(screen.queryByDisplayValue('wrong second prompt')).toBeNull(); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + expect(screen.getByDisplayValue('base prompt')).toBeTruthy(); + + act(() => drain.resolve(fullAgent({ ...initial, description: 'final description', enabled: false }, 'final prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('local description')).toBeTruthy()); + await waitFor(() => expect(screen.getByDisplayValue('final prompt')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + expect(api.connectWorkbenchEvents).toHaveBeenCalledTimes(1); + }); + + it('lets the post-batch read win across an A -> B -> A selection race', async () => { + const agentA = { ...brief('agent-a', 'A'), model: 'base-model' }; + const agentB = brief('agent-b', 'B'); + const patch = deferred>(); + const oldB = deferred>(); + const oldA = deferred>(); + const drain = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(oldB.promise) + .mockReturnValueOnce(oldA.promise) + .mockReturnValueOnce(drain.promise); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'local A' } }); + fireEvent.blur(screen.getByDisplayValue('local A')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + const rowA = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(rowA).not.toBeNull(); + fireEvent.click(rowA!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + + act(() => patch.resolve(fullAgent({ ...agentA, description: 'wrong patch response' }, 'wrong patch prompt'))); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(4)); + act(() => oldB.resolve(fullAgent(agentB, 'old B'))); + act(() => oldA.resolve(fullAgent({ ...agentA, description: 'old A' }, 'old A'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByDisplayValue('old A')).toBeNull(); + + act(() => drain.resolve(fullAgent({ ...agentA, description: 'final A' }, 'final prompt'))); + await waitFor(() => expect(screen.getByText('agents.detail.systemPrompt').closest('button')).toBeTruthy()); + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + await waitFor(() => expect(screen.getByDisplayValue('final prompt')).toBeTruthy()); + expect(screen.getByDisplayValue('final A')).toBeTruthy(); + expect(getVibeAgent).toHaveBeenCalledTimes(4); + }); + + it('preserves dirty drafts while clean detail fields adopt a same-agent snapshot', async () => { + const initial = { ...brief('agent-a', 'clean description'), model: 'old-model' }; + const changed = { ...initial, description: 'server description', model: 'new-model' }; + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'clean prompt')) + .mockResolvedValueOnce(fullAgent(changed, 'server prompt')); + const api = makeApi( + vi.fn() + .mockResolvedValueOnce(listResult(initial)) + .mockResolvedValueOnce(listResult(changed)), + getVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('clean description')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('clean description'), { target: { value: 'dirty description' } }); + const promptToggle = screen.getByText('agents.detail.systemPrompt').closest('button'); + expect(promptToggle).not.toBeNull(); + fireEvent.click(promptToggle!); + const inlinePrompt = screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'); + fireEvent.change(inlinePrompt, { target: { value: 'dirty inline prompt' } }); + + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + const modalPrompt = screen.getAllByPlaceholderText('agents.create.systemPromptPlaceholder').at(-1)!; + fireEvent.change(modalPrompt, { target: { value: 'dirty modal prompt' } }); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('new-model')); + expect(screen.getByDisplayValue('dirty description')).toBeTruthy(); + expect(screen.getByDisplayValue('dirty inline prompt')).toBeTruthy(); + expect(screen.getByDisplayValue('dirty modal prompt')).toBeTruthy(); + expect(screen.getByText('agents.detail.systemPromptEditorHint')).toBeTruthy(); + }); + + it('canonicalizes trimmed description and inline prompt saves', async () => { + const initial = brief('agent-a', 'before'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'old prompt')) + .mockResolvedValueOnce(fullAgent({ ...initial, description: 'canonical description' }, 'old prompt')) + .mockResolvedValueOnce(fullAgent({ ...initial, description: 'canonical description' }, 'canonical prompt')) + .mockResolvedValueOnce(fullAgent({ ...initial, description: 'external description' }, 'external prompt')); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true, agent: fullAgent(initial, 'ack').agent }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + const description = screen.getByDisplayValue('before'); + fireEvent.change(description, { target: { value: ' canonical description ' } }); + fireEvent.blur(description); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { description: 'canonical description' })); + expect(screen.getByDisplayValue('canonical description')).toBeTruthy(); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + const inlinePrompt = screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'); + fireEvent.change(inlinePrompt, { target: { value: ' canonical prompt ' } }); + fireEvent.blur(inlinePrompt); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { system_prompt: 'canonical prompt' })); + expect(screen.getByDisplayValue('canonical prompt')).toBeTruthy(); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('external description')).toBeTruthy()); + expect(screen.getByDisplayValue('external description')).toBeTruthy(); + }); + + it.each([ + { field: 'name', initial: 'agent-a', draft: 'agent-a ', submitted: 'agent-a continued', patch: { name: 'agent-a continued' } }, + { field: 'description', initial: 'description', draft: 'description ', submitted: 'description continued', patch: { description: 'description continued' } }, + { field: 'systemPrompt', initial: 'system prompt', draft: 'system prompt ', submitted: 'system prompt continued', patch: { system_prompt: 'system prompt continued' } }, + ] as const)('preserves an active raw $field draft across same-agent reconciliation', async ({ field, initial, draft, submitted, patch }) => { + const initialAgent = brief('agent-a', field === 'description' ? initial : 'description'); + const changedAgent = { ...initialAgent, model: 'reconciled-model' }; + const finalAgent = { + ...changedAgent, + ...(field === 'name' ? { name: submitted, display_name: submitted } : {}), + ...(field === 'description' ? { description: submitted } : {}), + }; + let readCount = 0; + const getVibeAgent = vi.fn((requestedName: string) => { + readCount += 1; + if (readCount === 1) return Promise.resolve(fullAgent(initialAgent, field === 'systemPrompt' ? initial : 'prompt')); + if (readCount === 2) return Promise.resolve(fullAgent(changedAgent, field === 'systemPrompt' ? initial : 'prompt')); + return Promise.resolve(fullAgent({ ...finalAgent, name: field === 'name' ? requestedName : finalAgent.name }, submitted)); + }); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initialAgent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + let editedInput: HTMLInputElement | HTMLTextAreaElement; + if (field === 'systemPrompt') { + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + editedInput = screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'); + fireEvent.change(editedInput, { target: { value: draft } }); + } else { + await waitFor(() => expect(screen.getByDisplayValue(initial)).toBeTruthy()); + editedInput = screen.getByDisplayValue(initial); + fireEvent.change(editedInput, { target: { value: draft } }); + } + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + expect(editedInput.value).toBe(draft); + + fireEvent.change(editedInput, { target: { value: submitted } }); + fireEvent.blur(editedInput); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', patch)); + }); + + it.each([ + { field: 'description', label: 'description' }, + { field: 'systemPrompt', label: 'inline system prompt' }, + ] as const)('adopts a newer server snapshot after a $label edit is restored to baseline', async ({ field }) => { + const initial = brief('agent-a', 'before'); + const changed = { ...initial, description: 'server description' }; + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'before prompt')) + .mockResolvedValueOnce(fullAgent(changed, 'server prompt')); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + if (field === 'description') { + const input = screen.getByDisplayValue('before'); + fireEvent.change(input, { target: { value: 'local description' } }); + fireEvent.change(input, { target: { value: 'before' } }); + fireEvent.blur(input); + expect(api.updateVibeAgent).not.toHaveBeenCalled(); + } else { + fireEvent.click(screen.getByRole('button', { name: /agents\.detail\.systemPromptCount/ })); + const input = screen.getByDisplayValue('before prompt'); + fireEvent.change(input, { target: { value: 'local prompt' } }); + fireEvent.change(input, { target: { value: 'before prompt' } }); + fireEvent.blur(input); + expect(api.updateVibeAgent).not.toHaveBeenCalled(); + } + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue(field === 'description' ? 'server description' : 'server prompt')).toBeTruthy()); + }); + + it('restores the authoritative name on Escape and does not rename on blur', async () => { + const initial = brief('agent-a', 'description'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'prompt')) + .mockResolvedValueOnce(fullAgent({ ...initial, description: 'after gap' }, 'prompt')); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('agent-a')).toBeTruthy()); + const input = screen.getByDisplayValue('agent-a'); + fireEvent.change(input, { target: { value: 'local-name' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + fireEvent.blur(input); + expect(screen.getByDisplayValue('agent-a')).toBeTruthy(); + expect(api.updateVibeAgent).not.toHaveBeenCalled(); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('after gap')).toBeTruthy()); + expect(api.updateVibeAgent).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'rejected PATCH', patch: 'reject' as const }, + { label: 'ok-false PATCH', patch: 'okfalse' as const }, + { label: 'failed authoritative drain', patch: 'drain' as const }, + ])('keeps the prompt editor draft open after a $label and closes after retry', async ({ patch }) => { + const initial = brief('agent-a', 'description'); + const getVibeAgent = vi.fn().mockResolvedValueOnce(fullAgent(initial, 'server prompt')); + const updateVibeAgent = vi.fn(); + if (patch === 'drain') { + getVibeAgent.mockRejectedValueOnce({ message: 'drain failed' }); + updateVibeAgent.mockResolvedValueOnce(fullAgent(initial, 'acknowledged')); + } else { + getVibeAgent.mockResolvedValueOnce(fullAgent(initial, 'server prompt')); + if (patch === 'reject') updateVibeAgent.mockRejectedValueOnce({ message: 'save failed' }); + else updateVibeAgent.mockResolvedValueOnce({ ok: false, message: 'save failed' }); + } + getVibeAgent.mockResolvedValueOnce(fullAgent({ ...initial, description: 'saved' }, 'saved prompt')); + updateVibeAgent.mockResolvedValueOnce(fullAgent({ ...initial, description: 'saved' }, 'saved prompt')); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + const draft = screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'); + fireEvent.change(draft, { target: { value: 'failed private draft' } }); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByDisplayValue('failed private draft')).toBeTruthy()); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(document.body.textContent).toContain(patch === 'drain' ? 'drain failed' : 'save failed'); + + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + fireEvent.click(screen.getByRole('button', { name: /agents\.detail\.systemPromptCount/ })); + expect(screen.getByDisplayValue('saved prompt')).toBeTruthy(); + }); + + it('lets a newer reconnect publish satisfy a successful prompt save drain', async () => { + const agent = brief('agent-a', 'description'); + const firstDrain = deferred>(); + const reconnect = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(firstDrain.promise) + .mockReturnValueOnce(reconnect.promise); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'saved prompt' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => reconnect.resolve(fullAgent(agent, 'saved prompt'))); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.queryByText('Agent reconciliation failed')).toBeNull(); + act(() => firstDrain.resolve(fullAgent(agent, 'stale drain'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(updateVibeAgent).toHaveBeenCalledTimes(1); + }); + + it('keeps the selected catch-up barrier alive when a manual list refresh supersedes it', async () => { + const agent = brief('agent-a', 'before'); + const gapList = deferred>(); + const manualList = deferred>(); + const detail = deferred>(); + const refreshed = { ...agent, description: 'after gap' }; + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(agent)) + .mockReturnValueOnce(gapList.promise) + .mockReturnValueOnce(manualList.promise); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockReturnValueOnce(detail.promise); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: 'common.refresh' })); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(3)); + + act(() => manualList.resolve(listResult(refreshed))); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + act(() => detail.resolve(fullAgent(refreshed, 'reconciled'))); + await waitFor(() => expect(screen.getByDisplayValue('after gap')).toBeTruthy()); + + act(() => gapList.resolve(listResult(agent))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('after gap')).toBeTruthy(); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + }); + + it('publishes the Definitions list only after the authoritative mutation detail drain', async () => { + const agent = brief('agent-a', 'before'); + const preMutationList = deferred>(); + const postDrainList = deferred>(); + const drain = deferred>(); + const finalAgent = { ...agent, description: 'after mutation' }; + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(agent)) + .mockReturnValueOnce(preMutationList.promise) + .mockReturnValueOnce(postDrainList.promise); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockReturnValueOnce(drain.promise) + .mockResolvedValue(fullAgent(finalAgent, 'final')); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi(listVibeAgents, getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByDisplayValue('before'), { target: { value: 'local' } }); + fireEvent.blur(screen.getByDisplayValue('local')); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => drain.resolve(fullAgent(finalAgent, 'final'))); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(3)); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + expect(screen.getByDisplayValue('final')).toBeTruthy(); + act(() => postDrainList.resolve(listResult(finalAgent))); + await waitFor(() => expect(screen.getAllByText('after mutation').length).toBeGreaterThan(0)); + + act(() => preMutationList.resolve(listResult(agent))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getAllByText('after mutation').length).toBeGreaterThan(0); + }); + + it('keeps per-operation mutation outcomes independent within one detail barrier', async () => { + const agent = brief('agent-a', 'before'); + const descriptionPatch = deferred(); + const promptPatch = deferred(); + const drain = deferred>(); + const finalAgent = { ...agent, description: 'server description' }; + const updateVibeAgent = vi.fn() + .mockReturnValueOnce(descriptionPatch.promise) + .mockReturnValueOnce(promptPatch.promise); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial prompt')) + .mockReturnValueOnce(drain.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult(finalAgent)), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('before'), { target: { value: 'local description' } }); + fireEvent.blur(screen.getByDisplayValue('local description')); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'saved prompt' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => promptPatch.resolve({ ok: true })); + act(() => descriptionPatch.reject({ message: 'description failed' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => drain.resolve(fullAgent(finalAgent, 'saved prompt'))); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.getByText('description failed')).toBeTruthy(); + expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { system_prompt: 'saved prompt' }); + }); + + it.each(['model', 'effort'] as const)('restores the authoritative value after a failed %s mutation', async (field) => { + const initial = { + ...brief('agent-a', 'description'), + model: 'old-model', + reasoning_effort: 'medium' as const, + }; + const update = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'initial prompt')) + .mockResolvedValueOnce(fullAgent(initial, 'authoritative prompt')); + const updateVibeAgent = vi.fn().mockReturnValue(update.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + if (field === 'model') { + fireEvent.click(screen.getByRole('combobox')); + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value: 'new-model' } }); + fireEvent.click(screen.getByText('Use "new-model"')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { model: 'new-model' })); + } else { + fireEvent.click(screen.getByRole('button', { name: 'high', exact: true })); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { reasoning_effort: 'high' })); + } + + act(() => update.reject({ message: `${field} failed` })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + if (field === 'model') { + await waitFor(() => expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('old-model')); + } else { + await waitFor(() => expect(screen.getByRole('button', { name: 'medium', exact: true }).className).toContain('bg-mint-soft')); + } + expect(screen.queryByText(`${field} failed`)).toBeTruthy(); + }); + + it.each([ + { label: 'underlying message', failure: { message: 'authoritative drain failed' }, expected: 'authoritative drain failed' }, + { label: 'message-less fallback', failure: {}, expected: 'errorBoundary.title' }, + ])('surfaces the $label from a current mutation drain', async ({ failure, expected }) => { + const agent = brief('agent-a', 'before'); + const drain = deferred>(); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'prompt')) + .mockReturnValueOnce(drain.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('before')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('before'), { target: { value: 'submitted' } }); + fireEvent.blur(screen.getByDisplayValue('submitted')); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => drain.reject(failure)); + await waitFor(() => expect(screen.getByText(expected)).toBeTruthy()); + expect(screen.queryByText('Agent reconciliation failed')).toBeNull(); + }); + + it.each([ + { label: 'transport rejection', update: 'reject' as const, message: 'enabled transport failed' }, + { label: 'HTTP ok false', update: 'okfalse' as const, message: 'enabled server rejected' }, + { label: 'authoritative drain failure', update: 'drain' as const, message: 'enabled drain failed' }, + ])('consumes enabled background failures ($label)', async ({ update, message }) => { + const agent = brief('agent-a', 'description'); + const getVibeAgent = vi.fn().mockResolvedValueOnce(fullAgent(agent, 'prompt')); + const updateVibeAgent = vi.fn(); + if (update === 'reject') { + updateVibeAgent.mockRejectedValueOnce({ message }); + getVibeAgent.mockResolvedValue(fullAgent(agent, 'prompt')); + } else if (update === 'okfalse') { + updateVibeAgent.mockResolvedValueOnce({ ok: false, message }); + getVibeAgent.mockResolvedValue(fullAgent(agent, 'prompt')); + } else { + updateVibeAgent.mockResolvedValueOnce({ ok: true }); + getVibeAgent.mockRejectedValueOnce({ message }); + getVibeAgent.mockResolvedValue(fullAgent(agent, 'prompt')); + } + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + const toggle = screen.getByRole('switch', { name: 'agents.detail.enabled' }); + fireEvent.click(toggle); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { enabled: false })); + await waitFor(() => expect(screen.getByText(message)).toBeTruthy()); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + }); + + it('preserves a newer model edit when an older mutation drain settles', async () => { + const initial = { ...brief('agent-a', 'description'), model: 'old-model', reasoning_effort: 'medium' as const }; + const firstPatch = deferred>(); + const secondPatch = deferred>(); + const firstDrain = deferred>(); + const secondDrain = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'initial prompt')) + .mockReturnValueOnce(firstDrain.promise) + .mockReturnValueOnce(secondDrain.promise); + const updateVibeAgent = vi.fn() + .mockReturnValueOnce(firstPatch.promise) + .mockReturnValueOnce(secondPatch.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + const chooseModel = (value: string) => { + fireEvent.click(screen.getByRole('combobox')); + fireEvent.change(screen.getByPlaceholderText('Search...'), { target: { value } }); + fireEvent.click(screen.getByText(`Use "${value}"`)); + }; + chooseModel('model-one'); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { model: 'model-one' })); + act(() => firstPatch.resolve(fullAgent({ ...initial, model: 'model-one' }, 'first acknowledgement'))); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + chooseModel('model-two'); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-a', { model: 'model-two' })); + act(() => firstDrain.resolve(fullAgent({ ...initial, model: 'model-one' }, 'first drain'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('model-two'); + + act(() => secondPatch.reject({ message: 'second failed' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => secondDrain.resolve(fullAgent(initial, 'final authoritative'))); + await waitFor(() => expect(screen.getByRole('combobox', { hidden: true }).textContent).toContain('old-model')); + }); + + it('reseeds an untouched open prompt editor but preserves an edited modal draft', async () => { + const initial = brief('agent-a', 'description'); + const changed = { ...initial, description: 'description', model: 'new-model' }; + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(initial, 'old prompt')) + .mockResolvedValueOnce(fullAgent(changed, 'new prompt')) + .mockResolvedValueOnce(fullAgent({ ...changed, description: 'description 2' }, 'latest prompt')) + .mockResolvedValueOnce(fullAgent({ ...changed, description: 'description 3' }, 'reconciled prompt')); + const api = makeApi(vi.fn().mockResolvedValue(listResult(initial)), getVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + await waitFor(() => expect(screen.getAllByDisplayValue('old prompt').length).toBeGreaterThan(0)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('new prompt')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'common.save' })); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + await waitFor(() => expect(screen.getByDisplayValue('new prompt')).toBeTruthy()); + + const modalPrompt = screen.getAllByPlaceholderText('agents.create.systemPromptPlaceholder').at(-1)!; + fireEvent.change(modalPrompt, { target: { value: 'edited modal draft' } }); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('edited modal draft')).toBeTruthy()); + }); + + it('orders onboarding reads around a mutation and ignores stale results', async () => { + const agent = brief('agent-a', 'agent description'); + const initial = deferred>(); + const reconnect = deferred>(); + const gapAfterMutation = deferred>(); + const settlement = deferred>(); + const onboardingResult = (notOnboarded: number, privateCount: number) => ({ + ok: true, + available: true, + organization_id: 'org-1', + agents: [], + counts: { total: 1, system: 0, custom: 1, not_onboarded: notOnboarded, private: privateCount, published: 0, conflicts: 0 }, + }); + const getVibeAgentOnboarding = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(reconnect.promise) + .mockReturnValueOnce(gapAfterMutation.promise) + .mockReturnValueOnce(settlement.promise); + const post = deferred>(); + const api = makeApi( + vi.fn().mockResolvedValue(listResult(agent)), + vi.fn().mockResolvedValue({ ok: false }), + getVibeAgentOnboarding, + vi.fn().mockReturnValue(post.promise), + ); + renderPage(api, { canManageAgents: true }); + await waitFor(() => expect(handlers).not.toBeNull()); + + act(() => handlers?.onConnected?.()); + act(() => reconnect.resolve(onboardingResult(1, 0))); + await waitFor(() => expect(screen.getByText('agents.onboarding.privateCount:{"count":0}')).toBeTruthy()); + act(() => initial.resolve(onboardingResult(1, 1))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('agents.onboarding.privateCount:{"count":0}')).toBeTruthy(); + + fireEvent.click(screen.getByText('agents.onboarding.onboardPrivate')); + await waitFor(() => expect(api.onboardVibeAgents).toHaveBeenCalledTimes(1)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgentOnboarding).toHaveBeenCalledTimes(3)); + act(() => gapAfterMutation.resolve(onboardingResult(0, 3))); + await waitFor(() => expect(screen.getByText('agents.onboarding.privateCount:{"count":3}')).toBeTruthy()); + act(() => post.resolve(onboardingResult(0, 99))); + act(() => settlement.resolve(onboardingResult(1, 2))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await waitFor(() => expect(screen.getByText('agents.onboarding.notOnboardedCount:{"count":1}')).toBeTruthy()); + expect(screen.getByText('agents.onboarding.privateCount:{"count":2}')).toBeTruthy(); + expect(getVibeAgentOnboarding).toHaveBeenCalledTimes(4); + }); + + it('keeps a newer reconnect onboarding snapshot over a settled mutation read', async () => { + const agent = brief('agent-a', 'agent description'); + const settlement = deferred<{ available: boolean; counts: Record }>(); + const reconnect = deferred<{ available: boolean; counts: Record }>(); + const result = (privateCount: number) => ({ + ok: true, + available: true, + organization_id: 'org-1', + agents: [], + counts: { total: 1, system: 0, custom: 1, not_onboarded: 1, private: privateCount, published: 0, conflicts: 0 }, + }); + const getVibeAgentOnboarding = vi.fn() + .mockResolvedValueOnce(result(0)) + .mockReturnValueOnce(settlement.promise) + .mockReturnValueOnce(reconnect.promise); + const post = vi.fn().mockResolvedValue(result(99)); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), vi.fn().mockResolvedValue({ ok: false }), getVibeAgentOnboarding, post); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByText('agents.onboarding.privateCount:{"count":0}')).toBeTruthy()); + fireEvent.click(screen.getByText('agents.onboarding.onboardPrivate')); + await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgentOnboarding).toHaveBeenCalledTimes(3)); + act(() => settlement.resolve(result(1))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText('agents.onboarding.privateCount:{"count":0}')).toBeTruthy(); + act(() => reconnect.resolve(result(2))); + await waitFor(() => expect(screen.getByText('agents.onboarding.privateCount:{"count":2}')).toBeTruthy()); + expect(screen.queryByText('agents.onboarding.privateCount:{"count":99}')).toBeNull(); + }); + + it('keeps onboarding submission locked until its settlement inventory publishes', async () => { + const agent = brief('agent-a', 'agent description'); + const settlement = deferred>(); + const onboardingResult = (notOnboarded: number) => ({ + ok: true, + available: true, + organization_id: 'org-1', + agents: [], + counts: { total: 1, system: 0, custom: 1, not_onboarded: notOnboarded, private: 0, published: 0, conflicts: 0 }, + }); + const getVibeAgentOnboarding = vi.fn() + .mockResolvedValueOnce(onboardingResult(1)) + .mockReturnValueOnce(settlement.promise); + const onboardVibeAgents = vi.fn().mockResolvedValue({ ok: true, created: 1 }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), vi.fn().mockResolvedValue(fullAgent(agent, 'prompt')), getVibeAgentOnboarding, onboardVibeAgents); + renderPage(api, { canManageAgents: true }); + + const button = await screen.findByRole('button', { name: 'agents.onboarding.onboardPrivate' }); + fireEvent.click(button); + await waitFor(() => expect(onboardVibeAgents).toHaveBeenCalledTimes(1)); + expect((button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + expect(onboardVibeAgents).toHaveBeenCalledTimes(1); + + act(() => settlement.resolve(onboardingResult(0))); + await waitFor(() => expect((screen.getByRole('button', { name: 'agents.onboarding.onboarded' }) as HTMLButtonElement).disabled).toBe(true)); + }); + + it('does not re-enable stale onboarding inventory after settlement failure', async () => { + const agent = brief('agent-a', 'agent description'); + const settlement = deferred>(); + const onboardingResult = (notOnboarded: number) => ({ + ok: true, + available: true, + organization_id: 'org-1', + agents: [], + counts: { total: 1, system: 0, custom: 1, not_onboarded: notOnboarded, private: 0, published: 0, conflicts: 0 }, + }); + const getVibeAgentOnboarding = vi.fn() + .mockResolvedValueOnce(onboardingResult(1)) + .mockReturnValueOnce(settlement.promise); + const onboardVibeAgents = vi.fn().mockResolvedValue({ ok: true, created: 1 }); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), vi.fn().mockResolvedValue(fullAgent(agent, 'prompt')), getVibeAgentOnboarding, onboardVibeAgents); + renderPage(api, { canManageAgents: true }); + + const button = await screen.findByRole('button', { name: 'agents.onboarding.onboardPrivate' }); + fireEvent.click(button); + await waitFor(() => expect(onboardVibeAgents).toHaveBeenCalledTimes(1)); + act(() => settlement.reject(new Error('inventory unavailable'))); + await waitFor(() => expect(screen.queryByRole('button', { name: 'agents.onboarding.onboardPrivate' })).toBeNull()); + expect(onboardVibeAgents).toHaveBeenCalledTimes(1); + }); + + it('does not drain a non-selected mutation and retires it before returning to that agent', async () => { + const agentA = { ...brief('agent-a', 'A'), model: 'a-model' }; + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const finalA = fullAgent({ ...agentA, description: 'A after return' }, 'A after return'); + const patch = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockResolvedValueOnce(finalA); + const listVibeAgents = vi.fn().mockResolvedValue(listResult([agentA, agentB])); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi(listVibeAgents, getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => patch.resolve(fullAgent({ ...agentA, description: 'A local' }, 'A ack'))); + await waitFor(() => expect(listVibeAgents).toHaveBeenCalledTimes(2)); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + act(() => pendingB.resolve(fullAgent(agentB, 'B selected'))); + await waitFor(() => expect(screen.getByDisplayValue('B')).toBeTruthy()); + const rowA = screen.getAllByText('agent-a').find((node) => node.closest('button'))?.closest('button'); + expect(rowA).not.toBeNull(); + fireEvent.click(rowA!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(screen.getByDisplayValue('A after return')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }); + + it.each([ + { label: 'thrown failure', reject: true }, + { label: 'HTTP ok false', reject: false }, + ])('keeps a non-selected mutation $label visible after Definitions refresh', async ({ reject }) => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const patch = deferred(); + const finalA = fullAgent({ ...agentA, description: 'A after failed return' }, 'A after failed return'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockResolvedValueOnce(finalA); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + if (reject) { + act(() => patch.reject({ message: 'A mutation failed' })); + } else { + act(() => patch.resolve({ ok: false, message: 'A mutation failed' })); + } + await waitFor(() => expect(screen.getByText('A mutation failed')).toBeTruthy()); + act(() => pendingB.resolve(fullAgent(agentB, 'B selected'))); + await waitFor(() => expect(screen.getByDisplayValue('B')).toBeTruthy()); + expect(screen.getByText('A mutation failed')).toBeTruthy(); + fireEvent.click(screen.getByText('agent-a').closest('button')!); + await waitFor(() => expect(screen.getByDisplayValue('A after failed return')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }); + + it.each([ + { label: 'thrown failure', reject: true }, + { label: 'HTTP ok false', reject: false }, + ])('drains a settled mutation after selection rollback for $label', async ({ reject }) => { + const agentA = { ...brief('agent-a', 'A'), model: 'a-model' }; + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const patch = deferred(); + const rollbackA = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockReturnValueOnce(rollbackA.promise); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + if (reject) act(() => patch.reject({ message: 'A mutation failed' })); + else act(() => patch.resolve({ ok: false, message: 'A mutation failed' })); + await waitFor(() => expect(screen.getByText('A mutation failed')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + + act(() => pendingB.reject({ code: 'agent_backend_unavailable', message: 'B unavailable' })); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + act(() => rollbackA.resolve(fullAgent({ ...agentA, description: 'A after patch' }, 'A reconciled'))); + await waitFor(() => expect(screen.getByDisplayValue('A after patch')).toBeTruthy()); + fireEvent.click(screen.getByText('agents.detail.systemPrompt').closest('button')!); + expect(screen.getByDisplayValue('A reconciled')).toBeTruthy(); + expect(screen.getByText('A mutation failed')).toBeTruthy(); + }); + + it.each([ + { label: 'rejected', reject: true, code: 'agent_not_found' }, + { label: 'ok-false', reject: false, code: 'agent_access_forbidden' }, + ])('drains accepted-A debt after pending B disappears on the same edge ($label)', async ({ reject, code }) => { + const agentA = { ...brief('agent-a', 'A before'), model: 'a-model' }; + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const patch = deferred(); + const catchupB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockReturnValueOnce(catchupB.promise) + .mockResolvedValueOnce(fullAgent({ ...agentA, description: 'A after debt' }, 'A reconciled')); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A before')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A before'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => patch.resolve({ ok: true })); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + if (reject) act(() => catchupB.reject({ code, message: 'B disappeared' })); + else act(() => catchupB.resolve({ ok: false, code, message: 'B disappeared' } as never)); + + await waitFor(() => expect(screen.getByDisplayValue('A after debt')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(4, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(screen.queryByText('agent-b')).toBeNull(); + }); + + it.each([ + { label: 'HTTP ok false', rejected: false, result: { ok: false, message: 'delete rejected' } }, + { label: 'transport rejection', rejected: true, result: { code: 'delete_failed', message: 'delete failed' } }, + ])('keeps an in-flight selection read valid after DELETE $label', async ({ rejected, result }) => { + const agent = brief('agent-a', 'A'); + const reconnect = deferred>(); + const removeVibeAgent = rejected ? vi.fn().mockRejectedValue(result) : vi.fn().mockResolvedValue(result); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'initial')) + .mockReturnValueOnce(reconnect.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, undefined, removeVibeAgent); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + await waitFor(() => expect(removeVibeAgent).toHaveBeenCalledWith('agent-a')); + act(() => reconnect.resolve(fullAgent({ ...agent, description: 'A remains' }, 'fresh A'))); + await waitFor(() => expect(screen.getByDisplayValue('A remains')).toBeTruthy()); + expect(screen.getByText(result.message)).toBeTruthy(); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + }); + + it('does not restore or auto-select a deleted A when a pending B selection fails', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockResolvedValue(fullAgent(agentB, 'B retry')); + const removeVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + undefined, + removeVibeAgent, + ); + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + await waitFor(() => expect(removeVibeAgent).toHaveBeenCalledWith('agent-a')); + act(() => pendingB.reject({ code: 'agent_backend_unavailable', message: 'B unavailable' })); + await waitFor(() => expect(screen.queryByDisplayValue('A initial')).toBeNull()); + expect(getVibeAgent.mock.calls.slice(2).some(([name]) => name === 'agent-a')).toBe(false); + }); + + it.each(['agent_not_found', 'agent_access_forbidden'] as const)( + 'retires an expected disappearance without recursively refreshing Definitions (%s)', + async (code) => { + const agentA = brief('agent-a', 'A'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockRejectedValueOnce({ code, message: 'gone' }); + const listVibeAgents = vi.fn().mockResolvedValue(listResult(agentA)); + const api = makeApi(listVibeAgents, getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // Detail retirement schedules one bounded Definitions follow-up so a + // replacement identity can be discovered; it must not recurse further. + expect(listVibeAgents).toHaveBeenCalledTimes(3); + expect(screen.queryByDisplayValue('A')).toBeNull(); + expect(screen.queryByText('agent-a')).toBeNull(); + expect(showToast).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { label: 'thrown failure', reject: true }, + { label: 'HTTP ok false', reject: false }, + ])('keeps a current mutation $label visible through its drain and Definitions refresh', async ({ reject }) => { + const agentA = brief('agent-a', 'A'); + const drain = deferred>(); + const patch = deferred(); + const getVibeAgent = vi.fn().mockResolvedValueOnce(fullAgent(agentA, 'A initial')).mockReturnValueOnce(drain.promise); + const updateVibeAgent = vi.fn().mockReturnValue(patch.promise); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agentA)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('A'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledTimes(1)); + if (reject) { + act(() => patch.reject({ message: 'A mutation failed' })); + } else { + act(() => patch.resolve({ ok: false, message: 'A mutation failed' })); + } + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => drain.resolve(fullAgent({ ...agentA, description: 'A server' }, 'A server prompt'))); + await waitFor(() => expect(screen.getByText('A mutation failed')).toBeTruthy()); + expect(screen.getByText('A mutation failed')).toBeTruthy(); + }); + + it.each([ + { + label: 'an ok-false response', + response: { ok: false, message: 'server detail rejected' }, + expected: 'server detail rejected', + }, + { + label: 'a mismatched response', + response: fullAgent(brief('agent-b', 'B'), 'wrong identity'), + expected: 'errorBoundary.title', + }, + ])('rolls back a selected load for $label with a localized fallback', async ({ response, expected }) => { + const agentA = brief('agent-a', 'A'); + const getVibeAgent = vi.fn().mockResolvedValueOnce(fullAgent(agentA, 'A initial')).mockResolvedValueOnce(response); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agentA)), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByText(expected)).toBeTruthy()); + expect(screen.getByDisplayValue('A')).toBeTruthy(); + }); + + it('silences expected selected disappearance but surfaces unexpected current failures', async () => { + const agent = brief('agent-a', 'agent description'); + const expectedApi = makeApi( + vi.fn().mockResolvedValue(listResult(agent)), + vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'prompt')) + .mockRejectedValueOnce({ code: 'agent_not_found', message: 'gone' }), + ); + renderPage(expectedApi); + await waitFor(() => expect(screen.getByDisplayValue('agent description')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.queryByDisplayValue('agent description')).toBeNull()); + expect(showToast).not.toHaveBeenCalled(); + + cleanup(); + handlers = null; + const unexpectedApi = makeApi( + vi.fn().mockResolvedValue(listResult(agent)), + vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'prompt')) + .mockRejectedValueOnce({ code: 'agent_backend_unavailable', message: 'backend unavailable' }), + ); + renderPage(unexpectedApi); + await waitFor(() => expect(screen.getByDisplayValue('agent description')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByText('backend unavailable')).toBeTruthy()); + }); + + it('reconciles organization onboarding inventory only from the gap owner', async () => { + const agent = brief('agent-a', 'agent'); + const onboarding = (notOnboarded: number) => ({ + ok: true, + available: true, + organization_id: 'org-1', + agents: [], + counts: { total: 1, system: 0, custom: 1, not_onboarded: notOnboarded, private: 1 - notOnboarded, published: 0, conflicts: 0 }, + }); + const getVibeAgentOnboarding = vi.fn() + .mockResolvedValueOnce(onboarding(1)) + .mockResolvedValueOnce(onboarding(0)); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), vi.fn().mockResolvedValue({ ok: false }), getVibeAgentOnboarding); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByText('agents.onboarding.notOnboardedCount:{"count":1}')).toBeTruthy()); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByText('agents.onboarding.notOnboardedCount:{"count":0}')).toBeTruthy()); + + expect(getVibeAgentOnboarding).toHaveBeenCalledTimes(2); + act(() => { + handlers?.onEventBridgeStatus?.({ connected: false }); + handlers?.onEventBridgeStatus?.({ connected: true }); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(getVibeAgentOnboarding).toHaveBeenCalledTimes(2); + }); + + it.each(['agent_not_found', 'agent_access_forbidden'] as const)( + 'restores accepted A when pending B disappears (%s)', + async (code) => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const pendingB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(pendingB.promise) + .mockResolvedValueOnce(fullAgent({ ...agentA, description: 'A after gap' }, 'A refreshed')) + .mockResolvedValueOnce(fullAgent({ ...agentA, description: 'A after mutation' }, 'A mutation drain')); + const updateVibeAgent = vi.fn().mockResolvedValue(fullAgent(agentA, 'ack')); + const api = makeApi( + vi.fn().mockResolvedValue(listResult([agentA, agentB])), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => pendingB.reject({ code, message: 'B disappeared' })); + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('A after gap')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent.mock.calls.slice(2).some(([name]) => name === 'agent-b')).toBe(false); + + fireEvent.change(screen.getByDisplayValue('A after gap'), { target: { value: 'A local' } }); + fireEvent.blur(screen.getByDisplayValue('A local')); + await waitFor(() => expect(screen.getByDisplayValue('A after mutation')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(4, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }, + ); + + it('migrates a stable-id rename and keeps the reconciled name clean', async () => { + const oldAgent = brief('agent-old', 'description'); + const renamedAgent = { ...oldAgent, name: 'agent-new', display_name: 'agent-new' }; + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(oldAgent, 'prompt')) + .mockResolvedValueOnce(fullAgent(renamedAgent, 'prompt')); + const updateVibeAgent = vi.fn().mockResolvedValue(fullAgent(renamedAgent, 'prompt')); + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(oldAgent)) + .mockResolvedValue(listResult(renamedAgent)); + const api = makeApi(listVibeAgents, getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('agent-old')).toBeTruthy()); + const nameInput = screen.getByDisplayValue('agent-old'); + fireEvent.change(nameInput, { target: { value: 'agent-new' } }); + fireEvent.blur(screen.getByDisplayValue('agent-new')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-old', { name: 'agent-new' })); + await waitFor(() => expect(screen.getByDisplayValue('agent-new')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-new', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + + fireEvent.blur(screen.getByDisplayValue('agent-new')); + expect(updateVibeAgent).toHaveBeenCalledTimes(1); + + updateVibeAgent.mockRejectedValueOnce({ message: 'rename failed' }); + fireEvent.change(screen.getByDisplayValue('agent-new'), { target: { value: 'agent-lost' } }); + fireEvent.blur(screen.getByDisplayValue('agent-lost')); + await waitFor(() => expect(screen.getByDisplayValue('agent-new')).toBeTruthy()); + }); + + it.each(['agent_not_found', 'agent_access_forbidden'] as const)( + 'localizes rename retirement errors without manufacturing English (%s)', + async (code) => { + const oldAgent = brief('agent-old', 'description'); + const renamedAgent = { ...oldAgent, name: 'agent-new', display_name: 'agent-new' }; + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(oldAgent, 'prompt')) + .mockRejectedValueOnce({ code }); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const listVibeAgents = vi.fn() + .mockResolvedValueOnce(listResult(oldAgent)) + .mockResolvedValue(listResult(renamedAgent)); + const api = makeApi(listVibeAgents, getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('agent-old')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('agent-old'), { target: { value: 'agent-new' } }); + fireEvent.blur(screen.getByDisplayValue('agent-new')); + await waitFor(() => expect(updateVibeAgent).toHaveBeenCalledWith('agent-old', { name: 'agent-new' })); + await waitFor(() => expect(screen.getByText('errorBoundary.title')).toBeTruthy()); + expect(screen.queryByText('Agent is no longer available')).toBeNull(); + }, + ); + + it('lets a newer new-name read satisfy a superseded rename drain', async () => { + const oldAgent = brief('agent-old', 'description'); + const renamedAgent = { ...oldAgent, name: 'agent-new', display_name: 'agent-new' }; + const oldDrain = deferred>(); + const newRead = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(oldAgent, 'prompt')) + .mockReturnValueOnce(oldDrain.promise) + .mockReturnValueOnce(newRead.promise); + const updateVibeAgent = vi.fn().mockResolvedValue({ ok: true }); + const api = makeApi( + vi.fn() + .mockResolvedValueOnce(listResult(oldAgent)) + .mockResolvedValue(listResult(renamedAgent)), + getVibeAgent, + undefined, + undefined, + updateVibeAgent, + ); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('agent-old')).toBeTruthy()); + fireEvent.change(screen.getByDisplayValue('agent-old'), { target: { value: 'agent-new' } }); + fireEvent.blur(screen.getByDisplayValue('agent-new')); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + act(() => newRead.resolve(fullAgent(renamedAgent, 'prompt'))); + await waitFor(() => expect(screen.getByDisplayValue('agent-new')).toBeTruthy()); + act(() => oldDrain.resolve(fullAgent(renamedAgent, 'stale rename drain'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + fireEvent.blur(screen.getByDisplayValue('agent-new')); + expect(updateVibeAgent).toHaveBeenCalledTimes(1); + }); + + it('cancels modal-only prompt edits without dirtying the shared field', async () => { + const agent = brief('agent-a', 'description'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agent, 'server prompt')) + .mockResolvedValueOnce(fullAgent(agent, 'new server prompt')) + .mockResolvedValueOnce(fullAgent(agent, 'latest server prompt')); + const updateVibeAgent = vi.fn(); + const api = makeApi(vi.fn().mockResolvedValue(listResult(agent)), getVibeAgent, undefined, undefined, updateVibeAgent); + renderPage(api, { canManageAgents: true }); + + await waitFor(() => expect(screen.getByDisplayValue('description')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + const modal = screen.getByRole('dialog'); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'canceled modal text' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })); + expect(updateVibeAgent).not.toHaveBeenCalled(); + + act(() => handlers?.onConnected?.()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + await waitFor(() => expect(screen.getByDisplayValue('new server prompt')).toBeTruthy()); + fireEvent.change(screen.getByPlaceholderText('agents.create.systemPromptPlaceholder'), { + target: { value: 'x-canceled modal text' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(updateVibeAgent).not.toHaveBeenCalled(); + + act(() => handlers?.onConnected?.()); + fireEvent.click(screen.getByRole('button', { name: 'agents.detail.systemPromptExpand' })); + await waitFor(() => expect(screen.getByDisplayValue('latest server prompt')).toBeTruthy()); + expect(modal).not.toBeNull(); + }); + + it.each([ + { label: 'rejected expected disappearance', reject: true }, + { label: 'ok-false expected disappearance', reject: false }, + ])('continues the same catch-up edge to accepted A after B $label', async ({ reject }) => { + const agentA = brief('agent-a', 'A before gap'); + const agentB = brief('agent-b', 'B'); + const preGapB = deferred>(); + const catchupA = fullAgent({ ...agentA, description: 'A changed during gap' }, 'A changed prompt'); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(preGapB.promise) + .mockImplementationOnce(() => + reject + ? Promise.reject({ code: 'agent_not_found', message: 'B disappeared' }) + : Promise.resolve({ ok: false, code: 'agent_not_found', message: 'B disappeared' }), + ) + .mockResolvedValueOnce(catchupA); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A before gap')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('A changed during gap')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(3, 'agent-b', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + expect(getVibeAgent).toHaveBeenNthCalledWith(4, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + + act(() => preGapB.resolve(fullAgent(agentB, 'stale B'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByDisplayValue('A changed during gap')).toBeTruthy(); + expect(getVibeAgent.mock.calls.slice(3).some(([name]) => name === 'agent-b')).toBe(false); + }); + + it('does not retry a failed resumed A until the next reconnect edge', async () => { + const agentA = brief('agent-a', 'A before gap'); + const agentB = brief('agent-b', 'B'); + const preGapB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(preGapB.promise) + .mockRejectedValueOnce({ code: 'agent_not_found', message: 'B disappeared' }) + .mockRejectedValueOnce({ code: 'agent_backend_unavailable', message: 'A unavailable' }) + .mockResolvedValueOnce(fullAgent({ ...agentA, description: 'A after next gap' }, 'A recovered')); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A before gap')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(4)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getVibeAgent).toHaveBeenCalledTimes(4); + + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('A after next gap')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenNthCalledWith(5, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }); + + it('retains auto-selection after a transient detail failure until the next edge', async () => { + const agent = brief('agent-a', 'A'); + const getVibeAgent = vi.fn() + .mockRejectedValueOnce({ message: 'temporary detail failure' }) + .mockResolvedValueOnce(fullAgent({ ...agent, description: 'A recovered' }, 'prompt')); + const api = makeApi( + vi.fn().mockResolvedValue(listResult(agent)), + getVibeAgent, + ); + renderPage(api); + + await waitFor(() => expect(screen.getByText('temporary detail failure')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(1); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(screen.getByDisplayValue('A recovered')).toBeTruthy()); + expect(getVibeAgent).toHaveBeenCalledTimes(2); + expect(getVibeAgent).toHaveBeenNthCalledWith(2, 'agent-a', { + cache: false, + expectedCodes: ['agent_not_found', 'agent_access_forbidden'], + }); + }); + + it('does not resume A when a newer C intent wins during the B catch-up read', async () => { + const agentA = brief('agent-a', 'A'); + const agentB = brief('agent-b', 'B'); + const agentC = brief('agent-c', 'C'); + const preGapB = deferred>(); + const catchupB = deferred>(); + const getVibeAgent = vi.fn() + .mockResolvedValueOnce(fullAgent(agentA, 'A initial')) + .mockReturnValueOnce(preGapB.promise) + .mockReturnValueOnce(catchupB.promise) + .mockResolvedValueOnce(fullAgent(agentC, 'C current')); + const api = makeApi(vi.fn().mockResolvedValue(listResult([agentA, agentB, agentC])), getVibeAgent); + renderPage(api); + + await waitFor(() => expect(screen.getByDisplayValue('A')).toBeTruthy()); + fireEvent.click(screen.getByText('agent-b').closest('button')!); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(2)); + act(() => handlers?.onConnected?.()); + await waitFor(() => expect(getVibeAgent).toHaveBeenCalledTimes(3)); + + fireEvent.click(screen.getByText('agent-c').closest('button')!); + await waitFor(() => expect(screen.getByDisplayValue('C')).toBeTruthy()); + act(() => catchupB.reject({ code: 'agent_not_found', message: 'B disappeared' })); + act(() => preGapB.resolve(fullAgent(agentB, 'stale B'))); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByDisplayValue('C')).toBeTruthy(); + expect(getVibeAgent.mock.calls.slice(3).some(([name]) => name === 'agent-a')).toBe(false); + }); +}); diff --git a/ui/src/components/workbench/AgentsPage.tsx b/ui/src/components/workbench/AgentsPage.tsx index eaec30f93d..23449afee8 100644 --- a/ui/src/components/workbench/AgentsPage.tsx +++ b/ui/src/components/workbench/AgentsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import { @@ -75,8 +75,199 @@ function isSystemAgent(agent: { source: string }): boolean { return agent.source === 'builtin' || agent.source === 'system'; } +type AgentRequestToken = { version: number; identity: string | null }; + +type AgentRequestVersion = { + begin: (identity?: string | null) => AgentRequestToken; + invalidate: () => number; + current: () => number; + isCurrent: (token: AgentRequestToken) => boolean; +}; + +type SelectedMutationBatch = { + id: number; + identity: string; + pending: number; + version: number; + operations: SelectedMutationOperation[]; +}; + +type SelectedMutationResult = { ok: true } | { ok: false; error: unknown }; + +type SelectedMutationOperation = { + id: number; + batchId: number; + identity: string; + sequence: number; + failure?: unknown; + joinedDetailBarrier?: boolean; + resolve?: (result: SelectedMutationResult) => void; +}; + +type SelectedReconciliationWaiter = { + resolve: (result: SelectedMutationResult) => void; +}; + +type SelectedReconciliationDebt = { + version: number; + scheduled: boolean; + waiters: SelectedReconciliationWaiter[]; +}; + +type AutoSelectReason = 'initial' | 'replacement'; +type SelectedReadPurpose = 'selection' | 'reconcile' | 'continuation' | 'debt'; + +type SelectedReadStage = { + key: string; + identity: string; + identityEpoch: number; + causalFloor: number; + intentGeneration: number; + readGeneration: number; + debtVersion?: number; + obligationId: number; + purpose: SelectedReadPurpose; + expectedCodes?: readonly string[]; + debtOnly: boolean; + refreshDefinitions: boolean; + rollbackOnFailure: boolean; + clearSelectionError: boolean; + invalidated: boolean; + promise: Promise; + resolve: (outcome: SelectedReadOutcome) => void; + settled: boolean; +}; + +type SelectedRetirement = { + retired: boolean; + resumedIdentity: string | null; + intentGeneration: number; +}; + +type SelectedReadContinuation = { nextIdentity: string; intentGeneration: number }; + +type SelectedReadOutcome = + | { kind: 'published'; continuation?: SelectedReadContinuation } + | { kind: 'failed'; error?: unknown; continuation?: SelectedReadContinuation } + | { kind: 'stale'; continuation?: SelectedReadContinuation } + | { kind: 'expected-retired'; continuation?: SelectedReadContinuation }; + +type SelectedCoordinator = { + desiredName: string | null; + desiredOpenDetail: boolean; + desiredSource: 'user' | 'auto' | 'passive'; + identityEpoch: number; + intentGeneration: number; + readGenerations: Map; + accepted: VibeAgentFull | null; + acceptedGeneration: number; + stages: Map; + stageQueue: SelectedReadStage[]; + stageDrainActive: boolean; + obligationAttempts: Map>; + nextObligationId: number; + nextBatchId: number; + nextOperationId: number; + nextMutationVersion: number; + mutations: Map; + reconciliationDebt: Map; + retired: Set; + retiredAtDefinitionsVersion: Map; + autoSelectReason: AutoSelectReason | null; + autoSelectDismissed: boolean; +}; + +type DefinitionsBarrierWaiter = { + watermark: number; + resolve: (published: boolean) => void; +}; + +type DefinitionsBarrierState = { + publishedVersion: number; + waiters: DefinitionsBarrierWaiter[]; +}; + +const createSelectedCoordinator = (): SelectedCoordinator => ({ + desiredName: null, + desiredOpenDetail: false, + desiredSource: 'passive', + identityEpoch: 0, + intentGeneration: 0, + readGenerations: new Map(), + accepted: null, + acceptedGeneration: 0, + stages: new Map(), + stageQueue: [], + stageDrainActive: false, + obligationAttempts: new Map(), + nextObligationId: 0, + nextBatchId: 0, + nextOperationId: 0, + nextMutationVersion: 0, + mutations: new Map(), + reconciliationDebt: new Map(), + retired: new Set(), + retiredAtDefinitionsVersion: new Map(), + autoSelectReason: 'initial', + autoSelectDismissed: false, +}); + +const advanceSelectedRead = (coordinator: SelectedCoordinator, identity: string): number => { + const next = (coordinator.readGenerations.get(identity) ?? 0) + 1; + coordinator.readGenerations.set(identity, next); + for (const stage of coordinator.stages.values()) { + if (stage.identity === identity && stage.readGeneration < next) stage.invalidated = true; + } + return next; +}; + +const selectedReadIsCurrent = ( + coordinator: SelectedCoordinator, + identity: string, + generation: number, +): boolean => coordinator.readGenerations.get(identity) === generation; + +const releaseSelectedDebtWaiters = (coordinator: SelectedCoordinator, identity: string) => { + const debt = coordinator.reconciliationDebt.get(identity); + if (!debt || debt.waiters.length === 0) return; + // Leaving an identity supersedes its in-flight read. The mutation itself is + // already terminal, so settle callers now; any retained debt is evidence for + // the next authoritative read when the identity is selected again. + debt.scheduled = false; + const waiters = debt.waiters.splice(0); + for (const waiter of waiters) { + waiter.resolve({ ok: true }); + } +}; + +type ResourceErrorOwner = 'definitions' | 'selection' | 'mutation'; +type ResourceErrors = Record; + +// A tiny per-resource epoch owner. Every asynchronous read captures a token; +// any committed mutation or identity change advances the epoch and makes older +// responses inert without relying on timing or a name comparison alone. +const createAgentRequestVersion = (): AgentRequestVersion => { + let version = 0; + return { + begin: (identity = null) => ({ version: ++version, identity }), + invalidate: () => ++version, + current: () => version, + isCurrent: (token) => token.version === version, + }; +}; + +const SELECTED_DISAPPEARANCE_CODES = ['agent_not_found', 'agent_access_forbidden'] as const; + +const errorCodeOf = (error: unknown): string | null => { + if (!error || typeof error !== 'object') return null; + const code = (error as { code?: unknown }).code; + return typeof code === 'string' ? code : null; +}; + export const AgentsPage: React.FC = () => { const { t } = useTranslation(); + const tRef = useRef(t); + tRef.current = t; const api = useApi(); const { showToast } = useToast(); const { @@ -110,13 +301,30 @@ export const AgentsPage: React.FC = () => { const [loading, setLoading] = useState(false); const [showNew, setShowNew] = useState(false); const [showGlobalPrompts, setShowGlobalPrompts] = useState(false); - const [error, setError] = useState(null); + const [resourceErrors, setResourceErrors] = useState({ + definitions: null, + selection: null, + mutation: null, + }); + const resourceErrorGenerationRef = useRef>({ + definitions: 0, + selection: 0, + mutation: 0, + }); + const mutationErrorSequenceRef = useRef(0); const [search, setSearch] = useState(''); const [backendFilter, setBackendFilter] = useState('all'); const [importing, setImporting] = useState(null); const [onboardingInventory, setOnboardingInventory] = useState(null); const [onboardingExpanded, setOnboardingExpanded] = useState(false); const [onboardingSubmitting, setOnboardingSubmitting] = useState(false); + const onboardingSettlementRef = useRef | null>(null); + const definitionsVersionRef = useRef(createAgentRequestVersion()); + const definitionsBarrierRef = useRef({ publishedVersion: 0, waiters: [] }); + const onboardingVersionRef = useRef(createAgentRequestVersion()); + const selectedCoordinatorRef = useRef(createSelectedCoordinator()); + const selectedMountedRef = useRef(true); + const catchUpEpochRef = useRef(0); // Mobile drill-down: a row tap opens the detail full-screen. The agent // auto-selected on mount stays in the list view until the user drills in. const [detailOpen, setDetailOpen] = useState(false); @@ -129,39 +337,763 @@ export const AgentsPage: React.FC = () => { // a member's page load into an owner-only GET and surfaced the 403 as a toast. const canOnboardAgents = capabilities.is_instance_owner; - const refreshOnboarding = useCallback(async () => { - if (!canOnboardAgents) { - setOnboardingInventory(null); - return; + const beginResourceError = useCallback((owner: ResourceErrorOwner) => { + const generation = resourceErrorGenerationRef.current[owner] + 1; + resourceErrorGenerationRef.current[owner] = generation; + setResourceErrors((current) => (current[owner] === null ? current : { ...current, [owner]: null })); + return generation; + }, []); + + const setResourceError = useCallback((owner: ResourceErrorOwner, message: string, generation?: number) => { + if (generation !== undefined && resourceErrorGenerationRef.current[owner] !== generation) return; + setResourceErrors((current) => ({ ...current, [owner]: message })); + }, []); + + const publishMutationError = useCallback( + (value: unknown, sequence: number) => { + if (mutationErrorSequenceRef.current > sequence) return; + mutationErrorSequenceRef.current = sequence; + setResourceError('mutation', errorMessage(value) || t('errorBoundary.title')); + }, + [setResourceError, t], + ); + + const clearResourceError = useCallback((owner: ResourceErrorOwner, generation?: number) => { + if (generation !== undefined && resourceErrorGenerationRef.current[owner] !== generation) return; + resourceErrorGenerationRef.current[owner] += 1; + setResourceErrors((current) => (current[owner] === null ? current : { ...current, [owner]: null })); + }, []); + + const error = resourceErrors.mutation ?? resourceErrors.selection ?? resourceErrors.definitions; + + const publishOnboarding = useCallback((result: VibeAgentOnboardingResult | null) => { + setOnboardingInventory(result?.available ? result : null); + }, []); + + const refreshOnboarding = useCallback(() => { + const request = (async () => { + if (!selectedMountedRef.current) return false; + if (!canOnboardAgents) { + setOnboardingInventory(null); + return true; + } + const token = onboardingVersionRef.current.begin(); + try { + const result = await api.getVibeAgentOnboarding(); + if (selectedMountedRef.current && onboardingVersionRef.current.isCurrent(token)) { + publishOnboarding(result); + return true; + } + } catch { + if (selectedMountedRef.current && onboardingVersionRef.current.isCurrent(token)) { + publishOnboarding(null); + return true; + } + } + return false; + })(); + onboardingSettlementRef.current = request; + return request; + }, [api, canOnboardAgents, publishOnboarding]); + + useEffect(() => { + return () => { + selectedMountedRef.current = false; + definitionsVersionRef.current.invalidate(); + for (const waiter of definitionsBarrierRef.current.waiters.splice(0)) waiter.resolve(false); + onboardingVersionRef.current.invalidate(); + const coordinator = selectedCoordinatorRef.current; + for (const identity of coordinator.readGenerations.keys()) advanceSelectedRead(coordinator, identity); + for (const stage of coordinator.stageQueue.splice(0)) { + stage.invalidated = true; + if (!stage.settled) { + stage.settled = true; + stage.resolve({ kind: 'stale' }); + } + } + coordinator.stages.clear(); + for (const batch of coordinator.mutations.values()) { + for (const operation of batch.operations.splice(0)) { + operation.resolve?.({ ok: false, error: new Error('Agent page unmounted') }); + } + } + for (const debt of coordinator.reconciliationDebt.values()) { + for (const waiter of debt.waiters.splice(0)) waiter.resolve({ ok: false, error: new Error('Agent page unmounted') }); + } + coordinator.reconciliationDebt.clear(); + coordinator.mutations.clear(); + }; + }, []); + + const scheduleSelectedReadRef = useRef< + ((identity: string, options?: { + expectedCodes?: readonly string[]; + debtOnly?: boolean; + causalFloor?: number; + refreshDefinitions?: boolean; + rollbackOnFailure?: boolean; + clearSelectionError?: boolean; + purpose?: SelectedReadPurpose; + obligationId?: number; + }) => Promise | null) + >(null); + const drainSelectedStagesRef = useRef<(() => void) | null>(null); + const retireSelectedIdentityRef = useRef< + ((identity: string, options?: { refreshDefinitions?: boolean; cause?: unknown }) => SelectedRetirement) | null + >(null); + + const commitSelected = useCallback((agent: VibeAgentFull | null) => { + const coordinator = selectedCoordinatorRef.current; + const previousIdentity = coordinator.accepted?.name; + if (previousIdentity) advanceSelectedRead(coordinator, previousIdentity); + if (agent?.name) advanceSelectedRead(coordinator, agent.name); + coordinator.acceptedGeneration += 1; + coordinator.accepted = agent; + if (agent?.name) { + coordinator.retired.delete(agent.name); + coordinator.retiredAtDefinitionsVersion.delete(agent.name); + if (coordinator.desiredSource === 'auto' && coordinator.desiredName === agent.name) { + // Auto-selection is consumed only by an authoritative publication. A + // failed attempt leaves the reason pending for the next external edge. + coordinator.autoSelectReason = null; + } } - try { - const result = await api.getVibeAgentOnboarding(); - setOnboardingInventory(result.available ? result : null); - } catch { - setOnboardingInventory(null); + setSelected(agent); + if (agent && coordinator.desiredName === agent.name && coordinator.desiredOpenDetail) { + setDetailOpen(true); } - }, [api, canOnboardAgents]); + }, []); - const refresh = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await api.listVibeAgents({ includeDisabled: true }); - setAgents(result.agents); - setDefaultName(result.default_agent_name); - // Keep the currently-selected agent fresh after edits / refreshes. - if (selected) { - const fresh = result.agents.find((a) => a.name === selected.name); - if (!fresh) setSelected(null); + const beginSelectedIntent = useCallback( + ( + identity: string | null, + options: { auto?: boolean; openDetail?: boolean; source?: 'user' | 'auto' | 'passive' } = {}, + ) => { + const coordinator = selectedCoordinatorRef.current; + const identityChanged = coordinator.desiredName !== identity; + if (coordinator.desiredName && identityChanged) { + releaseSelectedDebtWaiters(coordinator, coordinator.desiredName); + advanceSelectedRead(coordinator, coordinator.desiredName); } - } catch (err) { - setError(errorMessage(err) ?? String(err)); - } finally { - setLoading(false); + if (identity && identityChanged) advanceSelectedRead(coordinator, identity); + if (identity && !options.auto) { + coordinator.retired.delete(identity); + coordinator.retiredAtDefinitionsVersion.delete(identity); + coordinator.autoSelectDismissed = false; + coordinator.autoSelectReason = null; + } + if (identityChanged) coordinator.identityEpoch += 1; + coordinator.intentGeneration += 1; + coordinator.desiredName = identity; + coordinator.desiredOpenDetail = Boolean(identity && options.openDetail); + coordinator.desiredSource = options.source ?? (options.auto ? 'auto' : 'user'); + return { intentGeneration: coordinator.intentGeneration, identity }; + }, + [], + ); + + const rollbackSelectedIntent = useCallback(() => { + const coordinator = selectedCoordinatorRef.current; + if (coordinator.desiredName) advanceSelectedRead(coordinator, coordinator.desiredName); + const acceptedIdentity = coordinator.accepted?.name ?? null; + return beginSelectedIntent( + acceptedIdentity && !coordinator.retired.has(acceptedIdentity) ? acceptedIdentity : null, + { source: 'passive', openDetail: false }, + ); + }, [beginSelectedIntent]); + + + // Definitions refreshes are explicit reconciliation reads. They must always + // bypass the five-second client cache so a normal refresh cannot supersede a + // reconnect snapshot with a stale cached promise. + const refresh = useCallback(() => { + const token = definitionsVersionRef.current.begin(); + const barrier = new Promise((resolve) => { + const state = definitionsBarrierRef.current; + if (state.publishedVersion >= token.version) { + resolve(true); + } else { + state.waiters.push({ watermark: token.version, resolve }); + } + }); + const settleBarrier = (published: boolean) => { + const state = definitionsBarrierRef.current; + if (published) state.publishedVersion = Math.max(state.publishedVersion, token.version); + const remaining: DefinitionsBarrierWaiter[] = []; + for (const waiter of state.waiters) { + if (waiter.watermark <= token.version) waiter.resolve(published); + else remaining.push(waiter); + } + state.waiters = remaining; + }; + void (async (): Promise => { + setLoading(true); + clearResourceError('definitions'); + try { + const result = await api.listVibeAgents({ + includeDisabled: true, + cache: false, + }); + // A read issued before a stream gap may finish after the catch-up read. + // Only the latest request may publish its snapshot, so an older response + // cannot roll the Definitions list back to pre-gap state. + if (!definitionsVersionRef.current.isCurrent(token)) return; + const coordinator = selectedCoordinatorRef.current; + const visibleAgents = result.agents.filter((agent) => { + const retiredAt = coordinator.retiredAtDefinitionsVersion.get(agent.name); + if (retiredAt === undefined) return true; + if (token.version > retiredAt) { + coordinator.retired.delete(agent.name); + coordinator.retiredAtDefinitionsVersion.delete(agent.name); + return true; + } + return false; + }); + setAgents(visibleAgents); + setDefaultName(result.default_agent_name); + // List omission is authoritative retirement evidence. The coordinator + // owns all desired/accepted transitions; stale list responses cannot + // re-select an identity after retirement because the retired set is + // updated before the next render. + const currentIdentities = [coordinator.accepted?.name, coordinator.desiredName].filter( + (identity): identity is string => Boolean(identity), + ); + const retired = currentIdentities.filter( + (identity) => !result.agents.some((agent) => agent.name === identity) && !coordinator.mutations.has(identity), + ); + if (retired.length > 0) { + for (const identity of retired) { + const retirement = retireSelectedIdentityRef.current?.(identity, { refreshDefinitions: false }); + if (retirement?.resumedIdentity) { + const resumedDebt = coordinator.reconciliationDebt.get(retirement.resumedIdentity); + scheduleSelectedReadRef.current?.(retirement.resumedIdentity, { + expectedCodes: SELECTED_DISAPPEARANCE_CODES, + debtOnly: Boolean(resumedDebt), + rollbackOnFailure: false, + clearSelectionError: false, + purpose: 'continuation', + obligationId: catchUpEpochRef.current || token.version, + causalFloor: catchUpEpochRef.current || token.version, + }); + } + } + } + settleBarrier(true); + } catch (err) { + if (definitionsVersionRef.current.isCurrent(token)) { + setResourceError('definitions', errorMessage(err) ?? String(err)); + settleBarrier(false); + } + } finally { + if (definitionsVersionRef.current.isCurrent(token)) setLoading(false); + } + })(); + return barrier; + }, [api, clearResourceError, setResourceError]); + + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + + const retireSelectedIdentity = useCallback( + (identity: string, options: { refreshDefinitions?: boolean; cause?: unknown } = {}) => { + const coordinator = selectedCoordinatorRef.current; + if (coordinator.retired.has(identity)) { + return { retired: false, resumedIdentity: null, intentGeneration: coordinator.intentGeneration }; + } + const acceptedIdentity = coordinator.accepted?.name ?? null; + const pendingIdentity = coordinator.desiredName === identity; + const acceptedCanResume = Boolean( + acceptedIdentity && + acceptedIdentity !== identity && + !coordinator.retired.has(acceptedIdentity) && + !coordinator.autoSelectDismissed, + ); + coordinator.retired.add(identity); + coordinator.retiredAtDefinitionsVersion.set(identity, definitionsVersionRef.current.current()); + advanceSelectedRead(coordinator, identity); + // A detail-level tombstone is Definitions evidence too. Remove the row + // in the same transition, while the retirement watermark prevents an + // older in-flight list response from resurrecting it. + setAgents((current) => current.filter((agent) => agent.name !== identity)); + const debt = coordinator.reconciliationDebt.get(identity); + coordinator.reconciliationDebt.delete(identity); + for (const waiter of debt?.waiters.splice(0) ?? []) { + waiter.resolve({ ok: false, error: options.cause ?? { code: 'agent_not_found' } }); + } + if (pendingIdentity) { + // A pending selection can disappear without invalidating the accepted + // entity. Keep that accepted entity eligible for reconnect/debt + // reconciliation instead of leaving visible A with no desired A. + beginSelectedIntent(acceptedCanResume ? acceptedIdentity : null, { + source: 'passive', + openDetail: false, + }); + } + if (coordinator.accepted?.name === identity) { + coordinator.desiredOpenDetail = false; + coordinator.desiredSource = 'passive'; + commitSelected(null); + } + if (!coordinator.desiredName && !coordinator.accepted && !coordinator.autoSelectDismissed) { + coordinator.autoSelectReason = 'replacement'; + } + clearResourceError('selection'); + if (options.refreshDefinitions !== false) { + // The causal follow-up itself must not resurrect the tombstone. Mark + // the identity through the version that this refresh is about to + // publish; a later external Definitions edge may reintroduce it. + const followUpVersion = definitionsVersionRef.current.invalidate() + 1; + coordinator.retiredAtDefinitionsVersion.set(identity, followUpVersion); + void refreshRef.current(); + } + return { + retired: true, + resumedIdentity: pendingIdentity && acceptedCanResume ? acceptedIdentity : null, + intentGeneration: coordinator.intentGeneration, + }; + }, + [beginSelectedIntent, clearResourceError, commitSelected], + ); + retireSelectedIdentityRef.current = retireSelectedIdentity; + + // A successful rename keeps the stable entity id while changing the + // transport key used by every read and mutation. Migrate all coordinator + // state in one transition so an old-name response can never republish. + const migrateSelectedIdentity = useCallback( + (oldName: string, newName: string, stableId: string) => { + if (!oldName || !newName || oldName === newName) return; + const coordinator = selectedCoordinatorRef.current; + const accepted = coordinator.accepted; + const acceptedMatches = accepted?.id === stableId && accepted.name === oldName; + const desiredMatches = coordinator.desiredName === oldName; + if (!acceptedMatches && !desiredMatches) return; + + coordinator.retired.add(oldName); + advanceSelectedRead(coordinator, oldName); + + const oldDebt = coordinator.reconciliationDebt.get(oldName); + coordinator.reconciliationDebt.delete(oldName); + if (oldDebt) { + coordinator.reconciliationDebt.set(newName, { ...oldDebt, scheduled: false }); + } + + const oldBatch = coordinator.mutations.get(oldName); + if (oldBatch) { + coordinator.mutations.delete(oldName); + oldBatch.identity = newName; + coordinator.mutations.set(newName, oldBatch); + } + + coordinator.identityEpoch += 1; + coordinator.intentGeneration += 1; + if (desiredMatches) coordinator.desiredName = newName; + if (acceptedMatches && accepted) { + commitSelected({ ...accepted, name: newName, display_name: newName }); + } + advanceSelectedRead(coordinator, newName); + }, + [commitSelected], + ); + + const launchSelectedRead = useCallback( + async (options: { + identity: string; + identityEpoch: number; + causalFloor: number; + readGeneration: number; + intentGeneration?: number; + expectedCodes?: readonly string[]; + debtVersion?: number; + refreshDefinitions?: boolean; + rollbackOnFailure?: boolean; + clearSelectionError?: boolean; + }): Promise => { + const coordinator = selectedCoordinatorRef.current; + const isCurrent = () => + selectedMountedRef.current && + selectedReadIsCurrent(coordinator, options.identity, options.readGeneration) && + coordinator.identityEpoch === options.identityEpoch && + coordinator.desiredName === options.identity && + !coordinator.retired.has(options.identity); + const finishDebt = (published: boolean, error?: unknown) => { + if (options.debtVersion === undefined) return; + const debt = coordinator.reconciliationDebt.get(options.identity); + if (!debt || debt.version !== options.debtVersion) return; + if (!published) { + debt.scheduled = false; + const waiters = debt.waiters.splice(0); + for (const waiter of waiters) { + waiter.resolve({ ok: false, error }); + } + return; + } + coordinator.reconciliationDebt.delete(options.identity); + const waiters = debt.waiters.splice(0); + for (const waiter of waiters) { + waiter.resolve({ ok: true }); + } + }; + const continuationFor = () => { + if (options.rollbackOnFailure === false) return undefined; + const rollback = rollbackSelectedIntent(); + return rollback.identity && rollback.identity !== options.identity + ? { nextIdentity: rollback.identity, intentGeneration: rollback.intentGeneration } + : undefined; + }; + // All selected-read terminal states pass through this one owner. The + // executor only obtains a server result; retirement, debt completion, + // rollback and error publication are decided here for both transport + // shapes (HTTP ok:false and rejected requests). + const finalizeSelectedRead = (value: unknown): SelectedReadOutcome => { + if (!isCurrent()) return { kind: 'stale' }; + const result = value as { + ok?: boolean; + agent?: VibeAgentFull; + } | null; + if (result?.ok && result.agent?.name === options.identity) { + if (options.clearSelectionError !== false) clearResourceError('selection'); + commitSelected(result.agent); + finishDebt(true); + return { kind: 'published' }; + } + const code = errorCodeOf(value); + if ((options.expectedCodes ?? SELECTED_DISAPPEARANCE_CODES).includes(code ?? '')) { + const retirement = retireSelectedIdentityRef.current?.(options.identity, { + refreshDefinitions: options.refreshDefinitions === true, + cause: value, + }); + return { + kind: 'expected-retired', + continuation: retirement?.resumedIdentity + ? { + nextIdentity: retirement.resumedIdentity, + intentGeneration: retirement.intentGeneration, + } + : undefined, + }; + } + const continuation = continuationFor(); + setResourceError('selection', errorMessage(value) || tRef.current('errorBoundary.title')); + finishDebt(false, value); + return { kind: 'failed', error: value, continuation }; + }; + try { + const params = options.expectedCodes ? { cache: false, expectedCodes: options.expectedCodes } : { cache: false }; + return finalizeSelectedRead(await api.getVibeAgent(options.identity, params)); + } catch (err) { + return finalizeSelectedRead(err); + } + }, + [api, clearResourceError, commitSelected, rollbackSelectedIntent, setResourceError], + ); + + const scheduleSelectedRead = useCallback( + ( + identity: string, + options: { + expectedCodes?: readonly string[]; + debtOnly?: boolean; + causalFloor?: number; + refreshDefinitions?: boolean; + rollbackOnFailure?: boolean; + clearSelectionError?: boolean; + purpose?: SelectedReadPurpose; + obligationId?: number; + } = {}, + ): Promise | null => { + if (!selectedMountedRef.current || !identity) return null; + const coordinator = selectedCoordinatorRef.current; + if (coordinator.retired.has(identity) || coordinator.desiredName !== identity) return null; + const purpose = options.purpose ?? 'selection'; + const debt = coordinator.reconciliationDebt.get(identity); + if (options.debtOnly && !debt) return null; + if (coordinator.mutations.has(identity) && options.debtOnly) return null; + // A selected-detail stage is the physical producer. Its purpose and + // obligation are consumer metadata; the scheduler derives the causal + // floor from the current identity debt so a same-agent selection can + // join a live debt read instead of creating a debt-blind replacement. + const debtVersion = debt?.version; + const identityEpoch = coordinator.identityEpoch; + const causalFloor = options.causalFloor ?? 0; + const currentReadGeneration = coordinator.readGenerations.get(identity) ?? 0; + const obligationId = options.obligationId ?? ++coordinator.nextObligationId; + const intentGeneration = coordinator.intentGeneration; + const existing = [...coordinator.stages.values()].find( + (stage) => + !stage.invalidated && + !stage.settled && + stage.identity === identity && + stage.identityEpoch === identityEpoch && + stage.readGeneration === currentReadGeneration && + stage.causalFloor >= causalFloor && + (stage.debtVersion ?? 0) >= (debtVersion ?? 0), + ); + if (existing) { + if (debtVersion !== undefined) debt!.scheduled = true; + return existing.promise; + } + + // A new producer is the only place that advances an identity's read + // generation. This invalidates every older stage, including a lower-floor + // read that started before a newer mutation created reconciliation debt. + const nextReadGeneration = advanceSelectedRead(coordinator, identity); + if (debtVersion !== undefined) debt!.scheduled = true; + const key = [identity, identityEpoch, nextReadGeneration, causalFloor, debtVersion ?? '-', purpose, obligationId].join('::'); + let resolveStage!: (outcome: SelectedReadOutcome) => void; + const promise = new Promise((resolve) => { + resolveStage = resolve; + }); + const stage: SelectedReadStage = { + key, + identity, + identityEpoch, + causalFloor, + intentGeneration, + readGeneration: nextReadGeneration, + debtVersion, + obligationId, + purpose, + expectedCodes: options.expectedCodes, + debtOnly: Boolean(options.debtOnly), + refreshDefinitions: Boolean(options.refreshDefinitions), + rollbackOnFailure: options.rollbackOnFailure !== false, + clearSelectionError: options.clearSelectionError !== false, + invalidated: false, + promise, + resolve: resolveStage, + settled: false, + }; + coordinator.stages.set(key, stage); + coordinator.stageQueue.push(stage); + drainSelectedStagesRef.current?.(); + return promise; + }, + [], + ); + scheduleSelectedReadRef.current = scheduleSelectedRead; + + const drainSelectedStages = useCallback(() => { + const coordinator = selectedCoordinatorRef.current; + if (coordinator.stageDrainActive) return; + coordinator.stageDrainActive = true; + while (selectedMountedRef.current && coordinator.stageQueue.length > 0) { + const stage = coordinator.stageQueue.shift()!; + if (stage.invalidated || !selectedMountedRef.current) { + stage.settled = true; + stage.resolve({ kind: 'stale' }); + if (coordinator.stages.get(stage.key) === stage) coordinator.stages.delete(stage.key); + continue; + } + void (async () => { + const outcome = await launchSelectedRead({ + identity: stage.identity, + identityEpoch: stage.identityEpoch, + causalFloor: stage.causalFloor, + readGeneration: stage.readGeneration, + intentGeneration: stage.intentGeneration, + expectedCodes: stage.expectedCodes, + debtVersion: stage.debtVersion, + refreshDefinitions: stage.refreshDefinitions, + rollbackOnFailure: stage.rollbackOnFailure, + clearSelectionError: stage.clearSelectionError, + }); + stage.settled = true; + stage.resolve(outcome); + if (coordinator.stages.get(stage.key) === stage) coordinator.stages.delete(stage.key); + const continuation = outcome.continuation; + if ( + !continuation || + !selectedMountedRef.current || + coordinator.intentGeneration !== continuation.intentGeneration || + coordinator.desiredName !== continuation.nextIdentity + ) { + drainSelectedStagesRef.current?.(); + return; + } + const attempted = coordinator.obligationAttempts.get(stage.obligationId) ?? new Set(); + const nextDebt = coordinator.reconciliationDebt.get(continuation.nextIdentity); + // A failed/retired user selection rolls back the visible intent but + // does not start a passive read immediately. Reconnect or an existing + // mutation debt is the external edge that may reconcile the fallback. + if (stage.purpose === 'selection' && !nextDebt) { + drainSelectedStagesRef.current?.(); + return; + } + if (!attempted.has(continuation.nextIdentity)) { + attempted.add(continuation.nextIdentity); + coordinator.obligationAttempts.set(stage.obligationId, attempted); + scheduleSelectedReadRef.current?.(continuation.nextIdentity, { + expectedCodes: stage.expectedCodes ?? SELECTED_DISAPPEARANCE_CODES, + debtOnly: Boolean(nextDebt), + refreshDefinitions: false, + rollbackOnFailure: false, + clearSelectionError: false, + purpose: 'continuation', + obligationId: stage.obligationId, + causalFloor: stage.causalFloor, + }); + } + drainSelectedStagesRef.current?.(); + })(); } - }, [api, selected]); + coordinator.stageDrainActive = false; + }, [launchSelectedRead]); + drainSelectedStagesRef.current = () => { + void drainSelectedStages(); + }; + + const beginSelectedMutation = useCallback((identity: string) => { + const coordinator = selectedCoordinatorRef.current; + let batch = coordinator.mutations.get(identity); + if (!batch) { + batch = { + id: ++coordinator.nextBatchId, + identity, + pending: 0, + version: ++coordinator.nextMutationVersion, + operations: [], + }; + coordinator.mutations.set(identity, batch); + } + beginResourceError('mutation'); + batch.pending += 1; + batch.version = ++coordinator.nextMutationVersion; + // Keep the error watermark monotonic across batches. A delayed drain from + // older work must never republish after this operation has begun. + mutationErrorSequenceRef.current = Math.max(mutationErrorSequenceRef.current, batch.version); + // A pre-mutation Definitions snapshot cannot publish after the detail + // barrier. The settled transaction starts the next list publication. + definitionsVersionRef.current.invalidate(); + const debt = coordinator.reconciliationDebt.get(identity); + if (debt) debt.scheduled = false; + advanceSelectedRead(coordinator, identity); + const operation = { id: ++coordinator.nextOperationId, batchId: batch.id, identity, sequence: batch.version }; + batch.operations.push(operation); + return operation; + }, [beginResourceError]); + + const settleSelectedMutation = useCallback( + (operation: SelectedMutationOperation, failure?: unknown): Promise => { + if (!selectedMountedRef.current) return Promise.resolve({ ok: false, error: new Error('Agent page unmounted') }); + const coordinator = selectedCoordinatorRef.current; + const batch = [...coordinator.mutations.values()].find((candidate) => candidate.id === operation.batchId); + if (!batch) return Promise.resolve({ ok: false, error: new Error('Agent mutation is no longer current') }); + if (failure) operation.failure = failure; + batch.pending = Math.max(0, batch.pending - 1); + if (batch.pending !== 0) { + return new Promise((resolve) => { + operation.resolve = resolve; + }); + } + + const identity = batch.identity; + const version = batch.version; + coordinator.mutations.delete(identity); + const operations = [...batch.operations]; + for (const candidate of operations) { + if (candidate.failure !== undefined) { + // The batch is one publication edge, so expose a PATCH failure at + // the batch watermark even when that operation started before a + // sibling. Caller completion still uses the operation's own error. + publishMutationError(candidate.failure, batch.version); + } + } + const priorDebt = coordinator.reconciliationDebt.get(identity); + if (!coordinator.retired.has(identity)) { + coordinator.reconciliationDebt.set(identity, { + version, + scheduled: false, + waiters: priorDebt?.waiters ?? [], + }); + } + const currentDebt = coordinator.reconciliationDebt.get(identity); + const shouldDrain = Boolean( + currentDebt && + coordinator.desiredName === identity && + !coordinator.retired.has(identity), + ); + for (const candidate of operations) { + candidate.joinedDetailBarrier = shouldDrain && candidate.failure === undefined; + } + const transaction = (async (): Promise => { + let detailResult: SelectedMutationResult = { ok: true }; + if (shouldDrain && currentDebt) { + const completion = new Promise((resolve) => { + currentDebt.waiters.push({ resolve }); + }); + const drain = scheduleSelectedReadRef.current?.(identity, { + debtOnly: true, + expectedCodes: SELECTED_DISAPPEARANCE_CODES, + rollbackOnFailure: false, + purpose: 'debt', + obligationId: version, + }); + if (!drain && !currentDebt.scheduled) { + const waiters = currentDebt.waiters.splice(0); + for (const waiter of waiters) waiter.resolve({ ok: true }); + } + detailResult = await completion; + } + // A brief list captured before the authoritative detail drain must not + // publish after that detail. The barrier joins any newer list request. + await refreshRef.current(); + return detailResult; + })(); + const resultFor = (candidate: SelectedMutationOperation): Promise => transaction.then((detailResult) => { + if (candidate.failure !== undefined) { + return { ok: false, error: candidate.failure }; + } + if (candidate.joinedDetailBarrier && !detailResult.ok) { + publishMutationError(detailResult.error, candidate.sequence); + return detailResult; + } + return { ok: true }; + }); + for (const candidate of operations) { + if (candidate.resolve) void resultFor(candidate).then(candidate.resolve); + } + return resultFor(operation); + }, + [publishMutationError], + ); + + const reconcileSelected = useCallback(async (edgeEpoch?: number, causalFloor = 0) => { + const coordinator = selectedCoordinatorRef.current; + const identity = coordinator.desiredName; + if (!identity || coordinator.mutations.has(identity)) return; + if (edgeEpoch !== undefined && catchUpEpochRef.current !== edgeEpoch) return; + if ( + edgeEpoch !== undefined && + [...coordinator.stages.values()].some( + (stage) => + !stage.invalidated && + !stage.settled && + stage.identity === identity && + stage.obligationId === edgeEpoch && + stage.causalFloor >= causalFloor, + ) + ) return; + const debt = coordinator.reconciliationDebt.get(identity); + scheduleSelectedReadRef.current?.(identity, { + expectedCodes: SELECTED_DISAPPEARANCE_CODES, + refreshDefinitions: true, + clearSelectionError: true, + debtOnly: Boolean(debt), + purpose: 'reconcile', + obligationId: edgeEpoch ?? ++coordinator.nextObligationId, + causalFloor, + }); + }, []); + + const reconcileGap = useCallback(async () => { + const edgeEpoch = ++catchUpEpochRef.current; + const applied = await refreshRef.current(); + if (!applied || !selectedMountedRef.current || catchUpEpochRef.current !== edgeEpoch) return; + await reconcileSelected(edgeEpoch, edgeEpoch); + }, [reconcileSelected]); useEffect(() => { + selectedMountedRef.current = true; refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -174,9 +1106,18 @@ export const AgentsPage: React.FC = () => { // something to show — eliminates the empty "select an agent" state // that confused users on first visit. useEffect(() => { - if (selected || agents.length === 0) return; - const target = (defaultName && agents.find((a) => a.name === defaultName)) || agents[0]; - if (target) selectAgent(target.name); + const coordinator = selectedCoordinatorRef.current; + if ( + !coordinator.autoSelectReason || + coordinator.autoSelectDismissed || + selected || + coordinator.accepted || + coordinator.desiredName || + agents.length === 0 + ) return; + const available = agents.filter((agent) => !coordinator.retired.has(agent.name)); + const target = (defaultName && available.find((a) => a.name === defaultName)) || available[0]; + if (target) void selectAgent(target.name, false, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultName, agents]); @@ -223,16 +1164,22 @@ export const AgentsPage: React.FC = () => { // Every gap ends here, whichever leg it was on, so this is the catch-up. // The bridge report is only the indicator's level: it comes with its own // `onConnected`, and refetching from both would pay twice for one gap. - onConnected: () => fetchRunningActiveCount(), + onConnected: () => { + void reconcileGap(); + void refreshOnboarding(); + void fetchRunningActiveCount(); + }, onEventBridgeStatus: ({ connected }) => setEventBridgeConnected(connected), onError: () => setEventBridgeConnected(false), onRunsUpdated: () => fetchRunningActiveCount(), onTurnStart: () => fetchRunningActiveCount(), onTurnEnd: () => fetchRunningActiveCount(), onSessionStatus: () => fetchRunningActiveCount(), - onAuthorizationChanged: () => void refresh(), + onAuthorizationChanged: () => { + void reconcileGap(); + }, }); - }, [api, capabilities.can_use_agents, fetchRunningActiveCount, refresh]); + }, [api, capabilities.can_use_agents, fetchRunningActiveCount, reconcileGap, refreshOnboarding]); useEffect(() => { if (!capabilities.can_use_agents) return; @@ -282,22 +1229,28 @@ export const AgentsPage: React.FC = () => { }, [capabilities.can_use_agents, eventBridgeConnected, fetchRunningActiveCount]); const selectAgent = useCallback( - async (name: string, openDetail = false) => { - try { - const result = await api.getVibeAgent(name); - if (result.ok) { - setSelected(result.agent); - // Enter the mobile drill-down only once the detail has actually loaded — - // never optimistically, or a failed fetch hides the list with no panel. - if (openDetail) setDetailOpen(true); - } - } catch (err) { - setError(errorMessage(err) ?? String(err)); - } + async (name: string, openDetail = false, auto = false) => { + beginSelectedIntent(name, { auto, openDetail, source: auto ? 'auto' : 'user' }); + clearResourceError('selection'); + await scheduleSelectedReadRef.current?.(name, { + expectedCodes: SELECTED_DISAPPEARANCE_CODES, + refreshDefinitions: true, + purpose: auto ? 'selection' : 'selection', + obligationId: selectedCoordinatorRef.current.intentGeneration, + }); }, - [api], + [beginSelectedIntent, clearResourceError], ); + const dismissSelected = useCallback(() => { + const coordinator = selectedCoordinatorRef.current; + beginSelectedIntent(null); + coordinator.autoSelectDismissed = true; + coordinator.autoSelectReason = null; + commitSelected(null); + setDetailOpen(false); + }, [beginSelectedIntent, commitSelected]); + // Apply text search + backend filter; backend grouping is a layout // concern that operates on the filtered set. const filtered = useMemo(() => { @@ -323,21 +1276,30 @@ export const AgentsPage: React.FC = () => { }, [filtered]); const onCreated = (agent: VibeAgentFull) => { - refresh().then(() => setSelected(agent)); + beginSelectedIntent(agent.name); + commitSelected(agent); + void refresh(); void refreshOnboarding(); }; - const updateField = async (patch: VibeAgentUpdatePayload) => { + const updateField = async (patch: VibeAgentUpdatePayload): Promise => { if (!selected) return; + const name = selected.name; + const operation = beginSelectedMutation(name); + let mutationSettled = false; try { - const result = await api.updateVibeAgent(selected.name, patch); - if (result.ok) { - setSelected(result.agent); - refresh(); - } + const result = await api.updateVibeAgent(name, patch); + const settled = await settleSelectedMutation(operation, result.ok ? undefined : result); + mutationSettled = true; + if (!settled.ok) throw settled.error; } catch (err) { - setError(errorMessage(err) ?? String(err)); + if (!mutationSettled) { + const settled = await settleSelectedMutation(operation, err); + mutationSettled = true; + if (!settled.ok) throw settled.error; + } + throw err; } }; @@ -351,28 +1313,47 @@ export const AgentsPage: React.FC = () => { refresh(); }; - // After a rename (clone-then-delete) the list is stale: the old name lingers - // and the new one is missing. Refresh and re-select the renamed agent. - const onRenamed = (newName: string) => { - refresh().then(() => selectAgent(newName)); - void refreshOnboarding(); + // Rename is a selected mutation too: migrate the stable entity before the + // batch settles so its authoritative drain routes through the new name. + const onRename = async (newName: string) => { + if (!selected) return; + const oldName = selected.name; + const operation = beginSelectedMutation(oldName); + let settled = false; + try { + const result = await api.updateVibeAgent(oldName, { name: newName }); + if (!result.ok) { + await settleSelectedMutation(operation, result); + settled = true; + throw result; + } + migrateSelectedIdentity(oldName, newName, selected.id); + const mutationResult = await settleSelectedMutation(operation); + settled = true; + if (!mutationResult.ok) throw mutationResult.error; + void refreshOnboarding(); + } catch (err) { + if (!settled) await settleSelectedMutation(operation, err); + throw err; + } }; const onDelete = async () => { if (!selected || isSystemAgent(selected)) return; const confirmed = window.confirm(t('agents.deleteConfirm', { name: selected.name })); if (!confirmed) return; + const name = selected.name; + const errorGeneration = beginResourceError('mutation'); try { - const result = await api.removeVibeAgent(selected.name); + const result = await api.removeVibeAgent(name); if (result.ok) { - setSelected(null); - refresh(); + retireSelectedIdentityRef.current?.(name, { refreshDefinitions: true }); void refreshOnboarding(); - } else if (result.message) { - setError(result.message); + } else { + setResourceError('mutation', errorMessage(result) || t('errorBoundary.title'), errorGeneration); } } catch (err) { - setError(errorMessage(err) ?? String(err)); + setResourceError('mutation', errorMessage(err) || t('errorBoundary.title'), errorGeneration); } }; @@ -411,9 +1392,9 @@ export const AgentsPage: React.FC = () => { const onOnboardAgents = async () => { if (onboardingSubmitting) return; setOnboardingSubmitting(true); + onboardingVersionRef.current.invalidate(); try { const result = await api.onboardVibeAgents(); - setOnboardingInventory(result); showToast( result.sync?.ok === false ? t('agents.onboarding.savedPending') @@ -423,6 +1404,14 @@ export const AgentsPage: React.FC = () => { } catch (err) { showToast(t('agents.onboarding.failed', { error: errorMessage(err) ?? String(err) }), 'error'); } finally { + let settlement = refreshOnboarding(); + await settlement; + while (selectedMountedRef.current) { + const latest = onboardingSettlementRef.current; + if (!latest || latest === settlement) break; + settlement = latest; + await settlement; + } setOnboardingSubmitting(false); } }; @@ -447,7 +1436,6 @@ export const AgentsPage: React.FC = () => { void refresh(); void refreshOnboarding(); }} - disabled={loading} > {t('common.refresh')} @@ -615,14 +1603,15 @@ export const AgentsPage: React.FC = () => { {selected && (
{ setSelected(null); setDetailOpen(false); }} + onClose={dismissSelected} />
)} @@ -895,9 +1884,9 @@ interface DetailProps { isDefault: boolean; /** False on remote instances, where every mutating control is unavailable. */ canEdit: boolean; - onChange: (patch: VibeAgentUpdatePayload) => void; + onChange: (patch: VibeAgentUpdatePayload) => Promise; onSetDefault: () => Promise; - onRenamed: (newName: string) => void; + onRename: (newName: string) => Promise; onDelete: () => void; onClose: () => void; } @@ -908,7 +1897,7 @@ interface DetailProps { // for user agents. The backend renames the row and its references atomically; // system agents keep their locked identity. On a remote instance `canEdit` is // false and the panel degrades to a read-only view of the same fields. -const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, onChange, onSetDefault, onRenamed, onDelete, onClose }) => { +const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, onChange, onSetDefault, onRename, onDelete, onClose }) => { const { t } = useTranslation(); const api = useApi(); const { showToast } = useToast(); @@ -924,6 +1913,33 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on const [systemPrompt, setSystemPrompt] = useState(agent.system_prompt ?? ''); const [systemPromptOpen, setSystemPromptOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [editorSeedRevision, setEditorSeedRevision] = useState(0); + const editorDraftRef = useRef(agent.system_prompt ?? ''); + const editorDirtyRef = useRef(false); + const editorClosingRef = useRef(false); + const editorBaselineRef = useRef(agent.system_prompt ?? ''); + type EditableField = 'name' | 'description' | 'model' | 'effort' | 'systemPrompt'; + const fieldRevisionRef = useRef>({ + name: 0, + description: 0, + model: 0, + effort: 0, + systemPrompt: 0, + }); + const submittedRevisionRef = useRef>({ + name: 0, + description: 0, + model: 0, + effort: 0, + systemPrompt: 0, + }); + const pendingRevisionRef = useRef>({ + name: null, + description: null, + model: null, + effort: null, + systemPrompt: null, + }); const [modelCatalogs, setModelCatalogs] = useState< Record< string, @@ -935,19 +1951,72 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on >({}); const [running, setRunning] = useState(false); + const serverSnapshotRef = useRef({ + id: agent.id, + name: agent.name, + description: agent.description ?? '', + model: agent.model ?? '', + effort: agent.reasoning_effort ?? 'medium', + systemPrompt: agent.system_prompt ?? '', + }); + const activeModelCatalog = modelCatalogs[agent.backend]; const modelOptions = activeModelCatalog?.modelOptions ?? []; const reasoningOptions = activeModelCatalog?.reasoningOptions ?? {}; useEffect(() => { - setName(agent.name); - setDescription(agent.description ?? ''); - setModel(agent.model ?? ''); - setEffort(agent.reasoning_effort ?? 'medium'); - setSystemPrompt(agent.system_prompt ?? ''); - setSystemPromptOpen(false); - setEditorOpen(false); - }, [agent.id]); + const previous = serverSnapshotRef.current; + const next = { + id: agent.id, + name: agent.name, + description: agent.description ?? '', + model: agent.model ?? '', + effort: agent.reasoning_effort ?? 'medium', + systemPrompt: agent.system_prompt ?? '', + }; + const snapshotChanged = + previous.id !== next.id || + previous.name !== next.name || + previous.description !== next.description || + previous.model !== next.model || + previous.effort !== next.effort || + previous.systemPrompt !== next.systemPrompt; + if (!snapshotChanged) return; + if (previous.id !== next.id) { + fieldRevisionRef.current = { name: 0, description: 0, model: 0, effort: 0, systemPrompt: 0 }; + submittedRevisionRef.current = { name: 0, description: 0, model: 0, effort: 0, systemPrompt: 0 }; + pendingRevisionRef.current = { name: null, description: null, model: null, effort: null, systemPrompt: null }; + setName(next.name); + setDescription(next.description); + setModel(next.model); + setEffort(next.effort); + setSystemPrompt(next.systemPrompt); + setSystemPromptOpen(false); + setEditorOpen(false); + editorClosingRef.current = false; + editorDraftRef.current = next.systemPrompt; + editorBaselineRef.current = next.systemPrompt; + editorDirtyRef.current = false; + setEditorSeedRevision((revision) => revision + 1); + } else { + // Same-identity reconciliation compares raw text drafts to the raw + // accepted baseline. Submit-time trimming belongs only to the blur/save + // boundary, so an active leading/trailing-space draft remains visible. + const pending = pendingRevisionRef.current; + if (pending.name === null && name === previous.name) setName(next.name); + if (pending.description === null && description === previous.description) setDescription(next.description); + if (pending.model === null && model === previous.model) setModel(next.model); + if (pending.effort === null && effort === previous.effort) setEffort(next.effort); + if (pending.systemPrompt === null && systemPrompt === previous.systemPrompt) setSystemPrompt(next.systemPrompt); + if (!editorDirtyRef.current && editorOpen) { + editorDraftRef.current = next.systemPrompt; + editorBaselineRef.current = next.systemPrompt; + editorDirtyRef.current = false; + setEditorSeedRevision((revision) => revision + 1); + } + } + serverSnapshotRef.current = next; + }, [agent.id, agent.name, agent.description, agent.model, agent.reasoning_effort, agent.system_prompt, editorOpen, name, description, model, effort, systemPrompt]); // Load model catalog for the agent's backend so the Combobox can offer // suggestions. Keeps `allowCustomValue` so users can type a model the @@ -982,27 +2051,104 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on const systemPromptTokens = estimateTokens(systemPrompt); // Effort options follow the backend + selected model when the catalog provides them. const effortOptions = resolveEffortOptions(agent.backend, model, reasoningOptions); + const markFieldEdit = (field: keyof typeof fieldRevisionRef.current) => { + fieldRevisionRef.current[field] += 1; + return fieldRevisionRef.current[field]; + }; + const markFieldSubmitted = (field: keyof typeof submittedRevisionRef.current) => { + submittedRevisionRef.current[field] = fieldRevisionRef.current[field]; + }; + const cancelFieldEdit = (field: EditableField, value: string) => { + const revision = fieldRevisionRef.current[field]; + pendingRevisionRef.current[field] = null; + submittedRevisionRef.current[field] = revision; + if (fieldRevisionRef.current[field] === revision) { + if (field === 'name') setName(value); + if (field === 'description') setDescription(value); + if (field === 'model') setModel(value); + if (field === 'effort') setEffort(value); + if (field === 'systemPrompt') setSystemPrompt(value); + } + }; + const submitFields = (patch: VibeAgentUpdatePayload, fields: EditableField[]): Promise => { + const revisions = fields.map((field) => { + const revision = fieldRevisionRef.current[field]; + pendingRevisionRef.current[field] = revision; + return { field, revision }; + }); + return onChange(patch).then(() => { + for (const { field, revision } of revisions) { + if (pendingRevisionRef.current[field] !== revision) continue; + pendingRevisionRef.current[field] = null; + submittedRevisionRef.current[field] = revision; + if (fieldRevisionRef.current[field] !== revision) continue; + const snapshot = serverSnapshotRef.current; + if (field === 'name') setName(snapshot.name); + if (field === 'description') setDescription(snapshot.description ?? ''); + if (field === 'model') setModel(snapshot.model ?? ''); + if (field === 'effort') setEffort(snapshot.effort ?? 'medium'); + if (field === 'systemPrompt') { + const value = snapshot.systemPrompt ?? ''; + setSystemPrompt(value); + if (!editorDirtyRef.current && editorOpen) { + editorDraftRef.current = value; + editorBaselineRef.current = value; + setEditorSeedRevision((seed) => seed + 1); + } + } + } + }).catch((err) => { + // A PATCH or its authoritative drain failed. The caller must see the + // rejection, while the latest server snapshot owns any field without a + // newer local edit. Keep an open modal's private draft untouched. + for (const { field, revision } of revisions) { + if (pendingRevisionRef.current[field] !== revision) continue; + pendingRevisionRef.current[field] = null; + submittedRevisionRef.current[field] = revision; + if (fieldRevisionRef.current[field] !== revision) continue; + const snapshot = serverSnapshotRef.current; + if (field === 'name') setName(snapshot.name); + if (field === 'description') setDescription(snapshot.description ?? ''); + if (field === 'model') setModel(snapshot.model ?? ''); + if (field === 'effort') setEffort(snapshot.effort ?? 'medium'); + if (field === 'systemPrompt') setSystemPrompt(snapshot.systemPrompt ?? ''); + } + throw err; + }); + }; + + // Inline controls are background mutations: the coordinator records and + // renders failures, while this single owner consumes the rejection so JSX + // callbacks never create unhandled promises. The modal save intentionally + // bypasses this helper so EditorDialog can keep a failed draft open. + const consumeBackgroundMutation = (operation: Promise) => { + void operation.catch(() => undefined); + }; // Only user Agents can be renamed; the backend moves every durable name // reference in the same transaction. const commitRename = async () => { const trimmed = name.trim(); if (!trimmed || trimmed === agent.name) { - setName(agent.name); + cancelFieldEdit('name', serverSnapshotRef.current.name); return; } if (locked) { - setName(agent.name); + cancelFieldEdit('name', serverSnapshotRef.current.name); return; } + const revision = fieldRevisionRef.current.name; + pendingRevisionRef.current.name = revision; + submittedRevisionRef.current.name = revision; setRenaming(true); try { - await api.updateVibeAgent(agent.name, { name: trimmed }); + await onRename(trimmed); + pendingRevisionRef.current.name = null; + if (fieldRevisionRef.current.name === revision) setName(trimmed); showToast(t('agents.renameSuccess'), 'success'); - onRenamed(trimmed); } catch (err) { - showToast(errorMessage(err) ?? String(err), 'error'); - setName(agent.name); + showToast(errorMessage(err) ?? t('errorBoundary.title'), 'error'); + cancelFieldEdit('name', serverSnapshotRef.current.name); } finally { setRenaming(false); } @@ -1058,7 +2204,9 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on onChange({ enabled: next })} + onCheckedChange={(next) => { + consumeBackgroundMutation(onChange({ enabled: next })); + }} disabled={!canEdit} title={canEdit ? undefined : t('agents.remoteReadOnlyHint')} label={t('agents.detail.enabled')} @@ -1105,11 +2253,14 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on
setName(e.target.value)} + onChange={(e) => { + markFieldEdit('name'); + setName(e.target.value); + }} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - if (e.key === 'Escape') setName(agent.name); + if (e.key === 'Escape') cancelFieldEdit('name', serverSnapshotRef.current.name); }} disabled={locked || renaming} title={lockHint} @@ -1125,11 +2276,20 @@ const AgentDetailPanel: React.FC = ({ agent, isDefault, canEdit, on