feat: persist cancelled agent proposals and support current model parameters
This commit is contained in:
@@ -220,6 +220,35 @@ describe('AgentService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves cancelled proposals and rejected commands when saving and restoring history', () => {
|
||||
const service = Object.create(AgentService.prototype) as unknown as {
|
||||
parseSyncedMessage: (value: Record<string, unknown>, index: number) => Record<string, any>;
|
||||
toStoredActions: (message: unknown) => Array<Record<string, any>>;
|
||||
toSessionResponse: (session: unknown) => { messages: Array<Record<string, any>> };
|
||||
};
|
||||
const message = service.parseSyncedMessage({
|
||||
id: 'cancelled-proposal', role: 'assistant', content: '로그 추가 제안', applyState: 'cancelled',
|
||||
plan: {
|
||||
summary: '로그 추가', explanation: '시작 로그를 추가합니다.', actionPlan: ['파일 수정', '빌드'],
|
||||
commands: [{ id: 'build', moduleName: 'NAV2-Control', command: 'bash build.sh', purpose: '빌드' }],
|
||||
changes: [{ moduleName: 'NAV2-Control', path: 'src/node.py', content: 'print("started")', reason: '로그' }], createModule: null,
|
||||
},
|
||||
commandExecutions: { build: { status: 'rejected' } },
|
||||
}, 0);
|
||||
expect(message.applyState).toBe('cancelled');
|
||||
const actions = service.toStoredActions(message);
|
||||
expect(actions.find(action => action.type === 'FILE_CHANGE')?.status).toBe('cancelled');
|
||||
expect(actions.find(action => action.type === 'COMMAND')?.status).toBe('rejected');
|
||||
const restored = service.toSessionResponse({ id: 'session', messages: [{
|
||||
...message, clientMessageId: message.id, summary: message.plan.summary,
|
||||
explanation: message.plan.explanation, actionPlan: message.plan.actionPlan,
|
||||
actions,
|
||||
}] }).messages[0];
|
||||
expect(restored.applyState).toBe('cancelled');
|
||||
expect(restored.commandExecutions.build.status).toBe('rejected');
|
||||
expect(restored.plan.changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('persists a response requesting source or logs even without commands or changes', () => {
|
||||
const service = Object.create(AgentService.prototype) as unknown as {
|
||||
parseSyncedMessage: (value: Record<string, unknown>, index: number) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ const MAX_CHANGE_CONTENT_CHARACTERS = 200_000;
|
||||
const MAX_AGENT_COMMAND_COUNT = 6;
|
||||
const MAX_SYNC_PAYLOAD_CHARACTERS = 5_000_000;
|
||||
const AGENT_EXECUTION_STATUSES = new Set(['pending', 'running', 'completed', 'failed', 'rejected']);
|
||||
const AGENT_APPLY_STATES = new Set(['ready', 'applying', 'applied', 'error']);
|
||||
const AGENT_APPLY_STATES = new Set(['ready', 'applying', 'applied', 'error', 'cancelled']);
|
||||
|
||||
type AgentSessionRecord = Prisma.AgentSessionGetPayload<{
|
||||
include: { messages: { include: { actions: true } } };
|
||||
|
||||
@@ -41,4 +41,43 @@ describe('AiProviderService', () => {
|
||||
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(String(request.body))).toMatchObject({ model: 'qwen3.7-plus', response_format: { type: 'json_object' } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OPENAI', 'gpt-6.1-sol'], ['OPENAI', 'gpt-6-astra'], ['OPENAI', 'gpt-6-luna'],
|
||||
['ANTHROPIC', 'claude-sonnet-5-5'], ['ANTHROPIC', 'claude-opus-5-5'],
|
||||
['ANTHROPIC', 'claude-haiku-5-5'], ['ANTHROPIC', 'claude-fable-5-1'],
|
||||
['ANTHROPIC', 'claude-sonnet-5-5-20260928'],
|
||||
['GEMINI', 'gemini-4-argon'], ['GEMINI', 'models/gemini-4-argon'],
|
||||
] as const)('omits unsupported sampling overrides for %s %s', async (provider, model) => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({
|
||||
choices: [{ message: { content: '{"ok":true}' } }],
|
||||
content: [{ type: 'text', text: '{"ok":true}' }],
|
||||
candidates: [{ content: { parts: [{ text: '{"ok":true}' }] } }],
|
||||
}) });
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const service = new AiProviderService({ get: jest.fn() } as unknown as ConfigService);
|
||||
const result = await service.complete({ provider, model, apiKey: 'fixture', system: 'Return JSON', messages: [{ role: 'user', content: 'test' }], json: true });
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body));
|
||||
expect(provider === 'GEMINI' ? body.generationConfig : body).not.toHaveProperty('temperature');
|
||||
if (provider !== 'GEMINI') expect(body.model).toBe(model);
|
||||
if (provider === 'OPENAI') expect(body.response_format).toEqual({ type: 'json_object' });
|
||||
if (provider === 'GEMINI') expect(body.generationConfig.responseMimeType).toBe('application/json');
|
||||
expect(result.content).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OPENAI', 'gpt-4.1-mini'], ['ANTHROPIC', 'claude-sonnet-4-5'],
|
||||
['GEMINI', 'gemini-2.5-flash'], ['QWEN', 'qwen3.8-flash'],
|
||||
] as const)('preserves the existing request format for %s %s', async (provider, model) => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({
|
||||
choices: [{ message: { content: 'answer' } }], content: [{ type: 'text', text: 'answer' }],
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
}) });
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const service = new AiProviderService({ get: jest.fn() } as unknown as ConfigService);
|
||||
await service.complete({ provider, model, apiKey: 'fixture', system: 'system', messages: [{ role: 'user', content: 'test' }] });
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body));
|
||||
expect(provider === 'GEMINI' ? body.generationConfig.temperature : body.temperature).toBe(0.2);
|
||||
if (provider !== 'GEMINI') expect(body.model).toBe(model);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,12 +27,13 @@ export class AiProviderService {
|
||||
}
|
||||
|
||||
private async openAi(input: CompletionInput): Promise<AiCompletionResult> {
|
||||
const reasoningModel = /^gpt-(?:5|6)(?:[.-]|$)|^o[1-9](?:-|$)/.test(input.model);
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${input.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
temperature: 0.2,
|
||||
temperature: reasoningModel ? undefined : 0.2,
|
||||
response_format: input.json ? { type: 'json_object' } : undefined,
|
||||
messages: [{ role: 'system', content: input.system }, ...input.messages],
|
||||
}),
|
||||
@@ -49,6 +50,8 @@ export class AiProviderService {
|
||||
}
|
||||
|
||||
private async anthropic(input: CompletionInput): Promise<AiCompletionResult> {
|
||||
const defaultSampling = ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1']
|
||||
.some(model => input.model === model || input.model.startsWith(`${model}-`));
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -59,7 +62,7 @@ export class AiProviderService {
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
max_tokens: 8192,
|
||||
temperature: 0.2,
|
||||
temperature: defaultSampling ? undefined : 0.2,
|
||||
system: input.system,
|
||||
messages: input.messages,
|
||||
}),
|
||||
@@ -89,7 +92,7 @@ export class AiProviderService {
|
||||
parts: [{ text: message.content }],
|
||||
})),
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
temperature: input.model.replace(/^models\//, '').startsWith('gemini-4-') ? undefined : 0.2,
|
||||
responseMimeType: input.json ? 'application/json' : 'text/plain',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -278,7 +278,7 @@ EXPLORER 위쪽 아이콘:
|
||||
2. 응답의 설명과 `작업 계획`(Action plan)을 읽는다. 필요한 소스/로그는 에이전트가 요청해 가져올 수 있다. 코드 편집에서 모듈을 미리 열어 두는 것이 필수는 아니지만 파일이 설치·조회 가능한 상태여야 한다.
|
||||
최신 앱은 파일 목록·검색·선택 파일 조회를 필요한 만큼 이어간다. 자동 조회는 최대 8차례, 총 24개 도구 호출로 제한되고 동일 조회는 반복 실행하지 않는다. 결과가 많으면 이전 자료 일부가 제외될 수 있다. 한도나 반복 요청에 도달하면 확보한 자료로 최종 분석하며 확인하지 못한 부분은 명시한다. 기존 모듈 묶음 조회 방식은 최대 5번의 추가 조회를 사용한다.
|
||||
3. 제안 파일 경로를 클릭하면 모듈 코드 편집기 안의 변경사항 비교 탭에서 기존/제안 내용을 확인한다.
|
||||
4. `변경 적용`(Apply changes)을 눌러 실제 파일에 반영한다. `적용 중 / 적용 완료 / 다시 적용` 상태와 오류를 확인한다. 응답 문장에 수정했다고 써 있는 것만으로 실제 적용됐다고 보지 않는다.
|
||||
4. `변경 적용`(Apply changes)을 눌러 실제 파일에 반영한다. `적용 중 / 적용 완료 / 다시 적용` 상태와 오류를 확인한다. 적용하지 않을 제안은 왼쪽 `취소`를 누른다. 취소하면 `취소됨`으로 저장되고 해당 제안의 적용 및 미완료 명령 실행이 막힌다. 이미 적용된 파일을 되돌리는 기능은 아니며, 적용 중에는 취소할 수 없다. 응답 문장에 수정했다고 써 있는 것만으로 실제 적용됐다고 보지 않는다.
|
||||
5. 명령 카드의 대상 모듈·명령·목적을 확인하고 `승인` 또는 `거부`를 선택한다. 코드 변경에 의존하는 빌드/검증은 먼저 변경을 적용해야 하며 `변경 적용 후 실행`으로 잠길 수 있다.
|
||||
6. 명령 출력과 `실행 완료 · exit 0` 또는 `실행 실패 · exit 코드`를 확인한다. 실패 시 원인을 해결한 뒤 `다시 실행`(Run again)을 사용한다. 오류를 해결하지 않고 반복 승인하지 않는다.
|
||||
7. 빌드 종료 코드가 0이어도 WARN/ERROR가 남을 수 있다. 빌드 성공, 경고 해결, 실제 노드 동작 검증은 구분한다.
|
||||
|
||||
Reference in New Issue
Block a user