删除 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(