mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): scope artifact upgrade gate to AMR (#6760)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}'],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user