mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
fix(tui): harden cached denial notices
Persist cached-denial explanations in the transcript without shifting active tool virtual indices, and describe the matching, process-scoped cache consistently across locales. Honor CODEWHALE_HOME for audit records, prune expired toasts throughout the queue, and cover compact, localized, isolated-audit, and parallel-tool regressions. Signed-off-by: Nightt <[email protected]>
This commit is contained in:
14 files changed
+354
-63
No files matched your search
@@ -567,7 +567,7 @@
|
||||
"ApprovalChooseAction": "Enter selected option, or press y/a/d directly",
|
||||
"ApprovalIntentLabel": "Intent: ",
|
||||
"ApprovalMoreLines": " … (+{count} lines)",
|
||||
"ApprovalAutoDeniedSession": "Auto-denied {tool}: you rejected this exact request earlier in this session. Restart CodeWhale to reconsider it.",
|
||||
"ApprovalAutoDeniedSession": "Auto-denied {tool}: a matching request was denied earlier during this CodeWhale run. Restart CodeWhale to reconsider it.",
|
||||
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denied ",
|
||||
"ElevationTitleRequired": " Sandbox Elevation Required ",
|
||||
"ElevationFieldTool": " Tool: ",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"ApprovalChooseAction": "Enter para seleccionar, o presione y/a/d directamente",
|
||||
"ApprovalIntentLabel": "Intención: ",
|
||||
"ApprovalMoreLines": " … (+{count} líneas)",
|
||||
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: ya rechazaste esta solicitud exacta en esta sesión. Reinicia CodeWhale para reconsiderarla.",
|
||||
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: se rechazó antes una solicitud coincidente durante esta ejecución de CodeWhale. Reinicia CodeWhale para reconsiderarla.",
|
||||
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denegado ",
|
||||
"ElevationTitleRequired": " Elevación de Sandbox Requerida ",
|
||||
"ElevationFieldTool": " Herramienta: ",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"ApprovalChooseAction": "Enterで選択、または y/a/d を直接入力",
|
||||
"ApprovalIntentLabel": "意図:",
|
||||
"ApprovalMoreLines": " … (+{count} 行)",
|
||||
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: このセッションで同じリクエストを以前に拒否しています。再検討するには CodeWhale を再起動してください。",
|
||||
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: この CodeWhale の実行中に一致するリクエストが以前拒否されています。再検討するには CodeWhale を再起動してください。",
|
||||
"ElevationTitleSandboxDenied": " ⚠, サンドボックス拒否 ",
|
||||
"ElevationTitleRequired": " サンドボックス昇格 ",
|
||||
"ElevationFieldTool": " ツール:",
|
||||
|
||||
@@ -567,7 +567,7 @@
|
||||
"ApprovalChooseAction": "Enter로 선택 항목 적용, 또는 y/a/d를 바로 누르세요",
|
||||
"ApprovalIntentLabel": "의도: ",
|
||||
"ApprovalMoreLines": " … (+{count}줄)",
|
||||
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이 세션에서 동일한 요청을 이전에 거부했습니다. 다시 검토하려면 CodeWhale을 재시작하세요.",
|
||||
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이번 CodeWhale 실행 중 일치하는 요청이 이전에 거부되었습니다. 다시 검토하려면 CodeWhale을 재시작하세요.",
|
||||
"ElevationTitleSandboxDenied": " ⚠, 샌드박스 거부됨 ",
|
||||
"ElevationTitleRequired": " 샌드박스 권한 상승 필요 ",
|
||||
"ElevationFieldTool": " 도구: ",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"ApprovalChooseAction": "Enter para selecionar, ou pressione y/a/d diretamente",
|
||||
"ApprovalIntentLabel": "Intenção: ",
|
||||
"ApprovalMoreLines": " … (+{count} linhas)",
|
||||
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: você já negou esta mesma solicitação nesta sessão. Reinicie o CodeWhale para reconsiderá-la.",
|
||||
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: uma solicitação correspondente foi negada anteriormente nesta execução do CodeWhale. Reinicie o CodeWhale para reconsiderá-la.",
|
||||
"ElevationTitleSandboxDenied": " ⚠, Sandbox Negado ",
|
||||
"ElevationTitleRequired": " Elevação de Sandbox Necessária ",
|
||||
"ElevationFieldTool": " Ferramenta: ",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"ApprovalChooseAction": "Enter để chọn, hoặc nhấn y/a/d trực tiếp",
|
||||
"ApprovalIntentLabel": "Ý định: ",
|
||||
"ApprovalMoreLines": " … (+{count} dòng)",
|
||||
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: bạn đã từ chối chính yêu cầu này trước đó trong phiên. Hãy khởi động lại CodeWhale để xem xét lại.",
|
||||
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: một yêu cầu khớp đã bị từ chối trước đó trong lần chạy CodeWhale này. Hãy khởi động lại CodeWhale để xem xét lại.",
|
||||
"ElevationTitleSandboxDenied": " ⚠ Sandbox Bị Từ Chối ",
|
||||
"ElevationTitleRequired": " Yêu Cầu Nâng Cấp Sandbox ",
|
||||
"ElevationFieldTool": " Công cụ: ",
|
||||
|
||||
@@ -565,7 +565,7 @@
|
||||
"ApprovalChooseAction": "Enter 执行选中项,或直接按 y/a/d",
|
||||
"ApprovalIntentLabel": "意图:",
|
||||
"ApprovalMoreLines": " … (还有 {count} 行)",
|
||||
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:你之前在此会话中拒绝了相同请求。若要重新考虑,请重启 CodeWhale。",
|
||||
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:在本次 CodeWhale 运行期间,已有匹配请求被拒绝。若要重新考虑,请重启 CodeWhale。",
|
||||
"ElevationTitleSandboxDenied": " ⚠ 沙箱拒绝 ",
|
||||
"ElevationTitleRequired": " 沙箱提权 ",
|
||||
"ElevationFieldTool": " 工具:",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"ApprovalChooseAction": "Enter 執行選中項,或直接按 y/a/d",
|
||||
"ApprovalIntentLabel": "意圖:",
|
||||
"ApprovalMoreLines": " … (還有 {count} 行)",
|
||||
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:你先前在此工作階段中拒絕了相同請求。若要重新考慮,請重新啟動 CodeWhale。",
|
||||
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:在本次 CodeWhale 執行期間,已有相符請求被拒絕。若要重新考慮,請重新啟動 CodeWhale。",
|
||||
"ElevationTitleSandboxDenied": " ⚠, 沙箱拒絕 ",
|
||||
"ElevationTitleRequired": " 沙箱提權 ",
|
||||
"ElevationFieldTool": " 工具:",
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::utils::{flush_and_sync, open_append};
|
||||
|
||||
/// Append an audit event to `~/.codewhale/audit.log`.
|
||||
/// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default
|
||||
/// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured).
|
||||
///
|
||||
/// This helper is best-effort by design: callers should not fail critical flows
|
||||
/// if audit persistence fails.
|
||||
@@ -40,6 +41,5 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn default_audit_path() -> anyhow::Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
|
||||
Ok(home.join(".codewhale").join("audit.log"))
|
||||
Ok(codewhale_config::codewhale_home()?.join("audit.log"))
|
||||
}
|
||||
@@ -158,10 +158,9 @@ impl ActiveCell {
|
||||
entry_idx
|
||||
}
|
||||
|
||||
/// Push an entry with no tool id binding (used for non-tool grouping if
|
||||
/// ever needed). Currently unused; kept for symmetry with Codex which
|
||||
/// allows e.g. session-header cells to live in `active_cell`.
|
||||
#[allow(dead_code)]
|
||||
/// Push an entry with no tool id binding. Approval notices use this path
|
||||
/// so they can remain in the transcript without shifting the virtual
|
||||
/// indices of tools that are still running in `active_cell`.
|
||||
pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
|
||||
let entry_idx = self.entries.len();
|
||||
self.entries.push(cell);
|
||||
|
||||
+21
-42
@@ -4696,30 +4696,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_expired_status_toasts(&mut self, now: Instant) {
|
||||
let queued_before = self.status_toasts.len();
|
||||
self.status_toasts.retain(|toast| !toast.is_expired(now));
|
||||
let queued_removed = self.status_toasts.len() != queued_before;
|
||||
let sticky_removed = self
|
||||
.sticky_status
|
||||
.as_ref()
|
||||
.is_some_and(|toast| toast.is_expired(now));
|
||||
if sticky_removed {
|
||||
self.sticky_status = None;
|
||||
}
|
||||
if queued_removed || sticky_removed {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to `limit` currently-active toasts, most recent last (so a stacked
|
||||
/// renderer iterating top-to-bottom shows the freshest message at the
|
||||
/// bottom, like a chat log). Drains expired toasts off the front as a
|
||||
/// side effect — same cleanup as `active_status_toast` so callers see a
|
||||
/// consistent queue. Whalescale#439.
|
||||
/// bottom, like a chat log). Prunes expired toasts throughout the queue as
|
||||
/// a side effect — the same cleanup as `active_status_toast` so callers see
|
||||
/// a consistent queue. Whalescale#439.
|
||||
pub fn active_status_toasts(&mut self, limit: usize) -> Vec<StatusToast> {
|
||||
self.sync_status_message_to_toasts();
|
||||
let now = Instant::now();
|
||||
while self
|
||||
.status_toasts
|
||||
.front()
|
||||
.is_some_and(|toast| toast.is_expired(now))
|
||||
{
|
||||
self.status_toasts.pop_front();
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
if self
|
||||
.sticky_status
|
||||
.as_ref()
|
||||
.is_some_and(|toast| toast.is_expired(now))
|
||||
{
|
||||
self.sticky_status = None;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
self.prune_expired_status_toasts(now);
|
||||
|
||||
let mut out: Vec<StatusToast> = Vec::with_capacity(limit);
|
||||
if let Some(sticky) = self.sticky_status.clone() {
|
||||
@@ -4744,29 +4745,7 @@ impl App {
|
||||
pub fn active_status_toast(&mut self) -> Option<StatusToast> {
|
||||
self.sync_status_message_to_toasts();
|
||||
let now = Instant::now();
|
||||
let mut removed = false;
|
||||
|
||||
while self
|
||||
.status_toasts
|
||||
.front()
|
||||
.is_some_and(|toast| toast.is_expired(now))
|
||||
{
|
||||
self.status_toasts.pop_front();
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if self
|
||||
.sticky_status
|
||||
.as_ref()
|
||||
.is_some_and(|toast| toast.is_expired(now))
|
||||
{
|
||||
self.sticky_status = None;
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if removed {
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
self.prune_expired_status_toasts(now);
|
||||
|
||||
self.sticky_status
|
||||
.clone()
|
||||
|
||||
@@ -3836,6 +3836,41 @@ fn status_classifier_does_not_paint_negated_success_green() {
|
||||
assert_eq!(level, StatusToastLevel::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_toasts_expire_even_behind_a_persistent_entry() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.status_message = None;
|
||||
app.last_status_message_seen = None;
|
||||
app.status_toasts.clear();
|
||||
app.sticky_status = None;
|
||||
|
||||
app.push_status_toast("persistent", StatusToastLevel::Info, None);
|
||||
app.push_status_toast("expired-list", StatusToastLevel::Warning, Some(1));
|
||||
app.status_toasts
|
||||
.back_mut()
|
||||
.expect("temporary toast")
|
||||
.created_at = Instant::now() - std::time::Duration::from_millis(2);
|
||||
|
||||
let visible = app.active_status_toasts(3);
|
||||
assert_eq!(
|
||||
visible
|
||||
.iter()
|
||||
.map(|toast| toast.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["persistent"]
|
||||
);
|
||||
|
||||
app.push_status_toast("expired-single", StatusToastLevel::Warning, Some(1));
|
||||
app.status_toasts
|
||||
.back_mut()
|
||||
.expect("temporary toast")
|
||||
.created_at = Instant::now() - std::time::Duration::from_millis(2);
|
||||
|
||||
let active = app.active_status_toast().expect("persistent toast remains");
|
||||
assert_eq!(active.text, "persistent");
|
||||
assert_eq!(app.status_toasts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_provider_copy_is_provider_neutral_in_en() {
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
|
||||
@@ -273,6 +273,18 @@ async fn auto_deny_session_approval(
|
||||
let notice = app
|
||||
.tr(MessageId::ApprovalAutoDeniedSession)
|
||||
.replace("{tool}", tool_name);
|
||||
let notice_cell = HistoryCell::System {
|
||||
content: notice.clone(),
|
||||
};
|
||||
if let Some(active) = app.active_cell.as_mut() {
|
||||
// Keep the notice in the live transcript without changing
|
||||
// `history.len()`: running tools are addressed by virtual indices
|
||||
// based on that length until the active cell is flushed.
|
||||
active.push_untracked(notice_cell);
|
||||
app.mark_history_updated();
|
||||
} else {
|
||||
app.add_message(notice_cell);
|
||||
}
|
||||
app.push_status_toast(notice, StatusToastLevel::Warning, Some(12_000));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::tui::ui_text::truncate_line_to_width;
|
||||
use crate::tui::views::{HelpView, ModalView, ViewAction};
|
||||
use crate::working_set::Workspace;
|
||||
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::{Terminal, backend::TestBackend, text::Span};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
@@ -2119,8 +2119,28 @@ fn session_denied_cache_matches_only_approval_key() {
|
||||
assert!(is_session_denied_for_key(&app, "file:edit_file:retry"));
|
||||
}
|
||||
|
||||
fn render_underwater_test_app(app: &mut App, width: u16, height: u16) -> String {
|
||||
app.onboarding_workspace_trust_gate = false;
|
||||
app.onboarding = OnboardingState::None;
|
||||
let config = Config::default();
|
||||
let mut terminal =
|
||||
Terminal::new(TestBackend::new(width, height)).expect("underwater test terminal");
|
||||
terminal
|
||||
.draw(|frame| render(frame, app, &config))
|
||||
.expect("render underwater shell");
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
|
||||
let home = SettingsHomeGuard::new();
|
||||
let audit_path = home._tmp.path().join(".codewhale").join("audit.log");
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
let approval_key = "shell:exec_shell:git push secret-token";
|
||||
@@ -2143,14 +2163,260 @@ async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
|
||||
let toast = app.status_toasts.back().expect("auto-deny warning toast");
|
||||
assert_eq!(toast.level, StatusToastLevel::Warning);
|
||||
assert_eq!(toast.ttl_ms, Some(12_000));
|
||||
assert!(toast.text.contains("rejected this exact request earlier"));
|
||||
assert!(toast.text.contains("matching request was denied earlier"));
|
||||
assert!(toast.text.contains("during this CodeWhale run"));
|
||||
assert!(toast.text.contains("Restart CodeWhale"));
|
||||
assert!(toast.text.contains("exec_shell"));
|
||||
let history_notice = app
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|cell| match cell {
|
||||
HistoryCell::System { content } => Some(content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("persistent auto-deny explanation");
|
||||
assert_eq!(history_notice, toast.text);
|
||||
for visible_text in [&toast.text, history_notice] {
|
||||
assert!(
|
||||
!visible_text.contains(approval_key)
|
||||
&& !visible_text.contains("git push")
|
||||
&& !visible_text.contains("secret-token"),
|
||||
"the user-facing notice must not expose the approval key, command, or arguments"
|
||||
);
|
||||
}
|
||||
let audit = std::fs::read_to_string(&audit_path).expect("isolated approval audit log");
|
||||
assert!(audit.contains(approval_key));
|
||||
|
||||
let rendered = render_underwater_test_app(&mut app, 40, 12);
|
||||
assert!(rendered.contains("Auto-denied"), "{rendered:?}");
|
||||
assert!(
|
||||
!toast.text.contains(approval_key)
|
||||
&& !toast.text.contains("git push")
|
||||
&& !toast.text.contains("secret-token"),
|
||||
"the user-facing notice must not expose the approval key, command, or arguments"
|
||||
rendered.contains("Restart") && rendered.contains("CodeWhale"),
|
||||
"{rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_denied_cache_notice_preserves_active_tool_index() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
let tool_id = "tool-retry";
|
||||
let tool_name = "exec_shell";
|
||||
|
||||
handle_tool_call_started(
|
||||
&mut app,
|
||||
tool_id,
|
||||
tool_name,
|
||||
&serde_json::json!({"command": "git push secret-token"}),
|
||||
);
|
||||
let history_len = app.history.len();
|
||||
let tool_index = app.tool_cells[tool_id];
|
||||
assert_eq!(tool_index, history_len);
|
||||
|
||||
auto_deny_session_approval(
|
||||
&mut app,
|
||||
&engine.handle,
|
||||
tool_id,
|
||||
tool_name,
|
||||
"shell:exec_shell:git push secret-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
app.history.len(),
|
||||
history_len,
|
||||
"the notice must not shift virtual indices while a tool is active"
|
||||
);
|
||||
assert_eq!(app.tool_cells.get(tool_id), Some(&tool_index));
|
||||
let active = app.active_cell.as_ref().expect("active tool and notice");
|
||||
assert!(matches!(
|
||||
active.entries().first(),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Running
|
||||
));
|
||||
assert!(matches!(
|
||||
active.entries().get(1),
|
||||
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
engine.recv_approval_event().await,
|
||||
Some(crate::core::engine::MockApprovalEvent::Denied {
|
||||
id: tool_id.to_string(),
|
||||
})
|
||||
);
|
||||
let denied_result = Ok(crate::tools::spec::ToolResult::error(
|
||||
"request denied by cached approval",
|
||||
));
|
||||
handle_tool_call_complete(&mut app, tool_id, tool_name, &denied_result);
|
||||
|
||||
let active = app.active_cell.as_ref().expect("completed tool and notice");
|
||||
assert!(matches!(
|
||||
active.entries().first(),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Failed
|
||||
));
|
||||
assert!(matches!(
|
||||
active.entries().get(1),
|
||||
Some(HistoryCell::System { .. })
|
||||
));
|
||||
|
||||
app.flush_active_cell();
|
||||
assert!(matches!(
|
||||
app.history.get(history_len),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Failed
|
||||
));
|
||||
assert!(matches!(
|
||||
app.history.get(history_len + 1),
|
||||
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
|
||||
));
|
||||
let detail = app
|
||||
.tool_detail_record_for_cell(history_len)
|
||||
.expect("tool detail remains bound to the completed tool cell");
|
||||
assert_eq!(detail.tool_id, tool_id);
|
||||
assert!(
|
||||
detail
|
||||
.output
|
||||
.as_deref()
|
||||
.is_some_and(|output| output.contains("cached approval"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_denied_cache_notice_preserves_parallel_tool_indices() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
|
||||
handle_tool_call_started(
|
||||
&mut app,
|
||||
"tool-first",
|
||||
"exec_shell",
|
||||
&serde_json::json!({"command": "echo first"}),
|
||||
);
|
||||
handle_tool_call_started(
|
||||
&mut app,
|
||||
"tool-denied",
|
||||
"exec_shell",
|
||||
&serde_json::json!({"command": "git push secret-token"}),
|
||||
);
|
||||
let history_len = app.history.len();
|
||||
let first_index = app.tool_cells["tool-first"];
|
||||
let denied_index = app.tool_cells["tool-denied"];
|
||||
assert_eq!(first_index, history_len);
|
||||
assert_eq!(denied_index, history_len + 1);
|
||||
|
||||
auto_deny_session_approval(
|
||||
&mut app,
|
||||
&engine.handle,
|
||||
"tool-denied",
|
||||
"exec_shell",
|
||||
"shell:exec_shell:git push secret-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(app.history.len(), history_len);
|
||||
assert_eq!(app.tool_cells.get("tool-first"), Some(&first_index));
|
||||
assert_eq!(app.tool_cells.get("tool-denied"), Some(&denied_index));
|
||||
assert_eq!(
|
||||
engine.recv_approval_event().await,
|
||||
Some(crate::core::engine::MockApprovalEvent::Denied {
|
||||
id: "tool-denied".to_string(),
|
||||
})
|
||||
);
|
||||
|
||||
let denied_result = Ok(crate::tools::spec::ToolResult::error(
|
||||
"request denied by cached approval",
|
||||
));
|
||||
handle_tool_call_complete(&mut app, "tool-denied", "exec_shell", &denied_result);
|
||||
let active = app.active_cell.as_ref().expect("parallel tools and notice");
|
||||
assert!(matches!(
|
||||
active.entries().first(),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
|
||||
if exec.status == ToolStatus::Running && exec.command == "echo first"
|
||||
));
|
||||
assert!(matches!(
|
||||
active.entries().get(1),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
|
||||
if exec.status == ToolStatus::Failed
|
||||
&& exec.command == "git push secret-token"
|
||||
&& exec.output.as_deref().is_some_and(|output| output.contains("cached approval"))
|
||||
));
|
||||
assert!(matches!(
|
||||
active.entries().get(2),
|
||||
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
|
||||
));
|
||||
|
||||
handle_tool_call_complete(
|
||||
&mut app,
|
||||
"tool-first",
|
||||
"exec_shell",
|
||||
&ok_result("first output"),
|
||||
);
|
||||
app.flush_active_cell();
|
||||
|
||||
assert!(matches!(
|
||||
app.history.get(history_len),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
|
||||
if exec.status == ToolStatus::Success
|
||||
&& exec.command == "echo first"
|
||||
&& exec.output.as_deref() == Some("first output")
|
||||
));
|
||||
assert!(matches!(
|
||||
app.history.get(history_len + 1),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
|
||||
if exec.status == ToolStatus::Failed && exec.command == "git push secret-token"
|
||||
));
|
||||
assert!(matches!(
|
||||
app.history.get(history_len + 2),
|
||||
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_denied_cache_notice_renders_host_scope_in_zh_hans() {
|
||||
let _home = SettingsHomeGuard::new();
|
||||
let mut app = create_test_app();
|
||||
app.ui_locale = crate::localization::Locale::ZhHans;
|
||||
let mut engine = mock_engine_handle();
|
||||
|
||||
auto_deny_session_approval(
|
||||
&mut app,
|
||||
&engine.handle,
|
||||
"fetch-retry",
|
||||
"fetch_url",
|
||||
"net:example.com",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
engine.recv_approval_event().await,
|
||||
Some(crate::core::engine::MockApprovalEvent::Denied {
|
||||
id: "fetch-retry".to_string(),
|
||||
})
|
||||
);
|
||||
let notice = app
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|cell| match cell {
|
||||
HistoryCell::System { content } => Some(content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("localized persistent auto-deny explanation");
|
||||
assert!(notice.contains("本次 CodeWhale 运行期间"));
|
||||
assert!(notice.contains("匹配请求"));
|
||||
assert!(!notice.contains("example.com"));
|
||||
|
||||
let rendered = render_underwater_test_app(&mut app, 60, 16);
|
||||
let rendered_compact = rendered
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.collect::<String>();
|
||||
assert!(rendered_compact.contains("已自动拒绝"), "{rendered:?}");
|
||||
assert!(rendered_compact.contains("匹配请求"), "{rendered:?}");
|
||||
assert!(
|
||||
rendered_compact.contains("重启") && rendered_compact.contains("CodeWhale"),
|
||||
"{rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user