Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion python/src/agent_v2/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from contextlib import suppress
from datetime import UTC, date, datetime
from pathlib import Path
from typing import Literal

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -170,7 +171,7 @@ class ChatRequestV2(BaseModel):


class ApproveRequest(BaseModel):
decision: str = "allow_once" # allow_once, allow_session, deny
decision: Literal["allow_once", "allow_session", "deny"] = "allow_once"
reason: str | None = None


Expand Down
2 changes: 2 additions & 0 deletions python/src/agent_v2/runtime/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@ async def turn(

def approve(self, event_id: str, decision: str) -> bool:
"""Handle approval decision from frontend. Returns True if event was found."""
if decision not in {"allow_once", "allow_session", "deny"}:
return False
evt = self._approval_events.get(event_id)
if evt is None:
return False
Expand Down
17 changes: 17 additions & 0 deletions python/tests/agent_v2/test_approval_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,23 @@ async def test_approve_nonexistent_event(self, workspace: Path):
)
assert not rt.approve("nonexistent_id", "allow_once")

@pytest.mark.asyncio
async def test_approve_rejects_unknown_decision_without_unblocking(self, workspace: Path):
registry = create_default_registry(workspace_root=workspace)
policy = policy_from_registry(PermissionMode.WORKSPACE_WRITE, registry.permission_specs())
rt = ConversationRuntime(
provider=MockProvider(),
tool_registry=registry,
permission_policy=policy,
session=Session(workspace=str(workspace)),
)
approval_event = asyncio.Event()
rt._approval_events["evt_001"] = approval_event

assert not rt.approve("evt_001", "always_allow")
assert not approval_event.is_set()
assert "evt_001" not in rt._approval_decisions

@pytest.mark.asyncio
async def test_abort_unblocks_approval(self, workspace: Path):
provider = MockProvider(
Expand Down
8 changes: 8 additions & 0 deletions python/tests/integration/test_download_approve_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ def test_approve_reject_decision(self, client, agent_app_with_session):
assert resp.status_code == 200
assert resp.json()["status"] == "ok"

def test_approve_rejects_unknown_decision(self, agent_app_with_session):
session_id, event_id, tc = agent_app_with_session
resp = tc.post(
f"/api/agent/v2/approve/{session_id}/{event_id}",
json={"decision": "always_allow"},
)
assert resp.status_code == 422


# ── Fixture for approve happy path ───────────────────────────────────────

Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/AgentApprovalInline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import AgentApprovalInline from '../components/AgentApprovalInline.vue'
describe('AgentApprovalInline', () => {
const basePending = {
event_id: 'evt_1',
session_id: 'sess_1',
tool_name: 'write_file',
args: { file_path: 'draft.md' },
risk: 'destructive',
Expand Down Expand Up @@ -62,4 +63,18 @@ describe('AgentApprovalInline', () => {
expect(wrapper.text()).toContain('高风险')
expect(wrapper.find('.approval-risk').classes()).toContain('risk-destructive')
})

it.each([
['.allow-once', 'allow_once'],
['.allow-session', 'allow_session'],
['.deny', 'deny'],
])('emits the decision when %s is clicked', async (selector, decision) => {
const wrapper = mount(AgentApprovalInline, {
props: { pending: basePending },
})

await wrapper.get(selector).trigger('click')

expect(wrapper.emitted('decide')).toEqual([[decision]])
})
})
52 changes: 52 additions & 0 deletions src/__tests__/AiPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,17 @@ vi.mock('vue-i18n', () => ({
}))

import AiPanel from '../components/AiPanel.vue'
import { currentWorkspaceGrant } from '../composables/useProject'
import { useFileTree } from '../composables/useFileTree'
import { _resetForTesting } from '../composables/useAgentChat'

describe('AiPanel workflow routing', () => {
const fetchMock = vi.fn()

beforeEach(() => {
_resetForTesting()
currentWorkspaceGrant.value = 'grant-ai-panel'
useFileTree().rootDir.value = 'D:\\paper'
fetchMock.mockReset()
fetchMock.mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -80,5 +86,51 @@ describe('AiPanel workflow routing', () => {
await flushPromises()

expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/agent\/v2\/chat$/)
const payload = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(payload).toMatchObject({
workspace_root: 'D:\\paper',
workspace_grant: 'grant-ai-panel',
})
})

it('submits a rendered approval with its owner session and workspace grant', async () => {
readSseStream.mockImplementationOnce(
async (_reader: unknown, handler: (type: string, data: Record<string, unknown>) => void) => {
handler('session_started', { metadata: { session_id: 'sess_ai_panel' } })
handler('await_approval', {
event_id: 'evt_ai_panel',
metadata: {
tool_name: 'write_file',
args: { file_path: 'draft.md' },
reason: 'Confirm the edit',
},
})
},
)

const wrapper = mount(AiPanel, {
props: { editorContext: 'Editor context', workspaceFiles: [] },
})
await wrapper.find('textarea').setValue('Update the project')
await wrapper.find('.ac-send-btn').trigger('click')
await flushPromises()

expect(wrapper.find('.approval-bar').exists()).toBe(true)
await wrapper.find('.allow-once').trigger('click')
await flushPromises()

expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]).toEqual([
'/api/agent/v2/approve/sess_ai_panel/evt_ai_panel',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'X-Workspace-Grant': 'grant-ai-panel',
}),
body: JSON.stringify({ decision: 'allow_once' }),
}),
])
expect(wrapper.find('.approval-bar').exists()).toBe(false)
})
})
55 changes: 55 additions & 0 deletions src/__tests__/useAgentChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,61 @@ describe('useAgentChat', () => {
expect(approvalEvents.length).toBeGreaterThan(0)
})

it('keeps the approval bound to the session that raised it', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
makeSseResponse([
makeSessionStartedChunk('sess_approval_owner'),
makeAwaitApprovalChunk('write_file', 'Confirm the edit', 'evt_owned'),
makeDoneChunk(),
]),
)
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(
makeSseResponse([makeSessionStartedChunk('sess_approval_owner'), makeDoneChunk()]),
)
vi.stubGlobal('fetch', fetchMock)

const { sendMessage, sendApproval, activeRunSessionId, pendingApproval } = useAgentChat()
await sendMessage('Update the draft')

expect(activeRunSessionId.value).toBeNull()
expect(pendingApproval.value).toMatchObject({
event_id: 'evt_owned',
session_id: 'sess_approval_owner',
})
expect(await sendApproval('evt_owned', 'allow_once')).toBe(true)
expect(fetchMock).toHaveBeenLastCalledWith(
'http://127.0.0.1:18088/api/agent/v2/approve/sess_approval_owner/evt_owned',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ decision: 'allow_once' }),
}),
)

await sendMessage('Continue the task')
expect(pendingApproval.value).toBeNull()
})

it.each(['allow_once', 'allow_session', 'deny'] as const)(
'routes %s from the editor overlay to its explicit session owner',
async (decision) => {
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)

const { sendApproval } = useAgentChat()
expect(await sendApproval('evt_inline', decision, undefined, 'sess_inline')).toBe(true)
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:18088/api/agent/v2/approve/sess_inline/evt_inline',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ decision }),
}),
)
},
)

it('does not let an older approval_received event clear a newer tool approval', async () => {
const openStream = makeOpenSseResponse([
makeSessionStartedChunk('sess_overlap'),
Expand Down
13 changes: 10 additions & 3 deletions src/components/AgentApprovalInline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,31 @@
</div>
<div class="approval-actions">
<button
type="button"
class="approval-btn allow-once u-interactive"
@click="decide('allow_once')"
:disabled="deciding"
@click="decide('allow_once')"
>
<UiSpinner v-if="deciding && pendingDecision === 'allow_once'" size="sm" />
<span v-else>{{ t('agent.allowOnce') }}</span>
</button>
<button
v-if="!pending.force_approval"
type="button"
class="approval-btn allow-session u-interactive"
@click="decide('allow_session')"
:disabled="deciding"
@click="decide('allow_session')"
>
<UiSpinner v-if="deciding && pendingDecision === 'allow_session'" size="sm" />
<span v-else>{{ t('agent.allowSession') }}</span>
</button>
<span v-else class="approval-hint-force">{{ t('agent.confirmEach') }}</span>
<button class="approval-btn deny u-interactive" @click="decide('deny')" :disabled="deciding">
<button
type="button"
class="approval-btn deny u-interactive"
:disabled="deciding"
@click="decide('deny')"
>
<UiSpinner v-if="deciding && pendingDecision === 'deny'" size="sm" />
<span v-else>{{ t('general.no') }}</span>
</button>
Expand Down
8 changes: 5 additions & 3 deletions src/components/AgentPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,6 @@ const {
abortSession,
startNewWorkflow,
loadWorkflowMessages,
activeRunSessionId,
pendingCheckpoint,
fetchSessions: _fetchSessions,
fetchAgentSkills,
Expand Down Expand Up @@ -1090,7 +1089,10 @@ const showApprovalFallback = computed(() =>
async function handleApprovalDecision(decision: 'allow_once' | 'allow_session' | 'deny') {
const pending = pendingApproval.value
if (!pending) return
await sendApproval(pending.event_id, decision)
const accepted = await sendApproval(pending.event_id, decision)
if (!accepted) {
showWarning(t('agent.approvalSubmitFailed'), 8000)
}
}

// Route file-edit approvals to inline diff editor overlay
Expand All @@ -1101,7 +1103,7 @@ watch(pendingApproval, (p) => {
setActiveEdit({
editId: p.event_id,
eventId: p.event_id,
sessionId: activeRunSessionId.value || '',
sessionId: p.session_id,
operation: (p.tool_name === 'write_file' ? 'write_file' : 'str_replace') as
'str_replace' | 'write_file',
filePath: (args?.file_path as string) || '',
Expand Down
32 changes: 18 additions & 14 deletions src/components/AiPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,9 @@ import {
} from '../composables/useAiPanelState'
import { API_BASE } from '../utils/api'
import AgentApprovalInline from './AgentApprovalInline.vue'
import type { PendingApproval } from '../composables/useAgentChat'
import { useAgentChat, type PendingApproval } from '../composables/useAgentChat'
import { useFileTree } from '../composables/useFileTree'
import { currentWorkspaceGrant } from '../composables/useProject'
import { useEditorState } from '../composables/useEditorState'
import { useEditor } from '../composables/useEditor'
import { useSpeechRecognition } from '../composables/useSpeechRecognition'
Expand Down Expand Up @@ -474,6 +475,7 @@ const copiedId = ref<string | null>(null)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
const acSessionId = ref<string | null>(null)
const pendingApproval = ref<PendingApproval | null>(null)
const { sendApproval: sendAgentApproval } = useAgentChat()
const { rootDir, refresh: refreshFileTree } = useFileTree()
const { tabs: editorTabs, setActiveEdit, clearActiveEdit } = useEditorState()
const { reloadOpenTabs, applyExternalFileUpdate } = useEditor()
Expand Down Expand Up @@ -817,6 +819,7 @@ async function doSend(text: string) {
context_text: props.editorContext?.trim() || undefined,
context_file: props.activeFile?.trim() || undefined,
workspace_root: rootDir.value?.trim() || undefined,
workspace_grant: currentWorkspaceGrant.value || undefined,
}),
signal: aiAbortCtrl.value.signal,
})
Expand Down Expand Up @@ -866,6 +869,7 @@ async function doSend(text: string) {
} else if (evtType === 'await_approval') {
pendingApproval.value = {
event_id: (d.event_id as string) || '',
session_id: acSessionId.value || '',
tool_name: (meta?.tool_name as string) || (meta?.tool as string) || '',
args: (meta?.args ?? meta?.arguments) as Record<string, unknown> | undefined,
risk: meta?.risk as string | undefined,
Expand Down Expand Up @@ -960,19 +964,19 @@ function stopStream() {
}

async function handleApprovalDecision(decision: 'allow_once' | 'allow_session' | 'deny') {
const sid = acSessionId.value
const eventId = pendingApproval.value?.event_id
if (!sid || !eventId) return
pendingApproval.value = null
clearActiveEdit()
try {
await fetch(`${API}/api/agent/v2/approve/${sid}/${eventId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
})
} catch {
/* non-fatal */
const pending = pendingApproval.value
if (!pending) return
const accepted = await sendAgentApproval(
pending.event_id,
decision,
undefined,
pending.session_id,
)
if (accepted) {
pendingApproval.value = null
clearActiveEdit()
} else {
showWarning(t('agent.approvalSubmitFailed'), 8000)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/MonacoEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,7 @@ watch(activeEdit, (edit) => {
function _dispatchInlineDecision(decision: 'allow_once' | 'deny') {
const edit = activeEdit.value
if (!edit) return
sendApproval(edit.eventId, decision).then((ok) => {
sendApproval(edit.eventId, decision, undefined, edit.sessionId).then((ok) => {
if (ok) clearActiveEdit()
// On failure, widget stays visible for retry
})
Expand Down
Loading
Loading