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
4 changes: 3 additions & 1 deletion src/cli/commands/dev/browser-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ConfigIO, findConfigRoot, getWorkingDirectory } from '../../../lib';
import type { AgentCoreProjectSpec } from '../../../schema';
import { ANSI } from '../../constants';
import { isPreviewEnabled } from '../../feature-flags';
import { isDeploySkippable } from '../../operations/deploy/change-detection';
import { getDevConfig, getDevSupportedAgents, loadDevEnv, loadProjectConfig } from '../../operations/dev';
import { type OtelCollector, startOtelCollector } from '../../operations/dev/otel';
import {
Expand Down Expand Up @@ -133,7 +134,8 @@ export async function launchBrowserDev(): Promise<void> {
process.exit(1);
}

if (hasHarnesses) {
// Only auto-deploy for harness-only projects, and skip if no CDK changes
if (hasHarnesses && !hasRuntimes && !(await isDeploySkippable())) {
await runCliDeploy();
}

Expand Down
65 changes: 46 additions & 19 deletions src/cli/commands/dev/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getErrorMessage } from '../../errors';
import { detectContainerRuntime } from '../../external-requirements';
import { isPreviewEnabled } from '../../feature-flags';
import { ExecLogger } from '../../logging';
import { isDeploySkippable } from '../../operations/deploy/change-detection';
import {
callMcpTool,
createDevServer,
Expand Down Expand Up @@ -509,28 +510,54 @@ export const registerDev = (program: Command) => {
};
}

// Preview: show TUI deploy progress, then launch Agent Inspector in the browser
// Preview browser mode: check if deploy is needed BEFORE entering the TUI.
// This avoids an alt-screen flash when there's nothing to deploy.
// The TUI (launchTuiDevScreenWithPicker) is only used to show deploy progress.
if (isPreviewEnabled()) {
const pickerResult = await launchTuiDevScreenWithPicker(workingDir, {
skipDeploy: opts.skipDeploy,
});
const isHarnessOnly = hasHarnesses && supportedAgents.length === 0;
let needsTuiDeploy = false;

if (pickerResult != null) {
recorder.set({ ui_mode: 'browser' as const });
return {
success: true as const,
blockingPromise: runBrowserMode({
workingDir,
project,
port,
agentName: pickerResult.agentName,
harnessName: pickerResult.harnessName,
otelEnvVars,
collector,
}),
};
if (isHarnessOnly && !opts.skipDeploy) {
needsTuiDeploy = !(await isDeploySkippable());
}
return { success: true as const, blockingPromise: Promise.resolve() };

if (needsTuiDeploy) {
// Deploy is needed — show TUI deploy progress, then launch browser
const pickerResult = await launchTuiDevScreenWithPicker(workingDir, {
skipDeploy: opts.skipDeploy,
});

if (pickerResult != null) {
recorder.set({ ui_mode: 'browser' as const });
return {
success: true as const,
blockingPromise: runBrowserMode({
workingDir,
project,
port,
agentName: pickerResult.agentName,
harnessName: pickerResult.harnessName,
otelEnvVars,
collector,
}),
};
}
return { success: true as const, blockingPromise: Promise.resolve() };
}

// No deploy needed — skip TUI entirely, go straight to browser
recorder.set({ ui_mode: 'browser' as const });
return {
success: true as const,
blockingPromise: runBrowserMode({
workingDir,
project,
port,
agentName: opts.runtime,
otelEnvVars,
collector,
}),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change: harness pre-selection is lost on the new fast path.

For a harness-only project with a single harness and an up-to-date deploy, the previous flow rendered DevScreen, which set selectedHarness = harnesses[0] and called onLaunchBrowser({ harnessName }), so runBrowserMode was invoked with harnessName: harnesses[0] and the web UI opened with that harness pre-selected.

This new branch (needsTuiDeploy === false) bypasses the TUI entirely and calls runBrowserMode with no harnessName and agentName: opts.runtime (which is undefined for harness-only projects). End result: the user lands in the web UI without anything pre-selected, even though we know exactly which harness they want.

This also affects --skip-deploy for harness-only projects, which now follows this same fast path.

Two ways to fix:

  1. Resolve the harness name here in command.tsx before calling runBrowserMode (mirror DevScreen's logic: when isHarnessOnly && project.harnesses?.length === 1, pass harnessName: project.harnesses[0].name).
  2. Keep the gate but plumb the resolution through — e.g., still compute pickerResult-like selection here so both branches converge on the same runBrowserMode call.

Option 1 is the smaller change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed. web ui auto-selects the first option in dropdown if nothing is supplied

}

// Default: browser mode (blocks forever)
Expand Down
20 changes: 20 additions & 0 deletions src/cli/operations/deploy/change-detection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ConfigIO } from '../../../lib';
import { ensureDefaultDeploymentTarget } from './ensure-target';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
Expand Down Expand Up @@ -73,3 +74,22 @@ export async function canSkipDeploy(configIO: ConfigIO): Promise<boolean> {
return false;
}
}

/**
* Checks whether a deploy is needed, handling target auto-population.
* Returns true if deploy can be safely skipped (no changes detected).
* Returns false if deploy is needed or if the check itself fails.
*
* This is the high-level entry point used by both the browser-mode gate
* (command.tsx) and the terminal-mode gate (DevScreen) to avoid showing
* deploy UI when there's nothing to deploy.
*/
export async function isDeploySkippable(): Promise<boolean> {
try {
const configIO = new ConfigIO();
await ensureDefaultDeploymentTarget(configIO);
return await canSkipDeploy(configIO);
} catch {
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDeploySkippable() constructs a fresh new ConfigIO() with no arguments, which auto-discovers from process.cwd(). The two new callers (command.tsx and DevScreen.tsx) both already have a workingDir in scope, and DevScreen in particular receives it as a prop that may not equal cwd.

In practice these are usually the same, but it's a footgun for any future caller and means this helper silently ignores the workingDir that DevScreen was explicitly given. Suggest taking an optional baseDir (or configIO) parameter and threading workingDir through from the call sites. The existing canSkipDeploy(configIO) signature is a good model.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had initially passed in workingDir but it didnt work because config lives in agentcore folder, so ConfigIO wasnt able to find config in the project root. calling new ConfigIO() with no arguments is fine since the cdk package calls findConfigRoot

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage. No tests were added for isDeploySkippable or for the two new branching paths in command.tsx / DevScreen.tsx. Given that this is a fix for buggy UX behavior and the surrounding logic is non-trivial (multiple skipDeploy sources, harness-only vs mixed projects, browser vs terminal mode), please add at least:

  • Unit tests for isDeploySkippable covering the happy path, the ensureDefaultDeploymentTarget failure path, and the canSkipDeploy error path (all should fall through to false).
  • A test that exercises the DevScreen harness-only branch when skipDeploy=true vs when changes are detected, since that branch is the last line of defense for the terminal mode.

36 changes: 28 additions & 8 deletions src/cli/tui/screens/dev/DevScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentEnvSpec } from '../../../../schema';
import { isPreviewEnabled } from '../../../feature-flags';
import { isDeploySkippable } from '../../../operations/deploy/change-detection';
import { getDevSupportedAgents, getEndpointUrl, loadProjectConfig } from '../../../operations/dev';
import {
DeployStatus,
Expand Down Expand Up @@ -185,19 +186,35 @@ export function DevScreen(props: DevScreenProps) {
} else if (agents.length === 1 && harnesses.length === 0 && agents[0]) {
setSelectedAgentName(agents[0].name);
if (!onLaunchBrowser) setMode('chat');
} else if (harnesses.length === 1 && agents.length === 0) {
setSelectedHarness(harnesses[0]);
setMode('deploying');
} else if (harnesses.length > 0 && agents.length === 0) {
// Harness-only projects: check if deploy is needed before showing deploy UI.
// This covers terminal mode (--no-browser). Browser mode is gated earlier in command.tsx.
if (harnesses.length === 1) setSelectedHarness(harnesses[0]);

const skipDeploy = props.skipDeploy === true || (await isDeploySkippable());

if (skipDeploy) {
if (onLaunchBrowser) {
queueMicrotask(() => onLaunchBrowser({ harnessName: harnesses.length === 1 ? harnesses[0] : undefined }));
} else if (harnesses.length === 1) {
setMode('harness');
}
// Multiple harnesses + terminal: stays in 'select-agent' (chooser)
} else {
setMode('deploying');
}
} else if (agents.length === 0 && harnesses.length === 0) {
setNoAgentsError(true);
}

setAgentsLoaded(true);

// If onLaunchBrowser is set and only agents (no harnesses), auto-select immediately.
// Harness projects need deploy first — handled after deploy completes.
if (onLaunchBrowser && agents.length === 1 && harnesses.length === 0) {
queueMicrotask(() => onLaunchBrowser({ agentName: agents[0]?.name }));
// Browser mode: skip the terminal chooser and launch browser immediately.
// The web UI handles agent/harness selection.
// Harness-only projects need deploy first — handled after deploy completes.
if (onLaunchBrowser && agents.length > 0) {
const resolvedName = props.agentName ?? (agents.length === 1 ? agents[0]?.name : undefined);
queueMicrotask(() => onLaunchBrowser({ agentName: resolvedName }));
}
};
void load();
Expand Down Expand Up @@ -255,8 +272,11 @@ export function DevScreen(props: DevScreenProps) {
queueMicrotask(() => {
if (onLaunchBrowser) {
onLaunchBrowser({ harnessName: selectedHarness });
} else {
} else if (selectedHarness) {
setMode('harness');
} else {
// Multiple harnesses: show the chooser after deploy completes
setMode('select-agent');
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading