Skip to content

Commit 0d45953

Browse files
committed
fix: preserve engine name when navigating back from gateway selection
When pressing Escape on the gateway screen to go back to the name step, the previously entered engine name was lost because AddPolicyEngineScreen remounted with a generated default. Now the entered name is stored in pendingEngineName state and passed back as initialName so the user sees their original input. Constraint: Must not change flow state union type to keep diff minimal Rejected: Carry name in FlowState union variant | adds complexity to type for one field Confidence: high Scope-risk: narrow
1 parent 55b9dde commit 0d45953

3 files changed

Lines changed: 266 additions & 1 deletion

File tree

src/cli/tui/screens/policy/AddPolicyEngineScreen.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ interface AddPolicyEngineScreenProps {
99
onComplete: (config: AddPolicyEngineConfig) => void;
1010
onExit: () => void;
1111
existingEngineNames: string[];
12+
initialName?: string;
1213
headerContent?: React.ReactNode;
1314
}
1415

1516
export function AddPolicyEngineScreen({
1617
onComplete,
1718
onExit,
1819
existingEngineNames,
20+
initialName,
1921
headerContent,
2022
}: AddPolicyEngineScreenProps) {
2123
return (
@@ -24,7 +26,7 @@ export function AddPolicyEngineScreen({
2426
<TextInput
2527
key="name"
2628
prompt="Policy engine name"
27-
initialValue={generateUniqueName('MyPolicyEngine', existingEngineNames)}
29+
initialValue={initialName ?? generateUniqueName('MyPolicyEngine', existingEngineNames)}
2830
onSubmit={name => onComplete({ name })}
2931
onCancel={onExit}
3032
schema={PolicyEngineNameSchema}

src/cli/tui/screens/policy/AddPolicyFlow.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD
5656
const [engineNames, setEngineNames] = useState<string[]>([]);
5757
const [policyNames, setPolicyNames] = useState<string[]>([]);
5858
const [hasUnprotectedGateways, setHasUnprotectedGateways] = useState(false);
59+
const [pendingEngineName, setPendingEngineName] = useState<string | undefined>();
5960

6061
const engineSteps = useMemo<EngineCreationStep[]>(() => {
6162
const steps: EngineCreationStep[] = ['name'];
@@ -133,6 +134,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD
133134
return;
134135
}
135136
setEngineNames(prev => [...prev, engineName]);
137+
setPendingEngineName(undefined);
136138
if (gateways && gateways.length > 0 && mode) {
137139
await policyEnginePrimitive.attachToGateways(engineName, gateways, mode);
138140
}
@@ -141,6 +143,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD
141143

142144
const handleEngineComplete = useCallback(
143145
async (config: AddPolicyEngineConfig) => {
146+
setPendingEngineName(config.name);
144147
const unprotected = await policyEnginePrimitive.getUnprotectedGateways();
145148
if (unprotected.length > 0) {
146149
setFlow({ name: 'attach-gateways', engineName: config.name, gateways: unprotected });
@@ -208,6 +211,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD
208211
return (
209212
<AddPolicyEngineScreen
210213
existingEngineNames={engineNames}
214+
initialName={pendingEngineName}
211215
headerContent={<StepIndicator steps={engineSteps} currentStep="name" labels={ENGINE_STEP_LABELS} />}
212216
onComplete={(config: AddPolicyEngineConfig) => void handleEngineComplete(config)}
213217
onExit={() => {
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/**
2+
* TUI test: Verify that pressing Escape on the "Attach to gateways" screen
3+
* during policy engine creation goes back to the name step without writing
4+
* anything to agentcore.json.
5+
*
6+
* Three scenarios:
7+
* 1. Escape goes back (main bug fix) — no engine written
8+
* 2. Enter with no selection — engine written, no gateway attachment
9+
* 3. Full flow (select gateway + mode) — engine + attachment written
10+
*/
11+
import { TuiSession, closeAll } from '../index.js';
12+
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
13+
import { tmpdir } from 'os';
14+
import { join } from 'path';
15+
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
16+
17+
// Built CLI entry point (absolute path)
18+
const CLI_ENTRY = join(__dirname, '..', '..', '..', 'dist', 'cli', 'index.mjs');
19+
20+
// Base config: project with one unprotected gateway, no policy engines.
21+
function baseConfig() {
22+
return {
23+
name: 'PolicyTestProject',
24+
version: 1,
25+
managedBy: 'CDK',
26+
runtimes: [],
27+
memories: [],
28+
credentials: [],
29+
evaluators: [],
30+
onlineEvalConfigs: [],
31+
agentCoreGateways: [
32+
{
33+
name: 'TestGateway',
34+
targets: [],
35+
authorizerType: 'NONE',
36+
enableSemanticSearch: true,
37+
exceptionLevel: 'NONE',
38+
},
39+
],
40+
policyEngines: [],
41+
};
42+
}
43+
44+
describe('AddPolicyFlow — Escape on attach-gateways', () => {
45+
let projectDir: string;
46+
let configPath: string;
47+
let session: TuiSession | null = null;
48+
49+
beforeEach(async () => {
50+
// Create a fresh temp project for each test
51+
projectDir = join(tmpdir(), `policy-escape-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
52+
await mkdir(join(projectDir, 'agentcore'), { recursive: true });
53+
configPath = join(projectDir, 'agentcore', 'agentcore.json');
54+
await writeFile(configPath, JSON.stringify(baseConfig(), null, 2) + '\n', 'utf-8');
55+
});
56+
57+
afterEach(async () => {
58+
if (session) {
59+
try {
60+
await session.close();
61+
} catch {
62+
// already closed
63+
}
64+
session = null;
65+
}
66+
// Clean up temp directory
67+
await rm(projectDir, { recursive: true, force: true }).catch(() => {
68+
/* ignore cleanup errors */
69+
});
70+
});
71+
72+
afterAll(async () => {
73+
await closeAll();
74+
});
75+
76+
/**
77+
* Helper: launch CLI and navigate to the Policy Engine creation flow.
78+
* Returns the session positioned at the engine name input step.
79+
*/
80+
async function launchAndNavigateToEngineCreation(): Promise<TuiSession> {
81+
session = await TuiSession.launch({
82+
command: 'node',
83+
args: [CLI_ENTRY],
84+
cwd: projectDir,
85+
cols: 120,
86+
rows: 40,
87+
});
88+
89+
// Wait for HelpScreen
90+
await session.waitFor('Commands', 15000);
91+
92+
// Filter to "add" and press Enter
93+
await session.sendKeys('add', 500);
94+
await session.sendSpecialKey('enter', 1000);
95+
96+
// Wait for Add Resource screen
97+
await session.waitFor('Add Resource', 10000);
98+
99+
// Navigate down to "Policy" — the last item in the Add Resource list.
100+
// List order: Agent(0), Memory(1), Credential(2), Evaluator(3),
101+
// Online Eval Config(4), Gateway(5), Gateway Target(6), Policy(7)
102+
// Press down 7 times to reach "Policy" from the top.
103+
for (let i = 0; i < 7; i++) {
104+
await session.sendSpecialKey('down', 200);
105+
}
106+
107+
// Verify cursor is on "Policy" before pressing Enter.
108+
// The TUI uses U+276F (❯) as the cursor indicator, not ASCII ">".
109+
const screen = session.readScreen();
110+
const lines = screen.lines;
111+
const cursorOnPolicy = lines.some(l => l.includes('\u276F') && l.includes('Policy'));
112+
if (!cursorOnPolicy) {
113+
throw new Error('Cursor not on Policy item. Screen:\n' + lines.filter(l => l.trim().length > 0).join('\n'));
114+
}
115+
116+
await session.sendSpecialKey('enter', 1500);
117+
118+
// Since our test config has no policy engines, the AddPolicyFlow goes
119+
// directly to the engine-wizard (name input step). No "select engine"
120+
// screen appears.
121+
return session;
122+
}
123+
124+
async function readConfigFromDisk(): Promise<Record<string, unknown>> {
125+
const raw = await readFile(configPath, 'utf-8');
126+
return JSON.parse(raw);
127+
}
128+
129+
// -----------------------------------------------------------------------
130+
// Scenario 1: Escape on the gateway screen goes back to name step
131+
// -----------------------------------------------------------------------
132+
it('Escape on attach-gateways goes back to name step without writing to disk', async () => {
133+
await launchAndNavigateToEngineCreation();
134+
135+
// Should be on the engine name step. The TextInput has a default value
136+
// of "MyPolicyEngine". Just accept the default.
137+
await session!.waitFor('Name', 10000);
138+
await session!.sendSpecialKey('enter', 1000);
139+
140+
// Should now be on the "Attach to gateways" screen
141+
await session!.waitFor('Attach', 10000);
142+
143+
// Verify we see the gateway name
144+
const gatewayScreen = session!.readScreen();
145+
const gatewayText = gatewayScreen.lines.join('\n');
146+
expect(gatewayText).toContain('TestGateway');
147+
148+
// NOW press Escape — this is the core of the bug fix test
149+
await session!.sendSpecialKey('escape', 1000);
150+
151+
// Should go back to the name step
152+
const afterEscape = session!.readScreen();
153+
const afterEscapeText = afterEscape.lines.join('\n');
154+
155+
// Should NOT see the success screen
156+
expect(afterEscapeText).not.toContain('Added policy engine');
157+
158+
// Should be back at the name step (engine wizard)
159+
// The screen should show the name prompt again
160+
expect(afterEscapeText).toContain('Name');
161+
162+
// Verify agentcore.json was NOT modified
163+
const config = await readConfigFromDisk();
164+
const engines = config.policyEngines as unknown[];
165+
expect(engines).toHaveLength(0);
166+
}, 60000);
167+
168+
// -----------------------------------------------------------------------
169+
// Scenario 2: Enter with no selection commits the engine (no gateway attachment)
170+
// -----------------------------------------------------------------------
171+
it('Enter with no selection writes engine to agentcore.json without gateway attachment', async () => {
172+
await launchAndNavigateToEngineCreation();
173+
174+
await session!.waitFor('Name', 10000);
175+
176+
// Accept the default engine name "MyPolicyEngine"
177+
await session!.sendSpecialKey('enter', 1000);
178+
179+
// Wait for attach gateways screen
180+
await session!.waitFor('Attach', 10000);
181+
182+
// Press Enter with NO selection (nothing toggled with Space)
183+
await session!.sendSpecialKey('enter', 2000);
184+
185+
// Should see success screen
186+
await session!.waitFor('Added', 15000);
187+
188+
const successScreen = session!.readScreen();
189+
expect(successScreen.lines.join('\n')).toContain('MyPolicyEngine');
190+
191+
// Verify engine was written to disk
192+
const config = await readConfigFromDisk();
193+
const engines = config.policyEngines as { name: string }[];
194+
expect(engines).toHaveLength(1);
195+
expect(engines[0]!.name).toBe('MyPolicyEngine');
196+
197+
// Verify NO gateway attachment
198+
const gateways = config.agentCoreGateways as { name: string; policyEngineConfiguration?: unknown }[];
199+
const gw = gateways.find(g => g.name === 'TestGateway');
200+
expect(gw).toBeDefined();
201+
expect(gw!.policyEngineConfiguration).toBeUndefined();
202+
}, 60000);
203+
204+
// -----------------------------------------------------------------------
205+
// Scenario 3: Full flow — select gateway, pick mode, both written
206+
// -----------------------------------------------------------------------
207+
it('Full flow: select gateway + mode writes engine and gateway attachment', async () => {
208+
await launchAndNavigateToEngineCreation();
209+
210+
await session!.waitFor('Name', 10000);
211+
212+
// Accept the default engine name "MyPolicyEngine"
213+
await session!.sendSpecialKey('enter', 1000);
214+
215+
// Wait for attach gateways screen
216+
await session!.waitFor('Attach', 10000);
217+
218+
// Toggle the gateway with Space
219+
await session!.sendKeys(' ', 500);
220+
221+
// Press Enter to confirm the selection
222+
await session!.sendSpecialKey('enter', 1000);
223+
224+
// Should now be on the mode selection screen
225+
// Wait for enforcement mode options
226+
try {
227+
await session!.waitFor('mode', 10000);
228+
} catch {
229+
// Try alternate text
230+
await session!.waitFor('LOG_ONLY', 10000);
231+
}
232+
233+
// Select the first mode (LOG_ONLY) by pressing Enter
234+
await session!.sendSpecialKey('enter', 2000);
235+
236+
// Should see success screen
237+
await session!.waitFor('Added', 15000);
238+
239+
const successScreen = session!.readScreen();
240+
expect(successScreen.lines.join('\n')).toContain('MyPolicyEngine');
241+
242+
// Verify engine was written
243+
const config = await readConfigFromDisk();
244+
const engines = config.policyEngines as { name: string }[];
245+
expect(engines).toHaveLength(1);
246+
expect(engines[0]!.name).toBe('MyPolicyEngine');
247+
248+
// Verify gateway attachment
249+
const gateways = config.agentCoreGateways as {
250+
name: string;
251+
policyEngineConfiguration?: { policyEngineName: string; mode: string };
252+
}[];
253+
const gw = gateways.find(g => g.name === 'TestGateway');
254+
expect(gw).toBeDefined();
255+
expect(gw!.policyEngineConfiguration).toBeDefined();
256+
expect(gw!.policyEngineConfiguration!.policyEngineName).toBe('MyPolicyEngine');
257+
expect(gw!.policyEngineConfiguration!.mode).toBe('LOG_ONLY');
258+
}, 60000);
259+
});

0 commit comments

Comments
 (0)