Merge pull request #74 from patherly/feat/command-output-capture

feat: command output capture for troubleshooting sessions
This commit was merged in pull request #74.
This commit is contained in:
chihlasm
2026-02-12 01:04:15 -05:00
committed by GitHub
7 changed files with 413 additions and 3 deletions

View File

@@ -133,6 +133,10 @@ def _create_node_from_original(
new_node["action"] = original_node.get("action", "")
if decision and decision.get("action_performed"):
new_node["action"] = decision["action_performed"]
if decision and decision.get("command_output"):
output = decision["command_output"].strip()
if output:
new_node["action"] += f"\n\nCommand Output:\n{output}"
elif node_type == "solution":
new_node["solution"] = original_node.get("solution", "")

View File

@@ -28,6 +28,7 @@ class DecisionRecord(BaseModel):
answer: Optional[str] = None
action_performed: Optional[str] = None
notes: Optional[str] = None
command_output: Optional[str] = Field(None, max_length=10000)
automation_used: Optional[bool] = False
timestamp: datetime
entered_at: Optional[datetime] = None

View File

@@ -81,6 +81,30 @@ def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
return None
def _get_command_output(decision: dict[str, Any]) -> str | None:
"""Extract and normalize command_output from a decision dict."""
output = decision.get("command_output")
if not isinstance(output, str):
return None
output = output.strip()
return output if output else None
def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]:
"""Find the commands list for a node in the tree snapshot."""
def _search(node: dict[str, Any]) -> list[str] | None:
if node.get("id") == node_id:
return node.get("commands") or []
for child in node.get("children", []):
result = _search(child)
if result is not None:
return result
return None
structure = tree_snapshot.get("tree_structure") or tree_snapshot
return _search(structure) or []
def _get_outcome_label(session: Session) -> str | None:
"""Map stored outcome enum to human-friendly label."""
outcome = getattr(session, "outcome", None)
@@ -137,6 +161,14 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if command_output := _get_command_output(decision):
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
if commands:
lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}")
lines.append("**Output:**")
lines.append("```")
lines.append(command_output)
lines.append("```")
if duration_seconds is not None:
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
if options.include_timestamps and decision.get("timestamp"):
@@ -190,6 +222,13 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
if command_output := _get_command_output(decision):
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
if commands:
lines.append(f" Commands Run: {', '.join(commands)}")
lines.append(" Output:")
for output_line in command_output.splitlines():
lines.append(f" {output_line}")
if duration_seconds is not None:
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
@@ -253,6 +292,12 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if command_output := _get_command_output(decision):
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
if commands:
cmd_html = ", ".join(f"<code>{html.escape(c)}</code>" for c in commands)
html_parts.append(f'<p><strong>Commands Run:</strong> {cmd_html}</p>')
html_parts.append(f'<pre><code>{html.escape(command_output)}</code></pre>')
if duration_seconds is not None:
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
if options.include_timestamps and decision.get("timestamp"):
@@ -304,6 +349,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
if command_output := _get_command_output(decision):
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
if commands:
lines.append(f" Commands: {', '.join(commands)}")
lines.append(" Output:")
for output_line in command_output.splitlines():
lines.append(f" {output_line}")
else:
lines.append("No steps recorded.")
lines.append("")

View File

@@ -0,0 +1,239 @@
# Issue #57: Command Output Capture — Implementation Plan
## Overview
Engineers run commands during troubleshooting sessions but the output is lost — exports say "ran this command" but not what it returned. This feature adds a "Paste Output" textarea on action nodes and custom action steps so command output is captured in session data and included in all exports and session review.
**Scope:** Built-in action nodes AND custom action steps.
**Migration:** None required — `decisions` is already a JSONB array with flexible dict entries.
---
## Public Interfaces / Type Changes
- **Backend:** Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord` in `session.py`
- **Frontend:** Add `command_output?: string | null` to `DecisionRecord` type in `session.ts`
- **API:** No endpoint changes — `PUT /api/v1/sessions/{id}` continues to accept full decisions array; now includes optional `command_output`
- **Validation:** Backend enforces 10,000 character hard limit (returns 422 on overflow); frontend shows live character count
---
## Files to Modify
### Backend (3 files)
| File | Change |
|------|--------|
| `backend/app/schemas/session.py` | Add `command_output` field to `DecisionRecord` |
| `backend/app/services/export_service.py` | Render `command_output` in all 4 export formats with command context |
| `backend/app/core/session_to_tree.py` | Include command output when converting session decisions to forked tree nodes |
### Frontend (3 files)
| File | Change |
|------|--------|
| `frontend/src/types/session.ts` | Add `command_output` to `DecisionRecord` type |
| `frontend/src/pages/TreeNavigationPage.tsx` | Add capture UI for both built-in action nodes and custom action steps |
| `frontend/src/pages/SessionDetailPage.tsx` | Render `command_output` in decision review and clipboard copy |
---
## Implementation Steps
### Step 1: Backend Schema
**File:** `backend/app/schemas/session.py`
- Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord`
- The `max_length=10000` provides backend validation — requests exceeding this return 422
### Step 2: Frontend Type
**File:** `frontend/src/types/session.ts`
- Add `command_output?: string | null` to the `DecisionRecord` type
- This ensures the field is not dropped by TypeScript typing during round-trips
### Step 3: TreeNavigationPage — Capture UI for Built-in Action Nodes
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
**State:**
- Add `const [commandOutput, setCommandOutput] = useState('')` (same pattern as existing `notes` state)
- Clear `commandOutput` when node changes (same place `notes` is cleared)
- When revisiting a step, preload existing `command_output` from the decision record if present
**UI — on action nodes with `currentNode.commands?.length > 0`:**
- Render a collapsible "Paste Output (Optional)" section below the commands display
- Inside: a textarea with:
- Placeholder: `"Paste command output here..."`
- Monospace font (`font-mono`), consistent with command code block styling
- `bg-white/10` background styling to match existing design
- Live character count display: `"{count} / 10,000"` shown below the textarea
- Max length enforced on the frontend at 10,000 characters
- Use a `Terminal` icon from `lucide-react` for the section label
**Persistence — in `handleContinue()`:**
- Add `command_output: commandOutput.trim() || null` to the decision record pushed to the session
- Empty or whitespace-only input is normalized to `null` (treated as not provided)
### Step 4: TreeNavigationPage — Capture UI for Custom Action Steps
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
Custom action steps create their decision record at insertion time, which is different from built-in action nodes. The output capture UI and behavior should be the same as Step 3, but persistence requires updating the existing decision rather than creating a new one.
**UI:**
- Same collapsible "Paste Output (Optional)" section as built-in action nodes
- Available when a custom action step has commands defined
**Persistence:**
- Before `handleContinueToDescendant` or `handleCustomBranchComplete` is called, update the current custom step's decision record with `command_output: commandOutput.trim() || null`
- Persist the updated decisions array to the backend before navigation/completion transitions
- This is a wrapper flow around the existing custom step logic — not a replacement of it
### Step 5: Export Service — All 4 Formats
**File:** `backend/app/services/export_service.py`
**Helpers to add:**
- A helper to safely extract and normalize `command_output` from a decision dict (strip whitespace, return `None` if empty)
- A helper to resolve the commands associated with a step for context display:
1. First look up the tree snapshot action node by `node_id`
2. Fallback to custom step metadata by `node_id`
3. Fallback to no command list (just show the output)
**Export rendering per format** (all guarded by `if command_output := decision.get("command_output")`):
**Markdown (`_generate_markdown_export`):**
```
**Commands Run:** `ping 8.8.8.8`, `tracert 8.8.8.8`
**Output:**
```
{output}
```
```
**Text (`_generate_text_export`):**
```
Commands Run: ping 8.8.8.8, tracert 8.8.8.8
Output:
{output with each line indented}
```
**HTML (`_generate_html_export`):**
```html
<p><strong>Commands Run:</strong> <code>ping 8.8.8.8</code>, <code>tracert 8.8.8.8</code></p>
<pre><code>{html.escape(output)}</code></pre>
```
**PSA (`_generate_psa_export`):**
```
Commands: ping 8.8.8.8, tracert 8.8.8.8
Output:
{output with each line indented}
```
### Step 6: SessionDetailPage — Review Display
**File:** `frontend/src/pages/SessionDetailPage.tsx`
**Decision review:**
- After the `action_performed` rendering for each decision, check for `command_output`
- If present, render in a `<pre>` block with monospace styling and preserved whitespace
- Label: "Command Output" with consistent styling
**Clipboard copy (`copyTicketNotes`):**
- After the action performed line, add:
```
Output:
{decision.command_output}
```
- Only include if `command_output` is present and non-empty
### Step 7: Session-to-Tree Conversion
**File:** `backend/app/core/session_to_tree.py`
- When building node descriptions from decisions, check for `command_output`
- If present, append the output text to the node description so forked trees retain the captured output
- Format: include a "Command Output:" label followed by the output text
---
## Edge Cases & Failure Modes
| Scenario | Behavior |
|----------|----------|
| Existing sessions without `command_output` | Render normally with no errors — field is optional |
| Output exceeds 10,000 characters | Frontend prevents input beyond limit; backend returns 422 if somehow exceeded |
| Empty or whitespace-only input | Normalized to `null` — treated as not provided |
| Multiline output, JSON, special characters | Preserved as-is; HTML export escapes all content |
| Steps without commands | Output can still be stored; export shows output even without command context |
| Multi-command action nodes | One shared output field per step (not per command) |
| Revisiting a completed step | Preloads the previously captured output |
---
## Test Cases
### Backend API Tests (`test_sessions.py`)
1. Update a session with `command_output` in a decision record → verify it round-trips correctly on GET
2. Submit `command_output` exceeding 10,000 characters → verify 422 response
3. Submit empty string and whitespace-only `command_output` → verify stored as `null`
### Export Tests
4. Markdown export includes command context and fenced code block for output
5. Text export includes output block with preserved line breaks
6. HTML export includes escaped `<pre><code>` block
7. PSA export includes compact command context and indented output
8. Multi-command action node exports with single shared output block
9. Export of session without any `command_output` renders cleanly (no errors, no empty blocks)
### Custom Action Step Tests
10. Insert custom action step with commands → capture output → continue → verify output stored in decision
11. Custom step output appears in all export formats
### Frontend Behavior Tests
12. Action node with commands shows the "Paste Output" section (collapsed by default)
13. Custom action step with commands shows the "Paste Output" section
14. Action node WITHOUT commands does NOT show the "Paste Output" section
15. Character count updates live as user types
16. Revisiting a step preloads previously captured output
17. Session detail page renders output block with monospace formatting
18. "Copy to clipboard" includes command output when present
---
## Verification Checklist (Manual)
1. `cd frontend && npm run build` — confirm no TypeScript errors
2. Start a session on a tree with action nodes that have commands:
- Paste output into the textarea
- Click Continue
- Verify output persists in the session data
3. Start a session and add a custom action step with commands:
- Paste output
- Continue to next step
- Verify output persists
4. Complete a session → check SessionDetailPage shows the command output with proper formatting
5. Export in all 4 formats → verify output appears correctly formatted in each
6. Use "Copy to clipboard" on a step with output → verify output is included
7. Run a session on a tree WITHOUT commands on action nodes → verify no output section appears
8. Test with existing sessions that have no `command_output` → verify they render and export without errors
9. Test pasting large output (near 10,000 chars) → verify character count and limit work
10. Test pasting multiline output with special characters → verify preservation in review and exports
---
## Assumptions
- One shared output field per step, not per individual command
- Maximum stored output is 10,000 characters
- v1 does not include syntax highlighting or image paste
- No feature flag gating — ships directly
- Collapsed-by-default UI keeps the interface clean for steps where output isn't needed

View File

@@ -224,6 +224,7 @@ export function SessionDetailPage() {
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
try {
await navigator.clipboard.writeText(lines.join('\n'))
setCopiedStepIndex(index)
@@ -439,6 +440,14 @@ export function SessionDetailPage() {
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
{decision.command_output}
</pre>
</div>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}

View File

@@ -10,7 +10,7 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
import { Plus, CheckCircle, ArrowRight, Clock, Terminal } from 'lucide-react'
interface LocationState {
sessionId?: string
@@ -33,6 +33,8 @@ export function TreeNavigationPage() {
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
const [notes, setNotes] = useState<string>('')
const [commandOutput, setCommandOutput] = useState<string>('')
const [commandOutputOpen, setCommandOutputOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isCompleting, setIsCompleting] = useState(false)
@@ -122,6 +124,35 @@ export function TreeNavigationPage() {
},
})
// Inject command_output into the last decision (for custom steps) before continuing
const updateLastDecisionWithCommandOutput = async () => {
const output = commandOutput.trim() || null
if (!output || !session || decisions.length === 0) return
const updatedDecisions = [...decisions]
updatedDecisions[updatedDecisions.length - 1] = {
...updatedDecisions[updatedDecisions.length - 1],
command_output: output,
}
setDecisions(updatedDecisions)
try {
await sessionsApi.update(session.id, { decisions: updatedDecisions })
} catch (err) {
console.error('Failed to update decision with command output:', err)
}
}
const handleCustomContinueToDescendant = async () => {
await updateLastDecisionWithCommandOutput()
setCommandOutput('')
setCommandOutputOpen(false)
customStepFlow.handleContinueToDescendant()
}
const handleCustomBranchCompleteWithOutput = async () => {
await updateLastDecisionWithCommandOutput()
customStepFlow.handleCustomBranchComplete()
}
const handleScratchpadSave = async (content: string) => {
if (!session) return
await sessionsApi.updateScratchpad(session.id, content)
@@ -218,6 +249,8 @@ export function TreeNavigationPage() {
setCurrentNodeId(nextNodeId)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
setCommandOutput('')
setCommandOutputOpen(false)
try {
await sessionsApi.update(session.id, {
@@ -243,6 +276,7 @@ export function TreeNavigationPage() {
answer: null,
action_performed: actionPerformed || node.title || 'Action completed',
notes: notes || null,
command_output: commandOutput.trim() || null,
automation_used: false,
timestamp: exitedAt,
entered_at: enteredAt,
@@ -259,6 +293,8 @@ export function TreeNavigationPage() {
setCurrentNodeId(node.next_node_id)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
setCommandOutput('')
setCommandOutputOpen(false)
try {
await sessionsApi.update(session.id, {
@@ -280,6 +316,7 @@ export function TreeNavigationPage() {
answer: null,
action_performed: node.title || 'Session completed',
notes: notes || null,
command_output: commandOutput.trim() || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
@@ -321,11 +358,16 @@ export function TreeNavigationPage() {
const handleGoBack = () => {
if (pathTaken.length <= 1) return
const newPath = pathTaken.slice(0, -1)
const removedDecision = decisions[decisions.length - 1]
const newDecisions = decisions.slice(0, -1)
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(newPath[newPath.length - 1])
setCurrentStepEnteredAt(new Date().toISOString())
// Preload fields from the removed decision when revisiting
const prevOutput = removedDecision?.command_output || ''
setCommandOutput(prevOutput)
setCommandOutputOpen(!!prevOutput)
}
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
@@ -614,6 +656,37 @@ export function TreeNavigationPage() {
</div>
))}
</div>
{/* Command Output Capture */}
<div className="mt-3">
<button
type="button"
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
>
<Terminal className="h-3.5 w-3.5" />
<span>Paste Output (Optional)</span>
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
</button>
{commandOutputOpen && (
<div className="mt-2">
<textarea
value={commandOutput}
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
placeholder="Paste command output here..."
rows={4}
maxLength={10000}
className={cn(
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
'font-mono text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
<p className="mt-1 text-right text-xs text-white/30">
{commandOutput.length.toLocaleString()} / 10,000
</p>
</div>
)}
</div>
</div>
)}
@@ -625,7 +698,7 @@ export function TreeNavigationPage() {
<div className="mt-6 border-t border-purple-700 pt-4">
<button
type="button"
onClick={customStepFlow.handleContinueToDescendant}
onClick={handleCustomContinueToDescendant}
className={cn(
'flex w-full items-center justify-between rounded-md bg-white px-4 py-3 text-sm font-medium text-black',
'hover:bg-white/90'
@@ -656,7 +729,7 @@ export function TreeNavigationPage() {
Add Another Step
</button>
<button
onClick={customStepFlow.handleCustomBranchComplete}
onClick={handleCustomBranchCompleteWithOutput}
disabled={isCompleting}
className={cn(
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
@@ -696,6 +769,37 @@ export function TreeNavigationPage() {
</code>
))}
</div>
{/* Command Output Capture */}
<div className="mt-3">
<button
type="button"
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
>
<Terminal className="h-3.5 w-3.5" />
<span>Paste Output (Optional)</span>
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
</button>
{commandOutputOpen && (
<div className="mt-2">
<textarea
value={commandOutput}
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
placeholder="Paste command output here..."
rows={4}
maxLength={10000}
className={cn(
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
'font-mono text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
<p className="mt-1 text-right text-xs text-white/30">
{commandOutput.length.toLocaleString()} / 10,000
</p>
</div>
)}
</div>
</div>
)}
{currentNode.expected_outcome && (

View File

@@ -9,6 +9,7 @@ export interface DecisionRecord {
answer: string | null
action_performed: string | null
notes: string | null
command_output?: string | null
automation_used: boolean
timestamp: string
entered_at?: string | null