diff --git a/src/cli/commands/export/__tests__/format-export-notes.test.ts b/src/cli/commands/export/__tests__/format-export-notes.test.ts new file mode 100644 index 000000000..5a002e295 --- /dev/null +++ b/src/cli/commands/export/__tests__/format-export-notes.test.ts @@ -0,0 +1,94 @@ +import { serializeResult } from '../../../../lib/result'; +import type { ExportNote } from '../types'; +import { formatExportNotes } from '../types'; +import { describe, expect, it } from 'vitest'; + +const HINT = 'app/MyAgent/EXPORT_NOTES.md'; + +/** The export-harness success result the CLI `--json` branch serializes. `success: true as const` + + * the index signature satisfy serializeResult's `{ success: true } & Record`. */ +function makeExportSuccess(notes: ExportNote[]): { + success: true; + agentName: string; + agentPath: string; + notesPath: string; + notes: ExportNote[]; + [k: string]: unknown; +} { + return { success: true, agentName: 'MyAgent', agentPath: '/tmp/app/MyAgent', notesPath: HINT, notes }; +} + +describe('formatExportNotes', () => { + it('returns a single dim "no follow-up" line when there are no notes', () => { + const lines = formatExportNotes([], HINT); + expect(lines).toEqual([{ text: `No manual follow-up required. (Details: ${HINT})`, tone: 'dim' }]); + }); + + it('renders a warning header (singular) + category + message + file hint for one note', () => { + const notes: ExportNote[] = [ + { category: 'Browser tool requires Container build', message: 'Re-export with --build Container.' }, + ]; + const lines = formatExportNotes(notes, HINT); + + // Header is a single "note" (not "notes") and carries the warn tone. + expect(lines[0]).toEqual({ text: '⚠ 1 export note requiring manual follow-up:', tone: 'warn' }); + // Category is warn-toned and bulleted. + expect(lines).toContainEqual({ text: ' • Browser tool requires Container build', tone: 'warn' }); + // Message line is dim and indented. + expect(lines).toContainEqual({ text: ' Re-export with --build Container.', tone: 'dim' }); + // Trailing pointer to the on-disk copy. + expect(lines.at(-1)).toEqual({ text: `These notes are also saved to ${HINT}`, tone: 'dim' }); + }); + + it('pluralizes the header and lists every note when there are multiple', () => { + const notes: ExportNote[] = [ + { category: 'First', message: 'a' }, + { category: 'Second', message: 'b' }, + ]; + const lines = formatExportNotes(notes, HINT); + expect(lines[0]?.text).toBe('⚠ 2 export notes requiring manual follow-up:'); + expect(lines).toContainEqual({ text: ' • First', tone: 'warn' }); + expect(lines).toContainEqual({ text: ' • Second', tone: 'warn' }); + }); + + it('splits a multi-line message into one dim line per line (preserving order)', () => { + const notes: ExportNote[] = [{ category: 'C', message: 'line one\nline two\nline three' }]; + const lines = formatExportNotes(notes, HINT); + const messageLines = lines.filter(l => l.tone === 'dim' && l.text.startsWith(' ')); + expect(messageLines.map(l => l.text)).toEqual([' line one', ' line two', ' line three']); + }); +}); + +// The CLI `--json` branch emits `JSON.stringify(serializeResult(result))`, so the export success +// result's `notes` array is a public contract for scripted consumers. Assert it survives that path +// as structured { category, message } objects. +describe('export --json notes contract', () => { + const notes: ExportNote[] = [ + { category: 'Browser tool requires Container build', message: 'Re-export with --build Container.' }, + { category: 'Path skill not found locally', message: 'Ensure /opt/skills/x exists in the image.' }, + ]; + + it('serializeResult preserves the notes array (with category + message)', () => { + const serialized = serializeResult(makeExportSuccess(notes)) as { notes: ExportNote[] }; + expect(serialized.notes).toEqual(notes); + }); + + it('notes survive a JSON round-trip as structured objects (the emitted --json payload)', () => { + const emitted = JSON.parse(JSON.stringify(serializeResult(makeExportSuccess(notes)))) as { + success: boolean; + notes: ExportNote[]; + }; + expect(emitted.success).toBe(true); + expect(Array.isArray(emitted.notes)).toBe(true); + expect(emitted.notes).toHaveLength(2); + expect(emitted.notes[0]).toEqual({ + category: 'Browser tool requires Container build', + message: 'Re-export with --build Container.', + }); + }); + + it('emits an empty notes array (not undefined) when there are no notes', () => { + const emitted = JSON.parse(JSON.stringify(serializeResult(makeExportSuccess([])))) as { notes: ExportNote[] }; + expect(emitted.notes).toEqual([]); + }); +}); diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 89aa105c6..1bd18eaa0 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -35,7 +35,8 @@ export async function handleExportHarness( options: ExportHarnessOptions, progress?: ExportHarnessProgress ): Promise< - { success: true; agentName: string; agentPath: string; notesPath: string } | { success: false; error: Error } + | { success: true; agentName: string; agentPath: string; notesPath: string; notes: ExportNote[] } + | { success: false; error: Error } > { const log = (msg: string) => progress?.onProgress?.(msg); @@ -280,6 +281,7 @@ export async function handleExportHarness( agentName: targetAgentName, agentPath: agentDir, notesPath: join(agentDir, EXPORT_NOTES_FILENAME), + notes: context.exportNotes, }; } ); diff --git a/src/cli/commands/export/index.ts b/src/cli/commands/export/index.ts index ea7568eb6..73833955b 100644 --- a/src/cli/commands/export/index.ts +++ b/src/cli/commands/export/index.ts @@ -2,9 +2,10 @@ import { serializeResult } from '../../../lib/result'; import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; import { renderTUI } from '../../tui/render'; import { handleExportHarness } from './harness-action'; +import { formatExportNotes } from './types'; import type { Command } from '@commander-js/extra-typings'; -const { green, red, cyan, dim, reset } = ANSI; +const { green, red, cyan, dim, yellow, reset } = ANSI; export function registerExport(program: Command): void { const exportCmd = program @@ -75,8 +76,16 @@ export function registerExport(program: Command): void { console.log(`${dim}Generated:${reset}`); console.log(` app/${targetAgentName}/ Python agent (Strands)`); console.log(` agentcore/agentcore.json updated`); - console.log(` EXPORT_NOTES.md review for manual follow-up items`); console.log(''); + + // Surface any manual follow-up notes inline so they aren't missed (also written to + // app//EXPORT_NOTES.md). Shared formatter keeps CLI + TUI wording in sync. + for (const line of formatExportNotes(result.notes, `app/${targetAgentName}/EXPORT_NOTES.md`)) { + const color = line.tone === 'warn' ? yellow : dim; + console.log(`${color}${line.text}${reset}`); + } + console.log(''); + console.log('Next steps:'); console.log(''); console.log(` ${cyan}agentcore deploy${reset} ${dim}Deploy the new runtime agent${reset}`); diff --git a/src/cli/commands/export/types.ts b/src/cli/commands/export/types.ts index 9b150f986..56eea41b1 100644 --- a/src/cli/commands/export/types.ts +++ b/src/cli/commands/export/types.ts @@ -61,6 +61,37 @@ export interface ExportNote { message: string; } +/** A single rendered output line + a tone the caller maps to its own styling (ANSI, Ink, plain). */ +export interface ExportNoteLine { + text: string; + tone: 'warn' | 'dim'; +} + +/** + * Format export notes into styled lines for display on the export success path (CLI + TUI). Pure and + * side-effect-free so it can be unit-tested and shared, keeping the two surfaces' wording in sync. + * Returns a warning block listing each note's category + (multi-line) message when notes exist, or a + * single "no follow-up required" line otherwise. `notesFileHint` is the path shown for EXPORT_NOTES.md. + */ +export function formatExportNotes(notes: ExportNote[], notesFileHint: string): ExportNoteLine[] { + if (notes.length === 0) { + return [{ text: `No manual follow-up required. (Details: ${notesFileHint})`, tone: 'dim' }]; + } + + const label = notes.length === 1 ? 'note' : 'notes'; + const lines: ExportNoteLine[] = [ + { text: `⚠ ${notes.length} export ${label} requiring manual follow-up:`, tone: 'warn' }, + ]; + for (const note of notes) { + lines.push({ text: ` • ${note.category}`, tone: 'warn' }); + for (const messageLine of note.message.split('\n')) { + lines.push({ text: ` ${messageLine}`, tone: 'dim' }); + } + } + lines.push({ text: `These notes are also saved to ${notesFileHint}`, tone: 'dim' }); + return lines; +} + // ============================================================================ // Mapping output // ============================================================================ diff --git a/src/cli/tui/screens/export/ExportHarnessFlow.tsx b/src/cli/tui/screens/export/ExportHarnessFlow.tsx index 75d29ab53..88af5663f 100644 --- a/src/cli/tui/screens/export/ExportHarnessFlow.tsx +++ b/src/cli/tui/screens/export/ExportHarnessFlow.tsx @@ -1,3 +1,5 @@ +import { formatExportNotes } from '../../../commands/export/types'; +import type { ExportNote } from '../../../commands/export/types'; import { ErrorPrompt, GradientText, NextSteps, Screen, StepProgress } from '../../components'; import type { NextStep, Step } from '../../components'; import { ExportHarnessScreen } from './ExportHarnessScreen'; @@ -10,7 +12,7 @@ type FlowState = | { name: 'wizard'; harnessNames: string[]; existingAgentNames: string[]; containerOnlyHarnesses: Set } | { name: 'no-harnesses' } | { name: 'exporting'; steps: Step[] } - | { name: 'success'; agentName: string; notesPath: string } + | { name: 'success'; agentName: string; notesPath: string; notes: ExportNote[] } | { name: 'error'; message: string }; interface ExportHarnessFlowProps { @@ -112,7 +114,7 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl return; } - setFlow({ name: 'success', agentName: result.agentName, notesPath: result.notesPath }); + setFlow({ name: 'success', agentName: result.agentName, notesPath: result.notesPath, notes: result.notes }); } catch (err) { const { getErrorMessage } = await import('../../../errors'); setFlow({ name: 'error', message: getErrorMessage(err) }); @@ -178,8 +180,18 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl ✓ Exported harness → runtime agent {flow.agentName} Generated: app/{flow.agentName}/ · agentcore/agentcore.json updated - Review export notes: {flow.notesPath} + + {/* Surface manual follow-up notes inline so they aren't missed (also in EXPORT_NOTES.md). + Shared formatter keeps the wording in sync with the CLI. */} + + {formatExportNotes(flow.notes, flow.notesPath).map((line, i) => ( + + {line.text} + + ))} + + {isInteractive && ( )}