diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts index 7defe9f8..7faa97a4 100644 --- a/packages/core/src/agent/agent-session.ts +++ b/packages/core/src/agent/agent-session.ts @@ -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) => { diff --git a/packages/studio/e2e/authoring-confirm.spec.ts b/packages/studio/e2e/authoring-confirm.spec.ts new file mode 100644 index 00000000..5ac405a4 --- /dev/null +++ b/packages/studio/e2e/authoring-confirm.spec.ts @@ -0,0 +1,71 @@ +/** + * Real-browser multi-step E2E: user drives the agent authoring confirm flow. + * + * Flow: + * 1. Open the film tree page (#/film/) — 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/ + 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); +}); diff --git a/packages/studio/e2e/fixtures/seed-authoring-confirm.ts b/packages/studio/e2e/fixtures/seed-authoring-confirm.ts new file mode 100644 index 00000000..2085bc5f --- /dev/null +++ b/packages/studio/e2e/fixtures/seed-authoring-confirm.ts @@ -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//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 { + // 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", + ); +} diff --git a/packages/studio/playwright.config.ts b/packages/studio/playwright.config.ts index d2191821..c60b47d6 100644 --- a/packages/studio/playwright.config.ts +++ b/packages/studio/playwright.config.ts @@ -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: ".", }, diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index 67c2e9b5..f27a39d2 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -4024,6 +4024,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o sessionKind, ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}), }, + details: { toolExecutions: collectedToolExecs }, }); } diff --git a/packages/studio/src/components/chat/ToolExecutionSteps.tsx b/packages/studio/src/components/chat/ToolExecutionSteps.tsx index 9e46dc21..48f71de8 100644 --- a/packages/studio/src/components/chat/ToolExecutionSteps.tsx +++ b/packages/studio/src/components/chat/ToolExecutionSteps.tsx @@ -529,6 +529,7 @@ function ProposedActionPreview({