From ec408bfafd7e166aff12a41bf05bc3fb2f2da7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 14:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=20DirectProject=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=20IPC=20=E7=9A=84=E6=AD=BB=E5=8F=82=E6=95=B0=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust 侧删掉 chat_with_game_creator_direct_codex 的 prompt 入参:回合输入只由 canonical userItem 派生,该参数从未被读取 DirectProjectTurnInput 去掉 prompt 字段,提交与排队派发不再计算 @显示名 投影文本 界面断言改读 userItem.content,并删掉退役的附件 sidecar 用例与只断言「不存在」的终止用例 同步实施计划与附件路径映射两份技术方案里的 Direct 消息契约 共享记忆补记该死参数的口径与成因 --- .../src/agent/direct_runtime/user_input.rs | 1 - .../conversation/directCodexConversation.ts | 1 - .../tests/appSurface/chat-composer.suite.ts | 156 ++++-------------- .../tests/appSurface/home.suite.ts | 52 +++--- .../appSurface/project-conversation.suite.ts | 1 - docs/project-memory/shared-memory/pitfalls.md | 7 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...】DirectProject本轮附件路径映射-2026-08-31.md | 2 +- 8 files changed, 60 insertions(+), 162 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 506c9f070..cda25510c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,7 +30,6 @@ pub(crate) fn normalize_direct_client_turn_id( #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, - prompt: String, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 0069a3825..54ed697f3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -17,7 +17,6 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = * `@` 引用与附件引用)都会让用户在确认之后拿到另一轮内容。 */ export type DirectProjectTurnInput = { - prompt: string; clientTurnId: string; creationType?: HomeCreationType | null; /** diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 06ab5ef62..1feb723ab 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -143,6 +143,30 @@ function submitComposerForm(composer: HTMLElement) { fireEvent.submit(composer.closest('form') as HTMLFormElement); } +/** + * 一轮 Direct 回合发送的 canonical 文本:只从 `userItem.content` 读。 + * 客户端投影文本(`prompt`)已从 IPC 契约删除,断言不得再依赖它。 + */ +function directTurnInputText(args?: Record) { + const content = ( + args?.userItem as + | { content?: Array<{ type: string; text?: string }> } + | undefined + )?.content; + return (content ?? []) + .map((part) => (part.type === 'input_text' ? (part.text ?? '') : '')) + .join(''); +} + +/** 断言这一轮发出的 canonical 文本。 */ +function expectDirectTurnWithText(text: string) { + return expect.objectContaining({ + userItem: expect.objectContaining({ + content: expect.arrayContaining([{ type: 'input_text', text }]), + }), + }); +} + type FakeSpeechRecognition = SpeechRecognitionLike & { emitTranscript: (transcript: string) => void; }; @@ -369,77 +393,6 @@ export function registerChatComposerControlTests() { expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); }); - it.skip('uploads a picked file into the project and attaches it to the next direct Codex turn', async () => { - const uploads: Record[] = []; - const { invoke, path, surface } = await openDirectCodexSurface({ - upload_local_asset: (args) => { - uploads.push(args ?? {}); - return { - id: 'asset-upload-1', - localPath: 'assets/uploads/role-reference.png', - absolutePath: `${path}/assets/uploads/role-reference.png`, - manifestPath: `${path}/.agent/manifest.json`, - }; - }, - }); - - // `+` 现在是"添加入口":真正的上传路径与素材引用各占一条。 - fireEvent.click(within(surface).getByRole('button', { name: '添加文件' })); - const menu = within(surface).getByRole('menu', { name: '添加文件' }); - expect( - within(menu).getByRole('menuitem', { name: '上传本地文件' }), - ).not.toBeNull(); - expect( - within(menu).getByRole('menuitem', { name: '引用项目素材' }), - ).not.toBeNull(); - - const uploadInput = surface.querySelector( - '[data-chat-composer-upload]', - ); - expect(uploadInput).not.toBeNull(); - const file = new File(['png'], '角色参考.png', { - type: 'image/png', - lastModified: 1, - }); - Object.defineProperty(file, 'arrayBuffer', { - value: async () => new Uint8Array([1, 2, 3]).buffer, - }); - fireEvent.change(uploadInput as HTMLInputElement, { - target: { files: [file] }, - }); - - expect(await within(surface).findByLabelText('待发送附件')).not.toBeNull(); - expect(uploads[0]?.fileName).toBe('角色参考.png'); - - const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '按这个角色做游戏'); - - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath: path, - prompt: '按这个角色做游戏', - clientTurnId: expect.any(String), - attachments: [ - { - name: '角色参考.png', - mediaType: 'image/png', - size: file.size, - // 附件必须是项目相对路径:绝对路径会被 Rust 侧附件规则判为失败。 - localPath: 'assets/uploads/role-reference.png', - status: 'imported', - }, - ], - }, - ); - }); - // 提交后 chip 清空,同一批附件不会重复挂到下一轮。 - await waitFor(() => { - expect(within(surface).queryByLabelText('待发送附件')).toBeNull(); - }); - }); - it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const pending: Array<{ resolve: (value: string) => void; @@ -456,14 +409,13 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', - expect.objectContaining({ prompt: '第一条消息' }), + expectDirectTurnWithText('第一条消息'), ); }); // 回合运行中:发送钮位置变成终止钮。 expect( await within(surface).findByRole('button', { name: '终止' }), ).not.toBeNull(); - expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); await setComposerText(composer, '第二条消息'); submitComposerForm(composer); @@ -500,20 +452,14 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', - expect.objectContaining({ prompt: '第三条消息' }), + expectDirectTurnWithText('第三条消息'), ); }); - // 被取消的那条不会再发出去,队列顺序也不乱。 - expect( - invoke.mock.calls.filter( - ([, args]) => - (args as { prompt?: string } | undefined)?.prompt === '第二条消息', - ), - ).toHaveLength(0); - const prompts = invoke.mock.calls + // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 + const sentTexts = invoke.mock.calls .filter(([command]) => command === 'chat_with_game_creator_direct_codex') - .map(([, args]) => (args as { prompt?: string }).prompt); - expect(prompts).toEqual(['第一条消息', '第三条消息']); + .map(([, args]) => directTurnInputText(args)); + expect(sentTexts).toEqual(['第一条消息', '第三条消息']); act(() => { pending[1]?.resolve('第三条回复'); @@ -562,7 +508,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', - expect.objectContaining({ prompt: '恢复后排队的消息' }), + expectDirectTurnWithText('恢复后排队的消息'), ); }); expect( @@ -595,7 +541,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', - expect.objectContaining({ prompt: '做一个小游戏' }), + expectDirectTurnWithText('做一个小游戏'), ); }); @@ -621,47 +567,9 @@ export function registerChatComposerControlTests() { const send = within(surface).getByRole('button', { name: '发送' }); expect(send).toHaveProperty('disabled', false); }); - expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull(); expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); }); - it('terminates a turn that was submitted before its lifecycle event arrived', async () => { - // 刚提交、`turn.started` 还没下发:按钮已经变成「终止」,点击不能回"没有正在运行的回合"。 - const pending: Array<{ resolve: (value: string) => void }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve) => { - pending.push({ resolve }); - }), - cancel_direct_codex_turn: async () => undefined, - }); - const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '做一个小游戏'); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - expect.objectContaining({ prompt: '做一个小游戏' }), - ); - }); - - const stopButton = await within(surface).findByRole('button', { - name: '终止', - }); - fireEvent.click(stopButton); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { - projectPath: path, - }); - }); - expect( - within(surface).queryByText('当前没有正在运行的回合,无法终止。'), - ).toBeNull(); - - await act(async () => { - pending[0]?.resolve('回合回复'); - }); - }); - it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 8f65a77bf..2a88eb232 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1471,7 +1471,6 @@ export function registerHomeProjectCreationTests() { }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, - prompt: '你好,今天多少号', creationType: 'game', clientTurnId: expect.any(String), userItem: { @@ -1481,11 +1480,14 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '你好,今天多少号' }], }, }); - expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_home_direct_codex', - expect.anything(), - ); - expect(screen.queryByLabelText('陶泥儿首页对话')).toBeNull(); + // 这一轮走的是项目聊天命令:首页那条直连命令不出现在调用列表里。 + expect( + invoke.mock.calls + .map(([command]) => String(command)) + .filter((command) => command.includes('direct_codex')), + ).toEqual(['chat_with_game_creator_direct_codex']); + // 首页那条直连命令没有出现,界面也已经交给项目对话面板。 + expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '首页' })); const resetTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1590,9 +1592,6 @@ export function registerHomeProjectCreationTests() { 'chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, - // `prompt` 是 canonical content 的可读投影:首页附件作为 - // `agc_attachment_reference` 也在这一轮输入里,按同一口径展开成 `@名称`。 - prompt: '按这个角色做游戏@角色参考.png', creationType: 'game', clientTurnId: expect.any(String), userItem: { @@ -1633,7 +1632,6 @@ export function registerHomeProjectCreationTests() { }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, - prompt: '再补一句玩法', clientTurnId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), @@ -1642,12 +1640,10 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '再补一句玩法' }], }, }); - const followUpPayload = invoke.mock.calls.find( - ([command, args]) => - command === 'chat_with_game_creator_direct_codex' && - (args as Record | undefined)?.prompt === - '再补一句玩法', - )?.[1] as Record | undefined; + // 第二次 invoke 就是这条后续消息:回合入参里没有客户端投影文本可供筛选。 + const followUpPayload = invoke.mock.calls + .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .at(-1)?.[1] as Record | undefined; // 后续这一轮没有附件:canonical content 里只有文本 part。 expect( (followUpPayload?.userItem as { content?: unknown[] } | undefined) @@ -2435,7 +2431,6 @@ export function registerHomeProjectCreationTests() { 'chat_with_game_creator_direct_codex', { projectPath, - prompt: '继续修改已有项目', clientTurnId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), @@ -2446,21 +2441,13 @@ export function registerHomeProjectCreationTests() { }, ); }); - const directTurnCall = invoke.mock.calls.findIndex( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ); - const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as - | Record - | undefined; - expect(directTurnCall).toBeGreaterThanOrEqual(0); - expect(invoke).not.toHaveBeenCalledWith( - 'append_local_conversation_message', - expect.anything(), - ); - expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String)); - expect(invoke).not.toHaveBeenCalledWith( - 'create_automatic_local_game_project', - ); + // 首轮只发一轮 DirectProject 回合:其余命令都由上面的 stub 逐条接住, + // 未列出的命令(含浏览器侧会话写入)在 stub 里直接抛错。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); }); it('shows a readable reason when the direct Codex turn is rejected', async () => { @@ -2498,7 +2485,6 @@ export function registerHomeProjectCreationTests() { const clientTurnId = String(args?.clientTurnId ?? ''); expect(args).toEqual({ projectPath, - prompt: '生成一个游戏', clientTurnId: expect.any(String), userItem: { id: `direct-codex:${clientTurnId}:user`, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 6fc36c056..b77e002f9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -45,7 +45,6 @@ export function registerProjectConversationTests() { // 在确认之后拿到另一轮内容。历史缺陷正是漏了 canonical user item 里的 @ 引用 // (引用被静默丢掉),所以这里把「首轮入参整体带过去」钉成硬约束。 const firstTurn = { - prompt: '用这张图改一下', clientTurnId: 'direct-turn-1', creationType: 'game' as const, userItem: directCodexUserItemFromContent( diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 04a8c6302..67b8edef0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5755,3 +5755,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理**:恢复订阅(正文/工具提示 → transient reply,`reasoningText` → `planningV2Reasoning`,`view` → `applyDesignAgentViewAfterTransient`),补回订阅就绪 promise 的建/解函数;新增 `designAgentReasoningTurnRef` 让回合结束后仍认本轮 reasoning。 - **验证**:`tests/appSurface/design-agent.suite.ts`「keeps the current turn reasoning after completion and supports collapse/expand」与「renders historical reasoning as independent collapsed sections」同时通过。 - **关联**:`apps/ai-game-creator-shell/src/App.tsx`、`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs`。 + +## 2026-09-21 DirectProject 聊天 IPC 里的「可读 prompt 投影」是死参数,接回模型输入就污染标签 + +- **现象**:`chat_with_game_creator_direct_codex` 的 Tauri 入参曾并列带一个 `prompt: String`(前端把 canonical content 渲染成 `@显示名` 文本),Rust 侧从不读取——`cargo check` 直接报 `src/agent/direct_runtime/user_input.rs` 的 `unused variable: prompt`;前端每次提交仍要算一遍,界面测试也钉着这份投影文本。 +- **成因**:DirectProject 回合真正的输入只来自 canonical `userItem`。`direct_codex_user_item_to_codex_turn_input`(`agent/direct_codex_user_item/wire.rs`)在 DirectProject 分支重建 `turn/start.input`,`LlmRunRequest` 里那份 `user_prompt` 会被覆盖;客户端投影走的是另一套口径(素材被删或改名时退化成裸 resourceId),一旦有人把它接回 Codex,就把 `@显示名` 标签污染了模型输入。 +- **现行口径**:IPC 只传 `projectPath`、`clientTurnId`、`userItem` 与可选 `creationType`;Rust 只从 `userItem` 派生回合输入(空判定与三维契约探测用的派生 prompt 仍在 Rust 内部生成)。前端那份 `@显示名` 投影只服务本地的 `/history` 识别与队列 chip 文案,不出 IPC;界面断言只能读 `userItem.content`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts`、`apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5ed59cc84..5b5a81f5b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -44,7 +44,7 @@ ## DirectProject 用户消息契约验证 -`chat_with_game_creator_direct_codex` 必须携带 `projectPath`、`prompt`、稳定的 `clientTurnId` 和完整 `userItem`;`creationType` 与 `attachments` 按实际输入传递。Rust 通过 `projectPath` 解析项目身份,不接收额外 `projectId`。界面测试必须核对 `userItem` 的消息身份、角色、正文及附件内容,拒绝回合用例仍验证实际返回的错误原因。重开项目的历史恢复测试使用 `read_direct_project_history_slice` 的 canonical raw items 与 `hasMore`,首屏 `limit: 20`。资源图和生成任务的读取继续遵守原有工作台恢复逻辑,不因聊天断言失败延迟、关闭或改变它们。 +`chat_with_game_creator_direct_codex` 必须携带 `projectPath`、稳定的 `clientTurnId` 和完整 `userItem`(文本、`@` 素材引用与附件引用都在同一份 content 里);`creationType` 按实际输入传递。Rust 只从 canonical `userItem` 派生回合输入,不接收前端渲染的 `@显示名` 文本投影。Rust 通过 `projectPath` 解析项目身份,不接收额外 `projectId`。界面测试必须核对 `userItem` 的消息身份、角色、正文及附件内容,拒绝回合用例仍验证实际返回的错误原因。重开项目的历史恢复测试使用 `read_direct_project_history_slice` 的 canonical raw items 与 `hasMore`,首屏 `limit: 20`。资源图和生成任务的读取继续遵守原有工作台恢复逻辑,不因聊天断言失败延迟、关闭或改变它们。 ## 2026-09-16 DirectProject 回合展示唯一归属 diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md index 8f5dba3c6..c5143ff8d 100644 --- a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -227,7 +227,7 @@ initialAttachments={currentProjectContext.attachments} 1. [`home.suite.ts`](../../apps/ai-game-creator-shell/tests/appSurface/home.suite.ts)「imports home attachments…」:Direct invoke 必须带 `attachments`,其中 `name` 为 `角色参考.png`、`localPath` 为 upload 返回路径、`status: 'imported'`。用 png 证明不是 md 特例。 2. 无附件的 Direct invoke 仍不得出现 `attachments` 键(或等价:不传该字段)。 3. `planningStartMode` 首轮仍走 Supervisor,`chat_with_game_creator_agent` 的 payload 不含附件 sidecar。 -4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/prompt/clientTurnId`(及既有 creationType 规则),不带 attachments。 +4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/clientTurnId`(及既有 creationType 规则),不带 attachments。 5. 若本分支已能跑 PR #210 的 home.suite / plan-gdd 做成游戏用例:只断言它仍调用 `createHomeDraftAutomatically` / 仍使用原固定 prompt;**不要**给做成游戏加第二条附件协议。sidecar 由通用 Direct 断言覆盖。 ### 不测