fix(ai): 本会话运行为整会话托管,去掉插入不执行

Always 不再按命令指纹记忆,点一次后本话题内非 critical
命令不再弹确认;移除「插入不执行」按钮与相关 prop。
This commit is contained in:
Logan.Li committed 2026-09-19 00:29:08 +08:00
1 parent 2ca32f3199
commit b18214e96a
11 files changed
+66 -111

No files matched your search

+30 -54
View File
@@ -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
}
}
+2 -5
View File
@@ -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>
+3 -6
View File
@@ -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",
+3 -6
View File
@@ -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 プロバイダーを設定してください",
+3 -6
View File
@@ -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 제공자를 구성하세요",
+3 -6
View File
@@ -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",
+3 -6
View File
@@ -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",
+3 -6
View File
@@ -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": "请先在设置 → 大模型里配置供应商",
+3 -6
View File
@@ -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": "請先在設定 → 大模型裡設定供應商",
+13 -9
View File
@@ -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 () => {