feat: persist cancelled agent proposals and support current model parameters

This commit is contained in:
2026-10-19 13:12:04 +09:00
parent cdfa1706b0
commit ed70bad7a2
5 changed files with 76 additions and 5 deletions
+29
View File
@@ -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', () => { it('persists a response requesting source or logs even without commands or changes', () => {
const service = Object.create(AgentService.prototype) as unknown as { const service = Object.create(AgentService.prototype) as unknown as {
parseSyncedMessage: (value: Record<string, unknown>, index: number) => { parseSyncedMessage: (value: Record<string, unknown>, index: number) => {
+1 -1
View File
@@ -20,7 +20,7 @@ const MAX_CHANGE_CONTENT_CHARACTERS = 200_000;
const MAX_AGENT_COMMAND_COUNT = 6; const MAX_AGENT_COMMAND_COUNT = 6;
const MAX_SYNC_PAYLOAD_CHARACTERS = 5_000_000; const MAX_SYNC_PAYLOAD_CHARACTERS = 5_000_000;
const AGENT_EXECUTION_STATUSES = new Set(['pending', 'running', 'completed', 'failed', 'rejected']); 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<{ type AgentSessionRecord = Prisma.AgentSessionGetPayload<{
include: { messages: { include: { actions: true } } }; include: { messages: { include: { actions: true } } };
+39
View File
@@ -41,4 +41,43 @@ describe('AiProviderService', () => {
const request = fetchMock.mock.calls[0][1] as RequestInit; 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' } }); 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);
});
}); });
+6 -3
View File
@@ -27,12 +27,13 @@ export class AiProviderService {
} }
private async openAi(input: CompletionInput): Promise<AiCompletionResult> { 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', { const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${input.apiKey}`, 'Content-Type': 'application/json' }, headers: { Authorization: `Bearer ${input.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: input.model, model: input.model,
temperature: 0.2, temperature: reasoningModel ? undefined : 0.2,
response_format: input.json ? { type: 'json_object' } : undefined, response_format: input.json ? { type: 'json_object' } : undefined,
messages: [{ role: 'system', content: input.system }, ...input.messages], messages: [{ role: 'system', content: input.system }, ...input.messages],
}), }),
@@ -49,6 +50,8 @@ export class AiProviderService {
} }
private async anthropic(input: CompletionInput): Promise<AiCompletionResult> { 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', { const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -59,7 +62,7 @@ export class AiProviderService {
body: JSON.stringify({ body: JSON.stringify({
model: input.model, model: input.model,
max_tokens: 8192, max_tokens: 8192,
temperature: 0.2, temperature: defaultSampling ? undefined : 0.2,
system: input.system, system: input.system,
messages: input.messages, messages: input.messages,
}), }),
@@ -89,7 +92,7 @@ export class AiProviderService {
parts: [{ text: message.content }], parts: [{ text: message.content }],
})), })),
generationConfig: { generationConfig: {
temperature: 0.2, temperature: input.model.replace(/^models\//, '').startsWith('gemini-4-') ? undefined : 0.2,
responseMimeType: input.json ? 'application/json' : 'text/plain', responseMimeType: input.json ? 'application/json' : 'text/plain',
}, },
}), }),
+1 -1
View File
@@ -278,7 +278,7 @@ EXPLORER 위쪽 아이콘:
2. 응답의 설명과 `작업 계획`(Action plan)을 읽는다. 필요한 소스/로그는 에이전트가 요청해 가져올 수 있다. 코드 편집에서 모듈을 미리 열어 두는 것이 필수는 아니지만 파일이 설치·조회 가능한 상태여야 한다. 2. 응답의 설명과 `작업 계획`(Action plan)을 읽는다. 필요한 소스/로그는 에이전트가 요청해 가져올 수 있다. 코드 편집에서 모듈을 미리 열어 두는 것이 필수는 아니지만 파일이 설치·조회 가능한 상태여야 한다.
최신 앱은 파일 목록·검색·선택 파일 조회를 필요한 만큼 이어간다. 자동 조회는 최대 8차례, 총 24개 도구 호출로 제한되고 동일 조회는 반복 실행하지 않는다. 결과가 많으면 이전 자료 일부가 제외될 수 있다. 한도나 반복 요청에 도달하면 확보한 자료로 최종 분석하며 확인하지 못한 부분은 명시한다. 기존 모듈 묶음 조회 방식은 최대 5번의 추가 조회를 사용한다. 최신 앱은 파일 목록·검색·선택 파일 조회를 필요한 만큼 이어간다. 자동 조회는 최대 8차례, 총 24개 도구 호출로 제한되고 동일 조회는 반복 실행하지 않는다. 결과가 많으면 이전 자료 일부가 제외될 수 있다. 한도나 반복 요청에 도달하면 확보한 자료로 최종 분석하며 확인하지 못한 부분은 명시한다. 기존 모듈 묶음 조회 방식은 최대 5번의 추가 조회를 사용한다.
3. 제안 파일 경로를 클릭하면 모듈 코드 편집기 안의 변경사항 비교 탭에서 기존/제안 내용을 확인한다. 3. 제안 파일 경로를 클릭하면 모듈 코드 편집기 안의 변경사항 비교 탭에서 기존/제안 내용을 확인한다.
4. `변경 적용`(Apply changes)을 눌러 실제 파일에 반영한다. `적용 중 / 적용 완료 / 다시 적용` 상태와 오류를 확인한다. 응답 문장에 수정했다고 써 있는 것만으로 실제 적용됐다고 보지 않는다. 4. `변경 적용`(Apply changes)을 눌러 실제 파일에 반영한다. `적용 중 / 적용 완료 / 다시 적용` 상태와 오류를 확인한다. 적용하지 않을 제안은 왼쪽 `취소`를 누른다. 취소하면 `취소됨`으로 저장되고 해당 제안의 적용 및 미완료 명령 실행이 막힌다. 이미 적용된 파일을 되돌리는 기능은 아니며, 적용 중에는 취소할 수 없다. 응답 문장에 수정했다고 써 있는 것만으로 실제 적용됐다고 보지 않는다.
5. 명령 카드의 대상 모듈·명령·목적을 확인하고 `승인` 또는 `거부`를 선택한다. 코드 변경에 의존하는 빌드/검증은 먼저 변경을 적용해야 하며 `변경 적용 후 실행`으로 잠길 수 있다. 5. 명령 카드의 대상 모듈·명령·목적을 확인하고 `승인` 또는 `거부`를 선택한다. 코드 변경에 의존하는 빌드/검증은 먼저 변경을 적용해야 하며 `변경 적용 후 실행`으로 잠길 수 있다.
6. 명령 출력과 `실행 완료 · exit 0` 또는 `실행 실패 · exit 코드`를 확인한다. 실패 시 원인을 해결한 뒤 `다시 실행`(Run again)을 사용한다. 오류를 해결하지 않고 반복 승인하지 않는다. 6. 명령 출력과 `실행 완료 · exit 0` 또는 `실행 실패 · exit 코드`를 확인한다. 실패 시 원인을 해결한 뒤 `다시 실행`(Run again)을 사용한다. 오류를 해결하지 않고 반복 승인하지 않는다.
7. 빌드 종료 코드가 0이어도 WARN/ERROR가 남을 수 있다. 빌드 성공, 경고 해결, 실제 노드 동작 검증은 구분한다. 7. 빌드 종료 코드가 0이어도 WARN/ERROR가 남을 수 있다. 빌드 성공, 경고 해결, 실제 노드 동작 검증은 구분한다.