fix(ai): 本会话运行为整会话托管,去掉插入不执行
Always 不再按命令指纹记忆,点一次后本话题内非 critical 命令不再弹确认;移除「插入不执行」按钮与相关 prop。
This commit is contained in:
1 parent
2ca32f3199
commit
b18214e96a
11 files changed
+66
-111
No files matched your search
@@ -20,7 +20,6 @@ import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount }
|
||||
import { writeClipboardText } from '../../platform/clipboard'
|
||||
import { analyzeAgentOutput } from '../../utils/agentOutput'
|
||||
import {
|
||||
agentCommandAllowFingerprint,
|
||||
agentCommandNeedsConfirmation,
|
||||
assessAgentCommandRisk,
|
||||
normalizeAgentSafetyMode,
|
||||
@@ -45,8 +44,6 @@ interface AiTerminalPanelProps {
|
||||
connectionId?: string | null
|
||||
targetLabel?: string
|
||||
isLocal?: boolean
|
||||
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
|
||||
insertToTerminal?: (text: string) => boolean
|
||||
}
|
||||
|
||||
interface AgentStep {
|
||||
@@ -176,7 +173,6 @@ export default function AiTerminalPanel({
|
||||
connectionId = null,
|
||||
targetLabel = '当前 SSH 会话',
|
||||
isLocal = false,
|
||||
insertToTerminal,
|
||||
}: AiTerminalPanelProps) {
|
||||
const { t } = useTranslation('ai')
|
||||
const store = useAiProviderStore()
|
||||
@@ -197,7 +193,7 @@ export default function AiTerminalPanel({
|
||||
const [copiedId, setCopiedId] = useState('')
|
||||
const runIdRef = useRef(0)
|
||||
const runConnectionIdRef = useRef<string | null>(null)
|
||||
const sessionAllowRef = useRef(new Set<string>())
|
||||
const sessionAllowRef = useRef(false)
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const initialSelectionRef = useRef('')
|
||||
const liveStepsRef = useRef<AgentStep[]>([])
|
||||
@@ -230,7 +226,7 @@ export default function AiTerminalPanel({
|
||||
setPrompt('')
|
||||
setQueued(null)
|
||||
setCopiedId('')
|
||||
sessionAllowRef.current.clear()
|
||||
sessionAllowRef.current = false
|
||||
initialSelectionRef.current = ''
|
||||
}
|
||||
|
||||
@@ -334,54 +330,34 @@ export default function AiTerminalPanel({
|
||||
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
|
||||
}
|
||||
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
|
||||
const fingerprint = agentCommandAllowFingerprint(command, risk)
|
||||
if (!sessionAllowRef.current.has(fingerprint)) {
|
||||
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||
setLiveSteps([...completedSteps, awaiting])
|
||||
const choice = await confirmAgentRisk({
|
||||
title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }),
|
||||
message: t('step.confirmMessage', {
|
||||
target: targetLabel,
|
||||
mode: t(`safety.${runSafetyMode}.label`),
|
||||
command,
|
||||
}),
|
||||
danger: risk === 'high',
|
||||
})
|
||||
if (runIdRef.current !== runId) return
|
||||
if (choice === 'insert') {
|
||||
const inserted = insertToTerminal?.(command) || false
|
||||
if (!inserted) {
|
||||
await writeClipboardText(command)
|
||||
}
|
||||
setLiveSteps([])
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId(),
|
||||
role: 'assistant',
|
||||
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }),
|
||||
steps: [...completedSteps],
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (choice === 'reject') {
|
||||
setLiveSteps([])
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId(),
|
||||
role: 'assistant',
|
||||
content: t('step.rejected', { round }),
|
||||
steps: [...completedSteps],
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (choice === 'always') {
|
||||
sessionAllowRef.current.add(fingerprint)
|
||||
}
|
||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll) && !sessionAllowRef.current) {
|
||||
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||
setLiveSteps([...completedSteps, awaiting])
|
||||
const choice = await confirmAgentRisk({
|
||||
title: t('step.confirmTitle', { round, risk: t(`risk.${risk}`) }),
|
||||
message: t('step.confirmMessage', {
|
||||
target: targetLabel,
|
||||
mode: t(`safety.${runSafetyMode}.label`),
|
||||
command,
|
||||
}),
|
||||
danger: risk === 'high',
|
||||
})
|
||||
if (runIdRef.current !== runId) return
|
||||
if (choice === 'reject') {
|
||||
setLiveSteps([])
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId(),
|
||||
role: 'assistant',
|
||||
content: t('step.rejected', { round }),
|
||||
steps: [...completedSteps],
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (choice === 'always') {
|
||||
sessionAllowRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export interface ConfirmOptions {
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */
|
||||
export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert'
|
||||
/** Agent 高风险确认:Reject / Once / Always(Always 对本话题后续命令生效,仅内存) */
|
||||
export type AgentRiskChoice = 'reject' | 'once' | 'always'
|
||||
|
||||
export interface AgentRiskConfirmOptions {
|
||||
title: string
|
||||
@@ -173,9 +173,6 @@ export function useConfirmDialog() {
|
||||
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
|
||||
{t('ai:confirm.reject')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
|
||||
{t('ai:confirm.insert')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={agentRisk.danger ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
|
||||
@@ -1361,7 +1361,6 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
|
||||
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
|
||||
isLocal={false}
|
||||
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -61,16 +61,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "Step {{round}} command rejected.",
|
||||
"terminated": "Terminated",
|
||||
"stoppedMessage": "Stopped. Interrupt requested for the running remote command.",
|
||||
"inserted": "Round {{round}} command was inserted into the terminal input line without running; review it and press Enter yourself.",
|
||||
"insertedClipboard": "Round {{round}} command was copied to the clipboard (terminal not writable); paste, review, then run."
|
||||
"stoppedMessage": "Stopped. Interrupt requested for the running remote command."
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "Reject",
|
||||
"once": "Once",
|
||||
"always": "Always",
|
||||
"hint": "Enter · Once Esc · Reject Always · skip similar confirmations this session",
|
||||
"insert": "Insert only"
|
||||
"always": "Run for session",
|
||||
"hint": "Enter · once Esc · reject Run for session · no more prompts in this task (critical still blocked)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "Configure an AI provider in Settings → AI first",
|
||||
|
||||
@@ -54,16 +54,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "ステップ {{round}} のコマンドは拒否されました。",
|
||||
"terminated": "終了しました",
|
||||
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。",
|
||||
"inserted": "ラウンド {{round}} のコマンドを入力行に挿入しました(未実行)。確認してから Enter で実行してください。",
|
||||
"insertedClipboard": "ラウンド {{round}} のコマンドをクリップボードにコピーしました(端末に書き込めません)。貼り付けて確認してから実行してください。"
|
||||
"stoppedMessage": "停止しました。実行中のリモートコマンドに中断要求を送信しました。"
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "拒否",
|
||||
"once": "1 回のみ",
|
||||
"always": "常に",
|
||||
"hint": "Enter · 1 回のみ Esc · 拒否 常に · このセッションで同種の確認をスキップ",
|
||||
"insert": "挿入のみ"
|
||||
"always": "このセッションで実行",
|
||||
"hint": "Enter · 一度実行 Esc · 拒否 このセッションで実行 · 以降このタスクでは確認しない(危険コマンドは遮断)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "先に「設定 → AI」で AI プロバイダーを設定してください",
|
||||
|
||||
@@ -54,16 +54,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "{{round}}단계 명령이 거부되었습니다.",
|
||||
"terminated": "종료됨",
|
||||
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다.",
|
||||
"inserted": "{{round}}번째 명령을 입력 줄에 삽입했지만 실행하지 않았습니다. 확인 후 직접 Enter를 눌러 실행하세요.",
|
||||
"insertedClipboard": "{{round}}번째 명령을 클립보드에 복사했습니다(터미널에 쓸 수 없음). 붙여넣고 확인한 뒤 실행하세요."
|
||||
"stoppedMessage": "중지됨. 실행 중인 원격 명령에 대해 인터럽트가 요청되었습니다."
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "거부",
|
||||
"once": "1회",
|
||||
"always": "항상",
|
||||
"hint": "Enter · 1회 Esc · 거부 항상 · 이 세션에서 유사한 확인 건너뛰기",
|
||||
"insert": "삽입만"
|
||||
"always": "이 세션에서 실행",
|
||||
"hint": "Enter · 한 번 실행 Esc · 거부 이 세션에서 실행 · 이후 이 작업에서는 확인 안 함(위험 명령은 차단)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "먼저 설정 → AI에서 AI 제공자를 구성하세요",
|
||||
|
||||
@@ -54,16 +54,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "Команда шага {{round}} отклонена.",
|
||||
"terminated": "Прервано",
|
||||
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды.",
|
||||
"inserted": "Команда раунда {{round}} вставлена в строку ввода терминала без выполнения; проверьте и нажмите Enter сами.",
|
||||
"insertedClipboard": "Команда раунда {{round}} скопирована в буфер обмена (терминал недоступен для записи); вставьте, проверьте и выполните."
|
||||
"stoppedMessage": "Остановлено. Запрошено прерывание выполняемой удалённой команды."
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "Отклонить",
|
||||
"once": "Один раз",
|
||||
"always": "Всегда",
|
||||
"hint": "Enter · Один раз Esc · Отклонить Всегда · пропускать похожие подтверждения в этом сеансе",
|
||||
"insert": "Только вставить"
|
||||
"always": "На сессию",
|
||||
"hint": "Enter · один раз Esc · отклонить На сессию · без подтверждений до конца задачи (критичные всё ещё блокируются)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "Сначала настройте AI-провайдера в Настройки → AI",
|
||||
|
||||
@@ -54,16 +54,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "Lệnh bước {{round}} đã bị từ chối.",
|
||||
"terminated": "Đã chấm dứt",
|
||||
"stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy.",
|
||||
"inserted": "Lệnh vòng {{round}} đã được chèn vào dòng nhập của terminal, chưa chạy; hãy kiểm tra rồi tự nhấn Enter.",
|
||||
"insertedClipboard": "Lệnh vòng {{round}} đã được sao chép vào clipboard (terminal không ghi được); dán, kiểm tra rồi mới chạy."
|
||||
"stoppedMessage": "Đã dừng. Đã yêu cầu ngắt cho lệnh từ xa đang chạy."
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "Từ chối",
|
||||
"once": "Một lần",
|
||||
"always": "Luôn luôn",
|
||||
"hint": "Enter · Một lần Esc · Từ chối Luôn luôn · bỏ qua các xác nhận tương tự trong phiên này",
|
||||
"insert": "Chỉ chèn"
|
||||
"always": "Chạy trong phiên",
|
||||
"hint": "Enter · một lần Esc · từ chối Chạy trong phiên · không hỏi lại trong tác vụ này (lệnh nguy hiểm vẫn chặn)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "Cấu hình nhà cung cấp AI trong Cài đặt → AI trước tiên",
|
||||
|
||||
@@ -61,16 +61,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "已拒绝第 {{round}} 步命令。",
|
||||
"terminated": "已终止",
|
||||
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。",
|
||||
"inserted": "第 {{round}} 轮命令已插入终端输入行,未执行;请检查后自行回车运行。",
|
||||
"insertedClipboard": "第 {{round}} 轮命令已复制到剪贴板(当前终端不可写入);粘贴后请检查再运行。"
|
||||
"stoppedMessage": "已终止。正在执行的远端命令已请求中断。"
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "拒绝",
|
||||
"once": "执行一次",
|
||||
"always": "本会话允许",
|
||||
"hint": "Enter · 执行一次 Esc · 拒绝 本会话允许 · 同类命令本会话不再确认",
|
||||
"insert": "插入不执行"
|
||||
"always": "本会话运行",
|
||||
"hint": "Enter · 执行一次 Esc · 拒绝 本会话运行 · 本任务内其余命令不再确认(危险命令仍拦截)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "请先在设置 → 大模型里配置供应商",
|
||||
|
||||
@@ -54,16 +54,13 @@
|
||||
"confirmMessage": "SSH · {{target}}\n{{mode}}\n\n$ {{command}}",
|
||||
"rejected": "已拒絕第 {{round}} 步命令。",
|
||||
"terminated": "已終止",
|
||||
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。",
|
||||
"inserted": "第 {{round}} 輪命令已插入終端機輸入行,未執行;請檢查後自行按 Enter 執行。",
|
||||
"insertedClipboard": "第 {{round}} 輪命令已複製到剪貼簿(目前終端機不可寫入);貼上後請檢查再執行。"
|
||||
"stoppedMessage": "已終止。正在執行的遠端命令已請求中斷。"
|
||||
},
|
||||
"confirm": {
|
||||
"reject": "拒絕",
|
||||
"once": "執行一次",
|
||||
"always": "本連線允許",
|
||||
"hint": "Enter · 執行一次 Esc · 拒絕 本連線允許 · 同類命令本連線不再確認",
|
||||
"insert": "插入不執行"
|
||||
"always": "本工作階段執行",
|
||||
"hint": "Enter · 執行一次 Esc · 拒絕 本工作階段執行 · 本任務內其餘命令不再確認(危險命令仍攔截)"
|
||||
},
|
||||
"error": {
|
||||
"noProvider": "請先在設定 → 大模型裡設定供應商",
|
||||
|
||||
@@ -129,26 +129,27 @@ describe('terminal Agent CLI panel', () => {
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/宽松/)).toBeVisible()
|
||||
expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible()
|
||||
expect(within(dialog).queryByRole('button', { name: '插入不执行' })).not.toBeInTheDocument()
|
||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' }))
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('Always skips confirmation for the same command shape in the current panel session', async () => {
|
||||
it('Always auto-runs remaining confirmable commands in the current panel session', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
text: '```bash\nrm /tmp/tmp.AAA111\n```',
|
||||
text: '```bash\nsudo rmdir /var/ftp\n```',
|
||||
model: 'test',
|
||||
redactedCount: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
text: '```bash\nrm /tmp/tmp.BBB222\n```',
|
||||
text: '```bash\nsystemctl restart nginx\n```',
|
||||
model: 'test',
|
||||
redactedCount: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 清理完成。', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 清理并重启完成。', model: 'test', redactedCount: 0 })
|
||||
vi.mocked(mockDesktopApi.sshExec)
|
||||
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
|
||||
@@ -162,20 +163,23 @@ describe('terminal Agent CLI panel', () => {
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '清理临时文件' },
|
||||
target: { value: '清理并重启' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '本会话允许' }))
|
||||
expect(within(dialog).queryByRole('button', { name: '插入不执行' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '本会话运行' }))
|
||||
await waitFor(() =>
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.AAA111', { fromAgent: true })
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'sudo rmdir /var/ftp', { fromAgent: true })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.BBB222', { fromAgent: true })
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'systemctl restart nginx', {
|
||||
fromAgent: true,
|
||||
})
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('清理完成。')).toBeVisible()
|
||||
expect(await screen.findByText('清理并重启完成。')).toBeVisible()
|
||||
})
|
||||
|
||||
it('lets strict mode require confirmation from medium risk upward', async () => {
|
||||
|
||||
Reference in new issue
Block a user