删除 DirectProject 聊天 IPC 的死参数 prompt
Rust 侧删掉 chat_with_game_creator_direct_codex 的 prompt 入参:回合输入只由 canonical userItem 派生,该参数从未被读取 DirectProjectTurnInput 去掉 prompt 字段,提交与排队派发不再计算 @显示名 投影文本 界面断言改读 userItem.content,并删掉退役的附件 sidecar 用例与只断言「不存在」的终止用例 同步实施计划与附件路径映射两份技术方案里的 Direct 消息契约 共享记忆补记该死参数的口径与成因
This commit is contained in:
@@ -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>,
|
||||
|
||||
-1
@@ -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 断言覆盖。
|
||||
|
||||
### 不测
|
||||
|
||||
Reference in New Issue
Block a user