feat(e2e): authoring confirm flow — propose_action card + real-browser test

- server.ts: add details.toolExecutions to the propose_action response path
  so the frontend can render ProposedActionPreview from the JSON body.
- ToolExecutionSteps.tsx: add data-testid="confirm-action" to the confirm
  button so the E2E selector can click it.
- agent-session.ts: move terminalToolResultTail guard before isLlmStubEnabled
  check to prevent stub re-proposing after a terminal tool result.
- playwright.config.ts: use reuseExistingServer=false and dedicated ports
  4580/4581 (separate from the dev server's 4567/4569) so INKOS_AGENT_LLM_STUB=1
  is always active and no real LLM call is made with the fake API key.
- e2e/authoring-confirm.spec.ts: new E2E spec covering the full confirm flow.
- e2e/fixtures/seed-authoring-confirm.ts: seed fixture that writes story graph,
  fake secrets, and a fresh session before each test run.
This commit is contained in:
Ma
2026-06-24 18:14:10 +08:00
parent eca7fe523c
commit a07993dc3a
6 changed files with 195 additions and 9 deletions
+1 -1
View File
@@ -890,11 +890,11 @@ async function runAgentSessionUnlocked(
return convertAgentMessagesForModel(messages, model);
},
streamFn: (streamModel, context, options) => {
if (isLlmStubEnabled()) return stubAgentStream(streamModel, context);
if (terminalToolResultTail) {
terminalToolResultTail = false;
return localAssistantStopStream(streamModel);
}
if (isLlmStubEnabled()) return stubAgentStream(streamModel, context);
return guardedStreamSimple(streamModel, context, options);
},
getApiKey: (provider: string) => {
@@ -0,0 +1,71 @@
/**
* Real-browser multi-step E2E: user drives the agent authoring confirm flow.
*
* Flow:
* 1. Open the film tree page (#/film/<id>) — the project must already exist.
* 2. Click the "AI 对话创作" button (data-testid="open-authoring").
* 3. Type "帮我搭一个三幕结构" into the chat textarea and press Enter.
* 4. Wait for the agent to stream and render the proposed-action confirm card.
* 5. Click the confirm button (data-testid="confirm-action").
* 6. Wait for execution, then cross-check the graph via the API endpoint.
*
* The dev server runs with INKOS_AGENT_LLM_STUB=1 (set in playwright.config.ts),
* so the agent deterministically proposes draft_structure when the user message
* mentions "结构". No real LLM call is made.
*/
import { test, expect } from "@playwright/test";
import { seedAuthoringConfirm, E2E_AUTHOR_ID } from "./fixtures/seed-authoring-confirm";
test.beforeEach(async () => {
await seedAuthoringConfirm();
});
test("user drives agent to draft a structure via the confirm flow", async ({ page }) => {
// Step 1: navigate to the film tree page
await page.goto(`/#/film/${E2E_AUTHOR_ID}`);
// Wait for the tree page to mount (the film-title testid is set in StoryGraphTree
// only after the graph loads; the project may not have a graph yet, so we wait
// for the page's main content area to appear instead)
await expect(page.getByTestId("open-authoring")).toBeVisible({ timeout: 15_000 });
// Step 2: click the authoring button → navigates to #/film-author/<id>
await page.getByTestId("open-authoring").click();
// The ChatPage loads and immediately creates/loads an authoring session.
// Wait for the chat input to become enabled (session ready).
const chatInput = page.getByPlaceholder("输入指令...");
await expect(chatInput).toBeVisible({ timeout: 20_000 });
await expect(chatInput).toBeEnabled({ timeout: 20_000 });
// Step 3: type the instruction and press Enter to send
await chatInput.fill("帮我搭一个三幕结构");
await chatInput.press("Enter");
// Step 4: wait for the agent to stream and render the proposed-action card.
// The stub emits propose_action immediately, but the SSE pipeline still goes
// through the server → SSE event → store update → React render cycle.
// We give it up to 60 s to account for any startup lag on the CI machine.
await expect(page.getByTestId("confirm-action")).toBeVisible({ timeout: 60_000 });
// Step 5: click the confirm button
await page.getByTestId("confirm-action").click();
// Step 6: cross-check via the graph API endpoint.
// The stub's stubChatCompletion returns STRUCTURE_JSON (4 nodes) when the
// prompt mentions "骨架/nodes/结构". Poll until the graph has ≥4 nodes.
await expect
.poll(
async () => {
const res = await page.request.get(
`/api/v1/projects/${E2E_AUTHOR_ID}/story-graph`,
);
if (!res.ok()) return 0;
const g = (await res.json()) as { nodes: unknown[] };
return g.nodes?.length ?? 0;
},
{ timeout: 60_000, intervals: [1_000, 2_000, 3_000] },
)
.toBeGreaterThanOrEqual(4);
});
@@ -0,0 +1,111 @@
import {
saveStoryGraph,
StoryGraphSchema,
createAndPersistBookSession,
saveSecrets,
} from "@actalk/inkos-core";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readdir, rm } from "node:fs/promises";
const dir = fileURLToPath(new URL(".", import.meta.url));
const E2E_ROOT = resolve(dir, "../../../..", "test-project");
export const E2E_AUTHOR_ID = "e2e-authoring-confirm";
/**
* Seed the project directory that the dev server needs for the authoring
* confirm E2E. The dev server uses test-project/ as its INKOS_PROJECT_ROOT.
* We create:
* - test-project/interactive-films/<E2E_AUTHOR_ID>/story-graph.json
* (minimal graph so StoryGraphTree renders and shows the open-authoring button)
* - test-project/.inkos/secrets.json
* (a fake DeepSeek API key so the UI's model picker goes to "ready" state
* and ChatPage auto-selects a model, allowing sendMessage to proceed past
* the "请先选择一个模型" guard)
* - a fresh book session so the agent endpoint can load it
*
* IMPORTANT: we do NOT touch test-project/inkos.json — that file is shared
* across all E2E tests and must not be overwritten.
*
* The actual LLM calls are bypassed by INKOS_AGENT_LLM_STUB=1 set in
* playwright.config.ts, so the fake API key is never sent to any real service.
*/
export async function seedAuthoringConfirm(): Promise<void> {
// Delete all stale sessions for this project from previous test runs.
// ChatPage picks the most-recently-updated session (ids[0] from listBookSessions);
// stale sessions with old message history can confuse the test.
const sessionsDir = resolve(E2E_ROOT, ".inkos", "sessions");
try {
const files = await readdir(sessionsDir);
await Promise.all(
files
.filter((f) => f.startsWith(`e2e-confirm-seed-`) && f.endsWith(".jsonl"))
.map((f) => rm(resolve(sessionsDir, f), { force: true })),
);
} catch {
// sessions dir may not exist on first run — ignore
}
// Write a minimal story graph so StoryGraphTree can load the film page
// without returning an error. The agent will overwrite this with a 4-node
// graph once the confirm flow executes.
await saveStoryGraph(
E2E_ROOT,
E2E_AUTHOR_ID,
StoryGraphSchema.parse({
schemaVersion: 1,
projectId: E2E_AUTHOR_ID,
title: "E2E 确认流程样例",
worldAnchor: {
storyCore: "测试",
theme: "测试",
genre: "现代",
worldRules: "无设定",
durationMinutes: 10,
},
variables: [],
characters: [],
nodes: [
{
id: "s",
type: "start",
title: "开场",
sceneDesc: "起始场景",
choices: [{ id: "c1", text: "继续", targetNodeId: "e" }],
},
{
id: "e",
type: "ending",
title: "结局",
choices: [],
},
],
endings: [
{ id: "g", nodeId: "e", title: "结局", type: "good", description: "" },
],
}),
);
// Write a fake secrets.json so the UI's /api/v1/services endpoint reports
// DeepSeek as "connected". The dev server then returns DeepSeek's static
// model list from /api/v1/services/models, which allows ChatPage to
// auto-select a model and pass the sendMessage guard.
// The INKOS_AGENT_LLM_STUB=1 env var ensures no real API call is made.
await saveSecrets(E2E_ROOT, {
services: {
deepseek: { apiKey: "stub-key-e2e-not-real" },
},
});
// Pre-create a session so the directory tree is complete before the browser
// loads. ChatPage will POST /api/v1/chat/sessions to create its own session,
// but the directory must already exist.
const sessionId = `e2e-confirm-seed-${Date.now()}`;
await createAndPersistBookSession(
E2E_ROOT,
E2E_AUTHOR_ID,
sessionId,
"interactive-film-authoring",
);
}
+10 -8
View File
@@ -4,18 +4,20 @@ export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
use: {
baseURL: "http://localhost:4567",
baseURL: "http://localhost:4580",
headless: true,
screenshot: "only-on-failure",
},
// Reuse already-running dev server; start one if none is running.
// INKOS_PROJECT_ROOT points to test-project/ which has an inkos.json,
// so the API /api/v1/project endpoint returns valid JSON and the React
// app can reach the ready state.
// Always start a dedicated E2E server with INKOS_AGENT_LLM_STUB=1 so the
// agent uses the deterministic stub and never makes real LLM calls.
// reuseExistingServer: false ensures the stub env var is always active —
// if an existing dev server (started without the stub) were reused, the
// agent would attempt a real LLM call with the fake API key and hang.
// Ports 4580/4581 are dedicated to E2E to avoid conflict with the dev server.
webServer: {
command: "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../../test-project tsx watch --clear-screen=false src/api/index.ts & vite --host --port 4567 ; kill %1 2>/dev/null",
url: "http://localhost:4567",
reuseExistingServer: true,
command: "INKOS_AGENT_LLM_STUB=1 INKOS_STUDIO_PORT=4581 INKOS_PROJECT_ROOT=../../test-project tsx watch --clear-screen=false src/api/index.ts & INKOS_AGENT_LLM_STUB=1 INKOS_STUDIO_PORT=4581 vite --host --port 4580 ; kill %1 2>/dev/null",
url: "http://localhost:4580",
reuseExistingServer: false,
timeout: 120_000,
cwd: ".",
},
+1
View File
@@ -4024,6 +4024,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
sessionKind,
...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
},
details: { toolExecutions: collectedToolExecs },
});
}
@@ -529,6 +529,7 @@ function ProposedActionPreview({
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
data-testid="confirm-action"
onClick={() => onProposedAction?.(details)}
disabled={!onProposedAction || streaming || locked}
className="rounded-lg bg-primary px-3.5 py-2 text-[15px] leading-6 font-medium text-primary-foreground disabled:opacity-50"