A cleared conversation field is refilled from the file layer on the next invocation
- Status: Done
- Kind: Bug
- Authors: jp
- Date: 2026-09-10
- Label: client=cli
- Label: domain=conversation
- Label: package=jp_cli
- Label: package=jp_config
- Label: package=jp_conversation
- Label: type=bug
jp query --cfg assistant.name=null clears the field for that turn. The next jp query on the same conversation runs with the workspace's value again, and writes a config delta restoring it, so the clear is undone permanently rather than merely forgotten.
Any field a config file also declares behaves this way. A field only the conversation ever set stays clear, which is why the delta-level tests pass.
Why
The stream is correct. A delta carries the cleared path in unsets, fold_config_delta applies it before merging, and ConversationStream::config resolves the field to None.
The invocation pipeline then puts the value back. Query::apply_conversation_config (crates/jp_cli/src/cmd/query.rs:2392) turns the stream's resolved config into a partial, where the cleared field is None. ConfigPipeline::partial_with_conversation (crates/jp_cli/src/config_pipeline.rs:322) then calls conversation.fill_from(self.base.clone()), and filling reads None as "this layer says nothing". PartialAssistantConfig::fill_from (crates/jp_config/src/assistant.rs:166) is self.name.or(defaults.name), so the file layer's value wins.
Nothing in config_pipeline.rs mentions unset: the cleared paths are consumed by the fold and never cross into the pipeline.
The reversal then persists. get_config_delta_from_cli diffs the stream (None) against the runtime config (Some("Bot")), delta_opt_at takes its (None, next) => next arm, and a delta setting the field back is recorded.
Scope
Not a regression. Before deltas could express a clear, the clear was never recorded and the outcome was the same value on the next turn. What changed is where it is lost: the stream now holds the clear, and the layer above it overrides.
The fill semantics themselves are right, and are what stop an appending list from doubling when the conversation snapshot merges over the layer it came from. The gap is that a partial cannot distinguish "cleared" from "not stated", which is the same distinction unsets exists to carry.
Fix
Carry the conversation's cleared paths across the boundary.
ConversationStream grows an accumulator over its deltas' unsets — a path cleared by one delta and set by a later one is no longer cleared — and partial_with_conversation applies what it reports after fill_from, before --cfg merges on top. A --cfg that sets the field in the same invocation still wins, which is what a user typing it expects.
Verifying
A two-invocation regression through the pipeline, which is the boundary the jp_config sweep in delta_law_tests.rs cannot reach: a workspace config naming assistant.name, one query with --cfg assistant.name:=null, then a second with no flag. Assert the resolved name is still clear, and that the second query wrote no delta restoring it.
Comments
- From: jp
- Date: 2026-09-10T18:10:17Z
Implemented ConversationStream::config_unsets() to report cleared paths that remain absent after folding the stream, excluding clears before a reset and paths with replacement values. The conversation-config hook carries those paths alongside its partial; ConfigPipeline reapplies them after base-layer filling and before --cfg directives.
The regression calls the production resolve_config and turn_config_delta paths for successive invocations. It failed before the fix with Some("Bot") on the second invocation. It passes with the name still None and no second config delta. Additional tests cover later sets, same-delta replacements, resets, removed map entries, duplicate/unknown paths, and CLI override precedence.
Validation: full jp_cli and jp_conversation test suites pass; scoped Clippy checks pass without warnings; formatting and the diff were reviewed.