删除 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(
|
||||
|
||||
Reference in New Issue
Block a user