From c62d23deb0c1092ce6134cc3ec4e403930ea911b Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:19:17 +0800 Subject: [PATCH] feat(agent): add in-run user guidance steering --- docs/API/Agent.md | 38 ++ docs/Agent/RunEventJournal.md | 14 + docs/CurrentState/AgentFramework.md | 1 + src-tauri/src/application/dto/agent_dto.rs | 23 + .../services/agent_runtime_service.rs | 1 + .../services/agent_runtime_service/commit.rs | 6 + .../agent_runtime_service/executor.rs | 19 + .../agent_runtime_service/guidance.rs | 412 ++++++++++++++++++ .../agent_runtime_service/lifecycle.rs | 6 + .../agent_runtime_service/loop_runner.rs | 4 + .../agent_runtime_service/scheduler.rs | 3 + .../services/agent_runtime_service/tests.rs | 277 +++++++++++- .../presentation/commands/agent_commands.rs | 17 +- .../src/presentation/commands/registry.rs | 1 + src/script.js | 61 +++ .../extensions/agent-system/i18n/zh-cn.json | 8 + .../extensions/agent-system/i18n/zh-tw.json | 8 + .../extensions/agent-system/src/i18n.js | 8 + .../agent-system/src/run-detail-format.js | 52 +++ .../agent-system/src/run-event-presenter.js | 64 +++ .../src/run-timeline-detail-reader.js | 4 + .../agent-system/src/run-timeline-display.js | 2 + .../tauritavern/agent/agent-run-controller.js | 24 + src/style.css | 8 + src/tauri/main/api/agent-run-guidance.js | 51 +++ src/tauri/main/api/agent-run-runtime.js | 6 +- src/tauri/main/api/agent.js | 1 + src/types.d.ts | 16 + tests/agent-api-contract.test.mjs | 49 +++ tests/agent-frontend-contract.test.mjs | 165 +++++++ 30 files changed, 1344 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/application/services/agent_runtime_service/guidance.rs create mode 100644 src/tauri/main/api/agent-run-guidance.js diff --git a/docs/API/Agent.md b/docs/API/Agent.md index 442cf29c..33b03ef9 100644 --- a/docs/API/Agent.md +++ b/docs/API/Agent.md @@ -25,6 +25,7 @@ type TauriTavernAgentApi = { startRunWithPromptSnapshot(input: AgentStartRunWithPromptSnapshotInput): Promise; subscribe(runId: string, handler: (event: AgentRunEvent) => void, options?: AgentSubscribeOptions): TauriTavernHostUnsubscribe; cancel(runId: string): Promise; + submitGuidance(input: AgentSubmitGuidanceInput): Promise; readEvents(input: AgentReadEventsInput): Promise; readWorkspaceFile(input: AgentReadWorkspaceFileInput): Promise; readModelTurn(input: AgentReadModelTurnInput): Promise; @@ -248,6 +249,43 @@ await agent.cancel(runId); - Cancel 后不能自动 commit。 - 返回最新 `AgentRunHandle`。 +## 6.1 submitGuidance + +```ts +await agent.submitGuidance({ + runId, + text: "Prefer the quieter ending and keep the character's agency clear.", + clientGuidanceId: "optional-client-correlation-id", +}); +``` + +```ts +type AgentSubmitGuidanceInput = { + runId: string; + text: string; + clientGuidanceId?: string; +}; + +type AgentSubmitGuidanceResult = { + runId: string; + guidanceId: string; + clientGuidanceId?: string; + status: 'queued'; + preview: string; + chars: number; + words: number; + pendingCount: number; +}; +``` + +语义: + +- `submitGuidance()` 是 active AgentRun 的 run-scoped 输入通道,不是普通聊天发送,不写入 SillyTavern chat history,也不会启动新的 Generate。 +- Runtime 会先写 `user_guidance_submitted`,再将 guidance 放入当前 active run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,pending guidance 会合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。 +- 已经发出的 provider request 不会被热修改。若当前模型调用正在进行,guidance 只影响后续模型请求边界。 +- `cancel()`、run finish、run failure / partial success 会关闭 mailbox;尚未应用的 guidance 写 `user_guidance_discarded`。 +- 空文本、过长文本、非 active run、`finishing` / `cancelling` / terminal run、mailbox 已满都会 fail-fast,不做静默丢弃。 + ## 7. approveToolCall 当前未实现审批流程;`approveToolCall()` 会显式 throw。 diff --git a/docs/Agent/RunEventJournal.md b/docs/Agent/RunEventJournal.md index d13ae3b6..a57a7b1a 100644 --- a/docs/Agent/RunEventJournal.md +++ b/docs/Agent/RunEventJournal.md @@ -289,6 +289,20 @@ run_failed - 恢复失败(模型再次返回 0 tool_calls 且已没有下一轮预算)→ 若没有成功 chat commit,写 `run_failed`(`userRetryable=true`);若已有成功 chat commit,写 `run_partial_success` 并保留输出 - 上述 rollback / partial-success 语义与前端入口无关;普通发送、`/trigger`、regenerate 与 overswipe 只要进入 Agent run,都必须遵守同一 journal 与 host commit 契约。 +### 4.1.1 User Guidance + +```text +user_guidance_submitted +user_guidance_applied +user_guidance_discarded +``` + +User guidance 是 active AgentRun 的 run-scoped 用户输入,不是普通聊天消息。提交成功后 runtime 必须先写 `user_guidance_submitted`,再将 guidance 放入当前 run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,runtime 将 pending guidance 合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。 + +已经发出的 provider request 不可被热修改。若 guidance 在模型调用、工具调用、host commit 等过程中提交,只能影响后续安全模型请求边界。run cancel、finish、failure / partial success 会关闭 mailbox,并对尚未应用的 guidance 写 `user_guidance_discarded`。 + +事件 payload 必须包含 `guidanceId` 或 `guidanceIds`、`chars`、`words`、`preview`、`status`,并带 `invocationId` / canonical `eventScope` 以便 Timeline 可以归入前台 Agent 链。`user_guidance_submitted` 在 V1 直接内联受长度限制的完整 `text`,便于 Timeline detail 和审计读取;applied / discarded 事件用 ids、统计和 preview 关联,不重复写全文。guidance 文本不应写入普通 chat history。 + ### 4.2 Workspace ```text diff --git a/docs/CurrentState/AgentFramework.md b/docs/CurrentState/AgentFramework.md index ca2b0a3a..d002a83b 100644 --- a/docs/CurrentState/AgentFramework.md +++ b/docs/CurrentState/AgentFramework.md @@ -37,6 +37,7 @@ api.agent.startRunFromLegacyGenerate(input?) api.agent.startRunWithPromptSnapshot(input) api.agent.subscribe(runId, handler, options?) api.agent.cancel(runId) +api.agent.submitGuidance(input) api.agent.listRuns(input?) api.agent.retention.readSettings() api.agent.retention.updateSettings(input) diff --git a/src-tauri/src/application/dto/agent_dto.rs b/src-tauri/src/application/dto/agent_dto.rs index 5b80aefc..8e3547d3 100644 --- a/src-tauri/src/application/dto/agent_dto.rs +++ b/src-tauri/src/application/dto/agent_dto.rs @@ -232,6 +232,29 @@ pub struct AgentRunHandleDto { pub status: AgentRunStatus, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSubmitGuidanceDto { + pub run_id: String, + pub text: String, + #[serde(default)] + pub client_guidance_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSubmitGuidanceResultDto { + pub run_id: String, + pub guidance_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_guidance_id: Option, + pub status: String, + pub preview: String, + pub chars: usize, + pub words: usize, + pub pending_count: usize, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentListRunsDto { diff --git a/src-tauri/src/application/services/agent_runtime_service.rs b/src-tauri/src/application/services/agent_runtime_service.rs index 283870ec..60ce1b98 100644 --- a/src-tauri/src/application/services/agent_runtime_service.rs +++ b/src-tauri/src/application/services/agent_runtime_service.rs @@ -31,6 +31,7 @@ mod commit_ledger; mod delegation; mod error_payload; mod executor; +mod guidance; mod input_context; mod invocation; mod journal; diff --git a/src-tauri/src/application/services/agent_runtime_service/commit.rs b/src-tauri/src/application/services/agent_runtime_service/commit.rs index f022487a..c7257869 100644 --- a/src-tauri/src/application/services/agent_runtime_service/commit.rs +++ b/src-tauri/src/application/services/agent_runtime_service/commit.rs @@ -333,6 +333,12 @@ impl AgentRuntimeService { cancel: &mut AgentCancelReceiver, ) -> Result<(), ApplicationError> { self.cancel_unfinished_child_tasks(run_id).await?; + self.close_guidance_mailbox_for_run( + run_id, + "run_finished_before_next_model_request", + AgentRunEventLevel::Info, + ) + .await?; self.transition_status(run_id, AgentRunStatus::Finishing) .await?; let run = self.run_repository.load_run(run_id).await?; diff --git a/src-tauri/src/application/services/agent_runtime_service/executor.rs b/src-tauri/src/application/services/agent_runtime_service/executor.rs index 7c590132..4adbea49 100644 --- a/src-tauri/src/application/services/agent_runtime_service/executor.rs +++ b/src-tauri/src/application/services/agent_runtime_service/executor.rs @@ -56,6 +56,13 @@ impl AgentRuntimeService { match result { Ok(()) => {} Err(ApplicationError::Cancelled(message)) => { + let _ = self + .close_guidance_mailbox_for_run( + run_id, + "run_cancelled_before_next_model_request", + AgentRunEventLevel::Info, + ) + .await; let _ = self.cancel_unfinished_child_tasks(run_id).await; let _ = self .finish_root_invocation(run_id, AgentInvocationStatus::Cancelled) @@ -75,6 +82,18 @@ impl AgentRuntimeService { self.active_runs.write().await.remove(run_id); } Err(error) => { + let guidance_discard_reason = if commit_ledger.is_empty() { + "run_failed_before_next_model_request" + } else { + "run_partial_success_before_next_model_request" + }; + let _ = self + .close_guidance_mailbox_for_run( + run_id, + guidance_discard_reason, + AgentRunEventLevel::Warn, + ) + .await; let _ = self.cancel_unfinished_child_tasks(run_id).await; let _ = self .finish_root_invocation(run_id, AgentInvocationStatus::Failed) diff --git a/src-tauri/src/application/services/agent_runtime_service/guidance.rs b/src-tauri/src/application/services/agent_runtime_service/guidance.rs new file mode 100644 index 00000000..42a7f763 --- /dev/null +++ b/src-tauri/src/application/services/agent_runtime_service/guidance.rs @@ -0,0 +1,412 @@ +use std::collections::VecDeque; + +use chrono::{DateTime, Utc}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use super::AgentRuntimeService; +use crate::application::dto::agent_dto::{AgentSubmitGuidanceDto, AgentSubmitGuidanceResultDto}; +use crate::application::errors::ApplicationError; +use crate::domain::models::agent::{ + AgentModelContentPart, AgentModelMessage, AgentModelRequest, AgentModelRole, + AgentRunEventLevel, AgentRunStatus, ROOT_AGENT_INVOCATION_ID, +}; +use crate::domain::text_metrics::TextMetrics; + +const MAX_GUIDANCE_TEXT_CHARS: usize = 16_000; +const MAX_PENDING_GUIDANCE_ITEMS: usize = 8; +const MAX_PENDING_GUIDANCE_CHARS: usize = 64_000; +const GUIDANCE_PREVIEW_CHARS: usize = 240; +const USER_GUIDANCE_OPEN_TAG: &str = ""; +const USER_GUIDANCE_CLOSE_TAG: &str = ""; + +#[derive(Debug, Clone)] +pub(super) struct AgentGuidanceItem { + pub(super) guidance_id: String, + pub(super) client_guidance_id: Option, + pub(super) text: String, + pub(super) preview: String, + pub(super) metrics: TextMetrics, + pub(super) submitted_at: DateTime, +} + +#[derive(Debug, Default)] +pub(super) struct AgentGuidanceMailbox { + state: Mutex, +} + +#[derive(Debug, Default)] +struct AgentGuidanceMailboxState { + queue: VecDeque, + pending_chars: usize, + closed: bool, +} + +impl AgentGuidanceMailbox { + pub(super) fn new() -> Self { + Self::default() + } + + pub(super) async fn ensure_can_accept(&self, chars: usize) -> Result<(), ApplicationError> { + let state = self.state.lock().await; + validate_accepts(&state, chars) + } + + pub(super) async fn enqueue(&self, item: AgentGuidanceItem) -> Result { + let mut state = self.state.lock().await; + validate_accepts(&state, item.metrics.chars)?; + state.pending_chars += item.metrics.chars; + state.queue.push_back(item); + Ok(state.queue.len()) + } + + pub(super) async fn drain(&self) -> Vec { + let mut state = self.state.lock().await; + let items = state.queue.drain(..).collect::>(); + state.pending_chars = 0; + items + } + + pub(super) async fn close_and_drain(&self) -> Vec { + let mut state = self.state.lock().await; + state.closed = true; + let items = state.queue.drain(..).collect::>(); + state.pending_chars = 0; + items + } +} + +impl AgentRuntimeService { + pub async fn submit_guidance( + &self, + dto: AgentSubmitGuidanceDto, + ) -> Result { + let run_id = normalize_required("runId", &dto.run_id)?; + let text = normalize_guidance_text(&dto.text)?; + let metrics = TextMetrics::from_text(&text); + if metrics.chars > MAX_GUIDANCE_TEXT_CHARS { + return Err(ApplicationError::ValidationError(format!( + "agent.guidance_too_large: guidance is {} chars; maximum is {}", + metrics.chars, MAX_GUIDANCE_TEXT_CHARS + ))); + } + + let run = self.run_repository.load_run(run_id.as_str()).await?; + if !run_status_accepts_guidance(run.status) { + return Err(ApplicationError::ValidationError(format!( + "agent.guidance_run_not_accepting: run `{}` is {} and cannot accept guidance", + run.id, + serde_json::to_string(&run.status) + .unwrap_or_else(|_| "unknown".to_string()) + .trim_matches('"') + ))); + } + + let active_handle = self.active_runs.read().await.get(&run.id).cloned(); + let active_handle = active_handle.ok_or_else(|| { + ApplicationError::ValidationError(format!( + "agent.guidance_active_run_missing: run `{}` is not active", + run.id + )) + })?; + + active_handle + .guidance_mailbox + .ensure_can_accept(metrics.chars) + .await?; + + let item = AgentGuidanceItem { + guidance_id: format!("guidance_{}", Uuid::new_v4().simple()), + client_guidance_id: normalize_optional(dto.client_guidance_id.as_deref()), + preview: guidance_preview(&text), + text, + metrics, + submitted_at: Utc::now(), + }; + + self.event( + &run.id, + AgentRunEventLevel::Info, + "user_guidance_submitted", + json!({ + "guidanceId": item.guidance_id.as_str(), + "clientGuidanceId": item.client_guidance_id.as_deref(), + "invocationId": ROOT_AGENT_INVOCATION_ID, + "chars": item.metrics.chars, + "words": item.metrics.words, + "preview": item.preview.as_str(), + "text": item.text.as_str(), + "submittedAt": item.submitted_at, + "status": "queued", + }), + ) + .await?; + + let pending_count = match active_handle.guidance_mailbox.enqueue(item.clone()).await { + Ok(pending_count) => pending_count, + Err(error) => { + self.emit_guidance_discarded( + &run.id, + &[item], + "submit_rejected_after_journal_append", + AgentRunEventLevel::Warn, + ) + .await?; + return Err(error); + } + }; + + Ok(AgentSubmitGuidanceResultDto { + run_id: run.id, + guidance_id: item.guidance_id, + client_guidance_id: item.client_guidance_id, + status: "queued".to_string(), + preview: item.preview, + chars: item.metrics.chars, + words: item.metrics.words, + pending_count, + }) + } + + pub(super) async fn apply_pending_guidance_to_request( + &self, + run_id: &str, + invocation_id: &str, + round: usize, + request: &mut AgentModelRequest, + ) -> Result<(), ApplicationError> { + let items = self.drain_pending_guidance(run_id).await; + if items.is_empty() { + return Ok(()); + } + + let message_index = request.messages.len(); + let message_text = build_guidance_message(&items); + request.messages.push(AgentModelMessage { + role: AgentModelRole::User, + parts: vec![AgentModelContentPart::Text { text: message_text }], + provider_metadata: Value::Null, + }); + + let summary = guidance_batch_summary(&items); + self.event( + run_id, + AgentRunEventLevel::Info, + "user_guidance_applied", + json!({ + "guidanceIds": summary.guidance_ids, + "clientGuidanceIds": summary.client_guidance_ids, + "invocationId": invocation_id, + "round": round, + "count": summary.count, + "chars": summary.chars, + "words": summary.words, + "preview": summary.preview, + "messageIndex": message_index, + "requestMessageCount": request.messages.len(), + "status": "applied", + }), + ) + .await?; + + Ok(()) + } + + pub(super) async fn close_guidance_mailbox_for_run( + &self, + run_id: &str, + reason: &str, + level: AgentRunEventLevel, + ) -> Result<(), ApplicationError> { + let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else { + return Ok(()); + }; + let items = handle.guidance_mailbox.close_and_drain().await; + if items.is_empty() { + return Ok(()); + } + self.emit_guidance_discarded(run_id, &items, reason, level) + .await + } + + async fn drain_pending_guidance(&self, run_id: &str) -> Vec { + let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else { + return Vec::new(); + }; + handle.guidance_mailbox.drain().await + } + + async fn emit_guidance_discarded( + &self, + run_id: &str, + items: &[AgentGuidanceItem], + reason: &str, + level: AgentRunEventLevel, + ) -> Result<(), ApplicationError> { + let summary = guidance_batch_summary(items); + self.event( + run_id, + level, + "user_guidance_discarded", + json!({ + "guidanceIds": summary.guidance_ids, + "clientGuidanceIds": summary.client_guidance_ids, + "invocationId": ROOT_AGENT_INVOCATION_ID, + "count": summary.count, + "chars": summary.chars, + "words": summary.words, + "preview": summary.preview, + "reason": reason, + "status": "discarded", + }), + ) + .await?; + Ok(()) + } +} + +fn validate_accepts( + state: &AgentGuidanceMailboxState, + incoming_chars: usize, +) -> Result<(), ApplicationError> { + if state.closed { + return Err(ApplicationError::ValidationError( + "agent.guidance_mailbox_closed: run is no longer accepting guidance".to_string(), + )); + } + if state.queue.len() >= MAX_PENDING_GUIDANCE_ITEMS { + return Err(ApplicationError::ValidationError(format!( + "agent.guidance_mailbox_full: pending guidance item limit {} reached", + MAX_PENDING_GUIDANCE_ITEMS + ))); + } + if state.pending_chars + incoming_chars > MAX_PENDING_GUIDANCE_CHARS { + return Err(ApplicationError::ValidationError(format!( + "agent.guidance_mailbox_full: pending guidance char limit {} exceeded", + MAX_PENDING_GUIDANCE_CHARS + ))); + } + Ok(()) +} + +fn run_status_accepts_guidance(status: AgentRunStatus) -> bool { + !matches!( + status, + AgentRunStatus::Finishing + | AgentRunStatus::Completed + | AgentRunStatus::PartialSuccess + | AgentRunStatus::Cancelling + | AgentRunStatus::Cancelled + | AgentRunStatus::Failed + ) +} + +fn normalize_required(field: &str, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(ApplicationError::ValidationError(format!( + "agent.guidance_invalid: {field} is required" + ))); + } + Ok(value.to_string()) +} + +fn normalize_optional(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn normalize_guidance_text(value: &str) -> Result { + let text = value.trim(); + if text.is_empty() { + return Err(ApplicationError::ValidationError( + "agent.guidance_empty: guidance text cannot be empty".to_string(), + )); + } + Ok(text.to_string()) +} + +fn guidance_preview(text: &str) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + truncate_chars(&collapsed, GUIDANCE_PREVIEW_CHARS) +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + text.chars().take(max_chars).collect::() +} + +fn build_guidance_message(items: &[AgentGuidanceItem]) -> String { + let mut text = String::from(USER_GUIDANCE_OPEN_TAG); + text.push_str( + "\nThe user sent the following guidance while you were working. \ + Apply the guidance in order as the user's latest direction for your next step, \ + within your existing instructions and tool rules.", + ); + + if items.len() == 1 { + text.push_str("\n\n"); + text.push_str(&items[0].text); + text.push('\n'); + text.push_str(USER_GUIDANCE_CLOSE_TAG); + return text; + } + + for (index, item) in items.iter().enumerate() { + text.push_str(&format!( + "\n\n\n{}\n", + index + 1, + item.text + )); + } + text.push('\n'); + text.push_str(USER_GUIDANCE_CLOSE_TAG); + text +} + +struct GuidanceBatchSummary { + guidance_ids: Vec, + client_guidance_ids: Vec, + count: usize, + chars: usize, + words: usize, + preview: String, +} + +fn guidance_batch_summary(items: &[AgentGuidanceItem]) -> GuidanceBatchSummary { + let guidance_ids = items + .iter() + .map(|item| item.guidance_id.clone()) + .collect::>(); + let client_guidance_ids = items + .iter() + .filter_map(|item| item.client_guidance_id.clone()) + .collect::>(); + let chars = items.iter().map(|item| item.metrics.chars).sum(); + let words = items.iter().map(|item| item.metrics.words).sum(); + let preview = if items.len() == 1 { + items[0].preview.clone() + } else { + guidance_preview( + &items + .iter() + .map(|item| item.text.as_str()) + .collect::>() + .join("\n\n"), + ) + }; + + GuidanceBatchSummary { + guidance_ids, + client_guidance_ids, + count: items.len(), + chars, + words, + preview, + } +} diff --git a/src-tauri/src/application/services/agent_runtime_service/lifecycle.rs b/src-tauri/src/application/services/agent_runtime_service/lifecycle.rs index 52e5501e..693c6a70 100644 --- a/src-tauri/src/application/services/agent_runtime_service/lifecycle.rs +++ b/src-tauri/src/application/services/agent_runtime_service/lifecycle.rs @@ -255,6 +255,12 @@ impl AgentRuntimeService { let active_handle = self.active_runs.read().await.get(&dto.run_id).cloned(); let next = if let Some(handle) = active_handle { + self.close_guidance_mailbox_for_run( + &dto.run_id, + "run_cancel_requested", + AgentRunEventLevel::Info, + ) + .await?; let _ = handle.cancel_sender.send(true); handle.scheduler.cancel_all_unfinished().await?; self.transition_status(&dto.run_id, AgentRunStatus::Cancelling) diff --git a/src-tauri/src/application/services/agent_runtime_service/loop_runner.rs b/src-tauri/src/application/services/agent_runtime_service/loop_runner.rs index 69f3c324..0b63144b 100644 --- a/src-tauri/src/application/services/agent_runtime_service/loop_runner.rs +++ b/src-tauri/src/application/services/agent_runtime_service/loop_runner.rs @@ -52,6 +52,10 @@ impl AgentRuntimeService { let mut drift_recovery_attempts: usize = 0; for round in 1..=profile.tools.max_rounds { let updates_run_status = exit_policy == AgentInvocationExitPolicy::RunFinishAllowed; + if updates_run_status { + self.apply_pending_guidance_to_request(run_id, invocation_id, round, &mut request) + .await?; + } if updates_run_status { self.transition_status(run_id, AgentRunStatus::CallingModel) .await?; diff --git a/src-tauri/src/application/services/agent_runtime_service/scheduler.rs b/src-tauri/src/application/services/agent_runtime_service/scheduler.rs index 096e4620..f42741ed 100644 --- a/src-tauri/src/application/services/agent_runtime_service/scheduler.rs +++ b/src-tauri/src/application/services/agent_runtime_service/scheduler.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, Weak}; use tokio::sync::watch; use super::AgentRuntimeService; +use super::guidance::AgentGuidanceMailbox; use crate::application::errors::ApplicationError; use crate::domain::models::agent::{ AgentDelegationContinuation, AgentInvocationStatus, AgentTaskRecord, AgentTaskStatus, @@ -13,6 +14,7 @@ use crate::domain::models::agent::{ pub(super) struct ActiveRunHandle { pub(super) cancel_sender: watch::Sender, pub(super) scheduler: Arc, + pub(super) guidance_mailbox: Arc, } impl ActiveRunHandle { @@ -24,6 +26,7 @@ impl ActiveRunHandle { Self { cancel_sender, scheduler: Arc::new(AgentTaskScheduler::new(service, run_id)), + guidance_mailbox: Arc::new(AgentGuidanceMailbox::new()), } } } diff --git a/src-tauri/src/application/services/agent_runtime_service/tests.rs b/src-tauri/src/application/services/agent_runtime_service/tests.rs index 2acb4972..fb07da96 100644 --- a/src-tauri/src/application/services/agent_runtime_service/tests.rs +++ b/src-tauri/src/application/services/agent_runtime_service/tests.rs @@ -15,10 +15,10 @@ use super::commit_ledger::RunCommitLedger; use super::delegation::workspace_policy::InvocationWorkspaceRepository; use super::skill_scope::{resolve_run_skill_scope_refs, skill_scope_order_for_profile}; use crate::application::dto::agent_dto::{ - AgentPromptAssemblyScopeDto, AgentReadEventsDto, AgentReadModelTurnDto, + AgentCancelRunDto, AgentPromptAssemblyScopeDto, AgentReadEventsDto, AgentReadModelTurnDto, AgentReadPromptAssemblyRequestDto, AgentResolveChatCommitDto, AgentResolvePersistentStateMetadataUpdateDto, AgentResolvePromptAssemblyDto, - AgentSkillScopeRefsDto, AgentStartRunDto, AgentStartRunOptionsDto, + AgentSkillScopeRefsDto, AgentStartRunDto, AgentStartRunOptionsDto, AgentSubmitGuidanceDto, }; use crate::application::dto::chat_completion_dto::ChatCompletionGenerateRequestDto; use crate::application::errors::ApplicationError; @@ -7811,6 +7811,279 @@ async fn return_mode_child_write_rejects_non_writable_visible_root() { tokio::fs::remove_dir_all(root).await.expect("cleanup"); } +#[tokio::test] +async fn submitted_guidance_is_applied_to_next_model_request() { + let root = std::env::temp_dir().join(format!( + "tauritavern-agent-guidance-apply-{}", + Uuid::new_v4().simple() + )); + let repository = Arc::new(FileAgentRepository::new(root.clone())); + let model_gateway = Arc::new(MockAgentModelGateway::new(vec![json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_finish_guidance", + "type": "function", + "function": { + "name": "workspace_finish", + "arguments": "{}" + } + }] + } + }] + })])); + let model_gateway_probe = model_gateway.clone(); + let service = Arc::new(AgentRuntimeService::new( + repository.clone(), + repository.clone(), + repository.clone(), + repository.clone(), + test_chat_repository(&root), + test_chat_repository(&root), + test_skill_service(&root), + model_gateway, + test_profile_service(&root), + test_llm_connection_service(&root), + )); + let run = guidance_test_run("run_guidance_apply"); + repository.create_run(&run).await.expect("create run"); + insert_active_run_handle(&service, &run.id).await; + + let submitted = service + .submit_guidance(AgentSubmitGuidanceDto { + run_id: run.id.clone(), + text: "Focus the next step on the user's revised direction.".to_string(), + client_guidance_id: Some("client-guidance-apply".to_string()), + }) + .await + .expect("submit guidance"); + assert_eq!(submitted.status, "queued"); + assert_eq!(submitted.pending_count, 1); + + let request = ChatCompletionGenerateRequestDto { + payload: json!({ + "chat_completion_source": "openai", + "model": "test-model", + "messages": prompt_messages("write the initial draft") + }) + .as_object() + .cloned() + .unwrap(), + }; + let prompt_snapshot = json!({ "chatCompletionPayload": request.payload.clone() }); + let (_cancel_sender, mut cancel_receiver) = watch::channel(false); + let profile = test_resolved_profile(&root).await; + + service + .execute_agent_loop_run_inner( + &run.id, + prompt_snapshot, + request, + profile, + &mut cancel_receiver, + ) + .await + .expect("agent loop"); + + let requests = model_gateway_probe.requests().await; + assert_eq!(requests.len(), 1); + let user_texts = requests[0] + .messages + .iter() + .filter(|message| message.role == AgentModelRole::User) + .filter_map(first_text_part) + .collect::>(); + let guidance_message = user_texts.last().expect("guidance user message"); + assert_eq!( + *guidance_message, + concat!( + "\n", + "The user sent the following guidance while you were working. ", + "Apply the guidance in order as the user's latest direction for your next step, ", + "within your existing instructions and tool rules.\n\n", + "Focus the next step on the user's revised direction.\n", + "" + ) + ); + + let events = repository + .read_events( + &run.id, + AgentRunEventReadQuery { + after_seq: Some(0), + before_seq: None, + limit: 100, + invocation_id: None, + }, + ) + .await + .expect("read events"); + let submitted_event_index = events + .iter() + .position(|event| event.event_type == "user_guidance_submitted") + .expect("submitted event"); + let applied_event_index = events + .iter() + .position(|event| event.event_type == "user_guidance_applied") + .expect("applied event"); + let request_event_index = events + .iter() + .position(|event| event.event_type == "model_request_created") + .expect("model request event"); + assert!(submitted_event_index < applied_event_index); + assert!(applied_event_index < request_event_index); + + let submitted_event = events + .iter() + .find(|event| event.event_type == "user_guidance_submitted") + .expect("submitted event"); + assert_eq!( + submitted_event.payload["text"], + "Focus the next step on the user's revised direction." + ); + let applied = events + .iter() + .find(|event| event.event_type == "user_guidance_applied") + .expect("applied event"); + assert_eq!( + applied.payload["guidanceIds"], + json!([submitted.guidance_id]) + ); + assert_eq!( + applied.payload["clientGuidanceIds"], + json!(["client-guidance-apply"]) + ); + assert_eq!(applied.payload["count"], 1); + assert_eq!(applied.payload["status"], "applied"); + let model_request = events + .iter() + .find(|event| event.event_type == "model_request_created") + .expect("model request event"); + assert_eq!(model_request.payload["request"]["messageCount"], 3); + assert!( + !events + .iter() + .any(|event| event.event_type == "user_guidance_discarded") + ); + + tokio::fs::remove_dir_all(root).await.expect("cleanup"); +} + +#[tokio::test] +async fn cancel_run_discards_pending_guidance_and_closes_mailbox() { + let root = std::env::temp_dir().join(format!( + "tauritavern-agent-guidance-cancel-{}", + Uuid::new_v4().simple() + )); + let repository = Arc::new(FileAgentRepository::new(root.clone())); + let service = Arc::new(AgentRuntimeService::new( + repository.clone(), + repository.clone(), + repository.clone(), + repository.clone(), + test_chat_repository(&root), + test_chat_repository(&root), + test_skill_service(&root), + Arc::new(MockAgentModelGateway::new(vec![])), + test_profile_service(&root), + test_llm_connection_service(&root), + )); + let run = guidance_test_run("run_guidance_cancel"); + repository.create_run(&run).await.expect("create run"); + insert_active_run_handle(&service, &run.id).await; + + let submitted = service + .submit_guidance(AgentSubmitGuidanceDto { + run_id: run.id.clone(), + text: "Change direction before the next model call.".to_string(), + client_guidance_id: Some("client-guidance-cancel".to_string()), + }) + .await + .expect("submit guidance"); + + let cancelled = service + .cancel_run(AgentCancelRunDto { + run_id: run.id.clone(), + }) + .await + .expect("cancel run"); + assert_eq!(cancelled.status, AgentRunStatus::Cancelling); + + let retry_error = service + .submit_guidance(AgentSubmitGuidanceDto { + run_id: run.id.clone(), + text: "This should be rejected after cancellation.".to_string(), + client_guidance_id: None, + }) + .await + .expect_err("cancelled run must reject guidance"); + assert!( + retry_error + .to_string() + .contains("agent.guidance_run_not_accepting") + ); + + let events = repository + .read_events( + &run.id, + AgentRunEventReadQuery { + after_seq: Some(0), + before_seq: None, + limit: 100, + invocation_id: None, + }, + ) + .await + .expect("read events"); + let discarded = events + .iter() + .find(|event| event.event_type == "user_guidance_discarded") + .expect("discarded event"); + assert_eq!( + discarded.payload["guidanceIds"], + json!([submitted.guidance_id]) + ); + assert_eq!( + discarded.payload["clientGuidanceIds"], + json!(["client-guidance-cancel"]) + ); + assert_eq!(discarded.payload["reason"], "run_cancel_requested"); + assert_eq!(discarded.payload["status"], "discarded"); + + tokio::fs::remove_dir_all(root).await.expect("cleanup"); +} + +fn guidance_test_run(run_id: &str) -> AgentRun { + let now = Utc::now(); + AgentRun { + id: run_id.to_string(), + workspace_id: format!("chat_{run_id}"), + stable_chat_id: format!("stable_{run_id}"), + chat_ref: AgentChatRef::Character { + character_id: "Seraphina".to_string(), + file_name: "Seraphina.png".to_string(), + }, + generation_type: "normal".to_string(), + profile_id: None, + skill_scope_refs: AgentRunSkillScopeRefs::default(), + persist_base_state_id: None, + input_message_count: None, + presentation: AgentRunPresentation::Background, + status: AgentRunStatus::Created, + created_at: now, + updated_at: now, + } +} + +fn first_text_part(message: &crate::domain::models::agent::AgentModelMessage) -> Option<&str> { + message.parts.iter().find_map(|part| match part { + AgentModelContentPart::Text { text } => Some(text.as_str()), + _ => None, + }) +} + fn prompt_messages(user_content: &str) -> Value { json!([ { diff --git a/src-tauri/src/presentation/commands/agent_commands.rs b/src-tauri/src/presentation/commands/agent_commands.rs index b957a26c..afd390b5 100644 --- a/src-tauri/src/presentation/commands/agent_commands.rs +++ b/src-tauri/src/presentation/commands/agent_commands.rs @@ -17,7 +17,8 @@ use crate::application::dto::agent_dto::{ AgentResolvePersistentStateMetadataUpdateDto, AgentResolvePromptAssemblyDto, AgentResolveSystemPromptDto, AgentResolveSystemPromptResultDto, AgentRetargetPresetRefsDto, AgentRetargetPresetRefsResultDto, AgentRunHandleDto, AgentRunPruneApplyResultDto, - AgentRunPrunePlanDto, AgentSaveProfileDto, AgentStartRunDto, AgentWorkspaceFileDto, + AgentRunPrunePlanDto, AgentSaveProfileDto, AgentStartRunDto, AgentSubmitGuidanceDto, + AgentSubmitGuidanceResultDto, AgentWorkspaceFileDto, }; use crate::application::errors::ApplicationError; use crate::application::services::agent_workspace_lifecycle_service::AgentChatWorkspaceTarget; @@ -233,6 +234,20 @@ pub async fn cancel_agent_run( .map_err(map_command_error("Failed to cancel agent run")) } +#[tauri::command] +pub async fn submit_agent_run_guidance( + dto: AgentSubmitGuidanceDto, + app_state: State<'_, Arc>, +) -> Result { + log_command("submit_agent_run_guidance"); + + app_state + .agent_runtime_service + .submit_guidance(dto) + .await + .map_err(map_command_error("Failed to submit agent run guidance")) +} + #[tauri::command] pub async fn list_agent_runs( dto: AgentListRunsDto, diff --git a/src-tauri/src/presentation/commands/registry.rs b/src-tauri/src/presentation/commands/registry.rs index d26a3e79..57f7cea7 100644 --- a/src-tauri/src/presentation/commands/registry.rs +++ b/src-tauri/src/presentation/commands/registry.rs @@ -298,6 +298,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen super::agent_commands::repair_agent_profile_file, super::agent_commands::retarget_agent_profile_preset_refs, super::agent_commands::cancel_agent_run, + super::agent_commands::submit_agent_run_guidance, super::agent_commands::list_agent_runs, super::agent_commands::plan_agent_run_prune, super::agent_commands::apply_agent_run_prune, diff --git a/src/script.js b/src/script.js index 94b9b366..ea279f26 100644 --- a/src/script.js +++ b/src/script.js @@ -52,6 +52,8 @@ import { cancelActiveAgentRun, hasActiveAgentRun, startAndWaitForAgentRun, + submitGuidanceToActiveAgentRun, + subscribeAgentRunState, } from './scripts/tauritavern/agent/agent-run-controller.js'; import { agentErrorMessage } from './scripts/tauritavern/agent/agent-error-presenter.js'; import { normalizeAgentContextPolicy } from './scripts/tauritavern/agent/agent-context-policy.js'; @@ -2211,6 +2213,7 @@ export async function sendTextareaMessage() { return; } if (swipeState !== SWIPE_STATE.NONE) return; // don't proceed if mid-swipe. + if (await maybeSubmitAgentGuidanceFromComposer()) return; if (is_send_press) return; if (isExecutingCommandsFromChatInput) return; @@ -2259,6 +2262,56 @@ export async function sendTextareaMessage() { } } +function getAgentGuidanceComposerText() { + return String($('#send_textarea').val() ?? '').trim(); +} + +function shouldOfferAgentGuidanceFromComposer() { + return hasActiveAgentRun() + && Boolean(getAgentGuidanceComposerText()); +} + +function syncAgentGuidanceComposerState() { + if (shouldOfferAgentGuidanceFromComposer()) { + document.body.dataset.agentGuidanceReady = 'true'; + return; + } + + delete document.body.dataset.agentGuidanceReady; +} + +async function maybeSubmitAgentGuidanceFromComposer() { + if (!shouldOfferAgentGuidanceFromComposer()) { + syncAgentGuidanceComposerState(); + return false; + } + + const text = getAgentGuidanceComposerText(); + const result = await Popup.show.confirm( + '是否引导Agent行为?', + '

这条内容会作为用户指引插入下一次 Agent 模型请求,不会作为普通聊天消息保存。

', + { + okButton: '是', + cancelButton: '否', + }, + ); + if (result !== POPUP_RESULT.AFFIRMATIVE) { + syncAgentGuidanceComposerState(); + return true; + } + + try { + await submitGuidanceToActiveAgentRun(text); + } catch (error) { + toastr.error(agentErrorMessage(error), t`Agent Mode`); + throw error; + } + + $('#send_textarea').val('')[0]?.dispatchEvent(new Event('input', { bubbles: true })); + syncAgentGuidanceComposerState(); + return true; +} + /** * Formats the message text into an HTML string using Markdown and other formatting. * @param {string} mes Message text @@ -7997,6 +8050,7 @@ export function activateSendButtons() { hideStopButton(); showSwipeButtons(); delete document.body.dataset.generating; + delete document.body.dataset.agentGuidanceReady; } /** @@ -12514,6 +12568,9 @@ jQuery(async function () { $(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled')); installChatInputFocusKeeper(); + $('#send_textarea').on('input', syncAgentGuidanceComposerState); + subscribeAgentRunState(syncAgentGuidanceComposerState); + syncAgentGuidanceComposerState(); $('#swipes-checkbox').on('change', function () { swipes = !!$('#swipes-checkbox').prop('checked'); @@ -12544,6 +12601,10 @@ jQuery(async function () { const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage); $('#send_but').on('click', async function () { + if (await maybeSubmitAgentGuidanceFromComposer()) { + return; + } + await userInputGenerateMutex.update(); }); diff --git a/src/scripts/extensions/agent-system/i18n/zh-cn.json b/src/scripts/extensions/agent-system/i18n/zh-cn.json index 77e08ba2..d2f4a763 100644 --- a/src/scripts/extensions/agent-system/i18n/zh-cn.json +++ b/src/scripts/extensions/agent-system/i18n/zh-cn.json @@ -228,6 +228,8 @@ "agent_system.timelineDetailEmpty": "此事件没有详情。", "agent_system.timelineDetailFieldErrorCode": "错误", "agent_system.timelineDetailFieldAgent": "Agent", + "agent_system.timelineDetailFieldClient": "客户端", + "agent_system.timelineDetailFieldGuidance": "用户指引", "agent_system.timelineDetailFieldInvocation": "Invocation", "agent_system.timelineDetailFieldMatches": "匹配", "agent_system.timelineDetailFieldModel": "模型", @@ -235,6 +237,7 @@ "agent_system.timelineDetailFieldProvider": "Provider", "agent_system.timelineDetailFieldQuery": "查询", "agent_system.timelineDetailFieldRange": "范围", + "agent_system.timelineDetailFieldReason": "原因", "agent_system.timelineDetailFieldReplaceAll": "全部替换", "agent_system.timelineDetailFieldReplacements": "替换次数", "agent_system.timelineDetailFieldTextMetrics": "文本", @@ -266,6 +269,9 @@ "agent_system.timelineEventDriftRecoveryAttempted": "正在纠正工具流程({attempt}/{max})", "agent_system.timelineEventFileWritten": "写入 {path}", "agent_system.timelineEventGeneric": "运行事件", + "agent_system.timelineEventGuidanceApplied": "用户指引已应用({count})", + "agent_system.timelineEventGuidanceDiscarded": "用户指引已丢弃({count})", + "agent_system.timelineEventGuidanceSubmitted": "用户指引已排队", "agent_system.timelineEventHandoffAccepted": "已由 {agent} 接力", "agent_system.timelineEventNarration": "{text}", "agent_system.timelineEventInvocationCancelled": "Agent 已取消:{agent}", @@ -304,6 +310,7 @@ "agent_system.timelineOpCommit": "提交", "agent_system.timelineOpDone": "完成", "agent_system.timelineOpFail": "失败", + "agent_system.timelineOpGuidance": "指引", "agent_system.timelineOpList": "列表", "agent_system.timelineOpModel": "模型", "agent_system.timelineOpNarration": "旁白", @@ -328,6 +335,7 @@ "agent_system.timelineReady": "Agent 已就绪", "agent_system.timelineReasoning": "推理", "agent_system.timelineResultText": "结果", + "agent_system.timelineGuidance": "用户指引", "agent_system.timelineRunning": "Agent 正在运行", "agent_system.timelineOpenSubAgent": "打开", "agent_system.timelineStatusCancelled": "已取消", diff --git a/src/scripts/extensions/agent-system/i18n/zh-tw.json b/src/scripts/extensions/agent-system/i18n/zh-tw.json index a045725b..d37fd3c5 100644 --- a/src/scripts/extensions/agent-system/i18n/zh-tw.json +++ b/src/scripts/extensions/agent-system/i18n/zh-tw.json @@ -228,6 +228,8 @@ "agent_system.timelineDetailEmpty": "此事件沒有詳情。", "agent_system.timelineDetailFieldErrorCode": "錯誤", "agent_system.timelineDetailFieldAgent": "Agent", + "agent_system.timelineDetailFieldClient": "客戶端", + "agent_system.timelineDetailFieldGuidance": "使用者指引", "agent_system.timelineDetailFieldInvocation": "Invocation", "agent_system.timelineDetailFieldMatches": "匹配", "agent_system.timelineDetailFieldModel": "模型", @@ -235,6 +237,7 @@ "agent_system.timelineDetailFieldProvider": "Provider", "agent_system.timelineDetailFieldQuery": "查詢", "agent_system.timelineDetailFieldRange": "範圍", + "agent_system.timelineDetailFieldReason": "原因", "agent_system.timelineDetailFieldReplaceAll": "全部替換", "agent_system.timelineDetailFieldReplacements": "替換次數", "agent_system.timelineDetailFieldTextMetrics": "文本", @@ -266,6 +269,9 @@ "agent_system.timelineEventDriftRecoveryAttempted": "正在糾正工具流程({attempt}/{max})", "agent_system.timelineEventFileWritten": "寫入 {path}", "agent_system.timelineEventGeneric": "執行事件", + "agent_system.timelineEventGuidanceApplied": "使用者指引已套用({count})", + "agent_system.timelineEventGuidanceDiscarded": "使用者指引已丟棄({count})", + "agent_system.timelineEventGuidanceSubmitted": "使用者指引已排隊", "agent_system.timelineEventHandoffAccepted": "已由 {agent} 接力", "agent_system.timelineEventNarration": "{text}", "agent_system.timelineEventInvocationCancelled": "Agent 已取消:{agent}", @@ -304,6 +310,7 @@ "agent_system.timelineOpCommit": "提交", "agent_system.timelineOpDone": "完成", "agent_system.timelineOpFail": "失敗", + "agent_system.timelineOpGuidance": "指引", "agent_system.timelineOpList": "列表", "agent_system.timelineOpModel": "模型", "agent_system.timelineOpNarration": "旁白", @@ -328,6 +335,7 @@ "agent_system.timelineReady": "Agent 已就緒", "agent_system.timelineReasoning": "推理", "agent_system.timelineResultText": "結果", + "agent_system.timelineGuidance": "使用者指引", "agent_system.timelineRunning": "Agent 正在執行", "agent_system.timelineOpenSubAgent": "開啟", "agent_system.timelineStatusCancelled": "已取消", diff --git a/src/scripts/extensions/agent-system/src/i18n.js b/src/scripts/extensions/agent-system/src/i18n.js index 4e4229ba..1a01914a 100644 --- a/src/scripts/extensions/agent-system/src/i18n.js +++ b/src/scripts/extensions/agent-system/src/i18n.js @@ -347,6 +347,8 @@ const DEFAULT_MESSAGES = Object.freeze({ timelineDetailEmpty: 'No details for this event.', timelineDetailFieldErrorCode: 'Error', timelineDetailFieldAgent: 'Agent', + timelineDetailFieldClient: 'Client', + timelineDetailFieldGuidance: 'Guidance', timelineDetailFieldInvocation: 'Invocation', timelineDetailFieldMatches: 'Matches', timelineDetailFieldModel: 'Model', @@ -354,6 +356,7 @@ const DEFAULT_MESSAGES = Object.freeze({ timelineDetailFieldProvider: 'Provider', timelineDetailFieldQuery: 'Query', timelineDetailFieldRange: 'Range', + timelineDetailFieldReason: 'Reason', timelineDetailFieldReplaceAll: 'Replace all', timelineDetailFieldReplacements: 'Replacements', timelineDetailFieldTextMetrics: 'Text', @@ -385,6 +388,9 @@ const DEFAULT_MESSAGES = Object.freeze({ timelineEventDriftRecoveryAttempted: 'Correcting tool flow ({attempt}/{max})', timelineEventFileWritten: 'Wrote {path}', timelineEventGeneric: 'Run event', + timelineEventGuidanceApplied: 'User guidance applied ({count})', + timelineEventGuidanceDiscarded: 'User guidance discarded ({count})', + timelineEventGuidanceSubmitted: 'User guidance queued', timelineEventHandoffAccepted: 'Handed off to {agent}', timelineEventNarration: '{text}', timelineEventInvocationCancelled: 'Agent cancelled: {agent}', @@ -423,6 +429,7 @@ const DEFAULT_MESSAGES = Object.freeze({ timelineOpCommit: 'Commit', timelineOpDone: 'Done', timelineOpFail: 'Fail', + timelineOpGuidance: 'Guide', timelineOpList: 'List', timelineOpModel: 'Model', timelineOpNarration: 'Note', @@ -447,6 +454,7 @@ const DEFAULT_MESSAGES = Object.freeze({ timelineReady: 'Agent ready', timelineReasoning: 'Reasoning', timelineResultText: 'Result', + timelineGuidance: 'User guidance', timelineRunning: 'Agent running', timelineOpenSubAgent: 'Open', timelineStatusCancelled: 'cancelled', diff --git a/src/scripts/extensions/agent-system/src/run-detail-format.js b/src/scripts/extensions/agent-system/src/run-detail-format.js index c0452ab8..14821072 100644 --- a/src/scripts/extensions/agent-system/src/run-detail-format.js +++ b/src/scripts/extensions/agent-system/src/run-detail-format.js @@ -165,6 +165,51 @@ export function formatHandoffDetail(target) { }; } +export function formatGuidanceDetail(target) { + const fields = []; + const blocks = []; + + const guidanceIds = joinStringArray(target.guidanceIds); + if (guidanceIds) { + fields.push(field(tr('timelineDetailFieldGuidance'), guidanceIds)); + } + const clientGuidanceIds = joinStringArray(target.clientGuidanceIds); + if (clientGuidanceIds) { + fields.push(field(tr('timelineDetailFieldClient'), clientGuidanceIds)); + } + if (target.status) { + fields.push(field(tr('timelineDetailFieldStatus'), target.status)); + } + if (target.invocationId) { + fields.push(field(tr('timelineDetailFieldInvocation'), target.invocationId)); + } + if (target.round != null) { + fields.push(field(tr('timelineDetailFieldRound'), target.round)); + } + if (target.reason) { + fields.push(field(tr('timelineDetailFieldReason'), target.reason)); + } + const metrics = textMetricsSummary(target); + if (metrics) { + fields.push(field(tr('timelineDetailFieldTextMetrics'), metrics)); + } + + const text = String(target.text || target.preview || '').trim(); + if (text) { + addBlock(blocks, 'timelineContent', text, DETAIL_TEXT_LIMIT, false, { + kind: 'user', + }); + } + + return { + labelKey: target.labelKey, + path: '', + fields, + blocks, + actions: [], + }; +} + export function formatPatchDiffDetail(target, file) { const parsed = parseJson(String(file?.text || '')); if (!parsed.ok || !plainObject(parsed.value)) { @@ -601,6 +646,13 @@ function field(label, value) { return { label, value: String(value) }; } +function joinStringArray(value) { + if (!Array.isArray(value)) { + return ''; + } + return value.map((item) => String(item || '').trim()).filter(Boolean).join(', '); +} + function formatPrimitive(value) { if (typeof value === 'boolean') { return value ? 'yes' : 'no'; diff --git a/src/scripts/extensions/agent-system/src/run-event-presenter.js b/src/scripts/extensions/agent-system/src/run-event-presenter.js index 3c5c4efd..31ee636b 100644 --- a/src/scripts/extensions/agent-system/src/run-event-presenter.js +++ b/src/scripts/extensions/agent-system/src/run-event-presenter.js @@ -31,6 +31,9 @@ const DISPLAY_EVENT_TYPES = new Set([ 'chat_commit_failed', 'persistent_changes_committed', 'drift_recovery_attempted', + 'user_guidance_submitted', + 'user_guidance_applied', + 'user_guidance_discarded', 'run_completed', 'run_partial_success', 'run_cancelled', @@ -85,6 +88,9 @@ const EVENT_META = Object.freeze({ chat_commit_failed: { icon: 'fa-circle-exclamation', tone: 'error', kind: 'fail', titleKey: 'timelineEventCommitFailed' }, persistent_changes_committed: { icon: 'fa-database', tone: 'success', kind: 'persist', titleKey: 'timelineEventPersistentCommitted' }, drift_recovery_attempted: { icon: 'fa-arrows-rotate', tone: 'warn', kind: 'recover', titleKey: 'timelineEventDriftRecoveryAttempted' }, + user_guidance_submitted: { icon: 'fa-user-pen', tone: 'active', kind: 'guidance', titleKey: 'timelineEventGuidanceSubmitted' }, + user_guidance_applied: { icon: 'fa-share', tone: 'success', kind: 'guidance', titleKey: 'timelineEventGuidanceApplied' }, + user_guidance_discarded: { icon: 'fa-ban', tone: 'warn', kind: 'guidance', titleKey: 'timelineEventGuidanceDiscarded' }, model_completed: { icon: 'fa-quote-left', tone: 'info', kind: 'narration', titleKey: 'timelineEventNarration' }, run_completed: { icon: 'fa-circle-check', tone: 'success', kind: 'done', titleKey: 'timelineEventRunCompleted' }, run_partial_success: { icon: 'fa-circle-exclamation', tone: 'warn', kind: 'partial', titleKey: 'timelineEventRunPartialSuccess' }, @@ -295,9 +301,31 @@ export function buildEventDetailTargets(item, allEvents) { addFile('timelineWorkspaceFile', payload.path, event.payload); } + if (event?.type === 'user_guidance_submitted' + || event?.type === 'user_guidance_applied' + || event?.type === 'user_guidance_discarded') { + targets.push(buildGuidanceDetailTarget(payload)); + } + return targets; } +function buildGuidanceDetailTarget(payload) { + return { + type: 'guidance', + labelKey: 'timelineGuidance', + guidanceIds: normalizeGuidanceIds(payload), + clientGuidanceIds: normalizeClientGuidanceIds(payload), + invocationId: payload.invocationId || '', + round: payload.round, + status: payload.status || '', + reason: payload.reason || '', + text: payload.text || '', + preview: payload.preview || '', + ...textMetricFields(payload), + }; +} + function buildPatchDiffTarget(event, events) { const payload = plainObject(event?.payload) ? event.payload : {}; const path = String(payload.path || '').trim(); @@ -621,6 +649,9 @@ function eventTitleParams(type, payload) { return { count: payload.changeCount ?? 0 }; case 'drift_recovery_attempted': return { attempt: payload.attempt ?? 0, max: payload.maxAttempts ?? 0 }; + case 'user_guidance_applied': + case 'user_guidance_discarded': + return { count: payload.count ?? normalizeGuidanceIds(payload).length }; case 'run_partial_success': return { count: payload.preservedCommitCount ?? 0 }; default: @@ -670,6 +701,11 @@ function eventSummary(type, payload, allEvents) { return Array.isArray(payload.changes) ? payload.changes.map((change) => change.path).filter(Boolean).join(', ') : ''; case 'drift_recovery_attempted': return payload.reasonCode || ''; + case 'user_guidance_submitted': + case 'user_guidance_applied': + return guidanceSummary(payload); + case 'user_guidance_discarded': + return [payload.reason, guidanceSummary(payload)].filter(Boolean).join(' | '); case 'run_cancelled': return payload.message || ''; case 'run_partial_success': @@ -690,6 +726,9 @@ function eventKind(type, payload, fallback) { if (type === 'agent_handoff_accepted') { return 'handoff'; } + if (type.startsWith('user_guidance_')) { + return 'guidance'; + } if (type === 'tool_call_requested' || type === 'tool_call_completed') { return toolKind(payload.name); } @@ -754,6 +793,31 @@ function commitCompletedSummary(payload, events) { return parts.filter(Boolean).join(' | '); } +function guidanceSummary(payload) { + return String(payload.preview || '').trim() + || textMetricsSummary(payload) + || normalizeGuidanceIds(payload).join(', '); +} + +function normalizeGuidanceIds(payload) { + const ids = normalizeStringArray(payload.guidanceIds); + const guidanceId = String(payload.guidanceId || '').trim(); + return guidanceId ? [guidanceId, ...ids] : ids; +} + +function normalizeClientGuidanceIds(payload) { + const ids = normalizeStringArray(payload.clientGuidanceIds); + const clientGuidanceId = String(payload.clientGuidanceId || '').trim(); + return clientGuidanceId ? [clientGuidanceId, ...ids] : ids; +} + +function normalizeStringArray(value) { + if (!Array.isArray(value)) { + return []; + } + return value.map((item) => String(item || '').trim()).filter(Boolean); +} + function findCommitRequestedEvent(events, commitId) { const normalized = String(commitId || '').trim(); if (!normalized) { diff --git a/src/scripts/extensions/agent-system/src/run-timeline-detail-reader.js b/src/scripts/extensions/agent-system/src/run-timeline-detail-reader.js index 96ccde62..bab6defd 100644 --- a/src/scripts/extensions/agent-system/src/run-timeline-detail-reader.js +++ b/src/scripts/extensions/agent-system/src/run-timeline-detail-reader.js @@ -2,6 +2,7 @@ import { requireHostApi } from './host-api.js'; import { translateAgentSystem as tr } from './i18n.js'; import { formatDetailFile, + formatGuidanceDetail, formatHandoffDetail, formatModelTurnDetail, formatPatchDiffDetail, @@ -35,6 +36,9 @@ export async function readTimelineDetailTarget({ runId, target, readOnly = false if (target.type === 'subAgentTask') { return formatSubAgentTaskDetail(target); } + if (target.type === 'guidance') { + return formatGuidanceDetail(target); + } if (target.type === 'modelTurn' || target.type === 'modelReasoning' || target.type === 'modelNarration') { const input = { runId: normalizedRunId, diff --git a/src/scripts/extensions/agent-system/src/run-timeline-display.js b/src/scripts/extensions/agent-system/src/run-timeline-display.js index 4a235edc..59d86980 100644 --- a/src/scripts/extensions/agent-system/src/run-timeline-display.js +++ b/src/scripts/extensions/agent-system/src/run-timeline-display.js @@ -35,6 +35,8 @@ export function timelineItemShortLabel(item) { return tr('timelineOpHandoff'); case 'subagent': return tr('timelineOpSubAgent'); + case 'guidance': + return tr('timelineOpGuidance'); default: break; } diff --git a/src/scripts/tauritavern/agent/agent-run-controller.js b/src/scripts/tauritavern/agent/agent-run-controller.js index 681e5278..b931aa8c 100644 --- a/src/scripts/tauritavern/agent/agent-run-controller.js +++ b/src/scripts/tauritavern/agent/agent-run-controller.js @@ -8,6 +8,7 @@ const ROLLBACK_EVENT_TYPE = 'run_rollback_targets'; let activeRun = null; let rollbackScriptOverride = null; +let guidanceSequence = 0; function requireAgentApi() { const agent = window.__TAURITAVERN__?.api?.agent; @@ -89,6 +90,19 @@ export async function cancelActiveAgentRun() { return true; } +export async function submitGuidanceToActiveAgentRun(text) { + const runId = String(activeRun?.runId || '').trim(); + if (!runId) { + throw new Error('agent.guidance_active_run_missing: no active Agent run'); + } + + return requireAgentApi().submitGuidance({ + runId, + text, + clientGuidanceId: createClientGuidanceId(), + }); +} + export async function startAndWaitForAgentRun(input) { if (activeRun?.runId) { throw new Error(`Agent run ${activeRun.runId} is already active`); @@ -172,6 +186,16 @@ async function handleRollbackEvent(runId, event) { await rollbackAgentRunDriftMessages({ runId, targets, script }); } +function createClientGuidanceId() { + const randomId = globalThis.crypto?.randomUUID?.(); + if (randomId) { + return `client_guidance_${randomId}`; + } + + guidanceSequence += 1; + return `client_guidance_${Date.now()}_${guidanceSequence}`; +} + export function subscribeAgentRunState(listener) { const handler = (event) => listener(event.detail); window.addEventListener(AGENT_RUN_STATE_CHANGED, handler); diff --git a/src/style.css b/src/style.css index d8767904..cb15b53c 100644 --- a/src/style.css +++ b/src/style.css @@ -4772,6 +4772,14 @@ body:is([data-generating="true"], [data-swiping="true"]) :is(#send_but, display: none; } +body[data-generating="true"][data-agent-guidance-ready="true"] #rightSendForm > #send_but { + display: flex !important; +} + +body[data-generating="true"][data-agent-guidance-ready="true"] #mes_stop { + display: none !important; +} + #anchor_order { margin-bottom: 15px; } diff --git a/src/tauri/main/api/agent-run-guidance.js b/src/tauri/main/api/agent-run-guidance.js new file mode 100644 index 00000000..733ece41 --- /dev/null +++ b/src/tauri/main/api/agent-run-guidance.js @@ -0,0 +1,51 @@ +// @ts-check + +/** + * @param {{ safeInvoke: (command: string, args?: any) => Promise }} deps + */ +export function createAgentRunGuidanceApi({ safeInvoke }) { + async function submitGuidance(input = {}) { + if (!isPlainObject(input)) { + throw new Error('Agent submitGuidance input must be an object'); + } + + const runId = requireRunId(input.runId); + const text = String(input.text || '').trim(); + if (!text) { + throw new Error('guidance text is required'); + } + const clientGuidanceId = normalizeOptionalString(input.clientGuidanceId ?? input.client_guidance_id); + + return safeInvoke('submit_agent_run_guidance', { + dto: { + runId, + text, + ...(clientGuidanceId ? { clientGuidanceId } : {}), + }, + }); + } + + return { + submitGuidance, + }; +} + +function requireRunId(value) { + const runId = String(value || '').trim(); + if (!runId) { + throw new Error('runId is required'); + } + return runId; +} + +function normalizeOptionalString(value) { + if (value == null || value === '') { + return undefined; + } + const text = String(value).trim(); + return text || undefined; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/tauri/main/api/agent-run-runtime.js b/src/tauri/main/api/agent-run-runtime.js index 55998b0d..974739ed 100644 --- a/src/tauri/main/api/agent-run-runtime.js +++ b/src/tauri/main/api/agent-run-runtime.js @@ -1,7 +1,6 @@ // @ts-check - import { resolveStableChatId } from './agent-chat-identity.js'; - +import { createAgentRunGuidanceApi } from './agent-run-guidance.js'; const DEFAULT_EVENT_POLL_MS = 500; const MAX_RUN_LIST_LIMIT = 200; const MAX_RUN_PRUNE_DETAIL_LIMIT = 1000; @@ -27,6 +26,8 @@ const AGENT_RUN_STATUSES = new Set([ * @param {{ safeInvoke: (command: string, args?: any) => Promise }} deps */ export function createAgentRunRuntimeApi({ safeInvoke }) { + const guidance = createAgentRunGuidanceApi({ safeInvoke }); + async function cancel(runId) { const normalizedRunId = requireRunId(runId); return safeInvoke('cancel_agent_run', { dto: { runId: normalizedRunId } }); @@ -247,6 +248,7 @@ export function createAgentRunRuntimeApi({ safeInvoke }) { return { cancel, + submitGuidance: guidance.submitGuidance, listRuns, retention: { readSettings: readRetentionSettings, diff --git a/src/tauri/main/api/agent.js b/src/tauri/main/api/agent.js index 0364f0fa..5470feba 100644 --- a/src/tauri/main/api/agent.js +++ b/src/tauri/main/api/agent.js @@ -85,6 +85,7 @@ function createAgentApi({ safeInvoke }) { startRunWithPromptSnapshot, startRunFromLegacyGenerate, cancel: runtime.cancel, + submitGuidance: runtime.submitGuidance, readEvents: runtime.readEvents, readWorkspaceFile: runtime.readWorkspaceFile, readModelTurn: runtime.readModelTurn, diff --git a/src/types.d.ts b/src/types.d.ts index 45d9ba9e..ae4100bd 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -167,6 +167,17 @@ type TauriTavernAgentRunHandle = { status: TauriTavernAgentRunStatus; }; +type TauriTavernAgentGuidanceResult = { + runId: string; + guidanceId: string; + clientGuidanceId?: string; + status: 'queued'; + preview: string; + chars: number; + words: number; + pendingCount: number; +}; + type TauriTavernAgentRunListCursor = { createdAt: string; runId: string; @@ -525,6 +536,11 @@ type TauriTavernAgentApi = { options?: { presentation?: TauriTavernAgentRunPresentation; stream?: false }; }) => Promise; cancel: (runId: string) => Promise; + submitGuidance: (input: { + runId: string; + text: string; + clientGuidanceId?: string; + }) => Promise; readEvents: (input: { runId: string; afterSeq?: number; diff --git a/tests/agent-api-contract.test.mjs b/tests/agent-api-contract.test.mjs index d532e8f0..d929bd3b 100644 --- a/tests/agent-api-contract.test.mjs +++ b/tests/agent-api-contract.test.mjs @@ -211,6 +211,55 @@ test('api.agent.readEvents requests timeline projection only when asked', async ]); }); +test('api.agent.submitGuidance forwards camelCase DTO and fails fast on invalid input', async () => { + const { calls, agent } = await installHarness(); + + await agent.submitGuidance({ + runId: ' run_guidance ', + text: ' Keep the ending restrained. ', + clientGuidanceId: ' client-guidance-1 ', + }); + await agent.submitGuidance({ + runId: 'run_guidance', + text: 'No client id.', + }); + + assert.deepEqual(calls, [ + { + command: 'submit_agent_run_guidance', + args: { + dto: { + runId: 'run_guidance', + text: 'Keep the ending restrained.', + clientGuidanceId: 'client-guidance-1', + }, + }, + }, + { + command: 'submit_agent_run_guidance', + args: { + dto: { + runId: 'run_guidance', + text: 'No client id.', + }, + }, + }, + ]); + + await assert.rejects( + () => agent.submitGuidance(null), + /Agent submitGuidance input must be an object/, + ); + await assert.rejects( + () => agent.submitGuidance({ runId: '', text: 'hello' }), + /runId is required/, + ); + await assert.rejects( + () => agent.submitGuidance({ runId: 'run_guidance', text: ' ' }), + /guidance text is required/, + ); +}); + test('api.agent.listRuns forwards run history filters with camelCase DTOs', async () => { const { calls, agent } = await installHarness(); const chatRef = { kind: 'character', characterId: 'char-1', fileName: 'Char.json' }; diff --git a/tests/agent-frontend-contract.test.mjs b/tests/agent-frontend-contract.test.mjs index 6cfb59a2..c2e30902 100644 --- a/tests/agent-frontend-contract.test.mjs +++ b/tests/agent-frontend-contract.test.mjs @@ -615,6 +615,92 @@ test('Agent run timeline refresh predicates use narrated model turns explicitly' assert.doesNotMatch(dialogSource, /event\.type === 'model_completed'/); }); +test('Agent run timeline displays user guidance events with inline detail targets', async () => { + const presenter = await importFresh('src/scripts/extensions/agent-system/src/run-event-presenter.js'); + const detailFormat = await importFresh('src/scripts/extensions/agent-system/src/run-detail-format.js'); + const events = [ + { + seq: 1, + id: 'evt-guidance-submitted', + runId: 'run-1', + type: 'user_guidance_submitted', + payload: { + guidanceId: 'guidance_1', + clientGuidanceId: 'client_1', + invocationId: 'inv_root', + status: 'queued', + text: 'Keep the next step focused on the revised ending.', + preview: 'Keep the next step focused on the revised ending.', + chars: 52, + words: 9, + }, + }, + { + seq: 2, + id: 'evt-guidance-applied', + runId: 'run-1', + type: 'user_guidance_applied', + payload: { + guidanceIds: ['guidance_1'], + clientGuidanceIds: ['client_1'], + invocationId: 'inv_root', + round: 2, + count: 1, + status: 'applied', + preview: 'Keep the next step focused on the revised ending.', + chars: 52, + words: 9, + }, + }, + { + seq: 3, + id: 'evt-guidance-discarded', + runId: 'run-1', + type: 'user_guidance_discarded', + payload: { + guidanceIds: ['guidance_2'], + clientGuidanceIds: [], + invocationId: 'inv_root', + count: 1, + status: 'discarded', + reason: 'run_cancelled', + preview: 'Ignore the previous outline.', + chars: 28, + words: 4, + }, + }, + ]; + + const items = presenter.timelineItemsFromEvents(events); + assert.deepEqual(items.map(item => item.type), [ + 'user_guidance_submitted', + 'user_guidance_applied', + 'user_guidance_discarded', + ]); + assert.deepEqual(items.map(item => item.kind), ['guidance', 'guidance', 'guidance']); + assert.deepEqual(items.map(item => item.titleKey), [ + 'timelineEventGuidanceSubmitted', + 'timelineEventGuidanceApplied', + 'timelineEventGuidanceDiscarded', + ]); + assert.equal(items[0].summary, 'Keep the next step focused on the revised ending.'); + assert.equal(items[1].titleParams.count, 1); + assert.equal(items[2].summary, 'run_cancelled | Ignore the previous outline.'); + + const targets = presenter.buildEventDetailTargets(items[0], events); + assert.equal(targets.length, 1); + assert.equal(targets[0].type, 'guidance'); + assert.deepEqual(targets[0].guidanceIds, ['guidance_1']); + assert.deepEqual(targets[0].clientGuidanceIds, ['client_1']); + assert.equal(targets[0].text, 'Keep the next step focused on the revised ending.'); + + const section = detailFormat.formatGuidanceDetail(targets[0]); + assert.equal(section.labelKey, 'timelineGuidance'); + assert.equal(section.blocks[0].kind, 'user'); + assert.equal(section.blocks[0].text, 'Keep the next step focused on the revised ending.'); + assert.ok(section.fields.some(field => field.value === 'guidance_1')); +}); + test('Agent run timeline keeps SubAgent dialog state outside the main panel', async () => { const panelSource = await readFile(path.join( REPO_ROOT, @@ -2802,6 +2888,85 @@ test('Agent run controller tracks active runs until terminal events', async () = assert.equal(stateChanges.at(-1).lastEvent.type, 'run_completed'); }); +test('Agent run controller submits guidance through the active run facade', async () => { + let listener = null; + const submissions = []; + installWindow({ + agent: { + async startRunWithPromptSnapshot(input) { + return { runId: 'run-guidance', input }; + }, + subscribe(runId, callback) { + assert.equal(runId, 'run-guidance'); + listener = callback; + return () => {}; + }, + async submitGuidance(input) { + submissions.push(input); + return { + runId: input.runId, + guidanceId: 'guidance_1', + status: 'queued', + preview: input.text, + chars: input.text.length, + words: 1, + pendingCount: 1, + }; + }, + }, + }); + + const controller = await importFresh('src/scripts/tauritavern/agent/agent-run-controller.js'); + const run = controller.startAndWaitForAgentRun({ generationType: 'normal' }); + await Promise.resolve(); + + assert.equal(controller.hasActiveAgentRun(), true); + await controller.submitGuidanceToActiveAgentRun('Steer the next step.'); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].runId, 'run-guidance'); + assert.equal(submissions[0].text, 'Steer the next step.'); + assert.match(submissions[0].clientGuidanceId, /^client_guidance_/); + + listener({ type: 'run_completed', payload: {} }); + await run; +}); + +test('Agent guidance composer intercepts active-run sends before busy generation gates', async () => { + const scriptSource = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'); + const styleSource = await readFile(path.join(REPO_ROOT, 'src/style.css'), 'utf8'); + + const sendStart = scriptSource.indexOf('export async function sendTextareaMessage() {'); + assert.ok(sendStart >= 0, 'sendTextareaMessage must exist'); + const sendGateEnd = scriptSource.indexOf('hideSwipeButtons();', sendStart); + assert.ok(sendGateEnd > sendStart, 'sendTextareaMessage gate block must be present'); + const sendGateSource = scriptSource.slice(sendStart, sendGateEnd); + const guidanceGateIndex = sendGateSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()'); + const sendPressIndex = sendGateSource.indexOf('if (is_send_press) return;'); + assert.ok(guidanceGateIndex >= 0, 'Agent guidance gate must run from sendTextareaMessage'); + assert.ok(sendPressIndex >= 0, 'is_send_press gate must stay explicit'); + assert.ok(guidanceGateIndex < sendPressIndex, 'guidance must be offered before active generation blocks sends'); + + const clickSource = sourceBetween( + scriptSource, + 'const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage);', + ' //menu buttons setup', + ); + assert.ok(clickSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()') >= 0); + assert.ok(clickSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()') < clickSource.indexOf('userInputGenerateMutex.update()')); + const guidanceOfferSource = sourceBetween( + scriptSource, + 'function shouldOfferAgentGuidanceFromComposer() {', + 'function syncAgentGuidanceComposerState() {', + ); + assert.match(guidanceOfferSource, /hasActiveAgentRun\(\)/); + assert.match(guidanceOfferSource, /getAgentGuidanceComposerText\(\)/); + assert.doesNotMatch(guidanceOfferSource, /swipeState|isExecutingCommandsFromChatInput|regenerate/); + assert.match(scriptSource, /subscribeAgentRunState\(syncAgentGuidanceComposerState\)/); + assert.match(scriptSource, /Popup\.show\.confirm\(\s*'是否引导Agent行为?'/); + assert.match(styleSource, /body\[data-generating="true"\]\[data-agent-guidance-ready="true"\] #rightSendForm > #send_but\s*\{\s*display:\s*flex !important;/); + assert.match(styleSource, /body\[data-generating="true"\]\[data-agent-guidance-ready="true"\] #mes_stop/); +}); + test('Agent run controller treats partial success as a terminal resolved run', async () => { let listener = null; installWindow({