删除 DirectProject 聊天 IPC 的死参数 prompt

Rust 侧删掉 chat_with_game_creator_direct_codex 的 prompt 入参:回合输入只由 canonical userItem 派生,该参数从未被读取

DirectProjectTurnInput 去掉 prompt 字段,提交与排队派发不再计算 @显示名 投影文本

界面断言改读 userItem.content,并删掉退役的附件 sidecar 用例与只断言「不存在」的终止用例

同步实施计划与附件路径映射两份技术方案里的 Direct 消息契约

共享记忆补记该死参数的口径与成因
This commit is contained in:
2026-09-21 14:27:23 +08:00
parent ab52805d3c
commit ec408bfafd
8 changed files with 60 additions and 162 deletions
@@ -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<String>,
client_turn_id: Option<String>,
@@ -17,7 +17,6 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER =
* `@` 引用与附件引用)都会让用户在确认之后拿到另一轮内容。
*/
export type DirectProjectTurnInput = {
prompt: string;
clientTurnId: string;
creationType?: HomeCreationType | null;
/**
@@ -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<string, unknown>) {
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<string, unknown>[] = [];
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<HTMLInputElement>(
'[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<string>((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({
@@ -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<string, unknown> | undefined)?.prompt ===
'再补一句玩法',
)?.[1] as Record<string, unknown> | undefined;
// 第二次 invoke 就是这条后续消息:回合入参里没有客户端投影文本可供筛选。
const followUpPayload = invoke.mock.calls
.filter(([command]) => command === 'chat_with_game_creator_direct_codex')
.at(-1)?.[1] as Record<string, unknown> | 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<string, unknown>
| 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`,
@@ -45,7 +45,6 @@ export function registerProjectConversationTests() {
// 在确认之后拿到另一轮内容。历史缺陷正是漏了 canonical user item 里的 @ 引用
// (引用被静默丢掉),所以这里把「首轮入参整体带过去」钉成硬约束。
const firstTurn = {
prompt: '用这张图改一下',
clientTurnId: 'direct-turn-1',
creationType: 'game' as const,
userItem: directCodexUserItemFromContent(
@@ -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`
@@ -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 回合展示唯一归属
@@ -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 断言覆盖。
### 不测