Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 54e3b63547 fix(themes): add the shell command token to the theme template and docs
#3458 added --syn-shell-command without setting it in the Harbor Night
scheme (the template new themes start from) or regenerating plugins/themes.md,
so scripts/theme/theme.test.ts failed. Imported IntelliJ schemes map
BASH.EXTERNAL_COMMAND to it.
2026-11-15 08:23:08 +08:00
288 змінених файлів з 827 додано та 5222 видалено

No files matched your search

+37 -303
Переглянути файл
@@ -283,11 +283,6 @@ struct TaskFile {
/// The folder (in the task store) with copies of the `before` files, for rolling back.
#[serde(default, skip_serializing_if = "Option::is_none")]
saved: Option<String>,
/// In the project's own checkout, where the user and other tasks change files too: the
/// files that changed while one of this task's turns ran, the only ones its changes list.
/// `None` for a worktree task, and a task saved before these were recorded.
#[serde(default, skip_serializing_if = "Option::is_none")]
touched: Option<BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subagents: Vec<SavedSubagent>,
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
@@ -298,9 +293,6 @@ struct TaskFile {
title_renamed: bool,
}
/// The changed files of a checkout, stamped.
type Snapshot = BTreeMap<String, worktree::Stamp>;
struct TaskState {
task: AgentTask,
session_id: Option<String>,
@@ -308,18 +300,11 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
saved: Option<String>,
/// See `TaskFile::touched`.
touched: Option<Arc<BTreeSet<String>>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// A temporary side conversation: lives in memory only, never persisted.
ephemeral: bool,
/// The conversation a temporary side conversation starts with, sent to the agent with
/// its first real message; the task's own entries hold only the questions.
ephemeral_context: Option<String>,
/// Background tasks the agent reported as running (`run_in_background` commands and
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
/// waits for the last one. A (re)started process runs nothing yet.
@@ -439,14 +424,8 @@ impl AgentManager {
}
/// Routes events to `sink` (replacing the previous subscriber); returns every task.
/// Ephemeral tasks belonged to the previous page session: a fresh one starts without them.
pub fn subscribe(&self, sink: AgentSink) -> Vec<AgentTask> {
*self.shared.sink.write() = Some(sink);
let stale: Vec<Arc<Task>> = self.tasks.read().values().filter(|t| t.state.lock().ephemeral).cloned().collect();
for task in &stale {
self.drop_in_memory(task);
self.shared.emit(AgentEvent::Removed { id: task.id.clone() });
}
let mut tasks: Vec<AgentTask> = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect();
tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
tasks
@@ -676,18 +655,19 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let handle = self.register(TaskFile {
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
touched: Some(BTreeSet::new()),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
});
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
@@ -725,8 +705,27 @@ impl AgentManager {
(root, None, base, commit, before)
}
};
// The session's settings: what the agent offered last time, with the choices applied.
let config = self.merged_config(&spec.id, &req.config);
// Until the session reports its own, show what the agent offered last time with the choices applied.
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
{
config.extend(levels.into_iter().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
// Copies of the files the user had changed already, for rolling back the task's changes to them.
@@ -767,128 +766,26 @@ impl AgentManager {
retry: None,
background: Box::default(),
};
let handle = self.register(TaskFile {
let file = TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved,
touched: (req.isolation == Isolation::Local).then(BTreeSet::new),
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
});
};
let handle = Task::restore(file, self.shared.clone());
handle.state.lock().busy = true;
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
self.shared.runtime.spawn(async move { handle.run(prompt).await });
Ok(task)
}
/// Builds the in-memory handle of a fresh task, registers it and announces it. The
/// caller decides what else its life starts with (a persisted file, a session, a run).
fn register(&self, file: TaskFile) -> Arc<Task> {
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(handle.id.clone(), handle.clone());
handle.emit_task();
handle
}
/// The session's settings: what the agent offered last time, with the request's
/// choices — and the chosen model's thought levels — applied.
fn merged_config(&self, provider: &str, req: &[AgentConfigChoice]) -> Vec<AgentConfigOption> {
let catalog = self.shared.paths.catalogs.cached(&self.shared, provider);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, provider);
for choice in req {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
{
config.extend(levels.into_iter().map(|mut o| {
if let Some(choice) = req.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) {
o.current = Some(choice.value.clone());
}
o
}));
}
config
}
/// Creates a temporary side conversation of `source`: an in-memory task that is never
/// persisted, whose session starts with its first real message (`prompt` only names
/// the title). `source`'s conversation rides along as the context of that message.
/// The task is read-only: its settings are pushed to a mode that asks before anything,
/// and requests that would change the project are denied regardless. Fails for an
/// agent that offers no way to ask.
pub async fn create_ephemeral(&self, req: NewAgentTask, source: &str) -> IdeResult<AgentTask> {
let spec = self
.shared
.spec(&req.provider)
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the topic first"))?;
let title = title_of(&prompt);
let mut config = self.merged_config(&spec.id, &req.config);
config::ask_for_approvals(&mut config);
if config::bypass_approvals(&config) {
return Err(IdeError::invalid(
"this agent has no mode that asks before changing things, so it can't run a side conversation",
));
}
let read_only = !config::bypass_approvals(&config);
let context = context_transcript(&self.task(source)?.state.lock().conversation.main.entries);
let root = self.shared.paths.project_root.clone();
let before = blocking(move || Ok(worktree::snapshot(&root).unwrap_or_default())).await?;
let now = now_ms();
let mut capabilities = self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default();
capabilities.read_only = read_only;
let task = AgentTask {
project_root: Some(IpcPath(self.shared.paths.project_root.clone())),
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(self.shared.paths.project_root.clone()),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities,
resets_ms: None,
retry: None,
background: Box::default(),
config,
};
let handle = self.register(TaskFile {
task: task.clone(),
session_id: None,
entries: Vec::new(),
wanted: req.config,
before,
saved: None,
touched: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
});
{
let mut s = handle.state.lock();
s.ephemeral = true;
s.ephemeral_context = Some(context);
}
Ok(task)
}
/// Sends a message. While the agent works it is queued, or with `now` the current turn
/// is cancelled and the message goes next.
pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
@@ -1109,7 +1006,6 @@ impl AgentManager {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
@@ -1309,15 +1205,6 @@ impl AgentManager {
Ok(())
}
/// Removes a task from the manager's table and stops its agent. The disk (its file,
/// its worktree) is the caller's business.
fn drop_in_memory(&self, task: &Arc<Task>) {
task.kill();
self.tasks.write().remove(&task.id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{}.json", task.id)));
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
@@ -1401,8 +1288,8 @@ impl AgentManager {
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let (before, touched) = task.attribution();
blocking(move || Ok(worktree::made_by_task(at.changes(&before)?, touched.as_deref()))).await
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
}
/// The task's uncommitted changes and, for a worktree task, the commits made since it
@@ -1410,13 +1297,8 @@ impl AgentManager {
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
let task = self.task(id)?;
let at = task.base();
let (before, touched) = task.attribution();
blocking(move || {
let mut grouped = at.grouped_changes(&before)?;
grouped.uncommitted = worktree::made_by_task(grouped.uncommitted, touched.as_deref());
Ok(grouped)
})
.await
let before = task.state.lock().before.clone();
blocking(move || at.grouped_changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
@@ -1442,16 +1324,15 @@ impl AgentManager {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (before, saved, touched) = {
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)), s.touched.clone())
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let (changes, rev) = match view {
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
};
let changes = worktree::made_by_task(changes, touched.as_deref());
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
@@ -1542,12 +1423,9 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
saved: file.saved,
touched: file.touched.map(Arc::new),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
ephemeral: false,
ephemeral_context: None,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
@@ -1569,41 +1447,6 @@ impl Task {
})
}
/// The files already changed when the task started, and the files its turns changed.
fn attribution(&self) -> (Arc<Snapshot>, Option<Arc<BTreeSet<String>>>) {
let s = self.state.lock();
(s.before.clone(), s.touched.clone())
}
/// The changed files of the checkout as a turn starts, when the task records what its turns change.
async fn checkout_before_turn(&self) -> Option<Snapshot> {
let cwd = {
let s = self.state.lock();
s.touched.as_ref()?;
s.task.cwd.0.clone()
};
blocking(move || worktree::snapshot(&cwd)).await.ok()
}
/// Records the files that changed in the checkout since `start`, as the turn ends, as the task's.
async fn record_turn_changes(&self, start: Option<Snapshot>) {
let Some(start) = start else { return };
let cwd = self.state.lock().task.cwd.0.clone();
let Ok(end) = blocking(move || worktree::snapshot(&cwd)).await else { return };
let changed = worktree::changed_between(&start, &end);
if changed.is_empty() {
return;
}
{
let mut s = self.state.lock();
let Some(touched) = &s.touched else { return };
let mut touched = (**touched).clone();
touched.extend(changed);
s.touched = Some(Arc::new(touched));
}
self.persist();
}
fn base(&self) -> worktree::TaskBase {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
@@ -1684,9 +1527,6 @@ impl Task {
if *deleted {
return;
}
if self.state.lock().ephemeral {
return;
}
let bytes = {
let s = self.state.lock();
serde_json::to_vec(&TaskFile {
@@ -1696,7 +1536,6 @@ impl Task {
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
touched: s.touched.as_deref().cloned(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
@@ -1748,11 +1587,9 @@ impl Task {
/// the user stops one. The caller set `busy`.
async fn run(self: Arc<Self>, mut prompt: AgentPrompt) {
loop {
let start = self.checkout_before_turn().await;
let result = self.turn(&prompt).await;
self.cancel_pending.store(false, Ordering::Release);
tokio::time::sleep(TURN_GRACE).await;
self.record_turn_changes(start).await;
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
self.mark_dirty(changed);
let send_now = self.send_now.swap(false, Ordering::AcqRel);
@@ -1920,20 +1757,7 @@ impl Task {
if !prompt.images.is_empty() && !live.capabilities.images {
return Err(Failure::from("This agent can't read images.".to_owned()));
}
// A temporary side conversation's context rides with its first real message; the
// conversation itself records only the question (and whatever comes after).
let prompt = {
let context = self.state.lock().ephemeral_context.take();
match context {
Some(context) => {
let mut owned = prompt.clone();
owned.text = format!("{context}\n\n---\n\n{}", owned.text);
owned
}
None => prompt.clone(),
}
};
let content = self.content(&live, &prompt).await;
let content = self.content(&live, prompt).await;
let result: Result<Value, RpcError> =
live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await;
match result {
@@ -2387,29 +2211,6 @@ impl Task {
.collect()
})
.unwrap_or_default();
// A side conversation is read-only: deny anything that would change the project
// (its task is deleted when its pane closes, leaving nothing to review or revert).
if self.state.lock().ephemeral
&& params
.pointer("/toolCall/kind")
.or_else(|| params.pointer("/subject/toolCall/kind"))
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "edit" | "delete" | "move" | "execute"))
{
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
command: None,
options,
questions: Vec::new(),
chosen: Some("Denied: side conversations are read-only".to_owned()),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
@@ -2607,72 +2408,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
/// The character budget of a side conversation's context: the newest entries win, the
/// oldest are dropped first. Leaves room for the question and the agent's answer.
const CONTEXT_BUDGET: usize = 100_000;
/// A long tool output trimmed to its first and last few lines, with the elided middle noted.
fn trimmed_output(output: &str) -> String {
const KEPT: usize = 4;
let lines: Vec<&str> = output.lines().collect();
if lines.len() <= KEPT * 2 + 1 {
return output.to_owned();
}
let omitted = lines.len() - KEPT * 2;
let mut out = lines[..KEPT].join("\n");
out.push_str(&format!("\n… {omitted} lines omitted …\n"));
out.push_str(&lines[lines.len() - KEPT..].join("\n"));
out
}
fn context_entry(e: &AgentEntry) -> Option<String> {
match e {
AgentEntry::User { text, .. } => Some(format!("User: {text}")),
AgentEntry::Agent { text, .. } if !text.trim().is_empty() => Some(format!("Agent: {text}")),
AgentEntry::Thought { text, .. } if !text.trim().is_empty() => Some(format!("Thought: {text}")),
AgentEntry::Tool { title, output, .. } => match output.as_deref() {
Some(output) if !output.trim().is_empty() => Some(format!("Tool: {title}\n{}", trimmed_output(output))),
_ => Some(format!("Tool: {title}")),
},
AgentEntry::Error { message, .. } => Some(format!("Error: {message}")),
_ => None,
}
}
/// The conversation as the context a temporary side conversation starts with: what was
/// said, what was done (tool calls with trimmed output), errors and the agent's reasoning,
/// oldest first, within [`CONTEXT_BUDGET`].
fn context_transcript(entries: &[AgentEntry]) -> String {
// Newest wins: walk back until the budget is spent, dropping the oldest entries first.
let mut lines: Vec<String> = Vec::new();
let mut used = 0usize;
for e in entries.iter().rev() {
let Some(line) = context_entry(e) else { continue };
if used + line.len() > CONTEXT_BUDGET {
if lines.is_empty() {
// Even the newest entry alone exceeds the budget: keep its head.
lines.push(line.chars().take(CONTEXT_BUDGET).collect());
}
break;
}
used += line.len();
lines.push(line);
}
lines.reverse();
let dropped = entries.iter().rev().filter_map(context_entry).count() - lines.len();
let mut context = String::from(
"You are answering a side question in a temporary conversation. The conversation so far is below, \
for context only; answer the new question after the marker, and do not continue the main task \
unless asked.\n\n<conversation>\n",
);
if dropped > 0 {
context.push_str(&format!("(The {dropped} oldest entries are omitted.)\n\n"));
}
context.push_str(&lines.join("\n\n"));
context.push_str("\n</conversation>");
context
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
@@ -2873,7 +2608,6 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
read_only: false,
};
(capabilities, init.prompt("embeddedContext"))
}
-3
Переглянути файл
@@ -36,9 +36,6 @@ pub struct AgentCapabilities {
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
/// The task runs read-only: it asks before anything, and requests that would change
/// the project are denied (a temporary side conversation).
pub read_only: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
+3 -203
Переглянути файл
@@ -2211,7 +2211,6 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
@@ -2843,14 +2842,12 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
// What the agent did, while its turn ran.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// What the agent did.
write("mine.txt", "my edit\nand the agent's\n");
write("other.txt", "agent\n");
write("new.txt", "agent\n");
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// The user's (or another task's) work between turns is not the task's.
write("later.txt", "mine\n");
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -2866,7 +2863,6 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert_eq!(read("later.txt"), "mine\n", "not the task's to roll back");
assert!(changed(&mgr, &task.id).await.is_empty());
mgr.delete(&task.id, false).await.unwrap();
@@ -2951,17 +2947,10 @@ impl Checkout {
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
count_every_change(&mgr, &task.id);
(mgr, task)
}
}
/// Counts every change in the checkout as the task's, as for a task saved before tasks
/// recorded what their turns changed: for tests that change files outside a turn.
fn count_every_change(mgr: &AgentManager, id: &str) {
mgr.task(id).unwrap().state.lock().touched = None;
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
@@ -3238,192 +3227,3 @@ async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_co
"{entries:?}"
);
}
/// A temporary side conversation is created ready, without starting its session and
/// without writing anything to the store; a fresh subscriber (a reloaded page) sweeps
/// it, and deleting it just drops it from memory.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_stay_in_memory_only() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("hello"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let calls_before = agent.calls.lock().len();
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
// Registering it starts nothing: the session waits for its first real message.
assert_eq!(agent.calls.lock().len(), calls_before);
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// Its first message starts the session like any task's, and still saves nothing: the
// conversation records the question, not the context that rode along to the agent.
// The fake agent's turn asks to change something, which a side conversation denies by
// itself, so the turn ends without waiting for the user.
mgr.prompt(&task.id, text("Why?"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let question = entries.iter().find_map(|e| match e {
AgentEntry::User { text, .. } => Some(text.clone()),
_ => None,
});
assert_eq!(question.as_deref(), Some("Why?"));
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c.contains("read-only"))),
"its `cargo test` request was denied: {entries:?}"
);
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
// A fresh subscriber (a reloaded page) sweeps it: it never reaches the task list.
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let tasks = mgr.subscribe(Arc::new(move |e| sink_events.lock().push(e)));
assert!(!tasks.iter().any(|t| t.id == task.id));
assert!(mgr.task(&task.id).is_err());
assert!(events.lock().iter().any(|e| matches!(e, AgentEvent::Removed { id } if *id == task.id)));
mgr.shutdown();
}
/// A side conversation's context stays within its budget: long tool outputs are cut to
/// their head and tail, and entries that no longer fit are dropped oldest first.
#[test]
fn side_conversation_context_is_bounded() {
let long_output = (0..400).map(|i| format!("line {i}")).collect::<Vec<_>>().join(
"
",
);
// Each reply alone is well inside the budget; together they are past it.
let reply = |n: usize| AgentEntry::Agent { text: format!("reply {n} {}", "x".repeat(30_000)) };
let entries = vec![
AgentEntry::User {
text: "oldest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
reply(1),
reply(2),
reply(3),
reply(4),
AgentEntry::Tool {
id: "t1".into(),
title: "grep".into(),
kind: "search".into(),
status: AgentToolStatus::Completed,
locations: vec![],
output: Some(long_output),
diffs: vec![],
},
AgentEntry::User {
text: "newest question".into(),
attachments: vec![],
images: vec![],
sent_ms: None,
done_ms: None,
steered: false,
},
];
let transcript = context_transcript(&entries);
assert!(transcript.contains("newest question"), "the newest entry is always kept");
assert!(transcript.contains("lines omitted"), "a long tool output is trimmed");
assert!(transcript.contains("line 0") && transcript.contains("line 399"), "its head and tail stay");
assert!(!transcript.contains("oldest question"), "entries past the budget are dropped oldest first");
assert!(transcript.contains("oldest entries are omitted"));
}
/// An ephemeral task denies a permission request that would change the project.
#[tokio::test(flavor = "multi_thread")]
async fn ephemeral_tasks_deny_write_permissions() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let source = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Something with a conversation to reuse"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
let pending = mgr
.entries(&source.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
_ => None,
})
.unwrap_or_else(|| panic!("no pending permission request"));
mgr.respond(&source.id, pending, Some("no".into())).unwrap();
wait_for(&mgr, &source.id, AgentStatus::Ready).await;
let task = mgr
.create_ephemeral(
NewAgentTask {
project_root: None,
prompt: text("What changed in this file?"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
},
&source.id,
)
.await
.unwrap();
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.read_only);
let answer = mgr
.task(&task.id)
.unwrap()
.ask_permission(&json!({
"sessionId": "c1",
"toolCall": {"toolCallId": "c1-t2", "title": "Edit main.rs", "kind": "edit"},
"options": [
{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}
]
}))
.pointer("/outcome/optionId")
.and_then(Value::as_str)
.unwrap_or("none")
.to_owned();
assert_eq!(answer, "no", "the write request is denied");
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(chosen), .. } if chosen.contains("read-only"))),
"{entries:?}"
);
mgr.shutdown();
}
+1 -36
Переглянути файл
@@ -3,7 +3,7 @@
use crate::model::{AgentChange, AgentChanges, AgentCommit};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_vcs::{ChangeKind, LogQuery, Repo};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, UNIX_EPOCH};
@@ -548,25 +548,6 @@ pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
}
/// The files whose state changed between two snapshots of a checkout: edited, new, deleted,
/// or back to as committed.
pub(crate) fn changed_between(start: &BTreeMap<String, Stamp>, end: &BTreeMap<String, Stamp>) -> Vec<String> {
let mut changed: Vec<String> =
end.iter().filter(|(path, stamp)| start.get(*path) != Some(*stamp)).map(|(path, _)| path.clone()).collect();
changed.extend(start.keys().filter(|path| !end.contains_key(*path)).cloned());
changed
}
/// The changes among `changes` that the task made, when it records the files its turns
/// changed (`None`: every change counts).
pub(crate) fn made_by_task(changes: Vec<AgentChange>, touched: Option<&BTreeSet<String>>) -> Vec<AgentChange> {
let Some(touched) = touched else { return changes };
changes
.into_iter()
.filter(|c| touched.contains(&c.rel_path) || c.old_path.as_ref().is_some_and(|old| touched.contains(old)))
.collect()
}
/// Drops the changes that were there before the task and haven't been touched since.
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
@@ -1203,20 +1184,4 @@ mod tests {
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
#[test]
fn a_turn_changed_the_files_whose_state_moved() {
let start: BTreeMap<String, Stamp> =
[("edited", Some((1, 1))), ("same", Some((2, 2))), ("reverted", Some((3, 3)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let end: BTreeMap<String, Stamp> = [("edited", Some((1, 9))), ("same", Some((2, 2))), ("new", Some((4, 4)))]
.into_iter()
.map(|(p, s)| (p.to_owned(), s))
.collect();
let mut changed = changed_between(&start, &end);
changed.sort();
assert_eq!(changed, ["edited", "new", "reverted"]);
}
}
-24
Переглянути файл
@@ -141,30 +141,6 @@ pub fn process_tree(pid: i32) -> Vec<i32> {
tree
}
/// Every process `pid` started, directly or not, parents before their children.
pub fn descendants(pid: u32) -> Vec<u32> {
#[cfg(unix)]
{
let Ok(root) = i32::try_from(pid) else { return Vec::new() };
process_tree(root).into_iter().skip(1).filter_map(|p| u32::try_from(p).ok()).collect()
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
windows_tree::descendants(pid)
.iter()
// SAFETY: a live process handle opened with query access.
.map(|h| unsafe { windows_sys::Win32::System::Threading::GetProcessId(h.as_raw_handle()) })
.filter(|&p| p != 0)
.collect()
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
Vec::new()
}
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
-7
Переглянути файл
@@ -86,8 +86,6 @@ pub struct AgentSettings {
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
/// Conversations and the composer expand to the full width of the pane instead of staying in a centred column.
pub fit_window_width: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
@@ -129,7 +127,6 @@ impl Default for AgentSettings {
show_cost: false,
fold_turns: true,
smooth_text: true,
fit_window_width: false,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
@@ -1718,15 +1715,11 @@ mod tests {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!old.agents.fit_window_width);
assert!(!Settings::default().agents.show_cost);
assert!(!Settings::default().agents.fit_window_width);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
settings.agents.fit_window_width = true;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
assert_eq!(json["agents"]["fitWindowWidth"], true);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
-40
Переглянути файл
@@ -405,24 +405,6 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
Ok(out)
}
/// The Attach to Process candidates among the processes `root` started, parents first.
/// Blocking, like [`attachable_processes`].
pub fn attachable_descendants(root: u32) -> IdeResult<Vec<AttachableProcess>> {
let order = ide_core::process::descendants(root);
if order.is_empty() {
return Ok(Vec::new());
}
let cwd = std::env::temp_dir();
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
let mut out: Vec<AttachableProcess> = list_processes()?
.iter()
.filter(|p| order.contains(&p.pid))
.filter_map(|p| classify(p, is_dotnet, is_go_binary, native))
.collect();
out.sort_by_key(|p| order.iter().position(|&pid| pid == p.pid));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -436,28 +418,6 @@ mod tests {
.map(|p| (p.kind, p.port))
}
#[cfg(unix)]
#[test]
fn a_runs_candidates_are_the_processes_it_started() {
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30; true"]).spawn().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let found = loop {
let found = attachable_descendants(child.id()).unwrap();
if !found.is_empty() || std::time::Instant::now() > deadline {
break found;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
for p in &found {
let _ = std::process::Command::new("kill").arg(p.pid.to_string()).status();
}
child.kill().unwrap();
child.wait().unwrap();
assert!(found.iter().all(|p| p.pid != child.id()), "{found:?}");
assert!(found.iter().any(|p| p.name == "sleep" && p.kind == AttachKind::Native), "{found:?}");
assert!(attachable_descendants(u32::MAX - 1).unwrap().is_empty());
}
#[test]
fn parses_jdwp_agents() {
let a = "java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar";
+1 -1
Переглянути файл
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.9.0";
pub const ENGINE_VERSION: &str = "1.8.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+6 -29
Переглянути файл
@@ -1,10 +1,6 @@
use super::page::{OFFSET, Page};
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Map, Value, json};
/// Problems `get_project_problems` lists by default.
const PROBLEMS_LIMIT: usize = 100;
use serde_json::{Value, json};
pub fn tools() -> Vec<Tool> {
vec![
@@ -41,10 +37,7 @@ Lines and UTF-16 columns are 1-based.",
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
)
.param(Param::optional("errorsOnly", ParamType::Boolean, "Only errors (default) or warnings too"))
.param(Param::optional("limit", ParamType::Integer, "Problems to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET)),
),
]
}
@@ -97,25 +90,9 @@ async fn file_problems(call: Call) -> IdeResult<Output> {
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let page = Page::of(&call, PROBLEMS_LIMIT)?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let problems: Vec<(String, Value)> = files
.into_iter()
.flat_map(|f| {
let path = call.display(&f.path.0);
let wanted = f.items.into_iter().filter(|d| !errors_only || d.severity == Severity::Error);
wanted.map(move |d| (path.clone(), problem(d, "")))
})
.collect();
let mut out = Map::new();
let mut files: Vec<Value> = Vec::new();
for (path, problem) in page.take(problems, &mut out) {
match files.last_mut() {
Some(file) if file["filePath"] == path.as_str() => file["problems"].as_array_mut().unwrap().push(problem),
_ => files.push(json!({ "filePath": path, "problems": [problem] })),
}
}
out.insert("files".into(), files.into());
Output::json(Value::Object(out))
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
}
+6 -100
Переглянути файл
@@ -2,7 +2,6 @@
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
@@ -18,8 +17,6 @@ const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
/// Entries `list_directory_tree` lists by default.
const TREE_LIMIT: usize = 100;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
@@ -152,12 +149,6 @@ struct DirectoryTreeInfo {
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
/// Entries under the directory within `maxDepth`.
total: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
}
fn list_directory_tree() -> Tool {
@@ -165,12 +156,10 @@ fn list_directory_tree() -> Tool {
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.
A page that starts inside a directory repeats the lines of the directories above it.",
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let page = Page::of(&call, TREE_LIMIT)?;
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
@@ -181,16 +170,8 @@ A page that starts inside a directory repeats the lines of the directories above
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline,
cancel: cancel.clone(),
page,
seen: 0,
above: Vec::new(),
};
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let tree = call
.host()
.blocking(move || {
@@ -206,16 +187,11 @@ A page that starts inside a directory repeats the lines of the directories above
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
total: tree.seen,
more: tree.seen > page.end(),
next_offset: (tree.seen > page.end()).then_some(page.end()),
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
@@ -224,28 +200,19 @@ A page that starts inside a directory repeats the lines of the directories above
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
"total": { "type": "integer" },
"more": { "type": "boolean" },
"nextOffset": { "type": "integer" },
},
"required": ["traversedDirectory", "tree", "errors", "total"],
"required": ["traversedDirectory", "tree", "errors"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`; entries outside the
/// page are counted, not listed.
/// ignored ones too) by lower-cased name, directories marked with `/`.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
page: Page,
/// Entries met so far.
seen: usize,
/// The lines of the directories above the entry, and whether this page shows them yet.
above: Vec<(String, bool)>,
}
impl TreeRender {
@@ -268,13 +235,7 @@ impl TreeRender {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
let line = format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" });
let shown = if is_root {
self.out.push_str(&line);
true
} else {
self.entry(&line)
};
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
if !is_dir {
return;
}
@@ -289,32 +250,9 @@ impl TreeRender {
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
if !is_root {
self.above.push((line, shown));
}
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
if !is_root {
self.above.pop();
}
}
/// Counts an entry and lists it when on the page, after the directories above it.
fn entry(&mut self, line: &str) -> bool {
let n = self.seen;
self.seen += 1;
if n < self.page.offset || n >= self.page.end() {
return false;
}
for (above, shown) in &mut self.above {
if !*shown {
self.out.push_str(above);
*shown = true;
}
}
self.out.push_str(line);
true
}
}
@@ -364,36 +302,4 @@ mod tests {
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
#[test]
fn a_tree_page_repeats_the_directories_above_it() {
let tmp = tempfile::tempdir().unwrap();
for file in ["a/x.txt", "a/y.txt", "b.txt"] {
let path = tmp.path().join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let render = |offset, limit| {
let mut tree = TreeRender {
out: String::new(),
errors: Vec::new(),
timed_out: false,
deadline: Instant::now() + Duration::from_secs(10),
cancel: CancellationToken::new(),
page: Page { offset, limit },
seen: 0,
above: Vec::new(),
};
tree.render(tmp.path(), "", true, 5, true);
let root = format!("{}/\n", tmp.path().display());
(tree.out.strip_prefix(&root).unwrap().to_owned(), tree.seen)
};
assert_eq!(render(0, 2), (" ├── a/\n │ ├── x.txt\n".to_owned(), 4));
assert_eq!(
render(2, 2).0,
" ├── a/\n │ └── y.txt\n └── b.txt\n",
"the page in `a/` repeats its line"
);
assert_eq!(render(0, 100).0, " ├── a/\n │ ├── x.txt\n │ └── y.txt\n └── b.txt\n");
}
}
-1
Переглянути файл
@@ -7,7 +7,6 @@ pub(crate) mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod page;
mod reports;
mod search;
mod symbols;
-71
Переглянути файл
@@ -1,71 +0,0 @@
//! `limit` and `offset` of the tools that list things: a few items by default, the rest page by page.
use super::tool::Call;
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
/// The most items one call returns.
pub const MAX_LIMIT: usize = 1000;
pub const OFFSET: &str = "Items to skip: `nextOffset` of the previous page";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Page {
pub offset: usize,
pub limit: usize,
}
impl Page {
/// The `limit` (`default` when absent, at most [`MAX_LIMIT`]) and `offset` (0) arguments.
pub fn of(call: &Call, default: usize) -> IdeResult<Self> {
let limit = match call.opt_i64("limit")? {
None => default,
Some(n) if n <= 0 => return Err(IdeError::invalid("limit must be > 0")),
Some(n) => (n as usize).min(MAX_LIMIT),
};
let offset = match call.opt_i64("offset")? {
None => 0,
Some(n) if n < 0 => return Err(IdeError::invalid("offset must be >= 0")),
Some(n) => n as usize,
};
Ok(Self { offset, limit })
}
/// Where the page ends: the items to find for it.
pub fn end(&self) -> usize {
self.offset.saturating_add(self.limit)
}
/// This page of all the `items`; `out` gets their `total`, and `more` and `nextOffset` when some follow.
pub fn take<T>(&self, items: Vec<T>, out: &mut Map<String, Value>) -> Vec<T> {
let total = items.len();
out.insert("total".into(), total.into());
self.mark(total > self.end(), out);
items.into_iter().skip(self.offset).take(self.limit).collect()
}
/// `more` and `nextOffset` when items follow the page.
pub fn mark(&self, more: bool, out: &mut Map<String, Value>) {
if more {
out.insert("more".into(), true.into());
out.insert("nextOffset".into(), self.end().into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_say_what_follows() {
let page = |offset, limit| Page { offset, limit };
let mut out = Map::new();
assert_eq!(page(0, 2).take(vec![1, 2, 3, 4, 5], &mut out), [1, 2]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5, "more": true, "nextOffset": 2 }));
let mut out = Map::new();
assert_eq!(page(4, 2).take(vec![1, 2, 3, 4, 5], &mut out), [5]);
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5 }), "the last page");
let mut out = Map::new();
assert!(page(9, 2).take(vec![1], &mut out).is_empty());
}
}
+4 -80
Переглянути файл
@@ -3,7 +3,6 @@
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
@@ -36,14 +35,6 @@ and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
(keeps the start), MIDDLE (keeps both ends) or NONE";
/// `list_runs`: tabs returned by default, and characters of each command, enough to tell them apart.
const LIST_LIMIT: usize = 10;
const LIST_COMMAND_CHARS: usize = 80;
const LIST_STATES: [(&str, &[&str]); 3] = [
("running", &["starting", "running", "stopping"]),
("idle", &["succeeded", "failed", "crashed", "stoppedByAgent", "stoppedByUser", "startFailed"]),
("error", &["failed", "crashed", "startFailed"]),
];
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
@@ -292,64 +283,13 @@ for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`), newest first: name, state, the \
start of the command, exit code and times. `get_run_status` gives a tab's whole status.",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let page = Page::of(&call, LIST_LIMIT)?;
let wanted = call.opt_str("state")?.filter(|s| s != "all");
let states = match wanted.as_deref() {
None => None,
Some(s) => Some(match LIST_STATES.iter().find(|(group, _)| *group == s) {
Some((_, states)) => states.to_vec(),
None if LIST_STATES[1].1.contains(&s) || LIST_STATES[0].1.contains(&s) => vec![s],
None => {
return Err(IdeError::invalid(format!(
"unknown state `{s}`: all, running, idle, error, or one of the states of get_run_status"
)));
}
}),
};
let runs: Vec<Value> = call
.host()
.mcp_agent_runs
.list(call.root())
.iter()
.rev()
.map(|r| r.status())
.filter(|s| {
states.as_ref().is_none_or(|states| s["state"].as_str().is_some_and(|v| states.contains(&v)))
})
.map(|s| Value::Object(brief(s)))
.collect();
let mut out = Map::new();
let runs = page.take(runs, &mut out);
out.insert("runs".into(), runs.into());
Output::json(Value::Object(out))
let runs: Vec<Value> =
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
Output::json(serde_json::json!({ "runs": runs }))
},
)
.param(Param::optional(
"state",
ParamType::String,
"all (default), running, idle (not running), error (failed, crashed, startFailed), or one state",
))
.param(Param::optional("limit", ParamType::Integer, "Tabs to return at most (default 10)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// A tab in `list_runs`: what tells it apart and how it went, the command cut short.
fn brief(mut status: Map<String, Value>) -> Map<String, Value> {
let mut out = Map::new();
for key in ["name", "state", "command", "exitCode", "crash", "error", "startedAt", "endedAt", "tabClosedAt"] {
if let Some(v) = status.remove(key) {
out.insert(key.into(), v);
}
}
if let Some(Value::String(command)) = out.get_mut("command")
&& command.chars().count() > LIST_COMMAND_CHARS
{
*command = format!("{}…", command.chars().take(LIST_COMMAND_CHARS).collect::<String>());
}
out
}
/// What a key does to a command on pipes.
@@ -467,22 +407,6 @@ pub fn tools() -> Vec<Tool> {
mod tests {
use super::*;
#[test]
fn listed_tabs_are_brief_and_their_commands_cut_short() {
let status = serde_json::json!({
"name": "_agent_build", "state": "failed", "command": "x".repeat(100), "exitCode": 1,
"workingDirectory": "", "outputLength": 9, "startedBy": "agent",
});
let Value::Object(status) = status else { unreachable!() };
let brief = Value::Object(brief(status));
assert_eq!(brief["command"].as_str().unwrap(), format!("{}…", "x".repeat(LIST_COMMAND_CHARS)));
assert_eq!(
(&brief["name"], &brief["state"], &brief["exitCode"]),
(&"_agent_build".into(), &"failed".into(), &1.into())
);
assert!(brief.get("outputLength").is_none() && brief.get("workingDirectory").is_none());
}
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
+3 -25
Переглянути файл
@@ -7,7 +7,6 @@ use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
@@ -20,8 +19,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// Configurations `get_run_configurations` lists by default.
const CONFIGURATIONS_LIMIT: usize = 100;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
@@ -53,8 +50,7 @@ fn describe_config(call: &Call, c: &RunConfig) -> Value {
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
// A detected configuration's environment is the IDE's own (its tools on `PATH`).
if !c.env.is_empty() && !c.detected {
if !c.env.is_empty() {
v["environment"] = json!(c.env);
}
}
@@ -137,23 +133,8 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let page = Page::of(&call, CONFIGURATIONS_LIMIT)?;
let query = call.opt_str("query")?.map(|q| q.to_lowercase());
let list: Vec<Value> = configs
.iter()
.filter(|c| {
query.as_ref().is_none_or(|q| {
[c.name.as_str(), c.kind.as_str(), command_line(c).as_str()]
.iter()
.any(|s| s.to_lowercase().contains(q.as_str()))
})
})
.map(|c| describe_config(&call, c))
.collect();
let mut out = Map::new();
let list = page.take(list, &mut out);
out.insert("configurations".into(), list.into());
Output::json(Value::Object(out))
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
},
)
.write()
@@ -163,9 +144,6 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
.param(Param::optional("query", ParamType::String, "Only configurations whose name, type or command contains it"))
.param(Param::optional("limit", ParamType::Integer, "Configurations to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
}
/// What `execute_run_configuration` runs.
+16 -36
Переглянути файл
@@ -5,7 +5,6 @@
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
@@ -22,8 +21,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: usize = 100;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: usize = 50;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
@@ -38,8 +37,7 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Results to return at most (default 100)";
const SYMBOL_LIMIT_DESCRIPTION: &str = "Results to return at most (default 50)";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
@@ -69,7 +67,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -91,7 +88,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -117,8 +113,7 @@ If you don't find a suitable result, try again with include_external=true to sea
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, SYMBOL_LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -143,7 +138,6 @@ Paths are optional additional glob filters relative to the project root.",
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.output(result_schema())
}
@@ -197,9 +191,6 @@ struct SearchResult {
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
/// The `offset` of the next page, when `more`.
#[serde(skip_serializing_if = "Option::is_none")]
next_offset: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
@@ -221,23 +212,16 @@ fn result_schema() -> Value {
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"nextOffset": int,
"partialResultReason": string,
},
})
}
/// The page of `call` and the results to find for it: up to its end.
fn page(call: &Call, default: usize) -> IdeResult<(Page, usize)> {
let page = Page::of(call, default)?;
Ok((page, page.end().min(MAX_RESULTS_UPPER_BOUND)))
}
/// Drops the results before the page, and the `nextOffset` when more follow. A search finds
/// [`MAX_RESULTS_UPPER_BOUND`] results at most, so no page starts past them.
fn skip<T>(page: Page, items: &mut Vec<T>, more: bool) -> Option<usize> {
items.drain(..page.offset.min(items.len()));
(more && page.end() < MAX_RESULTS_UPPER_BOUND).then_some(page.end())
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
@@ -537,7 +521,7 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
@@ -628,12 +612,11 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let next_offset = skip(page, &mut items, more);
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
@@ -738,7 +721,7 @@ async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatch
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
@@ -776,11 +759,9 @@ async fn file_search(call: Call) -> IdeResult<Output> {
Ok((found, more, walk.timed_out.get()))
})
.await?;
let mut items: Vec<SearchItem> =
paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let next_offset = skip(page, &mut items, more);
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
struct Walk {
@@ -854,7 +835,7 @@ impl Walk {
async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let (page, limit) = page(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
@@ -905,7 +886,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let next_offset = skip(page, &mut items, more);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
@@ -919,7 +899,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
-15
Переглянути файл
@@ -210,7 +210,6 @@ fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let page = super::page::Page::of(&call, REFERENCES_LIMIT)?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
@@ -245,28 +244,14 @@ pub fn find_references() -> Tool {
}
}
positions(&mut value);
// `classified`, when there, goes with `locations` one to one.
if let serde_json::Value::Object(map) = &mut value {
let mut list = |key: &str| match map.remove(key) { Some(serde_json::Value::Array(items)) => items, _ => Vec::new() };
let (locations, classified) = (list("locations"), list("classified"));
let classified = page.take(classified, &mut serde_json::Map::new());
let locations = page.take(locations, map);
map.insert("locations".into(), locations.into());
map.insert("classified".into(), classified.into());
}
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
.param(Param::optional("limit", ParamType::Integer, "References to return at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, super::page::OFFSET))
}
/// References `find_references` lists by default.
const REFERENCES_LIMIT: usize = 100;
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
+11 -15
Переглянути файл
@@ -2,13 +2,12 @@
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::page::{OFFSET, Page};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: usize = 100;
const STATUS_DEFAULT_LIMIT: i64 = 1000;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
@@ -255,7 +254,11 @@ By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let page = Page::of(&call, STATUS_DEFAULT_LIMIT)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
@@ -302,8 +305,7 @@ By default all Git repositories are returned.",
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.skip(page.offset)
.take(page.limit)
.take(limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
@@ -316,24 +318,19 @@ By default all Git repositories are returned.",
v
})
.collect();
let more = entries.len() > page.end();
let mut repository = json!({
repositories.push(json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": more,
"hasMoreEntries": entries.len() > listed.len(),
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
});
if more {
repository["nextOffset"] = page.end().into();
}
repositories.push(repository);
}));
}
Output::json(json!({ "repositories": repositories }))
},
@@ -345,8 +342,7 @@ By default all Git repositories are returned.",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Entries to return per repository at most (default 100)"))
.param(Param::optional("offset", ParamType::Integer, OFFSET))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
}
fn git_diff() -> Tool {
-12
Переглянути файл
@@ -261,18 +261,6 @@ pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -
host.project(window)?.agents.fork(&task, turns).await
}
/// Creates a temporary side conversation of `source`: lives in memory only, never
/// persisted, not listed.
pub async fn create_ephemeral(
host: &Host,
window: &str,
request: NewAgentTask,
source: String,
) -> IdeResult<AgentTask> {
host.require_trust(window)?;
host.project(window)?.agents.create_ephemeral(request, &source).await
}
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
host.require_trust(window)?;
+1 -10
Переглянути файл
@@ -3047,16 +3047,7 @@ impl PluginWorkspace for HostWorkspace {
&project.root,
&project.settings.effective(),
);
if config.debug_adapter.is_none()
&& let Some(language) = config.extras.params.get(super::run_detectors::DEBUG_LANGUAGE)
{
config.debug_adapter = project
.languages
.get(&ide_core::LanguageId::new(language.as_str()))
.and_then(|l| l.debugger)
.map(|d| d.adapter);
}
if config.debug_adapter.is_none() && !config.extras.params.contains_key(super::run_detectors::DEBUG_ATTACH) {
if config.debug_adapter.is_none() {
let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root);
config.debug_adapter =
config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| {
-155
Переглянути файл
@@ -415,67 +415,6 @@ fn validate_configurations(
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
validate_env(&c.env)?;
validate_debugger(c.debugger.as_ref())?;
}
Ok(())
}
/// Like `process` commands, a configuration only adds variables: one the IDE's own
/// environment has (`PATH`, …) would change what the approved program runs.
fn validate_env(env: &[(String, String)]) -> Result<(), HostError> {
if env.len() > 64 {
return Err(HostError::Invalid("at most 64 environment variables".into()));
}
let mut names = std::collections::BTreeSet::new();
for (name, value) in env {
if name.is_empty() || name.len() > 256 || name.contains(['=', '\0']) {
return Err(HostError::Invalid(format!("{name:?} is not an environment variable name")));
}
if value.len() > 4096 || value.contains('\0') {
return Err(HostError::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
}
let key = if cfg!(windows) { name.to_ascii_uppercase() } else { name.clone() };
if !names.insert(key) || inherited_env(name) {
return Err(HostError::Invalid(format!(
"environment variable {name:?} is repeated or already set; only new variables may be added"
)));
}
}
Ok(())
}
fn inherited_env(name: &str) -> bool {
std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) { inherited.to_string_lossy().eq_ignore_ascii_case(name) } else { inherited == name }
})
}
/// Attach to Process kinds a configuration may attach to.
pub(super) const ATTACH_KINDS: [&str; 5] = ["dotnet", "node", "python", "go", "native"];
fn validate_debugger(debugger: Option<&ide_plugin::PluginDebugger>) -> Result<(), HostError> {
match debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
if language.is_empty()
|| language.len() > 64
|| !language.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(HostError::Invalid(format!("{language:?} is not a language id")));
}
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
if !ATTACH_KINDS.contains(&kind.as_str()) {
return Err(HostError::Invalid(format!(
"attach-child kind {kind:?} is not one of {}",
ATTACH_KINDS.join(", ")
)));
}
if command_contains.as_ref().is_some_and(|t| t.is_empty() || t.len() > 256 || t.contains('\0')) {
return Err(HostError::Invalid("attach-child command-contains must be 1-256 bytes".into()));
}
}
None => {}
}
Ok(())
}
@@ -592,52 +531,6 @@ impl PluginHost {
}
}
impl PluginHost {
/// The process an `attach-child` configuration's debugger attaches to: the first Attach to
/// Process candidate of its kind that `run` (a run of this configuration) started, waited
/// for while the run lasts, up to a minute. Only the run's own descendants are candidates.
pub fn debug_attach_target(
&self,
host: &crate::Host,
client: &str,
config: &str,
run: ide_core::RunId,
) -> IdeResult<ide_dap::attach::AttachableProcess> {
use super::run_detectors::{DEBUG_ATTACH, DEBUG_ATTACH_CONTAINS};
let Some((plugin, id)) = config.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) else {
return Err(IdeError::invalid("not a plugin configuration"));
};
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
let kind = owned
.extras
.params
.get(DEBUG_ATTACH)
.filter(|k| ATTACH_KINDS.contains(&k.as_str()))
.ok_or_else(|| IdeError::invalid("this configuration does not attach to a process"))?
.clone();
let contains = owned.extras.params.get(DEBUG_ATTACH_CONTAINS).cloned();
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let info = crate::ops::run::list(host).into_iter().find(|r| r.id == run && r.config_id == owned.id);
let Some(pid) = info.as_ref().filter(|r| r.running).and_then(|r| r.pid) else {
return Err(IdeError::not_found("the run ended before a process to debug started"));
};
let found = ide_dap::attach::attachable_descendants(pid)?.into_iter().find(|p| {
serde_json::to_value(p.kind).ok().and_then(|v| v.as_str().map(|k| k == kind)) == Some(true)
&& contains.as_ref().is_none_or(|t| p.command_line.contains(t.as_str()))
});
if let Some(process) = found {
return Ok(process);
}
if Instant::now() >= deadline {
return Err(IdeError::not_found(format!("no {kind} process started by the run within a minute")));
}
std::thread::sleep(Duration::from_millis(500));
}
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
@@ -712,52 +605,4 @@ mod tests {
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
);
}
fn launch(
env: Vec<(&str, &str)>,
debugger: Option<ide_plugin::PluginDebugger>,
) -> ide_plugin::PluginRunConfiguration {
ide_plugin::PluginRunConfiguration {
id: "app".into(),
name: "App".into(),
program: "tool".into(),
env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
debugger,
..Default::default()
}
}
#[test]
fn configurations_only_add_environment_variables() {
let root = std::env::temp_dir();
let ok = |c| validate_configurations(&root, &[c]).is_ok();
assert!(ok(launch(vec![("OPENIDEA_TEST_NEW_VARIABLE", "1")], None)));
let (inherited, _) = std::env::vars().next().expect("the test runs with an environment");
assert!(!ok(launch(vec![(inherited.as_str(), "x")], None)));
assert!(!ok(launch(vec![("A_B", "1"), ("A_B", "2")], None)));
for name in ["", "A=B", "A\0"] {
assert!(!ok(launch(vec![(name, "1")], None)), "{name:?}");
}
assert!(!ok(launch(vec![("OPENIDEA_TEST_NUL", "a\0")], None)));
let many: Vec<_> = (0..65).map(|i| (format!("OPENIDEA_TEST_{i}"), String::new())).collect();
let mut config = launch(vec![], None);
config.env = many;
assert!(!ok(config));
}
#[test]
fn configurations_name_a_language_or_an_attach_to_process_kind() {
use ide_plugin::PluginDebugger::{AttachChild, Launch};
let root = std::env::temp_dir();
let ok = |d| validate_configurations(&root, &[launch(vec![], Some(d))]).is_ok();
assert!(ok(Launch { language: "csharp".into() }));
assert!(!ok(Launch { language: String::new() }));
assert!(!ok(Launch { language: "c sharp".into() }));
for kind in ATTACH_KINDS {
assert!(ok(AttachChild { kind: kind.into(), command_contains: None }), "{kind}");
}
assert!(!ok(AttachChild { kind: "java".into(), command_contains: None }));
assert!(ok(AttachChild { kind: "dotnet".into(), command_contains: Some("Worker.dll".into()) }));
assert!(!ok(AttachChild { kind: "dotnet".into(), command_contains: Some(String::new()) }));
}
}
-10
Переглянути файл
@@ -57,7 +57,6 @@ pub const COMMANDS: &[&str] = &[
"plugin_terminal_session_close",
"plugin_terminal_session_spawn",
"plugin_prepare_debug_run",
"plugin_debug_attach_target",
"plugin_finish_operation",
"plugin_content_tab_action",
"plugin_post_events",
@@ -573,15 +572,6 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_debug_attach_target", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let (config, run) = (c.arg::<String>("config")?, c.arg("run")?);
blocking(move || plugins.debug_attach_target(&c.host, &c.window, &config, run)).await
}
});
let h = host.clone();
d.add("plugin_finish_operation", move |c| {
let h = h.clone();
async move {
-18
Переглянути файл
@@ -130,11 +130,6 @@ pub struct PluginRunDetection {
pub(crate) project_pins: super::api::ProjectPins,
}
/// `RunConfig.extras.params` keys carrying an API 1.9 configuration's debugger.
pub const DEBUG_LANGUAGE: &str = "debugLanguage";
pub const DEBUG_ATTACH: &str = "debugAttach";
pub const DEBUG_ATTACH_CONTAINS: &str = "debugAttachContains";
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
@@ -200,19 +195,6 @@ impl ExternalDetection for PluginRunDetection {
if let Some(framework) = &c.framework {
config.extras.params.insert("framework".into(), framework.clone());
}
config.env = c.env.iter().cloned().collect();
match &c.debugger {
Some(ide_plugin::PluginDebugger::Launch { language }) => {
config.extras.params.insert(DEBUG_LANGUAGE.into(), language.clone());
}
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
config.extras.params.insert(DEBUG_ATTACH.into(), kind.clone());
if let Some(text) = command_contains {
config.extras.params.insert(DEBUG_ATTACH_CONTAINS.into(), text.clone());
}
}
None => {}
}
configs.push(config);
}
}
+1 -68
Переглянути файл
@@ -110,11 +110,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
let found = h.json("search_text", json!({"q":"needle","limit":2})).await;
assert_eq!(found["items"].as_array().unwrap().len(), 2);
assert_eq!(found["more"], true);
assert_eq!(found["nextOffset"], 2);
// The next page goes on where this one ended.
let every = h.json("search_text", json!({"q":"needle","limit":20})).await["items"].as_array().unwrap().clone();
let next = h.json("search_text", json!({"q":"needle","limit":2,"offset":2})).await;
assert_eq!(next["items"].as_array().unwrap()[..], every[2..every.len().min(4)]);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
@@ -167,9 +162,7 @@ async fn window_reports_supply_editor_selection_and_problems_then_disappear_on_c
assert_eq!(focused["selection"]["startColumn"], 3);
let problems = h.json("get_file_problems", json!({"filePath":"src/a.txt","errorsOnly":false})).await;
assert_eq!(problems["errors"][0]["description"], "reported warning");
assert_eq!(h.json("get_project_problems", json!({})).await["files"], json!([]), "errors only by default");
let all = h.json("get_project_problems", json!({"errorsOnly":false})).await;
assert_eq!((all["files"].as_array().unwrap().len(), &all["total"]), (1, &json!(1)));
assert_eq!(h.json("get_project_problems", json!({})).await["files"].as_array().unwrap().len(), 1);
h.host.close_project("main");
h.host.open_project("main", &h.root).unwrap();
assert_eq!(h.json("get_all_open_file_paths", json!({})).await["openFiles"], json!([]));
@@ -294,41 +287,6 @@ async fn execution_requires_a_window_and_denial_never_runs_a_build() {
assert!(error.contains("window closed"), "{error}");
}
#[tokio::test(flavor = "multi_thread")]
async fn run_configurations_are_listed_a_page_at_a_time_and_filtered() {
let h = Harness::new();
h.trust().await;
let saved = |name: &str, command: &str, env: &[(&str, &str)]| ide_run::RunConfig {
id: format!("user:{name}"),
name: name.into(),
kind: "shell".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let configs =
[saved("build", "tsc", &[("MODE", "ci")]), saved("lint", "oxlint .", &[]), saved("test", "vitest", &[])];
ide_run::save_user_configs(&h.root, &configs).unwrap();
let all = h.json("get_run_configurations", json!({})).await;
let total = all["total"].as_u64().unwrap();
assert!(total >= 3, "{all}");
// A saved configuration's own environment is listed.
let build = all["configurations"].as_array().unwrap().iter().find(|c| c["name"] == "build").unwrap();
assert_eq!(build["environment"], json!({ "MODE": "ci" }));
let first = h.json("get_run_configurations", json!({"limit": 1})).await;
assert_eq!((first["configurations"].as_array().unwrap().len(), &first["nextOffset"]), (1, &json!(1)));
let lint = h.json("get_run_configurations", json!({"query": "OXLINT"})).await;
let found = lint["configurations"].as_array().unwrap();
assert!(!found.is_empty() && (found.len() as u64) < total, "{lint}");
assert!(found.iter().all(|c| c.to_string().to_lowercase().contains("oxlint")), "{lint}");
}
#[tokio::test(flavor = "multi_thread")]
async fn git_status_and_diff_on_a_temporary_repository() {
let h = Harness::new();
@@ -350,18 +308,6 @@ async fn git_status_and_diff_on_a_temporary_repository() {
std::fs::write(h.root.join("src/a.txt"), "changed\n").unwrap();
let status = h.json("git_status", json!({"repositoryPathRelativeToProject":"."})).await;
assert_eq!(status["repositories"][0]["unstagedCount"], 1);
// Two entries, one per page.
std::fs::write(h.root.join("src/new.txt"), "new\n").unwrap();
let page =
|offset: u64| h.json("git_status", json!({"repositoryPathRelativeToProject":".","limit":1,"offset":offset}));
let first = page(0).await["repositories"][0].clone();
assert_eq!(
(&first["totalEntries"], &first["hasMoreEntries"], &first["nextOffset"]),
(&json!(2), &json!(true), &json!(1))
);
let second = page(1).await["repositories"][0].clone();
assert_ne!(second["entries"][0], first["entries"][0]);
assert!(second.get("nextOffset").is_none(), "{second}");
assert!(h.call("git_diff", json!({"filePath":"src/a.txt"})).await.contains("+changed"));
assert!(h.call("git_diff", json!({})).await.contains("src/a.txt"));
assert!(h.call("git_diff", json!({"staged":true})).await.is_empty());
@@ -664,19 +610,6 @@ async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
// Newest first, brief, filtered by state and paged.
let newest = h.json("list_runs", json!({"limit": 1})).await;
assert_eq!(
(&newest["runs"][0]["name"], &newest["total"], &newest["nextOffset"]),
(&json!("_agent_server"), &json!(2), &json!(1))
);
assert!(newest["runs"][0].get("outputLength").is_none(), "{newest}");
assert_eq!(h.json("list_runs", json!({"state": "idle"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "running"})).await["total"], 0);
assert_eq!(h.json("list_runs", json!({"state": "stoppedByAgent"})).await["total"], 2);
assert_eq!(h.json("list_runs", json!({"state": "error"})).await["total"], 0);
let unknown = h.error("list_runs", json!({"state": "done"})).await;
assert!(unknown.contains("unknown state `done`"), "{unknown}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
+1 -7
Переглянути файл
@@ -6450,13 +6450,7 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports
let configs = f.host.run_detection().detect(&f.project);
assert!(configs.iter().any(|c| c.id == "plugin:execution:dynamic"
&& c.is_test
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")
&& c.env.get("OPENIDEA_EXECUTION_FIXTURE").map(String::as_str) == Some("1")
&& c.extras.params.get("debugLanguage").map(String::as_str) == Some("python")));
let worker = configs.iter().find(|c| c.id == "plugin:execution:worker").unwrap();
assert_eq!(worker.extras.params.get("debugAttach").map(String::as_str), Some("dotnet"));
assert_eq!(worker.extras.params.get("debugAttachContains").map(String::as_str), Some("Worker.dll"));
assert!(worker.env.is_empty() && !worker.is_test);
&& c.extras.params.get("framework").map(String::as_str) == Some("plugin")));
assert_eq!(
f.host.parse_test_results("main", "plugin:execution:dynamic", "batch".into()).unwrap().unwrap().len(),
3
+16 -18
Переглянути файл
@@ -715,35 +715,33 @@ mod tests {
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
// The SQL server's failed launch reports its status from a runtime thread; while that
// thread drains the server's event queue, events emitted here reach the sink from it.
let latest = |want: &dyn Fn(&[ide_core::Diagnostic]) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let items = events.lock().iter().rev().find_map(|e| match e {
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
});
match items {
Some(items) if want(&items) => return items,
_ if std::time::Instant::now() > deadline => panic!("latest host diagnostics: {items:?}"),
_ => std::thread::sleep(Duration::from_millis(5)),
}
}
})
.unwrap()
};
let messages = |items: &[ide_core::Diagnostic]| items.iter().map(|d| d.message.clone()).collect::<Vec<_>>();
let merged = latest(&|items| items.len() == 3);
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
assert!(
merged.iter().filter(|d| d.message != "native").all(|d| d.source.as_deref() == Some(server.name.as_str()))
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
latest(&|items| messages(items) == ["native", "b"]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
latest(&|items| messages(items) == ["native"]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
-33
Переглянути файл
@@ -1767,38 +1767,6 @@ impl LspManager {
}
}
/// Library classes in `jar:` targets that have no cached sources jar: the official Kotlin
/// LSP decompiles them to signatures only (`/* compiled code */`). When jdtls is running,
/// the same class from its Java model instead, decompiled with method bodies and navigable.
/// Its documents belong to jdtls ([`Self::virtual_document`] records it).
async fn jdt_library_classes(&self, snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
let jar_class = |l: &Location| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"));
if !locs.iter().any(jar_class)
|| !self.servers.lock().iter().any(|s| s.name == jdtls::SERVER && s.running().is_some())
{
return locs;
}
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
let mut out = Vec::with_capacity(locs.len());
for loc in locs {
let entry = loc.uri.as_deref().filter(|_| jar_class(&loc)).and_then(library_source::parse);
let Some(entry) = entry else {
out.push(loc);
continue;
};
let Ok(uri) = self.java_class_file(Path::new(""), Some((entry.jar, entry.entry))).await else {
out.push(loc);
continue;
};
let range = match (&name, self.virtual_document(&uri).await) {
(Some(name), Ok(doc)) => library_source::declaration_range(&doc.text, name),
_ => Range::default(),
};
out.push(Location { path: uri.clone().into(), range, uri: Some(uri) });
}
out
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1840,7 +1808,6 @@ impl LspManager {
let locs = library_sources(snap, &self.project_root, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = self.jdt_library_classes(snap, pos, locs).await;
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
+28 -96
Переглянути файл
@@ -296,48 +296,37 @@ fn harness_configured(
servers: &[(&str, Value)],
adjust: impl Fn(&mut LanguageServerConfig),
) -> Harness {
harness_languages(&[(lang_id, ext, servers)], adjust)
}
/// A language as `(id, extension, servers)`.
type MockLanguage<'a> = (&'a str, &'a str, &'a [(&'a str, Value)]);
/// Several languages; documents open as the first.
fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut LanguageServerConfig)) -> Harness {
let dir = tempfile::tempdir().unwrap();
// Edits and references report canonical paths.
let root = dunce::canonicalize(dir.path()).unwrap();
let configs = languages
.iter()
.map(|(lang_id, ext, servers)| LanguageConfig {
id: LanguageId::new(*lang_id),
name: "Mock".into(),
extensions: vec![ext.to_string()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
})
.collect();
let registry = Arc::new(LanguageRegistry::from_configs(configs));
let lang = LanguageConfig {
id: LanguageId::new(lang_id),
name: "Mock".into(),
extensions: vec![ext.into()],
filenames: vec![],
first_line: None,
line_comment: None,
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: servers
.iter()
.map(|(n, _)| {
let mut cfg = server_cfg(n);
adjust(&mut cfg);
cfg
})
.collect(),
debugger: None,
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
let launcher = Arc::new(MockLauncher::default());
for (name, caps) in languages.iter().flat_map(|(_, _, servers)| servers.iter()) {
for (name, caps) in servers {
*launcher.state(name).caps.lock() = caps.clone();
}
let events = Arc::new(Mutex::new(Vec::new()));
@@ -346,7 +335,7 @@ fn harness_languages(languages: &[MockLanguage<'_>], adjust: impl Fn(&mut Langua
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(languages[0].0) }
Harness { _dir: dir, root, mgr, launcher, events, lang: LanguageId::new(lang_id) }
}
impl Harness {
@@ -2957,63 +2946,6 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
}
#[tokio::test(flavor = "multi_thread")]
async fn library_classes_without_sources_open_decompiled_by_a_running_jdtls() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let jar = dunce::canonicalize(repo.path()).unwrap().join("acme/lib/1.0/lib-1.0.jar");
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&jar).unwrap());
zip.start_file("acme/Thing.class", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"\0").unwrap();
zip.finish().unwrap();
let class = format!("jar://{}!/acme/Thing.class", uri_of(&jar).trim_start_matches("file://"));
let escaped = jar.to_string_lossy().replace('/', "%5C/");
let jdt = format!("jdt://contents/lib-1.0.jar/acme/Thing.class?=app/{escaped}%3Cacme%28Thing.class");
let decompiled =
"package acme;\n\npublic class Thing {\n public void run() {\n System.out.println();\n }\n}\n";
let mut kotlin = incremental_caps();
kotlin["executeCommandProvider"] = json!({ "commands": ["decompile"] });
let mut java = incremental_caps();
java["workspaceSymbolProvider"] = json!(true);
let h =
harness_languages(&[("kotlin", "kt", &[("kotlin-lsp", kotlin)]), ("java", "java", &[("jdtls", java)])], |_| {});
let (doc, _) = h.open("Main.kt", "val thing = Thing()\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let k = h.launcher.state("kotlin-lsp");
k.respond(
"textDocument/definition",
json!({ "uri": class, "range": {"start":{"line":0,"character":6},"end":{"line":0,"character":11}} }),
);
let r = json!({"start":{"line":2,"character":13},"end":{"line":2,"character":18}});
// jdtls not running: the Kotlin server's own target and decompiler.
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].uri.as_deref(), Some(class.as_str()));
let path = h.root.join("Sample.java");
std::fs::write(&path, "class Sample {}").unwrap();
h.mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class Sample {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let j = h.launcher.state("jdtls");
j.respond(
"workspace/symbol",
json!([{ "name": "Thing", "kind": 5, "containerName": "acme", "location": { "uri": jdt, "range": r } }]),
);
j.respond("java/classFileContents", json!(decompiled));
let defs = h.mgr.goto(doc, Pos::new(0, 14), GotoKind::Definition).await.unwrap();
assert_eq!(
defs,
vec![ide_core::Location { path: jdt.clone().into(), range: rng(2, 13, 2, 18), uri: Some(jdt.clone()) }]
);
assert_eq!(j.requested("workspace/symbol")[0]["query"], "acme.Thing");
let vd = h.mgr.virtual_document(&jdt).await.unwrap();
assert_eq!(vd.text, decompiled);
assert!(k.requested("workspace/executeCommand").is_empty(), "the Kotlin stub is not asked for");
}
#[tokio::test(flavor = "multi_thread")]
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
-18
Переглянути файл
@@ -61,24 +61,6 @@ pub struct PluginRunConfiguration {
pub cwd: Option<String>,
pub test: bool,
pub framework: Option<String>,
/// API 1.9: variables added to the run's environment.
#[serde(default)]
pub env: Vec<(String, String)>,
/// API 1.9: how Debug starts the configuration.
#[serde(default)]
pub debugger: Option<PluginDebugger>,
}
/// How Debug starts a plugin configuration (API 1.9 `launch-configurations.debugger`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum PluginDebugger {
/// `program` under the debugger of this language id.
#[serde(rename_all = "camelCase")]
Launch { language: String },
/// Run, then attach to the first matching process the run starts.
#[serde(rename_all = "camelCase")]
AttachChild { kind: String, command_contains: Option<String> },
}
/// RunExited data exchanged with the IDE.
+23 -62
Переглянути файл
@@ -36,7 +36,6 @@ use wasmtime::{Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDea
use wasmtime_wasi::p2::pipe::MemoryOutputPipe;
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use crate::wit_host::launch_configurations_bindings::exports::openidea::plugin::launch_configurations as wit_launch;
use crate::wit_host::run_configurations_bindings::exports::openidea::plugin::run_configurations as wit_configs;
use crate::wit_host::test_results_bindings::exports::openidea::plugin::test_results as wit_tests;
use bindings::openidea::plugin::events as wit_events;
@@ -56,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.9";
pub const API_VERSION: &str = "1.8";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -1009,7 +1008,6 @@ struct Exports {
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
run_configurations: Option<wit_configs::Guest>,
launch_configurations: Option<wit_launch::Guest>,
test_results: Option<wit_tests::Guest>,
mcp: Option<crate::wit_host::mcp_bindings::exports::openidea::plugin::mcp_provider::Guest>,
}
@@ -1149,7 +1147,6 @@ impl Runtime {
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
let launch_indices = wit_launch::GuestIndices::new(&pre).ok();
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
let validation_indices = wit_rtv::GuestIndices::new(&pre).ok();
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
@@ -1160,8 +1157,6 @@ impl Runtime {
let mcp = mcp_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let run_configurations =
configs_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let launch_configurations =
launch_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
@@ -1235,7 +1230,6 @@ impl Runtime {
inlay,
endpoints,
run_configurations,
launch_configurations,
test_results,
mcp,
file_actions,
@@ -1275,67 +1269,34 @@ impl Runtime {
.map(|r| r.map_err(|message| clip(message, 4096)))
}
/// Complete configurations from the optional API 1.9 export, else the API 1.4 one.
/// Complete configurations from the optional API 1.4 export.
pub fn run_configurations(
&mut self,
root: String,
timeout: Duration,
) -> Result<Vec<crate::PluginRunConfiguration>, CallError> {
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items: Vec<crate::PluginRunConfiguration> = if self.exports.launch_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.launch_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
env: c.env,
debugger: match c.debugger {
Some(wit_launch::Debugger::Launch(language)) => {
Some(crate::PluginDebugger::Launch { language })
}
Some(wit_launch::Debugger::AttachChild(a)) => Some(crate::PluginDebugger::AttachChild {
kind: a.kind,
command_contains: a.command_contains,
}),
Some(wit_launch::Debugger::Other(_)) | None => None,
},
})
.collect()
} else if self.exports.run_configurations.is_some() {
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
..Default::default()
})
.collect()
} else {
if self.exports.run_configurations.is_none() {
return Err(CallError::missing_export("run-configurations"));
};
Ok(items)
}
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
let items = self.call(invocation, timeout, |e, store| {
e.run_configurations.as_ref().unwrap().call_configurations(store, &root)
})?;
if items.len() > 1000 {
return Err(CallError::Guest(GuestError::Invalid("too many run configurations".into())));
}
Ok(items
.into_iter()
.map(|c| crate::PluginRunConfiguration {
id: c.id,
name: c.name,
program: c.program,
args: c.args,
cwd: c.cwd,
test: c.test,
framework: c.framework,
})
.collect())
}
/// Selects tests under the same budget as the test parser.
pub fn select_tests(
-5
Переглянути файл
@@ -2254,11 +2254,6 @@ impl bindings::openidea::plugin::run::Host for StoreData {
pub(crate) mod run_configurations_bindings {
wasmtime::component::bindgen!({ world: "configurations-provider", path: "../../wit" });
}
pub(crate) mod launch_configurations_bindings {
wasmtime::component::bindgen!({ world: "launch-configurations-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod test_results_bindings {
wasmtime::component::bindgen!({ world: "test-results-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.9.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0", "1.0.0-rc.1"] {
let host = v("1.8.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.10.0"), &host).unwrap_err();
assert!(newer.contains("1.10") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.9 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a3d2511f30277245a8cb9673c4d8a948a8a025b8b9149d5015bed5137a98ccdd
0b77e7a414ec3b0dd1f41e5658b981f059dd842743dbe2f06e5d428f5b9570f9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
dad3edad99ad85234aa612bbc1f3a66e50361189e54038b91674703815f049cc
6f7d6527837c8e5c86c377479cceef5116decd61666b28f36dd11332e36e9934
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
918af7d4d8bc302621b9b68070a56930813b1fc2ba235d89de4e56c53eaba5bd
c1ac86a5b4d45b55013e6e00dcb83deda33b759ecc366c3fdf57739bdd22cbff
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7a140c2255a3d251a5ab9079bec953cccc4408727b067b49da47cf8fb27d8cb8
e4d8d8f5f5849b912b2c0ebee6b91d60796c3070929d7698323e8ce4f200a38a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ef41934bd0b67f76a17ef321b44c172496314c9fea6b46b4cd2f1d0f4b96907d
e2e2fc44ad9cae9cf26a5bf706768d3786502f23db6994dc29695deb249ed73f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c6dc1ae00d9f3d936f4bf5ff110915050cb503ac06982bdc48e967deac84a913
61e1f0c43bbfddc35dcf40685169eb273777ddca13da534626e1699b47e8eac3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
25a782556dd6bbf8ed1ad7b50b9b55685f074d80220af08f3bd9cb12cf07f74e
2ea1c1693db8638db996c9c47597f0c1635ef0a9c7dfb33685c515b7af95e6a6
+1 -1
Переглянути файл
@@ -1,7 +1,7 @@
id = "execution"
name = "Plugin execution tests"
version = "0.1.0"
engines.openidea = "^1.9"
engines.openidea = "^1.5"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
+12 -32
Переглянути файл
@@ -114,37 +114,17 @@ impl Plugin for Execution {
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
}
}
impl LaunchConfigurations for Execution {
fn launch_configurations(&mut self, _: &str) -> Vec<PluginLaunchConfiguration> {
vec![
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
},
env: vec![("OPENIDEA_EXECUTION_FIXTURE".into(), "1".into())],
debugger: Some(Debugger::Launch { language: "python".into() }),
},
PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "worker".into(),
name: "Worker".into(),
program: "execution-tool".into(),
args: vec!["start".into()],
..Default::default()
},
env: Vec::new(),
debugger: Some(Debugger::AttachChild {
kind: "dotnet".into(),
command_contains: Some("Worker.dll".into()),
}),
},
]
impl RunConfigurations for Execution {
fn configurations(&mut self, _: &str) -> Vec<PluginRunConfiguration> {
vec![PluginRunConfiguration {
id: "dynamic".into(),
name: "Dynamic tests".into(),
program: "execution-tool".into(),
args: vec!["test".into()],
test: true,
framework: Some("plugin".into()),
..Default::default()
}]
}
}
impl TestResults for Execution {
@@ -201,4 +181,4 @@ impl RunTargetValidation for Execution {
}
}
}
register_plugin!(Execution, launch_configurations, test_results, run_target_validation);
register_plugin!(Execution, run_configurations, test_results, run_target_validation);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f58b5f68d56611c3d15f69ff8e73e6e65d84e55d14c9bae00ed50b346460adfa
3c788f6092b229484a5d94fd0e6f4651afc88dd0ab1b22b067d84614a6a0c85e
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
749bff2d051984edf65590494ef803225dfccd03d032de140974d8e984683ac1
18273599cc459833d6a30185a5f0ba127cdb4e22bc054a973571e530eb3a85ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
50c609ebf387b400a01ea28e6ccf0f3de2ff7a5eedbb47c0a4bc40d05e3aaade
c9fafba362fa0745d7ee1685efe5582d432510003c04c98100eddd557bd1e2cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2350d0277636285b182d1484d01d706c3116ef5e28917ddeb24ca78bac251ba
4121009913709edeca8c5cb52d88dfcc2b16867bc06859768f73c17cf9c456a2
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3aaa2a7655189a1e4da55a86fb30fdfde29a5b6ad65de53a78b18df30eeb97f3
b55bd202a06f5643c4f70abb6fb473fbe53af657f4e21a6d13581a23a726e085
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
975ead22afb4c5bc5c79799b76c1d3a683b572b906e3bade94944319920f7f84
11a12228df67f9b94bd52375b5dddb2d955f7e5f88d7f9ea0a430b1db128f6ca
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b849aeca8e5d6b6ede216e372cfc019bac08774006e11ed81d272f67770adc17
553ce6c57052f8ff2ea2526e88762d5a0aee2489cda192fc7ab708766078ccf7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
79505f2677e5f19374a3ed46cccdc1ed8d3092a3c43e3154e8cfd73063533251
7b43f8a2a06d7b2952f8d46727a4cce524fb2bdecee1035dad9c767261d276d0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f7f43d46ebd545cc4c52d859c4307d08bb95b3187a4b947704e37c27cf0f4fa0
e994c69fda5eef94ae85d6dac5aec3d04fb6811c35c446c9d33236a449bd1c6c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
491e8e713e51045ebef81c4f07ed5475ed41688e88ac47ee26d13f2bc5c53b49
c37f6a7ea9018ca636d0fd486a9d91cd397158615410de9b837ca9b8f9f593b7
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
63b2f6007782c9d3498c86505a6c107b70f09d90678c79cf16c859ecbf349e70
1b298dfa5c1f2163d609ef0ccc3d42fafe78e69326002effe53abc4b82974749
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.9.0;
export openidea:plugin/providers@1.9.0;
export openidea:plugin/file-actions@1.9.0;
include openidea:plugin/plugin-syntax@1.8.0;
export openidea:plugin/providers@1.8.0;
export openidea:plugin/file-actions@1.8.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3d1f2451dee8bba3cc874f497bcea909fa9bd58ae605dc1d74b4298b4b11ed94
1e11a95e0909d18308d21a9a8448f8b2e427d240d5d8cd13d8ddbe6ba5009237
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
26a2b15b9354fa0ba2bf5934114bfdd20f7906c4a67497d013146cc6b3fc9643
d5cd77130cc97cffdbc742f04e3e1196f14160107b636806c6ef3c9b40724e84
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c9ef2cf7b6e8f3204f8ee382d7a381a5190b1e812e11d51bd1262c3d429a315f
a0f74430b0a7d9ff48524d7aae4f8a0bd0fc60511e2a951dde9991b9bf7d15a9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b07cc854c1302fd5bfd29fb3037732c7439efde606fc57295b2c0f94602a1077
65508f6e25bd0efc85820ae7d0f9037b58c778fa61e8498c5b9a5055cc38f6fc
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
12c4004f118070d0f37125ac32639ab9f332d59cc4585e74a8af61e3e6c5c1e8
c86ed41d68c132f72eb8b5794f31b975492e5ad42e41b7823d1bf28ba118a262
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
7517da988ab8cacaa3abf7645274a84e364de582ea9edf60a63926b1442d1581
3da1a09906d803234016b3f9c30a88b9b40bf3187fc73840ff60a2e05f504b48
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6e045ee3fcaa655bae773774e48e3eeb9d97ae9d8db1f52bf76c38459dcecb3f
39e7a805120ce6433750c32b1d670b3caba990f7b15aea866a29c23b01cba708
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
69bb92e6e24e54458978a316c465d17b93d9ce9613cd26d7d6de9a1cef9b9447
5ec9be607955ce2aa4d921884df17c49af3ce98945f8fa641cec9a7f1b4d8384
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
fc64a30e9d3b46275a6480641454b367b5082aea82da23a11b57106aa02387e1
f05df5c2faf1718ba9702be4d979c367ea8445a854794bb21e8e36b211f18122
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e6f3b5a4d81e62240fa1de4d1624b3706f145d47c014a1a2ed2b899cab24e0e4
548cb8321b63b0685049fcf796dbc8390fcb22f3c1ffcfa09452e5a63382d27d
+13 -7
Переглянути файл
@@ -246,24 +246,30 @@ mod imp {
}
pub fn process_start() -> Option<std::time::SystemTime> {
#[repr(C)]
#[derive(Default)]
struct FileTime {
low: u32,
high: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
// The same signature `memory.rs` declares: FILETIMEs read as packed u64s.
fn GetProcessTimes(
process: *mut std::ffi::c_void,
creation: *mut u64,
exit: *mut u64,
kernel: *mut u64,
user: *mut u64,
creation: *mut FileTime,
exit: *mut FileTime,
kernel: *mut FileTime,
user: *mut FileTime,
) -> i32;
}
let (mut c, mut e, mut k, mut u) = Default::default();
// SAFETY: all out-pointers are valid 64-bit FILETIME slots.
// SAFETY: all out-pointers are valid FILETIME structs.
let ok = unsafe { GetProcessTimes(GetCurrentProcess(), &mut c, &mut e, &mut k, &mut u) };
if ok == 0 {
return None;
}
let ticks = c; // 100 ns since 1601-01-01
let c: FileTime = c;
let ticks = ((c.high as u64) << 32) | c.low as u64; // 100 ns since 1601-01-01
let unix_100ns = ticks.checked_sub(116_444_736_000_000_000)?;
Some(std::time::UNIX_EPOCH + std::time::Duration::from_nanos(unix_100ns * 100))
}
+1 -5
Переглянути файл
@@ -76,10 +76,6 @@ On your own messages you can **Edit** (the messages after it are replaced; files
The shortcut does not apply to archived tasks, subagent conversations or the New Task page, and does not take focus from open popups or dialogs. Outside the task conversation, the keys keep their usual editor or terminal behavior. To change or remove the binding, open **Settings › Keymap › Agents › Focus Agent Input**.
**Cycle Agent Effort** defaults to `Option+T` on macOS and `Alt+T` on Windows / Linux in every built-in keymap. With focus inside New Task or your own writable task conversation, in an editor tab or the tool window, it cycles the current model's provider-advertised effort levels in order, wrapping from the last to the first. It preserves your message, selection, caret and focus, and sends nothing. Like the effort control, it changes the provider configuration; it does not promise to change a turn already running.
The shortcut is unavailable when effort is unsupported or has only one level, for archived tasks or subagent conversations, and while a popup or dialog is open. It does not take focus from the code editor or terminal. Change or remove it under **Settings › Keymap › Agents › Cycle Agent Effort**.
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
### Permissions
@@ -90,7 +86,7 @@ Once an agent has run a task, new tasks start in its mode that approves everythi
### Review the changes
Click **Show changes** in the task header to list the files the task changed. A task in the project's own checkout lists only the files that changed while the agent was working, not what you or another task changed while it was idle. Click a file to open a diff between the task's starting point and now. For a worktree task, the changes live on its `agent/…` branch: open a terminal there with **Open in Terminal**, and merge or cherry-pick the branch with the Git tools (see [Version control](/en/guide/vcs)) when you are happy with it.
Click **Show changes** in the task header to list the files the task changed. Click a file to open a diff between the task's starting point and now. For a worktree task, the changes live on its `agent/…` branch: open a terminal there with **Open in Terminal**, and merge or cherry-pick the branch with the Git tools (see [Version control](/en/guide/vcs)) when you are happy with it.
Drag the pane's left edge to resize it. The width applies to every task and is remembered across restarts.
-1
Переглянути файл
@@ -98,7 +98,6 @@ While the server runs, OpenIDEA writes its port and token to `mcp/server.json` i
Notes on what they do:
- `read_file` also reads sources of dependencies and files inside jar and zip archives.
- The tools that list things return one page: `limit` items (100 by default; 50 for `search_symbol`, 10 for `list_runs`), from `offset`. `total` (where counting is cheap), `more` and `nextOffset` say what follows. `list_runs` filters by `state` (`running`, `idle`, `error` or one state), `get_run_configurations` by `query`, and `get_project_problems` lists errors only unless `errorsOnly` is false.
- `search_symbol` and `find_references` use the IDE's index and the language servers; `get_symbol_info` returns what Quick Documentation shows.
- `get_file_problems` and `get_project_problems` return what the Problems tool window shows, after your inspection profile is applied, rather than a fresh analysis.
- `check_files` is the fresh analysis, for after an edit made by any tool: it syncs each file from disk, tells its language servers it was saved (so on-save checks such as `cargo check` run) and waits until they answered and finished. Each file gets a `status` that says whether the problems are complete, or why it could not be checked: no language server for it, or one that is not installed, misconfigured or crashed.
-1
Переглянути файл
@@ -77,7 +77,6 @@ The IDE calls these. The first three are in every code plugin; the rest are opti
| `detail-provider` | [details.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/details.wit) | The detail pane of the plugin's nodes in the Services tool window |
| `commit-checker` | [commit.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/commit.wit) | Before-commit checks |
| `run-configurations` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | Dynamic run configurations for a project (API 1.4) |
| `launch-configurations` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | Run configurations with an environment and a debugger (API 1.9) |
| `test-results` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | Parsing test output and selecting tests to rerun (API 1.4) |
| `mcp-provider` | [mcp.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/mcp.wit) | Tools the plugin contributes to the built-in MCP server (API 1.4) |
-40
Переглянути файл
@@ -11,7 +11,6 @@ every start. On top of that, a plugin can:
| ---------------------------------------------------------------------- | ----------------------------------------------------------------- | --------- |
| Offer a fixed command when the project has a marker file | `[[contributes.runDetectors]]` (no code) | 1.0 |
| Compute the project's configurations in code | `[[contributes.runConfigurations]]` + `run-configurations` export | 1.4 |
| Give configurations an environment and a debugger | `launch-configurations` export | 1.9 |
| Turn test output into the IDE's test tree | `test-results` export, `framework = "plugin"` | 1.4 |
| Start one of its configurations from a command | `host::start_run` (WIT `run.start`) | 1.4 |
| Make every run of the project go through a wrapper (DDEV, a container) | `host::register_run_target` (WIT `run-targets`) | 1.4 |
@@ -79,45 +78,6 @@ the project root; it is refreshed when configurations are listed. The host keeps
1000 entries / 4 MiB, keeps the last valid list when the export fails, and doesn't offer
entries whose program isn't approved. The export runs without a window and can't prompt.
## Environment and debugger
From API 1.9, export `launch-configurations` instead (`LaunchConfigurations`, registered as
`launch_configurations`) to give each configuration an environment and a debugger. The IDE
uses it when a component exports both.
```rust
use openidea_plugin::{Debugger, LaunchConfigurations, PluginLaunchConfiguration, PluginRunConfiguration};
impl LaunchConfigurations for Functions {
fn launch_configurations(&mut self, _root: &str) -> Vec<PluginLaunchConfiguration> {
vec![PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "func".into(),
name: "Functions host".into(),
program: "func".into(),
args: vec!["start".into(), "--dotnet-isolated-debug".into()],
..Default::default()
},
env: vec![("FUNCTIONS_WORKER_RUNTIME".into(), "dotnet-isolated".into())],
debugger: Some(Debugger::AttachChild { kind: "dotnet".into(), command_contains: None }),
}]
}
}
register_plugin!(Functions, launch_configurations);
```
- `env` adds at most 64 variables to the run's environment. Like `process` commands, it can
only add: a name the IDE's own environment already has (`PATH`, …) makes the list invalid.
- `debugger: None` keeps the default: Debug infers the debugger from a source file in `args`.
- `Debugger::Launch { language }` starts `program` under that language's debugger (its
`[debugger]`), for programs whose arguments name no source file.
- `Debugger::AttachChild { kind, command_contains }` makes Debug run the configuration as Run
does, then attach to the first process of that kind (`dotnet`, `node`, `python`, `go` or
`native`, as in Attach to Process) the run starts, optionally only one whose command line
contains `command_contains`. Only the run's own processes are candidates. The IDE waits while
the run lasts, up to a minute; without a match the run goes on without the debugger.
## Test results
With `framework = "plugin"` (on a detector or a returned configuration), the IDE feeds the
+1 -5
Переглянути файл
@@ -76,10 +76,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
此快捷键不适用于已归档任务、子 agent 对话或“新建任务”页面,也不会从打开的弹出窗口或对话框中抢走焦点。在任务对话之外,按键保留原有的编辑器或终端行为。要修改或移除绑定,请打开 **设置 › 按键映射 › 智能体 › 聚焦 Agent 输入框**。
所有内置按键映射中,**循环切换 Agent 思考强度** 的默认快捷键都是 macOS 上的 `Option+T`、Windows / Linux 上的 `Alt+T`。焦点位于“新建任务”或你自己的可输入任务对话中时,无论显示在编辑器标签页还是工具窗口中,它都会按提供商声明的顺序切换当前模型支持的思考强度,从最后一级循环回第一级。它会保留消息、选区、光标和焦点,不发送任何内容。与思考强度控件一样,它修改的是提供商配置,不保证改变已经运行中的轮次。
不支持思考强度或仅支持一级时,以及已归档任务、子 agent 对话或打开弹出窗口、对话框时,此快捷键不可用。它不会从代码编辑器或终端抢走焦点。要修改或移除绑定,请打开 **设置 › 按键映射 › 智能体 › 循环切换 Agent 思考强度**。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
@@ -90,7 +86,7 @@ agent 运行过一个任务后,新任务会以它自动批准所有操作的
### 查看改动
在任务标题栏中单击 **显示更改**,列出任务改动过的文件。直接在项目中运行的任务只列出 agent 工作期间改动的文件,不包括它空闲时你自己或其他任务做的修改。单击文件可以打开它从任务开始到现在的 diff。worktree 任务的改动在它的 `agent/…` 分支上:用 **在终端中打开** 在那里打开终端,满意后用 Git 工具合并或 cherry-pick 这个分支(见 [版本控制](/zh/guide/vcs))。
在任务标题栏中单击 **显示更改**,列出任务改动过的文件;单击文件可以打开它从任务开始到现在的 diff。worktree 任务的改动在它的 `agent/…` 分支上:用 **在终端中打开** 在那里打开终端,满意后用 Git 工具合并或 cherry-pick 这个分支(见 [版本控制](/zh/guide/vcs))。
拖动改动面板的左边缘可以调整宽度。宽度对所有任务生效,重启后保留。
-1
Переглянути файл
@@ -98,7 +98,6 @@ stdio 配置大致如下(路径以你机器上的为准):
- `read_file` 还能读取依赖的源代码,以及 jar 和 zip 压缩包中的文件。
- `search_symbol` 和 `find_references` 使用 IDE 的索引和语言服务器;`get_symbol_info` 返回的内容与快速文档相同。
- 列表类工具每次返回一页:从 `offset` 开始的 `limit` 项(默认 100;`search_symbol` 为 50,`list_runs` 为 10)。`total`(计数代价低的工具才有)、`more` 和 `nextOffset` 说明后面还有多少。`list_runs` 可按 `state` 过滤(`running`、`idle`、`error` 或某个具体状态),`get_run_configurations` 可按 `query` 过滤,`get_project_problems` 默认只列错误,`errorsOnly` 传 false 时也列警告。
- `get_file_problems` 和 `get_project_problems` 返回的是问题工具窗口当前显示的内容(已应用你的检查配置文件),而不是重新分析的结果。
- `check_files` 才是重新分析,适合在任何工具修改文件之后调用:它从磁盘同步每个文件,通知语言服务器文件已保存(这样 `cargo check` 等保存时检查也会运行),并等到服务器答复且分析完成。每个文件都有一个 `status`,说明问题列表是否完整,或者为什么无法检查:没有对应的语言服务器,或者服务器未安装、配置错误、已崩溃。
- `rename_refactoring` 通过语言服务器重命名,文本搜索找不到的引用也会一并更新;也可以只预览不写入。
-1
Переглянути файл
@@ -77,7 +77,6 @@ description: "openidea:plugin WIT 包中的每个 interface:一句话说明及
| `detail-provider` | [details.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/details.wit) | Services 工具窗口中插件节点的详情面板 |
| `commit-checker` | [commit.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/commit.wit) | 提交前检查 |
| `run-configurations` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | 项目的动态运行配置(API 1.4) |
| `launch-configurations` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | 带环境变量和调试器的运行配置(API 1.9) |
| `test-results` | [run.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/run.wit) | 解析测试输出、选择要重新运行的测试(API 1.4) |
| `mcp-provider` | [mcp.wit](https://github.com/l0ng-ai/openidea/blob/main/wit/mcp.wit) | 插件为内置 MCP 服务器提供的工具(API 1.4) |
-35
Переглянути файл
@@ -10,7 +10,6 @@ description: "贡献运行配置,把测试输出解析进测试树,从代码
| ---------------------------------------------- | --------------------------------------------------------------- | --------- |
| 项目包含某个标记文件时提供固定命令 | `[[contributes.runDetectors]]`(无需代码) | 1.0 |
| 在代码中计算项目的运行配置 | `[[contributes.runConfigurations]]` + `run-configurations` 导出 | 1.4 |
| 给配置加上环境变量和调试器 | `launch-configurations` 导出 | 1.9 |
| 把测试输出转成 IDE 的测试树 | `test-results` 导出,`framework = "plugin"` | 1.4 |
| 从命令中启动自己的某个配置 | `host::start_run`(WIT `run.start`) | 1.4 |
| 让项目的所有运行都经过包装程序(DDEV、容器等) | `host::register_run_target`(WIT `run-targets`) | 1.4 |
@@ -72,40 +71,6 @@ register_plugin!(TestRunner, run_configurations);
请返回该项目根目录的完整列表;列出配置时会刷新。宿主最多保留 1000 项 / 4 MiB,导出失败时保留上一次有效的列表,
程序未获批准的条目不会提供。该导出在没有窗口的情况下运行,不能弹出对话框。
## 环境变量和调试器
从 API 1.9 起,可以改为导出 `launch-configurations`(实现 `LaunchConfigurations`,注册为 `launch_configurations`),
给每个配置加上环境变量和调试器。组件同时导出两者时,IDE 使用这一个。
```rust
use openidea_plugin::{Debugger, LaunchConfigurations, PluginLaunchConfiguration, PluginRunConfiguration};
impl LaunchConfigurations for Functions {
fn launch_configurations(&mut self, _root: &str) -> Vec<PluginLaunchConfiguration> {
vec![PluginLaunchConfiguration {
config: PluginRunConfiguration {
id: "func".into(),
name: "Functions host".into(),
program: "func".into(),
args: vec!["start".into(), "--dotnet-isolated-debug".into()],
..Default::default()
},
env: vec![("FUNCTIONS_WORKER_RUNTIME".into(), "dotnet-isolated".into())],
debugger: Some(Debugger::AttachChild { kind: "dotnet".into(), command_contains: None }),
}]
}
}
register_plugin!(Functions, launch_configurations);
```
- `env` 最多给运行的环境添加 64 个变量。和 `process` 命令一样只能新增:IDE 自身环境里已有的变量名(`PATH` 等)会让整个列表无效。
- `debugger: None` 保持默认行为:Debug 根据 `args` 中的源文件推断调试器。
- `Debugger::Launch { language }` 在该语言的调试器(其 `[debugger]`)下启动 `program`,适用于参数里没有源文件的程序。
- `Debugger::AttachChild { kind, command_contains }` 让 Debug 先像 Run 一样运行配置,再附加到这次运行启动的第一个该类型进程
(`dotnet`、`node`、`python`、`go` 或 `native`,与 Attach to Process 相同),可选地只匹配命令行包含 `command_contains` 的进程。
只有这次运行自己的进程才是候选。IDE 在运行期间最多等待一分钟;没有匹配时运行继续,但不附加调试器。
## 测试结果
设置 `framework = "plugin"`(在检测器或返回的配置上)后,IDE 会把运行输出交给 `test-results` 导出。
-533
Переглянути файл
@@ -1,533 +0,0 @@
import type { Locator, Page } from "@playwright/test";
import type { AgentConfigOption, AgentEntry, AgentProvider, AgentTask } from "../src/bindings";
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
type Placement = "editor" | "toolWindow";
const effortId = "deliberation-budget";
const modelId = "cortex";
const draft = "draft keep this unsent";
const rebound = "Control+Meta+Shift+J";
const option = (id: string, category: string, values: [string, string][], current: string): AgentConfigOption => ({
id,
category,
name: category === "model" ? "Model" : "Reasoning",
description: null,
current,
via: "option",
values: values.map(([value, name]) => ({ value, name, description: null })),
});
const models = option(
modelId,
"model",
[
["atlas", "Atlas"],
["swift", "Swift"],
["plain", "Plain"],
["solo", "Solo"],
],
"atlas",
);
const atlas = option(
effortId,
"thought_level",
[
["budget-7", "Gentle"],
["budget-23", "Balanced"],
["budget-91", "Deep"],
],
"budget-7",
);
const swift = option(
effortId,
"thought_level",
[
["quick", "Quick"],
["thorough", "Thorough"],
],
"quick",
);
const solo = option(effortId, "thought_level", [["only", "Only"]], "only");
const provider: AgentProvider = {
id: "acceptance-provider",
name: "Acceptance Provider",
command: "acceptance",
program: "acceptance",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [models],
thoughtLevels: { atlas: [atlas], swift: [swift], plain: [], solo: [solo] },
};
const firstTask: AgentTask = {
id: "effort-first",
title: "First effort task",
provider: provider.id,
status: "ready",
isolation: "local",
model: "atlas",
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [models, atlas],
commands: [],
usage: null,
queue: [],
};
const secondTask: AgentTask = { ...firstTask, id: "effort-second", title: "Second effort task" };
const subagent: AgentEntry = {
type: "subagent",
id: "effort-child",
title: "Inspect the project",
description: null,
status: "done",
error: null,
prompt: null,
reply: null,
tools: 0,
activity: null,
startedMs: null,
endedMs: null,
cancellable: false,
};
async function seed(
page: Page,
placement: Placement,
surface: "new" | "task" = "task",
model = "atlas",
status: AgentTask["status"] = "ready",
) {
await stubDesktop(page);
await page.evaluate(
async ({ placement, surface, provider, firstTask, secondTask, subagent, model, status, modelId }) => {
// In-memory sample tasks belong to Vite's browser realm. Static Node imports create a different store and cannot seed the workbench.
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
const configFor = (model: string) => [
{ ...provider.config[0]!, current: model },
...(provider.thoughtLevels?.[model] ?? []),
];
const tasks = new Map(
[firstTask, secondTask].map((task) => [task.id, { ...task, status, model, config: configFor(model) }]),
);
const entries: AgentEntry[] = Array.from({ length: 24 }, (_, turn): AgentEntry[] => [
{ type: "user", text: `Request ${turn}`, attachments: [] },
{ type: "agent", text: `Reply ${turn}` },
]).flat();
let channel: unknown;
const handlers = window.__ipc.handlers;
// Settings writes must update the browser's project store, just as the desktop response does.
const projectPath = "/src/platform/project.ts";
const { useProject } = await import(projectPath);
handlers.settings_set = ({ key, value }) => {
const next: Record<string, unknown> = structuredClone(useProject.getState().settings ?? {});
const path = String(key).split(".");
let node = next;
for (const segment of path.slice(0, -1)) {
node[segment] ??= {};
node = node[segment] as Record<string, unknown>;
}
const last = path.at(-1)!;
const parsed: unknown = JSON.parse(String(value));
if (parsed === null) delete node[last];
else node[last] = parsed;
useProject.setState({ settings: next });
return next;
};
handlers.agent_subscribe = ({ onEvent }) => {
channel = onEvent;
return [...tasks.values()];
};
handlers.agent_providers = () => [provider];
handlers.agent_refresh_models = () => ({
provider: provider.id,
config: provider.config,
thoughtLevels: provider.thoughtLevels,
refreshing: false,
refreshedAtMs: Date.now(),
retryAtMs: null,
error: null,
});
handlers.agent_probe = () => null;
handlers.agent_commands = () => [];
handlers.agent_entries = ({ subagent: child }) =>
child ? [{ type: "agent", text: "Subagent findings" }] : [...entries, subagent];
handlers.agent_changes = () => [];
handlers.agent_grouped_changes = () => ({ uncommitted: [], commits: [] });
handlers.vcs_branch_info = () => ({ head: "main" });
handlers.vcs_branches = () => [];
handlers.agent_set_config = async ({ task: id, option, value }) => {
const previous = tasks.get(String(id))!;
const updated =
option === modelId
? { ...previous, model: String(value), config: configFor(String(value)) }
: {
...previous,
config: previous.config.map((entry) =>
entry.id === option ? { ...entry, current: String(value) } : entry,
),
};
// Mirror the backend's optimistic task event; production's subscription reducer owns the UI update.
tasks.set(updated.id, updated);
window.__ipc.send(channel, { type: "task", task: updated });
if (document.documentElement.dataset.holdConfig === "true") {
delete document.documentElement.dataset.holdConfig;
const { promise, resolve } = Promise.withResolvers<void>();
document.addEventListener("release-config", () => resolve(), { once: true });
await promise;
}
return null;
};
handlers.agent_prompt = () => null;
handlers.agent_interrupt = () => null;
await store.connect();
store.useAgents.setState({
composers: new Map(),
unread: new Set(),
docked: null,
homeOpen: false,
sessionLocation: placement,
draft: store.savedDraft({ provider: provider.id, config: { [modelId]: model } }),
});
if (surface === "new") store.openHome();
else store.openTask(firstTask.id);
},
{ placement, surface, provider, firstTask, secondTask, subagent, model, status, modelId },
);
await expect(box(page)).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
function box(page: Page) {
return page.getByRole("textbox", { name: "Message", exact: true }).filter({ visible: true });
}
function history(page: Page) {
return page.locator("[class*='_stream_']:visible");
}
function modelButton(page: Page) {
return page.getByRole("button", { name: "Model", exact: true }).filter({ visible: true });
}
async function expectEffort(page: Page, name: string) {
await expect(modelButton(page)).toHaveAttribute("title", new RegExp(`Reasoning: ${name}(?: ·|$)`));
}
async function selected(box: Locator) {
return box.evaluate((element) => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!selection.focusNode ||
!element.contains(selection.anchorNode) ||
!element.contains(selection.focusNode)
)
return null;
const offset = (node: Node, at: number) => {
const range = document.createRange();
range.selectNodeContents(element);
range.setEnd(node, at);
return range.toString().length;
};
return {
anchor: offset(selection.anchorNode, selection.anchorOffset),
head: offset(selection.focusNode, selection.focusOffset),
text: selection.toString(),
};
});
}
async function selectDraft(page: Page) {
const input = box(page);
await input.fill(draft);
await input.press("Home");
for (let i = 0; i < 6; i++) await input.press("ArrowRight");
for (let i = 0; i < 4; i++) await input.press("Shift+ArrowRight");
await expect.poll(() => selected(input)).toEqual({ anchor: 6, head: 10, text: "keep" });
}
async function chooseModel(page: Page, name: string) {
await modelButton(page).click();
await page.getByRole("option", { name, exact: true }).click();
await page.keyboard.press("Escape");
await box(page).focus();
}
async function expectNoSubmission(page: Page) {
expect(await ipcCalls(page, "agent_prompt")).toEqual([]);
expect(await ipcCalls(page, "agent_interrupt")).toEqual([]);
expect(await ipcCalls(page, "agent_create")).toEqual([]);
}
for (const placement of ["editor", "toolWindow"] as const) {
test(`${placement}: existing task cycles advertised values and wraps without editing, moving focus or scrolling`, async ({
workbench: page,
}) => {
await seed(page, placement);
const stream = history(page);
await stream.click({ position: { x: 12, y: 20 } });
await stream.press("Home");
await expect.poll(() => stream.evaluate((element) => element.scrollTop)).toBe(0);
await selectDraft(page);
for (const [value, name] of [
["budget-23", "Balanced"],
["budget-91", "Deep"],
["budget-7", "Gentle"],
]) {
await page.keyboard.press("Alt+t");
await expectEffort(page, name!);
await expect(box(page)).toBeFocused();
await expect(box(page)).toHaveText(draft);
await expect.poll(() => selected(box(page))).toEqual({ anchor: 6, head: 10, text: "keep" });
await expect.poll(() => stream.evaluate((element) => element.scrollTop)).toBe(0);
expect((await ipcCalls(page, "agent_set_config")).at(-1)).toEqual({
task: firstTask.id,
option: effortId,
value,
});
await expect(page.getByRole("dialog", { name: "Model & effort settings", exact: true })).toHaveCount(0);
}
await expectNoSubmission(page);
await stream.click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Alt+t");
await expectEffort(page, "Balanced");
await expect(stream).toBeFocused();
await expect.poll(() => stream.evaluate((element) => element.scrollTop)).toBe(0);
});
test(`${placement}: New Task cycles effort without changing or submitting the draft`, async ({ workbench: page }) => {
await seed(page, placement, "new");
await selectDraft(page);
// macOS Option+T may report † as key; the dispatcher's physical code must still resolve Alt+T.
await box(page).dispatchEvent("keydown", { key: "†", code: "KeyT", altKey: true, bubbles: true, cancelable: true });
await expectEffort(page, "Balanced");
await expect(box(page)).toBeFocused();
await expect(box(page)).toHaveText(draft);
await expect.poll(() => selected(box(page))).toEqual({ anchor: 6, head: 10, text: "keep" });
await expectNoSubmission(page);
});
}
for (const [surface, model, status] of [
["new", "plain", "ready"],
["new", "solo", "ready"],
["task", "plain", "ready"],
["task", "solo", "ready"],
["task", "atlas", "archived"],
] as const) {
test(`${surface} ${model} ${status}: unavailable effort leaves configuration untouched`, async ({
workbench: page,
}) => {
await seed(page, "editor", surface, model, status);
if (status === "archived") await history(page).click({ position: { x: 12, y: 20 } });
else await box(page).focus();
const before = await modelButton(page).textContent();
await page.keyboard.press("Alt+t");
await expect(modelButton(page)).toHaveText(before!);
expect(await ipcCalls(page, "agent_set_config")).toEqual([]);
await expectNoSubmission(page);
});
}
test("subagent conversation never changes its hidden parent's effort", async ({ workbench: page }) => {
await seed(page, "editor");
await page.getByTitle("Open the subagent's conversation", { exact: true }).click();
await expect(page.getByRole("navigation", { name: "Subagent", exact: true })).toBeVisible();
await history(page).click({ position: { x: 12, y: 20 } });
await page.keyboard.press("Alt+t");
expect(await ipcCalls(page, "agent_set_config")).toEqual([]);
await page
.getByRole("navigation", { name: "Subagent", exact: true })
.getByRole("button", { name: firstTask.title, exact: true })
.click();
await expectEffort(page, "Gentle");
});
test("Settings and model popup exclude the agent shortcut", async ({ workbench: page }) => {
await seed(page, "editor");
await box(page).fill(draft);
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
const search = settings.getByRole("textbox", { name: "Search settings", exact: true });
await search.fill("keymap");
await page.keyboard.press("Alt+t");
await expect(search).toBeFocused();
await expect(search).toHaveValue("keymap");
await settings.getByRole("button", { name: "Cancel", exact: true }).click();
await modelButton(page).click();
const popup = page.getByRole("dialog", { name: "Model & effort settings", exact: true });
await expect(popup).toBeVisible();
await popup.getByRole("slider", { name: "Reasoning" }).focus();
await page.keyboard.press("Alt+t");
await expect(popup.getByRole("slider", { name: "Reasoning" })).toHaveAttribute("aria-valuetext", "Gentle");
expect(await ipcCalls(page, "agent_set_config")).toEqual([]);
await page.keyboard.press("Escape");
await expectEffort(page, "Gentle");
await expect(box(page)).toHaveText(draft);
});
test("code editor and terminal do not mutate the previously focused docked agent", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
await seed(page, "toolWindow");
await box(page).fill(draft);
await editor.locator(".cm-content").click();
await page.keyboard.press("Alt+t");
await expect(editor.locator(".cm-content")).toBeFocused();
await page.keyboard.press("Alt+F12");
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
const input = terminal.getByRole("textbox", { name: "Terminal input" });
await expect(input).toBeFocused();
await page.keyboard.press("Alt+t");
await expect(input).toBeFocused();
expect(await ipcCalls(page, "agent_set_config")).toEqual([]);
await expectNoSubmission(page);
});
test("hidden kept-alive editor tasks stay isolated", async ({ workbench: page }) => {
await seed(page, "editor");
await box(page).fill("first draft");
await page.evaluate(async (id) => {
// Open the existing browser store; a static Node import cannot reach the mounted workbench.
const storePath = "/src/features/agents/store.ts";
(await import(storePath)).openTask(id);
}, secondTask.id);
await box(page).fill("second draft");
await page.keyboard.press("Alt+t");
await expectEffort(page, "Balanced");
await page.getByRole("tab", { name: firstTask.title, exact: true }).click();
await expectEffort(page, "Gentle");
await expect(box(page)).toHaveText("first draft");
await box(page).focus();
await page.keyboard.press("Alt+t");
await expectEffort(page, "Balanced");
expect(await ipcCalls(page, "agent_set_config")).toEqual([
{ task: secondTask.id, option: effortId, value: "budget-23" },
{ task: firstTask.id, option: effortId, value: "budget-23" },
]);
});
test("Settings → Keymap rebinds Cycle Agent Effort and removes Alt+T", async ({ workbench: page }) => {
await seed(page, "editor");
await selectDraft(page);
await page.keyboard.press("Meta+,");
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("treeitem", { name: "Keymap", exact: true }).click();
await settings.getByRole("textbox", { name: "Search actions", exact: true }).fill("Cycle Agent Effort");
const row = settings.getByRole("tree", { name: "Actions" }).getByRole("treeitem", { name: /Cycle Agent Effort/ });
await row.dblclick();
await page.getByRole("menuitem", { name: "Add Keyboard Shortcut", exact: true }).click();
const recorder = page.getByRole("dialog", { name: "Keyboard Shortcut: Cycle Agent Effort", exact: true });
await recorder.getByRole("textbox", { name: "First stroke" }).press(rebound);
await recorder.getByRole("button", { name: "OK", exact: true }).click();
await expect(recorder).toBeHidden();
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Remove ⌥T", exact: true }).click();
await settings.getByRole("button", { name: "OK", exact: true }).click();
await expect(settings).toBeHidden();
await box(page).focus();
await page.keyboard.press("Alt+t");
await expectEffort(page, "Gentle");
expect(await ipcCalls(page, "agent_set_config")).toEqual([]);
await page.keyboard.press(rebound);
await expectEffort(page, "Balanced");
expect(await ipcCalls(page, "agent_set_config")).toEqual([
{ task: firstTask.id, option: effortId, value: "budget-23" },
]);
await expect(box(page)).toHaveText(draft);
});
test("Find Action executes Cycle Agent Effort after closing its popup", async ({ workbench: page }) => {
await seed(page, "editor");
await selectDraft(page);
await page.keyboard.press("Meta+Shift+A");
const search = page.getByRole("dialog", { name: "Search Everywhere", exact: true });
await search.getByRole("combobox").fill("Cycle Agent Effort");
await expect(search.getByRole("option", { name: /Cycle Agent Effort/ })).toBeVisible();
await search.getByRole("combobox").press("Enter");
await expect(search).toBeHidden();
await expectEffort(page, "Balanced");
await expect(box(page)).toHaveText(draft);
expect(await ipcCalls(page, "agent_set_config")).toEqual([
{ task: firstTask.id, option: effortId, value: "budget-23" },
]);
});
test("compact docked composer shows the cycled effort visibly", async ({ workbench: page }) => {
await page.setViewportSize({ width: 600, height: 800 });
await seed(page, "toolWindow");
await box(page).focus();
await page.keyboard.press("Alt+t");
await expect(page.getByText("Effort: Balanced", { exact: true }).filter({ visible: true })).toBeVisible();
expect(await ipcCalls(page, "agent_set_config")).toEqual([
{ task: firstTask.id, option: effortId, value: "budget-23" },
]);
});
for (const preset of [
"intellij-macos",
"intellij-windows",
"vscode-macos",
"vscode-windows",
"eclipse-macos",
"eclipse-windows",
]) {
test(`${preset}: Alt+T cycles effort using the preset's default binding`, async ({ workbench: page }) => {
await seed(page, "editor");
await page.evaluate(async (preset) => {
// The dispatcher lives in Vite's browser realm; a Node import cannot update its active keymap.
const keymapPath = "/src/platform/keymap.ts";
(await import(keymapPath)).applyKeymapSettings(preset, {});
}, preset);
await box(page).focus();
await page.keyboard.press("Alt+t");
await expectEffort(page, "Balanced");
expect(await ipcCalls(page, "agent_set_config")).toEqual([
{ task: firstTask.id, option: effortId, value: "budget-23" },
]);
});
}
for (const setting of ["effort", "model"] as const) {
test(`pending ${setting} changes do not block Send or serialize shortcut presses`, async ({ workbench: page }) => {
await seed(page, "editor");
await box(page).fill(draft);
await page.evaluate(() => {
document.documentElement.dataset.holdConfig = "true";
});
if (setting === "effort") {
await page.keyboard.press("Alt+t");
await expectEffort(page, "Balanced");
await page.keyboard.press("Alt+t");
await expectEffort(page, "Deep");
} else {
await chooseModel(page, "Swift");
await expectEffort(page, "Quick");
}
expect(await ipcCalls(page, "agent_set_config")).toEqual(
setting === "effort"
? [
{ task: firstTask.id, option: effortId, value: "budget-23" },
{ task: firstTask.id, option: effortId, value: "budget-91" },
]
: [{ task: firstTask.id, option: modelId, value: "swift" }],
);
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled();
await box(page).fill("send while configuration is pending");
await box(page).press("Enter");
await expect
.poll(() => ipcCalls(page, "agent_prompt"))
.toEqual([
expect.objectContaining({
task: firstTask.id,
prompt: expect.objectContaining({ text: "send while configuration is pending" }),
}),
]);
await page.evaluate(() => document.dispatchEvent(new Event("release-config")));
});
}
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more