Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/cli/commands/export/__tests__/format-export-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>`. */
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([]);
});
});
4 changes: 3 additions & 1 deletion src/cli/commands/export/harness-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -280,6 +281,7 @@ export async function handleExportHarness(
agentName: targetAgentName,
agentPath: agentDir,
notesPath: join(agentDir, EXPORT_NOTES_FILENAME),
notes: context.exportNotes,
};
}
);
Expand Down
13 changes: 11 additions & 2 deletions src/cli/commands/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<agent>/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}`);
Expand Down
31 changes: 31 additions & 0 deletions src/cli/commands/export/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
18 changes: 15 additions & 3 deletions src/cli/tui/screens/export/ExportHarnessFlow.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,7 +12,7 @@ type FlowState =
| { name: 'wizard'; harnessNames: string[]; existingAgentNames: string[]; containerOnlyHarnesses: Set<string> }
| { 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 {
Expand Down Expand Up @@ -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) });
Expand Down Expand Up @@ -178,8 +180,18 @@ export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDepl
<Box flexDirection="column">
<Text color="green">✓ Exported harness → runtime agent {flow.agentName}</Text>
<Text dimColor>Generated: app/{flow.agentName}/ · agentcore/agentcore.json updated</Text>
<Text dimColor>Review export notes: {flow.notesPath}</Text>
</Box>

{/* 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. */}
<Box flexDirection="column">
{formatExportNotes(flow.notes, flow.notesPath).map((line, i) => (
<Text key={i} color={line.tone === 'warn' ? 'yellow' : undefined} dimColor={line.tone === 'dim'}>
{line.text}
</Text>
))}
</Box>

{isInteractive && (
<NextSteps steps={EXPORT_SUCCESS_STEPS} isInteractive={true} onSelect={handleSelect} onBack={onExit} />
)}
Expand Down
Loading