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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

- **Test Connection in Image Gen settings now tests the CLI whose tab you're on.** Pressing it from the Grok CLI or Agy CLI tab probed whichever backend was saved as the default instead — so an Agy test answered `codex — codex-cli 0.145.0`, which looked like Agy had somehow resolved to the Codex binary. The button now probes the provider for the open tab (Backend, Tokens, Expose, and Test still probe the saved default backend), and every result is shown only under the tab that asked for it — so a slow probe you left behind can't surface under the provider you switched to.
- **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend.
- **Agy's model field is now labeled for what it actually selects, and can be overridden per render.** The Image Gen settings field was labeled "Image model" while listing Gemini agent models, which read as the wrong list being loaded. It isn't: `agy models` is the catalog of *agent* models the CLI's `--model` flag accepts, and that agent is what drives the built-in `generate_image` tool. Agy exposes no knob for the underlying image model — its `generate_image` takes prompt, image name, aspect ratio, and reference paths and nothing else — so there is no `imagen-*` option to offer, and passing one makes agy exit before it generates. The field is now "Default agent model" with that explanation, and the Image Gen page adds an **Agent model** picker so a single queue item can run on a different agent model than the saved default without a settings round-trip. Leaving it blank inherits the Settings value; a pinned model that Google later drops from the catalog falls back to the default instead of failing the render. The retry editor on a failed Agy job now edits that same model field (it was bound to the local model-id field, which Agy never reads).
## Game

- **[issue-3177] Added a Game studio for assembling managed-app asset bundles.** Create a Game workspace, bind reusable Sprite records and Music tracks, compile an immutable versioned manifest with SHA-256 asset references, and request persisted AI feedback with any configured provider, model, and supported effort. The new `/game/:id` workspace is reachable from Create, Cmd+K, and voice navigation.
Expand Down
33 changes: 33 additions & 0 deletions client/src/components/imageGen/ImageGenControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,25 @@ export default function ImageGenControls({
modelStatus = null,
onModelDownload,
onModelDownloadCancel,
// Per-render cloud-CLI model override. `cloudModels` is the live catalog —
// an id outside it makes the CLI exit before generating, so the select only
// renders once the probe has returned something AND the host wired
// `onCloudModelChange`. Omitting either prop hides the knob entirely rather
// than shipping a control whose value the host would silently drop. No mode
// check here: the host owns which backends it supplies a catalog for (see
// supportsCloudModelOverride), so a second CLI backend is a caller change,
// not an edit to this shared component.
cloudModels = [],
cloudModel = '',
onCloudModelChange,
cloudModelLabel = 'Model',
cloudModelDefaultLabel = '',
}) {
const isLocal = mode === IMAGE_GEN_MODE.LOCAL;
// Cloud-CLI backends (codex, grok) pick model/steps/seed internally — only
// resolution + style fields apply, so the local-only knobs are hidden.
const isCloudCli = isCloudCliMode(mode);
const showCloudModel = !!onCloudModelChange && cloudModels.length > 0;

const currentModel = models.find((m) => m.id === modelId);
const isFlux2 = currentModel?.runner === RUNNER_FAMILIES.FLUX2;
Expand Down Expand Up @@ -89,6 +103,25 @@ export default function ImageGenControls({
</FormField>
)}

{/* The cloud CLI's own model, overridable per queue item. Blank = inherit
the Settings default, so clearing the select never pins an empty id —
the server treats a blank `cloudModel` as "use the saved value". */}
{showCloudModel && (
<FormField label={cloudModelLabel} labelClassName="block text-xs font-medium text-gray-400 mb-1">
<select
value={cloudModel}
onChange={(e) => onCloudModelChange(e.target.value)}
disabled={disabled}
className={inputCls}
>
<option value="">
{cloudModelDefaultLabel ? `Settings default (${cloudModelDefaultLabel})` : 'Settings default'}
</option>
{cloudModels.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
</FormField>
)}

{/* Preset dropdown + arbitrary width/height. The image route accepts ANY
integer edge in [64, 3840] with total pixels ≤ 8.29M (imageEdgeSchema
is `z.number().int().min(64).max(3840)` + refineImagePixelCap), so
Expand Down
49 changes: 49 additions & 0 deletions client/src/components/imageGen/ImageGenControls.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,52 @@ describe('ImageGenControls — custom dimensions', () => {
expect(screen.getByLabelText('Height')).toBeTruthy();
});
});

// The per-render cloud-CLI model picker. Gated on BOTH a non-empty catalog and
// an `onCloudModelChange` handler — rendering a select whose value the host
// would silently drop is worse than not offering the knob at all.
describe('ImageGenControls — per-render cloud model override', () => {
const agyProps = (overrides = {}) => baseProps({
mode: IMAGE_GEN_MODE.AGY,
cloudModels: ['gemini-3.6-flash-high', 'gemini-3.1-pro-high'],
cloudModelLabel: 'Agent model',
onCloudModelChange: vi.fn(),
...overrides,
});

it('hides the picker when the probe returned no models', () => {
render(<ImageGenControls {...agyProps({ cloudModels: [] })} />);
expect(screen.queryByLabelText('Agent model')).toBeNull();
});

it('hides the picker when the host wired no change handler', () => {
render(<ImageGenControls {...agyProps({ onCloudModelChange: undefined })} />);
expect(screen.queryByLabelText('Agent model')).toBeNull();
});

it('lists every probed model plus a blank "Settings default" option', () => {
render(<ImageGenControls {...agyProps({ cloudModelDefaultLabel: 'gemini-3.5-flash-high' })} />);
const select = screen.getByLabelText('Agent model');
expect([...select.options].map((o) => o.value))
.toEqual(['', 'gemini-3.6-flash-high', 'gemini-3.1-pro-high']);
// Naming what blank resolves to is the point — "Settings default" alone
// leaves the user guessing which model a blank select actually runs.
expect(select.options[0].textContent).toBe('Settings default (gemini-3.5-flash-high)');
});

it('emits the picked id, and emits blank when cleared back to the default', () => {
const onCloudModelChange = vi.fn();
render(<ImageGenControls {...agyProps({ cloudModel: '', onCloudModelChange })} />);
const select = screen.getByLabelText('Agent model');
fireEvent.change(select, { target: { value: 'gemini-3.1-pro-high' } });
expect(onCloudModelChange).toHaveBeenLastCalledWith('gemini-3.1-pro-high');
fireEvent.change(select, { target: { value: '' } });
expect(onCloudModelChange).toHaveBeenLastCalledWith('');
});

it('hides the local-only model/steps/seed knobs on a cloud backend', () => {
render(<ImageGenControls {...agyProps()} />);
expect(screen.getByLabelText('Agent model')).toBeTruthy();
expect(screen.queryByLabelText('Model')).toBeNull();
});
});
4 changes: 3 additions & 1 deletion client/src/components/imageGen/ImageGenSettingsForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ export default function ImageGenSettingsForm({
</p>
) : isAgy ? (
<p className="text-[10px] text-gray-500 mt-1">
Agy uses the model selected in Settings and supports text-to-image only. Resolution and style fields apply.
Agy's <code className="text-gray-400">generate_image</code> picks the image model internally
and supports text-to-image only. The model in Settings is the <em>agent</em> that drives the
tool. Resolution and style fields apply.
</p>
) : null}
</div>
Expand Down
36 changes: 26 additions & 10 deletions client/src/components/media/MediaJobsQueue.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import ConfirmButtonPair from '../ui/ConfirmButtonPair';
import { FormField } from '../ui/FormField';
import { listMediaJobs, cancelMediaJob, cancelQueuedMediaJobs, deleteMediaJob, retryMediaJob, runMediaJobNow } from '../../services/apiMediaJobs.js';
import { listLoraTrainingCheckpoints } from '../../services/apiLoraTraining.js';
import { isCloudCliMode, IMAGE_GEN_MODE, CODEX_IMAGEGEN_DEFAULT_EFFORT } from '../../lib/imageGenBackends';
import { CODEX_EFFORT_LEVELS } from '../../utils/providers';
import { isCloudCliMode, IMAGE_GEN_MODE, CODEX_IMAGEGEN_DEFAULT_EFFORT, supportsCloudModelOverride, modeLabel } from '../../lib/imageGenBackends';
import { ANTIGRAVITY_CONFIGURED_DEFAULT, CODEX_EFFORT_LEVELS, isConfiguredDefaultModel } from '../../utils/providers';
import { lossSparklineGeometry } from '../../lib/lossSparkline';
import { useAutoRefetch } from '../../hooks/useAutoRefetch';
import { useConfirmDelete } from '../../hooks/useConfirmDelete';
Expand Down Expand Up @@ -57,7 +57,7 @@ function modelLabel(params) {
}
if (params.mode === IMAGE_GEN_MODE.AGY) {
const model = (typeof params.model === 'string' ? params.model.trim() : '');
return model && model !== 'antigravity-configured-default'
return model && model !== ANTIGRAVITY_CONFIGURED_DEFAULT
? `agy / ${model}`
: 'agy / configured default';
}
Expand Down Expand Up @@ -480,9 +480,22 @@ const EFFORT_DEFAULT_OPTION = 'default';
function EditRetryForm({ job, onSubmit, onCancel }) {
const p = job.params || {};
const isCodex = p.mode === IMAGE_GEN_MODE.CODEX;
// Codex and Agy both run under a CLI model id carried on `params.model`;
// local jobs use `params.modelId` (a PortOS image-model registry id). Binding
// an Agy retry to `modelId` would edit a field its provider never reads AND
// leave the actual model unchangeable, so the field follows `params.model`
// for every backend that takes a CLI model — the same capability list the
// gen form's override picker is gated on.
const usesCliModel = supportsCloudModelOverride(p.mode);
const [prompt, setPrompt] = useState(p.prompt || '');
const [negativePrompt, setNegativePrompt] = useState(p.negativePrompt || '');
const [model, setModel] = useState(p.model || '');
// A configured-default sentinel means "let the CLI's own config pick" — display
// it as an empty field so the "leave empty for default" placeholder is honest.
// The submit comparison below uses this same blanked value, not the raw
// `p.model`: otherwise an untouched sentinel field reads as an edit and
// submits `model: ''` on every retry.
const originalModel = isConfiguredDefaultModel(p.model) ? '' : (p.model || '');
const [model, setModel] = useState(originalModel);
const [modelId, setModelId] = useState(p.modelId || '');
const [width, setWidth] = useState(p.width ?? '');
const [height, setHeight] = useState(p.height ?? '');
Expand All @@ -500,8 +513,8 @@ function EditRetryForm({ job, onSubmit, onCancel }) {
const numEq = (a, b) => (a === '' ? null : Number(a)) === (b ?? null);
if (!trimEq(prompt, p.prompt) && prompt.trim()) overrides.prompt = prompt.trim();
if (!trimEq(negativePrompt, p.negativePrompt)) overrides.negativePrompt = negativePrompt.trim();
if (isCodex && !trimEq(model, p.model)) overrides.model = model.trim();
if (!isCodex && !trimEq(modelId, p.modelId)) overrides.modelId = modelId.trim();
if (usesCliModel && !trimEq(model, originalModel)) overrides.model = model.trim();
if (!usesCliModel && !trimEq(modelId, p.modelId)) overrides.modelId = modelId.trim();
if (!numEq(width, p.width) && width !== '') overrides.width = Number(width);
if (!numEq(height, p.height) && height !== '') overrides.height = Number(height);
if (!numEq(steps, p.steps) && steps !== '') overrides.steps = Number(steps);
Expand Down Expand Up @@ -532,12 +545,15 @@ function EditRetryForm({ job, onSubmit, onCancel }) {
/>
</FormField>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<FormField className="col-span-2" label={isCodex ? 'Codex model' : 'Model id'} labelClassName="block text-[10px] uppercase tracking-wide text-port-text-muted">
{/* 'Codex model' / 'Agy model' come from the shared backend META rather
than a per-backend ternary ladder — a new CLI backend gets the right
label from its registry entry alone. */}
<FormField className="col-span-2" label={usesCliModel ? `${modeLabel(p.mode)} model` : 'Model id'} labelClassName="block text-[10px] uppercase tracking-wide text-port-text-muted">
<input
type="text"
value={isCodex ? model : modelId}
onChange={(e) => (isCodex ? setModel(e.target.value) : setModelId(e.target.value))}
placeholder={isCodex ? 'leave empty for default' : 'e.g. z-image-turbo-bf16'}
value={usesCliModel ? model : modelId}
onChange={(e) => (usesCliModel ? setModel(e.target.value) : setModelId(e.target.value))}
placeholder={usesCliModel ? 'leave empty for default' : 'e.g. z-image-turbo-bf16'}
className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-xs"
maxLength={200}
/>
Expand Down
52 changes: 52 additions & 0 deletions client/src/components/media/MediaJobsQueue.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,58 @@ describe('MediaJobsQueue — Codex reasoning-effort retry control', () => {
});
});

// An Agy job's model lives on `params.model` (the CLI's `--model` session
// model), NOT `params.modelId` (a local image-model registry id). The retry
// editor must follow the same field, or it edits something Agy never reads and
// leaves the actual model unchangeable.
describe('MediaJobsQueue — Agy retry model field', () => {
const failedAgyJob = {
id: 'agyfail000000dead',
kind: 'image',
status: 'failed',
error: 'boom',
queuedAt: '2026-06-19T10:00:00Z',
params: { prompt: 'a fox', mode: 'agy', model: 'gemini-3.5-flash-high' },
};

it('pre-fills the Agy model from params.model and retries with the edited value', async () => {
const user = userEvent.setup();
listMediaJobs.mockResolvedValue([failedAgyJob]);
render(<MediaJobsQueue kind="image" />);
await expandReel(user);
await user.click(await screen.findByLabelText('Edit and retry'));

const input = await screen.findByLabelText('Agy model');
expect(input.value).toBe('gemini-3.5-flash-high');
await user.clear(input);
await user.type(input, 'gemini-3.1-pro-high');
await user.click(screen.getByRole('button', { name: /Retry with changes/i }));

expect(retryMediaJob).toHaveBeenCalledWith(
'agyfail000000dead', { model: 'gemini-3.1-pro-high' }, { silent: true },
);
});

it('shows the configured-default sentinel as an empty field and sends no override', async () => {
const user = userEvent.setup();
listMediaJobs.mockResolvedValue([{
...failedAgyJob,
id: 'agysentinel00dead',
params: { ...failedAgyJob.params, model: 'antigravity-configured-default' },
}]);
render(<MediaJobsQueue kind="image" />);
await expandReel(user);
await user.click(await screen.findByLabelText('Edit and retry'));

// The sentinel is "let agy choose", not a literal id the user should see or
// resubmit — an untouched field must leave the original params intact.
const input = await screen.findByLabelText('Agy model');
expect(input.value).toBe('');
await user.click(screen.getByRole('button', { name: /Retry with changes/i }));
expect(retryMediaJob).toHaveBeenCalledWith('agysentinel00dead', null, { silent: true });
});
});

describe('MediaJobsQueue — training rows', () => {
it('renders a training summary + engine/character label instead of a prompt', async () => {
listMediaJobs.mockResolvedValue([trainingJob]);
Expand Down
Loading