diff --git a/crates/jp_cli/src/config_pipeline.rs b/crates/jp_cli/src/config_pipeline.rs index 642fee195..fa3782511 100644 --- a/crates/jp_cli/src/config_pipeline.rs +++ b/crates/jp_cli/src/config_pipeline.rs @@ -323,6 +323,12 @@ impl ConfigPipeline { unsets: &[String], ) -> Result { let mut partial = conversation.fill_from(self.base.clone()); + if !unsets.is_empty() { + debug!( + ?unsets, + "Conversation clears suppressing base-layer values." + ); + } for path in unsets { if let Err(error) = partial.unset(path) { warn!(%path, %error, "Ignoring a config delta unset for an unknown field."); diff --git a/crates/jp_conversation/src/stream.rs b/crates/jp_conversation/src/stream.rs index 919997514..f362866c9 100644 --- a/crates/jp_conversation/src/stream.rs +++ b/crates/jp_conversation/src/stream.rs @@ -233,11 +233,12 @@ impl ConversationStream { Ok(partial) } - /// Dotted config paths explicitly cleared and not subsequently set. + /// Dotted config paths explicitly cleared and still absent from the + /// accumulated state. /// /// Reset deltas discard earlier clears. - /// A clear followed by a replacement in the same apply delta is not - /// included. + /// A path the accumulated state holds a value at is not included, whether + /// the value arrived in the clearing delta or a later one. /// Unknown paths are ignored. /// /// # Errors @@ -252,6 +253,12 @@ impl ConversationStream { } } + // Nothing cleared means nothing to probe, and the fold below is a full + // merge per delta in the stream. + if unsets.is_empty() { + return Ok(Vec::new()); + } + let partial = self.config_partial()?; Ok(unsets .into_iter() diff --git a/crates/jp_conversation/src/stream_tests.rs b/crates/jp_conversation/src/stream_tests.rs index 61303e149..33967fe2a 100644 --- a/crates/jp_conversation/src/stream_tests.rs +++ b/crates/jp_conversation/src/stream_tests.rs @@ -187,6 +187,18 @@ fn a_delta_that_only_clears_is_recorded() { assert!(resolved_arguments(&stream).is_empty()); } +/// A stream that never cleared anything reports nothing, deltas or not. +#[test] +fn config_unsets_is_empty_without_a_clearing_delta() { + let mut stream = stream_with_server(&["serve"]); + stream.add_config_delta(ApplyDelta::new( + delta_timestamp(), + server_arguments_partial(&["run"]), + )); + + assert!(stream.config_unsets().unwrap().is_empty()); +} + #[test] fn config_unsets_keeps_clears_across_unrelated_deltas() { let mut stream = stream_with_server(&["serve"]); diff --git a/docs/.vitepress/rfd-summaries.json b/docs/.vitepress/rfd-summaries.json index 115fbe1c1..40c2dfa5e 100644 --- a/docs/.vitepress/rfd-summaries.json +++ b/docs/.vitepress/rfd-summaries.json @@ -292,7 +292,7 @@ "summary": "UPPERCASE keywords NONE and WORKSPACE reset config state; loader.reset entry setting provides local resets; ConfigDelta becomes enum to persist resets." }, "079-config-sources-and-load-order.md": { - "hash": "f6dc6f90d9a17bd469d82db2b8c4998f43cd8d126e774dc117cc4a9337978321", + "hash": "1a9ca633586e826b8ecd5a44d5412e811eb26c3515d270a97c43b9b88e93e7fe", "summary": "JP loads config from four implicit sources plus environment variables, with extends directives and inherit flags controlling precedence." }, "074-eager-loading-with-command-declared-data-requirements.md": { diff --git a/docs/configuration.md b/docs/configuration.md index a41ea1967..44e3de0be 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,6 +225,23 @@ Set configuration in conversation This appends a delta to the conversation's event stream, leaving earlier turns untouched. +An option is cleared by querying with it set to `null`: + +```bash +$ jp query --id --cfg assistant.name:=null "..." +``` + +A cleared option stays cleared for the rest of the conversation. +Later turns run without it even when a configuration file sets that option. +The conversation records the option as empty, and the file layer cannot fill it +back in. + +Two things retire a clear: + +- Setting a value at the same path, such as `--cfg assistant.name=Bot`. +- A reset (`--cfg=WORKSPACE`), which discards everything the conversation + accumulated, including its clears. + To edit the files directly, use `jp conversation edit`: ```bash diff --git a/docs/rfd/079-config-sources-and-load-order.md b/docs/rfd/079-config-sources-and-load-order.md index e87800e2f..791af7c82 100644 --- a/docs/rfd/079-config-sources-and-load-order.md +++ b/docs/rfd/079-config-sources-and-load-order.md @@ -255,6 +255,9 @@ init`), the sequence is: 11. The result is the **base partial** for this invocation. 12. Load the conversation's `base_config.json` and event-stream `ConfigDelta`s (for continuing or forking invocations only). + A path those deltas cleared and never set again ([RFD 070]) is also cleared + from the base partial, so the file layer cannot restore it. + Step 13 can set it again. 13. Apply `--cfg` and `--no-cfg` ([RFD 038]) directives left-to-right ([RFD 008]). 14. Apply CLI shortcut flags (`--model`, `--reasoning`, etc.). @@ -278,6 +281,7 @@ pipeline may care about; see `ConfigPipeline::partial_without_conversation` and `config_load_paths` resolution across roots. - [RFD 054]: Split Conversation Config and Events — how `base_config.json` and event-stream `ConfigDelta`s are structured. +- [RFD 070]: Negative Config Deltas — how a delta records a cleared path. - `crates/jp_cli/src/lib.rs` — `load_partial_configs_from_files` and `load_base_partial`. - `crates/jp_config/src/util.rs` — `load_partials_with_inheritance`, @@ -290,4 +294,5 @@ pipeline may care about; see `ConfigPipeline::partial_without_conversation` and [RFD 035]: 035-multi-root-config-load-path-resolution.md [RFD 038]: 038-config-reset-keywords.md [RFD 054]: 054-split-conversation-config-and-events.md +[RFD 070]: 070-negative-config-deltas.md [RFD 080]: 080-editor-as-a-config-source.md diff --git a/docs/ticket/0kwgjda-no-surface-lists-the-config-options-a-conversation-has-clear.md b/docs/ticket/0kwgjda-no-surface-lists-the-config-options-a-conversation-has-clear.md new file mode 100644 index 000000000..2ed45aaed --- /dev/null +++ b/docs/ticket/0kwgjda-no-surface-lists-the-config-options-a-conversation-has-clear.md @@ -0,0 +1,51 @@ +# No surface lists the config options a conversation has cleared + +- **Status**: Todo +- **Kind**: Feature +- **Authors**: jp +- **Date**: 2026-09-16 +- **Label**: client=cli +- **Label**: domain=conversation +- **Label**: package=jp_cli +- **Label**: type=enhancement + +A conversation can clear a config option (`jp query --cfg +assistant.name:=null`), and the clear holds for the rest of the conversation: +the file layer cannot restore the option, because the pipeline reapplies the +stream's cleared paths after filling from the base. + +Nothing reports which paths a conversation currently holds clear. + +`jp config show` resolves the file, env, and `--cfg` layers only — +`Commands::Config(_)` returns an empty conversation layer +(`crates/jp_cli/src/cmd.rs:200-207`), and `cmd/config/show.rs` never touches a +conversation. +So the option reads as absent, with no indication that a conversation-level +clear is what holds it there. +The only trace is the `unsets` array inside the stored event stream, reachable +through `jp conversation edit --events`. + +The named input: a user clears `assistant.name` in a conversation, sets +`assistant.name` in `.jp/config.toml` a week later, and has no way to find out +why that conversation ignores it. +`RUST_LOG=debug` logs the suppressed paths at the moment they apply, which helps +a user who already suspects the cause, but no read surface answers the question +directly. + +## Shape + +Two candidates, not exclusive: + +- Teach `jp config show` to apply the conversation layer when given `--id`, and + mark cleared paths distinctly from unset ones. +- Report them in `jp conversation show`, alongside the conversation's other + stored state. + +`ConversationStream::config_unsets()` already computes this list, so either +surface is a read away. + +Related: [RFD 060] (Config Explain) would subsume this if it lands first — a +per-field provenance display answers "what is holding this field empty?" as a +special case. + +[RFD 060]: https://jp.computer/rfd/060 diff --git a/docs/ticket/0kwgqkx-jp-config-set-reports-success-for-a-clear-it-never-records.md b/docs/ticket/0kwgqkx-jp-config-set-reports-success-for-a-clear-it-never-records.md new file mode 100644 index 000000000..1372b6ab2 --- /dev/null +++ b/docs/ticket/0kwgqkx-jp-config-set-reports-success-for-a-clear-it-never-records.md @@ -0,0 +1,60 @@ +# jp config set reports success for a clear it never records + +- **Status**: Todo +- **Kind**: Bug +- **Authors**: jp +- **Date**: 2026-09-16 +- **Label**: client=cli +- **Label**: domain=conversation +- **Label**: package=jp_cli +- **Label**: type=bug + +`jp config set --id --cfg assistant.name:=null` prints `Set configuration +in conversation ` and records nothing. +The same argument through `jp query` records a delta whose `unsets` holds +`assistant.name`, and the option stays cleared for the rest of the conversation. + +## Why + +`Set::run` builds its payload with +`config_pipeline::build_partial_from_cfg_args` +(`crates/jp_cli/src/cmd/config/set.rs:28`), which applies the arguments onto +`PartialAppConfig::empty()` (`config_pipeline.rs:578`). +A `:=null` assignment clears a field, so clearing a field of an already-empty +partial produces an empty partial: the payload carries no trace of what the user +asked to clear. + +`override_to_record` then merges that empty partial onto the conversation's +accumulated state, resolves both sides, finds them equal, and returns `None` +(`config_pipeline.rs:64-78`), so no `ConfigDelta` is appended. +The success line is printed unconditionally afterwards (`set.rs:68-69`). + +The file target has the same hole: `set_in_file` merges the empty partial into +the config file and writes it back unchanged (`set.rs:85-90`). + +A partial cannot express "cleared" — that is what `ApplyDelta::unsets` exists +for ([RFD 070]). +The query path reaches it through `turn_config_delta`/`delta_with_unsets` +(`cmd/query.rs:2333-2351`); the `config set` path has no equivalent. + +## Fix + +Carry the cleared paths alongside the partial, the way the conversation layer +now does: have `build_partial_from_cfg_args` report the paths its arguments +cleared, and have `set_in_conversations` pass them to `ApplyDelta::with_unsets` +so a clear becomes a recorded delta. +For the file target, a clear means removing the key from the file, which +`ConfigFile`'s format-preserving edit already knows how to express. + +Failing either, the command should refuse `:=null` with an error naming the +working alternative, rather than reporting a change it did not make. + +## Verifying + +A test asserting that `jp config set --id --cfg assistant.name:=null` +leaves the conversation resolving `assistant.name` to `None` on the next +invocation, mirroring +`resolve_config_keeps_a_cleared_conversation_field_across_invocations` in +`crates/jp_cli/src/cmd/query_tests.rs`. + +[RFD 070]: https://jp.computer/rfd/070