Skip to content

Commit 434b50d

Browse files
fix: clear permission-scoped state when leaving the permission flow (#9525) (#9671)
## Description Resolves #9525. `PermissionRequest` populates `session_context.summary` (plus `tool_name` / `tool_input_preview`) so the tab can show e.g. *\"Wants to run bash: …\"*. Nothing was clearing those fields when the permission flow ended, so after the session reached `Success` the tab kept showing the stale permission text — the bug the reporter hit on Claude Code + [claude-code-warp](https://github.com/warpdotdev/claude-code-warp). ### Root cause The title path under `AgentTabTextPreference::ConversationTitle` reads `session_context.title_like_text()` directly, which is `summary`. So even after `Stop` repopulates `query` and `response`, the tab keeps showing whatever the last `PermissionRequest` set in `summary`. ```rust // app/src/workspace/view/vertical_tabs.rs:3081 agent_text.cli_agent_title = session.session_context.title_like_text(); ``` ### Fix Introduce `CLIAgentSession::clear_permission_scoped_state()` and call it on the three transitions that close out the permission flow: | Transition | Why | |---|---| | `Stop` | Session completed; permission state is no longer relevant — primary repro path. | | `PermissionReplied` | User answered; status returns to InProgress. | | `PromptSubmit` | Defensive: the user starting a new turn invalidates any leftover permission state (mirrors the existing `response = None` reset already on this arm). | `PermissionRequest` itself still populates these fields, so the Blocked-state UI is unchanged. ## Linked Issue - [x] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. (#9525 is `ready-to-implement`.) Resolves #9525. ## Testing Four new unit tests in `app/src/terminal/cli_agent_sessions/mod_tests.rs`: | Test | What it checks | |---|---| | `stop_clears_permission_scoped_state` | Primary GH-9525 regression: `summary`/`tool_name`/`tool_input_preview` are cleared and `query`/`response` are repopulated when `Stop` follows `PermissionRequest`. | | `permission_replied_clears_permission_scoped_state` | Covers the user-replied path; status returns to InProgress with permission state cleared. | | `prompt_submit_clears_permission_scoped_state` | Covers the abandoned-permission path; clears `response` (existing behavior) and the permission fields (new behavior). | | `permission_request_still_populates_summary_and_tool_fields` | Sanity: the population path is unaffected — `PermissionRequest` still sets all three fields and transitions to Blocked. | Each test starts from a Blocked session with permission state populated (factored into a `blocked_claude_session_with_permission_state()` helper), applies the relevant event, and asserts the post-state. ## Agent Mode - [ ] Warp Agent Mode - This PR was created via Warp's AI Agent Mode CHANGELOG-BUG-FIX: Tab titles for plugin-backed CLI agents (e.g. Claude Code via claude-code-warp) no longer keep showing stale permission-prompt text after a session completes. Thanks @lonexreb! --------- Co-authored-by: harryalbert <harryalbert364@gmail.com>
1 parent 044d6eb commit 434b50d

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

app/src/terminal/cli_agent_sessions/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,17 @@ impl CLIAgentSession {
148148
self.remote_host.is_some()
149149
}
150150

151+
/// Clears state populated by `PermissionRequest`. Called whenever the
152+
/// session leaves the permission flow (the user replied, a new prompt
153+
/// is submitted, or the session ends successfully) so the permission
154+
/// summary doesn't leak into later UI surfaces — most visibly the tab
155+
/// title, which can fall back to `summary` when `query` is unset.
156+
fn clear_permission_scoped_state(&mut self) {
157+
self.session_context.summary = None;
158+
self.session_context.tool_name = None;
159+
self.session_context.tool_input_preview = None;
160+
}
161+
151162
/// Applies an event to this session, updating context and status.
152163
/// Returns the new status if it changed, or `None` if the event was irrelevant.
153164
fn apply_event(&mut self, event: &CLIAgentEvent) -> Option<CLIAgentSessionStatus> {
@@ -165,6 +176,7 @@ impl CLIAgentSession {
165176
CLIAgentEventType::PromptSubmit => {
166177
self.session_context.query = event.payload.query.clone();
167178
self.session_context.response = None;
179+
self.clear_permission_scoped_state();
168180
CLIAgentSessionStatus::InProgress
169181
}
170182
CLIAgentEventType::ToolComplete => {
@@ -176,6 +188,7 @@ impl CLIAgentSession {
176188
CLIAgentEventType::Stop => {
177189
self.session_context.query = event.payload.query.clone();
178190
self.session_context.response = event.payload.response.clone();
191+
self.clear_permission_scoped_state();
179192
CLIAgentSessionStatus::Success
180193
}
181194
CLIAgentEventType::PermissionRequest => {
@@ -197,6 +210,7 @@ impl CLIAgentSession {
197210
if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) {
198211
return None;
199212
}
213+
self.clear_permission_scoped_state();
200214
CLIAgentSessionStatus::InProgress
201215
}
202216
// IdlePrompt means the agent is sitting at its prompt waiting for input.

app/src/terminal/cli_agent_sessions/mod_tests.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,174 @@ fn session_start_without_plugin_version_leaves_none() {
422422
session.apply_event(&event);
423423
assert_eq!(session.plugin_version, None);
424424
}
425+
426+
/// Constructs a session with permission-scoped state already populated, as if
427+
/// a `PermissionRequest` had just been received and the agent is now Blocked.
428+
/// Used by the GH-9525 regression tests below.
429+
fn blocked_claude_session_with_permission_state() -> CLIAgentSession {
430+
CLIAgentSession {
431+
agent: CLIAgent::Claude,
432+
status: CLIAgentSessionStatus::Blocked {
433+
message: Some("Wants to run bash: rm -rf /tmp".to_owned()),
434+
},
435+
session_context: CLIAgentSessionContext {
436+
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
437+
tool_name: Some("Bash".to_owned()),
438+
tool_input_preview: Some("rm -rf /tmp".to_owned()),
439+
..Default::default()
440+
},
441+
input_state: CLIAgentInputState::Closed,
442+
should_auto_toggle_input: false,
443+
listener: None,
444+
plugin_version: None,
445+
draft_text: None,
446+
remote_host: None,
447+
custom_command_prefix: None,
448+
}
449+
}
450+
451+
#[test]
452+
fn stop_clears_permission_scoped_state() {
453+
// GH-9525: after a PermissionRequest sets `summary`, the Stop event must
454+
// clear it. Otherwise the tab title falls back to the stale permission
455+
// text instead of reflecting the now-completed session.
456+
let mut session = blocked_claude_session_with_permission_state();
457+
458+
let event = CLIAgentEvent {
459+
v: 1,
460+
agent: CLIAgent::Claude,
461+
event: CLIAgentEventType::Stop,
462+
session_id: Some("abc".to_owned()),
463+
cwd: None,
464+
project: None,
465+
payload: CLIAgentEventPayload {
466+
query: Some("write a haiku".to_owned()),
467+
response: Some("Memory is safe".to_owned()),
468+
..Default::default()
469+
},
470+
};
471+
472+
session.apply_event(&event);
473+
474+
assert_eq!(session.session_context.summary, None);
475+
assert_eq!(session.session_context.tool_name, None);
476+
assert_eq!(session.session_context.tool_input_preview, None);
477+
assert_eq!(
478+
session.session_context.query.as_deref(),
479+
Some("write a haiku"),
480+
);
481+
assert_eq!(
482+
session.session_context.response.as_deref(),
483+
Some("Memory is safe"),
484+
);
485+
assert!(matches!(session.status, CLIAgentSessionStatus::Success));
486+
}
487+
488+
#[test]
489+
fn permission_replied_clears_permission_scoped_state() {
490+
// When the user replies to a permission prompt the agent transitions back
491+
// to InProgress; the now-stale summary/tool fields must be cleared so they
492+
// don't leak into UI surfaces during the next turn.
493+
let mut session = blocked_claude_session_with_permission_state();
494+
495+
let event = CLIAgentEvent {
496+
v: 1,
497+
agent: CLIAgent::Claude,
498+
event: CLIAgentEventType::PermissionReplied,
499+
session_id: Some("abc".to_owned()),
500+
cwd: None,
501+
project: None,
502+
payload: CLIAgentEventPayload::default(),
503+
};
504+
505+
session.apply_event(&event);
506+
507+
assert_eq!(session.session_context.summary, None);
508+
assert_eq!(session.session_context.tool_name, None);
509+
assert_eq!(session.session_context.tool_input_preview, None);
510+
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
511+
}
512+
513+
#[test]
514+
fn prompt_submit_clears_permission_scoped_state() {
515+
// PromptSubmit already clears `response`; clearing the permission-scoped
516+
// fields keeps the same hygiene if the user manages to start a new turn
517+
// while permission state is still populated (e.g. an abandoned permission
518+
// flow that was not closed by an explicit PermissionReplied).
519+
let mut session = blocked_claude_session_with_permission_state();
520+
session.session_context.response = Some("stale response".to_owned());
521+
522+
let event = CLIAgentEvent {
523+
v: 1,
524+
agent: CLIAgent::Claude,
525+
event: CLIAgentEventType::PromptSubmit,
526+
session_id: Some("abc".to_owned()),
527+
cwd: None,
528+
project: None,
529+
payload: CLIAgentEventPayload {
530+
query: Some("next prompt".to_owned()),
531+
..Default::default()
532+
},
533+
};
534+
535+
session.apply_event(&event);
536+
537+
assert_eq!(session.session_context.summary, None);
538+
assert_eq!(session.session_context.tool_name, None);
539+
assert_eq!(session.session_context.tool_input_preview, None);
540+
assert_eq!(session.session_context.response, None);
541+
assert_eq!(
542+
session.session_context.query.as_deref(),
543+
Some("next prompt")
544+
);
545+
assert!(matches!(session.status, CLIAgentSessionStatus::InProgress));
546+
}
547+
548+
#[test]
549+
fn permission_request_still_populates_summary_and_tool_fields() {
550+
// Sanity: clearing permission-scoped state on Stop/Reply/Submit must not
551+
// also break the PermissionRequest path that initially populates them.
552+
let mut session = CLIAgentSession {
553+
agent: CLIAgent::Claude,
554+
status: CLIAgentSessionStatus::InProgress,
555+
session_context: CLIAgentSessionContext::default(),
556+
input_state: CLIAgentInputState::Closed,
557+
should_auto_toggle_input: false,
558+
listener: None,
559+
plugin_version: None,
560+
draft_text: None,
561+
remote_host: None,
562+
custom_command_prefix: None,
563+
};
564+
565+
let event = CLIAgentEvent {
566+
v: 1,
567+
agent: CLIAgent::Claude,
568+
event: CLIAgentEventType::PermissionRequest,
569+
session_id: Some("abc".to_owned()),
570+
cwd: None,
571+
project: None,
572+
payload: CLIAgentEventPayload {
573+
summary: Some("Wants to run bash: rm -rf /tmp".to_owned()),
574+
tool_name: Some("Bash".to_owned()),
575+
tool_input_preview: Some("rm -rf /tmp".to_owned()),
576+
..Default::default()
577+
},
578+
};
579+
580+
session.apply_event(&event);
581+
582+
assert_eq!(
583+
session.session_context.summary.as_deref(),
584+
Some("Wants to run bash: rm -rf /tmp"),
585+
);
586+
assert_eq!(session.session_context.tool_name.as_deref(), Some("Bash"));
587+
assert_eq!(
588+
session.session_context.tool_input_preview.as_deref(),
589+
Some("rm -rf /tmp"),
590+
);
591+
assert!(matches!(
592+
session.status,
593+
CLIAgentSessionStatus::Blocked { .. },
594+
));
595+
}

0 commit comments

Comments
 (0)