Preserve interactive ACP permission choices (#2762)
* fix(acp): preserve permission choices ACP question bridges encode answers as repeated permission options. Preserve each option through the UI and return the exact selected ID, while keeping auto-accept from choosing on the user's behalf. * fix(acp): validate selected permission behavior Reject inconsistent action IDs instead of allowing the action ID to override the response behavior. Move the misplaced browser spec so the current base CI contract can run. * fix(acp): preserve invalid permission requests Validate selected ACP actions before consuming the pending request so inconsistent client responses can be corrected and retried. * test(acp): verify real permission questions * fix(acp): reject empty permission action ids * test(e2e): scope Kimi config to ACP spec
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { choosePermissionAction, expectPermissionActions } from "../support/helpers/permissions";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
|
||||
const QUESTION = "Which option should I choose?";
|
||||
const OPTIONS = ["Alpha", "Beta", "Gamma"];
|
||||
|
||||
async function askAgentToOfferChoices(handle: AgentHandle): Promise<void> {
|
||||
await submitMessage(
|
||||
handle.page,
|
||||
`Use your built-in question tool now. Ask exactly "${QUESTION}" with exactly three choices named Alpha, Beta, and Gamma. Do not answer the question yourself. After I choose, reply exactly SELECTED_<CHOICE>, replacing <CHOICE> with my selection.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectAgentToResumeWithSelection(handle: AgentHandle, selection: string) {
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, 240_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
handle.page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ hasText: `SELECTED_${selection}` })
|
||||
.last(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function capturePermissionChoices(page: Page, outputPath: string) {
|
||||
await page.screenshot({ path: outputPath });
|
||||
await expect(page.getByText(QUESTION, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Beta" })).toBeVisible();
|
||||
}
|
||||
|
||||
test.use({ e2eForkProviders: ["kimi"] });
|
||||
|
||||
test.describe("real ACP permission choices", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("Kimi renders its question choices and resumes with the selected option", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const provider = "kimi" satisfies RewindFlowProvider;
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-acp-question-kimi-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({ page, provider, cwd, mode: "full-access" });
|
||||
await askAgentToOfferChoices(handle);
|
||||
await expectPermissionActions(page, OPTIONS);
|
||||
const screenshotPath = testInfo.outputPath("kimi-permission-choices.png");
|
||||
await capturePermissionChoices(page, screenshotPath);
|
||||
await testInfo.attach("Kimi permission choices", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await choosePermissionAction(page, "Beta");
|
||||
await expectAgentToResumeWithSelection(handle, "BETA");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,11 +42,14 @@ const test = metroTest.extend<
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
},
|
||||
{ e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({}, provide, workerInfo) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex);
|
||||
async ({ e2eForkProviders }, provide, workerInfo) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
forkProviders: e2eForkProviders,
|
||||
});
|
||||
try {
|
||||
await provide();
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
|
||||
@@ -109,7 +109,7 @@ process.exit(result.status ?? 1);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
async function applyMetadataFork(targetHome: string): Promise<void> {
|
||||
async function applyMetadataFork(targetHome: string, providerIds: string[]): Promise<void> {
|
||||
const sourceHome = resolveOptionalHome(process.env.E2E_FORK_PASEO_HOME_FROM);
|
||||
if (!sourceHome) return;
|
||||
const result = await forkPaseoHomeMetadata({ sourceHome, targetHome });
|
||||
@@ -117,9 +117,32 @@ async function applyMetadataFork(targetHome: string): Promise<void> {
|
||||
process.env.E2E_FORK_TARGET_PASEO_HOME = result.targetHome;
|
||||
process.env.E2E_FORK_COPIED_FILES = String(result.copiedFiles);
|
||||
process.env.E2E_FORK_COPIED_BYTES = String(result.copiedBytes);
|
||||
|
||||
if (providerIds.length === 0) return;
|
||||
|
||||
const sourceConfig = JSON.parse(
|
||||
await readFile(path.join(result.sourceHome, "config.json"), "utf8"),
|
||||
);
|
||||
const sourceProviders = sourceConfig.agents?.providers ?? {};
|
||||
const providers = Object.fromEntries(
|
||||
providerIds.map((providerId: string) => {
|
||||
const provider = sourceProviders[providerId];
|
||||
if (!provider) {
|
||||
throw new Error(`E2E provider '${providerId}' is not configured in ${result.sourceHome}`);
|
||||
}
|
||||
return [providerId, provider];
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(targetHome, "config.json"),
|
||||
`${JSON.stringify({ version: 1, agents: { providers } }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startE2EWorker(workerIndex: number): Promise<E2EWorker> {
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[] } = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
? path.join(requestedRoot, `worker-${workerIndex}`)
|
||||
@@ -130,7 +153,7 @@ export async function startE2EWorker(workerIndex: number): Promise<E2EWorker> {
|
||||
const serverId = `srv_e2e_worker_${workerIndex}`;
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome);
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
preserveHome,
|
||||
|
||||
@@ -15,3 +15,16 @@ export async function denyPermission(page: Page): Promise<void> {
|
||||
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||
await denyButton.click();
|
||||
}
|
||||
|
||||
export async function expectPermissionActions(page: Page, labels: string[]): Promise<void> {
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
const card = page.getByTestId("permission-request-question").first().locator("..");
|
||||
for (const label of labels) {
|
||||
await expect(card.getByRole("button", { name: label })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function choosePermissionAction(page: Page, label: string): Promise<void> {
|
||||
const card = page.getByTestId("permission-request-question").first().locator("..");
|
||||
await card.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowProvider = "claude" | "codex" | "kimi" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
@@ -54,6 +54,8 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
};
|
||||
case "kimi":
|
||||
return { provider, modeId: "default", featureValues: { auto_accept: true } };
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
@@ -156,6 +158,8 @@ export async function launchAgent(input: {
|
||||
mode: "full-access";
|
||||
providerConfig?: {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
|
||||
@@ -1166,7 +1166,13 @@ function PermissionActionButton({
|
||||
: permissionStyles.optionText;
|
||||
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
|
||||
) : (
|
||||
@@ -1517,6 +1523,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
optionsContainerDesktop: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
|
||||
Reference in New Issue
Block a user