🐛 fix: default execAgent approval mode to headless (#13873)

* 🐛 fix: default execAgent approval mode to headless

Backend execAgent calls should run headlessly by default since only
frontend scenarios require manual human approval. This prevents cron
jobs and other server-side triggers from unexpectedly waiting for
human intervention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  test: add regression test for headless approval default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-16 14:05:53 +08:00 committed by GitHub
parent 4203e32dc7
commit ab05020f62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 174 additions and 1 deletions

View file

@ -0,0 +1,173 @@
import type * as ModelBankModule from 'model-bank';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AiAgentService } from '../index';
const { mockCreateOperation, mockGetAgentConfig, mockMessageCreate } = vi.hoisted(() => ({
mockCreateOperation: vi.fn(),
mockGetAgentConfig: vi.fn(),
mockMessageCreate: vi.fn(),
}));
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined),
isTrustedClientEnabled: vi.fn().mockReturnValue(false),
}));
vi.mock('@/database/models/message', () => ({
MessageModel: vi.fn().mockImplementation(() => ({
create: mockMessageCreate,
query: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
})),
}));
vi.mock('@/database/models/agent', () => ({
AgentModel: vi.fn().mockImplementation(() => ({
getAgentConfig: vi.fn(),
queryAgents: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('@/server/services/agent', () => ({
AgentService: vi.fn().mockImplementation(() => ({
getAgentConfig: mockGetAgentConfig,
})),
}));
vi.mock('@/database/models/plugin', () => ({
PluginModel: vi.fn().mockImplementation(() => ({
query: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn().mockImplementation(() => ({
create: vi.fn().mockResolvedValue({ id: 'topic-1' }),
})),
}));
vi.mock('@/database/models/thread', () => ({
ThreadModel: vi.fn().mockImplementation(() => ({
create: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
})),
}));
vi.mock('@/server/services/agentRuntime', () => ({
AgentRuntimeService: vi.fn().mockImplementation(() => ({
createOperation: mockCreateOperation,
})),
}));
vi.mock('@/server/services/market', () => ({
MarketService: vi.fn().mockImplementation(() => ({
getLobehubSkillManifests: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
uploadFromUrl: vi.fn(),
})),
}));
vi.mock('@/server/modules/Mecha', () => ({
createServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
}));
vi.mock('@/server/services/toolExecution/deviceProxy', () => ({
deviceProxy: {
isConfigured: false,
queryDeviceList: vi.fn().mockResolvedValue([]),
},
}));
vi.mock('model-bank', async (importOriginal) => {
const actual = await importOriginal<typeof ModelBankModule>();
return {
...actual,
LOBE_DEFAULT_MODEL_LIST: [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
providerId: 'openai',
},
],
};
});
describe('AiAgentService.execAgent - headless approval default', () => {
let service: AiAgentService;
const mockDb = {} as any;
const userId = 'test-user-id';
beforeEach(() => {
vi.clearAllMocks();
mockMessageCreate.mockResolvedValue({ id: 'msg-1' });
mockCreateOperation.mockResolvedValue({
autoStarted: true,
messageId: 'queue-msg-1',
operationId: 'op-123',
success: true,
});
mockGetAgentConfig.mockResolvedValue({
chatConfig: {},
id: 'agent-1',
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: '',
});
service = new AiAgentService(mockDb, userId);
});
it('should default to headless approval mode when userInterventionConfig is not provided', async () => {
await service.execAgent({
agentId: 'agent-1',
prompt: 'Hello',
});
expect(mockCreateOperation).toHaveBeenCalledTimes(1);
const callArgs = mockCreateOperation.mock.calls[0][0];
expect(callArgs.userInterventionConfig).toEqual({ approvalMode: 'headless' });
});
it('should respect explicit userInterventionConfig when provided', async () => {
await service.execAgent({
agentId: 'agent-1',
prompt: 'Hello',
userInterventionConfig: { approvalMode: 'manual' },
});
expect(mockCreateOperation).toHaveBeenCalledTimes(1);
const callArgs = mockCreateOperation.mock.calls[0][0];
expect(callArgs.userInterventionConfig).toEqual({ approvalMode: 'manual' });
});
it('should respect explicit allow-list approval mode with allowList', async () => {
const config = { allowList: ['tool-a', 'tool-b'], approvalMode: 'allow-list' as const };
await service.execAgent({
agentId: 'agent-1',
prompt: 'Hello',
userInterventionConfig: config,
});
expect(mockCreateOperation).toHaveBeenCalledTimes(1);
const callArgs = mockCreateOperation.mock.calls[0][0];
expect(callArgs.userInterventionConfig).toEqual(config);
});
});

View file

@ -260,7 +260,7 @@ export class AiAgentService {
maxSteps,
initialStepCount,
signal,
userInterventionConfig,
userInterventionConfig = { approvalMode: 'headless' },
queueRetries,
queueRetryDelay,
parentMessageId,