diff --git a/packages/core/src/agent/llm-stub.ts b/packages/core/src/agent/llm-stub.ts index 75d62552..54e96c3c 100644 --- a/packages/core/src/agent/llm-stub.ts +++ b/packages/core/src/agent/llm-stub.ts @@ -35,9 +35,29 @@ function lastUserText(context: { messages?: Array<{ role: string; content: unkno function alreadyProposed( context: { messages?: Array<{ role: string; content: unknown; toolName?: string }> }, ): boolean { - return (context.messages ?? []).some( - (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "propose_action", - ); + return (context.messages ?? []).some((m) => { + // Agent format (non-openai-completions): role="toolResult" with toolName + if (m.role === "toolResult" && (m as { toolName?: string }).toolName === "propose_action") { + return true; + } + // LLM format (openai-completions): assistant message with toolCall content + if (m.role === "assistant" && Array.isArray(m.content)) { + if ( + (m.content as Array<{ type: string; name?: string }>).some( + (c) => c.type === "toolCall" && c.name === "propose_action", + ) + ) { + return true; + } + } + // LLM format (openai-completions folded): tool result folded into user message string + if (m.role === "user" && typeof m.content === "string") { + if (/- propose_action \(/.test(m.content as string)) { + return true; + } + } + return false; + }); } /** diff --git a/packages/studio/src/__tests__/authoring-confirm-flow.test.ts b/packages/studio/src/__tests__/authoring-confirm-flow.test.ts new file mode 100644 index 00000000..2a461599 --- /dev/null +++ b/packages/studio/src/__tests__/authoring-confirm-flow.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStudioServer } from "../api/server.js"; +import { createAndPersistBookSession, loadStoryGraph } from "@actalk/inkos-core"; + +const INKOS_CONFIG = JSON.stringify({ + name: "test-project", + version: "0.1.0", + language: "zh", + llm: { model: "test-model", provider: "anthropic" }, + notify: [], +}); + +describe("interactive-film-authoring confirm flow (stubbed LLM)", () => { + let root: string; + const prev = process.env.INKOS_AGENT_LLM_STUB; + beforeAll(() => { process.env.INKOS_AGENT_LLM_STUB = "1"; }); + afterAll(() => { + if (prev === undefined) delete process.env.INKOS_AGENT_LLM_STUB; + else process.env.INKOS_AGENT_LLM_STUB = prev; + }); + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "if-confirm-")); + await writeFile(join(root, "inkos.json"), INKOS_CONFIG, "utf-8"); + await mkdir(join(root, "interactive-films", "p"), { recursive: true }); + }); + afterEach(async () => { await rm(root, { recursive: true, force: true }); }); + + it("free-text proposes draft_structure, confirm creates the graph", async () => { + const app = createStudioServer({} as never, root); + const sessionId = "1000000000-test"; + const bookId = "p"; + + // Pre-create the session so the agent endpoint can load it + await createAndPersistBookSession(root, bookId, sessionId, "interactive-film-authoring"); + + // Step 1: free-text instruction → stubbed agent proposes draft_structure via propose_action + const propose = await app.request("/api/v1/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction: "帮我搭一个三幕结构", + activeBookId: bookId, + sessionKind: "interactive-film-authoring", + actionSource: "free-text", + sessionId, + }), + }); + expect(propose.status).toBe(200); + + // Step 2: confirm the proposed action → executeConfirmedProductionAction runs draft_structure + // stubChatCompletion returns STRUCTURE_JSON (4 nodes) when prompt mentions "骨架/nodes/结构" + const confirm = await app.request("/api/v1/agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction: "搭建三幕分支结构", + activeBookId: bookId, + sessionKind: "interactive-film-authoring", + actionSource: "button", + requestedIntent: "draft_structure", + actionPayload: { draftStructure: { instruction: "三幕分支结构", projectId: bookId } }, + sessionId, + }), + }); + expect(confirm.status).toBe(200); + + // Assert the story graph was created with at least 4 nodes + const graph = await loadStoryGraph(root, bookId); + expect(graph?.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index 15b17992..67c2e9b5 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -3458,7 +3458,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o bookSession = updatedSession; } let activeBookConfig: { readonly language?: string } | null = null; - if (agentBookId) { + if (agentBookId && sessionKind !== "interactive-film-authoring") { try { activeBookConfig = await state.loadBookConfig(agentBookId); } catch {