diff --git a/backend/app/core/session_to_tree.py b/backend/app/core/session_to_tree.py index 0c72ce72..5bbf4b69 100644 --- a/backend/app/core/session_to_tree.py +++ b/backend/app/core/session_to_tree.py @@ -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", "") diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 45958b1b..f546b99b 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -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 diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 6c826e88..1d7148a0 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -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'

Answer: {answer}

') if notes: html_parts.append(f'

Notes: {notes}

') + if command_output := _get_command_output(decision): + commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) + if commands: + cmd_html = ", ".join(f"{html.escape(c)}" for c in commands) + html_parts.append(f'

Commands Run: {cmd_html}

') + html_parts.append(f'
{html.escape(command_output)}
') if duration_seconds is not None: html_parts.append(f'

Duration: {_format_step_duration(duration_seconds)}

') 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("") diff --git a/docs/plans/2026-02-12-issue-57-command-output-capture.md b/docs/plans/2026-02-12-issue-57-command-output-capture.md new file mode 100644 index 00000000..0f31bf86 --- /dev/null +++ b/docs/plans/2026-02-12-issue-57-command-output-capture.md @@ -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 +

Commands Run: ping 8.8.8.8, tracert 8.8.8.8

+
{html.escape(output)}
+``` + +**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 `
` 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 `
` 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
diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx
index 5bfaabc0..fc380ed6 100644
--- a/frontend/src/pages/SessionDetailPage.tsx
+++ b/frontend/src/pages/SessionDetailPage.tsx
@@ -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}
                         

)} + {decision.command_output && ( +
+

Command Output

+
+                            {decision.command_output}
+                          
+
+ )} {decision.duration_seconds != null && (

Duration: {formatDuration(decision.duration_seconds)} diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 40e141f8..1ffaff95 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -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([]) const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState(new Date().toISOString()) const [notes, setNotes] = useState('') + const [commandOutput, setCommandOutput] = useState('') + const [commandOutputOpen, setCommandOutputOpen] = useState(false) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(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() { ))} + {/* Command Output Capture */} +

+ + {commandOutputOpen && ( +
+