fix(web): scope artifact upgrade gate to AMR (#6760)

This commit is contained in:
lefarcen
2026-08-12 03:45:04 +00:00
committed by GitHub
parent 6dbae48a51
commit 044324b9a6
7 changed files with 216 additions and 15 deletions
@@ -110,6 +110,7 @@ export function AmrArtifactUpgradeGate({
!detail
|| typeof detail.runId !== 'string'
|| !detail.runId.trim()
|| detail.agentId !== 'amr'
|| !sessionKey
|| detail.result !== 'success'
|| !Number.isFinite(detail.artifactCount)
+16 -3
View File
@@ -5484,6 +5484,7 @@ export function ProjectView({
|| spuriouslyFailedPending
|| recoverableGenericDisconnectFailed;
void reattachDaemonRun({
agentId: message.agentId,
runId,
projectId: project.id,
conversationId: reattachConversationId,
@@ -5791,9 +5792,11 @@ export function ProjectView({
if (
shouldPublishRunFinishedEvent
&& latestRunStatus?.status === 'succeeded'
&& latestRunStatus.agentId === 'amr'
&& typeof latestRunStatus.artifactCount === 'number'
) {
publishDaemonRunFinishedEvent({
agentId: latestRunStatus.agentId,
runId,
projectId: project.id,
conversationId: reattachConversationId,
@@ -5884,9 +5887,11 @@ export function ProjectView({
} else if (latestRunStatus.status === 'succeeded') {
if (
shouldPublishRunFinishedEvent
&& latestRunStatus.agentId === 'amr'
&& typeof latestRunStatus.artifactCount === 'number'
) {
publishDaemonRunFinishedEvent({
agentId: latestRunStatus.agentId,
runId,
projectId: project.id,
conversationId: reattachConversationId,
@@ -7452,8 +7457,12 @@ export function ProjectView({
}
if (!latestRunStatus || isActiveRunStatus(latestRunStatus.status)) {
} else if (latestRunStatus.status === 'succeeded') {
if (typeof latestRunStatus.artifactCount === 'number') {
if (
latestRunStatus.agentId === 'amr'
&& typeof latestRunStatus.artifactCount === 'number'
) {
publishDaemonRunFinishedEvent({
agentId: latestRunStatus.agentId,
runId: runIdForGenericDisconnect,
projectId: project.id,
conversationId: runConversationId,
@@ -8005,7 +8014,11 @@ export function ProjectView({
commentAttachments: ChatCommentAttachment[],
meta?: ChatSendMeta,
): Promise<ChatSendOutcome> => {
if (activeConversationId) {
if (
activeConversationId
&& config.mode === 'daemon'
&& config.agentId === 'amr'
) {
const decision = await requestAmrArtifactUpgrade({
projectId: project.id,
conversationId: activeConversationId,
@@ -8015,7 +8028,7 @@ export function ProjectView({
}
void handleSend(prompt, attachments, commentAttachments, meta);
},
[activeConversationId, handleSend, project.id],
[activeConversationId, config.agentId, config.mode, handleSend, project.id],
);
// Cancel every in-flight run for the current conversation (the user's own
+7 -1
View File
@@ -348,6 +348,8 @@ export interface DaemonStreamOptions {
}
export interface DaemonReattachOptions {
/** Runtime that owns the reattached run, when persisted with its message. */
agentId?: string;
runId: string;
projectId?: string | null;
conversationId?: string | null;
@@ -367,6 +369,7 @@ export const RUNS_CHANGED_EVENT = 'open-design:runs-changed';
export const DAEMON_RUN_FINISHED_EVENT = 'open-design:daemon-run-finished';
export interface DaemonRunFinishedEventDetail {
agentId: string;
runId: string;
projectId: string;
conversationId: string;
@@ -379,6 +382,7 @@ export function publishDaemonRunFinishedEvent(
): void {
if (
typeof window === 'undefined'
|| detail.agentId !== 'amr'
|| !detail.runId.trim()
|| !detail.projectId.trim()
|| !detail.conversationId.trim()
@@ -1171,7 +1175,7 @@ async function consumeDaemonRun({
conversationId,
workspaceContext,
publishRunFinishedEvent,
}: DaemonReattachOptions & { agentId?: string }): Promise<void> {
}: DaemonReattachOptions): Promise<void> {
let acc = '';
let stderrBuf = '';
let exitCode: number | null = null;
@@ -1485,6 +1489,7 @@ async function consumeDaemonRun({
}
if (
publishRunFinishedEvent
&& agentId === 'amr'
&& Boolean(projectId?.trim())
&& Boolean(conversationId?.trim())
&& serverDeclaredSuccess
@@ -1493,6 +1498,7 @@ async function consumeDaemonRun({
&& resolvedArtifactCount > 0
) {
publishDaemonRunFinishedEvent({
agentId,
runId,
projectId: projectId!,
conversationId: conversationId!,
@@ -9,18 +9,22 @@ import {
} from '../../src/providers/daemon';
import { requestAmrArtifactUpgrade } from '../../src/runtime/amr-artifact-upgrade';
function publishFinishedRun(detail: Partial<DaemonRunFinishedEventDetail> = {}) {
type RuntimeAwareFinishedRunDetail = DaemonRunFinishedEventDetail & {
agentId?: string;
};
function publishFinishedRun(detail: Partial<RuntimeAwareFinishedRunDetail> = {}): void {
const payload: RuntimeAwareFinishedRunDetail = {
runId: detail.runId ?? 'run-1',
projectId: detail.projectId ?? 'project-1',
conversationId: detail.conversationId ?? 'conversation-1',
result: detail.result ?? 'success',
artifactCount: detail.artifactCount ?? 1,
agentId: detail.agentId ?? 'amr',
};
window.dispatchEvent(new CustomEvent<DaemonRunFinishedEventDetail>(
DAEMON_RUN_FINISHED_EVENT,
{
detail: {
runId: detail.runId ?? 'run-1',
projectId: detail.projectId ?? 'project-1',
conversationId: detail.conversationId ?? 'conversation-1',
result: detail.result ?? 'success',
artifactCount: detail.artifactCount ?? 1,
},
},
{ detail: payload },
));
}
@@ -74,6 +78,15 @@ describe('AmrArtifactUpgradeGate', () => {
expect(screen.queryByTestId('amr-artifact-upgrade-dialog')).toBeNull();
});
it('does not prompt a Free-plan user after Kimi creates an artifact', async () => {
render(<AmrArtifactUpgradeGate {...BASE_PROPS} plan="free" planResolved />);
act(() => publishFinishedRun({ agentId: 'kimi' }));
await expect(requestSend()).resolves.toBe('proceed');
expect(screen.queryByTestId('amr-artifact-upgrade-dialog')).toBeNull();
});
it('does not prompt the same session again after the first decision', async () => {
render(<AmrArtifactUpgradeGate {...BASE_PROPS} plan="free" planResolved />);
@@ -515,6 +515,7 @@ describe('ProjectView daemon reattach restore', () => {
{
id: 'msg-reattach',
role: 'assistant',
agentId: 'kimi',
content: '',
createdAt: startedAt,
startedAt,
@@ -561,6 +562,7 @@ describe('ProjectView daemon reattach restore', () => {
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(reattachDaemonRun).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'kimi',
publishRunFinishedEvent: true,
}));
expect(capturedHandlers).not.toBeNull();
+37 -1
View File
@@ -132,7 +132,7 @@ describe('streamViaDaemon', () => {
}));
await streamViaDaemon({
agentId: 'mock',
agentId: 'amr',
history: [{ id: '1', role: 'user', content: 'make a design' }],
systemPrompt: '',
signal: new AbortController().signal,
@@ -143,6 +143,7 @@ describe('streamViaDaemon', () => {
});
expect(published).toEqual([{
agentId: 'amr',
runId: 'run-artifact-success',
projectId: 'project-1',
conversationId: 'conversation-1',
@@ -152,6 +153,41 @@ describe('streamViaDaemon', () => {
expect(artifactPaths).toEqual([['existing.png', 'renders/new.png']]);
});
it.each(['kimi', 'codex'])(
'does not publish a local %s artifact run to the AMR upgrade gate',
async (agentId) => {
const handlers = createDaemonHandlers();
const eventTarget = new EventTarget();
const published: DaemonRunFinishedEventDetail[] = [];
eventTarget.addEventListener(DAEMON_RUN_FINISHED_EVENT, (event) => {
published.push((event as CustomEvent<DaemonRunFinishedEventDetail>).detail);
});
vi.stubGlobal('window', eventTarget);
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/runs') return jsonResponse({ runId: `run-${agentId}` });
if (url === `/api/runs/run-${agentId}/events`) {
return sseResponse(
'event: end\ndata: {"code":0,"status":"succeeded","artifactCount":1}\n\n',
);
}
throw new Error(`unexpected fetch ${url}`);
}));
await streamViaDaemon({
agentId,
history: [{ id: '1', role: 'user', content: 'make a design' }],
systemPrompt: '',
signal: new AbortController().signal,
handlers,
projectId: 'project-1',
conversationId: 'conversation-1',
});
expect(published).toEqual([]);
},
);
it.each([
['no artifact', '{"code":0,"status":"succeeded","artifactCount":0}'],
['failed', '{"code":1,"status":"failed","artifactCount":1}'],
+130
View File
@@ -0,0 +1,130 @@
import { expect, test } from '@/playwright/suite';
import {
AMR_PERSONAL_WORKSPACE_HEADERS,
createProjectViaApi,
gotoProject,
putAppConfig,
seedBrowserConfig,
} from '@/playwright/amr';
import {
routeAgents,
routeSuccessfulRuns,
suppressWhatsNew,
} from '@/playwright/mock-factory';
import { T } from '@/timeouts';
import type { Page } from '@playwright/test';
const KIMI_AGENT = {
id: 'kimi',
name: 'Kimi CLI',
bin: 'kimi',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
};
const KIMI_CONFIG = {
mode: 'daemon',
apiKey: '',
baseUrl: '',
model: '',
agentId: 'kimi',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: { kimi: { model: 'default', reasoning: 'default' } },
};
const KIMI_ARTIFACT_RUN = [
'event: stdout',
`data: ${JSON.stringify({
chunk: '<artifact identifier="kimi-upgrade-gate" type="text/html" title="Kimi artifact"><!doctype html><html><body><h1>Kimi artifact</h1></body></html></artifact>',
})}`,
'',
'event: end',
'data: {"code":0,"status":"succeeded","artifactCount":1}',
'',
'',
].join('\n');
test('[P1] Kimi artifact follow-ups bypass the AMR Free upgrade dialog', async ({ page }) => {
test.setTimeout(T.xlong);
await suppressWhatsNew(page);
await routeAgents(page, [KIMI_AGENT]);
await page.route('**/api/integrations/vela/status*', async (route) => {
await route.fulfill({
json: {
loggedIn: true,
profile: 'local',
configPath: '/tmp/.amr/config.json',
user: {
id: 'kimi-upgrade-gate-user',
email: 'kimi-upgrade-gate@example.com',
plan: 'free',
},
account: { plan: 'free', balanceUsd: '0.00' },
},
});
});
await seedBrowserConfig(page, KIMI_CONFIG);
await putAppConfig(page, KIMI_CONFIG);
const projectId = `kimi-upgrade-gate-${Date.now()}`;
const runs = await routeSuccessfulRuns(page, {
runIdPrefix: 'kimi-upgrade-gate',
eventBody: async (requestIndex) => {
if (requestIndex === 1) {
const response = await page.request.post(`/api/projects/${projectId}/files`, {
headers: { ...AMR_PERSONAL_WORKSPACE_HEADERS },
data: {
name: 'kimi-upgrade-gate.html',
content: '<!doctype html><html><body><h1>Kimi artifact</h1></body></html>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Kimi artifact',
entry: 'kimi-upgrade-gate.html',
renderer: 'html',
status: 'complete',
exports: ['html'],
primary: true,
metadata: { identifier: 'kimi-upgrade-gate' },
},
},
});
expect(response.ok(), await response.text()).toBeTruthy();
}
return KIMI_ARTIFACT_RUN;
},
});
await createProjectViaApi(page, projectId, 'Kimi upgrade gate');
await gotoProject(page, projectId);
await sendPrompt(page, 'Create a Kimi artifact');
await runs.expectCount(1);
await expect(page.frameLocator(
'[data-testid="artifact-preview-frame"]:visible, '
+ '[data-testid="artifact-preview-frame-url-load"]:visible, '
+ '[data-testid="artifact-preview-frame-srcdoc"]:visible, '
+ '[data-testid="live-artifact-preview-frame"]:visible',
).getByRole('heading', { name: 'Kimi artifact' })).toBeVisible({ timeout: T.long });
await expect(page.getByTestId('amr-artifact-upgrade-dialog')).toHaveCount(0);
await sendPrompt(page, 'Refine the Kimi artifact');
await runs.expectCount(2, {
timeout: T.long,
message: 'the Kimi follow-up should reach POST /api/runs without an AMR upsell gate',
});
await expect(page.getByTestId('amr-artifact-upgrade-dialog')).toHaveCount(0);
});
async function sendPrompt(page: Page, prompt: string): Promise<void> {
const input = page.getByTestId('chat-composer-input');
await expect(input).toBeVisible({ timeout: T.medium });
await input.fill(prompt);
await expect(page.getByTestId('chat-send')).toBeEnabled();
await input.press('Enter');
}