mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-03 08:02:02 +00:00
feat(agents): infer interrupted and stale turns, report permission prompts first-hand (#1003)
- Interrupts: an Esc / Ctrl+C (legacy, kitty and modifyOtherKeys forms) on a pane whose agent is mid-turn arms a 1s settle; if no hook event arrives, the turn is assumed over and goes Done. Claude's Stop skips user interrupts, so panes used to stay on working until the next prompt. - Stale turns: a daemon sweep moves a turn that has been Working with no hook event for 30 minutes to Idle. - Both set `inferred` on the session; the next real event clears it, and a tool finishing after a guess puts the turn back on Working. Inferred states raise no finish notification and no unread badge. - Claude and Codex now hook PermissionRequest, and PreToolUse for their ask-the-user tools (AskUserQuestion / request_user_input), so Waiting shows the moment the prompt opens. Codex also gains PostToolUse, so it leaves Waiting once the approved tool has run. - The "finished" desktop notification waits 1.5s and is dropped if the next turn starts first (queued message, Stop hook sending it back).
This commit is contained in:
@@ -42,7 +42,20 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
|
||||
if ran_by_grok { "grok" } else { agent }
|
||||
}
|
||||
|
||||
/// The tools that stop a turn to ask the user a question: Claude's
|
||||
/// `AskUserQuestion` and Codex's `request_user_input`. Neither goes through a
|
||||
/// permission prompt, so their `PreToolUse` is the only sign the turn is now
|
||||
/// waiting on the user.
|
||||
const QUESTION_TOOLS: &[&str] = &["AskUserQuestion", "request_user_input"];
|
||||
|
||||
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
|
||||
// `tool-start` is not an event of its own: it fires before every tool
|
||||
// call, and only the ones that ask the user something change the status.
|
||||
if event == "tool-start" {
|
||||
let payload = serde_json::from_str::<serde_json::Value>(stdin_json).ok()?;
|
||||
let tool = payload.get("tool_name").and_then(|t| t.as_str())?;
|
||||
return QUESTION_TOOLS.contains(&tool).then_some("question-asked");
|
||||
}
|
||||
// Antigravity has no turn-start event, only `PreInvocation` before every
|
||||
// model call. The first call of a turn starts it; later ones are the same
|
||||
// turn still working, and must not count as new prompts. `Stop` with
|
||||
@@ -892,18 +905,33 @@ fn home_dir() -> Option<PathBuf> {
|
||||
const OWNED_FILE_STEM_JSON: &str = "tty7.json";
|
||||
const OWNED_FILE_STEM_JS: &str = "tty7.js";
|
||||
|
||||
/// `PermissionRequest` fires the moment the permission dialog opens; the
|
||||
/// `Notification` that also reports it can trail by seconds, and stays as the
|
||||
/// fallback for older Claude builds that do not know the event. A hook that
|
||||
/// prints nothing leaves the decision to the dialog. `PreToolUse` is there for
|
||||
/// `AskUserQuestion` alone (see [`QUESTION_TOOLS`]).
|
||||
const CLAUDE_HOOK_EVENTS: &[(&str, &str)] = &[
|
||||
("SessionStart", "session-start"),
|
||||
("UserPromptSubmit", "prompt-submit"),
|
||||
("PermissionRequest", "permission-request"),
|
||||
("PreToolUse", "tool-start"),
|
||||
("Notification", "notification"),
|
||||
("PostToolUse", "tool-complete"),
|
||||
("Stop", "stop"),
|
||||
("SessionEnd", "session-end"),
|
||||
];
|
||||
|
||||
/// Codex's `PermissionRequest` answered with no output falls through to its
|
||||
/// own approval prompt, so it reports the wait without deciding anything.
|
||||
/// `PostToolUse` is what takes the pane off waiting once the approved tool has
|
||||
/// run, and `PreToolUse` catches `request_user_input` (see
|
||||
/// [`QUESTION_TOOLS`]), which Codex runs without asking for approval.
|
||||
const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
|
||||
("SessionStart", "session-start"),
|
||||
("UserPromptSubmit", "prompt-submit"),
|
||||
("PermissionRequest", "permission-request"),
|
||||
("PreToolUse", "tool-start"),
|
||||
("PostToolUse", "tool-complete"),
|
||||
("Stop", "stop"),
|
||||
];
|
||||
|
||||
@@ -1966,6 +1994,46 @@ mod tests {
|
||||
assert_eq!(effective_agent("grok", false), "grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_start_only_speaks_for_question_tools() {
|
||||
for (agent, tool) in [
|
||||
("claude", "AskUserQuestion"),
|
||||
("codex", "request_user_input"),
|
||||
] {
|
||||
let input = format!(r#"{{"tool_name":"{tool}","tool_input":{{}}}}"#);
|
||||
assert_eq!(
|
||||
effective_event(agent, "tool-start", &input),
|
||||
Some("question-asked"),
|
||||
"{agent} {tool}"
|
||||
);
|
||||
}
|
||||
for input in [
|
||||
r#"{"tool_name":"Bash"}"#,
|
||||
r#"{"tool_name":"shell"}"#,
|
||||
"{}",
|
||||
"not json",
|
||||
] {
|
||||
assert_eq!(
|
||||
effective_event("claude", "tool-start", input),
|
||||
None,
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_report_permission_prompts_first_hand() {
|
||||
for events in [CLAUDE_HOOK_EVENTS, CODEX_HOOK_EVENTS] {
|
||||
for (hook, sentinel) in [
|
||||
("PermissionRequest", "permission-request"),
|
||||
("PreToolUse", "tool-start"),
|
||||
("PostToolUse", "tool-complete"),
|
||||
] {
|
||||
assert!(events.contains(&(hook, sentinel)), "{hook}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_installed_event_parses_as_a_sentinel_kind() {
|
||||
use crate::core::cli_agent::parse_agent_event;
|
||||
@@ -1985,6 +2053,9 @@ mod tests {
|
||||
.chain(CURSOR_HOOK_EVENTS)
|
||||
.map(|(_, e)| *e)
|
||||
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
|
||||
// Never sent as itself: `effective_event` turns it into a real
|
||||
// kind or drops it (`tool_start_only_speaks_for_question_tools`).
|
||||
.filter(|e| *e != "tool-start")
|
||||
.collect();
|
||||
events.extend([
|
||||
"prompt-submit",
|
||||
|
||||
@@ -971,6 +971,13 @@ pub struct AgentSessionState {
|
||||
/// later one that finished while nobody was watching (#870).
|
||||
#[serde(default)]
|
||||
pub turns: u64,
|
||||
/// The status was concluded by tty7, not reported by the agent: a turn
|
||||
/// the user interrupted ([`Self::assume_interrupted`]) or one that went
|
||||
/// quiet for too long ([`Self::assume_stale`]). Cleared by the next real
|
||||
/// event, and a tool finishing after it proves the guess wrong and puts
|
||||
/// the turn back on [`AgentStatus::Working`].
|
||||
#[serde(default)]
|
||||
pub inferred: bool,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
@@ -989,8 +996,44 @@ impl AgentSessionState {
|
||||
AgentStatus::Idle
|
||||
}
|
||||
|
||||
/// Whether a turn is in flight: the only statuses a user interrupt or a
|
||||
/// lost `Stop` can leave behind.
|
||||
pub fn mid_turn(&self) -> bool {
|
||||
matches!(self.status, AgentStatus::Working | AgentStatus::Waiting)
|
||||
}
|
||||
|
||||
/// End the turn the user just interrupted.
|
||||
///
|
||||
/// Most agents fire no hook when <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>
|
||||
/// cancels a turn — Claude's `Stop` explicitly skips user interrupts — so
|
||||
/// without this the pane stays on working until the next prompt, and a
|
||||
/// close asks about a turn that is long over. The caller has already
|
||||
/// waited for a real event to say otherwise.
|
||||
pub fn assume_interrupted(&mut self) {
|
||||
if !self.mid_turn() {
|
||||
return;
|
||||
}
|
||||
self.turns = self.turns.wrapping_add(1);
|
||||
self.status = AgentStatus::Done;
|
||||
self.message = None;
|
||||
self.inferred = true;
|
||||
}
|
||||
|
||||
/// Give up on a turn that has gone silent for longer than any real one
|
||||
/// stays quiet. Idle rather than Done: nothing says it finished, only
|
||||
/// that nothing says it is still going.
|
||||
pub fn assume_stale(&mut self) {
|
||||
if self.status != AgentStatus::Working {
|
||||
return;
|
||||
}
|
||||
self.status = AgentStatus::Idle;
|
||||
self.message = None;
|
||||
self.inferred = true;
|
||||
}
|
||||
|
||||
pub fn apply_event(&mut self, ev: &AgentEvent) {
|
||||
self.rich = true;
|
||||
let guessed = std::mem::take(&mut self.inferred);
|
||||
if let Some(id) = &ev.session_id {
|
||||
self.session_id = Some(id.clone());
|
||||
}
|
||||
@@ -1018,7 +1061,7 @@ impl AgentSessionState {
|
||||
}
|
||||
AgentEventKind::ToolComplete => {
|
||||
self.activity = self.activity.wrapping_add(1);
|
||||
if self.status == AgentStatus::Waiting {
|
||||
if self.status == AgentStatus::Waiting || guessed {
|
||||
self.status = AgentStatus::Working;
|
||||
self.message = None;
|
||||
}
|
||||
@@ -1648,6 +1691,95 @@ mod tests {
|
||||
assert_eq!(s.activity, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_assumed_interrupt_ends_the_turn_until_the_agent_says_otherwise() {
|
||||
let ev = |kind| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: None,
|
||||
prompt: None,
|
||||
};
|
||||
|
||||
let mut s = AgentSessionState::default();
|
||||
s.assume_interrupted();
|
||||
assert_eq!(s.status, AgentStatus::Idle, "no turn, nothing to interrupt");
|
||||
assert!(!s.inferred);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit));
|
||||
s.apply_event(&ev(AgentEventKind::PermissionRequest));
|
||||
s.assume_interrupted();
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Done,
|
||||
"a rejected prompt ends the turn"
|
||||
);
|
||||
assert_eq!(s.turns, 1);
|
||||
assert!(s.inferred);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Working,
|
||||
"a tool finishing after the key proves the turn was still going"
|
||||
);
|
||||
assert!(!s.inferred);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::Stop));
|
||||
assert_eq!(s.turns, 2);
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Done,
|
||||
"a real Stop is not a guess and stays put"
|
||||
);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit));
|
||||
s.assume_interrupted();
|
||||
s.apply_event(&ev(AgentEventKind::Notification));
|
||||
assert!(!s.inferred, "any real event retires the guess");
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(s.status, AgentStatus::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_turn_goes_idle_and_comes_back_on_the_next_tool() {
|
||||
let ev = |kind| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: None,
|
||||
prompt: None,
|
||||
};
|
||||
|
||||
let mut s = AgentSessionState::default();
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit));
|
||||
s.apply_event(&ev(AgentEventKind::PermissionRequest));
|
||||
s.assume_stale();
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Waiting,
|
||||
"a prompt nobody answered is not stale"
|
||||
);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
s.assume_stale();
|
||||
assert_eq!(s.status, AgentStatus::Idle);
|
||||
assert_eq!(s.turns, 0, "going quiet is not finishing");
|
||||
assert!(s.inferred);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(s.status, AgentStatus::Working);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_state_from_an_older_daemon_is_not_inferred() {
|
||||
let s: AgentSessionState = serde_json::from_str(r#"{"status":"done"}"#).unwrap();
|
||||
assert!(!s.inferred);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_finished_turn_is_counted_once() {
|
||||
let ev = |kind| AgentEvent {
|
||||
|
||||
@@ -1715,6 +1715,7 @@ mod tests {
|
||||
cwd: Some("/work/api".into()),
|
||||
activity: 3,
|
||||
turns: 1,
|
||||
inferred: false,
|
||||
},
|
||||
}])),
|
||||
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
|
||||
|
||||
@@ -717,10 +717,77 @@ struct PaneState {
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
agent_argv: Option<Vec<String>>,
|
||||
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
|
||||
agent_clock: AgentClock,
|
||||
alive: bool,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// When the agent session last heard from the agent, for the two conclusions
|
||||
/// tty7 draws from silence: a turn the user interrupted, and a turn whose end
|
||||
/// was lost.
|
||||
#[derive(Default)]
|
||||
struct AgentClock {
|
||||
/// Bumped by every hook event and every session reset, so a pending
|
||||
/// interrupt can tell whether anything spoke after the key it was armed by.
|
||||
generation: u64,
|
||||
/// The last hook event, or when the sweep first saw a turn it had no
|
||||
/// event time for (a pane carried across a daemon handoff).
|
||||
last_event: Option<std::time::Instant>,
|
||||
/// An interrupt key is already waiting out [`INTERRUPT_SETTLE`].
|
||||
interrupt_pending: bool,
|
||||
}
|
||||
|
||||
/// How long after an interrupt key a real hook event still gets to speak
|
||||
/// first. Agents that do report an interrupt (Kimi's `Interrupt`, a `Stop`
|
||||
/// racing the key) do it well inside this.
|
||||
const INTERRUPT_SETTLE: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// How long a turn may stay on working with no hook event at all before the
|
||||
/// status is given up on. Far past any single quiet stretch a live turn has:
|
||||
/// Claude's longest tool call is capped at ten minutes, and every other event
|
||||
/// — a tool finishing, a permission prompt — resets the clock.
|
||||
pub(crate) const AGENT_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Whether one input write is the user asking a running agent to stop: a bare
|
||||
/// <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>, in the legacy encoding or in the
|
||||
/// kitty keyboard protocol and xterm `modifyOtherKeys` forms a TUI may have
|
||||
/// switched the terminal into. Only a whole write counts — the key arrives on
|
||||
/// its own, and a paste that happens to hold `0x03` is not a keypress.
|
||||
fn is_interrupt_key(bytes: &[u8]) -> bool {
|
||||
match bytes {
|
||||
b"\x1b" | b"\x03" | b"\x1b[27;5;99~" => return true,
|
||||
_ => {}
|
||||
}
|
||||
let Some(body) = bytes
|
||||
.strip_prefix(b"\x1b[")
|
||||
.and_then(|b| b.strip_suffix(b"u"))
|
||||
.and_then(|b| std::str::from_utf8(b).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut fields = body.split(';');
|
||||
let Some(code) = fields
|
||||
.next()
|
||||
.and_then(|f| f.split(':').next())
|
||||
.and_then(|c| c.parse::<u32>().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut modifiers = fields.next().unwrap_or("1").split(':');
|
||||
let mods = modifiers
|
||||
.next()
|
||||
.and_then(|m| m.parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
let event = modifiers.next().map_or(Some(1), |e| e.parse::<u32>().ok());
|
||||
if event != Some(1) {
|
||||
return false;
|
||||
}
|
||||
// Caps Lock and Num Lock ride along in the mask without changing the key.
|
||||
const LOCKS: u32 = 64 | 128;
|
||||
let mods = mods.saturating_sub(1) & !LOCKS;
|
||||
(code == 27 && mods == 0) || (code == 99 && mods == 4)
|
||||
}
|
||||
|
||||
fn notify(st: &mut PaneState, msg: DaemonMsg) {
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(msg.clone());
|
||||
@@ -1594,6 +1661,7 @@ impl DaemonPane {
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
agent_clock: AgentClock::default(),
|
||||
alive: true,
|
||||
exit_code: None,
|
||||
},
|
||||
@@ -1825,6 +1893,7 @@ impl DaemonPane {
|
||||
agent: carried.agent,
|
||||
agent_session: carried.agent_session,
|
||||
agent_argv: carried.agent_argv,
|
||||
agent_clock: AgentClock::default(),
|
||||
alive: true,
|
||||
exit_code: None,
|
||||
},
|
||||
@@ -1879,6 +1948,7 @@ impl DaemonPane {
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
agent_clock: AgentClock::default(),
|
||||
alive: true,
|
||||
exit_code: None,
|
||||
}));
|
||||
@@ -2251,6 +2321,11 @@ impl DaemonPane {
|
||||
agent_state_snapshot(&self.state.lock().unwrap())
|
||||
}
|
||||
|
||||
/// See [`expire_stale_agent`].
|
||||
pub fn expire_stale_agent(&self) {
|
||||
expire_stale_agent(&mut self.state.lock().unwrap(), std::time::Instant::now());
|
||||
}
|
||||
|
||||
pub fn gate(&self) -> Arc<OutputGate> {
|
||||
self.gate.clone()
|
||||
}
|
||||
@@ -2259,6 +2334,9 @@ impl DaemonPane {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if is_interrupt_key(bytes) {
|
||||
arm_interrupt(&self.state);
|
||||
}
|
||||
if let Ok(mut writer) = self.writer.lock() {
|
||||
let _ = writer.write_all(bytes);
|
||||
let _ = writer.flush();
|
||||
@@ -3067,6 +3145,73 @@ fn agent_from_shell_mark(
|
||||
Some((agent, crate::core::cli_agent::command_argv(cmd)))
|
||||
}
|
||||
|
||||
/// Start waiting out an interrupt key on a pane whose agent is mid-turn.
|
||||
///
|
||||
/// The key only *may* have stopped the turn — it also dismisses menus and
|
||||
/// clears half-typed input — so nothing changes until [`INTERRUPT_SETTLE`]
|
||||
/// passes with no hook event. A guess that still turns out wrong is undone by
|
||||
/// the agent's next finished tool ([`AgentSessionState::apply_event`]).
|
||||
///
|
||||
/// [`AgentSessionState::apply_event`]: crate::core::cli_agent::AgentSessionState::apply_event
|
||||
fn arm_interrupt(state: &Arc<Mutex<PaneState>>) {
|
||||
let generation = {
|
||||
let mut st = state.lock().unwrap();
|
||||
let armable = st
|
||||
.agent_session
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.rich && s.mid_turn());
|
||||
if !armable || st.agent_clock.interrupt_pending {
|
||||
return;
|
||||
}
|
||||
st.agent_clock.interrupt_pending = true;
|
||||
st.agent_clock.generation
|
||||
};
|
||||
let waiter = state.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tty7-agent-interrupt".into())
|
||||
.spawn(move || {
|
||||
std::thread::sleep(INTERRUPT_SETTLE);
|
||||
let mut st = waiter.lock().unwrap();
|
||||
settle_interrupt(&mut st, generation);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
state.lock().unwrap().agent_clock.interrupt_pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn settle_interrupt(st: &mut PaneState, armed_at: u64) {
|
||||
st.agent_clock.interrupt_pending = false;
|
||||
if st.agent_clock.generation != armed_at || !st.alive {
|
||||
return;
|
||||
}
|
||||
let Some(sess) = st.agent_session.as_mut().filter(|s| s.mid_turn()) else {
|
||||
return;
|
||||
};
|
||||
sess.assume_interrupted();
|
||||
notify(st, DaemonMsg::AgentStatus(st.agent_session.clone()));
|
||||
}
|
||||
|
||||
/// Give up on a turn that has said nothing for [`AGENT_STALE_AFTER`]. Run by
|
||||
/// the daemon's periodic sweep, since a silent pane produces nothing that
|
||||
/// would otherwise wake anything up to notice.
|
||||
fn expire_stale_agent(st: &mut PaneState, now: std::time::Instant) {
|
||||
let working = st
|
||||
.agent_session
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.status == crate::core::cli_agent::AgentStatus::Working);
|
||||
if !working {
|
||||
return;
|
||||
}
|
||||
let since = *st.agent_clock.last_event.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) < AGENT_STALE_AFTER {
|
||||
return;
|
||||
}
|
||||
if let Some(sess) = st.agent_session.as_mut() {
|
||||
sess.assume_stale();
|
||||
}
|
||||
notify(st, DaemonMsg::AgentStatus(st.agent_session.clone()));
|
||||
}
|
||||
|
||||
fn apply_agent_signals(
|
||||
st: &mut PaneState,
|
||||
events: Vec<crate::core::cli_agent::AgentEvent>,
|
||||
@@ -3092,6 +3237,8 @@ fn apply_agent_signals(
|
||||
st.agent = event.agent;
|
||||
notify(st, DaemonMsg::Agent(st.agent));
|
||||
}
|
||||
st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1);
|
||||
st.agent_clock.last_event = Some(std::time::Instant::now());
|
||||
st.agent_session
|
||||
.get_or_insert_with(AgentSessionState::default)
|
||||
.apply_event(event);
|
||||
@@ -3218,6 +3365,8 @@ fn apply_agent(
|
||||
// Claude to omp (or back to the shell) must not keep the previous id.
|
||||
if st.agent_session.is_some() {
|
||||
st.agent_session = None;
|
||||
st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1);
|
||||
st.agent_clock.last_event = None;
|
||||
notify(st, DaemonMsg::AgentStatus(None));
|
||||
}
|
||||
if agent.is_none() {
|
||||
@@ -5170,11 +5319,159 @@ mod tests {
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
agent_clock: AgentClock::default(),
|
||||
alive,
|
||||
exit_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_keys_are_recognised_in_every_encoding() {
|
||||
for key in [
|
||||
&b"\x1b"[..],
|
||||
b"\x03",
|
||||
b"\x1b[27u",
|
||||
b"\x1b[27;1u",
|
||||
b"\x1b[27;1:1u",
|
||||
b"\x1b[27;65u",
|
||||
b"\x1b[99;5u",
|
||||
b"\x1b[99;5:1u",
|
||||
b"\x1b[99:67;5u",
|
||||
b"\x1b[27;5;99~",
|
||||
] {
|
||||
assert!(is_interrupt_key(key), "{key:?}");
|
||||
}
|
||||
for key in [
|
||||
&b"\x1b[A"[..],
|
||||
b"\x1b\x1b",
|
||||
b"\x1b[27;1:3u",
|
||||
b"\x1b[27;3u",
|
||||
b"\x1b[99u",
|
||||
b"\x1b[99;6u",
|
||||
b"abc\x03",
|
||||
b"\x1b[13u",
|
||||
b"q",
|
||||
] {
|
||||
assert!(!is_interrupt_key(key), "{key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn working_session() -> crate::core::cli_agent::AgentSessionState {
|
||||
crate::core::cli_agent::AgentSessionState {
|
||||
status: crate::core::cli_agent::AgentStatus::Working,
|
||||
rich: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupt_settles_only_if_nothing_spoke_after_it() {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
|
||||
let mut st = test_state(true);
|
||||
st.agent_session = Some(working_session());
|
||||
st.agent_clock.interrupt_pending = true;
|
||||
let armed = st.agent_clock.generation;
|
||||
settle_interrupt(&mut st, armed);
|
||||
let sess = st.agent_session.as_ref().unwrap();
|
||||
assert_eq!(sess.status, AgentStatus::Done);
|
||||
assert!(sess.inferred);
|
||||
assert!(!st.agent_clock.interrupt_pending);
|
||||
|
||||
let mut st = test_state(true);
|
||||
st.agent_session = Some(working_session());
|
||||
let armed = st.agent_clock.generation;
|
||||
st.agent_clock.generation += 1;
|
||||
settle_interrupt(&mut st, armed);
|
||||
assert_eq!(
|
||||
st.agent_session.as_ref().unwrap().status,
|
||||
AgentStatus::Working,
|
||||
"a hook event after the key has the last word"
|
||||
);
|
||||
|
||||
let mut st = test_state(false);
|
||||
st.agent_session = Some(working_session());
|
||||
let armed = st.agent_clock.generation;
|
||||
settle_interrupt(&mut st, armed);
|
||||
assert_eq!(
|
||||
st.agent_session.as_ref().unwrap().status,
|
||||
AgentStatus::Working,
|
||||
"a dead pane is left to its exit report"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupt_arms_once_and_only_mid_turn() {
|
||||
let state = Arc::new(Mutex::new(test_state(true)));
|
||||
arm_interrupt(&state);
|
||||
assert!(
|
||||
!state.lock().unwrap().agent_clock.interrupt_pending,
|
||||
"no agent"
|
||||
);
|
||||
|
||||
state.lock().unwrap().agent_session = Some(crate::core::cli_agent::AgentSessionState {
|
||||
rich: false,
|
||||
..working_session()
|
||||
});
|
||||
arm_interrupt(&state);
|
||||
assert!(
|
||||
!state.lock().unwrap().agent_clock.interrupt_pending,
|
||||
"a status without hooks behind it has no Stop to miss"
|
||||
);
|
||||
|
||||
state.lock().unwrap().agent_session = Some(working_session());
|
||||
arm_interrupt(&state);
|
||||
assert!(state.lock().unwrap().agent_clock.interrupt_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_silent_past_the_limit_goes_idle() {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let mut st = test_state(true);
|
||||
st.agent_session = Some(working_session());
|
||||
|
||||
expire_stale_agent(&mut st, now);
|
||||
assert_eq!(
|
||||
st.agent_clock.last_event,
|
||||
Some(now),
|
||||
"a turn with no event time starts the clock rather than expiring"
|
||||
);
|
||||
expire_stale_agent(&mut st, now + AGENT_STALE_AFTER - Duration::from_secs(1));
|
||||
assert_eq!(
|
||||
st.agent_session.as_ref().unwrap().status,
|
||||
AgentStatus::Working
|
||||
);
|
||||
|
||||
expire_stale_agent(&mut st, now + AGENT_STALE_AFTER);
|
||||
let sess = st.agent_session.as_ref().unwrap();
|
||||
assert_eq!(sess.status, AgentStatus::Idle);
|
||||
assert!(sess.inferred);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_events_restart_the_agent_clock() {
|
||||
use crate::core::cli_agent::{AgentEvent, AgentEventKind};
|
||||
|
||||
let mut st = test_state(true);
|
||||
let before = st.agent_clock.generation;
|
||||
apply_agent_signals(
|
||||
&mut st,
|
||||
vec![AgentEvent {
|
||||
agent: None,
|
||||
kind: AgentEventKind::PromptSubmit,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: None,
|
||||
prompt: None,
|
||||
}],
|
||||
None,
|
||||
);
|
||||
assert_ne!(st.agent_clock.generation, before);
|
||||
assert!(st.agent_clock.last_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
|
||||
@@ -5473,6 +5770,7 @@ mod tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
});
|
||||
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -1905,6 +1905,7 @@ mod tests {
|
||||
cwd: Some("/repo/.claude/worktrees/fix-x".into()),
|
||||
activity: 12,
|
||||
turns: 4,
|
||||
inferred: false,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
|
||||
@@ -117,6 +117,27 @@ impl crate::host::server::PaneDirectory for Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// How often panes are checked for an agent turn that has gone silent past
|
||||
/// [`crate::daemon::pane::AGENT_STALE_AFTER`]. The threshold is half an hour,
|
||||
/// so being up to a minute late to notice costs nothing.
|
||||
const AGENT_STALE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
fn spawn_agent_stale_sweep(registry: Arc<Registry>) {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tty7-agent-stale".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(AGENT_STALE_SWEEP_INTERVAL);
|
||||
for pane in registry.all() {
|
||||
pane.expire_stale_agent();
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("could not start the agent status sweep: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
fn spawn_orphan_sweep(registry: Arc<Registry>) {
|
||||
@@ -606,6 +627,7 @@ fn run_with(registry: Arc<Registry>, alone: bool) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
spawn_orphan_sweep(registry.clone());
|
||||
spawn_agent_stale_sweep(registry.clone());
|
||||
// No sweeping here, deliberately — of either kind. Startup is the one
|
||||
// moment this process knows least: it owns no panes yet, and the windows
|
||||
// that know which of a dead daemon's files are still wanted cannot say so
|
||||
|
||||
@@ -6496,6 +6496,7 @@ mod tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(daemon)
|
||||
.unwrap();
|
||||
@@ -6557,6 +6558,7 @@ mod tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
@@ -6607,6 +6609,7 @@ mod tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
|
||||
+34
-3
@@ -6,7 +6,7 @@ use alacritty_terminal::term::TermMode;
|
||||
use gpui::{
|
||||
App, ClipboardEntry, ClipboardItem, Context, EntityId, ExternalPaths, FocusHandle, Focusable,
|
||||
Font, KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta,
|
||||
ScrollWheelEvent, WeakEntity, Window, actions, div, prelude::*, px,
|
||||
ScrollWheelEvent, Task, WeakEntity, Window, actions, div, prelude::*, px,
|
||||
};
|
||||
use gpui_component::kbd::Kbd;
|
||||
use gpui_component::menu::{ContextMenuExt, PopupMenuItem};
|
||||
@@ -437,6 +437,10 @@ pub struct TerminalView {
|
||||
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
|
||||
last_agent_session: (Option<String>, Option<Vec<String>>),
|
||||
agent_turn_started: Option<std::time::Instant>,
|
||||
/// The "finished" notification for a turn that just reached `Done`,
|
||||
/// waiting out [`AGENT_DONE_SETTLE`]. Dropped — and so never sent — when
|
||||
/// the status moves on first.
|
||||
pending_finish_notice: Option<Task<()>>,
|
||||
agent_was_rich: bool,
|
||||
agent_result_unread: bool,
|
||||
keep_unread_on_focus: bool,
|
||||
@@ -664,6 +668,11 @@ const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_mil
|
||||
/// wait for it is still what the reader is doing.
|
||||
const TITLE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
/// How long a turn has to stay finished before it is announced. Long enough
|
||||
/// for a queued message or a `Stop` hook to start the next turn, short enough
|
||||
/// that the notification still reads as the moment the agent stopped.
|
||||
const AGENT_DONE_SETTLE: std::time::Duration = std::time::Duration::from_millis(1500);
|
||||
|
||||
/// What a pane does with a title the program just set — see
|
||||
/// `TerminalView::set_title_when_settled`.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
@@ -1659,6 +1668,7 @@ impl TerminalView {
|
||||
last_agent_status: None,
|
||||
last_agent_session: (None, None),
|
||||
agent_turn_started: None,
|
||||
pending_finish_notice: None,
|
||||
agent_was_rich: false,
|
||||
agent_result_unread: false,
|
||||
keep_unread_on_focus: false,
|
||||
@@ -4103,6 +4113,10 @@ impl TerminalView {
|
||||
}
|
||||
let prev = std::mem::replace(&mut self.last_agent_status, status);
|
||||
let first_sight = !std::mem::replace(&mut self.agent_status_seen, true);
|
||||
self.pending_finish_notice = None;
|
||||
// tty7's own conclusion, not the agent's: the user interrupted the
|
||||
// turn, or it went silent. Neither is a result to announce.
|
||||
let inferred = session.as_ref().is_some_and(|s| s.inferred);
|
||||
|
||||
// A view built over a pane that already holds a finished turn sees
|
||||
// `Done` arrive from nothing, the same as a turn finishing now. If the
|
||||
@@ -4139,7 +4153,7 @@ impl TerminalView {
|
||||
// built, before its leaf was attached, never gets the blur that
|
||||
// would clear `self.focused` — trusting the cached flag there
|
||||
// drops the badge on the one pane the reader is not looking at.
|
||||
self.agent_result_unread = !self.focus_handle.is_focused(window);
|
||||
self.agent_result_unread = !inferred && !self.focus_handle.is_focused(window);
|
||||
self.keep_unread_on_focus = false;
|
||||
}
|
||||
Some(AgentStatus::Done) => {}
|
||||
@@ -4170,6 +4184,7 @@ impl TerminalView {
|
||||
Some(AgentStatus::Done)
|
||||
if rich
|
||||
&& notify_allowed
|
||||
&& !inferred
|
||||
&& matches!(
|
||||
prev,
|
||||
Some(AgentStatus::Working) | Some(AgentStatus::Waiting)
|
||||
@@ -4182,7 +4197,19 @@ impl TerminalView {
|
||||
}
|
||||
None => t(L10nKey::NotifyTurnFinished).to_string(),
|
||||
};
|
||||
self.notify_pane(Some(agent_name), &body, cx);
|
||||
// Held back briefly: a turn that ends only for the next one to
|
||||
// start at once — a message queued while the agent worked, a
|
||||
// `Stop` hook sending it back to work — is not finished, and
|
||||
// announcing it would be a notification about nothing.
|
||||
let agent_name = agent_name.to_string();
|
||||
self.pending_finish_notice = Some(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(AGENT_DONE_SETTLE).await;
|
||||
this.update(cx, |view, cx| {
|
||||
view.pending_finish_notice = None;
|
||||
view.notify_pane(Some(&agent_name), &body, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -10466,6 +10493,7 @@ mod gpui_tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(daemon)
|
||||
.unwrap();
|
||||
@@ -10522,6 +10550,7 @@ mod gpui_tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
@@ -10589,6 +10618,7 @@ mod gpui_tests {
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
turns,
|
||||
inferred: false,
|
||||
};
|
||||
DaemonMsg::AgentStatus(Some(state.clone()))
|
||||
.encode(daemon)
|
||||
@@ -11046,6 +11076,7 @@ mod gpui_tests {
|
||||
cwd: Some(working_in.clone()),
|
||||
activity: 0,
|
||||
turns: 0,
|
||||
inferred: false,
|
||||
}))
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user