({
+ trees: uiTrees,
+ cursor: null,
+ });
+ const lastCursor = cycle.trees === uiTrees ? cycle.cursor : null;
const focusNext = useCallback(() => {
const target = getNextMatchingUiTreeNodeTarget(
@@ -29,7 +40,10 @@ export function useUiTreeNodeCycle({
matches,
);
if (!target) return;
- setLastCursor({ treeId: target.treeId, nodeId: target.node.id });
+ setCycle({
+ trees: uiTrees,
+ cursor: { treeId: target.treeId, nodeId: target.node.id },
+ });
onFocusNode(target.treeId, target.node.id);
}, [lastCursor, matches, onFocusNode, uiTrees]);
diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
index 0bea3af72..6575d7c54 100644
--- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
+++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
@@ -916,7 +916,8 @@ describe('UiEditorPage', () => {
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('ui/generated-example.js'),
);
- expect(screen.getByRole('button', { name: '已复制' })).toBeTruthy();
+ // 复制态在剪贴板写入 resolve 之后才提交,必须等它落盘再断言。
+ expect(await screen.findByRole('button', { name: '已复制' })).toBeTruthy();
});
it('shows a save-success modal without a generated path', async () => {
diff --git a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts
new file mode 100644
index 000000000..fe73af837
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts
@@ -0,0 +1,96 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ isNeedReview,
+ type UiTreeNodeTarget,
+} from '../src/features/ui-editor/stageStatusOverview';
+import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
+import type { UITree } from '../src/features/ui-editor/types/UITree';
+import { useUiTreeNodeCycle } from '../src/view/ui-editor/useUiTreeNodeCycle';
+
+function node(
+ id: string,
+ layoutStatus: UiNode['metadata']['layout_status'],
+): UiNode {
+ return {
+ id,
+ layout: {
+ transform: {
+ anchor_min: [0, 0],
+ anchor_max: [1, 1],
+ offset_min: [0, 0],
+ offset_max: [0, 0],
+ },
+ custom_minimum_size: [0, 0],
+ size_flags_horizontal: 1,
+ size_flags_vertical: 1,
+ size_flags_stretch_ratio: 1,
+ container: 'None',
+ },
+ metadata: {
+ name: id,
+ description: '',
+ layout_status: layoutStatus,
+ component_status: 'NoProblem',
+ allow_llm_edit_layout: true,
+ allow_llm_edit_component: true,
+ source: 'System',
+ },
+ component: null,
+ children_display_mode: undefined,
+ offset: { min: [0, 0], max: [0, 0] },
+ children: [],
+ };
+}
+
+function reviewTree(): UITree {
+ return {
+ src_ui_design: 'page',
+ root: {
+ ...node('page-root', 'NoProblem'),
+ children: [
+ node('review-a', { NeedReview: '请检查 0' }),
+ node('review-b', { NeedReview: '请检查 1' }),
+ node('review-c', { NeedReview: '请检查 2' }),
+ ],
+ },
+ };
+}
+
+const matchesReviewNode = ({ node }: UiTreeNodeTarget) =>
+ isNeedReview(node.metadata.layout_status);
+
+describe('useUiTreeNodeCycle', () => {
+ it('按顺序循环待检查节点,并在界面树整体替换后重新从第一项开始', () => {
+ const uiTrees = [reviewTree()];
+ const onFocusNode = vi.fn();
+ const { result, rerender } = renderHook(
+ ({ trees }: { trees: UITree[] }) =>
+ useUiTreeNodeCycle({
+ uiTrees: trees,
+ matches: matchesReviewNode,
+ onFocusNode,
+ }),
+ { initialProps: { trees: uiTrees } },
+ );
+
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
+
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-b');
+
+ // 同一版界面树上的无关渲染不得重置循环游标。
+ rerender({ trees: uiTrees });
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-c');
+
+ // 界面树被整体替换时,下一项回到第一项。
+ rerender({ trees: [...uiTrees] });
+ act(() => result.current.focusNext());
+ expect(onFocusNode).toHaveBeenLastCalledWith('page', 'review-a');
+ });
+});
From 6bf57c1e51e1e017aef5e474eebd8867e76b5415 Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:43:17 +0800
Subject: [PATCH 2/9] =?UTF-8?q?AGC=20=E6=8A=8A=E6=89=A7=E8=A1=8C=E5=99=A8?=
=?UTF-8?q?=E4=B8=8E=E7=BA=BF=E5=8D=8F=E8=AE=AE=E6=8B=86=E6=88=90=E6=AD=A3?=
=?UTF-8?q?=E4=BA=A4=E7=BB=B4=E5=BA=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AgcAgentMode 回退为仅 codex/cc,只表示执行器;新增 AgcModelProtocol 表示线协议
协议取值 openai_responses/openai_chat/anthropic,与执行器组合封闭并在校验时失败关闭
存量目录缺 protocol 时按执行器推导;历史误写的 agentMode=anthropic 读取时归一为 cc
后台 AGC 模型页拆成 Agent 模式与协议两列,新增协议下拉与对应测试
公开目录 /api/llm/models 与后台 DTO 同时下发解析后的 protocol
客户端持久化 selectedModelProtocol,选模型时按执行器推导兜底协议并校验组合
Codex wire_api 按协议在 responses/chat 间切换,不再硬编码 responses
同步 appSurface 与模型选择测试断言,补 protocol 字段
同步模型别名文档与后端架构文档的正交维度说明
---
apps/admin-web/src/api/adminApiTypes.ts | 8 +-
.../src/pages/AdminAgcModelsPage.test.tsx | 46 ++++++
.../src/pages/AdminAgcModelsPage.tsx | 19 ++-
.../src/agent/codex_app_server/mod.rs | 27 +++-
.../src-tauri/src/commands.rs | 29 +++-
.../src-tauri/src/config.rs | 3 +
.../src-tauri/src/llm_catalog.rs | 8 +
.../src-tauri/src/main.rs | 7 +
.../ConversationModelSelect.tsx | 23 +++
.../src/services/clientApi.ts | 5 +-
.../tests/appSurface/design-agent.suite.ts | 1 +
.../tests/appSurface/home.suite.ts | 1 +
.../tests/conversationModelSelect.test.tsx | 5 +
...方案】AGC后台模型别名与对话选择-2026-09-05.md | 6 +-
...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +-
server-rs/crates/api-server/src/agc_models.rs | 27 +++-
server-rs/crates/api-server/src/llm/mod.rs | 14 +-
.../crates/module-runtime/src/agc_models.rs | 143 ++++++++++++++++--
.../crates/shared-contracts/src/admin.rs | 4 +
server-rs/crates/shared-contracts/src/llm.rs | 3 +
20 files changed, 346 insertions(+), 35 deletions(-)
diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts
index c3a9e7256..1f7ffeec2 100644
--- a/apps/admin-web/src/api/adminApiTypes.ts
+++ b/apps/admin-web/src/api/adminApiTypes.ts
@@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
-export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
+export type AdminAgcAgentMode = 'codex' | 'cc';
+export type AdminAgcModelProtocol =
+ | 'openai_responses'
+ | 'openai_chat'
+ | 'anthropic';
export interface AdminAgcModel {
id: string;
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
+ /** 上游线协议,与执行器正交。 */
+ protocol?: AdminAgcModelProtocol;
}
export interface AdminAgcModelCatalog {
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
index 77b9262bb..612d21826 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
@@ -104,3 +104,49 @@ test('new models default to Codex and save the selected Agent mode', async () =>
});
});
});
+
+test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
+ const catalog = {
+ revision: 3,
+ defaultModelId: 'quality',
+ models: [
+ {
+ id: 'quality',
+ alias: '高质量',
+ modelId: 'claude-opus-5-5',
+ enabled: true,
+ agentMode: 'cc' as const,
+ protocol: 'anthropic' as const,
+ },
+ ],
+ };
+ vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
+ vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
+ ...input,
+ revision: 4,
+ }));
+ render();
+ await screen.findByDisplayValue('claude-opus-5-5');
+
+ const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement;
+ const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
+ expect(agentMode.value).toBe('cc');
+ expect(protocol.value).toBe('anthropic');
+ // 执行器下拉里不再混入协议取值。
+ expect(
+ Array.from(agentMode.options).map((option) => option.value),
+ ).toEqual(['codex', 'cc']);
+
+ fireEvent.change(agentMode, { target: { value: 'codex' } });
+ fireEvent.change(protocol, { target: { value: 'openai_chat' } });
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
+
+ await waitFor(() => {
+ const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
+ const payload = calls[calls.length - 1]?.[1];
+ expect(payload?.models[0]).toMatchObject({
+ agentMode: 'codex',
+ protocol: 'openai_chat',
+ });
+ });
+});
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
index cab805cf7..b009aa222 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
+ { key: 'protocol', label: '协议' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
modelId: '',
enabled: true,
agentMode: 'codex',
+ protocol: 'openai_responses',
},
],
},
@@ -226,7 +228,22 @@ export function AdminAgcModelsPage({
>
-
+
+
+ |
+
|
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
index d81e102aa..3abf9ef84 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
@@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command(
)
}
+/// Codex 的 `wire_api` 取自所选模型的线协议。
+///
+/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定
+/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。
+fn game_creator_codex_provider_wire_api() -> &'static str {
+ #[cfg(test)]
+ {
+ "responses"
+ }
+ #[cfg(not(test))]
+ {
+ match crate::config::load_game_creator_app_config()
+ .ok()
+ .and_then(|config| config.selected_model_protocol)
+ .as_deref()
+ {
+ Some("openai_chat") => "chat",
+ _ => "responses",
+ }
+ }
+}
+
fn configure_game_creator_codex_app_server_command_for_mode(
command: &mut tokio::process::Command,
llm: &GameCreatorLlmConfig,
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
let provider_token = provider_proxy
.map(CodexProviderProxy::downstream_bearer_token)
.unwrap_or(legacy_api_key);
+ // 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
+ // 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
+ let wire_api = game_creator_codex_provider_wire_api();
command
.arg("-c")
.arg(format!(
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
.arg("-c")
.arg(format!(
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
- quoted_toml_string("responses")?
+ quoted_toml_string(wire_api)?
))
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
index 9be6df9bb..565de0461 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
@@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner(
model_id: String,
is_default: bool,
catalog_agent_mode: Option,
+ catalog_protocol: Option,
) -> Result {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
@@ -1098,13 +1099,34 @@ fn select_game_creator_model_inner(
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
- // `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
- "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
+ "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
+ // 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。
+ let protocol = match catalog_protocol.as_deref().map(str::trim) {
+ Some("openai_responses") => "openai_responses",
+ Some("openai_chat") => "openai_chat",
+ Some("anthropic") => "anthropic",
+ Some("") | None => {
+ if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
+ "anthropic"
+ } else {
+ "openai_responses"
+ }
+ }
+ Some(value) => return Err(format!("模型协议无效:{value}")),
+ };
+ let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI;
+ if claude != (protocol == "anthropic") {
+ return Err(format!(
+ "执行器与协议组合无效:{} 不支持 {protocol}",
+ config.agent_mode
+ ));
+ }
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
+ config.selected_model_protocol = Some(protocol.to_string());
persist_game_creator_app_config(config, overlays, true)
}
@@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model(
model_id: String,
is_default: bool,
agent_mode: Option,
+ protocol: Option,
) -> Result {
- select_game_creator_model_inner(model_id, is_default, agent_mode)
+ select_game_creator_model_inner(model_id, is_default, agent_mode, protocol)
}
fn persist_game_creator_app_config(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs
index 6eb153861..2eaf16112 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/config.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs
@@ -4037,6 +4037,9 @@ fn merge_game_creator_config_content(
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
config.selected_model_is_default = selected_model_is_default;
}
+ if let Some(selected_model_protocol) = file_config.selected_model_protocol {
+ config.selected_model_protocol = Some(selected_model_protocol);
+ }
Ok(())
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs
index 0944e4f9f..7e89cb64f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs
@@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String {
"codex".to_string()
}
+fn default_model_protocol() -> String {
+ "openai_responses".to_string()
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireModelSummary {
@@ -23,6 +27,9 @@ struct WireModelSummary {
display_name: String,
#[serde(default = "default_codex_agent_mode")]
agent_mode: String,
+ /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
+ #[serde(default = "default_model_protocol")]
+ protocol: String,
}
#[derive(Debug, Deserialize)]
@@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
id: model.id,
display_name: model.display_name,
agent_mode: model.agent_mode,
+ protocol: model.protocol,
})
.collect(),
revision: catalog.revision,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs
index ea1b8dc56..befa993db 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/main.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs
@@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile {
selected_model_id: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option,
+ /// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ selected_model_protocol: Option,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -873,6 +876,9 @@ struct GameCreatorAppConfig {
selected_model_id: String,
#[serde(default)]
selected_model_is_default: bool,
+ /// 所选模型的上游线协议;缺省按执行器推导。
+ #[serde(default)]
+ selected_model_protocol: Option,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1384,6 +1390,7 @@ impl Default for GameCreatorAppConfig {
editor_api: GameCreatorEditorApiConfig::default(),
selected_model_id: String::new(),
selected_model_is_default: false,
+ selected_model_protocol: None,
}
}
}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
index b7b44234b..2bc9df75c 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
@@ -19,6 +19,22 @@ import {
refreshLlmModelCatalog,
} from '../../services/llmModelCatalog';
+/**
+ * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。
+ */
+function resolveModelProtocol(
+ model: { agentMode?: 'codex' | 'cc'; protocol?: string } | undefined,
+): 'openai_responses' | 'openai_chat' | 'anthropic' {
+ if (
+ model?.protocol === 'openai_responses' ||
+ model?.protocol === 'openai_chat' ||
+ model?.protocol === 'anthropic'
+ ) {
+ return model.protocol;
+ }
+ return model?.agentMode === 'cc' ? 'anthropic' : 'openai_responses';
+}
+
export type ConversationModelSelectHandle = {
/** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */
ensureUsable: () => Promise;
@@ -174,6 +190,9 @@ export function ConversationModelSelect({
agentMode:
catalog.models.find((model) => model.id === next)?.agentMode ??
'codex',
+ protocol: resolveModelProtocol(
+ catalog.models.find((model) => model.id === next),
+ ),
}),
);
} catch {
@@ -341,11 +360,15 @@ export function ConversationModelSelect({
if (!invoke) throw new Error('Native host unavailable');
const agentMode =
models.find((model) => model.id === id)?.agentMode ?? 'codex';
+ const protocol = resolveModelProtocol(
+ models.find((model) => model.id === id),
+ );
const result = await queueConfigWrite(() =>
invoke('select_game_creator_model', {
modelId: id,
isDefault: false,
agentMode,
+ protocol,
}),
);
if (result.config.selectedModelId !== id)
diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts
index 2221e5cfd..5c1bff3cc 100644
--- a/apps/ai-game-creator-shell/src/services/clientApi.ts
+++ b/apps/ai-game-creator-shell/src/services/clientApi.ts
@@ -3,7 +3,10 @@ import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
- agentMode: 'codex' | 'cc' | 'anthropic';
+ /** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */
+ agentMode: 'codex' | 'cc';
+ /** 上游线协议;服务端未下发时按执行器的默认协议解释。 */
+ protocol?: 'openai_responses' | 'openai_chat' | 'anthropic';
};
export type ClientLlmModelCatalog = {
diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
index 15ec5f80d..d96c09714 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts
@@ -282,6 +282,7 @@ export function registerDesignAgentSurfaceTests() {
modelId: 'fast',
isDefault: false,
agentMode: 'codex',
+ protocol: 'openai_responses',
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
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 912be89a7..c0c9a5327 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -149,6 +149,7 @@ export function registerClientHomeTests() {
modelId: 'fast',
isDefault: false,
agentMode: 'codex',
+ protocol: 'openai_responses',
}),
);
expect(modelTrigger.textContent).toContain('快速');
diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
index 9208b832b..c2a5c252f 100644
--- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
+++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
@@ -130,6 +130,7 @@ test('custom mode only shows checked endpoint model IDs and never requests the p
modelId: 'vendor/fast:latest',
isDefault: false,
agentMode: 'codex',
+ protocol: 'openai_responses',
});
});
@@ -290,6 +291,7 @@ test('only displays aliases and persists selection through the native command',
modelId: 'fast',
isDefault: false,
agentMode: 'cc',
+ protocol: 'anthropic',
}),
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
@@ -320,6 +322,7 @@ test('falls back to the default model when the saved selection was removed', asy
modelId: 'quality',
isDefault: true,
agentMode: 'codex',
+ protocol: 'openai_responses',
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
@@ -529,6 +532,7 @@ test('pre-send validation falls back when the selected model is disabled', async
modelId: 'quality',
isDefault: true,
agentMode: 'codex',
+ protocol: 'openai_responses',
});
});
@@ -554,6 +558,7 @@ test('follows the new server default when the saved selection was the default',
modelId: 'fast',
isDefault: true,
agentMode: 'cc',
+ protocol: 'anthropic',
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
index 347575fbf..d14ae6087 100644
--- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
+++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
@@ -55,8 +55,10 @@
## 模型绑定 Agent 执行模式
-- 后台 AGC 模型目录每项新增 `agentMode`,接受 `codex`、`cc` 与 `anthropic`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。`anthropic` 是 2026-10-01 新增的显式 Anthropic Messages 协议值,`cc` 保留为同一 Claude 执行器的历史别名(两者都映射到 `claude_code_cli`),新目录项应直接写 `anthropic`。
-- 2026-10-01 补充:`cc`/`anthropic` 一律经平台 LLM 网关的独立 Anthropic 路由 `POST /api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。
+- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。
+- 2026-10-01 修正(原先误把协议塞进 `agentMode`):**执行器与线协议是两个正交维度**。目录新增独立字段 `protocol`,取值 `openai_responses` / `openai_chat` / `anthropic`;`agentMode` 只表示客户端启动哪个执行器。合法组合是封闭的——`codex` 只能用 `openai_responses` 或 `openai_chat`,`cc` 只能用 `anthropic`,其余组合在后台保存与目录加载时都失败关闭。存量目录缺少 `protocol` 时按执行器推导(codex → `openai_responses`,cc → `anthropic`);历史误写的 `agentMode: "anthropic"` 读取时归一到 `cc`。
+- 选路由协议决定:`openai_responses` → `/api/llm/v1/responses`,`openai_chat` → `/api/llm/v1/chat/completions`,`anthropic` → 独立路由 `/api/llm/anthropic/{*path}`。客户端把所选模型的协议与执行器一起持久化(`agentMode` + `selectedModelProtocol`),Codex 的 `wire_api` 按协议在 `responses` / `chat` 间切换。
+- 2026-10-01 补充:`cc` 一律经平台 LLM 网关的独立 Anthropic 路由,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。
- `codex` 仍表示现有 AGC Codex app-server 执行链路;`cc` 表示 AGC 客户端启动随包的 Claude Agent SDK sidecar,模型的 `modelId` 原样作为 Claude Agent SDK 的模型标识,不把 Claude 协议伪装成 OpenAI Responses。sidecar 随 AGC 安装包携带 SDK 及匹配平台的 Claude Code runtime,用户不需要预先安装 `claude` 命令。
- `/api/llm/models` 的启用模型摘要返回 `agentMode`,后台完整目录、管理 DTO 和客户端目录保持同一绑定快照。客户端在选择模型时同时持久化模型 ID 与执行模式;默认模型变化、模型被停用或目录刷新回退时一并更新执行模式。
- 客户端设置保存不得把后台模型绑定覆盖回 Codex。旧客户端配置缺少执行模式时继续按 Codex 运行;旧后台响应缺少 `agentMode` 时客户端按 Codex 兼容。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index fb31bb682..94f2a7a51 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -536,7 +536,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录。
- 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug,`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。
-- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。
+- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态、默认项,以及**执行器 `agentMode` 与线协议 `protocol` 两个正交维度**:`agentMode` 只取 `codex` / `cc`,`protocol` 只取 `openai_responses` / `openai_chat` / `anthropic`,合法组合封闭(codex ⇒ responses/chat,cc ⇒ anthropic),非法组合在保存与加载两侧都失败关闭;存量目录缺 `protocol` 时按执行器推导。客户端 `GET /api/llm/models` 返回启用项的稳定标识、别名、`agentMode`、解析后的 `protocol` 和目录 `revision`(供条件刷新,不暴露实际模型名)。
- Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。
### `error_report`
diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs
index 391e32f5f..4875fec63 100644
--- a/server-rs/crates/api-server/src/agc_models.rs
+++ b/server-rs/crates/api-server/src/agc_models.rs
@@ -9,6 +9,7 @@ use axum::{
};
use module_runtime::{
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelCatalog,
+ AgcModelProtocol,
};
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
use spacetime_client::SpacetimeClientError;
@@ -235,12 +236,21 @@ pub async fn admin_save_agc_models(
let agent_mode = AgcAgentMode::parse(&m.agent_mode).map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
})?;
+ let protocol = m
+ .protocol
+ .as_deref()
+ .map(AgcModelProtocol::parse)
+ .transpose()
+ .map_err(|message| {
+ AppError::from_status(StatusCode::BAD_REQUEST).with_message(message)
+ })?;
Ok(module_runtime::AgcModel {
id: m.id,
alias: m.alias,
model_id: m.model_id,
enabled: m.enabled,
agent_mode,
+ protocol,
})
})
.collect::, AppError>>()?;
@@ -277,12 +287,17 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
models: catalog
.models
.into_iter()
- .map(|m| AdminAgcModel {
- id: m.id,
- alias: m.alias,
- model_id: m.model_id,
- enabled: m.enabled,
- agent_mode: m.agent_mode.as_str().to_string(),
+ .map(|m| {
+ // 回读给出解析后的具体协议,后台不会看到空值。
+ let protocol = m.resolved_protocol().as_str().to_string();
+ AdminAgcModel {
+ id: m.id,
+ alias: m.alias,
+ model_id: m.model_id,
+ enabled: m.enabled,
+ agent_mode: m.agent_mode.as_str().to_string(),
+ protocol: Some(protocol),
+ }
})
.collect(),
}
diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs
index 3ff4dc75c..b97a8da5d 100644
--- a/server-rs/crates/api-server/src/llm/mod.rs
+++ b/server-rs/crates/api-server/src/llm/mod.rs
@@ -198,11 +198,15 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
.models
.into_iter()
.filter(|model| model.enabled)
- .map(|model| LlmModelSummary {
- id: model.id,
- // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
- display_name: model.alias,
- agent_mode: model.agent_mode.as_str().to_string(),
+ .map(|model| {
+ let protocol = model.resolved_protocol().as_str().to_string();
+ LlmModelSummary {
+ id: model.id,
+ // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
+ display_name: model.alias,
+ agent_mode: model.agent_mode.as_str().to_string(),
+ protocol,
+ }
})
.collect(),
revision: catalog.revision,
diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs
index e4ff1c5fb..a6d406afc 100644
--- a/server-rs/crates/module-runtime/src/agc_models.rs
+++ b/server-rs/crates/module-runtime/src/agc_models.rs
@@ -16,10 +16,10 @@ pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
#[serde(rename_all = "lowercase")]
pub enum AgcAgentMode {
Codex,
+ /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`;
+ /// 协议本身由 `AgcModel.protocol` 表达。
+ #[serde(alias = "anthropic")]
Cc,
- /// 显式 Anthropic Messages 协议。`cc` 是同一执行器的历史别名,
- /// 新目录项应直接写 `anthropic`。
- Anthropic,
}
impl Default for AgcAgentMode {
@@ -33,7 +33,6 @@ impl AgcAgentMode {
match self {
Self::Codex => "codex",
Self::Cc => "cc",
- Self::Anthropic => "anthropic",
}
}
@@ -41,8 +40,68 @@ impl AgcAgentMode {
match value {
"codex" => Ok(Self::Codex),
"cc" => Ok(Self::Cc),
+ _ => Err("Agent 模式只允许 codex 或 cc".into()),
+ }
+ }
+
+ /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的:
+ /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走
+ /// Anthropic Messages。
+ pub const fn supports(self, protocol: AgcModelProtocol) -> bool {
+ match self {
+ Self::Codex => matches!(
+ protocol,
+ AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat
+ ),
+ Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic),
+ }
+ }
+
+ /// 存量目录缺少协议字段时按执行器的默认线协议解释。
+ pub const fn default_protocol(self) -> AgcModelProtocol {
+ match self {
+ Self::Codex => AgcModelProtocol::OpenAiResponses,
+ Self::Cc => AgcModelProtocol::Anthropic,
+ }
+ }
+}
+
+/// 上游线协议,与执行器正交。
+///
+/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与
+/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的
+/// `/api/llm/anthropic/{*path}`。
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AgcModelProtocol {
+ #[serde(rename = "openai_responses")]
+ OpenAiResponses,
+ #[serde(rename = "openai_chat")]
+ OpenAiChat,
+ #[serde(rename = "anthropic")]
+ Anthropic,
+}
+
+impl Default for AgcModelProtocol {
+ fn default() -> Self {
+ Self::OpenAiResponses
+ }
+}
+
+impl AgcModelProtocol {
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::OpenAiResponses => "openai_responses",
+ Self::OpenAiChat => "openai_chat",
+ Self::Anthropic => "anthropic",
+ }
+ }
+
+ pub fn parse(value: &str) -> Result {
+ match value {
+ "openai_responses" => Ok(Self::OpenAiResponses),
+ "openai_chat" => Ok(Self::OpenAiChat),
"anthropic" => Ok(Self::Anthropic),
- _ => Err("Agent 模式只允许 codex、cc 或 anthropic".into()),
+ _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()),
}
}
}
@@ -57,6 +116,18 @@ pub struct AgcModel {
/// 绑定 AGC 客户端实际启动的 Agent,存量目录缺失时兼容为 Codex。
#[serde(default)]
pub agent_mode: AgcAgentMode,
+ /// 上游线协议。存量目录缺失时按 `agent_mode` 的默认协议解释;
+ /// 保存时必须与 `agent_mode` 组成合法组合。
+ #[serde(default)]
+ pub protocol: Option,
+}
+
+impl AgcModel {
+ /// 显式协议优先,缺失时回退到执行器的默认协议。
+ pub fn resolved_protocol(&self) -> AgcModelProtocol {
+ self.protocol
+ .unwrap_or_else(|| self.agent_mode.default_protocol())
+ }
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -97,6 +168,7 @@ impl AgcModelCatalog {
model_id,
enabled: true,
agent_mode: AgcAgentMode::default(),
+ protocol: None,
});
}
let default_model_id = entries
@@ -147,6 +219,14 @@ impl AgcModelCatalog {
{
return Err("实际模型名不能为空,长度不能超过 200 字节".into());
}
+ let protocol = model.resolved_protocol();
+ if !model.agent_mode.supports(protocol) {
+ return Err(format!(
+ "Agent 模式 {} 不支持协议 {}:Codex 只能用 openai_responses / openai_chat,Claude Code 只能用 anthropic",
+ model.agent_mode.as_str(),
+ protocol.as_str()
+ ));
+ }
}
self.resolve(&self.default_model_id).map(|_| ())
}
@@ -235,6 +315,7 @@ mod tests {
model_id: model_id.into(),
enabled: true,
agent_mode: AgcAgentMode::default(),
+ protocol: None,
}
}
@@ -345,20 +426,13 @@ mod tests {
}
#[test]
- fn catalog_agent_mode_accepts_codex_cc_and_anthropic_only() {
+ fn catalog_agent_mode_rejects_values_outside_codex_and_cc() {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
catalog.models[0].agent_mode = AgcAgentMode::Cc;
assert!(catalog.validate().is_ok());
assert_eq!(catalog.models[0].agent_mode.as_str(), "cc");
-
- catalog.models[0].agent_mode = AgcAgentMode::Anthropic;
- assert!(catalog.validate().is_ok());
- assert_eq!(catalog.models[0].agent_mode.as_str(), "anthropic");
- assert_eq!(
- AgcAgentMode::parse("anthropic").unwrap(),
- AgcAgentMode::Anthropic
- );
+ assert!(AgcAgentMode::parse("anthropic").is_err());
let invalid: Result = serde_json::from_str(
r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#,
@@ -366,6 +440,47 @@ mod tests {
assert!(invalid.is_err());
}
+ #[test]
+ fn protocol_is_orthogonal_to_agent_mode_and_pairs_are_closed() {
+ // 协议缺省按执行器推导:codex -> openai_responses,cc -> anthropic。
+ let mut catalog =
+ AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
+ assert_eq!(
+ catalog.models[0].resolved_protocol(),
+ AgcModelProtocol::OpenAiResponses
+ );
+ catalog.models[0].agent_mode = AgcAgentMode::Cc;
+ assert_eq!(
+ catalog.models[0].resolved_protocol(),
+ AgcModelProtocol::Anthropic
+ );
+ assert_eq!(
+ AgcModelProtocol::parse("anthropic").unwrap().as_str(),
+ "anthropic"
+ );
+
+ // codex 支持两种 OpenAI 线协议。
+ catalog.models[0].agent_mode = AgcAgentMode::Codex;
+ catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat);
+ assert!(catalog.validate().is_ok());
+ catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiResponses);
+ assert!(catalog.validate().is_ok());
+
+ // 非法组合必须失败关闭:codex + anthropic。
+ catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic);
+ let error = catalog
+ .validate()
+ .expect_err("codex must reject the anthropic protocol");
+ assert!(error.contains("不支持协议"), "{error}");
+
+ // cc 只支持 anthropic。
+ catalog.models[0].agent_mode = AgcAgentMode::Cc;
+ catalog.models[0].protocol = Some(AgcModelProtocol::OpenAiChat);
+ assert!(catalog.validate().is_err());
+ catalog.models[0].protocol = Some(AgcModelProtocol::Anthropic);
+ assert!(catalog.validate().is_ok());
+ }
+
#[test]
fn catalog_rejects_duplicate_aliases_and_ids() {
let mut catalog =
diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs
index 0309a831d..ad9d7af64 100644
--- a/server-rs/crates/shared-contracts/src/admin.rs
+++ b/server-rs/crates/shared-contracts/src/admin.rs
@@ -1663,6 +1663,10 @@ pub struct AdminAgcModel {
/// 旧后台请求缺失时按 Codex 兼容。
#[serde(default = "default_codex_agent_mode")]
pub agent_mode: String,
+ /// 上游线协议,与 `agent_mode` 正交。缺省时按 `agent_mode` 的默认协议解释,
+ /// 服务端回读时始终给出解析后的具体值。
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub protocol: Option,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs
index 0b958f23f..a02708cf7 100644
--- a/server-rs/crates/shared-contracts/src/llm.rs
+++ b/server-rs/crates/shared-contracts/src/llm.rs
@@ -65,7 +65,10 @@ mod tests {
pub struct LlmModelSummary {
pub display_name: String,
pub id: String,
+ /// 执行器:`codex` 或 `cc`。
pub agent_mode: String,
+ /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。
+ pub protocol: String,
}
#[derive(Debug, serde::Serialize)]
From 98cb0d237e95be27266b3437e5c51784f9c326f5 Mon Sep 17 00:00:00 2001
From: Linghong
Date: Fri, 2 Oct 2026 12:56:55 +0800
Subject: [PATCH 3/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BA=E5=AE=9A?=
=?UTF-8?q?=E8=AF=95=E7=8E=A9=E8=AF=AF=E5=88=A4=E7=A5=96=E5=85=88=E6=8C=87?=
=?UTF-8?q?=E9=92=88=E7=A9=BF=E9=80=8F=E6=A0=B7=E5=BC=8F=EF=BC=88#547?=
=?UTF-8?q?=EF=BC=89=20(#583)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
关联 #547。
固定试玩将任意祖先的 `pointer-events:none` 累计为按钮禁用,导致覆盖层穿透、按钮恢复 `auto` 的合法布局在实际点击前被拒绝。现在只检查目标控件自身的计算样式;未覆盖的继承 `none` 仍拒绝,原有命中、可见性、disabled、aria-disabled、inert 和真实输入校验保持有效。
复用现有浏览器夹具,补充双视口穿透布局回归及九种不可用控件拒绝场景,并同步场景指纹快照、技术方案和共享排障记录。
验证:
- 修复前真实 Chrome 在 desktop/mobile 均复现 start 被误判 disabled;修复后合法布局完整通过开始、主要操作和重开,sequence 从 0 到 3、最终 phase 为 ready。
- 浏览器模块单元测试:27 passed;14 个真实浏览器测试默认 ignored,其中本次显式执行并通过 4 个相关用例(新增 2 个,已有 2 个)。新增拒绝用例在双视口覆盖继承 none、目标 none、目标 none/子元素 auto、disabled、禁用 fieldset、aria-disabled、inert、隐藏和遮挡。
- rustfmt、编码检查、文档索引检查、git diff --check 通过;完成独立代码审查。
验证边界:未运行全量测试;当前工作区未找到现场游戏导出,尚未复核该游戏开始流程。generic 的 mobile 结果为移动视口下的真实鼠标输入,未进行真实触摸验收。
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/583
Co-authored-by: Linghong
Co-committed-by: Linghong
---
.../src-tauri/src/browser/playtest/mod.rs | 4 +-
.../src-tauri/src/browser/tests.rs | 134 ++++++++++++++++--
docs/project-memory/shared-memory/pitfalls.md | 5 +
...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +
4 files changed, 135 insertions(+), 14 deletions(-)
diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
index df4619c02..1df7ccf9a 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs
@@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
}
let stylesVisible = true;
- let pointerBlocked = false;
+ // 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。
+ const pointerBlocked = getComputedStyle(this).pointerEvents === 'none';
for (let current = this; current instanceof HTMLElement; current = current.parentElement) {
const style = getComputedStyle(current);
const opacity = Number.parseFloat(style.opacity);
@@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
&& style.visibility !== 'hidden'
&& style.visibility !== 'collapse'
&& (Number.isNaN(opacity) || opacity > 0);
- pointerBlocked ||= style.pointerEvents === 'none';
}
const rect = this.getBoundingClientRect();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
index 0d98e7c97..9d8733862 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
@@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
- generic,
- "e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
- );
- assert_eq!(
- tetris,
- "b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
- );
- assert_eq!(
- lane,
- "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
+ [generic.as_str(), tetris.as_str(), lane.as_str()],
+ [
+ "85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d",
+ "60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff",
+ "a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466",
+ ]
);
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
@@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
- let result = run_stable_generic_viewport_fixture(false).await;
+ let result = run_stable_generic_viewport_fixture(false, None).await;
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
@@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
- let result = run_stable_generic_viewport_fixture(true).await;
+ let result = run_stable_generic_viewport_fixture(true, None).await;
assert!(
!result.passed,
"a successful desktop cannot hide mobile gameplay failure"
@@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
.any(|message| message.starts_with("playtest mobile:")));
}
+#[tokio::test]
+#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
+async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() {
+ let result = run_stable_generic_viewport_fixture(
+ false,
+ Some(
+ r#"
+
+ "#,
+ ),
+ )
+ .await;
+ assert!(result.passed, "{:?}", result.diagnostics);
+ for viewport in REQUIRED_VIEWPORTS {
+ let playtest = &result
+ .viewport_playtests
+ .iter()
+ .find(|entry| entry.viewport == viewport)
+ .expect("each viewport must have gameplay evidence")
+ .result;
+ assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics);
+ assert_eq!(playtest.initial_sequence, Some(0));
+ assert_eq!(playtest.final_sequence, Some(3));
+ assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
+ assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
+ }
+}
+
+#[tokio::test]
+#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
+async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() {
+ for (case, markup) in [
+ (
+ "inherited-none",
+ r#""#,
+ ),
+ (
+ "own-none",
+ r#""#,
+ ),
+ (
+ "own-none-child-auto",
+ r#""#,
+ ),
+ (
+ "disabled",
+ r#""#,
+ ),
+ (
+ "disabled-fieldset",
+ r#""#,
+ ),
+ (
+ "aria-disabled",
+ r#""#,
+ ),
+ (
+ "inert",
+ r#""#,
+ ),
+ (
+ "hidden",
+ r#""#,
+ ),
+ (
+ "covered",
+ r#""#,
+ ),
+ ] {
+ let result = run_stable_generic_viewport_fixture(false, Some(markup)).await;
+ assert!(!result.passed, "{case} must fail");
+ for viewport in REQUIRED_VIEWPORTS {
+ let playtest = &result
+ .viewport_playtests
+ .iter()
+ .find(|entry| entry.viewport == viewport)
+ .expect("each viewport must have gameplay evidence")
+ .result;
+ assert!(!playtest.passed, "{case} / {viewport:?} must fail");
+ assert!(
+ playtest
+ .diagnostics
+ .iter()
+ .any(|message| message.contains("固定试玩控件 start")),
+ "{case} / {viewport:?}: {:?}",
+ playtest.diagnostics
+ );
+ assert!(
+ playtest.assertions.iter().any(|assertion| {
+ assertion.name == "start-control-clicked" && !assertion.passed
+ }),
+ "{case} / {viewport:?} must be rejected before input"
+ );
+ if case == "own-none-child-auto" {
+ assert!(
+ playtest.diagnostics.iter().any(|message| {
+ message == "固定试玩控件 start 处于 disabled 状态"
+ }),
+ "{viewport:?}: child hit must not enable the pointer-blocked target: {:?}",
+ playtest.diagnostics
+ );
+ }
+ }
+ }
+}
+
async fn run_stable_generic_viewport_fixture(
mobile_action_broken: bool,
+ start_markup: Option<&str>,
) -> BrowserValidationResult {
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture(
|