JP Query Architecture
This document describes the technical architecture of the jp query command, the core component responsible for LLM interactions. The goal is to provide a foundation for refactoring toward better maintainability, decoupling, and testability.
Table of Contents
- Overview
- System Architecture
- Component Hierarchy
- Data Flow
- Core Components
- State Management
- Error Handling
- Dependencies & Coupling
- Testing Challenges
- Refactoring Opportunities
Overview
The jp query command orchestrates interactions between the user, LLM providers, and tools via the Model Context Protocol (MCP). It handles:
- User input collection (CLI args, editor, templates)
- Conversation state management (workspace persistence)
- Configuration merging (files, env vars, CLI flags)
- Message thread construction (system prompts, history, attachments)
- LLM provider communication (streaming, structured output)
- Tool execution (local commands, MCP servers)
- Response rendering (Markdown, code blocks, streaming)
The command operates as a single-threaded async state machine with side effects on disk (workspace persistence) and network (LLM/MCP calls).
System Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ CLI Entry Point │
│ (jp_cli::main.rs) │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Context Builder │
│ • Parse CLI args • Load workspace • Merge config │
│ • Create runtime • Init MCP client • Setup signals │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Query::run(ctx) │
│ (cmd/query.rs:233) │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────┴──────────────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Input Phase │ │ Output Phase │
│ • Editor │ │ • Streaming │
│ • Templates │ │ • Buffering │
│ • Attachments │ │ • Persistence │
└────────┬─────────┘ └────────▲─────────┘
│ │
▼ │
┌────────────────────────────────────────────────────┴─────────┐
│ Execution Loop │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. Build Thread (history + message + attachments) │ │
│ │ 2. Query LLM Provider (streaming) │ │
│ │ 3. Handle Events (content, reasoning, tool calls) │ │
│ │ 4. Execute Tools (if requested) │ │
│ │ 5. Loop Until Complete (recursion on tool results) │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘Component Hierarchy
Crate Structure
jp_cli/ Command-line interface
├─ cmd/query.rs Query command implementation (1,246 LOC)
│ ├─ event.rs Stream event handling (tool calls, chunks)
│ ├─ turn.rs Turn state (tool answers, retry count)
│ └─ response_handler.rs Terminal output rendering
└─ ctx.rs Shared context (workspace, config, MCP)
jp_llm/ LLM provider abstraction
├─ provider.rs Provider trait + implementations
│ ├─ anthropic.rs
│ ├─ openai.rs
│ ├─ google.rs
│ ├─ ollama.rs
│ └─ ...
├─ stream.rs Streaming event types
├─ query/ Query builders (chat, structured)
└─ tool.rs Tool definition + execution
jp_conversation/ Conversation data structures
├─ conversation.rs Conversation metadata
├─ message.rs Message types (user, assistant, pairs)
└─ thread.rs Thread builder (system + history + message)
jp_workspace/ State persistence
├─ lib.rs Workspace operations
├─ state.rs In-memory state
└─ query.rs Conversation queries
jp_config/ Configuration system
├─ lib.rs Config merging (files + env + CLI)
├─ assistant.rs Assistant settings (model, params, tools)
├─ conversation.rs Conversation settings (attachments, tools)
└─ providers/ Provider-specific config
jp_mcp/ Model Context Protocol client
└─ client.rs MCP server lifecycle + tool calls
jp_process/ Running a program to completion
├─ lib.rs ProcessRunner trait, ProcessSpec, ProcessOutput
├─ system.rs The runner that spawns real processes
└─ mock.rs Scripted runner for tests (`mock` feature)
jp_task/ Background task handling
└─ task/title_generator.rs Async title generationData Flow
Query Execution Pipeline
1. CLI Input → 2. Config Merge → 3. Thread Build → 4. LLM Stream → 5. Persist
┌─────────┐
│ User │
└────┬────┘
│ jp query --new --model gpt-4 "explain async"
▼
┌────────────────────────────────────────┐
│ Parse & Validate │
│ • Flag parsing (clap) │
│ • Template rendering (Jinja2) │
│ • Editor invocation (if no query) │
└──────────────────┬─────────────────────┘
│ UserMessage::Query(String)
▼
┌────────────────────────────────────────┐
│ Configuration Merge (Layered) │
│ 1. Default config │
│ 2. Global config (~/.config/jp/) │
│ 3. Workspace config (.jp/config.toml) │
│ 4. Conversation config (messages.json)│
│ 5. Environment variables (JP_CFG_*) │
│ 6. CLI flags (--model, --tool, etc.) │
└──────────────────┬─────────────────────┘
│ AppConfig
▼
┌────────────────────────────────────────┐
│ Thread Construction │
│ • System prompt (from config) │
│ • Instructions (tool usage, etc.) │
│ • Attachments (files, resources) │
│ • History (prior messages) │
│ • Current message (user query) │
└──────────────────┬─────────────────────┘
│ Thread
▼
┌────────────────────────────────────────┐
│ Tool Definition Resolution │
│ • Local tools (command execution) │
│ • MCP tools (fetch from servers) │
│ • Tool choice strategy (auto/manual) │
└──────────────────┬─────────────────────┘
│ ChatQuery
▼
┌────────────────────────────────────────┐
│ Provider Selection & Call │
│ • Get provider (id → impl) │
│ • Get model details (capabilities) │
│ • Stream completion (event loop) │
└──────────────────┬─────────────────────┘
│ EventStream
▼
┌────────────────────────────────────────┐
│ Event Processing Loop │
│ ┌──────────────────────────────────┐ │
│ │ StreamEvent::ChatChunk │ │
│ │ → ResponseHandler.handle() │ │
│ │ → Stdout (streamed/buffered) │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ StreamEvent::ToolCall │ │
│ │ → ToolDefinition.call() │ │
│ │ → Execute (local/MCP) │ │
│ │ → Collect result │ │
│ │ → RECURSE (with tool results) │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ StreamEvent::EndOfStream │ │
│ │ → Build MessagePair │ │
│ └──────────────────────────────────┘ │
└──────────────────┬─────────────────────┘
│ Vec<MessagePair>
▼
┌────────────────────────────────────────┐
│ State Persistence │
│ • Append messages to conversation │
│ • Save to .jp/conversations/ │
│ • Update active conversation ID │
│ • Trigger title generation (async) │
└────────────────────────────────────────┘Stream Event Flow
Provider::chat_completion_stream() returns Pin<Box<dyn Stream<Item = Result<StreamEvent>>>>
StreamEvent
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
ChatChunk ToolCall Metadata
│ │ │
┌────┴────┐ │ │
│ │ │ │
Content Reasoning │ │
│ │ │ │
└────┬────┘ │ │
│ │ │
▼ ▼ ▼
StreamEventHandler::handle_chat_chunk()
StreamEventHandler::handle_tool_call()
│ │
│ └─────────────────────┐
│ │
▼ ▼
ResponseHandler.handle() ToolDefinition.call()
│ │
├─ Parse Markdown ├─ Local: duct::cmd()
├─ Syntax highlight └─ MCP: client.call_tool()
├─ Typewriter effect │
└─ Stdout ▼
ToolCallResult
│
└─────────────┐
│
▼
RECURSE: handle_stream() with UserMessage::ToolCallResultsCore Components
1. Query Command (jp_cli::cmd::query)
Responsibilities:
- CLI argument parsing and validation
- Message input collection (args, editor, templates)
- Configuration merging via
IntoPartialAppConfigtrait - Orchestrating the entire query execution pipeline
- Error handling and cleanup
State:
Querystruct (CLI args only)TurnState(ephemeral per-turn state: retry count, tool answers)
Key Methods:
async fn run(self, ctx: &mut Ctx) -> Output
async fn build_message(&self, ctx: &mut Ctx, conversation_id: &ConversationId)
-> Result<(UserMessage, Option<PathBuf>)>
async fn handle_stream(&self, ctx: &mut Ctx, turn_state: &mut TurnState,
thread: Thread, tool_choice: ToolChoice, tools: Vec<ToolDefinition>,
messages: &mut Vec<MessagePair>) -> Result<()>Problems:
- 1,246 lines of code in a single file
- Deep nesting (6+ levels of indentation in
handle_stream) - Recursive async functions (Box::pin workaround)
- Mixes concerns (input, execution, output, persistence)
- Hard to test (requires full
Ctxwith real I/O)
2. Context (jp_cli::ctx::Ctx)
Responsibilities:
- Shared state container for the entire CLI session
- Owns workspace, config, MCP client, task handler, signals
- Provides immutable config access after initialization
State:
pub struct Ctx {
pub workspace: Workspace,
config: AppConfig,
pub term: Term,
pub mcp_client: jp_mcp::Client,
pub task_handler: TaskHandler,
pub signals: SignalPair,
runtime: Runtime,
}Problems:
- God object (all commands depend on it)
- No clear lifecycle (created once, mutated throughout)
- Mixes concerns (config, I/O, async runtime)
- Makes unit testing impossible (needs real workspace)
3. LLM Provider (jp_llm::provider)
Responsibilities:
- Abstract LLM provider API differences
- Stream response events (content, reasoning, tool calls, metadata)
- Handle provider-specific features (reasoning, structured output)
Interface:
#[async_trait]
pub trait Provider: Debug + Send + Sync {
async fn model_details(&self, name: &Name) -> Result<ModelDetails>;
async fn models(&self) -> Result<Vec<ModelDetails>>;
async fn chat_completion_stream(&self, model: &ModelDetails,
parameters: &ParametersConfig, query: ChatQuery) -> Result<EventStream>;
async fn structured_completion(&self, model: &ModelDetails,
parameters: &ParametersConfig, query: StructuredQuery) -> Result<Value>;
}Problems:
- Trait cannot be mocked easily (async_trait)
- Provider selection is global (
get_provider(id, config)) - Error types are provider-specific but wrapped generically
- Reasoning extraction is post-hoc (parsed from stream)
4. Stream Event Handler (jp_cli::cmd::query::event)
Responsibilities:
- Accumulate streamed tokens into complete messages
- Distinguish reasoning from content tokens
- Execute tool calls (blocking async calls mid-stream)
- Prompt user for tool execution confirmation
State:
pub struct StreamEventHandler {
pub reasoning_tokens: String,
pub content_tokens: String,
pub tool_calls: Vec<ToolCallRequest>,
pub tool_call_results: Vec<ToolCallResult>,
}Problems:
- Mutable state accumulated during streaming
- Blocks stream processing for tool execution
- User prompts during streaming (synchronous I/O)
- Turn-level state (persisted answers) lives elsewhere
5. Response Handler (jp_cli::cmd::query::response_handler)
Responsibilities:
- Render Markdown with syntax highlighting
- Apply typewriter effect for streaming
- Handle code block extraction and file saving
- Generate terminal hyperlinks (OSC 8)
State:
pub struct ResponseHandler {
pub render_mode: RenderMode,
pub render_tool_calls: bool,
received: Vec<String>,
pub parsed: Vec<String>,
pub buffer: String,
in_fenced_code_block: bool,
code_buffer: (Option<String>, Vec<String>),
// ...
}Problems:
- Stateful line-by-line parsing (context-dependent)
- Mixes rendering logic with file I/O (saving code blocks)
- Handles both streaming and buffered modes differently
- Markdown parser state is implicit (
jp_mdintegration)
6. Tool System (jp_llm::tool, jp_tool)
Responsibilities:
- Define tool schemas (parameters, descriptions)
- Execute tools (local commands, MCP calls)
- Handle tool prompts (confirmation, questions, editing)
- Return results to LLM
Flow:
ToolConfig → ToolDefinition → ToolDefinition.call() → ToolCallResult
↓
(Local: duct::cmd)
(MCP: mcp_client.call_tool)
↓
Question/Answer
(jp_tool::Question)
↓
inquire::prompt (blocking!)Problems:
- Tool execution is synchronous and blocks event stream
- Local tool commands use shell execution (security concern)
- MCP tool parameters are dynamically merged (schema drift)
- Tool prompts (confirmation, editing) use blocking I/O
7. Workspace (jp_workspace)
Responsibilities:
- Manage conversation lifecycle
- Persist messages and metadata to disk
- Track active conversation
Structure:
.jp/
├─ conversations/
│ ├─ <conversation-id>/
│ │ ├─ metadata.json
│ │ └─ messages.json
├─ state.json
└─ config.tomlProblems:
- File I/O happens in
Drop(implicit, error-prone) - No transaction semantics (partial writes on crash)
- Active conversation is special-cased (stored separately)
- No versioning or migration strategy
State Management
Configuration Layers
Configuration merges happen in strict order, with later layers overriding earlier ones:
1. Default (compiled into binary)
2. Global (~/.config/jp/config.toml)
3. Workspace (.jp/config.toml)
4. Extended (config.d/**/* glob)
5. Conversation (per-message, in messages.json)
6. Environment (JP_CFG_*)
7. CLI flags (--model, --tool, etc.)Implementation:
// Merge happens via PartialConfig trait
let mut partial = PartialAppConfig::empty();
partial = load_file_config(partial, global_path)?;
partial = load_file_config(partial, workspace_path)?;
partial = load_extends(partial, extends_paths)?;
partial = load_conversation_config(partial, messages)?;
partial = PartialAppConfig::from_envs()?;
partial = Query::apply_cli_config(self, None, partial, Some(&partial))?;
let config = partial.finalize()?;Problems:
- Order-dependent merging (no declarative precedence)
- Partial config is mutated in-place (hard to trace)
- CLI args are applied twice (once for validation, once for merge)
- No way to inspect "what config came from where"
Conversation State
State {
local: LocalState {
active_conversation: Conversation,
conversations: TombMap<ConversationId, Conversation>,
messages: HashMap<ConversationId, Messages>,
},
user: UserState {
conversations_metadata: ConversationsMetadata {
active_conversation_id: ConversationId,
},
},
}Lifecycle:
- Load from disk on
Workspace::load() - Mutate in-memory during command execution
- Persist to disk on
Workspace::persist()(explicit) orDrop
Problems:
- Active conversation is stored separately (prevents removal)
- No event sourcing (can't replay conversation)
- Metadata is split (Conversation vs Messages)
- Persistence is all-or-nothing (no incremental saves)
Turn State
pub struct TurnState {
pub persisted_tool_answers: IndexMap<String, IndexMap<String, Value>>,
pub request_count: usize,
}Ephemeral state for a single query turn. Tracks:
- Tool answers to reuse (e.g., "use this answer for all calls")
- Retry count (for rate limit handling)
Problems:
- Lives outside
Querystruct (passed as&mut) - Cleared on recursion (nested tool calls lose state)
- No persistence across queries (user must re-answer)
Error Handling
Error Types
jp_cli::Error
├─ Llm(jp_llm::Error)
├─ Config(jp_config::Error)
├─ Workspace(jp_workspace::Error)
├─ Mcp(jp_mcp::Error)
├─ ToolError(jp_llm::ToolError)
├─ Io(std::io::Error)
└─ Custom(String)Error Propagation
Errors bubble up with ? operator, wrapped in crate-specific types. Most errors terminate the command and print to stderr.
Special Cases:
- Rate limits: Retry with exponential backoff (handled in
handle_event) - Empty responses: Retry with modified prompt (handled in
handle_stream) - Tool errors: Return as
ToolCallResultwitherror: true - User cancellation: Signal handling via
SignalPair
Problems:
- No error context (source location, trace)
- Mixed error types (some retryable, some fatal)
- Errors in
Dropare printed to stderr (not propagated) - Tool errors are swallowed and returned to LLM
Dependencies & Coupling
Dependency Graph
jp_cli
├─ jp_workspace
│ ├─ jp_conversation
│ ├─ jp_storage
│ └─ jp_config
├─ jp_llm
│ ├─ jp_conversation
│ └─ jp_config
├─ jp_mcp
│ ├─ jp_process
│ └─ rmcp (external)
├─ jp_process
├─ jp_config
│ └─ schematic (external)
├─ jp_task
└─ jp_termTight Coupling Points
1. Query → Ctx (God Object)
- Every operation requires
&mut Ctx - No interfaces, direct field access
- Cannot test without full workspace
2. Config → Everything
- Config is read by all components
- Deeply nested structure (config.assistant.model.parameters)
- No trait boundaries (concrete types everywhere)
3. Tool Execution → MCP Client
- Tool calls block event stream
- No abstraction over local vs MCP
- User prompts are synchronous
4. Persistence → Workspace
- Implicit on
Drop(side effects) - No dependency injection
- Cannot test without file I/O
Circular Dependencies
None at the crate level, but conceptual cycles exist:
Query needs Config to build Thread
Config needs Query (for CLI overrides)
→ Solved via trait (IntoPartialAppConfig)
LLM needs Tools for schema
Tools need LLM for execution
→ Solved via indirection (ToolDefinition)Testing Challenges
Current Test Coverage
- Unit tests: Minimal (mostly config parsing, serialization)
- Integration tests: None for query command
- Property tests: None
Why Testing is Hard
1. Global State Dependencies
// Cannot construct without real workspace
async fn test_query() {
let workspace = Workspace::new("/tmp/test").persisted()?;
let config = AppConfig::default();
let mut ctx = Ctx::new(workspace, runtime, args, config);
// Now what? Need real LLM API keys...
}2. External I/O
- File system (workspace persistence)
- Network (LLM APIs, MCP servers)
- Terminal (stdout, stdin for prompts)
- Editor (spawns external process)
3. Async Complexity
- Streaming requires tokio runtime
- Tool calls are nested async
- Signals and cancellation
4. Non-deterministic Behavior
- LLM responses vary
- Timestamps in conversation IDs
- Race conditions (background tasks)
5. Tightly Coupled Code
// Cannot mock because concrete types
let provider = provider::get_provider(id, &ctx.config().providers.llm)?;
let stream = provider.chat_completion_stream(&model, parameters, query).await?;
// Cannot inject test doubles
self.handle_stream(ctx, turn_state, thread, tool_choice, tools, messages).await?;Refactoring Opportunities
1. Extract Command Phases
Split Query::run() into discrete phases with clear boundaries:
pub struct QueryCommand {
input: InputPhase,
execution: ExecutionPhase,
output: OutputPhase,
persistence: PersistencePhase,
}
impl QueryCommand {
pub async fn run(self, ctx: &Ctx) -> Result<Output> {
let message = self.input.collect(ctx)?;
let response = self.execution.execute(ctx, message).await?;
self.output.render(ctx, &response)?;
self.persistence.save(ctx, response)?;
Ok(Output::Ok)
}
}Benefits:
- Each phase is testable in isolation
- Clear separation of concerns
- Can replace phases for testing (stub output, mock LLM)
2. Introduce Provider Abstraction
Replace direct provider calls with a trait object:
#[async_trait]
pub trait LlmClient: Send + Sync {
async fn stream(&self, query: ChatQuery) -> Result<EventStream>;
}
pub struct RealLlmClient {
provider: Box<dyn Provider>,
model: ModelDetails,
}
pub struct MockLlmClient {
responses: Vec<StreamEvent>,
}
// In tests
let client = Box::new(MockLlmClient::new(vec![
StreamEvent::ChatChunk(CompletionChunk::Content("Hello".into())),
StreamEvent::EndOfStream(StreamEndReason::Stop),
]));Benefits:
- Can inject test doubles
- Provider selection is encapsulated
- Easier to add new providers
3. Decouple Tool Execution
Move tool execution out of event stream handling:
pub trait ToolExecutor: Send + Sync {
async fn execute(&self, call: ToolCallRequest) -> Result<ToolCallResult>;
}
pub struct RealToolExecutor {
definitions: HashMap<String, ToolDefinition>,
mcp_client: Arc<jp_mcp::Client>,
}
pub struct MockToolExecutor {
results: HashMap<String, ToolCallResult>,
}
// In event handler
let result = executor.execute(call).await?;
// No blocking prompts in streamBenefits:
- Tools can be async without blocking stream
- Executor can be swapped (test, dry-run, parallel)
- User prompts can be decoupled (pre-approved, config-driven)
4. Make Context Injectable
Replace Ctx with focused traits:
pub trait ConfigProvider {
fn config(&self) -> &AppConfig;
}
pub trait WorkspaceProvider {
fn workspace(&self) -> &Workspace;
fn workspace_mut(&mut self) -> &mut Workspace;
}
pub trait McpProvider {
fn mcp_client(&self) -> &jp_mcp::Client;
}
// Query only needs what it uses
impl Query {
async fn run<C, W, M>(
self,
config: &C,
workspace: &mut W,
mcp: &M,
) -> Result<Output>
where
C: ConfigProvider,
W: WorkspaceProvider,
M: McpProvider,
{
// ...
}
}Benefits:
- Clear dependencies (documented in trait bounds)
- Easier to mock (implement trait for test struct)
- Prevents leaking access to unrelated state
5. Event-Driven Architecture
Replace recursive handle_stream with event loop:
pub enum QueryEvent {
StreamChunk(CompletionChunk),
ToolCallRequested(ToolCallRequest),
ToolCallCompleted(ToolCallResult),
StreamEnded(StreamEndReason),
}
pub struct QueryStateMachine {
state: QueryState,
messages: Vec<MessagePair>,
}
impl QueryStateMachine {
pub fn handle(&mut self, event: QueryEvent) -> Result<Action> {
match (&self.state, event) {
(QueryState::Streaming, QueryEvent::StreamChunk(chunk)) => {
// Accumulate
Ok(Action::Continue)
}
(QueryState::Streaming, QueryEvent::ToolCallRequested(call)) => {
// Transition
self.state = QueryState::ExecutingTool(call);
Ok(Action::ExecuteTool)
}
// ...
}
}
}Benefits:
- No recursion (easier to reason about)
- State machine is explicit (can visualize)
- Events can be recorded/replayed (debugging, testing)
6. Separate Rendering from Processing
Move ResponseHandler to a pure function:
pub fn render_markdown(
input: &str,
config: &StyleConfig,
mode: RenderMode,
) -> Result<RenderedOutput> {
// Pure function, no state
}
pub struct RenderedOutput {
pub lines: Vec<String>,
pub code_blocks: Vec<CodeBlock>,
pub links: Vec<Hyperlink>,
}Benefits:
- Testable without terminal
- Can render offline (e.g., in web UI)
- Easier to add new output formats (JSON, HTML)
7. Introduce Repository Pattern
Wrap workspace operations:
pub trait ConversationRepository {
fn get(&self, id: &ConversationId) -> Option<&Conversation>;
fn save(&mut self, id: ConversationId, conv: Conversation) -> Result<()>;
fn delete(&mut self, id: &ConversationId) -> Result<()>;
fn add_message(&mut self, id: ConversationId, msg: MessagePair) -> Result<()>;
}
pub struct WorkspaceRepository {
workspace: Workspace,
}
pub struct InMemoryRepository {
conversations: HashMap<ConversationId, Conversation>,
}Benefits:
- Persistence logic is isolated
- Can swap storage (SQLite, cloud)
- Easy to test without disk I/O
8. Configuration Builder Pattern
Replace partial config mutation:
pub struct ConfigBuilder {
layers: Vec<ConfigLayer>,
}
impl ConfigBuilder {
pub fn add_file(mut self, path: &Path) -> Result<Self> {
self.layers.push(ConfigLayer::File(path.into()));
Ok(self)
}
pub fn add_env_vars(mut self) -> Result<Self> {
self.layers.push(ConfigLayer::Env);
Ok(self)
}
pub fn add_cli_args(mut self, args: CliArgs) -> Result<Self> {
self.layers.push(ConfigLayer::Cli(args));
Ok(self)
}
pub fn build(self) -> Result<AppConfig> {
// Merge all layers in order
}
}
// In tests
let config = ConfigBuilder::new()
.add_cli_args(test_args)
.build()?;Benefits:
- Explicit layer ordering
- Can inspect "where did this value come from?"
- Easy to test specific merge scenarios
Summary
The jp query command is the heart of the application, but suffers from:
Architectural Issues:
- God object pattern (
Ctx) - Deep nesting and tight coupling
- Mixed concerns (I/O, logic, rendering)
- Implicit dependencies (global state)
Testing Issues:
- Cannot unit test in isolation
- Requires real I/O (file, network, terminal)
- Non-deterministic (LLM, timestamps)
- Async complexity
Maintainability Issues:
- 1,246 LOC in one file
- Recursive async functions
- Stateful rendering (context-dependent)
- Implicit error handling
Refactoring toward testability requires:
- Dependency injection (traits over concrete types)
- Pure functions (side effects at boundaries)
- Event-driven design (explicit state machine)
- Repository pattern (storage abstraction)
This document serves as a map for incremental refactoring. Each opportunity can be addressed independently, with tests added to prevent regressions.
SummaryThe jp query command stack as it stood in March 2026, and the coupling and testing problems the query stream pipeline set out to fix.
AuthorsJean Mertz
Date2026-03-05