Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions crates/jp_config/src/conversation/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,28 @@ impl PartialConfigDelta for PartialToolsConfig {

impl FillDefaults for PartialToolsConfig {
fn fill_from(self, defaults: Self) -> Self {
let tool_defaults = self.defaults.fill_from(defaults.defaults);

// A tool declaring any `style` field of its own resolves to a whole
// `DisplayStyleConfig`, at which point the fields it didn't set are
// indistinguishable from the ones it did. Fill the gaps from the `*`
// block here, while the partial still records what the tool asked for,
// so a single `[conversation.tools.'*'.style]` key reaches every tool.
let tools = self
.tools
.into_iter()
.map(|(name, mut tool)| {
tool.style = tool
.style
.map(|style| style.fill_from(tool_defaults.style.clone()));

(name, tool)
})
.collect();

Self {
defaults: self.defaults.fill_from(defaults.defaults),
tools: self.tools,
defaults: tool_defaults,
tools,
}
}
}
Expand Down Expand Up @@ -430,7 +449,8 @@ pub struct ToolConfig {

/// How to display the results of the tool in the terminal.
///
/// Overrides the global default.
/// Overrides the global default field by field: keys set here win, keys
/// left out take their value from `conversation.tools.'*'.style`.
/// The error overlay lives at `style.error.*` (see
/// [`DisplayStyleConfig::error`]).
#[setting(nested)]
Expand Down Expand Up @@ -1210,6 +1230,9 @@ impl ToolConfigWithDefaults {
}

/// Return the display style of the tool.
///
/// Fields the tool does not set carry the value from the global `*`
/// defaults, so the returned style is already fully resolved.
#[must_use]
pub fn style(&self) -> &DisplayStyleConfig {
self.tool.style.as_ref().unwrap_or(&self.defaults.style)
Expand Down
61 changes: 61 additions & 0 deletions crates/jp_config/src/conversation/tool_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,67 @@ fn access_on_local_tool_is_accepted_by_validation() {
assert!(build(partial).is_ok());
}

#[test]
fn tool_style_fills_unset_fields_from_the_global_defaults() {
use crate::{
PartialAppConfig,
conversation::tool::style::{InlineResults, PartialDisplayStyleConfig},
util::build,
};

let mut partial = PartialAppConfig::new_test();
partial.conversation.tools.defaults.style.hidden = Some(true);
partial
.conversation
.tools
.tools
.insert("my_tool".to_owned(), PartialToolConfig {
source: Some(ToolSource::Local { tool: None }),
style: Some(PartialDisplayStyleConfig {
inline_results: Some(InlineResults::Full),
..Default::default()
}),
..Default::default()
});

let config = build(partial).unwrap();
let tool = config.conversation.tools.get("my_tool").unwrap();

assert!(tool.style().hidden);
assert_eq!(tool.style().inline_results, InlineResults::Full);
}

#[test]
fn tool_style_overrides_the_global_default_per_field() {
use crate::{
PartialAppConfig,
conversation::tool::style::{InlineResults, PartialDisplayStyleConfig},
util::build,
};

let mut partial = PartialAppConfig::new_test();
partial.conversation.tools.defaults.style.hidden = Some(true);
partial.conversation.tools.defaults.style.inline_results = Some(InlineResults::Off);
partial
.conversation
.tools
.tools
.insert("my_tool".to_owned(), PartialToolConfig {
source: Some(ToolSource::Local { tool: None }),
style: Some(PartialDisplayStyleConfig {
hidden: Some(false),
..Default::default()
}),
..Default::default()
});

let config = build(partial).unwrap();
let tool = config.conversation.tools.get("my_tool").unwrap();

assert!(!tool.style().hidden);
assert_eq!(tool.style().inline_results, InlineResults::Off);
}

#[test]
fn test_enable_config_from_bool() {
assert_eq!(PartialEnableConfig::from(true), PartialEnableConfig::ON);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# T0011: Tool style does not inherit field-by-field from the '\*' defaults

- **Status**: Done
- **Kind**: Bug
- **Authors**: jp
- **Date**: 2026-08-11

`[conversation.tools.'*'.style]` is silently ignored for any tool that sets a
single style field of its own.

`ToolConfigWithDefaults::style` resolves the whole struct at once
(`crates/jp_config/src/conversation/tool.rs:1215`):

```rust
pub fn style(&self) -> &DisplayStyleConfig {
self.tool.style.as_ref().unwrap_or(&self.defaults.style)
}
```

Every neighbouring accessor fills field-by-field from the defaults.
`enable` goes through `Enable::effective`, and `run`, `result`, `format`, and
`cancellation_response` each `unwrap_or` a scalar.
Only `style` falls back as a unit.

## Why it bites

Setting a single style default across every tool looks like this and does
nothing:

```toml
[conversation.tools.'*'.style]
hidden = true
```

Every tool the skills enable already sets `style.inline_results`, so every one
of them takes its own style wholesale and drops the default.
The failure is silent: no error, no warning, the setting is simply absent from
the resolved config.

Found while hiding read-only tool calls for the RFD pipeline personas, which
ended up enumerating twenty-two tools by name because the one-line version does
not work.
That list will drift as tools are added.

## Fix

`DisplayStyleConfig` already implements `FillDefaults`, and
`PartialToolConfig::fill_from` already calls `style.fill_from(defaults.style)`
at partial-merge time.
The resolved-read path wants the same treatment: resolve the per-tool style
against the defaults per field rather than choosing one struct.

## Watch for

Whether anything currently relies on the whole-struct behaviour, for instance a
tool that sets one style field expecting the others to fall back to the
hardcoded defaults rather than to a `'*'` block.
Loading