mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Fix watchdog owner input activity
This commit is contained in:
@@ -2619,6 +2619,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadInputActivityParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadListParams": {
|
||||
"properties": {
|
||||
"archived": {
|
||||
@@ -3701,6 +3712,30 @@
|
||||
"title": "Thread/shellCommandRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/inputActivity"
|
||||
],
|
||||
"title": "Thread/inputActivityRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadInputActivityParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/inputActivityRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -482,6 +482,30 @@
|
||||
"title": "Thread/shellCommandRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/inputActivity"
|
||||
],
|
||||
"title": "Thread/inputActivityRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ThreadInputActivityParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/inputActivityRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -12320,6 +12344,24 @@
|
||||
"ThreadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadInputActivityParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadInputActivityParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadInputActivityResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadInputActivityResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1057,6 +1057,30 @@
|
||||
"title": "Thread/shellCommandRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/inputActivity"
|
||||
],
|
||||
"title": "Thread/inputActivityRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadInputActivityParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/inputActivityRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -10166,6 +10190,24 @@
|
||||
"ThreadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadInputActivityParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadInputActivityParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadInputActivityResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadInputActivityResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadInputActivityParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ThreadInputActivityResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ThreadInputActivityParams = { threadId: string, };
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ThreadInputActivityResponse = Record<string, never>;
|
||||
@@ -273,6 +273,8 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
|
||||
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
|
||||
export type { ThreadForkParams } from "./ThreadForkParams";
|
||||
export type { ThreadForkResponse } from "./ThreadForkResponse";
|
||||
export type { ThreadInputActivityParams } from "./ThreadInputActivityParams";
|
||||
export type { ThreadInputActivityResponse } from "./ThreadInputActivityResponse";
|
||||
export type { ThreadItem } from "./ThreadItem";
|
||||
export type { ThreadListParams } from "./ThreadListParams";
|
||||
export type { ThreadListResponse } from "./ThreadListResponse";
|
||||
|
||||
@@ -301,6 +301,10 @@ client_request_definitions! {
|
||||
params: v2::ThreadBackgroundTerminalsCleanParams,
|
||||
response: v2::ThreadBackgroundTerminalsCleanResponse,
|
||||
},
|
||||
ThreadInputActivity => "thread/inputActivity" {
|
||||
params: v2::ThreadInputActivityParams,
|
||||
response: v2::ThreadInputActivityResponse,
|
||||
},
|
||||
ThreadRollback => "thread/rollback" {
|
||||
params: v2::ThreadRollbackParams,
|
||||
response: v2::ThreadRollbackResponse,
|
||||
|
||||
@@ -2992,6 +2992,18 @@ pub struct ThreadBackgroundTerminalsCleanParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadBackgroundTerminalsCleanResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadInputActivityParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadInputActivityResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -6413,6 +6425,38 @@ mod tests {
|
||||
assert_eq!(decoded, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_input_activity_params_round_trip() {
|
||||
let params = ThreadInputActivityParams {
|
||||
thread_id: "thr_123".to_string(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(¶ms).expect("serialize thread/inputActivity params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"threadId": "thr_123",
|
||||
})
|
||||
);
|
||||
|
||||
let decoded = serde_json::from_value::<ThreadInputActivityParams>(value)
|
||||
.expect("deserialize thread/inputActivity params");
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_input_activity_response_round_trip() {
|
||||
let response = ThreadInputActivityResponse {};
|
||||
|
||||
let value =
|
||||
serde_json::to_value(&response).expect("serialize thread/inputActivity response");
|
||||
assert_eq!(value, json!({}));
|
||||
|
||||
let decoded = serde_json::from_value::<ThreadInputActivityResponse>(value)
|
||||
.expect("deserialize thread/inputActivity response");
|
||||
assert_eq!(decoded, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_changed_notification_round_trips() {
|
||||
let notification = FsChangedNotification {
|
||||
|
||||
@@ -147,6 +147,7 @@ Example with notification opt-out:
|
||||
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
|
||||
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
|
||||
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
|
||||
- `thread/inputActivity` — record owner-side draft activity for a thread without starting or steering a turn; returns `{}` and is primarily useful for watchdog idle timers while the user is still typing.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
|
||||
@@ -604,6 +605,18 @@ not emit `turn/started` and does not accept turn context overrides.
|
||||
active turn, or the active turn kind does not accept same-turn steering (for example review or
|
||||
manual compaction), the request fails with an `invalid request` error.
|
||||
|
||||
### Example: Mark owner draft activity
|
||||
|
||||
Use `thread/inputActivity` to keep watchdog idle timing honest while the user is still editing a
|
||||
draft in the client.
|
||||
|
||||
```json
|
||||
{ "method": "thread/inputActivity", "id": 33, "params": {
|
||||
"threadId": "thr_123"
|
||||
} }
|
||||
{ "id": 33, "result": {} }
|
||||
```
|
||||
|
||||
### Example: Request a code review
|
||||
|
||||
Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed:
|
||||
|
||||
@@ -124,6 +124,8 @@ use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadIncrementElicitationParams;
|
||||
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
|
||||
use codex_app_server_protocol::ThreadInputActivityParams;
|
||||
use codex_app_server_protocol::ThreadInputActivityResponse;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
@@ -757,6 +759,10 @@ impl CodexMessageProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadInputActivity { request_id, params } => {
|
||||
self.thread_input_activity(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadRollback { request_id, params } => {
|
||||
self.thread_rollback(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -3235,6 +3241,40 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_input_activity(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ThreadInputActivityParams,
|
||||
) {
|
||||
let ThreadInputActivityParams { thread_id } = params;
|
||||
|
||||
let (_, thread) = match self.load_thread(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.submit_core_op(&request_id, thread.as_ref(), Op::NoteOwnerActivity)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.outgoing
|
||||
.send_response(request_id, ThreadInputActivityResponse {})
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to record thread input activity: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_shell_command(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
|
||||
@@ -591,13 +591,17 @@ impl AgentControl {
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
self.watchdogs.note_owner_input(agent_id).await;
|
||||
self.note_owner_input(agent_id).await;
|
||||
self.state
|
||||
.update_last_task_message(agent_id, last_task_message);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn note_owner_input(&self, agent_id: ThreadId) {
|
||||
self.watchdogs.note_owner_input(agent_id).await;
|
||||
}
|
||||
|
||||
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn append_message(
|
||||
|
||||
@@ -459,6 +459,44 @@ async fn send_input_resets_watchdog_owner_idle_state() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn note_owner_input_resets_watchdog_owner_idle_state() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (owner_thread_id, _thread) = harness.start_thread().await;
|
||||
let target_thread_id = ThreadId::new();
|
||||
|
||||
harness
|
||||
.control
|
||||
.register_watchdog(WatchdogRegistration {
|
||||
owner_thread_id,
|
||||
target_thread_id,
|
||||
child_depth: 0,
|
||||
interval_s: 30,
|
||||
prompt: String::new(),
|
||||
config: harness.config.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("watchdog registration should succeed");
|
||||
|
||||
assert_eq!(
|
||||
harness
|
||||
.control
|
||||
.watchdog_owner_idle_since_is_none_for_tests(target_thread_id)
|
||||
.await,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
harness.control.note_owner_input(owner_thread_id).await;
|
||||
|
||||
assert_eq!(
|
||||
harness
|
||||
.control
|
||||
.watchdog_owner_idle_since_is_none_for_tests(target_thread_id)
|
||||
.await,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_inbox_items_emits_function_call_and_output() {
|
||||
let sender_thread_id = ThreadId::new();
|
||||
|
||||
@@ -4585,6 +4585,13 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
handlers::interrupt(&sess).await;
|
||||
false
|
||||
}
|
||||
Op::NoteOwnerActivity => {
|
||||
sess.services
|
||||
.agent_control
|
||||
.note_owner_input(sess.conversation_id)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
Op::CleanBackgroundTerminals => {
|
||||
handlers::clean_background_terminals(&sess).await;
|
||||
false
|
||||
|
||||
@@ -235,6 +235,9 @@ pub enum Op {
|
||||
/// This server sends [`EventMsg::TurnAborted`] in response.
|
||||
Interrupt,
|
||||
|
||||
/// Mark owner-side input activity without starting or steering a turn.
|
||||
NoteOwnerActivity,
|
||||
|
||||
/// Terminate all running background terminal processes for this thread.
|
||||
/// Use this when callers intentionally want to stop long-lived background shells.
|
||||
CleanBackgroundTerminals,
|
||||
@@ -595,6 +598,7 @@ impl Op {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Interrupt => "interrupt",
|
||||
Self::NoteOwnerActivity => "note_owner_activity",
|
||||
Self::CleanBackgroundTerminals => "clean_background_terminals",
|
||||
Self::RealtimeConversationStart(_) => "realtime_conversation_start",
|
||||
Self::RealtimeConversationAudio(_) => "realtime_conversation_audio",
|
||||
|
||||
@@ -2907,6 +2907,10 @@ impl App {
|
||||
app_server.turn_interrupt(thread_id, turn_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommandView::NoteOwnerActivity => {
|
||||
app_server.thread_input_activity(thread_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommandView::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) struct AppCommand(Op);
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum AppCommandView<'a> {
|
||||
Interrupt,
|
||||
NoteOwnerActivity,
|
||||
CleanBackgroundTerminals,
|
||||
RealtimeConversationStart(&'a ConversationStartParams),
|
||||
RealtimeConversationAudio(&'a ConversationAudioParams),
|
||||
@@ -113,6 +114,10 @@ impl AppCommand {
|
||||
Self(Op::Interrupt)
|
||||
}
|
||||
|
||||
pub(crate) fn note_owner_activity() -> Self {
|
||||
Self(Op::NoteOwnerActivity)
|
||||
}
|
||||
|
||||
pub(crate) fn clean_background_terminals() -> Self {
|
||||
Self(Op::CleanBackgroundTerminals)
|
||||
}
|
||||
@@ -289,6 +294,7 @@ impl AppCommand {
|
||||
pub(crate) fn view(&self) -> AppCommandView<'_> {
|
||||
match &self.0 {
|
||||
Op::Interrupt => AppCommandView::Interrupt,
|
||||
Op::NoteOwnerActivity => AppCommandView::NoteOwnerActivity,
|
||||
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
|
||||
Op::RealtimeConversationStart(params) => {
|
||||
AppCommandView::RealtimeConversationStart(params)
|
||||
|
||||
@@ -30,6 +30,8 @@ use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartResponse;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadInputActivityParams;
|
||||
use codex_app_server_protocol::ThreadInputActivityResponse;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
@@ -454,6 +456,21 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_input_activity(&mut self, thread_id: ThreadId) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: ThreadInputActivityResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ThreadInputActivity {
|
||||
request_id,
|
||||
params: ThreadInputActivityParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/inputActivity failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn turn_steer(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -248,6 +248,7 @@ const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||||
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||||
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
|
||||
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
|
||||
const WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
|
||||
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
|
||||
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
|
||||
@@ -916,6 +917,8 @@ pub(crate) struct ChatWidget {
|
||||
/// We require the second press to match this key so `Ctrl+C` followed by
|
||||
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
|
||||
quit_shortcut_key: Option<KeyBinding>,
|
||||
// Last time we sent a lightweight owner-activity signal for the running thread.
|
||||
last_watchdog_owner_activity_signal_at: Option<Instant>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// Snapshot of token usage to restore after review mode exits.
|
||||
@@ -2322,6 +2325,7 @@ impl ChatWidget {
|
||||
|
||||
fn on_task_started(&mut self) {
|
||||
self.agent_turn_running = true;
|
||||
self.last_watchdog_owner_activity_signal_at = None;
|
||||
self.turn_sleep_inhibitor
|
||||
.set_turn_running(/*turn_running*/ true);
|
||||
self.saw_plan_update_this_turn = false;
|
||||
@@ -2392,6 +2396,7 @@ impl ChatWidget {
|
||||
// Mark task stopped and request redraw now that all content is in history.
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.agent_turn_running = false;
|
||||
self.last_watchdog_owner_activity_signal_at = None;
|
||||
self.turn_sleep_inhibitor
|
||||
.set_turn_running(/*turn_running*/ false);
|
||||
self.update_task_running_state();
|
||||
@@ -4858,6 +4863,7 @@ impl ChatWidget {
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
last_watchdog_owner_activity_signal_at: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
@@ -4921,6 +4927,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
let composer_before = self.bottom_pane.composer_text_with_pending();
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
@@ -5090,6 +5097,7 @@ impl ChatWidget {
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
|
||||
}
|
||||
|
||||
/// Attach a local image to the composer when the active model supports image inputs.
|
||||
@@ -5114,7 +5122,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn apply_external_edit(&mut self, text: String) {
|
||||
let composer_before = self.bottom_pane.composer_text_with_pending();
|
||||
self.bottom_pane.apply_external_edit(text);
|
||||
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -5663,7 +5673,36 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
let composer_before = self.bottom_pane.composer_text_with_pending();
|
||||
self.bottom_pane.handle_paste(text);
|
||||
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
|
||||
}
|
||||
|
||||
fn maybe_signal_watchdog_owner_activity_if_draft_changed(&mut self, composer_before: &str) {
|
||||
if self.bottom_pane.composer_text_with_pending() == composer_before {
|
||||
return;
|
||||
}
|
||||
self.maybe_signal_watchdog_owner_activity();
|
||||
}
|
||||
|
||||
fn maybe_signal_watchdog_owner_activity(&mut self) {
|
||||
if !self.agent_turn_running {
|
||||
return;
|
||||
}
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
if let Some(last_signal_at) = self.last_watchdog_owner_activity_signal_at
|
||||
&& now.duration_since(last_signal_at) < WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.last_watchdog_owner_activity_signal_at = Some(now);
|
||||
self.app_event_tx.send(AppEvent::SubmitThreadOp {
|
||||
thread_id,
|
||||
op: AppCommand::note_owner_activity().into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
|
||||
|
||||
@@ -2273,6 +2273,7 @@ async fn make_chatwidget_manual(
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
last_watchdog_owner_activity_signal_at: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
@@ -2333,6 +2334,21 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_owner_activity_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::NoteOwnerActivity) => return,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => {
|
||||
panic!("expected owner activity op but queue was empty")
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
panic!("expected owner activity op but channel closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
@@ -2388,6 +2404,29 @@ async fn worked_elapsed_from_resets_when_timer_restarts() {
|
||||
assert_eq!(chat.worked_elapsed_from(/*current_elapsed*/ 7), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typing_during_running_turn_emits_owner_activity_op() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.agent_turn_running = true;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||||
|
||||
next_owner_activity_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_mutating_keypress_during_running_turn_does_not_emit_owner_activity_op() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.agent_turn_running = true;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
pub(crate) async fn make_chatwidget_manual_with_sender() -> (
|
||||
ChatWidget,
|
||||
AppEventSender,
|
||||
|
||||
+2
-1
@@ -58,9 +58,10 @@ Messages sent to subagents continue to arrive as normal user input.
|
||||
|
||||
Watchdog agents use the top-level `watchdog_interval_s` setting to decide how long the owner thread
|
||||
must be idle before a check-in helper is spawned when the `agent_watchdog` feature is enabled.
|
||||
Owner-side typing/input activity resets the idle timer.
|
||||
|
||||
```toml
|
||||
watchdog_interval_s = 60
|
||||
watchdog_interval_s = 10
|
||||
```
|
||||
|
||||
## JSON Schema
|
||||
|
||||
Reference in New Issue
Block a user