Compare commits

...

5 commits

Author SHA1 Message Date
Gabriele Ancillai
541d23f78f
Merge 767bedf07e into 09165c15dc 2026-05-31 13:50:31 +05:30
Nathan Sobo
09165c15dc
gpui: Support prompt_for_paths in TestPlatform (#58139)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Implements the previously-`unimplemented!()`
`TestPlatform::prompt_for_paths` so tests can drive the platform Open
dialog deterministically.

Adds `TestAppContext::simulate_path_prompt_response` and
`did_prompt_for_paths`, mirroring the existing `prompt_for_new_path`
test helpers (`simulate_new_path_selection`). The simulated response
validates that callers don't return multiple paths when
`PathPromptOptions::multiple` is false.

Release Notes:

- N/A
2026-05-30 20:37:39 +00:00
Gabriele Ancillai
767bedf07e Merge branch 'lmstudio-fix-context-wheel-token-usage' of https://github.com/GabrieleAncillai/zed into lmstudio-fix-context-wheel-token-usage 2026-05-27 12:55:27 -05:00
Gabriele Ancillai
e9f6423f63 lmstudio: Fix context wheel by including token usage in streaming responses
Add stream_options with include_usage: true to the ChatCompletionRequest so
LM Studio returns token usage in streaming responses. Previously, without
this field, the API never included usage data, so the context wheel had nothing
to display.

Also move usage handling in the event mapper to run before the empty-choices
guard. OpenAI-compatible servers send the final usage summary as a chunk with
an empty choices array, so the old guard was discarding usage data instead of
emitting a UsageUpdate event.

Fixes #53790
2026-05-27 12:54:45 -05:00
Gabriele Ancillai
da6f241ff2 lmstudio: Fix context wheel by including token usage in streaming responses
Add stream_options with include_usage: true to the ChatCompletionRequest so
LM Studio returns token usage in streaming responses. Previously, without
this field, the API never included usage data, so the context wheel had nothing
to display.

Also move usage handling in the event mapper to run before the empty-choices
guard. OpenAI-compatible servers send the final usage summary as a chunk with
an empty choices array, so the old guard was discarding usage data instead of
emitting a UsageUpdate event.

Fixes #53790
2026-05-27 12:40:59 -05:00
4 changed files with 266 additions and 20 deletions

View file

@ -336,6 +336,20 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path);
}
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
pub fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
self.test_platform
.simulate_path_prompt_response(select_paths);
}
/// Returns true if there's a path selection dialog pending.
pub fn did_prompt_for_paths(&self) -> bool {
self.test_platform.did_prompt_for_paths()
}
/// Simulates clicking a button in an platform-level alert dialog.
#[track_caller]
pub fn simulate_prompt_answer(&self, button: &str) {
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
.unwrap()
}
}
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use std::path::PathBuf;
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
assert!(!cx.did_prompt_for_paths());
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: true,
prompt: None,
})
});
assert!(cx.did_prompt_for_paths());
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
cx.simulate_path_prompt_response({
let selected = selected.clone();
move |options| {
assert!(options.multiple);
Some(selected)
}
});
assert!(!cx.did_prompt_for_paths());
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, Some(selected));
}
#[gpui::test]
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
});
cx.simulate_path_prompt_response(|_options| None);
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, None);
}
}

View file

@ -1,9 +1,10 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
};
use anyhow::Result;
use collections::VecDeque;
@ -85,6 +86,10 @@ struct TestPrompt {
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
paths: VecDeque<(
PathPromptOptions,
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
)>,
}
impl TestPlatform {
@ -147,6 +152,33 @@ impl TestPlatform {
tx.send(Ok(select_path(&path))).ok();
}
pub(crate) fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
let (options, tx) = self
.prompts
.borrow_mut()
.paths
.pop_front()
.expect("no pending paths prompt");
let selection = select_paths(&options);
if let Some(paths) = &selection
&& !options.multiple
&& paths.len() > 1
{
panic!(
"selected {} paths for a prompt that does not allow multiple selection",
paths.len()
);
}
tx.send(Ok(selection)).ok();
}
pub(crate) fn did_prompt_for_paths(&self) -> bool {
!self.prompts.borrow().paths.is_empty()
}
#[track_caller]
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
let prompt = self
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
fn prompt_for_paths(
&self,
_options: crate::PathPromptOptions,
options: crate::PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
unimplemented!()
let (tx, rx) = oneshot::channel();
self.prompts.borrow_mut().paths.push_back((options, tx));
rx
}
fn prompt_for_new_path(

View file

@ -1,4 +1,4 @@
use anyhow::{Result, anyhow};
use anyhow::Result;
use collections::HashMap;
use credentials_provider::CredentialsProvider;
use fs::Fs;
@ -413,6 +413,9 @@ impl LmStudioLanguageModel {
model: self.model.name.clone(),
messages,
stream: true,
stream_options: Some(lmstudio::StreamOptions {
include_usage: true,
}),
max_tokens: Some(-1),
stop: Some(request.stop),
// In LM Studio you can configure specific settings you'd like to use for your model.
@ -558,13 +561,23 @@ impl LmStudioEventMapper {
&mut self,
event: lmstudio::ResponseStreamEvent,
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let mut events = Vec::new();
if let Some(usage) = event.usage {
events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})));
}
// The final usage summary chunk from OpenAI-compatible servers has an empty choices array.
// Return accumulated events instead of treating it as an error.
let Some(choice) = event.choices.into_iter().next() else {
return vec![Err(LanguageModelCompletionError::from(anyhow!(
"Response contained no choices"
)))];
return events;
};
let mut events = Vec::new();
if let Some(content) = choice.delta.content {
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
}
@ -603,15 +616,6 @@ impl LmStudioEventMapper {
}
}
if let Some(usage) = event.usage {
events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})));
}
match choice.finish_reason.as_deref() {
Some("stop") => {
events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
@ -658,6 +662,142 @@ struct RawToolCall {
arguments: String,
}
#[cfg(test)]
mod tests {
use super::*;
use lmstudio::{ChoiceDelta, ResponseMessageDelta, ResponseStreamEvent, Usage};
fn make_event(choices: Vec<ChoiceDelta>, usage: Option<Usage>) -> ResponseStreamEvent {
ResponseStreamEvent {
created: 0,
model: "test-model".to_string(),
object: "chat.completion.chunk".to_string(),
choices,
usage,
}
}
fn make_content_choice(content: &str) -> ChoiceDelta {
ChoiceDelta {
index: 0,
delta: ResponseMessageDelta {
role: None,
content: Some(content.to_string()),
reasoning_content: None,
tool_calls: None,
},
finish_reason: None,
}
}
fn make_stop_choice() -> ChoiceDelta {
ChoiceDelta {
index: 0,
delta: ResponseMessageDelta {
role: None,
content: None,
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some("stop".to_string()),
}
}
// OpenAI-compatible servers send a final chunk with usage data and an empty
// choices array. Before this fix, the mapper returned an error for empty
// choices, discarding usage entirely.
#[test]
fn test_usage_in_final_empty_choices_chunk() {
let mut mapper = LmStudioEventMapper::new();
let event = make_event(
vec![],
Some(Usage {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
}),
);
let results: Vec<_> = mapper
.map_event(event)
.into_iter()
.map(|r| r.unwrap())
.collect();
assert_eq!(
results,
vec![LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})]
);
}
#[test]
fn test_empty_choices_without_usage_returns_empty() {
let mut mapper = LmStudioEventMapper::new();
let event = make_event(vec![], None);
let results = mapper.map_event(event);
assert!(results.is_empty());
}
// Usage data can also arrive in a regular chunk that also contains content.
// Both events must be emitted, with UsageUpdate first.
#[test]
fn test_usage_emitted_alongside_content() {
let mut mapper = LmStudioEventMapper::new();
let event = make_event(
vec![make_content_choice("Hello!")],
Some(Usage {
prompt_tokens: 5,
completion_tokens: 3,
total_tokens: 8,
}),
);
let results: Vec<_> = mapper
.map_event(event)
.into_iter()
.map(|r| r.unwrap())
.collect();
assert_eq!(
results[0],
LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
input_tokens: 5,
output_tokens: 3,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})
);
assert_eq!(
results[1],
LanguageModelCompletionEvent::Text("Hello!".to_string())
);
}
#[test]
fn test_stop_event_emitted_on_finish_reason() {
let mut mapper = LmStudioEventMapper::new();
let event = make_event(vec![make_stop_choice()], None);
let results: Vec<_> = mapper
.map_event(event)
.into_iter()
.map(|r| r.unwrap())
.collect();
assert_eq!(
results,
vec![LanguageModelCompletionEvent::Stop(StopReason::EndTurn)]
);
}
}
fn add_message_content_part(
new_part: lmstudio::MessagePart,
role: Role,

View file

@ -205,12 +205,19 @@ pub struct FunctionContent {
pub arguments: String,
}
#[derive(Serialize, Debug)]
pub struct StreamOptions {
pub include_usage: bool,
}
#[derive(Serialize, Debug)]
pub struct ChatCompletionRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,